Compare commits
36
Commits
v1.3.0
..
8cb9b4f57b
@@ -6,6 +6,9 @@ Database__Provider=MySql
|
||||
Database__ApplyMigrationsOnStartup=false
|
||||
Database__CommandTimeoutSeconds=30
|
||||
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||
# 仅供备份与隔离恢复演练使用。该账号需要读取业务库,并仅能创建/删除
|
||||
# jiaowu_restore_drill_* 临时库;不要在此复用日常业务账号。
|
||||
# ConnectionStrings__OperationsMySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=OPS_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||
# Redis 是可选加速器;留空时应用仅使用进程内缓存。
|
||||
# ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false"
|
||||
|
||||
@@ -14,6 +17,7 @@ BackgroundJobs__Transport=InMemory
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
BackgroundJobs__ExamArrangementConcurrency=1
|
||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
# RabbitMq__Port=5671
|
||||
# RabbitMq__UserName=jiaowu
|
||||
@@ -32,6 +36,34 @@ 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
|
||||
Operations__ToolTimeoutMinutes=30
|
||||
Operations__MySqlDumpPath=mysqldump
|
||||
Operations__MySqlClientPath=mysql
|
||||
# 按实际客户端补充 TLS 参数;Oracle MySQL 客户端示例:
|
||||
# Operations__MySqlAdditionalArguments__0=--ssl-mode=VERIFY_IDENTITY
|
||||
# Operations__MySqlAdditionalArguments__1=--ssl-ca=/etc/jiaowu/mysql-ca.pem
|
||||
|
||||
Jwt__Issuer=Jiaowu.Api
|
||||
Jwt__Audience=Jiaowu.Web
|
||||
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||
@@ -39,6 +71,9 @@ Jwt__ExpireMinutes=60
|
||||
|
||||
AllowedHosts=jiaowu.example.edu.cn
|
||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||
Cors__Origins__1=capacitor://localhost
|
||||
Cors__Origins__2=https://localhost
|
||||
Cors__Origins__3=http://localhost
|
||||
|
||||
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
||||
OfficialDocuments__InstitutionName=明序大学
|
||||
|
||||
+9
-2
@@ -30,11 +30,14 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_HTTP_PORTS=8080 \
|
||||
DOTNET_EnableDiagnostics=0
|
||||
DOTNET_EnableDiagnostics=0 \
|
||||
Operations__BackupDirectory=/var/lib/jiaowu/backups \
|
||||
Operations__MySqlDumpPath=mariadb-dump \
|
||||
Operations__MySqlClientPath=mariadb
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish/ ./
|
||||
ARG UID=10001
|
||||
RUN apk add --no-cache font-noto-cjk
|
||||
RUN apk add --no-cache font-noto-cjk mariadb-client
|
||||
RUN adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
@@ -43,5 +46,9 @@ RUN adduser \
|
||||
--no-create-home \
|
||||
--uid "${UID}" \
|
||||
appuser
|
||||
RUN mkdir -p /var/lib/jiaowu/backups \
|
||||
&& chown appuser:appuser /var/lib/jiaowu/backups \
|
||||
&& chmod 700 /var/lib/jiaowu/backups
|
||||
VOLUME ["/var/lib/jiaowu/backups"]
|
||||
USER appuser
|
||||
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
|
||||
|
||||
@@ -47,6 +47,68 @@ dotnet run --project src/Jiaowu.Api
|
||||
|
||||
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
||||
|
||||
## Capacitor Android App
|
||||
|
||||
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
|
||||
Android 工程前先构建并同步原生插件:
|
||||
|
||||
```powershell
|
||||
Set-Location web
|
||||
npm ci
|
||||
npm run build:capacitor
|
||||
npm run cap:sync
|
||||
npm run cap:open:android
|
||||
```
|
||||
|
||||
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
|
||||
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
|
||||
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
|
||||
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
|
||||
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||
|
||||
### App 前端热更新
|
||||
|
||||
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
|
||||
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
|
||||
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
|
||||
前端修复不再需要重新打包。
|
||||
|
||||
生成更新 ZIP:
|
||||
|
||||
```powershell
|
||||
Set-Location web
|
||||
npm ci
|
||||
npm run ota:package -- --version 1.0.1
|
||||
```
|
||||
|
||||
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
|
||||
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
|
||||
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName` 为
|
||||
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`。
|
||||
|
||||
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
|
||||
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
|
||||
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
|
||||
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
|
||||
`--migrate-only`。
|
||||
|
||||
### Android 开屏、快捷入口与桌面组件
|
||||
|
||||
Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
|
||||
中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
|
||||
遵守系统“减少动画”设置。
|
||||
|
||||
长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
|
||||
签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
|
||||
课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
|
||||
退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
|
||||
|
||||
原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
|
||||
`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
|
||||
`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
|
||||
重新构建 App,不能通过前端 OTA 单独下发。
|
||||
|
||||
## MySQL 8.4 生产部署
|
||||
|
||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||
@@ -101,6 +163,12 @@ sudo chown -R root:jiaowu /opt/jiaowu
|
||||
sudo chmod 0750 /opt/jiaowu
|
||||
sudo chmod 0750 /opt/jiaowu/Jiaowu.Api
|
||||
sudo chmod 0640 /opt/jiaowu/.env
|
||||
sudo install -d \
|
||||
--owner=jiaowu \
|
||||
--group=jiaowu \
|
||||
--mode=0700 \
|
||||
/var/lib/jiaowu/backups
|
||||
sudo apt-get install default-mysql-client
|
||||
```
|
||||
|
||||
如果账号已存在,`useradd` 会报错,可以跳过该命令。RHEL 系发行版的 `nologin` 通常
|
||||
@@ -254,6 +322,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 保存任务消息。创建业务任务与
|
||||
@@ -274,6 +391,7 @@ BackgroundJobs__Transport=RabbitMq
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
BackgroundJobs__ExamArrangementConcurrency=1
|
||||
RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
RabbitMq__Port=5671
|
||||
RabbitMq__UserName=jiaowu
|
||||
@@ -297,6 +415,26 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
||||
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
||||
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
||||
|
||||
### 运维与审计控制台
|
||||
|
||||
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
|
||||
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
|
||||
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
|
||||
前端菜单隐藏。
|
||||
|
||||
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
||||
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
||||
`ConnectionStrings__OperationsMySql`。该账号不得复用日常业务账号:它需要读取业务库,
|
||||
并只应被授权创建和删除名称为 `jiaowu_restore_drill_*` 的临时演练库。恢复演练不会覆盖
|
||||
当前业务库,流程是“校验 SHA-256 → 恢复到随机临时库 → 检查表结构 → 删除临时库”。
|
||||
|
||||
备份目录必须是仅服务账号可写的持久化目录。示例配置使用
|
||||
`/var/lib/jiaowu/backups`;Compose 已挂载独立命名卷。启用 MySQL TLS 时,还要通过
|
||||
`Operations__MySqlAdditionalArguments__N` 传入与所选命令行客户端匹配的 CA 与主机名
|
||||
校验参数。例如 Oracle MySQL 客户端使用 `--ssl-mode=VERIFY_IDENTITY` 和
|
||||
`--ssl-ca=/etc/jiaowu/mysql-ca.pem`,容器内 MariaDB 客户端使用 `--ssl`、
|
||||
`--ssl-ca=...` 与 `--ssl-verify-server-cert`。
|
||||
|
||||
## 跨平台发布与 Docker
|
||||
|
||||
`.gitea/workflows/publish.yml` 只在推送 `v*` 标签或手动运行时执行,普通分支 push
|
||||
|
||||
@@ -17,11 +17,15 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- jiaowu-backups:/var/lib/jiaowu/backups
|
||||
|
||||
# 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到
|
||||
# ./certs/mysql-ca.pem,并取消下面三行注释。
|
||||
# volumes:
|
||||
# ./certs/mysql-ca.pem,并在上面的 volumes 中追加以下四行。
|
||||
# - type: bind
|
||||
# source: ./certs/mysql-ca.pem
|
||||
# target: /etc/jiaowu/mysql-ca.pem
|
||||
# read_only: true
|
||||
|
||||
volumes:
|
||||
jiaowu-backups:
|
||||
|
||||
@@ -20,6 +20,7 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
|
||||
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
|
||||
RabbitMq__HostName: rabbitmq
|
||||
RabbitMq__Port: "5672"
|
||||
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
|
||||
@@ -138,6 +139,8 @@ services:
|
||||
- "${JIAOWU_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
volumes:
|
||||
- backup-data:/var/lib/jiaowu/backups
|
||||
logging: *json-logging
|
||||
|
||||
# 工具型一次性服务:普通 docker compose up 不会执行它。
|
||||
@@ -161,3 +164,4 @@ services:
|
||||
volumes:
|
||||
mysql-data:
|
||||
rabbitmq-data:
|
||||
backup-data:
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Graduation;
|
||||
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/academic-planning")]
|
||||
public sealed class AcademicPlanningController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var loaded = await LoadAsync(cancellationToken);
|
||||
if (loaded.Error is not null) return loaded.Error;
|
||||
var context = loaded.Context!;
|
||||
|
||||
var completion = CurriculumCompletionRules.Evaluate(
|
||||
context.Plan.Modules,
|
||||
context.PassedCourseIds);
|
||||
var planCompletedCredits = context.Courses
|
||||
.Where(x => context.PassedCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits);
|
||||
var suggestionIds = AcademicPlanningRules.SuggestNextSemester(
|
||||
context.CourseSnapshots,
|
||||
context.PassedCourseIds,
|
||||
context.InProgressCourseIds,
|
||||
context.NextSemester);
|
||||
var suggestionIdSet = suggestionIds.ToHashSet();
|
||||
var latestAudit = await db.GraduationAuditResults.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == context.Student.Id &&
|
||||
x.GraduationAuditBatch!.Status ==
|
||||
GraduationAuditBatchStatus.Published)
|
||||
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
|
||||
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
BatchName = x.GraduationAuditBatch!.Name,
|
||||
x.GraduationAuditBatch.GraduationYear,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.GraduationAuditBatch.PublishedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var endSemester = Math.Max(
|
||||
context.Plan.Major!.SchoolingYears * 2,
|
||||
context.NextSemester + 3);
|
||||
return Ok(new
|
||||
{
|
||||
Student = new
|
||||
{
|
||||
context.Student.Id,
|
||||
context.Student.StudentNumber,
|
||||
context.Student.Name,
|
||||
context.Student.EnrollmentYear,
|
||||
context.Student.Status,
|
||||
ClassName = context.Student.AdministrativeClass!.Name,
|
||||
MajorName = context.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName =
|
||||
context.Student.AdministrativeClass.Major.College!.Name
|
||||
},
|
||||
Plan = new
|
||||
{
|
||||
context.Plan.Id,
|
||||
context.Plan.Name,
|
||||
context.Plan.Version,
|
||||
context.Plan.TotalCredits,
|
||||
SchoolingYears = context.Plan.Major.SchoolingYears,
|
||||
CurrentSemester = context.CurrentSemester,
|
||||
NextSemester = context.NextSemester,
|
||||
StandardGraduationSemester =
|
||||
context.Plan.Major.SchoolingYears * 2
|
||||
},
|
||||
Baseline = new
|
||||
{
|
||||
EarnedCredits = context.EarnedCredits,
|
||||
PlanCompletedCredits = planCompletedCredits,
|
||||
CreditGap = Math.Max(
|
||||
context.Plan.TotalCredits - context.EarnedCredits,
|
||||
0),
|
||||
CompletionRate = context.Plan.TotalCredits <= 0
|
||||
? 0
|
||||
: Math.Min(100, Math.Round(
|
||||
planCompletedCredits / context.Plan.TotalCredits * 100,
|
||||
1)),
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
completion.MissingRequirements,
|
||||
InProgressCredits = context.Courses
|
||||
.Where(x => context.InProgressCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits)
|
||||
},
|
||||
Terms = Enumerable.Range(
|
||||
context.NextSemester,
|
||||
endSemester - context.NextSemester + 1)
|
||||
.Select(semester => new
|
||||
{
|
||||
Semester = semester,
|
||||
Label = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
semester),
|
||||
IsBeyondStandard =
|
||||
semester > context.Plan.Major.SchoolingYears * 2
|
||||
}),
|
||||
Courses = context.Courses
|
||||
.OrderBy(x => x.RecommendedSemester)
|
||||
.ThenBy(x => x.CourseCode)
|
||||
.Select(course => new
|
||||
{
|
||||
course.CourseId,
|
||||
course.CourseCode,
|
||||
course.CourseName,
|
||||
course.Credits,
|
||||
course.RecommendedSemester,
|
||||
course.Type,
|
||||
course.ModuleCode,
|
||||
course.ModuleName,
|
||||
Status = context.StatusByCourse[course.CourseId],
|
||||
IsSuggested = suggestionIdSet.Contains(course.CourseId),
|
||||
Prerequisites = course.Prerequisites.Select(item => new
|
||||
{
|
||||
item.CourseId,
|
||||
item.CourseCode,
|
||||
item.CourseName,
|
||||
IsCompleted =
|
||||
context.PassedCourseIds.Contains(item.CourseId),
|
||||
IsInProgress =
|
||||
context.InProgressCourseIds.Contains(item.CourseId)
|
||||
})
|
||||
}),
|
||||
NextSemesterSuggestion = suggestionIds.Select(courseId =>
|
||||
{
|
||||
var course = context.CourseById[courseId];
|
||||
return new
|
||||
{
|
||||
course.CourseId,
|
||||
course.CourseCode,
|
||||
course.CourseName,
|
||||
course.Credits,
|
||||
course.Type,
|
||||
Reason = course.Type == CurriculumCourseType.Required &&
|
||||
course.RecommendedSemester <= context.NextSemester
|
||||
? "计划学期已到,且先修条件已满足"
|
||||
: course.Type == CurriculumCourseType.Required
|
||||
? "必修课程,按培养方案顺序推进"
|
||||
: "用于补足培养模块与总学分"
|
||||
};
|
||||
}),
|
||||
LatestGraduationAudit = latestAudit,
|
||||
Assumptions = new[]
|
||||
{
|
||||
"模拟课程按顺利通过计算,不会写入成绩或正式选课。",
|
||||
$"预计毕业学期按每学期最多 {AcademicPlanningRules.RecommendedSemesterCreditLimit:0} 学分估算。",
|
||||
"当前在读课程按本学期顺利完成计入预测。"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("simulate")]
|
||||
public async Task<ActionResult> Simulate(
|
||||
AcademicPlanningRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var loaded = await LoadAsync(cancellationToken);
|
||||
if (loaded.Error is not null) return loaded.Error;
|
||||
var context = loaded.Context!;
|
||||
|
||||
var selections = request.Terms
|
||||
.SelectMany(term => term.CourseIds.Select(courseId => new
|
||||
{
|
||||
term.Semester,
|
||||
CourseId = courseId
|
||||
}))
|
||||
.ToList();
|
||||
var duplicate = selections
|
||||
.GroupBy(x => x.CourseId)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicate is not null)
|
||||
return ValidationProblem("同一门课程不能安排在多个学期。");
|
||||
if (request.Terms.Any(x =>
|
||||
x.Semester < context.NextSemester ||
|
||||
x.Semester > context.NextSemester + 12))
|
||||
return ValidationProblem("模拟学期超出了可规划范围。");
|
||||
|
||||
var invalidCourse = selections.FirstOrDefault(x =>
|
||||
!context.CourseById.ContainsKey(x.CourseId));
|
||||
if (invalidCourse is not null)
|
||||
return ValidationProblem("模拟计划包含不属于当前培养方案的课程。");
|
||||
var alreadyHandled = selections.FirstOrDefault(x =>
|
||||
context.PassedCourseIds.Contains(x.CourseId) ||
|
||||
context.InProgressCourseIds.Contains(x.CourseId));
|
||||
if (alreadyHandled is not null)
|
||||
return ValidationProblem("已完成或当前在读课程无需重复安排。");
|
||||
|
||||
var plannedSemesters = selections.ToDictionary(
|
||||
x => x.CourseId,
|
||||
x => x.Semester);
|
||||
var projectedCourseIds = context.PassedCourseIds
|
||||
.Concat(context.InProgressCourseIds)
|
||||
.Concat(plannedSemesters.Keys)
|
||||
.ToHashSet();
|
||||
var completion = CurriculumCompletionRules.Evaluate(
|
||||
context.Plan.Modules,
|
||||
projectedCourseIds);
|
||||
var addedCredits = context.Courses
|
||||
.Where(x =>
|
||||
!context.PassedCourseIds.Contains(x.CourseId) &&
|
||||
(context.InProgressCourseIds.Contains(x.CourseId) ||
|
||||
plannedSemesters.ContainsKey(x.CourseId)))
|
||||
.Sum(x => x.Credits);
|
||||
var projectedEarnedCredits = context.EarnedCredits + addedCredits;
|
||||
var creditGap = Math.Max(
|
||||
context.Plan.TotalCredits - projectedEarnedCredits,
|
||||
0);
|
||||
var prerequisiteIssues = AcademicPlanningRules.FindPrerequisiteIssues(
|
||||
context.CourseSnapshots,
|
||||
context.PassedCourseIds,
|
||||
context.InProgressCourseIds,
|
||||
plannedSemesters);
|
||||
var conflicts = prerequisiteIssues.Select(issue =>
|
||||
{
|
||||
var course = context.CourseById[issue.CourseId];
|
||||
var prerequisite = context.AllCourseLabels.GetValueOrDefault(
|
||||
issue.PrerequisiteCourseId,
|
||||
new CourseLabel(
|
||||
issue.PrerequisiteCourseId,
|
||||
"未知课程",
|
||||
"未找到的先修课程"));
|
||||
return new
|
||||
{
|
||||
Type = "Prerequisite",
|
||||
issue.CourseId,
|
||||
course.CourseName,
|
||||
PrerequisiteCourseId = prerequisite.CourseId,
|
||||
PrerequisiteCourseName = prerequisite.CourseName,
|
||||
issue.PlannedSemester,
|
||||
issue.PrerequisitePlannedSemester,
|
||||
Message = issue.PrerequisitePlannedSemester.HasValue
|
||||
? $"《{prerequisite.CourseName}》必须安排在《{course.CourseName}》之前。"
|
||||
: $"《{course.CourseName}》的先修课程《{prerequisite.CourseName}》尚未完成或安排。"
|
||||
};
|
||||
}).ToList();
|
||||
var workloadWarnings = request.Terms
|
||||
.Select(term => new
|
||||
{
|
||||
term.Semester,
|
||||
Credits = term.CourseIds.Sum(courseId =>
|
||||
context.CourseById.GetValueOrDefault(courseId)?.Credits ?? 0)
|
||||
})
|
||||
.Where(x =>
|
||||
x.Credits > AcademicPlanningRules.HeavySemesterCreditLimit)
|
||||
.Select(x => new
|
||||
{
|
||||
Type = "Workload",
|
||||
x.Semester,
|
||||
x.Credits,
|
||||
Message =
|
||||
$"第 {x.Semester} 学期安排了 {x.Credits:0.#} 学分,超过建议上限 {AcademicPlanningRules.HeavySemesterCreditLimit:0.#} 学分。"
|
||||
})
|
||||
.ToList();
|
||||
var timingWarnings = selections
|
||||
.Select(item => new
|
||||
{
|
||||
item.Semester,
|
||||
Course = context.CourseById[item.CourseId]
|
||||
})
|
||||
.Where(x => x.Semester < x.Course.RecommendedSemester)
|
||||
.Select(x => new
|
||||
{
|
||||
Type = "EarlyCourse",
|
||||
x.Course.CourseId,
|
||||
x.Course.CourseName,
|
||||
x.Semester,
|
||||
x.Course.RecommendedSemester,
|
||||
Message =
|
||||
$"《{x.Course.CourseName}》早于培养方案建议学期修读,请确认课程开设条件。"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var unresolvedFailedCourseCount = context.FailedCourseIds.Count(
|
||||
courseId => !projectedCourseIds.Contains(courseId));
|
||||
var conclusion = GraduationAuditRules.Evaluate(
|
||||
true,
|
||||
context.Student.Status,
|
||||
context.Plan.TotalCredits,
|
||||
projectedEarnedCredits,
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
unresolvedFailedCourseCount);
|
||||
var latestPlannedSemester = plannedSemesters.Count == 0
|
||||
? context.NextSemester - 1
|
||||
: plannedSemesters.Values.Max();
|
||||
var estimatedSemester =
|
||||
AcademicPlanningRules.EstimateCompletionSemester(
|
||||
context.NextSemester,
|
||||
latestPlannedSemester,
|
||||
creditGap,
|
||||
completion.RequirementCount -
|
||||
completion.PassedRequirementCount);
|
||||
var remainingRequiredSemesterFloor = context.Courses
|
||||
.Where(course =>
|
||||
course.Type == CurriculumCourseType.Required &&
|
||||
!projectedCourseIds.Contains(course.CourseId))
|
||||
.Select(course => course.RecommendedSemester)
|
||||
.DefaultIfEmpty(estimatedSemester)
|
||||
.Max();
|
||||
estimatedSemester = Math.Max(
|
||||
estimatedSemester,
|
||||
remainingRequiredSemesterFloor);
|
||||
var planCompletedCredits = context.Courses
|
||||
.Where(x => projectedCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Projected = new
|
||||
{
|
||||
EarnedCredits = projectedEarnedCredits,
|
||||
PlanCompletedCredits = planCompletedCredits,
|
||||
CreditGap = creditGap,
|
||||
CompletionRate = context.Plan.TotalCredits <= 0
|
||||
? 0
|
||||
: Math.Min(100, Math.Round(
|
||||
planCompletedCredits / context.Plan.TotalCredits * 100,
|
||||
1)),
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
completion.MissingRequirements,
|
||||
UnresolvedFailedCourseCount = unresolvedFailedCourseCount,
|
||||
GraduationConclusion = conclusion,
|
||||
EstimatedGraduationSemester = estimatedSemester,
|
||||
EstimatedGraduationTerm = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
estimatedSemester),
|
||||
IsBeyondStandard =
|
||||
estimatedSemester >
|
||||
context.Plan.Major!.SchoolingYears * 2
|
||||
},
|
||||
Conflicts = conflicts,
|
||||
Warnings = workloadWarnings.Cast<object>()
|
||||
.Concat(timingWarnings)
|
||||
.ToList(),
|
||||
TermSummaries = request.Terms
|
||||
.OrderBy(x => x.Semester)
|
||||
.Select(term => new
|
||||
{
|
||||
term.Semester,
|
||||
Label = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
term.Semester),
|
||||
CourseCount = term.CourseIds.Count,
|
||||
Credits = term.CourseIds.Sum(courseId =>
|
||||
context.CourseById.GetValueOrDefault(courseId)?.Credits ??
|
||||
0)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<LoadResult> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Major)
|
||||
.ThenInclude(x => x!.College)
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||||
if (student is null)
|
||||
return new LoadResult(
|
||||
null,
|
||||
ConflictProblem("当前账号尚未关联学生档案。"));
|
||||
|
||||
var plan = await db.CurriculumPlans.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.ThenInclude(x => x.Course)
|
||||
.ThenInclude(x => x!.Prerequisites)
|
||||
.ThenInclude(x => x.PrerequisiteCourse)
|
||||
.Where(x =>
|
||||
x.MajorId == student.AdministrativeClass!.MajorId &&
|
||||
x.EffectiveGrade == student.EnrollmentYear &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null)
|
||||
return new LoadResult(
|
||||
null,
|
||||
ConflictProblem(
|
||||
$"{student.EnrollmentYear} 级{student.AdministrativeClass!.Major!.Name}尚未发布培养方案。"));
|
||||
|
||||
var courses = plan.Modules
|
||||
.SelectMany(module => module.Courses.Select(item =>
|
||||
new PlanningCourse(
|
||||
item.CourseId,
|
||||
item.Course!.Code,
|
||||
item.Course.Name,
|
||||
item.Course.Credits,
|
||||
item.RecommendedSemester,
|
||||
item.Type,
|
||||
module.Code,
|
||||
module.Name,
|
||||
item.Course.Prerequisites
|
||||
.Select(prerequisite => new CourseLabel(
|
||||
prerequisite.PrerequisiteCourseId,
|
||||
prerequisite.PrerequisiteCourse!.Code,
|
||||
prerequisite.PrerequisiteCourse.Name))
|
||||
.OrderBy(x => x.CourseCode)
|
||||
.ToList())))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
var planCourseIds = courses.Select(x => x.CourseId).ToArray();
|
||||
|
||||
var gradeAttempts = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Select(x => new GradeAttempt(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.GradeSheet.TeachingTask.Course!.Credits,
|
||||
x.TotalScore,
|
||||
x.ExamStatus))
|
||||
.ToListAsync(cancellationToken);
|
||||
var passedCourseIds = gradeAttempts
|
||||
.Where(x => StudentCourseProgressRules.IsPassed(
|
||||
new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus)))
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
var failedCourseIds = gradeAttempts
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Where(group => !group.Any(x =>
|
||||
StudentCourseProgressRules.IsPassed(
|
||||
new StudentCourseAttemptSnapshot(
|
||||
x.TotalScore,
|
||||
x.ExamStatus))))
|
||||
.Select(group => group.Key)
|
||||
.ToHashSet();
|
||||
var earnedCredits = gradeAttempts
|
||||
.Where(x => passedCourseIds.Contains(x.CourseId))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Sum(group => group.Max(x => x.Credits));
|
||||
|
||||
var inProgressCourseIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.AcademicTerm!.IsCurrent &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClassId ==
|
||||
student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)))
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var inProgressSet = inProgressCourseIds
|
||||
.Where(planCourseIds.Contains)
|
||||
.Where(courseId => !passedCourseIds.Contains(courseId))
|
||||
.ToHashSet();
|
||||
|
||||
var attemptsByCourse = gradeAttempts
|
||||
.Where(x => planCourseIds.Contains(x.CourseId))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
var statuses = courses.ToDictionary(
|
||||
x => x.CourseId,
|
||||
x => StudentCourseProgressRules.Evaluate(
|
||||
attemptsByCourse.GetValueOrDefault(x.CourseId, [])
|
||||
.Select(attempt => new StudentCourseAttemptSnapshot(
|
||||
attempt.TotalScore,
|
||||
attempt.ExamStatus)),
|
||||
inProgressSet.Contains(x.CourseId)).Status);
|
||||
var currentTerm = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsCurrent)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var currentSemester = CalculateCurrentSemester(
|
||||
student.EnrollmentYear,
|
||||
currentTerm);
|
||||
var snapshots = courses.Select(x =>
|
||||
new AcademicPlanningCourseSnapshot(
|
||||
x.CourseId,
|
||||
x.CourseName,
|
||||
x.Credits,
|
||||
x.RecommendedSemester,
|
||||
x.Type,
|
||||
x.Prerequisites.Select(p => p.CourseId).ToArray()))
|
||||
.ToList();
|
||||
var allLabels = courses
|
||||
.Select(x => new CourseLabel(
|
||||
x.CourseId,
|
||||
x.CourseCode,
|
||||
x.CourseName))
|
||||
.Concat(courses.SelectMany(x => x.Prerequisites))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
|
||||
return new LoadResult(
|
||||
new PlanningContext(
|
||||
student,
|
||||
plan,
|
||||
courses,
|
||||
courses.ToDictionary(x => x.CourseId),
|
||||
snapshots,
|
||||
allLabels,
|
||||
passedCourseIds,
|
||||
inProgressSet,
|
||||
failedCourseIds,
|
||||
statuses,
|
||||
earnedCredits,
|
||||
currentSemester,
|
||||
currentSemester + 1),
|
||||
null);
|
||||
}
|
||||
|
||||
private static int CalculateCurrentSemester(
|
||||
int enrollmentYear,
|
||||
AcademicTerm? currentTerm)
|
||||
{
|
||||
if (currentTerm is not null)
|
||||
{
|
||||
return Math.Max(
|
||||
1,
|
||||
currentTerm.Season == TermSeason.Autumn
|
||||
? (currentTerm.StartDate.Year - enrollmentYear) * 2 + 1
|
||||
: (currentTerm.StartDate.Year - enrollmentYear - 1) * 2 + 2);
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
return Math.Max(
|
||||
1,
|
||||
today.Month >= 8
|
||||
? (today.Year - enrollmentYear) * 2 + 1
|
||||
: (today.Year - enrollmentYear - 1) * 2 + 2);
|
||||
}
|
||||
|
||||
private static string FormatSemester(int enrollmentYear, int semester)
|
||||
{
|
||||
var startYear = enrollmentYear + (semester - 1) / 2;
|
||||
var season = semester % 2 == 1 ? "秋季学期" : "春季学期";
|
||||
return $"{startYear}—{startYear + 1} 学年{season}";
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法进行学业规划",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private sealed record GradeAttempt(
|
||||
Guid CourseId,
|
||||
decimal Credits,
|
||||
decimal? TotalScore,
|
||||
GradeExamStatus ExamStatus);
|
||||
|
||||
private sealed record CourseLabel(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName);
|
||||
|
||||
private sealed record PlanningCourse(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
string ModuleCode,
|
||||
string ModuleName,
|
||||
IReadOnlyList<CourseLabel> Prerequisites);
|
||||
|
||||
private sealed record PlanningContext(
|
||||
Student Student,
|
||||
CurriculumPlan Plan,
|
||||
IReadOnlyList<PlanningCourse> Courses,
|
||||
IReadOnlyDictionary<Guid, PlanningCourse> CourseById,
|
||||
IReadOnlyList<AcademicPlanningCourseSnapshot> CourseSnapshots,
|
||||
IReadOnlyDictionary<Guid, CourseLabel> AllCourseLabels,
|
||||
IReadOnlySet<Guid> PassedCourseIds,
|
||||
IReadOnlySet<Guid> InProgressCourseIds,
|
||||
IReadOnlySet<Guid> FailedCourseIds,
|
||||
IReadOnlyDictionary<Guid, StudentCourseProgressStatus> StatusByCourse,
|
||||
decimal EarnedCredits,
|
||||
int CurrentSemester,
|
||||
int NextSemester);
|
||||
|
||||
private sealed record LoadResult(
|
||||
PlanningContext? Context,
|
||||
ActionResult? Error);
|
||||
}
|
||||
|
||||
public sealed record AcademicPlanningRequest(
|
||||
IReadOnlyCollection<AcademicPlanningTermRequest> Terms);
|
||||
|
||||
public sealed record AcademicPlanningTermRequest(
|
||||
[Range(1, 30)] int Semester,
|
||||
IReadOnlyCollection<Guid> CourseIds);
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/app-updates")]
|
||||
public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
private const long MaximumBundleBytes = 30 * 1024 * 1024;
|
||||
private const long MaximumExpandedBytes = 150 * 1024 * 1024;
|
||||
private const int MaximumZipEntries = 10_000;
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("app-updates")]
|
||||
[HttpGet("latest")]
|
||||
public async Task<ActionResult<AppUpdateCheckResponse>> GetLatest(
|
||||
[FromQuery, Required] string platform,
|
||||
[FromQuery, Required] string nativeVersion,
|
||||
[FromQuery] string channel = "production",
|
||||
[FromQuery] string? currentVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
if (!TryParseChannel(channel, out var parsedChannel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
|
||||
var normalizedNativeVersion = nativeVersion.Trim();
|
||||
if (!NativeVersionRegex().IsMatch(normalizedNativeVersion))
|
||||
return ValidationProblem("原生版本格式无效。");
|
||||
|
||||
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Platform == parsedPlatform &&
|
||||
x.Channel == parsedChannel &&
|
||||
x.NativeVersion == normalizedNativeVersion &&
|
||||
x.Status == AppUpdateReleaseStatus.Published)
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (release is null ||
|
||||
string.Equals(
|
||||
release.Version,
|
||||
currentVersion?.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Ok(new AppUpdateCheckResponse(
|
||||
false,
|
||||
release?.Version,
|
||||
normalizedNativeVersion,
|
||||
parsedPlatform,
|
||||
parsedChannel,
|
||||
null,
|
||||
release?.ReleaseNotes,
|
||||
release?.FileSize,
|
||||
release?.Sha256,
|
||||
release?.PublishedAt));
|
||||
}
|
||||
|
||||
return Ok(new AppUpdateCheckResponse(
|
||||
true,
|
||||
release.Version,
|
||||
release.NativeVersion,
|
||||
release.Platform,
|
||||
release.Channel,
|
||||
$"app-updates/releases/{release.Id}/bundle",
|
||||
release.ReleaseNotes,
|
||||
release.FileSize,
|
||||
release.Sha256,
|
||||
release.PublishedAt));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("app-updates")]
|
||||
[HttpGet("releases/{id:guid}/bundle")]
|
||||
public async Task<IActionResult> DownloadBundle(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Id == id &&
|
||||
x.Status != AppUpdateReleaseStatus.Draft)
|
||||
.Select(x => new
|
||||
{
|
||||
x.BundleContent,
|
||||
x.FileName,
|
||||
x.Sha256
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (release is null) return NotFound();
|
||||
|
||||
Response.Headers.ETag = $"\"sha256-{release.Sha256}\"";
|
||||
Response.Headers.CacheControl = "public,max-age=31536000,immutable";
|
||||
return File(
|
||||
release.BundleContent,
|
||||
"application/zip",
|
||||
release.FileName,
|
||||
enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpGet("releases")]
|
||||
public async Task<ActionResult<PagedResult<AppUpdateReleaseItem>>> GetReleases(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? platform = null,
|
||||
[FromQuery] string? channel = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (page < 1 || pageSize is < 1 or > 100)
|
||||
return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。");
|
||||
|
||||
var query = db.AppUpdateReleases.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
query = query.Where(x => x.Platform == parsedPlatform);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(channel))
|
||||
{
|
||||
if (!TryParseChannel(channel, out var parsedChannel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
query = query.Where(x => x.Channel == parsedChannel);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await query
|
||||
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new AppUpdateReleaseItem(
|
||||
x.Id,
|
||||
x.Platform,
|
||||
x.Channel,
|
||||
x.Version,
|
||||
x.NativeVersion,
|
||||
x.Status,
|
||||
x.ReleaseNotes,
|
||||
x.FileName,
|
||||
x.FileSize,
|
||||
x.Sha256,
|
||||
x.CreatedByUserName,
|
||||
x.CreatedAt,
|
||||
x.PublishedByUserName,
|
||||
x.PublishedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return Ok(new PagedResult<AppUpdateReleaseItem>(
|
||||
rows,
|
||||
total,
|
||||
page,
|
||||
pageSize));
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)]
|
||||
[HttpPost("releases")]
|
||||
public async Task<ActionResult<AppUpdateReleaseItem>> UploadRelease(
|
||||
[FromForm] AppUpdateUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryParsePlatform(request.Platform, out var platform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
if (!TryParseChannel(request.Channel, out var channel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
|
||||
var version = request.Version.Trim();
|
||||
var nativeVersion = request.NativeVersion.Trim();
|
||||
if (!ReleaseVersionRegex().IsMatch(version))
|
||||
return ValidationProblem(
|
||||
"热更新版本必须使用语义版本,例如 1.0.1 或 1.0.1-beta.1。");
|
||||
if (!NativeVersionRegex().IsMatch(nativeVersion))
|
||||
return ValidationProblem("原生版本格式无效,例如 1.0 或 1.0.0。");
|
||||
if (request.ReleaseNotes?.Trim().Length > 1000)
|
||||
return ValidationProblem("更新说明不能超过 1000 字。");
|
||||
if (request.Bundle.Length is <= 0 or > MaximumBundleBytes)
|
||||
return ValidationProblem("更新包必须大于 0 字节且不超过 30 MB。");
|
||||
|
||||
var exists = await db.AppUpdateReleases.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x =>
|
||||
x.Platform == platform &&
|
||||
x.Channel == channel &&
|
||||
x.NativeVersion == nativeVersion &&
|
||||
x.Version == version,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "更新版本已存在",
|
||||
Detail = "同一平台、通道和原生版本下不能重复上传相同版本。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
await using var source = request.Bundle.OpenReadStream();
|
||||
await using var buffer = new MemoryStream(
|
||||
checked((int)request.Bundle.Length));
|
||||
await source.CopyToAsync(buffer, cancellationToken);
|
||||
var content = buffer.ToArray();
|
||||
var archiveError = ValidateArchive(content);
|
||||
if (archiveError is not null) return ValidationProblem(archiveError);
|
||||
|
||||
var fileName = Path.GetFileName(request.Bundle.FileName.Trim());
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length > 180)
|
||||
fileName = $"jiaowu-web-{version}.zip";
|
||||
|
||||
var release = new AppUpdateRelease
|
||||
{
|
||||
Platform = platform,
|
||||
Channel = channel,
|
||||
Version = version,
|
||||
NativeVersion = nativeVersion,
|
||||
ReleaseNotes = NullIfWhiteSpace(request.ReleaseNotes),
|
||||
FileName = fileName,
|
||||
FileSize = content.LongLength,
|
||||
Sha256 = Convert.ToHexString(
|
||||
SHA256.HashData(content))
|
||||
.ToLowerInvariant(),
|
||||
BundleContent = content,
|
||||
CreatedByUserName = User.Identity?.Name ?? "unknown"
|
||||
};
|
||||
db.AppUpdateReleases.Add(release);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetReleases),
|
||||
new { id = release.Id },
|
||||
ToItem(release));
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpPost("releases/{id:guid}/publish")]
|
||||
public async Task<ActionResult<AppUpdateReleaseItem>> PublishRelease(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
var published = await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
var target = await db.AppUpdateReleases
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (target is null) return null;
|
||||
|
||||
var currentlyPublished = await db.AppUpdateReleases
|
||||
.Where(x =>
|
||||
x.Id != target.Id &&
|
||||
x.Platform == target.Platform &&
|
||||
x.Channel == target.Channel &&
|
||||
x.NativeVersion == target.NativeVersion &&
|
||||
x.Status == AppUpdateReleaseStatus.Published)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var release in currentlyPublished)
|
||||
release.Status = AppUpdateReleaseStatus.Archived;
|
||||
|
||||
target.Status = AppUpdateReleaseStatus.Published;
|
||||
target.PublishedAt = DateTime.UtcNow;
|
||||
target.PublishedByUserName = User.Identity?.Name ?? "unknown";
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return ToItem(target);
|
||||
});
|
||||
|
||||
return published is null ? NotFound() : Ok(published);
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpDelete("releases/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteRelease(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await db.AppUpdateReleases
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (release is null) return NotFound();
|
||||
if (release.Status == AppUpdateReleaseStatus.Published)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "正式版本不能删除",
|
||||
Detail = "请先发布另一个兼容版本,再删除已归档的更新包。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
db.AppUpdateReleases.Remove(release);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static string? ValidateArchive(byte[] content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(content, writable: false);
|
||||
using var archive = new ZipArchive(
|
||||
stream,
|
||||
ZipArchiveMode.Read,
|
||||
leaveOpen: false);
|
||||
if (archive.Entries.Count is 0 or > MaximumZipEntries)
|
||||
return $"更新包文件数量必须在 1 到 {MaximumZipEntries} 之间。";
|
||||
|
||||
long expandedBytes = 0;
|
||||
var hasRootIndex = false;
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var normalized = entry.FullName.Replace('\\', '/');
|
||||
if (normalized.StartsWith('/') ||
|
||||
normalized.Split('/').Any(part => part == ".."))
|
||||
{
|
||||
return "更新包包含不安全的文件路径。";
|
||||
}
|
||||
if (string.Equals(
|
||||
normalized,
|
||||
"index.html",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hasRootIndex = true;
|
||||
}
|
||||
expandedBytes = checked(expandedBytes + entry.Length);
|
||||
if (expandedBytes > MaximumExpandedBytes)
|
||||
return "更新包解压后的总大小不能超过 150 MB。";
|
||||
}
|
||||
|
||||
return hasRootIndex
|
||||
? null
|
||||
: "更新包根目录必须包含 index.html;请直接压缩 dist 目录中的内容。";
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is InvalidDataException or IOException or OverflowException)
|
||||
{
|
||||
return "更新包不是有效的 ZIP 文件,或文件结构已损坏。";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParsePlatform(
|
||||
string value,
|
||||
out AppUpdatePlatform platform) =>
|
||||
Enum.TryParse(value.Trim(), ignoreCase: true, out platform) &&
|
||||
Enum.IsDefined(platform);
|
||||
|
||||
private static bool TryParseChannel(
|
||||
string value,
|
||||
out AppUpdateChannel channel) =>
|
||||
Enum.TryParse(value.Trim(), ignoreCase: true, out channel) &&
|
||||
Enum.IsDefined(channel);
|
||||
|
||||
private static string? NullIfWhiteSpace(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static AppUpdateReleaseItem ToItem(AppUpdateRelease release) =>
|
||||
new(
|
||||
release.Id,
|
||||
release.Platform,
|
||||
release.Channel,
|
||||
release.Version,
|
||||
release.NativeVersion,
|
||||
release.Status,
|
||||
release.ReleaseNotes,
|
||||
release.FileName,
|
||||
release.FileSize,
|
||||
release.Sha256,
|
||||
release.CreatedByUserName,
|
||||
release.CreatedAt,
|
||||
release.PublishedByUserName,
|
||||
release.PublishedAt);
|
||||
|
||||
[GeneratedRegex(
|
||||
@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ReleaseVersionRegex();
|
||||
|
||||
[GeneratedRegex(
|
||||
@"^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex NativeVersionRegex();
|
||||
}
|
||||
|
||||
public sealed class AppUpdateUploadRequest
|
||||
{
|
||||
[Required]
|
||||
public required IFormFile Bundle { get; init; }
|
||||
|
||||
[Required, MaxLength(20)]
|
||||
public required string Platform { get; init; }
|
||||
|
||||
[Required, MaxLength(20)]
|
||||
public required string Channel { get; init; }
|
||||
|
||||
[Required, MaxLength(40)]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[Required, MaxLength(40)]
|
||||
public required string NativeVersion { get; init; }
|
||||
|
||||
[MaxLength(1000)]
|
||||
public string? ReleaseNotes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AppUpdateCheckResponse(
|
||||
bool Available,
|
||||
string? Version,
|
||||
string NativeVersion,
|
||||
AppUpdatePlatform Platform,
|
||||
AppUpdateChannel Channel,
|
||||
string? DownloadUrl,
|
||||
string? ReleaseNotes,
|
||||
long? FileSize,
|
||||
string? Sha256,
|
||||
DateTime? PublishedAt);
|
||||
|
||||
public sealed record AppUpdateReleaseItem(
|
||||
Guid Id,
|
||||
AppUpdatePlatform Platform,
|
||||
AppUpdateChannel Channel,
|
||||
string Version,
|
||||
string NativeVersion,
|
||||
AppUpdateReleaseStatus Status,
|
||||
string? ReleaseNotes,
|
||||
string FileName,
|
||||
long FileSize,
|
||||
string Sha256,
|
||||
string CreatedByUserName,
|
||||
DateTime CreatedAt,
|
||||
string? PublishedByUserName,
|
||||
DateTime? PublishedAt);
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInToken,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt
|
||||
});
|
||||
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
|
||||
x.AttendanceDate,
|
||||
x.Status,
|
||||
x.CheckInMethod,
|
||||
x.CheckInToken,
|
||||
x.CheckInStartsAt,
|
||||
x.CheckInEndsAt,
|
||||
x.TargetLatitude,
|
||||
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
|
||||
cancellationToken);
|
||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||
var now = DateTime.UtcNow;
|
||||
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.Where(x => x.AttendanceSheetId == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
x.CreatedAt,
|
||||
x.IsSuccessful,
|
||||
x.FailureCode,
|
||||
x.DeviceIdentifierHash,
|
||||
x.DevicePlatform,
|
||||
x.IpAddress,
|
||||
x.RiskFlags
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var attemptsByStudent = attempts
|
||||
.GroupBy(x => x.StudentId)
|
||||
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
|
||||
var deviceHashes = attempts
|
||||
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
|
||||
.Select(x => x.DeviceIdentifierHash!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
if (deviceHashes.Count > 0)
|
||||
{
|
||||
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsSuccessful &&
|
||||
x.CreatedAt >= now.AddHours(-24) &&
|
||||
x.DeviceIdentifierHash != null)
|
||||
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
|
||||
.GroupBy(x => x.DeviceIdentifierHash!)
|
||||
.Select(x => new
|
||||
{
|
||||
DeviceIdentifierHash = x.Key,
|
||||
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
deviceReuseCounts = reuseRows.ToDictionary(
|
||||
x => x.DeviceIdentifierHash,
|
||||
x => x.StudentCount,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var responseRecords = sheet.Records.Select(r =>
|
||||
{
|
||||
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
|
||||
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
|
||||
var riskFlags = studentAttempts
|
||||
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
|
||||
riskFlags.Add("RepeatedFailures");
|
||||
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
|
||||
riskFlags.Add("HighFrequency");
|
||||
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
|
||||
? deviceReuseCounts.GetValueOrDefault(deviceHash)
|
||||
: 0;
|
||||
if (sharedDeviceStudentCount > 1)
|
||||
riskFlags.Add("SharedDevice");
|
||||
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
|
||||
? attempts
|
||||
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.Count()
|
||||
: 0;
|
||||
|
||||
return new
|
||||
{
|
||||
r.StudentId,
|
||||
r.StudentNumber,
|
||||
r.Name,
|
||||
r.ClassName,
|
||||
r.Status,
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters,
|
||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||
IsDeferred = deferredStudentIds.Contains(r.StudentId),
|
||||
CheckInAudit = latestSuccess is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
latestSuccess.IpAddress,
|
||||
latestSuccess.DevicePlatform,
|
||||
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
|
||||
SharedDeviceStudentCount = sharedDeviceStudentCount,
|
||||
SameIpStudentCount = sameIpStudentCount
|
||||
},
|
||||
AttemptCount = studentAttempts.Count,
|
||||
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
|
||||
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
|
||||
};
|
||||
}).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = new
|
||||
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
|
||||
sheet.AttendanceDate,
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
sheet.TargetLatitude,
|
||||
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
|
||||
sheet.TaskName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
Records = sheet.Records.Select(r => new
|
||||
Records = responseRecords,
|
||||
RiskSummary = new
|
||||
{
|
||||
r.StudentId,
|
||||
r.StudentNumber,
|
||||
r.Name,
|
||||
r.ClassName,
|
||||
r.Status,
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters,
|
||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
||||
})
|
||||
RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
|
||||
SharedDeviceStudentCount = responseRecords.Count(
|
||||
x => x.RiskFlags.Contains("SharedDevice")),
|
||||
FrequentAttemptStudentCount = responseRecords.Count(
|
||||
x => x.RiskFlags.Contains("HighFrequency") ||
|
||||
x.RiskFlags.Contains("RepeatedFailures"))
|
||||
}
|
||||
},
|
||||
CanEdit = canEdit
|
||||
});
|
||||
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}/qr-challenge")]
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> GetQrChallenge(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.AttendanceSheets
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanManageSheet(sheet)) return Forbid();
|
||||
if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||
string.IsNullOrWhiteSpace(sheet.CheckInToken))
|
||||
return ConflictProblem("该考勤表不是扫码签到。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return ConflictProblem("签到尚未开始或已经结束。");
|
||||
|
||||
var challenge = AttendanceCheckInChallenge.Create(
|
||||
sheet.Id,
|
||||
sheet.CheckInToken,
|
||||
now);
|
||||
return Ok(challenge);
|
||||
}
|
||||
|
||||
// ═══════════════ Student endpoints ═══════════════
|
||||
|
||||
[HttpGet("check-in-info")]
|
||||
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return NotFound();
|
||||
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
|
||||
return NotFound();
|
||||
|
||||
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.AttendanceSheet!.CheckInToken == token.Trim())
|
||||
x.AttendanceSheetId == sheetId)
|
||||
.Select(x => new
|
||||
{
|
||||
SheetId = x.AttendanceSheetId,
|
||||
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
|
||||
x.AttendanceSheet.CheckInMethod,
|
||||
x.AttendanceSheet.CheckInStartsAt,
|
||||
x.AttendanceSheet.CheckInEndsAt,
|
||||
x.AttendanceSheet.CheckInToken,
|
||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
|
||||
if (activity is null) return NotFound();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||
!AttendanceCheckInChallenge.IsValid(
|
||||
token,
|
||||
activity.SheetId,
|
||||
activity.CheckInToken,
|
||||
now))
|
||||
return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
activity.SheetId,
|
||||
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
|
||||
.Where(x => x.StudentId == studentId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
var token = request.Token.Trim();
|
||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
||||
if (!AttendanceCheckInChallenge.TryReadSheetId(
|
||||
request.Token.Trim(),
|
||||
out var tokenSheetId))
|
||||
return NotFound();
|
||||
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
|
||||
}
|
||||
else if (request.AttendanceSheetId.HasValue)
|
||||
{
|
||||
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
|
||||
if (record?.AttendanceSheet is null) return NotFound();
|
||||
var sheet = record.AttendanceSheet;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
async Task<ActionResult> RejectAttemptAsync(
|
||||
string failureCode,
|
||||
string detail,
|
||||
double? distanceMeters = null)
|
||||
{
|
||||
await AddCheckInAttemptAsync(
|
||||
sheet,
|
||||
studentId.Value,
|
||||
request,
|
||||
false,
|
||||
failureCode,
|
||||
distanceMeters,
|
||||
now,
|
||||
cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return ConflictProblem(detail);
|
||||
}
|
||||
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
|
||||
!AttendanceCheckInChallenge.IsValid(
|
||||
request.Token,
|
||||
sheet.Id,
|
||||
sheet.CheckInToken,
|
||||
now))
|
||||
return await RejectAttemptAsync(
|
||||
"InvalidQrChallenge",
|
||||
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return await RejectAttemptAsync(
|
||||
"CheckInClosed",
|
||||
"签到尚未开始或已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return await RejectAttemptAsync(
|
||||
"ManualSheet",
|
||||
"该考勤表不支持学生在线签到。");
|
||||
if (record.CheckInAt.HasValue)
|
||||
{
|
||||
await AddCheckInAttemptAsync(
|
||||
sheet,
|
||||
studentId.Value,
|
||||
request,
|
||||
true,
|
||||
null,
|
||||
record.CheckInDistanceMeters,
|
||||
now,
|
||||
cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AlreadyCheckedIn = true,
|
||||
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
|
||||
record.CheckInDistanceMeters
|
||||
});
|
||||
}
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return ConflictProblem("签到尚未开始或已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return ConflictProblem("该考勤表不支持学生在线签到。");
|
||||
|
||||
double? distanceMeters = null;
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token) ||
|
||||
!string.Equals(
|
||||
sheet.CheckInToken,
|
||||
request.Token.Trim(),
|
||||
StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
}
|
||||
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||
{
|
||||
if (request.Latitude is < -90 or > 90 ||
|
||||
request.Longitude is < -180 or > 180 ||
|
||||
request.Latitude is null ||
|
||||
request.Longitude is null)
|
||||
return ConflictProblem("未获取到有效的当前位置。");
|
||||
return await RejectAttemptAsync(
|
||||
"InvalidLocation",
|
||||
"未获取到有效的当前位置。");
|
||||
if (sheet.TargetLatitude is null ||
|
||||
sheet.TargetLongitude is null ||
|
||||
sheet.LocationRadiusMeters is null)
|
||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
||||
return await RejectAttemptAsync(
|
||||
"LocationNotConfigured",
|
||||
"签到活动没有配置有效的位置范围。");
|
||||
var maximumAllowedAccuracyMeters = Math.Min(
|
||||
100d,
|
||||
sheet.LocationRadiusMeters.Value);
|
||||
if (request.AccuracyMeters is null ||
|
||||
!double.IsFinite(request.AccuracyMeters.Value) ||
|
||||
request.AccuracyMeters <= 0 ||
|
||||
request.AccuracyMeters > maximumAllowedAccuracyMeters)
|
||||
{
|
||||
return await RejectAttemptAsync(
|
||||
"InsufficientAccuracy",
|
||||
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
|
||||
}
|
||||
|
||||
distanceMeters = CalculateDistanceMeters(
|
||||
(double)sheet.TargetLatitude.Value,
|
||||
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
|
||||
(double)request.Longitude.Value);
|
||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
||||
return await RejectAttemptAsync(
|
||||
"OutsideGeofence",
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
|
||||
distanceMeters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
|
||||
? request.AccuracyMeters
|
||||
: null;
|
||||
record.CheckInDistanceMeters = distanceMeters;
|
||||
await AddCheckInAttemptAsync(
|
||||
sheet,
|
||||
studentId.Value,
|
||||
request,
|
||||
true,
|
||||
null,
|
||||
distanceMeters,
|
||||
now,
|
||||
cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddCheckInAttemptAsync(
|
||||
AttendanceSheet sheet,
|
||||
Guid studentId,
|
||||
AttendanceCheckInRequest request,
|
||||
bool isSuccessful,
|
||||
string? failureCode,
|
||||
double? distanceMeters,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
|
||||
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (deviceIdentifierHash is null)
|
||||
{
|
||||
riskFlags.Add("MissingDeviceId");
|
||||
}
|
||||
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
|
||||
x =>
|
||||
x.IsSuccessful &&
|
||||
x.StudentId != studentId &&
|
||||
x.DeviceIdentifierHash == deviceIdentifierHash &&
|
||||
x.CreatedAt >= now.AddHours(-24),
|
||||
cancellationToken))
|
||||
{
|
||||
riskFlags.Add("SharedDevice");
|
||||
}
|
||||
|
||||
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.StudentId == studentId &&
|
||||
x.CreatedAt >= now.AddMinutes(-2),
|
||||
cancellationToken);
|
||||
if (recentAttemptCount >= 5)
|
||||
riskFlags.Add("HighFrequency");
|
||||
|
||||
if (!isSuccessful)
|
||||
{
|
||||
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.StudentId == studentId &&
|
||||
!x.IsSuccessful &&
|
||||
x.CreatedAt >= now.AddMinutes(-5),
|
||||
cancellationToken);
|
||||
if (recentFailureCount >= 2)
|
||||
riskFlags.Add("RepeatedFailures");
|
||||
}
|
||||
|
||||
var context = ControllerContext.HttpContext;
|
||||
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
|
||||
{
|
||||
AttendanceSheetId = sheet.Id,
|
||||
StudentId = studentId,
|
||||
CheckInMethod = sheet.CheckInMethod,
|
||||
IsSuccessful = isSuccessful,
|
||||
FailureCode = failureCode,
|
||||
DeviceIdentifierHash = deviceIdentifierHash,
|
||||
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
|
||||
IpAddress = Limit(
|
||||
context?.Connection.RemoteIpAddress?.ToString(),
|
||||
64),
|
||||
UserAgent = Limit(
|
||||
context?.Request.Headers.UserAgent.ToString(),
|
||||
500),
|
||||
RiskFlags = riskFlags.Count == 0
|
||||
? null
|
||||
: string.Join(',', riskFlags.OrderBy(x => x)),
|
||||
Latitude = request.Latitude,
|
||||
Longitude = request.Longitude,
|
||||
AccuracyMeters = request.AccuracyMeters,
|
||||
DistanceMeters = distanceMeters,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
private static string? HashDeviceIdentifier(string? deviceId)
|
||||
{
|
||||
var normalized = Normalize(deviceId)?.ToLowerInvariant();
|
||||
return normalized is null
|
||||
? null
|
||||
: Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ParseRiskFlags(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? []
|
||||
: value.Split(
|
||||
',',
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries);
|
||||
|
||||
private static string? Limit(string? value, int maximumLength) =>
|
||||
value is null || value.Length <= maximumLength
|
||||
? value
|
||||
: value[..maximumLength];
|
||||
|
||||
private static bool IsCheckInOpen(
|
||||
AttendanceSheetStatus status,
|
||||
AttendanceCheckInMethod method,
|
||||
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
|
||||
[MaxLength(64)] string? Token,
|
||||
[Range(-90, 90)] decimal? Latitude,
|
||||
[Range(-180, 180)] decimal? Longitude,
|
||||
[Range(0, 5000)] double? AccuracyMeters);
|
||||
[Range(0, 5000)] double? AccuracyMeters,
|
||||
[MaxLength(128)] string? DeviceId = null,
|
||||
[MaxLength(32)] string? DevicePlatform = null);
|
||||
|
||||
public sealed record AttendanceCourseStatistics(
|
||||
AttendanceStatisticsCourse Course,
|
||||
|
||||
@@ -84,6 +84,17 @@ public sealed class CoursesController(
|
||||
x.Nature,
|
||||
x.AssessmentMethod,
|
||||
x.Description,
|
||||
PrerequisiteCourseIds = x.Prerequisites
|
||||
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||
.Select(item => item.PrerequisiteCourseId),
|
||||
Prerequisites = x.Prerequisites
|
||||
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||
.Select(item => new
|
||||
{
|
||||
item.PrerequisiteCourseId,
|
||||
item.PrerequisiteCourse!.Code,
|
||||
item.PrerequisiteCourse.Name
|
||||
}),
|
||||
x.IsEnabled,
|
||||
x.SortOrder,
|
||||
x.CreatedAt,
|
||||
@@ -134,6 +145,8 @@ public sealed class CoursesController(
|
||||
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
|
||||
x.Credits,
|
||||
x.TotalHours,
|
||||
x.LectureHours,
|
||||
x.PracticeHours,
|
||||
x.Nature,
|
||||
x.AssessmentMethod
|
||||
})
|
||||
@@ -146,9 +159,6 @@ public sealed class CoursesController(
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ValidateAsync(request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
var entity = new Course
|
||||
{
|
||||
Code = request.Code.Trim(),
|
||||
@@ -166,6 +176,16 @@ public sealed class CoursesController(
|
||||
IsEnabled = request.IsEnabled,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
var validation = await ValidateAsync(entity.Id, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
entity.Prerequisites = NormalizePrerequisiteIds(request)
|
||||
.Select(prerequisiteId => new CoursePrerequisite
|
||||
{
|
||||
CourseId = entity.Id,
|
||||
PrerequisiteCourseId = prerequisiteId
|
||||
})
|
||||
.ToList();
|
||||
db.Courses.Add(entity);
|
||||
return await SaveAsync(entity.Id, true, cancellationToken);
|
||||
}
|
||||
@@ -177,10 +197,12 @@ public sealed class CoursesController(
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
||||
var entity = await db.Courses
|
||||
.Include(x => x.Prerequisites)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
|
||||
var validation = await ValidateAsync(request, cancellationToken);
|
||||
var validation = await ValidateAsync(id, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
entity.Code = request.Code.Trim();
|
||||
@@ -197,6 +219,19 @@ public sealed class CoursesController(
|
||||
entity.Description = Normalize(request.Description);
|
||||
entity.IsEnabled = request.IsEnabled;
|
||||
entity.SortOrder = request.SortOrder;
|
||||
var prerequisiteIds = NormalizePrerequisiteIds(request).ToHashSet();
|
||||
db.CoursePrerequisites.RemoveRange(
|
||||
entity.Prerequisites.Where(x =>
|
||||
!prerequisiteIds.Contains(x.PrerequisiteCourseId)));
|
||||
foreach (var prerequisiteId in prerequisiteIds.Except(
|
||||
entity.Prerequisites.Select(x => x.PrerequisiteCourseId)))
|
||||
{
|
||||
entity.Prerequisites.Add(new CoursePrerequisite
|
||||
{
|
||||
CourseId = entity.Id,
|
||||
PrerequisiteCourseId = prerequisiteId
|
||||
});
|
||||
}
|
||||
return await SaveAsync(entity.Id, false, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -212,6 +247,7 @@ public sealed class CoursesController(
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ValidateAsync(
|
||||
Guid courseId,
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -224,9 +260,62 @@ public sealed class CoursesController(
|
||||
return ValidationProblem("所选课程分类不存在或已停用。");
|
||||
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
||||
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
||||
|
||||
var prerequisiteIds = NormalizePrerequisiteIds(request).ToArray();
|
||||
if (prerequisiteIds.Contains(courseId))
|
||||
return ValidationProblem("课程不能把自身设置为先修课程。");
|
||||
var accessiblePrerequisiteCount = await ScopedCourses()
|
||||
.CountAsync(x =>
|
||||
prerequisiteIds.Contains(x.Id) &&
|
||||
x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (accessiblePrerequisiteCount != prerequisiteIds.Length)
|
||||
return ValidationProblem("包含不存在、已停用或不在当前数据范围内的先修课程。");
|
||||
if (await CreatesPrerequisiteCycleAsync(
|
||||
courseId,
|
||||
prerequisiteIds,
|
||||
cancellationToken))
|
||||
return ValidationProblem("先修关系不能形成循环依赖。");
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> CreatesPrerequisiteCycleAsync(
|
||||
Guid courseId,
|
||||
IReadOnlyCollection<Guid> prerequisiteIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (prerequisiteIds.Count == 0) return false;
|
||||
|
||||
var edges = await db.CoursePrerequisites.AsNoTracking()
|
||||
.Where(x => x.CourseId != courseId)
|
||||
.Select(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var prerequisitesByCourse = edges
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(x => x.PrerequisiteCourseId).ToArray());
|
||||
|
||||
foreach (var prerequisiteId in prerequisiteIds)
|
||||
{
|
||||
var pending = new Stack<Guid>();
|
||||
var visited = new HashSet<Guid>();
|
||||
pending.Push(prerequisiteId);
|
||||
while (pending.TryPop(out var candidate))
|
||||
{
|
||||
if (candidate == courseId) return true;
|
||||
if (!visited.Add(candidate) ||
|
||||
!prerequisitesByCourse.TryGetValue(candidate, out var next))
|
||||
continue;
|
||||
foreach (var item in next) pending.Push(item);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<Guid> NormalizePrerequisiteIds(CourseRequest request) =>
|
||||
(request.PrerequisiteCourseIds ?? []).Distinct();
|
||||
|
||||
private IQueryable<Course> ScopedCourses()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
@@ -307,4 +396,5 @@ public sealed record CourseRequest(
|
||||
AssessmentMethod AssessmentMethod,
|
||||
[MaxLength(1000)] string? Description,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
int SortOrder,
|
||||
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
@@ -14,89 +13,301 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/dashboard")]
|
||||
public sealed class DashboardController(
|
||||
AppDbContext db,
|
||||
IAppCache appCache,
|
||||
IOptions<JsonOptions> jsonOptions) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<DashboardResponse>> Get(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await appCache.GetOrCreateAsync(
|
||||
AppCacheKeys.Dashboard,
|
||||
LoadAsync,
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.Analytics],
|
||||
cancellationToken);
|
||||
return response;
|
||||
}
|
||||
var scope = currentUserDataScope.Current;
|
||||
Guid? restrictedCollegeId = scope.Scope == DataScope.All
|
||||
? null
|
||||
: scope.CollegeId ?? Guid.Empty;
|
||||
var collegeName = restrictedCollegeId.HasValue &&
|
||||
restrictedCollegeId.Value != Guid.Empty
|
||||
? await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.Id == restrictedCollegeId.Value)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTerm = await db.AcademicTerms
|
||||
.AsNoTracking()
|
||||
.Where(x => x.IsCurrent)
|
||||
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
||||
.Select(x => new DashboardTerm(
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.StartDate,
|
||||
x.EndDate))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var currentTermId = currentTerm?.Id;
|
||||
|
||||
return JsonSerializer.SerializeToElement(
|
||||
new
|
||||
{
|
||||
CurrentTerm = currentTerm,
|
||||
Counts = new
|
||||
{
|
||||
Campuses = await db.Campuses.CountAsync(cancellationToken),
|
||||
Colleges = await db.Colleges.CountAsync(cancellationToken),
|
||||
Majors = await db.Majors.CountAsync(cancellationToken),
|
||||
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
|
||||
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
|
||||
Teachers = await db.Teachers.CountAsync(cancellationToken),
|
||||
Students = await db.Students.CountAsync(cancellationToken),
|
||||
Courses = await db.Courses.CountAsync(cancellationToken),
|
||||
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
||||
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
||||
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
|
||||
CourseSelectionRounds = await db.CourseSelectionRounds
|
||||
.CountAsync(cancellationToken),
|
||||
CourseSelectionOfferings = await db.CourseSelectionOfferings
|
||||
.CountAsync(cancellationToken),
|
||||
CourseEnrollments = await db.CourseEnrollments
|
||||
.CountAsync(
|
||||
x => x.Status == CourseEnrollmentStatus.Enrolled,
|
||||
cancellationToken),
|
||||
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
|
||||
PublishedGradeSheets = await db.GradeSheets.CountAsync(
|
||||
x => x.Status == GradeSheetStatus.Published,
|
||||
cancellationToken),
|
||||
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
||||
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
|
||||
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
|
||||
StudentStatusChanges = await db.StudentStatusChanges
|
||||
.CountAsync(cancellationToken),
|
||||
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
|
||||
x => x.State == StudentStatusChangeState.Submitted ||
|
||||
x.State == StudentStatusChangeState.CounselorApproved ||
|
||||
x.State == StudentStatusChangeState.CollegeApproved,
|
||||
cancellationToken),
|
||||
GraduationAuditBatches = await db.GraduationAuditBatches
|
||||
.CountAsync(cancellationToken),
|
||||
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
|
||||
.CountAsync(
|
||||
x => x.Status == GraduationAuditBatchStatus.Published,
|
||||
cancellationToken),
|
||||
DegreeAwardBatches = await db.DegreeAwardBatches
|
||||
.CountAsync(cancellationToken),
|
||||
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
|
||||
.CountAsync(
|
||||
x => x.Status == DegreeAwardBatchStatus.Published,
|
||||
cancellationToken),
|
||||
GraduationClearanceBatches = await db.GraduationClearanceBatches
|
||||
.CountAsync(cancellationToken),
|
||||
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
|
||||
.CountAsync(
|
||||
x => x.Status == GraduationClearanceBatchStatus.Open,
|
||||
cancellationToken),
|
||||
Users = await db.Users.CountAsync(cancellationToken)
|
||||
}
|
||||
},
|
||||
jsonOptions.Value.JsonSerializerOptions);
|
||||
var students = db.Students.AsNoTracking()
|
||||
.Where(x =>
|
||||
!restrictedCollegeId.HasValue ||
|
||||
x.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value);
|
||||
var teachers = db.Teachers.AsNoTracking()
|
||||
.Where(x =>
|
||||
!restrictedCollegeId.HasValue ||
|
||||
x.CollegeId == restrictedCollegeId.Value);
|
||||
var courses = db.Courses.AsNoTracking()
|
||||
.Where(x =>
|
||||
!restrictedCollegeId.HasValue ||
|
||||
x.CollegeId == restrictedCollegeId.Value);
|
||||
var teachingTasks = db.TeachingTasks.AsNoTracking()
|
||||
.Where(x =>
|
||||
currentTermId.HasValue &&
|
||||
x.AcademicTermId == currentTermId.Value &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Course!.CollegeId == restrictedCollegeId.Value));
|
||||
var gradeSheets = db.GradeSheets.AsNoTracking()
|
||||
.Where(x =>
|
||||
currentTermId.HasValue &&
|
||||
x.TeachingTask!.AcademicTermId == currentTermId.Value &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var enrollments = db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
currentTermId.HasValue &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.CourseSelectionRound!
|
||||
.AcademicTermId == currentTermId.Value &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.CourseSelectionOffering.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
|
||||
var taskCount = await teachingTasks.CountAsync(cancellationToken);
|
||||
var publishedTaskCount = await teachingTasks.CountAsync(
|
||||
x => x.Status == TeachingTaskStatus.Published,
|
||||
cancellationToken);
|
||||
var scheduledTaskCount = currentTermId.HasValue
|
||||
? await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlan!.AcademicTermId == currentTermId.Value &&
|
||||
x.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value))
|
||||
.Select(x => x.TeachingTaskId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken)
|
||||
: 0;
|
||||
var gradeSheetCount = await gradeSheets.CountAsync(cancellationToken);
|
||||
var publishedGradeSheetCount = await gradeSheets.CountAsync(
|
||||
x => x.Status == GradeSheetStatus.Published,
|
||||
cancellationToken);
|
||||
|
||||
var counts = new DashboardCounts(
|
||||
await students.CountAsync(
|
||||
x => x.Status == StudentStatus.Active,
|
||||
cancellationToken),
|
||||
await teachers.CountAsync(
|
||||
x => x.Status == TeacherStatus.Active,
|
||||
cancellationToken),
|
||||
await courses.CountAsync(
|
||||
x => x.IsEnabled,
|
||||
cancellationToken),
|
||||
taskCount,
|
||||
publishedTaskCount,
|
||||
scheduledTaskCount,
|
||||
await enrollments.CountAsync(cancellationToken),
|
||||
gradeSheetCount,
|
||||
publishedGradeSheetCount,
|
||||
await gradeSheets.CountAsync(
|
||||
x => x.Status == GradeSheetStatus.Submitted,
|
||||
cancellationToken),
|
||||
currentTermId.HasValue
|
||||
? await db.CourseSelectionRounds.AsNoTracking().CountAsync(
|
||||
x => x.AcademicTermId == currentTermId.Value &&
|
||||
x.Status == CourseSelectionRoundStatus.Open,
|
||||
cancellationToken)
|
||||
: 0);
|
||||
|
||||
var pending = await LoadPendingAsync(
|
||||
scope,
|
||||
restrictedCollegeId,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new DashboardResponse(
|
||||
BuildAudience(scope, collegeName),
|
||||
currentTerm,
|
||||
counts,
|
||||
pending,
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
|
||||
private async Task<DashboardPending> LoadPendingAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? restrictedCollegeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isSchoolManager =
|
||||
scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
scope.IsInRole(SystemRoles.AcademicAdmin);
|
||||
var isCollegeManager =
|
||||
!isSchoolManager && scope.IsInRole(SystemRoles.CollegeAdmin);
|
||||
if (!isSchoolManager && !isCollegeManager)
|
||||
return new DashboardPending(0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
var teacherApplications = db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == TeacherCourseApplicationStatus.Pending &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Teacher!.CollegeId == restrictedCollegeId.Value));
|
||||
var gradeSheets = db.GradeSheets.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == GradeSheetStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var courseAdjustments = db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseAdjustmentStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var studentStatusChanges = db.StudentStatusChanges.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.State == (isCollegeManager
|
||||
? StudentStatusChangeState.CounselorApproved
|
||||
: StudentStatusChangeState.CollegeApproved) &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var gradeModifications = db.GradeModifications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == (isCollegeManager
|
||||
? GradeModificationStatus.TeacherSubmitted
|
||||
: GradeModificationStatus.CollegeApproved) &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
|
||||
var generalApprovals =
|
||||
await db.CourseExemptions.AsNoTracking().CountAsync(
|
||||
x => x.Status == ApprovalStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken) +
|
||||
await db.DeferredExams.AsNoTracking().CountAsync(
|
||||
x => x.Status == ApprovalStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken) +
|
||||
await db.CourseSubstitutions.AsNoTracking().CountAsync(
|
||||
x => x.Status == ApprovalStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken) +
|
||||
await db.AttendanceRecords.AsNoTracking().CountAsync(
|
||||
x => x.AppealStatus == AttendanceAppealStatus.Pending &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken);
|
||||
|
||||
var classroomReservations = isCollegeManager &&
|
||||
restrictedCollegeId.HasValue
|
||||
? await db.ClassroomReservations.AsNoTracking().CountAsync(
|
||||
x => x.Status == ClassroomReservationStatus.Submitted &&
|
||||
x.ApplicantCollegeId == restrictedCollegeId.Value,
|
||||
cancellationToken)
|
||||
: 0;
|
||||
|
||||
return new DashboardPending(
|
||||
await teacherApplications.CountAsync(cancellationToken),
|
||||
await gradeSheets.CountAsync(cancellationToken),
|
||||
await courseAdjustments.CountAsync(cancellationToken),
|
||||
await studentStatusChanges.CountAsync(cancellationToken),
|
||||
await gradeModifications.CountAsync(cancellationToken),
|
||||
classroomReservations,
|
||||
generalApprovals);
|
||||
}
|
||||
|
||||
private static DashboardAudience BuildAudience(
|
||||
CurrentUserScope scope,
|
||||
string? collegeName)
|
||||
{
|
||||
if (scope.IsInRole(SystemRoles.SuperAdmin))
|
||||
return new DashboardAudience(
|
||||
"System",
|
||||
"全域教务工作台",
|
||||
"全校",
|
||||
"统筹基础数据、教学运行与系统治理");
|
||||
if (scope.IsInRole(SystemRoles.AcademicAdmin))
|
||||
return new DashboardAudience(
|
||||
"School",
|
||||
"校级教务工作台",
|
||||
"全校",
|
||||
"聚焦跨学院教学运行与校级审核");
|
||||
if (scope.IsInRole(SystemRoles.CollegeAdmin))
|
||||
return new DashboardAudience(
|
||||
"College",
|
||||
"学院教务工作台",
|
||||
collegeName ?? "本学院",
|
||||
"聚焦本学院教学准备、过程审核与成绩归档");
|
||||
if (scope.IsInRole(SystemRoles.Leader))
|
||||
return new DashboardAudience(
|
||||
"Leadership",
|
||||
"教学运行观察台",
|
||||
"全校",
|
||||
"查看全校教学运行与质量数据");
|
||||
if (scope.IsInRole(SystemRoles.Counselor))
|
||||
return new DashboardAudience(
|
||||
"Counselor",
|
||||
"班级工作台",
|
||||
collegeName ?? "所辖班级",
|
||||
"处理学生过程管理与学业支持");
|
||||
return new DashboardAudience(
|
||||
"Teaching",
|
||||
"教学工作台",
|
||||
collegeName ?? "个人教学",
|
||||
"查看课程运行并进入日常教学工作");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DashboardResponse(
|
||||
DashboardAudience Audience,
|
||||
DashboardTerm? CurrentTerm,
|
||||
DashboardCounts Counts,
|
||||
DashboardPending Pending,
|
||||
DateTime GeneratedAt);
|
||||
|
||||
public sealed record DashboardAudience(
|
||||
string Level,
|
||||
string Title,
|
||||
string ScopeName,
|
||||
string Description);
|
||||
|
||||
public sealed record DashboardTerm(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate);
|
||||
|
||||
public sealed record DashboardCounts(
|
||||
int Students,
|
||||
int Teachers,
|
||||
int Courses,
|
||||
int TeachingTasks,
|
||||
int PublishedTeachingTasks,
|
||||
int ScheduledTeachingTasks,
|
||||
int CourseEnrollments,
|
||||
int GradeSheets,
|
||||
int PublishedGradeSheets,
|
||||
int SubmittedGradeSheets,
|
||||
int OpenCourseSelectionRounds);
|
||||
|
||||
public sealed record DashboardPending(
|
||||
int TeacherApplications,
|
||||
int GradeSheets,
|
||||
int CourseAdjustments,
|
||||
int StudentStatusChanges,
|
||||
int GradeModifications,
|
||||
int ClassroomReservations,
|
||||
int GeneralApprovals);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,945 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/experiment-grades")]
|
||||
public sealed class ExperimentGradesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Teacher;
|
||||
|
||||
private const string Reviewers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
private const string Publishers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin;
|
||||
|
||||
[HttpGet("management")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetManagement(
|
||||
Guid? academicTermId,
|
||||
ExperimentGradeSheetStatus? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = ScopedProjects().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExperimentProjectStatus.Published ||
|
||||
x.Status == ExperimentProjectStatus.Closed);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||
if (status.HasValue)
|
||||
source = source.Where(x =>
|
||||
x.GradeSheet != null &&
|
||||
x.GradeSheet.Status == status.Value);
|
||||
|
||||
return Ok(await source
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
ProjectStatus = x.Status,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
AcademicTermId = x.TeachingTask.AcademicTermId,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
CollegeName = x.TeachingTask.Course.College!.Name,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
Sheet = x.GradeSheet == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
x.GradeSheet.Id,
|
||||
x.GradeSheet.Status,
|
||||
x.GradeSheet.ContributionWeight,
|
||||
x.GradeSheet.PassScore,
|
||||
ItemCount = x.GradeSheet.Items.Count,
|
||||
StudentCount = x.GradeSheet.Records.Count,
|
||||
CompletedCount = x.GradeSheet.Records.Count(record =>
|
||||
record.ParticipationStatus !=
|
||||
ExperimentParticipationStatus.Pending),
|
||||
ScoredCount = x.GradeSheet.Records.Count(record =>
|
||||
record.TotalScore != null),
|
||||
PassedCount = x.GradeSheet.Records.Count(record =>
|
||||
record.IsPassed == true),
|
||||
x.GradeSheet.SubmittedAt,
|
||||
x.GradeSheet.ReviewedAt,
|
||||
x.GradeSheet.PublishedAt
|
||||
}
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await CurrentStudentAsync(cancellationToken);
|
||||
if (student is null)
|
||||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
|
||||
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.ExperimentGradeSheet!.Status ==
|
||||
ExperimentGradeSheetStatus.Published)
|
||||
.OrderByDescending(x =>
|
||||
x.ExperimentGradeSheet!.PublishedAt)
|
||||
.ThenBy(x =>
|
||||
x.ExperimentGradeSheet!.ExperimentProject!.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ExperimentGradeSheetId,
|
||||
ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
|
||||
ProjectCode =
|
||||
x.ExperimentGradeSheet.ExperimentProject!.Code,
|
||||
ProjectName =
|
||||
x.ExperimentGradeSheet.ExperimentProject.Name,
|
||||
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
|
||||
CourseCode = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask!.Course!.Code,
|
||||
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.Course.Name,
|
||||
TermName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.AcademicTerm!.Name,
|
||||
x.ExperimentGradeSheet.PassScore,
|
||||
x.ParticipationStatus,
|
||||
x.TotalScore,
|
||||
x.IsPassed,
|
||||
x.SafetyViolation,
|
||||
x.AttemptNumber,
|
||||
x.SubmissionReference,
|
||||
x.SubmittedAt,
|
||||
x.IsLate,
|
||||
x.TeacherComment,
|
||||
Items = x.ItemScores
|
||||
.OrderBy(score =>
|
||||
score.ExperimentGradeItem!.SortOrder)
|
||||
.Select(score => new
|
||||
{
|
||||
score.ExperimentGradeItemId,
|
||||
score.ExperimentGradeItem!.Name,
|
||||
score.ExperimentGradeItem.Kind,
|
||||
score.ExperimentGradeItem.Weight,
|
||||
score.Score,
|
||||
score.Comment
|
||||
}),
|
||||
x.ExperimentGradeSheet.PublishedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("sheets")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateSheet(
|
||||
ExperimentGradeSheetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = BuildItems(request.Items);
|
||||
if (!ExperimentGradeCalculator.AreWeightsValid(
|
||||
request.ContributionWeight,
|
||||
request.PassScore,
|
||||
items))
|
||||
return ValidationProblem(
|
||||
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
|
||||
|
||||
var project = await ScopedProjects()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == request.ExperimentProjectId,
|
||||
cancellationToken);
|
||||
if (project is null) return NotFound();
|
||||
if (project.Status is not (
|
||||
ExperimentProjectStatus.Published or
|
||||
ExperimentProjectStatus.Closed))
|
||||
return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
|
||||
if (!CanInitialize(project.TeachingTask!)) return Forbid();
|
||||
if (await db.ExperimentGradeSheets.AnyAsync(x =>
|
||||
x.ExperimentProjectId == project.Id,
|
||||
cancellationToken))
|
||||
return ConflictProblem("该实验项目已经建立成绩单。");
|
||||
|
||||
var participants = await LoadParticipantsAsync(
|
||||
project,
|
||||
cancellationToken);
|
||||
if (participants.Count == 0)
|
||||
return ConflictProblem("实验项目当前没有可评分学生。");
|
||||
|
||||
var sheet = new ExperimentGradeSheet
|
||||
{
|
||||
ExperimentProjectId = project.Id,
|
||||
ContributionWeight = request.ContributionWeight,
|
||||
PassScore = request.PassScore,
|
||||
Items = items,
|
||||
Records = participants.Select(participant =>
|
||||
new ExperimentGradeRecord
|
||||
{
|
||||
StudentId = participant.StudentId,
|
||||
ExperimentSessionId = participant.SessionId,
|
||||
ItemScores = items.Select(item =>
|
||||
new ExperimentGradeItemScore
|
||||
{
|
||||
ExperimentGradeItemId = item.Id
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
db.ExperimentGradeSheets.Add(sheet);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Created(string.Empty, new { sheet.Id });
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetSheet(
|
||||
Guid id,
|
||||
int recordPage = 1,
|
||||
int recordPageSize = 50,
|
||||
string? studentKeyword = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
recordPage = Math.Max(1, recordPage);
|
||||
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
|
||||
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
|
||||
? null
|
||||
: studentKeyword.Trim();
|
||||
|
||||
var sheet = await ScopedSheets().AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ExperimentProjectId,
|
||||
ProjectCode = x.ExperimentProject!.Code,
|
||||
ProjectName = x.ExperimentProject.Name,
|
||||
x.ExperimentProject.ArrangementMode,
|
||||
x.ExperimentProject.TeachingTaskId,
|
||||
x.ExperimentProject.TeachingTask!.TaskNumber,
|
||||
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||
AcademicTermId =
|
||||
x.ExperimentProject.TeachingTask.AcademicTermId,
|
||||
TermName = x.ExperimentProject.TeachingTask
|
||||
.AcademicTerm!.Name,
|
||||
CourseCollegeId = x.ExperimentProject.TeachingTask
|
||||
.Course!.CollegeId,
|
||||
CourseCollegeName = x.ExperimentProject.TeachingTask
|
||||
.Course.College!.Name,
|
||||
CourseCode =
|
||||
x.ExperimentProject.TeachingTask.Course.Code,
|
||||
CourseName =
|
||||
x.ExperimentProject.TeachingTask.Course.Name,
|
||||
TeacherNames = x.ExperimentProject.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
x.ContributionWeight,
|
||||
x.PassScore,
|
||||
Items = x.Items.OrderBy(item => item.SortOrder)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Kind,
|
||||
item.Weight
|
||||
}),
|
||||
x.Status,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt,
|
||||
x.PublishedAt,
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
||||
.Where(x => x.ExperimentGradeSheetId == id);
|
||||
if (studentKeyword is not null)
|
||||
recordsSource = recordsSource.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||
x.Student.Name.Contains(studentKeyword) ||
|
||||
x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
|
||||
var recordTotal = await recordsSource.CountAsync(cancellationToken);
|
||||
var records = await recordsSource
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Skip((recordPage - 1) * recordPageSize)
|
||||
.Take(recordPageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
x.ExperimentSessionId,
|
||||
Session = x.ExperimentSession == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
x.ExperimentSession.SessionDate,
|
||||
x.ExperimentSession.StartPeriod,
|
||||
x.ExperimentSession.PeriodCount,
|
||||
ClassroomName =
|
||||
x.ExperimentSession.Classroom!.Name
|
||||
},
|
||||
x.ParticipationStatus,
|
||||
x.TotalScore,
|
||||
x.IsPassed,
|
||||
x.SafetyViolation,
|
||||
x.AttemptNumber,
|
||||
x.SubmissionReference,
|
||||
x.SubmittedAt,
|
||||
x.IsLate,
|
||||
x.TeacherComment,
|
||||
ItemScores = x.ItemScores
|
||||
.OrderBy(score =>
|
||||
score.ExperimentGradeItem!.SortOrder)
|
||||
.Select(score => new
|
||||
{
|
||||
score.ExperimentGradeItemId,
|
||||
score.ExperimentGradeItem!.Name,
|
||||
score.Score,
|
||||
score.Comment
|
||||
}),
|
||||
x.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Include(x => x.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.SingleAsync(
|
||||
x => x.Id == sheet.TeachingTaskId,
|
||||
cancellationToken);
|
||||
var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
|
||||
sheet.Status ==
|
||||
ExperimentGradeSheetStatus.Submitted;
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = new
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.ExperimentProjectId,
|
||||
sheet.ProjectCode,
|
||||
sheet.ProjectName,
|
||||
sheet.ArrangementMode,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.TaskNumber,
|
||||
sheet.TaskName,
|
||||
sheet.AcademicTermId,
|
||||
sheet.TermName,
|
||||
sheet.CourseCollegeName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
sheet.TeacherNames,
|
||||
sheet.ContributionWeight,
|
||||
sheet.PassScore,
|
||||
sheet.Items,
|
||||
sheet.Status,
|
||||
sheet.ReviewComment,
|
||||
sheet.SubmittedAt,
|
||||
sheet.ReviewedAt,
|
||||
sheet.PublishedAt,
|
||||
RecordTotal = recordTotal,
|
||||
RecordPage = recordPage,
|
||||
RecordPageSize = recordPageSize,
|
||||
Records = records,
|
||||
sheet.CreatedAt,
|
||||
sheet.UpdatedAt
|
||||
},
|
||||
CanEdit = CanEdit(task) &&
|
||||
sheet.Status is
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned,
|
||||
CanReview = canReview,
|
||||
CanPublish = IsPublisher() &&
|
||||
sheet.Status == ExperimentGradeSheetStatus.Approved
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("sheets/{id:guid}/settings")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateSettings(
|
||||
Guid id,
|
||||
ExperimentGradeSettingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var replacementItems = BuildItems(request.Items);
|
||||
if (!ExperimentGradeCalculator.AreWeightsValid(
|
||||
request.ContributionWeight,
|
||||
request.PassScore,
|
||||
replacementItems))
|
||||
return ValidationProblem(
|
||||
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
|
||||
|
||||
var sheet = await ScopedSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("当前状态不能修改实验评分方案。");
|
||||
|
||||
sheet.ContributionWeight = request.ContributionWeight;
|
||||
sheet.PassScore = request.PassScore;
|
||||
db.ExperimentGradeItems.RemoveRange(sheet.Items);
|
||||
foreach (var record in sheet.Records)
|
||||
{
|
||||
record.ItemScores.Clear();
|
||||
record.TotalScore = null;
|
||||
record.IsPassed = null;
|
||||
}
|
||||
sheet.Items = replacementItems;
|
||||
foreach (var record in sheet.Records)
|
||||
foreach (var item in replacementItems)
|
||||
record.ItemScores.Add(new ExperimentGradeItemScore
|
||||
{
|
||||
ExperimentGradeItemId = item.Id
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("sheets/{id:guid}/records")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateRecords(
|
||||
Guid id,
|
||||
ExperimentGradeRecordsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await ScopedSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("成绩单提交后不能继续修改。");
|
||||
|
||||
var records = sheet.Records.ToDictionary(x => x.Id);
|
||||
if (request.Records.Select(x => x.Id).Distinct().Count() !=
|
||||
request.Records.Count ||
|
||||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||
return ValidationProblem("包含无效或重复的实验成绩记录。");
|
||||
var itemIds = sheet.Items.Select(x => x.Id).ToHashSet();
|
||||
foreach (var item in request.Records)
|
||||
{
|
||||
if (item.AttemptNumber is < 1 or > 20)
|
||||
return ValidationProblem("实验尝试次数应在 1—20 次之间。");
|
||||
if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
|
||||
.Distinct().Count() != item.ItemScores.Count ||
|
||||
item.ItemScores.Any(x =>
|
||||
!itemIds.Contains(x.ExperimentGradeItemId) ||
|
||||
!ValidScore(x.Score)))
|
||||
return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");
|
||||
|
||||
var record = records[item.Id];
|
||||
record.ParticipationStatus = item.ParticipationStatus;
|
||||
record.SafetyViolation = item.SafetyViolation;
|
||||
record.AttemptNumber = item.AttemptNumber;
|
||||
record.SubmissionReference =
|
||||
Normalize(item.SubmissionReference);
|
||||
record.SubmittedAt = item.SubmittedAt;
|
||||
record.IsLate = item.IsLate;
|
||||
record.TeacherComment = Normalize(item.TeacherComment);
|
||||
var scores = record.ItemScores.ToDictionary(
|
||||
x => x.ExperimentGradeItemId);
|
||||
foreach (var score in item.ItemScores)
|
||||
{
|
||||
var target = scores[score.ExperimentGradeItemId];
|
||||
target.Score = score.Score;
|
||||
target.Comment = Normalize(score.Comment);
|
||||
}
|
||||
Recalculate(sheet, record);
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/sync-participants")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> SyncParticipants(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await ScopedSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("成绩单提交后不能同步参与学生。");
|
||||
|
||||
var expected = await LoadParticipantsAsync(
|
||||
sheet.ExperimentProject,
|
||||
cancellationToken);
|
||||
var expectedByStudent = expected.ToDictionary(x => x.StudentId);
|
||||
var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
|
||||
var added = 0;
|
||||
var removed = 0;
|
||||
var retained = 0;
|
||||
var recordsToRemove = new List<ExperimentGradeRecord>();
|
||||
var recordsToAdd = new List<ExperimentGradeRecord>();
|
||||
|
||||
foreach (var participant in expected)
|
||||
{
|
||||
if (existingByStudent.TryGetValue(
|
||||
participant.StudentId,
|
||||
out var record))
|
||||
{
|
||||
if (record.ParticipationStatus ==
|
||||
ExperimentParticipationStatus.Pending &&
|
||||
record.ItemScores.All(x => !x.Score.HasValue))
|
||||
record.ExperimentSessionId = participant.SessionId;
|
||||
retained++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var newRecord = new ExperimentGradeRecord
|
||||
{
|
||||
ExperimentGradeSheetId = sheet.Id,
|
||||
StudentId = participant.StudentId,
|
||||
ExperimentSessionId = participant.SessionId
|
||||
};
|
||||
db.ExperimentGradeRecords.Add(newRecord);
|
||||
recordsToAdd.Add(newRecord);
|
||||
added++;
|
||||
}
|
||||
|
||||
foreach (var record in sheet.Records
|
||||
.Where(x => !expectedByStudent.ContainsKey(x.StudentId))
|
||||
.ToList())
|
||||
{
|
||||
if (record.ParticipationStatus !=
|
||||
ExperimentParticipationStatus.Pending ||
|
||||
record.ItemScores.Any(x => x.Score.HasValue))
|
||||
{
|
||||
retained++;
|
||||
continue;
|
||||
}
|
||||
|
||||
db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
|
||||
recordsToRemove.Add(record);
|
||||
removed++;
|
||||
}
|
||||
|
||||
if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var record in recordsToRemove)
|
||||
sheet.Records.Remove(record);
|
||||
db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
|
||||
}
|
||||
foreach (var record in recordsToAdd)
|
||||
foreach (var item in sheet.Items)
|
||||
record.ItemScores.Add(new ExperimentGradeItemScore
|
||||
{
|
||||
ExperimentGradeItemId = item.Id
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Added = added,
|
||||
Removed = removed,
|
||||
Retained = retained,
|
||||
Total = await db.ExperimentGradeRecords.CountAsync(
|
||||
x => x.ExperimentGradeSheetId == sheet.Id,
|
||||
cancellationToken)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/submit")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Submit(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("只有草稿或已退回实验成绩单可以提交。");
|
||||
if (sheet.Records.Count == 0)
|
||||
return ConflictProblem("实验成绩单没有学生记录。");
|
||||
|
||||
foreach (var record in sheet.Records) Recalculate(sheet, record);
|
||||
if (sheet.Records.Any(x =>
|
||||
x.ParticipationStatus ==
|
||||
ExperimentParticipationStatus.Pending))
|
||||
return ConflictProblem("仍有学生未登记实验参与状态。");
|
||||
if (sheet.Records.Any(x =>
|
||||
x.ParticipationStatus is
|
||||
ExperimentParticipationStatus.Completed or
|
||||
ExperimentParticipationStatus.Makeup &&
|
||||
!x.TotalScore.HasValue))
|
||||
return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Submitted;
|
||||
sheet.SubmittedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.CollegeAdmin,
|
||||
"实验成绩待审核",
|
||||
$"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。",
|
||||
sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
|
||||
"/experiment-grades",
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/approve")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> Approve(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
|
||||
if (!CanReviewCollege(collegeId)) return Forbid();
|
||||
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
|
||||
return ConflictProblem("只有待审核实验成绩单可以通过审核。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Approved;
|
||||
sheet.ReviewedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotifyTeachersAsync(
|
||||
sheet.ExperimentProject.TeachingTaskId,
|
||||
"实验成绩审核通过",
|
||||
$"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/return")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> Return(
|
||||
Guid id,
|
||||
ExperimentGradeReviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
|
||||
if (!CanReviewCollege(collegeId)) return Forbid();
|
||||
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
|
||||
return ConflictProblem("只有待审核实验成绩单可以退回。");
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return ValidationProblem("退回时必须填写修改意见。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Returned;
|
||||
sheet.ReviewedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = request.Comment.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotifyTeachersAsync(
|
||||
sheet.ExperimentProject.TeachingTaskId,
|
||||
"实验成绩被退回",
|
||||
$"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}",
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/publish")]
|
||||
[Authorize(Roles = Publishers)]
|
||||
public async Task<ActionResult> Publish(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!IsPublisher()) return Forbid();
|
||||
if (sheet.Status != ExperimentGradeSheetStatus.Approved)
|
||||
return ConflictProblem("只有审核通过的实验成绩单可以发布。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
var userIds = await db.ExperimentGradeRecords
|
||||
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
||||
.Select(x => x.Student!.UserId)
|
||||
.Where(x => x != null)
|
||||
.Select(x => x!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
await NotificationService.SendToUserIdsAsync(
|
||||
db,
|
||||
userIds,
|
||||
"实验成绩已发布",
|
||||
$"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
|
||||
"/experiment-grades",
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await ScopedSheets().AnyAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken))
|
||||
return null;
|
||||
return await db.ExperimentGradeSheets
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
|
||||
ExperimentProject project,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (project.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized)
|
||||
{
|
||||
return await TeachingTaskRosterQuery
|
||||
.ForTask(db, project.TeachingTaskId)
|
||||
.AsNoTracking()
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.Select(x => new ExperimentParticipantSeed(x.Id, null))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await db.ExperimentBookings.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExperimentProjectId == project.Id &&
|
||||
x.Status == ExperimentBookingStatus.Booked &&
|
||||
x.Student!.Status == StudentStatus.Active)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new ExperimentParticipantSeed(
|
||||
x.StudentId,
|
||||
x.ExperimentSessionId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static List<ExperimentGradeItem> BuildItems(
|
||||
IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
|
||||
requests?.Select((item, index) => new ExperimentGradeItem
|
||||
{
|
||||
Name = item.Name.Trim(),
|
||||
Kind = item.Kind,
|
||||
Weight = item.Weight,
|
||||
SortOrder = index
|
||||
}).ToList() ?? [];
|
||||
|
||||
private static void Recalculate(
|
||||
ExperimentGradeSheet sheet,
|
||||
ExperimentGradeRecord record)
|
||||
{
|
||||
record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
|
||||
record,
|
||||
sheet.Items.ToList());
|
||||
record.IsPassed = record.TotalScore.HasValue
|
||||
? record.TotalScore.Value >= sheet.PassScore &&
|
||||
!record.SafetyViolation
|
||||
: null;
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> AccessibleTeachingTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
var scope = currentUserDataScope.Current;
|
||||
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(item =>
|
||||
item.Teacher!.UserId == scope.UserId));
|
||||
return source.Where(_ => false);
|
||||
}
|
||||
|
||||
private IQueryable<ExperimentProject> ScopedProjects()
|
||||
{
|
||||
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
||||
return db.ExperimentProjects.Where(x =>
|
||||
taskIds.Contains(x.TeachingTaskId));
|
||||
}
|
||||
|
||||
private IQueryable<ExperimentGradeSheet> ScopedSheets()
|
||||
{
|
||||
var projectIds = ScopedProjects().Select(x => x.Id);
|
||||
return db.ExperimentGradeSheets.Where(x =>
|
||||
projectIds.Contains(x.ExperimentProjectId));
|
||||
}
|
||||
|
||||
private bool CanInitialize(TeachingTask task) =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
IsAssignedTeacher(task);
|
||||
|
||||
private bool CanEdit(TeachingTask task) =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
IsAssignedTeacher(task);
|
||||
|
||||
private bool IsAssignedTeacher(TeachingTask task) =>
|
||||
task.Teachers.Any(x =>
|
||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||
|
||||
private bool CanReviewCollege(Guid collegeId)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
|
||||
!scope.IsInRole(SystemRoles.AcademicAdmin) &&
|
||||
!scope.IsInRole(SystemRoles.CollegeAdmin))
|
||||
return false;
|
||||
return scope.Scope == DataScope.All ||
|
||||
scope.RestrictedCollegeId == collegeId;
|
||||
}
|
||||
|
||||
private bool IsPublisher() =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private Task<Student?> CurrentStudentAsync(
|
||||
CancellationToken cancellationToken) =>
|
||||
db.Students.FirstOrDefaultAsync(x =>
|
||||
x.UserId == currentUserDataScope.Current.UserId &&
|
||||
x.Status == StudentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
private async Task NotifyTeachersAsync(
|
||||
Guid teachingTaskId,
|
||||
string title,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userIds = await db.TeachingTaskTeachers
|
||||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||
.Select(x => x.Teacher!.UserId)
|
||||
.Where(x => x != null)
|
||||
.Select(x => x!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
await NotificationService.SendToUserIdsAsync(
|
||||
db,
|
||||
userIds,
|
||||
title,
|
||||
content,
|
||||
"/experiment-grades",
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
}
|
||||
|
||||
private static bool ValidScore(decimal? score) =>
|
||||
!score.HasValue || score.Value is >= 0 and <= 100;
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法完成实验成绩操作",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private sealed record ExperimentParticipantSeed(
|
||||
Guid StudentId,
|
||||
Guid? SessionId);
|
||||
}
|
||||
|
||||
public sealed record ExperimentGradeSheetRequest(
|
||||
Guid ExperimentProjectId,
|
||||
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal PassScore,
|
||||
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
|
||||
|
||||
public sealed record ExperimentGradeSettingsRequest(
|
||||
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal PassScore,
|
||||
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
|
||||
|
||||
public sealed record ExperimentGradeItemRequest(
|
||||
[Required, MaxLength(60)] string Name,
|
||||
ExperimentGradeItemKind Kind,
|
||||
[Range(typeof(decimal), "0.1", "100")] decimal Weight);
|
||||
|
||||
public sealed record ExperimentGradeRecordsRequest(
|
||||
IReadOnlyCollection<ExperimentGradeRecordRequest> Records);
|
||||
|
||||
public sealed record ExperimentGradeRecordRequest(
|
||||
Guid Id,
|
||||
ExperimentParticipationStatus ParticipationStatus,
|
||||
bool SafetyViolation,
|
||||
[Range(1, 20)] int AttemptNumber,
|
||||
[MaxLength(500)] string? SubmissionReference,
|
||||
DateTime? SubmittedAt,
|
||||
bool IsLate,
|
||||
[MaxLength(500)] string? TeacherComment,
|
||||
IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);
|
||||
|
||||
public sealed record ExperimentGradeItemScoreRequest(
|
||||
Guid ExperimentGradeItemId,
|
||||
decimal? Score,
|
||||
[MaxLength(300)] string? Comment);
|
||||
|
||||
public sealed record ExperimentGradeReviewRequest(
|
||||
[MaxLength(500)] string? Comment);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -101,7 +101,9 @@ public sealed class GradesController(
|
||||
{
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Weight
|
||||
item.Weight,
|
||||
item.SourceType,
|
||||
item.SourceSnapshotAt
|
||||
}),
|
||||
StudentCount = sheet.Records.Count,
|
||||
CompletedCount = sheet.Records.Count(record =>
|
||||
@@ -220,7 +222,9 @@ public sealed class GradesController(
|
||||
{
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Weight
|
||||
item.Weight,
|
||||
item.SourceType,
|
||||
item.SourceSnapshotAt
|
||||
}),
|
||||
x.Status,
|
||||
x.ReviewComment,
|
||||
@@ -408,7 +412,7 @@ public sealed class GradesController(
|
||||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||
return ValidationProblem("包含无效或重复的成绩记录。");
|
||||
|
||||
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
|
||||
var itemsById = sheet.Items.ToDictionary(item => item.Id);
|
||||
foreach (var item in request.Records)
|
||||
{
|
||||
if (!ValidScore(item.RegularScore) ||
|
||||
@@ -428,8 +432,19 @@ public sealed class GradesController(
|
||||
{
|
||||
if (!ValidScore(scoreEntry.Score))
|
||||
return ValidationProblem("分项成绩必须在 0—100 分之间。");
|
||||
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
|
||||
existing.Score = scoreEntry.Score;
|
||||
if (!itemsById.TryGetValue(
|
||||
scoreEntry.GradeItemId,
|
||||
out var gradeItem) ||
|
||||
!scoreMap.TryGetValue(
|
||||
scoreEntry.GradeItemId,
|
||||
out var existing))
|
||||
continue;
|
||||
if (gradeItem.SourceType ==
|
||||
GradeItemSourceType.ExperimentSummary &&
|
||||
existing.Score != scoreEntry.Score)
|
||||
return ConflictProblem(
|
||||
$"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。");
|
||||
existing.Score = scoreEntry.Score;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +453,66 @@ public sealed class GradesController(
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> ImportExperimentScores(
|
||||
Guid id,
|
||||
ImportExperimentScoresRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
GradeSheetStatus.Draft or
|
||||
GradeSheetStatus.Returned))
|
||||
return ConflictProblem("成绩单提交后不能导入实验成绩。");
|
||||
|
||||
var targetItem = sheet.Items.FirstOrDefault(x =>
|
||||
x.Id == request.GradeItemId);
|
||||
if (targetItem is null)
|
||||
return ValidationProblem("请选择当前成绩单中的有效分项。");
|
||||
|
||||
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
|
||||
db,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.Records.Select(x => x.StudentId).ToArray(),
|
||||
cancellationToken);
|
||||
if (aggregate.PublishedProjectCount == 0)
|
||||
return ConflictProblem(
|
||||
"该教学任务尚无已发布的实验项目成绩,无法导入。");
|
||||
|
||||
var imported = 0;
|
||||
var missing = 0;
|
||||
foreach (var record in sheet.Records)
|
||||
{
|
||||
var targetScore = record.ItemScores.First(x =>
|
||||
x.GradeItemId == targetItem.Id);
|
||||
targetScore.Score = aggregate.Scores[record.StudentId];
|
||||
if (targetScore.Score.HasValue) imported++;
|
||||
else missing++;
|
||||
Recalculate(sheet, record);
|
||||
}
|
||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
aggregate.PublishedProjectCount,
|
||||
aggregate.Projects,
|
||||
ImportedCount = imported,
|
||||
MissingCount = missing,
|
||||
targetItem.SourceSnapshotAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/submit")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||||
@@ -998,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
|
||||
Guid GradeItemId,
|
||||
decimal? Score);
|
||||
|
||||
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
|
||||
|
||||
public sealed record GradeReviewRequest(
|
||||
[MaxLength(500)] string? Comment);
|
||||
|
||||
@@ -18,8 +18,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class MakeupExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
MakeupExamArrangementService arrangementService) : ControllerBase
|
||||
MakeupExamEligibilityService eligibilityService) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
@@ -111,6 +110,7 @@ public sealed class MakeupExamsController(
|
||||
item.StartsAt,
|
||||
item.EndsAt,
|
||||
item.RequiredBuildingId,
|
||||
item.RequiredBuildingIds,
|
||||
RequiredBuildingName = item.RequiredBuilding != null
|
||||
? item.RequiredBuilding.Name : null,
|
||||
item.RequiredInvigilatorCount,
|
||||
@@ -138,38 +138,90 @@ public sealed class MakeupExamsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Enrollments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
|
||||
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
|
||||
|
||||
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Status,
|
||||
HasSessions = x.Sessions.Any()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿补考计划可以发布。");
|
||||
if (plan.Sessions.Count == 0)
|
||||
if (!plan.HasSessions)
|
||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||
|
||||
var unassigned = plan.Sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
||||
if (unassigned > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var job = new ExamPublishJob
|
||||
{
|
||||
Kind = ExamPublishJobKind.MakeupExam,
|
||||
PlanId = id,
|
||||
ActivePlanId = id,
|
||||
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||
CurrentStep = "等待后台校验"
|
||||
};
|
||||
db.ExamPublishJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.ExamPublish,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var existing = await FindActivePublishJobAsync(id, cancellationToken);
|
||||
if (existing is not null)
|
||||
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
|
||||
throw;
|
||||
}
|
||||
|
||||
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
|
||||
if (empty > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {empty} 个场次没有登记补考学生。");
|
||||
return AcceptedPublishJob(job, "补考发布任务已提交。");
|
||||
}
|
||||
|
||||
plan.Status = MakeupExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
[HttpGet("publish-jobs/{jobId:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetPublishJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId &&
|
||||
x.Kind == ExamPublishJobKind.MakeupExam,
|
||||
cancellationToken);
|
||||
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/publish-job")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetLatestPublishJob(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.PlanId == planId &&
|
||||
x.Kind == ExamPublishJobKind.MakeupExam)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpPost("plans/{id:guid}/archive")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
|
||||
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Published)
|
||||
@@ -189,6 +241,8 @@ public sealed class MakeupExamsController(
|
||||
CreateMakeupExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
@@ -215,6 +269,7 @@ public sealed class MakeupExamsController(
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredBuildingId = request.RequiredBuildingId,
|
||||
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||
Notes = Normalize(request.Notes),
|
||||
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||
@@ -231,6 +286,8 @@ public sealed class MakeupExamsController(
|
||||
CreateMakeupExamSessionsBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
@@ -281,6 +338,7 @@ public sealed class MakeupExamsController(
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredBuildingId = request.RequiredBuildingId,
|
||||
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||
Notes = Normalize(request.Notes)
|
||||
}));
|
||||
@@ -304,6 +362,8 @@ public sealed class MakeupExamsController(
|
||||
CreateMakeupExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var session = await db.MakeupExamSessions
|
||||
.Include(x => x.MakeupExamPlan)
|
||||
.Include(x => x.Invigilators)
|
||||
@@ -330,6 +390,7 @@ public sealed class MakeupExamsController(
|
||||
session.StartsAt = startsAt;
|
||||
session.EndsAt = endsAt;
|
||||
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
|
||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||
session.Notes = Normalize(request.Notes);
|
||||
|
||||
@@ -347,6 +408,8 @@ public sealed class MakeupExamsController(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
@@ -368,15 +431,117 @@ public sealed class MakeupExamsController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
request ??= new ExamAutoArrangeRequest();
|
||||
var result = await arrangementService.ArrangeAsync(
|
||||
if (!request.AssignClassrooms && !request.AssignInvigilators)
|
||||
return ValidationProblem("请至少选择分配考场或分配监考教师。");
|
||||
|
||||
var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
|
||||
if (sessionIds.Length > 100)
|
||||
return ValidationProblem("一次最多处理100个补考场次。");
|
||||
|
||||
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == planId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Status,
|
||||
TotalSessions = x.Sessions.Count
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null)
|
||||
return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
|
||||
if (sessionIds.Length > 0)
|
||||
{
|
||||
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == planId)
|
||||
.WhereIn(sessionIds, x => x.Id)
|
||||
.CountAsync(cancellationToken);
|
||||
if (selectedSessionCount != sessionIds.Length)
|
||||
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
|
||||
}
|
||||
|
||||
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x => x.MakeupExamPlanId == planId &&
|
||||
(x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running),
|
||||
cancellationToken);
|
||||
if (autoCreateActive)
|
||||
return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
|
||||
|
||||
var existing = await FindActiveArrangementJobAsync(
|
||||
planId,
|
||||
request.SessionIds,
|
||||
request.AssignClassrooms,
|
||||
request.AssignInvigilators,
|
||||
cancellationToken);
|
||||
if (!result.Success)
|
||||
return ConflictProblem(result.Message);
|
||||
return Ok(new { message = result.Message });
|
||||
if (existing is not null)
|
||||
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
|
||||
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var job = new ExamArrangementJob
|
||||
{
|
||||
Kind = ExamArrangementKind.MakeupExam,
|
||||
PlanId = planId,
|
||||
ActivePlanId = planId,
|
||||
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||
SessionIdsJson = sessionIds.Length == 0
|
||||
? null
|
||||
: System.Text.Json.JsonSerializer.Serialize(sessionIds),
|
||||
AssignClassrooms = request.AssignClassrooms,
|
||||
AssignInvigilators = request.AssignInvigilators,
|
||||
TotalSessions = sessionIds.Length == 0
|
||||
? plan.TotalSessions
|
||||
: sessionIds.Length,
|
||||
CurrentStep = "等待后台编排"
|
||||
};
|
||||
db.ExamArrangementJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
existing = await FindActiveArrangementJobAsync(
|
||||
planId,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
|
||||
throw;
|
||||
}
|
||||
|
||||
return AcceptedArrangementJob(job, "补考编排任务已提交。");
|
||||
}
|
||||
|
||||
[HttpGet("arrangement-jobs/{jobId:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetArrangementJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamArrangementJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId &&
|
||||
x.Kind == ExamArrangementKind.MakeupExam,
|
||||
cancellationToken);
|
||||
return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/arrangement-job")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetLatestArrangementJob(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.PlanId == planId &&
|
||||
x.Kind == ExamArrangementKind.MakeupExam)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -394,6 +559,8 @@ public sealed class MakeupExamsController(
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
|
||||
|
||||
// Check for existing active job
|
||||
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
@@ -1042,6 +1209,87 @@ public sealed class MakeupExamsController(
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Kind == ExamArrangementKind.MakeupExam &&
|
||||
x.ActivePlanId == planId &&
|
||||
(x.Status == ExamArrangementJobStatus.Queued ||
|
||||
x.Status == ExamArrangementJobStatus.Running))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
private ActionResult AcceptedArrangementJob(
|
||||
ExamArrangementJob job,
|
||||
string message) =>
|
||||
AcceptedAtAction(
|
||||
nameof(GetArrangementJob),
|
||||
new { jobId = job.Id },
|
||||
new
|
||||
{
|
||||
jobId = job.Id,
|
||||
status = job.Status.ToString(),
|
||||
message
|
||||
});
|
||||
|
||||
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
|
||||
{
|
||||
job.Id,
|
||||
job.PlanId,
|
||||
Kind = job.Kind.ToString(),
|
||||
Status = job.Status.ToString(),
|
||||
job.TotalSessions,
|
||||
job.ProcessedSessions,
|
||||
job.CurrentStep,
|
||||
job.ResultMessage,
|
||||
job.ErrorMessage,
|
||||
job.AssignClassrooms,
|
||||
job.AssignInvigilators,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt
|
||||
};
|
||||
|
||||
private Task<ExamPublishJob?> FindActivePublishJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Kind == ExamPublishJobKind.MakeupExam &&
|
||||
x.ActivePlanId == planId &&
|
||||
(x.Status == ExamPublishJobStatus.Queued ||
|
||||
x.Status == ExamPublishJobStatus.Running))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
private ActionResult AcceptedPublishJob(
|
||||
ExamPublishJob job,
|
||||
string message) =>
|
||||
AcceptedAtAction(
|
||||
nameof(GetPublishJob),
|
||||
new { jobId = job.Id },
|
||||
new
|
||||
{
|
||||
jobId = job.Id,
|
||||
status = job.Status.ToString(),
|
||||
message
|
||||
});
|
||||
|
||||
private static object ToPublishJobResponse(ExamPublishJob job) => new
|
||||
{
|
||||
job.Id,
|
||||
job.PlanId,
|
||||
Kind = job.Kind.ToString(),
|
||||
Status = job.Status.ToString(),
|
||||
job.CurrentStep,
|
||||
job.ErrorMessage,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt
|
||||
};
|
||||
|
||||
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||
CancellationToken token)
|
||||
{
|
||||
@@ -1064,6 +1312,10 @@ public sealed class MakeupExamsController(
|
||||
});
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
|
||||
ids is { Count: > 0 }
|
||||
? System.Text.Json.JsonSerializer.Serialize(ids)
|
||||
: null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -1082,6 +1334,7 @@ public sealed record CreateMakeupExamSessionRequest(
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
Guid? RequiredBuildingId,
|
||||
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||
[MaxLength(500)] string? Notes);
|
||||
@@ -1092,6 +1345,7 @@ public sealed record CreateMakeupExamSessionsBatchRequest(
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
Guid? RequiredBuildingId,
|
||||
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -14,8 +17,11 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/notifications")]
|
||||
public sealed class NotificationsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
ILogger<NotificationsController>? logger = null) : ControllerBase
|
||||
{
|
||||
private const int MaximumSelectedRecipients = 500;
|
||||
private const int NotificationBatchSize = 300;
|
||||
private const string Senders =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
@@ -106,17 +112,12 @@ public sealed class NotificationsController(
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
var recipientCount = await db.Users.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.IsEnabled && x.Id != scope.UserId,
|
||||
cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AudienceType = MessageAudienceType.School,
|
||||
AudienceName = "全校已启用账号",
|
||||
RecipientCount = recipientCount,
|
||||
TeachingTasks = Array.Empty<object>()
|
||||
});
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
null,
|
||||
MessageAudienceType.School,
|
||||
"全校已启用账号",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
@@ -138,19 +139,12 @@ public sealed class NotificationsController(
|
||||
if (college is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
var recipientCount = await db.Users.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.IsEnabled &&
|
||||
x.Id != scope.UserId &&
|
||||
x.CollegeId == collegeId.Value,
|
||||
cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AudienceType = MessageAudienceType.College,
|
||||
AudienceName = $"{college}全院成员",
|
||||
RecipientCount = recipientCount,
|
||||
TeachingTasks = Array.Empty<object>()
|
||||
});
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
collegeId.Value,
|
||||
MessageAudienceType.College,
|
||||
$"{college}全院成员",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
@@ -195,6 +189,114 @@ public sealed class NotificationsController(
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
[HttpGet("recipients")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> GetRecipients(
|
||||
Guid? collegeId,
|
||||
string? role,
|
||||
Guid? administrativeClassId,
|
||||
Guid? teachingTaskId,
|
||||
string? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 30,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (scope.IsInRole(SystemRoles.Teacher) &&
|
||||
!IsSchoolAdministrator(scope) &&
|
||||
!scope.IsInRole(SystemRoles.CollegeAdmin) &&
|
||||
!scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var resolvedCollegeId = IsSchoolAdministrator(scope)
|
||||
? null
|
||||
: await ResolveSenderCollegeIdAsync(scope, cancellationToken);
|
||||
if (!IsSchoolAdministrator(scope) && !resolvedCollegeId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学院,暂时无法确定收件人范围。");
|
||||
|
||||
var filter = new MessageRecipientFilter(
|
||||
collegeId,
|
||||
Normalize(role),
|
||||
administrativeClassId,
|
||||
teachingTaskId,
|
||||
Normalize(keyword));
|
||||
if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role))
|
||||
return ValidationProblem("所选身份类型无效。");
|
||||
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var source = ApplyRecipientFilter(
|
||||
ScopedRecipientQuery(scope, resolvedCollegeId),
|
||||
filter);
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var users = await source
|
||||
.OrderBy(x => x.DisplayName)
|
||||
.ThenBy(x => x.UserName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(user => new
|
||||
{
|
||||
user.Id,
|
||||
user.DisplayName,
|
||||
user.UserName,
|
||||
user.StaffNumber,
|
||||
CollegeName = db.Colleges
|
||||
.Where(college => college.Id == user.CollegeId)
|
||||
.Select(college => college.Name)
|
||||
.FirstOrDefault(),
|
||||
StudentNumber = db.Students
|
||||
.Where(student => student.UserId == user.Id)
|
||||
.Select(student => student.StudentNumber)
|
||||
.FirstOrDefault(),
|
||||
ClassName = db.Students
|
||||
.Where(student => student.UserId == user.Id)
|
||||
.Select(student => student.AdministrativeClass!.Name)
|
||||
.FirstOrDefault(),
|
||||
TeacherNumber = db.Teachers
|
||||
.Where(teacher => teacher.UserId == user.Id)
|
||||
.Select(teacher => teacher.TeacherNumber)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var userIds = users.Select(x => x.Id).ToArray();
|
||||
var roleRows = await db.UserRoles.AsNoTracking()
|
||||
.Where(x => userIds.Contains(x.UserId))
|
||||
.Join(
|
||||
db.Roles.AsNoTracking(),
|
||||
userRole => userRole.RoleId,
|
||||
roleEntity => roleEntity.Id,
|
||||
(userRole, roleEntity) => new
|
||||
{
|
||||
userRole.UserId,
|
||||
RoleName = roleEntity.Name!
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var rolesByUser = roleRows
|
||||
.GroupBy(x => x.UserId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(x => x.RoleName).Distinct().ToArray());
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = users.Select(user => new
|
||||
{
|
||||
user.Id,
|
||||
user.DisplayName,
|
||||
user.UserName,
|
||||
Number = user.StudentNumber ?? user.TeacherNumber ?? user.StaffNumber,
|
||||
user.CollegeName,
|
||||
user.ClassName,
|
||||
Roles = rolesByUser.GetValueOrDefault(user.Id, [])
|
||||
}),
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> Send(
|
||||
@@ -205,8 +307,10 @@ public sealed class NotificationsController(
|
||||
var content = request.Content.Trim();
|
||||
if (title.Length == 0)
|
||||
return ValidationProblem("请填写消息标题。");
|
||||
if (content.Length == 0)
|
||||
if (PlainText(content).Length == 0)
|
||||
return ValidationProblem("请填写消息正文。");
|
||||
if (content.Length > 20000)
|
||||
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
|
||||
var scope = currentUserDataScope.Current;
|
||||
MessageAudienceType audienceType;
|
||||
Guid? audienceId = null;
|
||||
@@ -215,12 +319,16 @@ public sealed class NotificationsController(
|
||||
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
audienceType = MessageAudienceType.School;
|
||||
audienceName = "全校已启用账号";
|
||||
recipientIds = await db.Users.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Id != scope.UserId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var result = await ResolveAdministratorRecipientsAsync(
|
||||
scope,
|
||||
null,
|
||||
request,
|
||||
"全校已启用账号",
|
||||
cancellationToken);
|
||||
if (result.Error is not null) return result.Error;
|
||||
audienceType = result.AudienceType;
|
||||
audienceName = result.AudienceName!;
|
||||
recipientIds = result.RecipientIds!;
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
scope.IsInRole(SystemRoles.Counselor))
|
||||
@@ -241,16 +349,19 @@ public sealed class NotificationsController(
|
||||
if (collegeName is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
audienceType = MessageAudienceType.College;
|
||||
audienceId = collegeId.Value;
|
||||
audienceName = $"{collegeName}全院成员";
|
||||
recipientIds = await db.Users.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.Id != scope.UserId &&
|
||||
x.CollegeId == collegeId.Value)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var result = await ResolveAdministratorRecipientsAsync(
|
||||
scope,
|
||||
collegeId.Value,
|
||||
request,
|
||||
$"{collegeName}全院成员",
|
||||
cancellationToken);
|
||||
if (result.Error is not null) return result.Error;
|
||||
audienceType = result.AudienceType;
|
||||
audienceId = audienceType == MessageAudienceType.College
|
||||
? collegeId.Value
|
||||
: null;
|
||||
audienceName = result.AudienceName!;
|
||||
recipientIds = result.RecipientIds!;
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
@@ -305,24 +416,54 @@ public sealed class NotificationsController(
|
||||
RecipientCount = recipientIds.Count,
|
||||
LinkUrl = null
|
||||
};
|
||||
dispatch.Notifications = recipientIds.Select(userId => new Notification
|
||||
try
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = NotificationCategory.General,
|
||||
LinkUrl = null
|
||||
}).ToList();
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.MessageDispatches.Add(dispatch);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
db.MessageDispatches.Add(dispatch);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
foreach (var batch in recipientIds.Chunk(NotificationBatchSize))
|
||||
{
|
||||
db.Notifications.AddRange(batch.Select(userId => new Notification
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = NotificationCategory.General,
|
||||
LinkUrl = null,
|
||||
MessageDispatchId = dispatch.Id
|
||||
}));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.AudienceName,
|
||||
dispatch.RecipientCount,
|
||||
dispatch.CreatedAt
|
||||
});
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.ReadCommitted);
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.AudienceName,
|
||||
dispatch.RecipientCount,
|
||||
dispatch.CreatedAt
|
||||
});
|
||||
logger?.LogError(
|
||||
exception,
|
||||
"Failed to send notification dispatch {DispatchId} to {RecipientCount} recipients.",
|
||||
dispatch.Id,
|
||||
recipientIds.Count);
|
||||
return Problem(
|
||||
title: "消息未能发送",
|
||||
detail: "消息数据保存失败。请确认数据库已完成最新升级后重试;本次消息没有发送。",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("sent")]
|
||||
@@ -433,6 +574,286 @@ public sealed class NotificationsController(
|
||||
scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
scope.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private async Task<object> BuildAdministratorComposerAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? fixedCollegeId,
|
||||
MessageAudienceType audienceType,
|
||||
string audienceName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => !fixedCollegeId.HasValue || x.Id == fixedCollegeId.Value)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var administrativeClasses = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
(!fixedCollegeId.HasValue ||
|
||||
x.Major!.CollegeId == fixedCollegeId.Value))
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
CollegeId = x.Major!.CollegeId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var teachingTasks = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status != TeachingTaskStatus.Draft &&
|
||||
(!fixedCollegeId.HasValue ||
|
||||
x.Course!.CollegeId == fixedCollegeId.Value))
|
||||
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
CourseName = x.Course!.Name,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
CollegeId = x.Course.CollegeId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var recipientCount = await ScopedRecipientQuery(scope, fixedCollegeId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
return new
|
||||
{
|
||||
AudienceType = audienceType,
|
||||
AudienceName = audienceName,
|
||||
RecipientCount = recipientCount,
|
||||
CanFilterRecipients = true,
|
||||
MaximumSelectedRecipients,
|
||||
Colleges = colleges,
|
||||
Roles = RoleOptions,
|
||||
AdministrativeClasses = administrativeClasses,
|
||||
TeachingTasks = teachingTasks
|
||||
};
|
||||
}
|
||||
|
||||
private IQueryable<ApplicationUser> ScopedRecipientQuery(
|
||||
CurrentUserScope scope,
|
||||
Guid? fixedCollegeId)
|
||||
{
|
||||
var source = db.Users.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Id != scope.UserId);
|
||||
if (IsSchoolAdministrator(scope)) return source;
|
||||
if (!fixedCollegeId.HasValue) return source.Where(_ => false);
|
||||
var collegeId = fixedCollegeId.Value;
|
||||
return source.Where(user =>
|
||||
user.CollegeId == collegeId ||
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
|
||||
db.Teachers.Any(teacher =>
|
||||
teacher.UserId == user.Id &&
|
||||
teacher.CollegeId == collegeId));
|
||||
}
|
||||
|
||||
private IQueryable<ApplicationUser> ApplyRecipientFilter(
|
||||
IQueryable<ApplicationUser> source,
|
||||
MessageRecipientFilter? filter)
|
||||
{
|
||||
if (filter is null) return source;
|
||||
if (filter.CollegeId.HasValue)
|
||||
{
|
||||
var collegeId = filter.CollegeId.Value;
|
||||
source = source.Where(user =>
|
||||
user.CollegeId == collegeId ||
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
|
||||
db.Teachers.Any(teacher =>
|
||||
teacher.UserId == user.Id &&
|
||||
teacher.CollegeId == collegeId));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(filter.Role))
|
||||
{
|
||||
var role = filter.Role;
|
||||
source = source.Where(user =>
|
||||
db.UserRoles.Any(userRole =>
|
||||
userRole.UserId == user.Id &&
|
||||
db.Roles.Any(roleEntity =>
|
||||
roleEntity.Id == userRole.RoleId &&
|
||||
roleEntity.Name == role)));
|
||||
}
|
||||
if (filter.AdministrativeClassId.HasValue)
|
||||
{
|
||||
var classId = filter.AdministrativeClassId.Value;
|
||||
source = source.Where(user =>
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
student.AdministrativeClassId == classId));
|
||||
}
|
||||
if (filter.TeachingTaskId.HasValue)
|
||||
{
|
||||
var taskId = filter.TeachingTaskId.Value;
|
||||
source = source.Where(user =>
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
(db.TeachingTaskClasses.Any(item =>
|
||||
item.TeachingTaskId == taskId &&
|
||||
item.AdministrativeClassId ==
|
||||
student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
taskId))));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword;
|
||||
source = source.Where(user =>
|
||||
user.DisplayName.Contains(keyword) ||
|
||||
(user.UserName != null && user.UserName.Contains(keyword)) ||
|
||||
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
(student.Name.Contains(keyword) ||
|
||||
student.StudentNumber.Contains(keyword))) ||
|
||||
db.Teachers.Any(teacher =>
|
||||
teacher.UserId == user.Id &&
|
||||
(teacher.Name.Contains(keyword) ||
|
||||
teacher.TeacherNumber.Contains(keyword))));
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
private async Task<RecipientResolution> ResolveAdministratorRecipientsAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? fixedCollegeId,
|
||||
SendMessageRequest request,
|
||||
string defaultAudienceName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = ScopedRecipientQuery(scope, fixedCollegeId);
|
||||
if (request.RecipientMode == MessageRecipientMode.Scope)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
fixedCollegeId.HasValue
|
||||
? MessageAudienceType.College
|
||||
: MessageAudienceType.School,
|
||||
defaultAudienceName,
|
||||
await source.Select(x => x.Id).ToListAsync(cancellationToken),
|
||||
null);
|
||||
}
|
||||
|
||||
if (request.RecipientMode == MessageRecipientMode.Filtered)
|
||||
{
|
||||
if (request.RecipientFilter?.Role is not null &&
|
||||
!SystemRoles.All.Contains(request.RecipientFilter.Role))
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("所选身份类型无效。"));
|
||||
}
|
||||
var recipientIds = await ApplyRecipientFilter(
|
||||
source,
|
||||
request.RecipientFilter)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
return new RecipientResolution(
|
||||
MessageAudienceType.Custom,
|
||||
BuildFilteredAudienceName(request.RecipientFilter),
|
||||
recipientIds,
|
||||
null);
|
||||
}
|
||||
|
||||
if (request.RecipientMode == MessageRecipientMode.Selected)
|
||||
{
|
||||
var requestedIds = request.RecipientUserIds?
|
||||
.Distinct()
|
||||
.ToArray() ?? [];
|
||||
if (requestedIds.Length == 0)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("请至少选择一名收件人。"));
|
||||
}
|
||||
if (requestedIds.Length > MaximumSelectedRecipients)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem(
|
||||
$"单次最多指定 {MaximumSelectedRecipients} 名收件人。"));
|
||||
}
|
||||
|
||||
var authorizedIds = await source
|
||||
.Where(x => requestedIds.Contains(x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (authorizedIds.Count != requestedIds.Length)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("所选收件人包含无权限访问或已停用的账号。"));
|
||||
}
|
||||
|
||||
return new RecipientResolution(
|
||||
MessageAudienceType.Custom,
|
||||
$"指定收件人({authorizedIds.Count} 人)",
|
||||
authorizedIds,
|
||||
null);
|
||||
}
|
||||
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("请选择有效的收件方式。"));
|
||||
}
|
||||
|
||||
private static string BuildFilteredAudienceName(MessageRecipientFilter? filter)
|
||||
{
|
||||
if (filter is null) return "当前权限范围内全部账号";
|
||||
var parts = new List<string>();
|
||||
if (filter.CollegeId.HasValue) parts.Add("指定学院");
|
||||
if (filter.Role is not null)
|
||||
parts.Add(RoleOptions.FirstOrDefault(x => x.Value == filter.Role)?.Label ??
|
||||
filter.Role);
|
||||
if (filter.AdministrativeClassId.HasValue) parts.Add("指定行政班");
|
||||
if (filter.TeachingTaskId.HasValue) parts.Add("指定教学班");
|
||||
if (filter.Keyword is not null) parts.Add($"关键词“{filter.Keyword}”");
|
||||
return parts.Count == 0
|
||||
? "当前权限范围内全部账号"
|
||||
: string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string PlainText(string html)
|
||||
{
|
||||
var withoutTags = Regex.Replace(html, "<[^>]+>", " ");
|
||||
return WebUtility.HtmlDecode(withoutTags).Trim();
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static readonly RoleOption[] RoleOptions =
|
||||
[
|
||||
new(SystemRoles.Student, "学生"),
|
||||
new(SystemRoles.Teacher, "任课教师"),
|
||||
new(SystemRoles.Counselor, "辅导员"),
|
||||
new(SystemRoles.CollegeAdmin, "学院管理员"),
|
||||
new(SystemRoles.AcademicAdmin, "校级教务管理员"),
|
||||
new(SystemRoles.Leader, "校领导"),
|
||||
new(SystemRoles.SuperAdmin, "超级管理员")
|
||||
];
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
@@ -451,9 +872,34 @@ public sealed class NotificationsController(
|
||||
}
|
||||
|
||||
public sealed record SendMessageRequest(
|
||||
[property: Required, StringLength(200)] string Title,
|
||||
[property: Required, StringLength(1000)] string Content,
|
||||
Guid? TeachingTaskId = null);
|
||||
[Required, StringLength(200)] string Title,
|
||||
[Required, StringLength(20000)] string Content,
|
||||
Guid? TeachingTaskId = null,
|
||||
MessageRecipientMode RecipientMode = MessageRecipientMode.Scope,
|
||||
MessageRecipientFilter? RecipientFilter = null,
|
||||
IReadOnlyCollection<Guid>? RecipientUserIds = null);
|
||||
|
||||
public sealed record MessageRecipientFilter(
|
||||
Guid? CollegeId = null,
|
||||
string? Role = null,
|
||||
Guid? AdministrativeClassId = null,
|
||||
Guid? TeachingTaskId = null,
|
||||
string? Keyword = null);
|
||||
|
||||
public enum MessageRecipientMode
|
||||
{
|
||||
Scope = 1,
|
||||
Filtered = 2,
|
||||
Selected = 3
|
||||
}
|
||||
|
||||
public sealed record RoleOption(string Value, string Label);
|
||||
|
||||
internal sealed record RecipientResolution(
|
||||
MessageAudienceType AudienceType,
|
||||
string? AudienceName,
|
||||
List<Guid>? RecipientIds,
|
||||
ActionResult? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Centralized helper to send notifications across the app.
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
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;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[Route("api/operations")]
|
||||
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)
|
||||
{
|
||||
var since = DateTime.UtcNow.AddHours(-24);
|
||||
var health = await healthService.CheckAsync(cancellationToken);
|
||||
var backups = await backupService.ListAsync(cancellationToken);
|
||||
var auditCount = await db.AuditLogs.AsNoTracking()
|
||||
.CountAsync(x => x.CreatedAt >= since, cancellationToken);
|
||||
var serverErrorCount = await db.AuditLogs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.CreatedAt >= since && x.StatusCode >= 500,
|
||||
cancellationToken);
|
||||
var failedJobCount = await CountFailedJobsAsync(
|
||||
DateTime.UtcNow.AddDays(-7),
|
||||
cancellationToken);
|
||||
var alerts = await BuildAlertsAsync(
|
||||
health,
|
||||
backups,
|
||||
serverErrorCount,
|
||||
failedJobCount,
|
||||
cancellationToken);
|
||||
return Ok(new OperationsSummary(
|
||||
DateTime.UtcNow,
|
||||
health,
|
||||
new OperationsCounters(
|
||||
auditCount,
|
||||
serverErrorCount,
|
||||
failedJobCount,
|
||||
alerts.Count(x => x.Severity == "critical")),
|
||||
alerts,
|
||||
backups.FirstOrDefault()));
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
public async Task<ActionResult<OperationalHealthSnapshot>> GetHealth(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await healthService.CheckAsync(cancellationToken));
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? method = null,
|
||||
[FromQuery] int? statusCode = null,
|
||||
[FromQuery] string? userName = null,
|
||||
[FromQuery] string? path = null,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagingError = ValidatePaging(page, pageSize);
|
||||
if (pagingError is not null) return pagingError;
|
||||
var rangeError = ValidateRange(from, to);
|
||||
if (rangeError is not null) return rangeError;
|
||||
|
||||
var query = db.AuditLogs.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(method))
|
||||
{
|
||||
var normalizedMethod = method.Trim().ToUpperInvariant();
|
||||
query = query.Where(x => x.Method == normalizedMethod);
|
||||
}
|
||||
if (statusCode.HasValue)
|
||||
query = query.Where(x => x.StatusCode == statusCode.Value);
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
var normalizedUser = userName.Trim();
|
||||
query = query.Where(x =>
|
||||
x.UserName != null && x.UserName.Contains(normalizedUser));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
var normalizedPath = path.Trim();
|
||||
query = query.Where(x => x.Path.Contains(normalizedPath));
|
||||
}
|
||||
query = query.Where(x =>
|
||||
x.CreatedAt >= (from ?? DateTime.UtcNow.AddDays(-1)));
|
||||
if (to.HasValue)
|
||||
query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new AuditLogItem(
|
||||
x.Id,
|
||||
x.UserName,
|
||||
x.Method,
|
||||
x.Path,
|
||||
x.StatusCode,
|
||||
x.IpAddress,
|
||||
x.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<AuditLogItem>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("failed-jobs")]
|
||||
public async Task<ActionResult<PagedResult<FailedBackgroundJobItem>>>
|
||||
GetFailedJobs(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? kind = null,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagingError = ValidatePaging(page, pageSize);
|
||||
if (pagingError is not null) return pagingError;
|
||||
var rangeError = ValidateRange(from, to);
|
||||
if (rangeError is not null) return rangeError;
|
||||
|
||||
var normalizedKind = NormalizeJobKind(kind);
|
||||
if (kind is not null && normalizedKind is null)
|
||||
return ValidationProblem("后台任务类型无效。");
|
||||
|
||||
var effectiveFrom = from ?? DateTime.UtcNow.AddDays(-30);
|
||||
var take = checked(page * pageSize);
|
||||
var rows = new List<FailedBackgroundJobItem>();
|
||||
var total = 0;
|
||||
|
||||
if (normalizedKind is null or "AutomaticSchedule")
|
||||
{
|
||||
var query = db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == AutomaticScheduleJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"AutomaticSchedule",
|
||||
"自动排课",
|
||||
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "SchedulePublish")
|
||||
{
|
||||
var query = db.SchedulePublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == SchedulePublishJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"SchedulePublish",
|
||||
"课表发布",
|
||||
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "MakeupExamAuto")
|
||||
{
|
||||
var query = db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == MakeupExamAutoJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"MakeupExamAuto",
|
||||
"补考自动安排",
|
||||
x.MakeupExamPlan == null ? "补考计划" : x.MakeupExamPlan.Name,
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "ExamArrangement")
|
||||
{
|
||||
var query = db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExamArrangementJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"ExamArrangement",
|
||||
x.Kind == ExamArrangementKind.FormalExam
|
||||
? "正式考试编排"
|
||||
: "补考编排",
|
||||
x.Kind == ExamArrangementKind.FormalExam
|
||||
? "正式考试计划"
|
||||
: "补考计划",
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "ExamPublish")
|
||||
{
|
||||
var query = db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExamPublishJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"ExamPublish",
|
||||
x.Kind == ExamPublishJobKind.FormalExam
|
||||
? "正式考试发布"
|
||||
: "补考发布",
|
||||
x.Kind == ExamPublishJobKind.FormalExam
|
||||
? "正式考试计划"
|
||||
: "补考计划",
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
var pageItems = rows
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToArray();
|
||||
if (pageItems.Length > 0)
|
||||
{
|
||||
var ids = pageItems.Select(x => x.Id).ToArray();
|
||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.JobId))
|
||||
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
||||
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
||||
cancellationToken);
|
||||
pageItems = pageItems
|
||||
.Select(x => x with
|
||||
{
|
||||
ProcessingAttempts = attempts.GetValueOrDefault(x.Id)
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return Ok(new PagedResult<FailedBackgroundJobItem>(
|
||||
pageItems,
|
||||
total,
|
||||
page,
|
||||
pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("backups")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackupArtifact>>> GetBackups(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await backupService.ListAsync(cancellationToken));
|
||||
|
||||
[HttpPost("backups")]
|
||||
public async Task<ActionResult<BackupArtifact>> CreateBackup(
|
||||
CreateBackupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var artifact = await backupService.CreateAsync(
|
||||
request.Note,
|
||||
cancellationToken);
|
||||
return CreatedAtAction(
|
||||
nameof(GetBackups),
|
||||
new { id = artifact.Id },
|
||||
artifact);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
return Problem(
|
||||
title: "数据库备份失败",
|
||||
detail: SafeMessage(exception),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("backups/{backupId}/restore-drill")]
|
||||
public async Task<ActionResult<RestoreDrillResult>> RunRestoreDrill(
|
||||
string backupId,
|
||||
RestoreDrillRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.Confirmation.Equals(
|
||||
"RESTORE_DRILL",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return ValidationProblem(
|
||||
"恢复演练必须明确确认,且不会覆盖当前业务数据库。");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Ok(await backupService.RunRestoreDrillAsync(
|
||||
backupId,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "备份不存在",
|
||||
Detail = "指定备份不存在或其文件已被移除。",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<OperationalAlert>> BuildAlertsAsync(
|
||||
OperationalHealthSnapshot health,
|
||||
IReadOnlyCollection<BackupArtifact> backups,
|
||||
int serverErrorCount,
|
||||
int failedJobCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var alerts = health.Components
|
||||
.Where(x => x.Status != "healthy")
|
||||
.Select(x => new OperationalAlert(
|
||||
$"health-{x.Key}",
|
||||
x.Status == "unhealthy" ? "critical" : "warning",
|
||||
"health",
|
||||
$"{x.Label}状态异常",
|
||||
x.Detail,
|
||||
health.CheckedAt))
|
||||
.ToList();
|
||||
|
||||
if (serverErrorCount > 0)
|
||||
{
|
||||
var latest = await db.AuditLogs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.CreatedAt >= DateTime.UtcNow.AddHours(-24) &&
|
||||
x.StatusCode >= 500)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new { x.Path, x.StatusCode, x.CreatedAt })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
alerts.Add(new OperationalAlert(
|
||||
"http-5xx",
|
||||
"critical",
|
||||
"audit",
|
||||
$"过去 24 小时发生 {serverErrorCount} 次服务端错误",
|
||||
latest is null
|
||||
? "请检查服务日志定位异常。"
|
||||
: $"最近一次为 {latest.StatusCode} {latest.Path}。",
|
||||
latest?.CreatedAt ?? DateTime.UtcNow));
|
||||
}
|
||||
|
||||
if (failedJobCount > 0)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"failed-jobs",
|
||||
"critical",
|
||||
"jobs",
|
||||
$"最近 7 天有 {failedJobCount} 个后台任务失败",
|
||||
"任务已停止或达到重试上限,请在失败任务中查看错误详情。",
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
|
||||
var retryingFailures = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.State != BackgroundJobOutboxState.Completed &&
|
||||
x.LastError != null,
|
||||
cancellationToken);
|
||||
if (retryingFailures > 0)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"retrying-jobs",
|
||||
"warning",
|
||||
"jobs",
|
||||
$"{retryingFailures} 个后台任务正在错误重试",
|
||||
"任务队列仍会自动重试;若持续出现,请检查依赖服务与任务参数。",
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
|
||||
var latestBackup = backups.FirstOrDefault();
|
||||
if (latestBackup is null)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"backup-missing",
|
||||
"critical",
|
||||
"backup",
|
||||
"尚无可验证的数据库备份",
|
||||
"立即创建首个备份,并在创建后执行一次隔离恢复演练。",
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
else
|
||||
{
|
||||
var ageHours = (DateTime.UtcNow - latestBackup.CreatedAt).TotalHours;
|
||||
if (ageHours > options.BackupWarningHours)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"backup-stale",
|
||||
"warning",
|
||||
"backup",
|
||||
$"最近备份已超过 {options.BackupWarningHours} 小时",
|
||||
$"最近备份创建于 {latestBackup.CreatedAt:u}。",
|
||||
latestBackup.CreatedAt));
|
||||
}
|
||||
if (latestBackup.LastDrillSucceeded == false)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"restore-drill-failed",
|
||||
"critical",
|
||||
"backup",
|
||||
"最近一次恢复演练失败",
|
||||
latestBackup.LastDrillDetail ?? "请重新运行演练并检查数据库工具日志。",
|
||||
latestBackup.LastDrillAt ?? latestBackup.CreatedAt));
|
||||
}
|
||||
else if (!latestBackup.LastDrillAt.HasValue)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"restore-drill-missing",
|
||||
"warning",
|
||||
"backup",
|
||||
"最近备份尚未完成恢复演练",
|
||||
"恢复演练只写入隔离数据库,不会覆盖当前业务数据。",
|
||||
latestBackup.CreatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
return alerts
|
||||
.OrderBy(x => x.Severity == "critical" ? 0 : 1)
|
||||
.ThenByDescending(x => x.OccurredAt)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<int> CountFailedJobsAsync(
|
||||
DateTime from,
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == AutomaticScheduleJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.SchedulePublishJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == SchedulePublishJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == MakeupExamAutoJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.ExamArrangementJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == ExamArrangementJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.ExamPublishJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == ExamPublishJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken);
|
||||
|
||||
private ActionResult? ValidatePaging(int page, int pageSize)
|
||||
{
|
||||
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
|
||||
{
|
||||
return ValidationProblem(
|
||||
"页码必须在 1 到 100000 之间,每页数量必须在 1 到 100 之间。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ActionResult? ValidateRange(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (from.HasValue && to.HasValue && from.Value > to.Value)
|
||||
return ValidationProblem("开始时间不能晚于结束时间。");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeJobKind(string? kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kind)) return null;
|
||||
return kind.Trim() switch
|
||||
{
|
||||
"AutomaticSchedule" => "AutomaticSchedule",
|
||||
"SchedulePublish" => "SchedulePublish",
|
||||
"MakeupExamAuto" => "MakeupExamAuto",
|
||||
"ExamArrangement" => "ExamArrangement",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception exception)
|
||||
{
|
||||
var message = exception.GetBaseException().Message;
|
||||
return message.Length <= 500 ? message : message[..500];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AuditLogItem(
|
||||
Guid Id,
|
||||
string? UserName,
|
||||
string Method,
|
||||
string Path,
|
||||
int StatusCode,
|
||||
string? IpAddress,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record FailedBackgroundJobItem(
|
||||
Guid Id,
|
||||
string Kind,
|
||||
string KindLabel,
|
||||
string Context,
|
||||
string ErrorMessage,
|
||||
DateTime CreatedAt,
|
||||
DateTime? StartedAt,
|
||||
DateTime? CompletedAt,
|
||||
int ProcessingAttempts);
|
||||
|
||||
public sealed record OperationalAlert(
|
||||
string Id,
|
||||
string Severity,
|
||||
string Source,
|
||||
string Title,
|
||||
string Detail,
|
||||
DateTime OccurredAt);
|
||||
|
||||
public sealed record OperationsCounters(
|
||||
int AuditEvents24Hours,
|
||||
int ServerErrors24Hours,
|
||||
int FailedJobs7Days,
|
||||
int CriticalAlerts);
|
||||
|
||||
public sealed record OperationsSummary(
|
||||
DateTime GeneratedAt,
|
||||
OperationalHealthSnapshot Health,
|
||||
OperationsCounters Counters,
|
||||
IReadOnlyCollection<OperationalAlert> Alerts,
|
||||
BackupArtifact? LatestBackup);
|
||||
|
||||
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
|
||||
|
||||
public sealed record RestoreDrillRequest([Required] string Confirmation);
|
||||
@@ -96,6 +96,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
x.SchedulingMode
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -121,6 +123,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
task.WeeklyHours,
|
||||
task.CourseTotalHours,
|
||||
task.CoursePracticeHours,
|
||||
task.SchedulingMode,
|
||||
HasCustomConstraint = constraint is not null,
|
||||
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||
|
||||
@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -70,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,
|
||||
@@ -166,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,
|
||||
@@ -418,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;
|
||||
@@ -497,6 +501,7 @@ public sealed class SchedulesController(
|
||||
.Include(x => x.Classes)
|
||||
.ThenInclude(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Students)
|
||||
.Include(x => x.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||
if (task is null ||
|
||||
task.Status != TeachingTaskStatus.Published ||
|
||||
@@ -506,13 +511,52 @@ public sealed class SchedulesController(
|
||||
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
||||
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
||||
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
||||
var targetHours = TeachingTaskHours.TargetHours(
|
||||
task.Course!,
|
||||
request.Kind);
|
||||
if (targetHours == 0)
|
||||
return ValidationProblem(
|
||||
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)
|
||||
.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(
|
||||
$"该教学任务{(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)
|
||||
@@ -536,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 &&
|
||||
!IsExperimentRoom(classroom.RoomType))
|
||||
return ValidationProblem(
|
||||
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
|
||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||
classroom.Building!.CampusId != campusId)
|
||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||
@@ -589,6 +637,7 @@ public sealed class SchedulesController(
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
Kind = request.Kind,
|
||||
ClassroomId = request.ClassroomId,
|
||||
DayOfWeek = request.DayOfWeek,
|
||||
StartPeriod = request.StartPeriod,
|
||||
@@ -606,6 +655,12 @@ public sealed class SchedulesController(
|
||||
.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);
|
||||
|
||||
private async Task<ActionResult> SaveAsync(
|
||||
Guid id,
|
||||
bool created,
|
||||
@@ -703,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,
|
||||
|
||||
@@ -25,7 +25,12 @@ public sealed class StatisticsController(
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Leader;
|
||||
|
||||
private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId;
|
||||
// HybridCache may execute its source factory without the request's ambient
|
||||
// HttpContext. Keep the authorization scope stable for the whole controller
|
||||
// invocation instead of resolving it again inside that background factory.
|
||||
private readonly CurrentUserScope currentUser = currentUserDataScope.Current;
|
||||
|
||||
private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId;
|
||||
|
||||
private Guid? ResolveCollegeId(Guid? requestedCollegeId)
|
||||
{
|
||||
@@ -957,13 +962,14 @@ public sealed class StatisticsController(
|
||||
async token =>
|
||||
{
|
||||
var source = await factory(token);
|
||||
if (source.Result is not null || source.Value is null)
|
||||
var data = source.Result is ObjectResult { Value: not null } objectResult
|
||||
? objectResult.Value
|
||||
: source.Value;
|
||||
if (data is null)
|
||||
throw new InvalidOperationException(
|
||||
"Statistics cache source did not return a successful value.");
|
||||
|
||||
return JsonSerializer.SerializeToElement(
|
||||
source.Value,
|
||||
source.Value.GetType());
|
||||
return JsonSerializer.SerializeToElement(data, data.GetType());
|
||||
},
|
||||
AppCacheProfile.Analytics,
|
||||
[
|
||||
@@ -981,7 +987,7 @@ public sealed class StatisticsController(
|
||||
params string?[] filters) =>
|
||||
AppCacheKeys.Statistics(
|
||||
area,
|
||||
currentUserDataScope.Current.Scope.ToString(),
|
||||
currentUser.Scope.ToString(),
|
||||
effectiveCollegeId,
|
||||
filters);
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ public sealed class TeachingTasksController(
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
CourseRegularScheduleHours =
|
||||
x.Course.TotalHours - x.Course.PracticeHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
TeacherNames = x.Teachers
|
||||
@@ -127,6 +130,10 @@ public sealed class TeachingTasksController(
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course!.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
CourseRegularScheduleHours =
|
||||
x.Course.TotalHours - x.Course.PracticeHours,
|
||||
TeacherNames = x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
@@ -163,6 +170,9 @@ public sealed class TeachingTasksController(
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
CourseRegularScheduleHours =
|
||||
x.Course.TotalHours - x.Course.PracticeHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
@@ -423,7 +433,8 @@ public sealed class TeachingTasksController(
|
||||
course,
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeeklyHours);
|
||||
request.WeeklyHours,
|
||||
TeachingTaskSchedulingMode.Standard);
|
||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
||||
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
||||
@@ -598,7 +609,8 @@ public sealed class TeachingTasksController(
|
||||
course,
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeeklyHours);
|
||||
request.WeeklyHours,
|
||||
request.SchedulingMode);
|
||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||
var collegeId = ScopedCollegeId();
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
|
||||
@@ -367,7 +367,10 @@ public sealed class TimetablesController(
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)))
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
|
||||
db.ExamPlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == ExamPlanStatus.Published)))
|
||||
.ToListAsync(cancellationToken);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
@@ -394,7 +397,12 @@ public sealed class TimetablesController(
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))))
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
|
||||
db.ExamSessions.Any(session =>
|
||||
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
|
||||
session.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||
session.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)))))
|
||||
.ToListAsync(cancellationToken);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class AttendanceRecord
|
||||
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
|
||||
public DateTime? AppealReviewedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AttendanceCheckInAttempt : EntityBase
|
||||
{
|
||||
public Guid AttendanceSheetId { get; set; }
|
||||
public AttendanceSheet? AttendanceSheet { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public AttendanceCheckInMethod CheckInMethod { get; set; }
|
||||
public bool IsSuccessful { get; set; }
|
||||
public string? FailureCode { get; set; }
|
||||
public string? DeviceIdentifierHash { get; set; }
|
||||
public string? DevicePlatform { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public string? RiskFlags { get; set; }
|
||||
public decimal? Latitude { get; set; }
|
||||
public decimal? Longitude { get; set; }
|
||||
public double? AccuracyMeters { get; set; }
|
||||
public double? DistanceMeters { get; set; }
|
||||
}
|
||||
|
||||
public enum AttendanceSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
|
||||
@@ -11,6 +11,41 @@ public sealed class ExamPlan : EntityBase
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<ExamSession> Sessions { get; set; } = [];
|
||||
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExamArrangementJob : EntityBase
|
||||
{
|
||||
public ExamArrangementKind Kind { get; set; }
|
||||
public Guid PlanId { get; set; }
|
||||
public Guid? ActivePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public string? SessionIdsJson { get; set; }
|
||||
public bool AssignClassrooms { get; set; }
|
||||
public bool AssignInvigilators { get; set; }
|
||||
public ExamArrangementJobStatus Status { get; set; } =
|
||||
ExamArrangementJobStatus.Queued;
|
||||
public int TotalSessions { get; set; }
|
||||
public int ProcessedSessions { get; set; }
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ResultMessage { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamArrangementKind
|
||||
{
|
||||
FormalExam = 1,
|
||||
MakeupExam = 2
|
||||
}
|
||||
|
||||
public enum ExamArrangementJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
public sealed class ExamSession : EntityBase
|
||||
@@ -28,9 +63,12 @@ public sealed class ExamSession : EntityBase
|
||||
public DateTime EndsAt { get; set; }
|
||||
public Guid? RequiredBuildingId { get; set; }
|
||||
public Building? RequiredBuilding { get; set; }
|
||||
public string? RequiredBuildingIds { get; set; }
|
||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||
public string? Notes { get; set; }
|
||||
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
|
||||
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExamSessionInvigilator
|
||||
@@ -41,9 +79,82 @@ public sealed class ExamSessionInvigilator
|
||||
public Teacher? Teacher { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExamRoomAssignment : EntityBase
|
||||
{
|
||||
public Guid ExamPlanId { get; set; }
|
||||
public ExamPlan? ExamPlan { get; set; }
|
||||
public Guid CourseId { get; set; }
|
||||
public Course? Course { get; set; }
|
||||
public Guid ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public DateOnly ExamDate { get; set; }
|
||||
public int StartPeriod { get; set; }
|
||||
public int PeriodCount { get; set; }
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime EndsAt { get; set; }
|
||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
|
||||
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
|
||||
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExamRoomSession
|
||||
{
|
||||
public Guid ExamRoomId { get; set; }
|
||||
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||
public Guid ExamSessionId { get; set; }
|
||||
public ExamSession? ExamSession { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExamSeatAssignment
|
||||
{
|
||||
public Guid ExamRoomId { get; set; }
|
||||
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||
public Guid ExamSessionId { get; set; }
|
||||
public ExamSession? ExamSession { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public int SeatNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExamRoomInvigilator
|
||||
{
|
||||
public Guid ExamRoomId { get; set; }
|
||||
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||
public Guid TeacherId { get; set; }
|
||||
public Teacher? Teacher { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamPlanStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Published = 2,
|
||||
Archived = 3
|
||||
}
|
||||
|
||||
public sealed class ExamPublishJob : EntityBase
|
||||
{
|
||||
public ExamPublishJobKind Kind { get; set; }
|
||||
public Guid PlanId { get; set; }
|
||||
public Guid? ActivePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamPublishJobKind
|
||||
{
|
||||
FormalExam = 1,
|
||||
MakeupExam = 2
|
||||
}
|
||||
|
||||
public enum ExamPublishJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ExamSignInExportJob : EntityBase
|
||||
{
|
||||
public Guid PlanId { get; set; }
|
||||
public ExamSignInExportJobStatus Status { get; set; } =
|
||||
ExamSignInExportJobStatus.Queued;
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
public byte[]? FileBytes { get; set; }
|
||||
public int FileSize { get; set; }
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamSignInExportJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ExperimentProject : EntityBase
|
||||
{
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
public required string Code { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Requirements { get; set; }
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public ExperimentProjectStatus Status { get; set; } =
|
||||
ExperimentProjectStatus.Draft;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
public ICollection<ExperimentSession> Sessions { get; set; } = [];
|
||||
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||
public ExperimentGradeSheet? GradeSheet { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExperimentSession : EntityBase
|
||||
{
|
||||
public Guid ExperimentProjectId { get; set; }
|
||||
public ExperimentProject? ExperimentProject { get; set; }
|
||||
public Guid ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public DateOnly SessionDate { get; set; }
|
||||
public int StartPeriod { get; set; }
|
||||
public int PeriodCount { get; set; }
|
||||
public int Capacity { get; set; }
|
||||
public int ReservedCount { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public ExperimentSessionStatus Status { get; set; } =
|
||||
ExperimentSessionStatus.Scheduled;
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentBooking : EntityBase
|
||||
{
|
||||
public Guid ExperimentProjectId { get; set; }
|
||||
public ExperimentProject? ExperimentProject { get; set; }
|
||||
public Guid ExperimentSessionId { get; set; }
|
||||
public ExperimentSession? ExperimentSession { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public ExperimentBookingStatus Status { get; set; } =
|
||||
ExperimentBookingStatus.Booked;
|
||||
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExperimentArrangementMode
|
||||
{
|
||||
Centralized = 1,
|
||||
SelfScheduled = 2
|
||||
}
|
||||
|
||||
public enum ExperimentProjectStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Published = 2,
|
||||
Closed = 3
|
||||
}
|
||||
|
||||
public enum ExperimentSessionStatus
|
||||
{
|
||||
Scheduled = 1,
|
||||
Cancelled = 2
|
||||
}
|
||||
|
||||
public enum ExperimentBookingStatus
|
||||
{
|
||||
Booked = 1,
|
||||
Cancelled = 2
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ExperimentGradeSheet : EntityBase
|
||||
{
|
||||
public Guid ExperimentProjectId { get; set; }
|
||||
public ExperimentProject? ExperimentProject { get; set; }
|
||||
public decimal ContributionWeight { get; set; } = 1;
|
||||
public decimal PassScore { get; set; } = 60;
|
||||
public ExperimentGradeSheetStatus Status { get; set; } =
|
||||
ExperimentGradeSheetStatus.Draft;
|
||||
public string? ReviewComment { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
|
||||
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentGradeItem : EntityBase
|
||||
{
|
||||
public Guid ExperimentGradeSheetId { get; set; }
|
||||
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentGradeItemKind Kind { get; set; } =
|
||||
ExperimentGradeItemKind.Other;
|
||||
public decimal Weight { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentGradeRecord : EntityBase
|
||||
{
|
||||
public Guid ExperimentGradeSheetId { get; set; }
|
||||
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public Guid? ExperimentSessionId { get; set; }
|
||||
public ExperimentSession? ExperimentSession { get; set; }
|
||||
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
|
||||
ExperimentParticipationStatus.Pending;
|
||||
public decimal? TotalScore { get; set; }
|
||||
public bool? IsPassed { get; set; }
|
||||
public bool SafetyViolation { get; set; }
|
||||
public int AttemptNumber { get; set; } = 1;
|
||||
public string? SubmissionReference { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public bool IsLate { get; set; }
|
||||
public string? TeacherComment { get; set; }
|
||||
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentGradeItemScore
|
||||
{
|
||||
public Guid ExperimentGradeRecordId { get; set; }
|
||||
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
|
||||
public Guid ExperimentGradeItemId { get; set; }
|
||||
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
|
||||
public decimal? Score { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
public enum ExperimentGradeSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Submitted = 2,
|
||||
Approved = 3,
|
||||
Published = 4,
|
||||
Returned = 5
|
||||
}
|
||||
|
||||
public enum ExperimentGradeItemKind
|
||||
{
|
||||
Operation = 1,
|
||||
Report = 2,
|
||||
Result = 3,
|
||||
Defense = 4,
|
||||
Attendance = 5,
|
||||
Safety = 6,
|
||||
Other = 7
|
||||
}
|
||||
|
||||
public enum ExperimentParticipationStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Completed = 2,
|
||||
Absent = 3,
|
||||
Excused = 4,
|
||||
Makeup = 5,
|
||||
Exempt = 6
|
||||
}
|
||||
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
|
||||
public required string Name { get; set; }
|
||||
public decimal Weight { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public GradeItemSourceType SourceType { get; set; } =
|
||||
GradeItemSourceType.Manual;
|
||||
public DateTime? SourceSnapshotAt { get; set; }
|
||||
public ICollection<GradeItemScore> Scores { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -68,3 +71,9 @@ public enum GradeExamStatus
|
||||
Exempt = 4,
|
||||
Makeup = 5
|
||||
}
|
||||
|
||||
public enum GradeItemSourceType
|
||||
{
|
||||
Manual = 1,
|
||||
ExperimentSummary = 2
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
|
||||
public DateTime EndsAt { get; set; }
|
||||
public Guid? RequiredBuildingId { get; set; }
|
||||
public Building? RequiredBuilding { get; set; }
|
||||
public string? RequiredBuildingIds { get; set; }
|
||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||
public string? Notes { get; set; }
|
||||
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||
|
||||
@@ -44,5 +44,6 @@ public enum MessageAudienceType
|
||||
{
|
||||
School = 1,
|
||||
College = 2,
|
||||
TeachingTask = 3
|
||||
TeachingTask = 3,
|
||||
Custom = 4
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ public sealed class Course : CatalogEntity
|
||||
public CourseNature Nature { get; set; }
|
||||
public AssessmentMethod AssessmentMethod { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public ICollection<CoursePrerequisite> Prerequisites { get; set; } = [];
|
||||
public ICollection<CoursePrerequisite> RequiredByCourses { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CoursePrerequisite : EntityBase
|
||||
{
|
||||
public Guid CourseId { get; set; }
|
||||
public Course? Course { get; set; }
|
||||
public Guid PrerequisiteCourseId { get; set; }
|
||||
public Course? PrerequisiteCourse { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CourseCategory : CatalogEntity
|
||||
|
||||
@@ -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; }
|
||||
@@ -129,3 +130,9 @@ public enum WeekPattern
|
||||
Odd = 2,
|
||||
Even = 3
|
||||
}
|
||||
|
||||
public enum ScheduleEntryKind
|
||||
{
|
||||
Lecture = 1,
|
||||
Experiment = 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.System;
|
||||
|
||||
public enum AppUpdatePlatform
|
||||
{
|
||||
Android,
|
||||
Ios
|
||||
}
|
||||
|
||||
public enum AppUpdateChannel
|
||||
{
|
||||
Production,
|
||||
Staging
|
||||
}
|
||||
|
||||
public enum AppUpdateReleaseStatus
|
||||
{
|
||||
Draft,
|
||||
Published,
|
||||
Archived
|
||||
}
|
||||
|
||||
public sealed class AppUpdateRelease : EntityBase
|
||||
{
|
||||
public AppUpdatePlatform Platform { get; set; }
|
||||
public AppUpdateChannel Channel { get; set; }
|
||||
public required string Version { get; set; }
|
||||
public required string NativeVersion { get; set; }
|
||||
public AppUpdateReleaseStatus Status { get; set; } =
|
||||
AppUpdateReleaseStatus.Draft;
|
||||
public string? ReleaseNotes { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
public required string Sha256 { get; set; }
|
||||
public required byte[] BundleContent { get; set; }
|
||||
public required string CreatedByUserName { get; set; }
|
||||
public string? PublishedByUserName { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
}
|
||||
@@ -30,7 +30,10 @@ public enum BackgroundJobKind
|
||||
{
|
||||
AutomaticSchedule = 1,
|
||||
SchedulePublish = 2,
|
||||
MakeupExamAuto = 3
|
||||
MakeupExamAuto = 3,
|
||||
ExamArrangement = 4,
|
||||
ExamSignInExport = 5,
|
||||
ExamPublish = 6
|
||||
}
|
||||
|
||||
public enum BackgroundJobOutboxState
|
||||
|
||||
@@ -13,6 +13,9 @@ public sealed class BackgroundJobOptions
|
||||
public int AutomaticScheduleConcurrency { get; set; } = 1;
|
||||
public int SchedulePublishConcurrency { get; set; } = 1;
|
||||
public int MakeupExamAutoConcurrency { get; set; } = 1;
|
||||
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||
public int ExamPublishConcurrency { 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;
|
||||
@@ -29,6 +32,9 @@ public sealed class BackgroundJobOptions
|
||||
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
|
||||
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
|
||||
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +103,33 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamArrangementJobStatus.Queued ||
|
||||
x.Status == ExamArrangementJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamArrangement &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamSignInExportJobStatus.Queued ||
|
||||
x.Status == ExamSignInExportJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamSignInExport &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamPublishJobStatus.Queued ||
|
||||
x.Status == ExamPublishJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamPublish &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missingKeys = automaticJobs
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
@@ -110,6 +137,12 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
(BackgroundJobKind.SchedulePublish, id)))
|
||||
.Concat(makeupJobs.Select(id =>
|
||||
(BackgroundJobKind.MakeupExamAuto, id)))
|
||||
.Concat(arrangementJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamArrangement, id)))
|
||||
.Concat(exportJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamSignInExport, id)))
|
||||
.Concat(publishJobs2.Select(id =>
|
||||
(BackgroundJobKind.ExamPublish, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
|
||||
@@ -85,6 +85,21 @@ public sealed class BackgroundJobRunner(
|
||||
.GetRequiredService<MakeupExamAutoJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamArrangement:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamArrangementJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamSignInExport:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamSignInExportJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamPublish:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamPublishJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
@@ -259,6 +274,40 @@ public sealed class BackgroundJobRunner(
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamArrangement:
|
||||
await db.ExamArrangementJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != ExamArrangementJobStatus.Succeeded &&
|
||||
x.Status != ExamArrangementJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
ExamArrangementJobStatus.Failed)
|
||||
.SetProperty(x => x.ActivePlanId, (Guid?)null)
|
||||
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamPublish:
|
||||
await db.ExamPublishJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != ExamPublishJobStatus.Succeeded &&
|
||||
x.Status != ExamPublishJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
ExamPublishJobStatus.Failed)
|
||||
.SetProperty(x => x.ActivePlanId, (Guid?)null)
|
||||
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(message.JobKind),
|
||||
|
||||
@@ -316,7 +316,10 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
[
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
BackgroundJobKind.MakeupExamAuto
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
BackgroundJobKind.ExamSignInExport,
|
||||
BackgroundJobKind.ExamPublish
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
@@ -412,6 +415,9 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
|
||||
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
||||
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||
BackgroundJobKind.ExamPublish => "exam.publish",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamArrangementJobProcessor(
|
||||
AppDbContext db,
|
||||
ExamArrangementService examArrangementService,
|
||||
MakeupExamArrangementService makeupExamArrangementService,
|
||||
IAppCache cache,
|
||||
ILogger<ExamArrangementJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.ExamArrangementJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is ExamArrangementJobStatus.Succeeded
|
||||
or ExamArrangementJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamArrangementJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.ResultMessage = null;
|
||||
job.CurrentStep = job.Kind == ExamArrangementKind.FormalExam
|
||||
? "正在编排正式考试"
|
||||
: "正在编排补考";
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
var sessionIds = DeserializeSessionIds(job.SessionIdsJson);
|
||||
var result = job.Kind switch
|
||||
{
|
||||
ExamArrangementKind.FormalExam =>
|
||||
await examArrangementService.ArrangeAsync(
|
||||
job.PlanId,
|
||||
sessionIds,
|
||||
job.AssignClassrooms,
|
||||
job.AssignInvigilators,
|
||||
stoppingToken),
|
||||
ExamArrangementKind.MakeupExam =>
|
||||
await makeupExamArrangementService.ArrangeAsync(
|
||||
job.PlanId,
|
||||
sessionIds,
|
||||
job.AssignClassrooms,
|
||||
job.AssignInvigilators,
|
||||
stoppingToken),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"不支持的考试编排类型:{job.Kind}。")
|
||||
};
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await MarkFailedAsync(jobId, result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamArrangementJobStatus.Succeeded;
|
||||
job.ProcessedSessions = job.TotalSessions;
|
||||
job.CurrentStep = "编排完成";
|
||||
job.ResultMessage = result.Message;
|
||||
job.ActivePlanId = null;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
if (job.Kind == ExamArrangementKind.FormalExam)
|
||||
{
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
stoppingToken);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Exam arrangement job {JobId} for {Kind}/{PlanId} completed.",
|
||||
job.Id,
|
||||
job.Kind,
|
||||
job.PlanId);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Exam arrangement job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Exam arrangement job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<Guid>? DeserializeSessionIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
var sessionIds = JsonSerializer.Deserialize<Guid[]>(json);
|
||||
return sessionIds is { Length: > 0 } ? sessionIds : null;
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.ExamArrangementJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
job.Status = ExamArrangementJobStatus.Failed;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "编排失败";
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamArrangementService(AppDbContext db)
|
||||
{
|
||||
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
|
||||
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
|
||||
private sealed record RoomGroupKey(
|
||||
Guid CourseId,
|
||||
DateOnly ExamDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
string RequiredBuildingIdsKey);
|
||||
|
||||
private sealed record RoomOccupancy(
|
||||
Guid ClassroomId,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt);
|
||||
|
||||
private sealed record InvigilatorOccupancy(
|
||||
Guid TeacherId,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt);
|
||||
|
||||
private sealed record CandidateSeat(
|
||||
Guid ExamSessionId,
|
||||
Guid StudentId,
|
||||
string StudentNumber);
|
||||
|
||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||
Guid planId,
|
||||
@@ -26,6 +46,16 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Include(x => x.Rooms)
|
||||
.ThenInclude(x => x.SessionLinks)
|
||||
.Include(x => x.Rooms)
|
||||
.ThenInclude(x => x.Seats)
|
||||
.Include(x => x.Rooms)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||
|
||||
if (plan is null)
|
||||
@@ -42,201 +72,549 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
||||
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
|
||||
|
||||
var sessions = plan.Sessions
|
||||
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenByDescending(x => x.RequiredInvigilatorCount)
|
||||
.ToList();
|
||||
if (sessions.Count == 0)
|
||||
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
||||
|
||||
var termId = plan.AcademicTermId;
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (timeSlots.Count == 0)
|
||||
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
|
||||
|
||||
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
||||
|
||||
int assignedRooms = 0;
|
||||
int assignedInvigilators = 0;
|
||||
int unavailableRooms = 0;
|
||||
int unavailableInvigilators = 0;
|
||||
var messages = new List<string>();
|
||||
|
||||
// Track occupied time slots to avoid conflicts
|
||||
var occupiedRooms = sessions
|
||||
.Where(x => x.ClassroomId.HasValue)
|
||||
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
|
||||
.ToList();
|
||||
|
||||
var occupiedInvigilators = sessions
|
||||
.SelectMany(x => x.Invigilators.Select(i =>
|
||||
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
|
||||
.ToList();
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
// Compute StartsAt/EndsAt from time slots
|
||||
foreach (var session in plan.Sessions)
|
||||
ComputeTimesFromSlots(session, timeSlotLookup);
|
||||
var studentCount = await db.CourseEnrollments.CountAsync(
|
||||
x => x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
|
||||
cancellationToken);
|
||||
|
||||
// ── Auto-assign classroom ──
|
||||
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||
var explicitlySelected = plan.Sessions
|
||||
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||
.ToList();
|
||||
if (explicitlySelected.Count == 0)
|
||||
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
||||
|
||||
var selectedKeys = explicitlySelected
|
||||
.Select(GroupKey)
|
||||
.ToHashSet();
|
||||
var sessions = plan.Sessions
|
||||
.Where(x => selectedKeys.Contains(GroupKey(x)))
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ToList();
|
||||
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
|
||||
|
||||
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
sessions.Select(x => x.TeachingTaskId),
|
||||
cancellationToken);
|
||||
var sessionByTaskId = sessions
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
|
||||
var replacedRooms = assignClassrooms
|
||||
? plan.Rooms
|
||||
.Where(room => room.SessionLinks.Any(link =>
|
||||
sessionIds.Contains(link.ExamSessionId)))
|
||||
.ToList()
|
||||
: [];
|
||||
var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet();
|
||||
if (replacedRooms.Count > 0)
|
||||
db.ExamRooms.RemoveRange(replacedRooms);
|
||||
|
||||
var occupiedRooms = await LoadOccupiedRoomsAsync(
|
||||
plan,
|
||||
sessionIds,
|
||||
replacedRoomIds,
|
||||
cancellationToken);
|
||||
var rooms = await db.Classrooms.AsNoTracking()
|
||||
.Include(x => x.Building)
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Building!.Name)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetRooms = new List<ExamRoomAssignment>();
|
||||
var messages = new List<string>();
|
||||
var seatedStudents = 0;
|
||||
var unavailableStudents = 0;
|
||||
|
||||
if (assignClassrooms)
|
||||
{
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var room = await FindBestClassroomAsync(
|
||||
session, studentCount, occupiedRooms, cancellationToken);
|
||||
if (room is not null)
|
||||
session.ClassroomId = null;
|
||||
if (session.Invigilators.Count > 0)
|
||||
{
|
||||
session.ClassroomId = room.Id;
|
||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
||||
assignedRooms++;
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
|
||||
}
|
||||
else
|
||||
{
|
||||
unavailableRooms++;
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
||||
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||
session.Invigilators.Clear();
|
||||
}
|
||||
}
|
||||
// ── Auto-assign invigilators ──
|
||||
var currentInvigilatorCount = session.Invigilators.Count;
|
||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||
if (assignInvigilators && needed > 0)
|
||||
|
||||
foreach (var group in sessions
|
||||
.GroupBy(GroupKey)
|
||||
.OrderBy(x => x.Key.ExamDate)
|
||||
.ThenBy(x => x.Key.StartPeriod)
|
||||
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
|
||||
{
|
||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||
.Select(x => x.TeacherId).ToHashSet();
|
||||
var newlyAssigned = await FindInvigilatorsAsync(
|
||||
session, needed, courseTeacherIds,
|
||||
occupiedInvigilators, cancellationToken);
|
||||
foreach (var teacher in newlyAssigned)
|
||||
var groupSessions = group.ToList();
|
||||
var candidates = InterleaveCandidates(
|
||||
groupSessions,
|
||||
roster,
|
||||
sessionByTaskId);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
session.Invigilators.Add(new ExamSessionInvigilator
|
||||
messages.Add(
|
||||
$"“{groupSessions[0].TeachingTask!.Course!.Name}”没有有效考生。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var availableRooms = rooms
|
||||
.Where(room =>
|
||||
(group.Key.RequiredBuildingIdsKey.Length == 0 ||
|
||||
group.Key.RequiredBuildingIdsKey
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Contains(room.BuildingId.ToString())) &&
|
||||
occupiedRooms.All(occupied =>
|
||||
occupied.ClassroomId != room.Id ||
|
||||
!ExamConflictRules.TimeOverlaps(
|
||||
occupied.StartsAt,
|
||||
occupied.EndsAt,
|
||||
groupSessions[0].StartsAt,
|
||||
groupSessions[0].EndsAt)))
|
||||
.ToList();
|
||||
var selectedRooms = SelectRooms(
|
||||
availableRooms,
|
||||
candidates.Count);
|
||||
if (selectedRooms.Sum(x => x.Capacity / 2) < candidates.Count)
|
||||
{
|
||||
unavailableStudents += candidates.Count;
|
||||
messages.Add(
|
||||
$"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," +
|
||||
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity / 2)} 座。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
foreach (var classroom in selectedRooms)
|
||||
{
|
||||
var roomCandidates = candidates
|
||||
.Skip(offset)
|
||||
.Take(classroom.Capacity / 2)
|
||||
.ToList();
|
||||
if (roomCandidates.Count == 0) break;
|
||||
offset += roomCandidates.Count;
|
||||
|
||||
var room = new ExamRoomAssignment
|
||||
{
|
||||
ExamPlanId = plan.Id,
|
||||
CourseId = group.Key.CourseId,
|
||||
ClassroomId = classroom.Id,
|
||||
ExamDate = group.Key.ExamDate,
|
||||
StartPeriod = group.Key.StartPeriod,
|
||||
PeriodCount = group.Key.PeriodCount,
|
||||
StartsAt = groupSessions[0].StartsAt,
|
||||
EndsAt = groupSessions[0].EndsAt,
|
||||
RequiredInvigilatorCount =
|
||||
groupSessions.Max(x => x.RequiredInvigilatorCount),
|
||||
SessionLinks = roomCandidates
|
||||
.Select(x => x.ExamSessionId)
|
||||
.Distinct()
|
||||
.Select(sessionId => new ExamRoomSession
|
||||
{
|
||||
ExamSessionId = sessionId
|
||||
})
|
||||
.ToList(),
|
||||
Seats = roomCandidates
|
||||
.Select((candidate, index) =>
|
||||
new ExamSeatAssignment
|
||||
{
|
||||
ExamSessionId = candidate.ExamSessionId,
|
||||
StudentId = candidate.StudentId,
|
||||
SeatNumber = index + 1
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
db.ExamRooms.Add(room);
|
||||
targetRooms.Add(room);
|
||||
occupiedRooms.Add(new RoomOccupancy(
|
||||
classroom.Id,
|
||||
room.StartsAt,
|
||||
room.EndsAt));
|
||||
seatedStudents += roomCandidates.Count;
|
||||
}
|
||||
|
||||
messages.Add(
|
||||
$"“{groupSessions[0].TeachingTask!.Course!.Name}”" +
|
||||
$"{groupSessions.Count}个教学班混排至{selectedRooms.Count}个考场。");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
targetRooms.AddRange(plan.Rooms.Where(room =>
|
||||
room.SessionLinks.Any(link =>
|
||||
sessionIds.Contains(link.ExamSessionId))));
|
||||
var linkedSessionIds = targetRooms
|
||||
.SelectMany(x => x.SessionLinks)
|
||||
.Select(x => x.ExamSessionId)
|
||||
.ToHashSet();
|
||||
foreach (var session in sessions.Where(x =>
|
||||
!linkedSessionIds.Contains(x.Id) &&
|
||||
x.ClassroomId.HasValue))
|
||||
{
|
||||
var legacyRoom = CreateLegacyRoom(
|
||||
plan,
|
||||
session,
|
||||
roster.Where(x =>
|
||||
x.TeachingTaskId == session.TeachingTaskId));
|
||||
db.ExamRooms.Add(legacyRoom);
|
||||
targetRooms.Add(legacyRoom);
|
||||
session.ClassroomId = null;
|
||||
if (session.Invigilators.Count > 0)
|
||||
{
|
||||
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||
session.Invigilators.Clear();
|
||||
}
|
||||
}
|
||||
seatedStudents = targetRooms.Sum(x => x.Seats.Count);
|
||||
}
|
||||
|
||||
var assignedInvigilators = 0;
|
||||
var unavailableInvigilators = 0;
|
||||
if (assignInvigilators)
|
||||
{
|
||||
if (targetRooms.Count == 0)
|
||||
return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
|
||||
|
||||
var occupiedInvigilators = plan.Rooms
|
||||
.Where(x => !replacedRoomIds.Contains(x.Id) &&
|
||||
!targetRooms.Any(target => target.Id == x.Id))
|
||||
.SelectMany(room => room.Invigilators.Select(item =>
|
||||
new InvigilatorOccupancy(
|
||||
item.TeacherId,
|
||||
room.StartsAt,
|
||||
room.EndsAt)))
|
||||
.Concat(plan.Sessions
|
||||
.Where(x => !sessionIds.Contains(x.Id))
|
||||
.SelectMany(session => session.Invigilators.Select(item =>
|
||||
new InvigilatorOccupancy(
|
||||
item.TeacherId,
|
||||
session.StartsAt,
|
||||
session.EndsAt))))
|
||||
.ToList();
|
||||
|
||||
foreach (var room in targetRooms
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.ClassroomId))
|
||||
{
|
||||
var needed = room.RequiredInvigilatorCount -
|
||||
room.Invigilators.Count;
|
||||
if (needed <= 0) continue;
|
||||
|
||||
var linkedSessionIds = room.SessionLinks
|
||||
.Select(x => x.ExamSessionId)
|
||||
.ToHashSet();
|
||||
var excludedTeacherIds = sessions
|
||||
.Where(x => linkedSessionIds.Contains(x.Id))
|
||||
.SelectMany(x => x.TeachingTask!.Teachers)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToHashSet();
|
||||
var teachers = await FindInvigilatorsAsync(
|
||||
room,
|
||||
needed,
|
||||
excludedTeacherIds,
|
||||
occupiedInvigilators,
|
||||
cancellationToken);
|
||||
foreach (var teacher in teachers)
|
||||
{
|
||||
room.Invigilators.Add(new ExamRoomInvigilator
|
||||
{
|
||||
ExamSessionId = session.Id,
|
||||
TeacherId = teacher.Id
|
||||
});
|
||||
occupiedInvigilators.Add(new InvigilatorOccupancy(
|
||||
teacher.Id, session.StartsAt, session.EndsAt));
|
||||
teacher.Id,
|
||||
room.StartsAt,
|
||||
room.EndsAt));
|
||||
assignedInvigilators++;
|
||||
}
|
||||
|
||||
if (newlyAssigned.Count < needed)
|
||||
{
|
||||
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||
}
|
||||
if (teachers.Count < needed)
|
||||
unavailableInvigilators += needed - teachers.Count;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var expandedCount = sessions.Count - explicitlySelected.Count;
|
||||
var detail = messages.Count > 0
|
||||
? $" 详情:{string.Join(";", messages.Take(10))}"
|
||||
: "";
|
||||
return new ExamArrangementResult(
|
||||
true,
|
||||
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||
$"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
|
||||
$"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
|
||||
(expandedCount > 0
|
||||
? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。"
|
||||
: "") +
|
||||
(unavailableStudents > 0
|
||||
? $" {unavailableStudents}名考生尚未安排考场。"
|
||||
: "") +
|
||||
(unavailableInvigilators > 0
|
||||
? $" 仍缺{unavailableInvigilators}名监考教师。"
|
||||
: "") +
|
||||
detail);
|
||||
}
|
||||
|
||||
private async Task<List<RoomOccupancy>> LoadOccupiedRoomsAsync(
|
||||
ExamPlan plan,
|
||||
IReadOnlySet<Guid> targetSessionIds,
|
||||
IReadOnlySet<Guid> replacedRoomIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var occupied = plan.Rooms
|
||||
.Where(x => !replacedRoomIds.Contains(x.Id))
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId,
|
||||
x.StartsAt,
|
||||
x.EndsAt))
|
||||
.ToList();
|
||||
occupied.AddRange(plan.Sessions
|
||||
.Where(x =>
|
||||
!targetSessionIds.Contains(x.Id) &&
|
||||
x.ClassroomId.HasValue)
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId!.Value,
|
||||
x.StartsAt,
|
||||
x.EndsAt)));
|
||||
|
||||
var externalRooms = await db.ExamRooms.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamPlanId != plan.Id &&
|
||||
x.ExamPlan!.Status != ExamPlanStatus.Archived)
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId,
|
||||
x.StartsAt,
|
||||
x.EndsAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
occupied.AddRange(externalRooms);
|
||||
|
||||
var externalLegacyRooms = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamPlanId != plan.Id &&
|
||||
x.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||
x.ClassroomId != null)
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId!.Value,
|
||||
x.StartsAt,
|
||||
x.EndsAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
occupied.AddRange(externalLegacyRooms);
|
||||
return occupied;
|
||||
}
|
||||
|
||||
private async Task<List<Teacher>> FindInvigilatorsAsync(
|
||||
ExamRoomAssignment room,
|
||||
int needed,
|
||||
HashSet<Guid> excludedTeacherIds,
|
||||
List<InvigilatorOccupancy> occupied,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var busyTeacherIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
room.StartsAt,
|
||||
room.EndsAt))
|
||||
.Select(x => x.TeacherId)
|
||||
.ToHashSet();
|
||||
|
||||
var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamRoomId != room.Id &&
|
||||
x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||
x.ExamRoom.StartsAt < room.EndsAt &&
|
||||
room.StartsAt < x.ExamRoom.EndsAt)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var teacherId in databaseBusyIds)
|
||||
busyTeacherIds.Add(teacherId);
|
||||
|
||||
var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||
x.ExamSession.StartsAt < room.EndsAt &&
|
||||
room.StartsAt < x.ExamSession.EndsAt)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var teacherId in legacyBusyIds)
|
||||
busyTeacherIds.Add(teacherId);
|
||||
foreach (var teacherId in excludedTeacherIds)
|
||||
busyTeacherIds.Add(teacherId);
|
||||
|
||||
var candidates = await InvigilatorCandidateQuery
|
||||
.Create(db, busyTeacherIds)
|
||||
.ToListAsync(cancellationToken);
|
||||
return candidates
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(needed)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<Classroom> SelectRooms(
|
||||
IReadOnlyCollection<Classroom> availableRooms,
|
||||
int candidateCount)
|
||||
{
|
||||
var remainingRooms = availableRooms.ToList();
|
||||
var selected = new List<Classroom>();
|
||||
var remainingSeats = candidateCount;
|
||||
while (remainingSeats > 0 && remainingRooms.Count > 0)
|
||||
{
|
||||
var room = remainingRooms
|
||||
.Where(x => x.Capacity / 2 >= remainingSeats)
|
||||
.OrderBy(x => x.Capacity / 2)
|
||||
.ThenBy(x => x.Name)
|
||||
.FirstOrDefault()
|
||||
?? remainingRooms
|
||||
.OrderByDescending(x => x.Capacity / 2)
|
||||
.ThenBy(x => x.Name)
|
||||
.First();
|
||||
selected.Add(room);
|
||||
remainingRooms.Remove(room);
|
||||
remainingSeats -= room.Capacity / 2;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static List<CandidateSeat> InterleaveCandidates(
|
||||
IReadOnlyCollection<ExamSession> sessions,
|
||||
IReadOnlyCollection<TeachingTaskRosterEntry> roster,
|
||||
IReadOnlyDictionary<Guid, ExamSession> sessionByTaskId)
|
||||
{
|
||||
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
|
||||
var queues = roster
|
||||
.Where(x =>
|
||||
sessionByTaskId.TryGetValue(
|
||||
x.TeachingTaskId,
|
||||
out var session) &&
|
||||
sessionIds.Contains(session.Id))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber)
|
||||
.Select(group => new Queue<CandidateSeat>(
|
||||
group.OrderBy(x => x.StudentNumber)
|
||||
.Select(x => new CandidateSeat(
|
||||
sessionByTaskId[x.TeachingTaskId].Id,
|
||||
x.StudentId,
|
||||
x.StudentNumber))))
|
||||
.ToList();
|
||||
|
||||
var result = new List<CandidateSeat>();
|
||||
var assignedStudentIds = new HashSet<Guid>();
|
||||
while (queues.Any(x => x.Count > 0))
|
||||
{
|
||||
foreach (var queue in queues)
|
||||
{
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var candidate = queue.Dequeue();
|
||||
if (!assignedStudentIds.Add(candidate.StudentId))
|
||||
continue;
|
||||
result.Add(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ExamRoomAssignment CreateLegacyRoom(
|
||||
ExamPlan plan,
|
||||
ExamSession session,
|
||||
IEnumerable<TeachingTaskRosterEntry> roster)
|
||||
{
|
||||
var students = roster
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.ToList();
|
||||
return new ExamRoomAssignment
|
||||
{
|
||||
ExamPlanId = plan.Id,
|
||||
CourseId = session.TeachingTask!.CourseId,
|
||||
ClassroomId = session.ClassroomId!.Value,
|
||||
ExamDate = session.ExamDate,
|
||||
StartPeriod = session.StartPeriod,
|
||||
PeriodCount = session.PeriodCount,
|
||||
StartsAt = session.StartsAt,
|
||||
EndsAt = session.EndsAt,
|
||||
RequiredInvigilatorCount = session.RequiredInvigilatorCount,
|
||||
SessionLinks =
|
||||
[
|
||||
new ExamRoomSession
|
||||
{
|
||||
ExamSessionId = session.Id
|
||||
}
|
||||
],
|
||||
Seats = students.Select((student, index) =>
|
||||
new ExamSeatAssignment
|
||||
{
|
||||
ExamSessionId = session.Id,
|
||||
StudentId = student.StudentId,
|
||||
SeatNumber = index + 1
|
||||
}).ToList(),
|
||||
Invigilators = session.Invigilators.Select(x =>
|
||||
new ExamRoomInvigilator
|
||||
{
|
||||
TeacherId = x.TeacherId
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static RoomGroupKey GroupKey(ExamSession session)
|
||||
{
|
||||
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||
var key = buildingIds.Count == 0
|
||||
? string.Empty
|
||||
: string.Join(",", buildingIds.OrderBy(x => x));
|
||||
return new(
|
||||
session.TeachingTask!.CourseId,
|
||||
session.ExamDate,
|
||||
session.StartPeriod,
|
||||
session.PeriodCount,
|
||||
key);
|
||||
}
|
||||
|
||||
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static void ComputeTimesFromSlots(
|
||||
ExamSession session,
|
||||
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
||||
IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
||||
{
|
||||
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
|
||||
var endSlot = timeSlotLookup.GetValueOrDefault(
|
||||
session.StartPeriod + session.PeriodCount - 1);
|
||||
if (startSlot is null || endSlot is null) return;
|
||||
|
||||
var examDate = session.ExamDate;
|
||||
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
|
||||
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private async Task<Classroom?> FindBestClassroomAsync(
|
||||
ExamSession session,
|
||||
int studentCount,
|
||||
List<RoomOccupancy> occupied,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
|
||||
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
||||
|
||||
// Exclude classrooms already occupied in-memory
|
||||
var occupiedRoomIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet();
|
||||
|
||||
if (occupiedRoomIds.Count > 0)
|
||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
||||
|
||||
// Exclude classrooms occupied by DB sessions not yet tracked in memory
|
||||
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == session.ExamPlanId &&
|
||||
x.Id != session.Id &&
|
||||
x.ClassroomId != null &&
|
||||
x.StartsAt < session.EndsAt &&
|
||||
session.StartsAt < x.EndsAt)
|
||||
.Select(x => x.ClassroomId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (dbOccupiedRooms.Count > 0)
|
||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
||||
|
||||
return await query
|
||||
.OrderBy(x => x.Capacity)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<Teacher>> FindInvigilatorsAsync(
|
||||
ExamSession session,
|
||||
int needed,
|
||||
HashSet<Guid> excludeTeacherIds,
|
||||
List<InvigilatorOccupancy> occupied,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var busyTeacherIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
||||
.Select(x => x.TeacherId)
|
||||
.ToHashSet();
|
||||
|
||||
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
|
||||
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
|
||||
x.ExamSessionId != session.Id &&
|
||||
x.ExamSession!.StartsAt < session.EndsAt &&
|
||||
session.StartsAt < x.ExamSession.EndsAt)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
.ToListAsync(cancellationToken);
|
||||
session.StartsAt = session.ExamDate.ToDateTime(
|
||||
startSlot.StartsAt,
|
||||
DateTimeKind.Utc);
|
||||
session.EndsAt = session.ExamDate.ToDateTime(
|
||||
endSlot.EndsAt,
|
||||
DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExamArrangementResult(bool Success, string Message)
|
||||
{
|
||||
public static ExamArrangementResult Fail(string message) => new(false, message);
|
||||
public static ExamArrangementResult Fail(string message) =>
|
||||
new(false, message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamPublishJobProcessor(
|
||||
AppDbContext db,
|
||||
IAppCache cache,
|
||||
ILogger<ExamPublishJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.ExamPublishJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is ExamPublishJobStatus.Succeeded
|
||||
or ExamPublishJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamPublishJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.CurrentStep = "正在校验考试计划";
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
switch (job.Kind)
|
||||
{
|
||||
case ExamPublishJobKind.FormalExam:
|
||||
await PublishFormalExamAsync(job, stoppingToken);
|
||||
break;
|
||||
case ExamPublishJobKind.MakeupExam:
|
||||
await PublishMakeupExamAsync(job, stoppingToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"不支持的考试发布类型:{job.Kind}。");
|
||||
}
|
||||
|
||||
job.Status = ExamPublishJobStatus.Succeeded;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "发布完成";
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
|
||||
job.Id,
|
||||
job.Kind,
|
||||
job.PlanId);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Exam publish job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (ExamPublishValidationException validationException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
validationException,
|
||||
"Exam publish job {JobId} validation failed.",
|
||||
jobId);
|
||||
await MarkFailedAsync(jobId, validationException.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishFormalExamAsync(
|
||||
ExamPublishJob job,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = await db.ExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||
if (plan is null)
|
||||
throw new ExamPublishValidationException("考试计划不存在。");
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
|
||||
|
||||
// Step 1: check sessions exist — simple count, no JOIN
|
||||
var sessionCount = await db.ExamSessions
|
||||
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
|
||||
if (sessionCount == 0)
|
||||
throw new ExamPublishValidationException(
|
||||
"至少安排一个考试场次后才能发布。");
|
||||
|
||||
// Step 2: load session summaries — only necessary columns, no Include chains
|
||||
var sessions = await db.ExamSessions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.ClassroomId,
|
||||
InvigilatorCount = x.Invigilators.Count,
|
||||
RoomLinkCount = x.RoomLinks.Count
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Step 3: load roster counts — independent query
|
||||
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
|
||||
var rosterCounts = (await TeachingTaskRosterQuery
|
||||
.LoadForTasksAsync(db, taskIds, ct))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.Count());
|
||||
|
||||
// Step 4: validate each session's assignment completeness
|
||||
var unassignedSessionIds = new List<Guid>();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
|
||||
if (session.RoomLinkCount > 0)
|
||||
{
|
||||
// Has room links — check seat assignment via separate query
|
||||
var assignedSeatCount = await db.ExamSeats
|
||||
.CountAsync(seat =>
|
||||
seat.ExamSessionId == session.Id,
|
||||
ct);
|
||||
if (assignedSeatCount != rosterCount)
|
||||
unassignedSessionIds.Add(session.Id);
|
||||
}
|
||||
else if (!session.ClassroomId.HasValue ||
|
||||
session.InvigilatorCount == 0)
|
||||
{
|
||||
unassignedSessionIds.Add(session.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (unassignedSessionIds.Count > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
|
||||
"请先完成自动编排。");
|
||||
|
||||
// Step 5: check room invigilator sufficiency — separate query
|
||||
var insufficientInvigilatorCount = await db.ExamRoomSessions
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||
link.ExamRoom.Invigilators.Count <
|
||||
link.ExamRoom.RequiredInvigilatorCount)
|
||||
.Select(link => link.ExamSessionId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
if (insufficientInvigilatorCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
|
||||
"请先完成自动编排。");
|
||||
|
||||
// Step 6: validate mixed rooms — split into two independent queries
|
||||
// to avoid Seats × SessionLinks Cartesian product
|
||||
|
||||
// 6a: capacity overflow
|
||||
var overCapacityCount = await db.ExamRooms
|
||||
.Where(r => r.ExamPlanId == job.PlanId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
SeatCount = r.Seats.Count,
|
||||
Capacity = r.Classroom!.Capacity
|
||||
})
|
||||
.CountAsync(x => x.SeatCount > x.Capacity, ct);
|
||||
if (overCapacityCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
|
||||
|
||||
// 6b: course mismatch
|
||||
var courseMismatchCount = await db.ExamRoomSessions
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||
link.ExamSession!.TeachingTask!.CourseId !=
|
||||
link.ExamRoom.CourseId)
|
||||
.Select(link => link.ExamRoomId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
if (courseMismatchCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
|
||||
|
||||
// Step 7: publish
|
||||
plan.Status = ExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task PublishMakeupExamAsync(
|
||||
ExamPublishJob job,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||
if (plan is null)
|
||||
throw new ExamPublishValidationException("补考计划不存在。");
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
|
||||
|
||||
// Step 1: check sessions exist — simple count
|
||||
var sessionCount = await db.MakeupExamSessions
|
||||
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
|
||||
if (sessionCount == 0)
|
||||
throw new ExamPublishValidationException(
|
||||
"至少安排一个考试场次后才能发布。");
|
||||
|
||||
// Step 2: load session summaries — only necessary columns
|
||||
var sessions = await db.MakeupExamSessions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ClassroomId,
|
||||
InvigilatorCount = x.Invigilators.Count,
|
||||
EnrollmentCount = x.Enrollments.Count
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Step 3: validate completeness — no Include chain needed
|
||||
var unassigned = sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
|
||||
if (unassigned > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
|
||||
// Step 4: validate enrollments
|
||||
var empty = sessions.Count(x => x.EnrollmentCount == 0);
|
||||
if (empty > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {empty} 个场次没有登记补考学生。");
|
||||
|
||||
// Step 5: publish
|
||||
plan.Status = MakeupExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
job.Status = ExamPublishJobStatus.Failed;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "发布失败";
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExamPublishValidationException(string message)
|
||||
: InvalidOperationException(message);
|
||||
@@ -0,0 +1,212 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamSignInExportJobProcessor(
|
||||
AppDbContext db,
|
||||
ILogger<ExamSignInExportJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.ExamSignInExportJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is ExamSignInExportJobStatus.Succeeded
|
||||
or ExamSignInExportJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamSignInExportJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.CurrentStep = "正在生成考场签名单";
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
var plan = await db.ExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Name,
|
||||
TermName = x.AcademicTerm!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(stoppingToken);
|
||||
if (plan is null)
|
||||
{
|
||||
await MarkFailedAsync(jobId, "考试计划不存在。");
|
||||
return;
|
||||
}
|
||||
|
||||
var rooms = await db.ExamRooms.AsNoTracking()
|
||||
.Include(x => x.Course)
|
||||
.Include(x => x.Classroom)
|
||||
.ThenInclude(x => x!.Building)
|
||||
.Include(x => x.Invigilators)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.Include(x => x.SessionLinks)
|
||||
.ThenInclude(x => x.ExamSession)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.Include(x => x.Seats)
|
||||
.ThenInclude(x => x.Student)
|
||||
.ThenInclude(x => x!.AdministrativeClass)
|
||||
.Where(x => x.ExamPlanId == job.PlanId)
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.Classroom!.Building!.Name)
|
||||
.ThenBy(x => x.Classroom!.Name)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
var legacySessions = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == job.PlanId)
|
||||
.Where(x => !x.RoomLinks.Any())
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(session => new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.ExamDate,
|
||||
session.StartsAt,
|
||||
session.EndsAt,
|
||||
CourseCode = session.TeachingTask!.Course!.Code,
|
||||
CourseName = session.TeachingTask.Course.Name,
|
||||
session.TeachingTask.TaskNumber,
|
||||
BuildingName = session.Classroom != null
|
||||
? session.Classroom.Building!.Name
|
||||
: null,
|
||||
ClassroomName = session.Classroom != null
|
||||
? session.Classroom.Name
|
||||
: null,
|
||||
InvigilatorNames = session.Invigilators
|
||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
})
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (rooms.Count == 0 && legacySessions.Count == 0)
|
||||
{
|
||||
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
|
||||
return;
|
||||
}
|
||||
|
||||
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
legacySessions.Select(x => x.TeachingTaskId),
|
||||
stoppingToken);
|
||||
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
|
||||
|
||||
var sheets = rooms.Select(room => new ExamSignInSessionData(
|
||||
room.Id,
|
||||
room.ExamDate,
|
||||
room.StartsAt,
|
||||
room.EndsAt,
|
||||
room.Course!.Code,
|
||||
room.Course.Name,
|
||||
string.Join(
|
||||
"、",
|
||||
room.SessionLinks
|
||||
.Select(link =>
|
||||
link.ExamSession!.TeachingTask!.TaskNumber)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)),
|
||||
room.Classroom!.Building!.Name,
|
||||
room.Classroom.Name,
|
||||
room.Invigilators
|
||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
.ToList(),
|
||||
room.Seats
|
||||
.OrderBy(seat => seat.SeatNumber)
|
||||
.Select(seat => new ExamSignInStudentData(
|
||||
seat.StudentId,
|
||||
seat.Student!.StudentNumber,
|
||||
seat.Student.Name,
|
||||
seat.Student.AdministrativeClass!.Name,
|
||||
seat.SeatNumber))
|
||||
.ToList()))
|
||||
.Concat(legacySessions.Select(session =>
|
||||
new ExamSignInSessionData(
|
||||
session.Id,
|
||||
session.ExamDate,
|
||||
session.StartsAt,
|
||||
session.EndsAt,
|
||||
session.CourseCode,
|
||||
session.CourseName,
|
||||
session.TaskNumber,
|
||||
session.BuildingName,
|
||||
session.ClassroomName,
|
||||
session.InvigilatorNames.ToList(),
|
||||
studentsByTask[session.TeachingTaskId]
|
||||
.Select((student, index) =>
|
||||
new ExamSignInStudentData(
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
index + 1))
|
||||
.ToList())))
|
||||
.ToList();
|
||||
|
||||
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
|
||||
var bytes = ExamSignInWorkbookExporter.Create(data);
|
||||
|
||||
job.Status = ExamSignInExportJobStatus.Succeeded;
|
||||
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
|
||||
job.FileBytes = bytes;
|
||||
job.FileSize = bytes.Length;
|
||||
job.CurrentStep = "生成完成";
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
|
||||
job.Id,
|
||||
job.PlanId,
|
||||
bytes.Length);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Sign-in export job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
job.Status = ExamSignInExportJobStatus.Failed;
|
||||
job.CurrentStep = "生成失败";
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
|
||||
var normalized = new string(value
|
||||
.Select(c => invalid.Contains(c) ? '_' : c)
|
||||
.ToArray()).Trim();
|
||||
return normalized.Length > 100 ? normalized[..100] : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public static class ExamSignInWorkbookExporter
|
||||
{
|
||||
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
|
||||
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
|
||||
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
|
||||
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
|
||||
|
||||
public static byte[] Create(ExamSignInWorkbookData data)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
AddSummarySheet(workbook, data);
|
||||
|
||||
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"考场汇总"
|
||||
};
|
||||
foreach (var session in data.Sessions
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartsAt)
|
||||
.ThenBy(x => x.BuildingName)
|
||||
.ThenBy(x => x.ClassroomName)
|
||||
.ThenBy(x => x.CourseCode))
|
||||
{
|
||||
AddSignInSheet(
|
||||
workbook,
|
||||
data,
|
||||
session,
|
||||
UniqueSheetName(session, usedNames));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void AddSummarySheet(
|
||||
XLWorkbook workbook,
|
||||
ExamSignInWorkbookData data)
|
||||
{
|
||||
var sheet = workbook.Worksheets.Add("考场汇总");
|
||||
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||
sheet.ShowGridLines = false;
|
||||
|
||||
sheet.Range("A1:G1").Merge();
|
||||
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
|
||||
StyleTitle(sheet.Range("A1:G1"));
|
||||
sheet.Row(1).Height = 34;
|
||||
|
||||
sheet.Range("A2:G2").Merge();
|
||||
sheet.Cell("A2").Value =
|
||||
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||
StyleSubTitle(sheet.Range("A2:G2"));
|
||||
sheet.Row(2).Height = 24;
|
||||
|
||||
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
|
||||
for (var column = 1; column <= headers.Length; column++)
|
||||
sheet.Cell(4, column).Value = headers[column - 1];
|
||||
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
|
||||
|
||||
var row = 5;
|
||||
foreach (var session in data.Sessions
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartsAt)
|
||||
.ThenBy(x => x.BuildingName)
|
||||
.ThenBy(x => x.ClassroomName))
|
||||
{
|
||||
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
|
||||
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
|
||||
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||
sheet.Cell(row, 3).Value = Location(session);
|
||||
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
|
||||
sheet.Cell(row, 5).Value = session.TaskNumber;
|
||||
sheet.Cell(row, 6).Value = session.Students.Count;
|
||||
sheet.Cell(row, 7).Value = Invigilators(session);
|
||||
row++;
|
||||
}
|
||||
|
||||
if (row > 5)
|
||||
{
|
||||
var table = sheet.Range(4, 1, row - 1, headers.Length);
|
||||
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||
table.Style.Border.InsideBorderColor = Rule;
|
||||
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||
table.Style.Border.OutsideBorderColor = Rule;
|
||||
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
sheet.Rows(5, row - 1).Height = 25;
|
||||
}
|
||||
|
||||
sheet.Column(1).Width = 13;
|
||||
sheet.Column(2).Width = 15;
|
||||
sheet.Column(3).Width = 24;
|
||||
sheet.Column(4).Width = 30;
|
||||
sheet.Column(5).Width = 22;
|
||||
sheet.Column(6).Width = 11;
|
||||
sheet.Column(7).Width = 24;
|
||||
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
|
||||
sheet.SheetView.FreezeRows(4);
|
||||
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
|
||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
sheet.PageSetup.Margins
|
||||
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
||||
}
|
||||
|
||||
private static void AddSignInSheet(
|
||||
XLWorkbook workbook,
|
||||
ExamSignInWorkbookData data,
|
||||
ExamSignInSessionData session,
|
||||
string sheetName)
|
||||
{
|
||||
var sheet = workbook.Worksheets.Add(sheetName);
|
||||
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||
sheet.ShowGridLines = false;
|
||||
|
||||
sheet.Range("A1:G1").Merge();
|
||||
sheet.Cell("A1").Value = "考试考场签名单";
|
||||
StyleTitle(sheet.Range("A1:G1"));
|
||||
sheet.Cell("A1").Style.Alignment.Horizontal =
|
||||
XLAlignmentHorizontalValues.Center;
|
||||
sheet.Row(1).Height = 36;
|
||||
|
||||
sheet.Range("A2:G2").Merge();
|
||||
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
|
||||
StyleSubTitle(sheet.Range("A2:G2"));
|
||||
sheet.Cell("A2").Style.Alignment.Horizontal =
|
||||
XLAlignmentHorizontalValues.Center;
|
||||
sheet.Row(2).Height = 23;
|
||||
|
||||
sheet.Range("A3:D3").Merge();
|
||||
sheet.Cell("A3").Value =
|
||||
$"课程:{session.CourseCode} {session.CourseName}({session.TaskNumber})";
|
||||
sheet.Range("E3:G3").Merge();
|
||||
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
|
||||
|
||||
sheet.Range("A4:D4").Merge();
|
||||
sheet.Cell("A4").Value =
|
||||
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||
sheet.Range("E4:G4").Merge();
|
||||
sheet.Cell("E4").Value = $"考场:{Location(session)}";
|
||||
|
||||
sheet.Range("A5:D5").Merge();
|
||||
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
|
||||
sheet.Range("E5:G5").Merge();
|
||||
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
|
||||
|
||||
var metadata = sheet.Range("A3:G5");
|
||||
metadata.Style.Fill.BackgroundColor = XLColor.White;
|
||||
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
|
||||
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
metadata.Style.Alignment.WrapText = true;
|
||||
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
||||
metadata.Style.Border.BottomBorderColor = Rule;
|
||||
sheet.Rows(3, 5).Height = 24;
|
||||
|
||||
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
|
||||
for (var column = 1; column <= headers.Length; column++)
|
||||
sheet.Cell(7, column).Value = headers[column - 1];
|
||||
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
|
||||
sheet.Row(7).Height = 27;
|
||||
|
||||
var row = 8;
|
||||
foreach (var student in session.Students
|
||||
.OrderBy(x => x.SeatNumber)
|
||||
.ThenBy(x => x.StudentNumber))
|
||||
{
|
||||
var index = row - 7;
|
||||
sheet.Cell(row, 1).Value = index;
|
||||
sheet.Cell(row, 2).Value =
|
||||
(student.SeatNumber > 0 ? student.SeatNumber : index)
|
||||
.ToString("D3");
|
||||
sheet.Cell(row, 3).Value = student.StudentNumber;
|
||||
sheet.Cell(row, 4).Value = student.Name;
|
||||
sheet.Cell(row, 5).Value = student.ClassName;
|
||||
sheet.Row(row).Height = 28;
|
||||
row++;
|
||||
}
|
||||
|
||||
if (session.Students.Count == 0)
|
||||
{
|
||||
sheet.Range("A8:G8").Merge();
|
||||
sheet.Cell("A8").Value = "本场次暂无考生";
|
||||
sheet.Cell("A8").Style
|
||||
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
|
||||
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
|
||||
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
|
||||
sheet.Row(8).Height = 28;
|
||||
row = 9;
|
||||
}
|
||||
|
||||
var roster = sheet.Range(7, 1, row - 1, headers.Length);
|
||||
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||
roster.Style.Border.InsideBorderColor = Rule;
|
||||
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
|
||||
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
|
||||
XLAlignmentHorizontalValues.Center;
|
||||
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
|
||||
|
||||
sheet.Column(1).Width = 7;
|
||||
sheet.Column(2).Width = 10;
|
||||
sheet.Column(3).Width = 16;
|
||||
sheet.Column(4).Width = 12;
|
||||
sheet.Column(5).Width = 20;
|
||||
sheet.Column(6).Width = 22;
|
||||
sheet.Column(7).Width = 17;
|
||||
sheet.SheetView.FreezeRows(7);
|
||||
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
|
||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
sheet.PageSetup.Margins
|
||||
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
|
||||
}
|
||||
|
||||
private static void StyleTitle(IXLRange range) =>
|
||||
range.Style
|
||||
.Font.SetBold()
|
||||
.Font.SetFontSize(18)
|
||||
.Font.SetFontColor(XLColor.White)
|
||||
.Fill.SetBackgroundColor(Navy)
|
||||
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||
|
||||
private static void StyleSubTitle(IXLRange range) =>
|
||||
range.Style
|
||||
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
|
||||
.Fill.SetBackgroundColor(Pale)
|
||||
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||
|
||||
private static void StyleHeader(IXLRange range) =>
|
||||
range.Style
|
||||
.Font.SetBold()
|
||||
.Font.SetFontColor(XLColor.White)
|
||||
.Fill.SetBackgroundColor(Teal)
|
||||
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
|
||||
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||
|
||||
private static string Location(ExamSignInSessionData session) =>
|
||||
string.Join(" · ", new[]
|
||||
{
|
||||
session.BuildingName,
|
||||
session.ClassroomName
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
|
||||
? value
|
||||
: "待分配考场";
|
||||
|
||||
private static string Invigilators(ExamSignInSessionData session) =>
|
||||
session.InvigilatorNames.Count > 0
|
||||
? string.Join('、', session.InvigilatorNames)
|
||||
: "待分配";
|
||||
|
||||
private static string UniqueSheetName(
|
||||
ExamSignInSessionData session,
|
||||
ISet<string> usedNames)
|
||||
{
|
||||
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
|
||||
? "待分配"
|
||||
: session.ClassroomName;
|
||||
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
|
||||
var invalidCharacters = new HashSet<char>(":\\/?*[]");
|
||||
var sanitized = new string(raw
|
||||
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
|
||||
.ToArray()).Trim(' ', '\'');
|
||||
if (sanitized.Length == 0) sanitized = "考场";
|
||||
if (sanitized.Length > 31) sanitized = sanitized[..31];
|
||||
|
||||
var candidate = sanitized;
|
||||
var suffix = 2;
|
||||
while (!usedNames.Add(candidate))
|
||||
{
|
||||
var marker = $"-{suffix++}";
|
||||
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
|
||||
candidate = sanitized[..prefixLength] + marker;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExamSignInWorkbookData(
|
||||
string PlanName,
|
||||
string TermName,
|
||||
IReadOnlyList<ExamSignInSessionData> Sessions);
|
||||
|
||||
public sealed record ExamSignInSessionData(
|
||||
Guid SessionId,
|
||||
DateOnly ExamDate,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TaskNumber,
|
||||
string? BuildingName,
|
||||
string? ClassroomName,
|
||||
IReadOnlyList<string> InvigilatorNames,
|
||||
IReadOnlyList<ExamSignInStudentData> Students);
|
||||
|
||||
public sealed record ExamSignInStudentData(
|
||||
Guid StudentId,
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
string ClassName,
|
||||
int SeatNumber = 0);
|
||||
@@ -0,0 +1,15 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
internal static class InvigilatorCandidateQuery
|
||||
{
|
||||
public static IQueryable<Teacher> Create(
|
||||
AppDbContext db,
|
||||
IEnumerable<Guid> excludedTeacherIds) =>
|
||||
db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(excludedTeacherIds, x => x.Id);
|
||||
}
|
||||
@@ -26,6 +26,10 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||
|
||||
if (plan is null)
|
||||
@@ -96,7 +100,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
||||
assignedRooms++;
|
||||
messages.Add(
|
||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)");
|
||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -167,10 +171,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount);
|
||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
|
||||
|
||||
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
||||
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||
if (buildingIds.Count > 0)
|
||||
query = query.Where(x => buildingIds.Contains(x.BuildingId));
|
||||
|
||||
var occupiedRoomIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
@@ -222,11 +229,27 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
var candidates = await InvigilatorCandidateQuery
|
||||
.Create(db, busyTeacherIds)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return candidates
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(needed)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Experiments;
|
||||
|
||||
internal static class CentralizedExperimentConflictQuery
|
||||
{
|
||||
public static IQueryable<Guid> TaskIdsForTeachers(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> teacherIds) =>
|
||||
db.TeachingTaskTeachers.AsNoTracking()
|
||||
.WhereIn(teacherIds, x => x.TeacherId)
|
||||
.Select(x => x.TeachingTaskId);
|
||||
|
||||
public static IQueryable<Guid> TaskIdsForClasses(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> classIds) =>
|
||||
db.TeachingTaskClasses.AsNoTracking()
|
||||
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||
.Select(x => x.TeachingTaskId);
|
||||
|
||||
public static IQueryable<ScheduleEntry> ScheduleEntries(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||
Guid academicTermId,
|
||||
int dayOfWeek,
|
||||
int week,
|
||||
int startPeriod,
|
||||
int periodCount) =>
|
||||
db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||
x.SchedulePlan.AcademicTermId == academicTermId &&
|
||||
x.DayOfWeek == dayOfWeek &&
|
||||
x.StartWeek <= week &&
|
||||
x.EndWeek >= week &&
|
||||
x.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < x.StartPeriod + x.PeriodCount)
|
||||
.WhereIn(relatedTaskIds, x => x.TeachingTaskId);
|
||||
|
||||
public static IQueryable<ExperimentSession> ExperimentSessions(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||
DateOnly date,
|
||||
int startPeriod,
|
||||
int periodCount) =>
|
||||
db.ExperimentSessions.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExperimentSessionStatus.Scheduled &&
|
||||
x.SessionDate == date &&
|
||||
x.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < x.StartPeriod + x.PeriodCount &&
|
||||
x.ExperimentProject!.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized)
|
||||
.WhereIn(
|
||||
relatedTaskIds,
|
||||
x => x.ExperimentProject!.TeachingTaskId);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
public static class ExperimentGradeAggregationService
|
||||
{
|
||||
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
|
||||
AppDbContext db,
|
||||
Guid teachingTaskId,
|
||||
IReadOnlyCollection<Guid> studentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status ==
|
||||
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
||||
x.ExperimentProject!.TeachingTaskId == teachingTaskId)
|
||||
.OrderBy(x => x.ExperimentProject!.Code)
|
||||
.Select(x => new PublishedExperimentSheet(
|
||||
x.Id,
|
||||
x.ExperimentProject!.Code,
|
||||
x.ExperimentProject.Name,
|
||||
x.ContributionWeight,
|
||||
x.Records.Select(record => new PublishedExperimentScore(
|
||||
record.StudentId,
|
||||
record.TotalScore)).ToList()))
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scores = new Dictionary<Guid, decimal?>();
|
||||
foreach (var studentId in studentIds.Distinct())
|
||||
{
|
||||
decimal weightedTotal = 0;
|
||||
decimal totalWeight = 0;
|
||||
var complete = sheets.Count > 0;
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var score = sheet.Scores.FirstOrDefault(x =>
|
||||
x.StudentId == studentId);
|
||||
if (score?.TotalScore is not decimal totalScore)
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||
totalWeight += sheet.ContributionWeight;
|
||||
}
|
||||
scores[studentId] = complete && totalWeight > 0
|
||||
? Math.Round(
|
||||
weightedTotal / totalWeight,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
}
|
||||
|
||||
return new ExperimentGradeAggregateResult(
|
||||
sheets.Count,
|
||||
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ContributionWeight)).ToList(),
|
||||
scores);
|
||||
}
|
||||
|
||||
private sealed record PublishedExperimentSheet(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
decimal ContributionWeight,
|
||||
List<PublishedExperimentScore> Scores);
|
||||
|
||||
private sealed record PublishedExperimentScore(
|
||||
Guid StudentId,
|
||||
decimal? TotalScore);
|
||||
}
|
||||
|
||||
public sealed record ExperimentGradeAggregateResult(
|
||||
int PublishedProjectCount,
|
||||
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
|
||||
IReadOnlyDictionary<Guid, decimal?> Scores);
|
||||
|
||||
public sealed record ExperimentGradeAggregateProject(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
decimal ContributionWeight);
|
||||
@@ -0,0 +1,45 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
public static class ExperimentGradeCalculator
|
||||
{
|
||||
public static bool AreWeightsValid(
|
||||
decimal contributionWeight,
|
||||
decimal passScore,
|
||||
IEnumerable<ExperimentGradeItem> items)
|
||||
{
|
||||
var itemList = items.ToList();
|
||||
return contributionWeight is > 0 and <= 100 &&
|
||||
passScore is >= 0 and <= 100 &&
|
||||
itemList.Count > 0 &&
|
||||
itemList.All(item => item.Weight is > 0 and <= 100) &&
|
||||
itemList.Sum(item => item.Weight) == 100;
|
||||
}
|
||||
|
||||
public static decimal? CalculateTotal(
|
||||
ExperimentGradeRecord record,
|
||||
IReadOnlyCollection<ExperimentGradeItem> items)
|
||||
{
|
||||
if (record.SafetyViolation ||
|
||||
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
|
||||
return 0;
|
||||
if (record.ParticipationStatus is
|
||||
ExperimentParticipationStatus.Pending or
|
||||
ExperimentParticipationStatus.Excused or
|
||||
ExperimentParticipationStatus.Exempt)
|
||||
return null;
|
||||
|
||||
var scores = record.ItemScores.ToDictionary(
|
||||
x => x.ExperimentGradeItemId);
|
||||
decimal total = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!scores.TryGetValue(item.Id, out var score) ||
|
||||
!score.Score.HasValue)
|
||||
return null;
|
||||
total += score.Score.Value * item.Weight / 100;
|
||||
}
|
||||
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Graduation;
|
||||
|
||||
public sealed record AcademicPlanningCourseSnapshot(
|
||||
Guid CourseId,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
IReadOnlyCollection<Guid> PrerequisiteCourseIds);
|
||||
|
||||
public sealed record AcademicPlanningPrerequisiteIssue(
|
||||
Guid CourseId,
|
||||
Guid PrerequisiteCourseId,
|
||||
int PlannedSemester,
|
||||
int? PrerequisitePlannedSemester);
|
||||
|
||||
public static class AcademicPlanningRules
|
||||
{
|
||||
public const decimal RecommendedSemesterCreditLimit = 24;
|
||||
public const decimal HeavySemesterCreditLimit = 30;
|
||||
|
||||
public static IReadOnlyList<AcademicPlanningPrerequisiteIssue>
|
||||
FindPrerequisiteIssues(
|
||||
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||
IReadOnlySet<Guid> completedCourseIds,
|
||||
IReadOnlySet<Guid> inProgressCourseIds,
|
||||
IReadOnlyDictionary<Guid, int> plannedSemesters)
|
||||
{
|
||||
var issues = new List<AcademicPlanningPrerequisiteIssue>();
|
||||
foreach (var course in courses.Where(x =>
|
||||
plannedSemesters.ContainsKey(x.CourseId)))
|
||||
{
|
||||
var plannedSemester = plannedSemesters[course.CourseId];
|
||||
foreach (var prerequisiteId in course.PrerequisiteCourseIds)
|
||||
{
|
||||
if (completedCourseIds.Contains(prerequisiteId) ||
|
||||
inProgressCourseIds.Contains(prerequisiteId))
|
||||
continue;
|
||||
|
||||
if (!plannedSemesters.TryGetValue(
|
||||
prerequisiteId,
|
||||
out var prerequisiteSemester) ||
|
||||
prerequisiteSemester >= plannedSemester)
|
||||
{
|
||||
issues.Add(new AcademicPlanningPrerequisiteIssue(
|
||||
course.CourseId,
|
||||
prerequisiteId,
|
||||
plannedSemester,
|
||||
plannedSemesters.GetValueOrDefault(prerequisiteId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Guid> SuggestNextSemester(
|
||||
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||
IReadOnlySet<Guid> completedCourseIds,
|
||||
IReadOnlySet<Guid> inProgressCourseIds,
|
||||
int nextSemester,
|
||||
decimal targetCredits = RecommendedSemesterCreditLimit)
|
||||
{
|
||||
var available = courses
|
||||
.Where(x =>
|
||||
!completedCourseIds.Contains(x.CourseId) &&
|
||||
!inProgressCourseIds.Contains(x.CourseId) &&
|
||||
x.RecommendedSemester <= nextSemester &&
|
||||
x.PrerequisiteCourseIds.All(prerequisiteId =>
|
||||
completedCourseIds.Contains(prerequisiteId) ||
|
||||
inProgressCourseIds.Contains(prerequisiteId)))
|
||||
.OrderBy(x => x.Type == CurriculumCourseType.Required ? 0 : 1)
|
||||
.ThenBy(x => x.RecommendedSemester > nextSemester ? 1 : 0)
|
||||
.ThenBy(x => x.RecommendedSemester)
|
||||
.ThenBy(x => x.CourseName)
|
||||
.ToList();
|
||||
|
||||
var selected = new List<Guid>();
|
||||
decimal credits = 0;
|
||||
foreach (var course in available)
|
||||
{
|
||||
var isOverdueRequired =
|
||||
course.Type == CurriculumCourseType.Required &&
|
||||
course.RecommendedSemester <= nextSemester;
|
||||
if (!isOverdueRequired &&
|
||||
selected.Count > 0 &&
|
||||
credits + course.Credits > targetCredits)
|
||||
continue;
|
||||
|
||||
selected.Add(course.CourseId);
|
||||
credits += course.Credits;
|
||||
if (credits >= targetCredits) break;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
public static int EstimateCompletionSemester(
|
||||
int nextSemester,
|
||||
int latestPlannedSemester,
|
||||
decimal remainingCredits,
|
||||
int remainingRequirementCount)
|
||||
{
|
||||
if (remainingCredits <= 0 && remainingRequirementCount <= 0)
|
||||
return Math.Max(nextSemester - 1, latestPlannedSemester);
|
||||
|
||||
var byCredits = (int)Math.Ceiling(
|
||||
Math.Max(remainingCredits, 0) / RecommendedSemesterCreditLimit);
|
||||
var byRequirements = (int)Math.Ceiling(
|
||||
Math.Max(remainingRequirementCount, 0) / 6m);
|
||||
var additionalSemesters = Math.Max(1, Math.Max(byCredits, byRequirements));
|
||||
return Math.Max(nextSemester - 1, latestPlannedSemester) +
|
||||
additionalSemesters;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Operations;
|
||||
|
||||
public sealed record BackupArtifact(
|
||||
string Id,
|
||||
string FileName,
|
||||
string Provider,
|
||||
DateTime CreatedAt,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
string? Note,
|
||||
DateTime? LastDrillAt,
|
||||
bool? LastDrillSucceeded,
|
||||
string? LastDrillDetail,
|
||||
long? LastDrillDurationMilliseconds);
|
||||
|
||||
public sealed record RestoreDrillResult(
|
||||
string BackupId,
|
||||
bool Succeeded,
|
||||
DateTime CompletedAt,
|
||||
string Detail,
|
||||
long DurationMilliseconds,
|
||||
int? TableCount);
|
||||
|
||||
public sealed class DatabaseBackupService(
|
||||
DatabaseOptions databaseOptions,
|
||||
OperationsOptions options,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment,
|
||||
ILogger<DatabaseBackupService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly SemaphoreSlim operationLock = new(1, 1);
|
||||
private readonly string backupDirectory = ResolveBackupDirectory(
|
||||
options.BackupDirectory,
|
||||
environment.ContentRootPath);
|
||||
|
||||
public async Task<IReadOnlyCollection<BackupArtifact>> ListAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(backupDirectory);
|
||||
var items = new List<BackupArtifact>();
|
||||
foreach (var metadataPath in Directory.EnumerateFiles(
|
||||
backupDirectory,
|
||||
"*.metadata.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(metadataPath);
|
||||
var artifact = await JsonSerializer.DeserializeAsync<BackupArtifact>(
|
||||
stream,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
if (artifact is not null &&
|
||||
File.Exists(Path.Combine(backupDirectory, artifact.FileName)))
|
||||
{
|
||||
items.Add(artifact);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Unable to read backup metadata {MetadataFile}.",
|
||||
Path.GetFileName(metadataPath));
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<BackupArtifact> CreateAsync(
|
||||
string? note,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(backupDirectory);
|
||||
var createdAt = DateTime.UtcNow;
|
||||
var id = $"{createdAt:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..29];
|
||||
var provider = NormalizeProvider(databaseOptions.Provider);
|
||||
var extension = provider == "SQLite" ? ".sqlite" : ".sql";
|
||||
var fileName = $"jiaowu-{id}{extension}";
|
||||
var backupPath = Path.Combine(backupDirectory, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
if (provider == "SQLite")
|
||||
await CreateSqliteBackupAsync(backupPath, cancellationToken);
|
||||
else
|
||||
await CreateMySqlBackupAsync(backupPath, cancellationToken);
|
||||
|
||||
var fileInfo = new FileInfo(backupPath);
|
||||
var artifact = new BackupArtifact(
|
||||
id,
|
||||
fileName,
|
||||
provider,
|
||||
createdAt,
|
||||
fileInfo.Length,
|
||||
await ComputeHashAsync(backupPath, cancellationToken),
|
||||
NormalizeNote(note),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
await WriteMetadataAsync(artifact, cancellationToken);
|
||||
return artifact;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(backupPath))
|
||||
File.Delete(backupPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RestoreDrillResult> RunRestoreDrillAsync(
|
||||
string backupId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var artifact = (await ListAsync(cancellationToken))
|
||||
.SingleOrDefault(x => x.Id.Equals(backupId, StringComparison.Ordinal));
|
||||
if (artifact is null)
|
||||
throw new FileNotFoundException("未找到指定备份。");
|
||||
|
||||
var backupPath = Path.Combine(backupDirectory, artifact.FileName);
|
||||
var actualHash = await ComputeHashAsync(backupPath, cancellationToken);
|
||||
if (!CryptographicOperations.FixedTimeEquals(
|
||||
Convert.FromHexString(artifact.Sha256),
|
||||
Convert.FromHexString(actualHash)))
|
||||
{
|
||||
var damaged = await CompleteDrillAsync(
|
||||
artifact,
|
||||
false,
|
||||
"备份文件校验和不一致,恢复演练已中止。",
|
||||
0,
|
||||
null,
|
||||
cancellationToken);
|
||||
return damaged;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
RestoreDrillResult result;
|
||||
try
|
||||
{
|
||||
var tableCount = artifact.Provider == "SQLite"
|
||||
? await DrillSqliteAsync(backupPath, cancellationToken)
|
||||
: await DrillMySqlAsync(backupPath, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
result = await CompleteDrillAsync(
|
||||
artifact,
|
||||
true,
|
||||
$"已在隔离数据库完成恢复并通过完整性检查,共发现 {tableCount} 张业务表。",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
tableCount,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Restore drill failed for backup {BackupId}.",
|
||||
artifact.Id);
|
||||
result = await CompleteDrillAsync(
|
||||
artifact,
|
||||
false,
|
||||
$"恢复演练失败:{SafeMessage(exception)}",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateSqliteBackupAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceBuilder = new SqliteConnectionStringBuilder(
|
||||
configuration.GetConnectionString("SQLite")
|
||||
?? throw new InvalidOperationException("缺少 ConnectionStrings:SQLite。"));
|
||||
if (!Path.IsPathRooted(sourceBuilder.DataSource))
|
||||
{
|
||||
sourceBuilder.DataSource = Path.GetFullPath(
|
||||
sourceBuilder.DataSource,
|
||||
environment.ContentRootPath);
|
||||
}
|
||||
|
||||
var destinationBuilder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = backupPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = false
|
||||
};
|
||||
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
|
||||
await using var destination = new SqliteConnection(destinationBuilder.ConnectionString);
|
||||
await source.OpenAsync(cancellationToken);
|
||||
await destination.OpenAsync(cancellationToken);
|
||||
source.BackupDatabase(destination);
|
||||
}
|
||||
|
||||
private async Task CreateMySqlBackupAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = GetMySqlConnectionBuilder();
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--protocol=tcp",
|
||||
$"--host={connection.Server}",
|
||||
$"--port={connection.Port}",
|
||||
$"--user={connection.UserID}",
|
||||
"--single-transaction",
|
||||
"--quick",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
"--events",
|
||||
"--hex-blob",
|
||||
"--default-character-set=utf8mb4"
|
||||
};
|
||||
arguments.AddRange(options.MySqlAdditionalArguments);
|
||||
arguments.Add(connection.Database);
|
||||
|
||||
await using var output = new FileStream(
|
||||
backupPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
81920,
|
||||
FileOptions.Asynchronous);
|
||||
await RunToolAsync(
|
||||
options.MySqlDumpPath,
|
||||
arguments,
|
||||
connection.Password,
|
||||
standardInput: null,
|
||||
standardOutput: output,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<int> DrillSqliteAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var drillPath = Path.Combine(
|
||||
Path.GetDirectoryName(backupPath)!,
|
||||
$".restore-drill-{Guid.NewGuid():N}.sqlite");
|
||||
try
|
||||
{
|
||||
var sourceBuilder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = backupPath,
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
Pooling = false
|
||||
};
|
||||
var drillBuilder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = drillPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = false
|
||||
};
|
||||
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
|
||||
await using var drill = new SqliteConnection(drillBuilder.ConnectionString);
|
||||
await source.OpenAsync(cancellationToken);
|
||||
await drill.OpenAsync(cancellationToken);
|
||||
source.BackupDatabase(drill);
|
||||
|
||||
await using var integrity = drill.CreateCommand();
|
||||
integrity.CommandText = "PRAGMA integrity_check;";
|
||||
var integrityResult = Convert.ToString(
|
||||
await integrity.ExecuteScalarAsync(cancellationToken));
|
||||
if (!string.Equals(integrityResult, "ok", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"SQLite 完整性检查返回 {integrityResult ?? "空结果"}。");
|
||||
|
||||
await using var tables = drill.CreateCommand();
|
||||
tables.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master " +
|
||||
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
|
||||
return Convert.ToInt32(await tables.ExecuteScalarAsync(cancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(drillPath))
|
||||
File.Delete(drillPath);
|
||||
if (File.Exists($"{drillPath}-shm"))
|
||||
File.Delete($"{drillPath}-shm");
|
||||
if (File.Exists($"{drillPath}-wal"))
|
||||
File.Delete($"{drillPath}-wal");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> DrillMySqlAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = GetMySqlConnectionBuilder();
|
||||
var drillDatabase = $"jiaowu_restore_drill_{DateTime.UtcNow:yyyyMMddHHmmss}_" +
|
||||
Guid.NewGuid().ToString("N")[..8];
|
||||
var adminBuilder = new MySqlConnectionStringBuilder(connection.ConnectionString)
|
||||
{
|
||||
Database = ""
|
||||
};
|
||||
|
||||
await using var admin = new MySqlConnection(adminBuilder.ConnectionString);
|
||||
await admin.OpenAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await using (var create = admin.CreateCommand())
|
||||
{
|
||||
create.CommandText =
|
||||
$"CREATE DATABASE `{drillDatabase}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;";
|
||||
await create.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--protocol=tcp",
|
||||
$"--host={connection.Server}",
|
||||
$"--port={connection.Port}",
|
||||
$"--user={connection.UserID}",
|
||||
"--default-character-set=utf8mb4"
|
||||
};
|
||||
arguments.AddRange(options.MySqlAdditionalArguments);
|
||||
arguments.Add($"--database={drillDatabase}");
|
||||
await using (var input = new FileStream(
|
||||
backupPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
81920,
|
||||
FileOptions.Asynchronous))
|
||||
{
|
||||
await RunToolAsync(
|
||||
options.MySqlClientPath,
|
||||
arguments,
|
||||
connection.Password,
|
||||
input,
|
||||
standardOutput: null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await using var count = admin.CreateCommand();
|
||||
count.CommandText =
|
||||
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||
"WHERE table_schema = @schema AND table_type = 'BASE TABLE';";
|
||||
count.Parameters.AddWithValue("@schema", drillDatabase);
|
||||
return Convert.ToInt32(await count.ExecuteScalarAsync(cancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var drop = admin.CreateCommand();
|
||||
drop.CommandText = $"DROP DATABASE IF EXISTS `{drillDatabase}`;";
|
||||
await drop.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogCritical(
|
||||
exception,
|
||||
"Unable to remove isolated restore drill database {DatabaseName}.",
|
||||
drillDatabase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunToolAsync(
|
||||
string executable,
|
||||
IReadOnlyCollection<string> arguments,
|
||||
string password,
|
||||
Stream? standardInput,
|
||||
Stream? standardOutput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = standardInput is not null,
|
||||
RedirectStandardOutput = standardOutput is not null,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
foreach (var argument in arguments)
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
startInfo.Environment["MYSQL_PWD"] = password;
|
||||
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException($"无法启动数据库工具 {executable}。");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromMinutes(options.ToolTimeoutMinutes));
|
||||
var errorTask = process.StandardError.ReadToEndAsync(timeout.Token);
|
||||
var inputTask = standardInput is null
|
||||
? Task.CompletedTask
|
||||
: CopyInputAsync(standardInput, process.StandardInput.BaseStream, timeout.Token);
|
||||
var outputTask = standardOutput is null
|
||||
? Task.CompletedTask
|
||||
: process.StandardOutput.BaseStream.CopyToAsync(
|
||||
standardOutput,
|
||||
timeout.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(
|
||||
process.WaitForExitAsync(timeout.Token),
|
||||
inputTask,
|
||||
outputTask);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(entireProcessTree: true);
|
||||
throw;
|
||||
}
|
||||
|
||||
var error = await errorTask;
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"数据库工具执行失败(退出码 {process.ExitCode}):{TrimToolError(error)}");
|
||||
}
|
||||
|
||||
private static async Task CopyInputAsync(
|
||||
Stream input,
|
||||
Stream processInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await input.CopyToAsync(processInput, cancellationToken);
|
||||
await processInput.FlushAsync(cancellationToken);
|
||||
processInput.Close();
|
||||
}
|
||||
|
||||
private MySqlConnectionStringBuilder GetMySqlConnectionBuilder()
|
||||
{
|
||||
var value = configuration.GetConnectionString("OperationsMySql");
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"MySQL 备份与恢复演练必须配置独立的 " +
|
||||
"ConnectionStrings:OperationsMySql 运维账号,不能复用日常业务账号。");
|
||||
}
|
||||
var builder = new MySqlConnectionStringBuilder(value);
|
||||
if (string.IsNullOrWhiteSpace(builder.Database))
|
||||
throw new InvalidOperationException(
|
||||
"OperationsMySql 连接字符串未指定业务数据库名称。");
|
||||
return builder;
|
||||
}
|
||||
|
||||
private async Task<RestoreDrillResult> CompleteDrillAsync(
|
||||
BackupArtifact artifact,
|
||||
bool succeeded,
|
||||
string detail,
|
||||
long durationMilliseconds,
|
||||
int? tableCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var completedAt = DateTime.UtcNow;
|
||||
var updated = artifact with
|
||||
{
|
||||
LastDrillAt = completedAt,
|
||||
LastDrillSucceeded = succeeded,
|
||||
LastDrillDetail = detail,
|
||||
LastDrillDurationMilliseconds = durationMilliseconds
|
||||
};
|
||||
await WriteMetadataAsync(updated, cancellationToken);
|
||||
return new RestoreDrillResult(
|
||||
artifact.Id,
|
||||
succeeded,
|
||||
completedAt,
|
||||
detail,
|
||||
durationMilliseconds,
|
||||
tableCount);
|
||||
}
|
||||
|
||||
private async Task WriteMetadataAsync(
|
||||
BackupArtifact artifact,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadataPath = Path.Combine(
|
||||
backupDirectory,
|
||||
$"{artifact.Id}.metadata.json");
|
||||
var temporaryPath = $"{metadataPath}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(
|
||||
temporaryPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
16384,
|
||||
FileOptions.Asynchronous))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
artifact,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
}
|
||||
File.Move(temporaryPath, metadataPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(
|
||||
await SHA256.HashDataAsync(stream, cancellationToken));
|
||||
}
|
||||
|
||||
private static string ResolveBackupDirectory(
|
||||
string configuredPath,
|
||||
string contentRoot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
throw new InvalidOperationException("Operations:BackupDirectory 不能为空。");
|
||||
return Path.IsPathRooted(configuredPath)
|
||||
? Path.GetFullPath(configuredPath)
|
||||
: Path.GetFullPath(configuredPath, contentRoot);
|
||||
}
|
||||
|
||||
private static string NormalizeProvider(string provider) =>
|
||||
provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)
|
||||
? "SQLite"
|
||||
: provider.Equals("MySql", StringComparison.OrdinalIgnoreCase)
|
||||
? "MySql"
|
||||
: throw new InvalidOperationException($"不支持数据库 Provider '{provider}'。");
|
||||
|
||||
private static string? NormalizeNote(string? note)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note)) return null;
|
||||
var trimmed = note.Trim();
|
||||
return trimmed.Length <= 200 ? trimmed : trimmed[..200];
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception exception)
|
||||
{
|
||||
var message = exception.GetBaseException().Message;
|
||||
return message.Length <= 500 ? message : message[..500];
|
||||
}
|
||||
|
||||
private static string TrimToolError(string error)
|
||||
{
|
||||
var trimmed = error.Trim();
|
||||
if (trimmed.Length == 0) return "未返回错误详情。";
|
||||
return trimmed.Length <= 500 ? trimmed : trimmed[..500];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Diagnostics;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Operations;
|
||||
|
||||
public sealed record OperationalComponentHealth(
|
||||
string Key,
|
||||
string Label,
|
||||
string Status,
|
||||
string Backend,
|
||||
long? LatencyMilliseconds,
|
||||
string Detail);
|
||||
|
||||
public sealed record OperationalHealthSnapshot(
|
||||
DateTime CheckedAt,
|
||||
string OverallStatus,
|
||||
IReadOnlyCollection<OperationalComponentHealth> Components,
|
||||
BackgroundJobBacklogSnapshot? Backlog);
|
||||
|
||||
public sealed class OperationalHealthService(
|
||||
AppDbContext db,
|
||||
IServiceProvider services,
|
||||
IBackgroundJobTransport transport,
|
||||
BackgroundJobMonitoringService monitoring,
|
||||
DatabaseOptions databaseOptions,
|
||||
AppCacheOptions cacheOptions,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
public async Task<OperationalHealthSnapshot> CheckAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await CheckDatabaseAsync(cancellationToken);
|
||||
var cache = await CheckCacheAsync(cancellationToken);
|
||||
var (messaging, backlog) = await CheckMessagingAsync(cancellationToken);
|
||||
var components = new[] { database, cache, messaging };
|
||||
var overall = components.Any(x => x.Status == "unhealthy")
|
||||
? "unhealthy"
|
||||
: components.Any(x => x.Status == "warning")
|
||||
? "warning"
|
||||
: "healthy";
|
||||
return new OperationalHealthSnapshot(
|
||||
DateTime.UtcNow,
|
||||
overall,
|
||||
components,
|
||||
backlog);
|
||||
}
|
||||
|
||||
private async Task<OperationalComponentHealth> CheckDatabaseAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var canConnect = await db.Database.CanConnectAsync(cancellationToken);
|
||||
stopwatch.Stop();
|
||||
return canConnect
|
||||
? new OperationalComponentHealth(
|
||||
"database",
|
||||
"数据库",
|
||||
"healthy",
|
||||
databaseOptions.Provider,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
"连接与基础查询正常。")
|
||||
: new OperationalComponentHealth(
|
||||
"database",
|
||||
"数据库",
|
||||
"unhealthy",
|
||||
databaseOptions.Provider,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
"无法建立数据库连接。");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new OperationalComponentHealth(
|
||||
"database",
|
||||
"数据库",
|
||||
"unhealthy",
|
||||
databaseOptions.Provider,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
SafeMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<OperationalComponentHealth> CheckCacheAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!cacheOptions.Enabled)
|
||||
{
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"warning",
|
||||
"disabled",
|
||||
null,
|
||||
"缓存已通过配置关闭,所有查询将直接访问数据源。");
|
||||
}
|
||||
|
||||
var hasRedisConfiguration = !string.IsNullOrWhiteSpace(
|
||||
configuration.GetConnectionString("Redis"));
|
||||
var distributedCache = services.GetService<IDistributedCache>();
|
||||
if (!hasRedisConfiguration || distributedCache is null)
|
||||
{
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"healthy",
|
||||
"memory",
|
||||
null,
|
||||
"使用进程内混合缓存;服务重启后缓存会自然重建。");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await distributedCache.GetAsync(
|
||||
"jiaowu:operations:health-probe",
|
||||
cancellationToken);
|
||||
stopwatch.Stop();
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"healthy",
|
||||
"redis",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
"Redis 连接与读取探针正常。");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"unhealthy",
|
||||
"redis",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
SafeMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(OperationalComponentHealth Component,
|
||||
BackgroundJobBacklogSnapshot? Backlog)> CheckMessagingAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
BackgroundJobBacklogSnapshot? backlog = null;
|
||||
try
|
||||
{
|
||||
var healthy = await transport.CheckHealthAsync(cancellationToken);
|
||||
backlog = await monitoring.GetSnapshotAsync(cancellationToken);
|
||||
stopwatch.Stop();
|
||||
var stuck = backlog.ExpiredLeases > 0 ||
|
||||
backlog.OldestUnfinishedAgeSeconds > 1800;
|
||||
var status = !healthy
|
||||
? "unhealthy"
|
||||
: stuck
|
||||
? "warning"
|
||||
: "healthy";
|
||||
var detail = !healthy
|
||||
? "后台任务传输不可用。"
|
||||
: stuck
|
||||
? $"发现 {backlog.ExpiredLeases} 个过期租约,最早未完成任务已等待 " +
|
||||
$"{Math.Round(backlog.OldestUnfinishedAgeSeconds ?? 0)} 秒。"
|
||||
: $"待发布 {backlog.Pending + backlog.Publishing}," +
|
||||
$"待处理 {backlog.Published + backlog.Processing}。";
|
||||
return (
|
||||
new OperationalComponentHealth(
|
||||
"messaging",
|
||||
"后台任务通道",
|
||||
status,
|
||||
transport.IsDurable ? "rabbitmq" : "memory",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
detail),
|
||||
backlog);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return (
|
||||
new OperationalComponentHealth(
|
||||
"messaging",
|
||||
"后台任务通道",
|
||||
"unhealthy",
|
||||
transport.IsDurable ? "rabbitmq" : "memory",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
SafeMessage(exception)),
|
||||
backlog);
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception exception)
|
||||
{
|
||||
var message = exception.GetBaseException().Message;
|
||||
return message.Length <= 300 ? message : message[..300];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Operations;
|
||||
|
||||
public sealed class OperationsOptions
|
||||
{
|
||||
public const string SectionName = "Operations";
|
||||
|
||||
public string BackupDirectory { get; set; } = "data/backups";
|
||||
public int BackupWarningHours { get; set; } = 24;
|
||||
public int ToolTimeoutMinutes { get; set; } = 30;
|
||||
public string MySqlDumpPath { get; set; } = "mysqldump";
|
||||
public string MySqlClientPath { get; set; } = "mysql";
|
||||
public string[] MySqlAdditionalArguments { get; set; } = [];
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<Student> Students => Set<Student>();
|
||||
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
|
||||
public DbSet<Course> Courses => Set<Course>();
|
||||
public DbSet<CoursePrerequisite> CoursePrerequisites => Set<CoursePrerequisite>();
|
||||
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
||||
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
||||
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
|
||||
@@ -43,6 +44,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<SchedulePublishJob>();
|
||||
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||
Set<ClassroomReservation>();
|
||||
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
||||
Set<ExperimentGradeSheet>();
|
||||
public DbSet<ExperimentGradeItem> ExperimentGradeItems =>
|
||||
Set<ExperimentGradeItem>();
|
||||
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
|
||||
Set<ExperimentGradeRecord>();
|
||||
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
|
||||
Set<ExperimentGradeItemScore>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
Set<CourseSelectionRound>();
|
||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||
@@ -56,10 +68,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||
Set<AttendanceCheckInAttempt>();
|
||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||
Set<ExamArrangementJob>();
|
||||
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
|
||||
Set<ExamSignInExportJob>();
|
||||
public DbSet<ExamPublishJob> ExamPublishJobs =>
|
||||
Set<ExamPublishJob>();
|
||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||
Set<ExamSessionInvigilator>();
|
||||
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
|
||||
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
|
||||
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
|
||||
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
|
||||
Set<ExamRoomInvigilator>();
|
||||
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
|
||||
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
|
||||
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
|
||||
@@ -100,6 +125,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
||||
Set<BackgroundJobOutboxMessage>();
|
||||
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||
Set<AppUpdateRelease>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
ModelConfigurationBuilder configurationBuilder)
|
||||
@@ -247,6 +274,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CoursePrerequisite>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||
.IsUnique();
|
||||
entity.HasOne(x => x.Course)
|
||||
.WithMany(x => x.Prerequisites)
|
||||
.HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.PrerequisiteCourse)
|
||||
.WithMany(x => x.RequiredByCourses)
|
||||
.HasForeignKey(x => x.PrerequisiteCourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CurriculumPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -375,6 +416,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
|
||||
builder.Entity<ScheduleEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind)
|
||||
.HasDefaultValue(ScheduleEntryKind.Lecture);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
@@ -519,6 +562,133 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentProject>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Code).HasMaxLength(40);
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
|
||||
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentSession>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentProjectId,
|
||||
x.SessionDate,
|
||||
x.StartPeriod
|
||||
});
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ClassroomId,
|
||||
x.SessionDate,
|
||||
x.Status,
|
||||
x.StartPeriod
|
||||
});
|
||||
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.ExperimentProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Classroom).WithMany()
|
||||
.HasForeignKey(x => x.ClassroomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentBooking>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.ExperimentProjectId, x.StudentId })
|
||||
.IsUnique();
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentSessionId,
|
||||
x.Status,
|
||||
x.BookedAt
|
||||
});
|
||||
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Bookings)
|
||||
.HasForeignKey(x => x.ExperimentProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Bookings)
|
||||
.HasForeignKey(x => x.ExperimentSessionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeSheet>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ContributionWeight).HasPrecision(5, 1);
|
||||
entity.Property(x => x.PassScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.ExperimentProjectId).IsUnique();
|
||||
entity.HasOne(x => x.ExperimentProject)
|
||||
.WithOne(x => x.GradeSheet)
|
||||
.HasForeignKey<ExperimentGradeSheet>(
|
||||
x => x.ExperimentProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeItem>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(60);
|
||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentGradeSheetId,
|
||||
x.SortOrder
|
||||
});
|
||||
entity.HasOne(x => x.ExperimentGradeSheet)
|
||||
.WithMany(x => x.Items)
|
||||
.HasForeignKey(x => x.ExperimentGradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeRecord>(entity =>
|
||||
{
|
||||
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.SubmissionReference).HasMaxLength(500);
|
||||
entity.Property(x => x.TeacherComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentGradeSheetId,
|
||||
x.StudentId
|
||||
}).IsUnique();
|
||||
entity.HasOne(x => x.ExperimentGradeSheet)
|
||||
.WithMany(x => x.Records)
|
||||
.HasForeignKey(x => x.ExperimentGradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.ExperimentSession).WithMany()
|
||||
.HasForeignKey(x => x.ExperimentSessionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeItemScore>(entity =>
|
||||
{
|
||||
entity.HasKey(x => new
|
||||
{
|
||||
x.ExperimentGradeRecordId,
|
||||
x.ExperimentGradeItemId
|
||||
});
|
||||
entity.Property(x => x.Score).HasPrecision(5, 1);
|
||||
entity.Property(x => x.Comment).HasMaxLength(300);
|
||||
entity.HasOne(x => x.ExperimentGradeRecord)
|
||||
.WithMany(x => x.ItemScores)
|
||||
.HasForeignKey(x => x.ExperimentGradeRecordId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExperimentGradeItem)
|
||||
.WithMany(x => x.Scores)
|
||||
.HasForeignKey(x => x.ExperimentGradeItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionRound>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -598,6 +768,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);
|
||||
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
||||
entity.HasOne(x => x.GradeSheet)
|
||||
.WithMany(x => x.Items)
|
||||
@@ -672,6 +844,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<AttendanceCheckInAttempt>(entity =>
|
||||
{
|
||||
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
|
||||
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
|
||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||
entity.Property(x => x.RiskFlags).HasMaxLength(300);
|
||||
entity.Property(x => x.Latitude).HasPrecision(10, 7);
|
||||
entity.Property(x => x.Longitude).HasPrecision(10, 7);
|
||||
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
|
||||
entity.HasOne(x => x.AttendanceSheet)
|
||||
.WithMany(x => x.CheckInAttempts)
|
||||
.HasForeignKey(x => x.AttendanceSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExamPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -687,6 +882,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
|
||||
entity.HasIndex(x => x.RequiredBuildingId);
|
||||
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
@@ -704,6 +900,87 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.Teacher).WithMany()
|
||||
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamRoomAssignment>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamRooms");
|
||||
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
||||
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExamPlanId,
|
||||
x.ClassroomId,
|
||||
x.StartsAt
|
||||
})
|
||||
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
|
||||
entity.HasIndex(x => x.CourseId)
|
||||
.HasDatabaseName("IX_ExamRooms_CourseId");
|
||||
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
|
||||
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Course).WithMany()
|
||||
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Classroom).WithMany()
|
||||
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamRoomSession>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamRoomSessions");
|
||||
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
|
||||
entity.HasIndex(x => x.ExamSessionId)
|
||||
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
|
||||
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
|
||||
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
|
||||
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamSeatAssignment>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamSeats");
|
||||
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
|
||||
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ExamSeats_Session_Student");
|
||||
entity.HasIndex(x => x.StudentId)
|
||||
.HasDatabaseName("IX_ExamSeats_StudentId");
|
||||
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
|
||||
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
|
||||
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamRoomInvigilator>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamRoomInvigilators");
|
||||
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
|
||||
entity.HasIndex(x => x.TeacherId)
|
||||
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
|
||||
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
|
||||
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Teacher).WithMany()
|
||||
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamArrangementJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
|
||||
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
|
||||
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.RequestedByUserId);
|
||||
});
|
||||
builder.Entity<ExamSignInExportJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||
entity.Property(x => x.FileName).HasMaxLength(200);
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.RequestedByUserId);
|
||||
});
|
||||
builder.Entity<MakeupExamPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -719,6 +996,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
||||
entity.HasIndex(x => x.RequiredBuildingId);
|
||||
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
@@ -965,7 +1243,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<Notification>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Title).HasMaxLength(200);
|
||||
entity.Property(x => x.Content).HasMaxLength(1000);
|
||||
entity.Property(x => x.Content).HasColumnType("longtext");
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
||||
@@ -981,7 +1259,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.SenderName).HasMaxLength(100);
|
||||
entity.Property(x => x.Title).HasMaxLength(200);
|
||||
entity.Property(x => x.Content).HasMaxLength(1000);
|
||||
entity.Property(x => x.Content).HasColumnType("longtext");
|
||||
entity.Property(x => x.AudienceName).HasMaxLength(200);
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
|
||||
@@ -1005,6 +1283,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.LeaseExpiresAt);
|
||||
});
|
||||
|
||||
builder.Entity<AppUpdateRelease>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Platform)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.Property(x => x.Channel)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.Property(x => x.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.Property(x => x.Version).HasMaxLength(40);
|
||||
entity.Property(x => x.NativeVersion).HasMaxLength(40);
|
||||
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
|
||||
entity.Property(x => x.FileName).HasMaxLength(180);
|
||||
entity.Property(x => x.Sha256).HasMaxLength(64);
|
||||
entity.Property(x => x.BundleContent).HasColumnType("longblob");
|
||||
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
|
||||
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.Platform,
|
||||
x.Channel,
|
||||
x.NativeVersion,
|
||||
x.Version
|
||||
}).IsUnique();
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.Platform,
|
||||
x.Channel,
|
||||
x.NativeVersion,
|
||||
x.Status
|
||||
});
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
|
||||
builder.Entity<OfficialDocument>(entity =>
|
||||
{
|
||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||
|
||||
@@ -62,6 +62,22 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260726_33_background_job_outbox";
|
||||
private const string CourseAdjustmentOccurrencesMigration =
|
||||
"20260727_34_course_adjustment_occurrences";
|
||||
private const string AcademicPlanningPrerequisitesMigration =
|
||||
"20260727_35_academic_planning_prerequisites";
|
||||
private const string ExamRoomMixingMigration =
|
||||
"20260727_36_exam_room_mixing";
|
||||
private const string AttendanceCheckInAuditMigration =
|
||||
"20260728_37_attendance_check_in_audit";
|
||||
private const string ExamPublishJobsMigration =
|
||||
"20260728_38_exam_publish_jobs";
|
||||
private const string ExperimentManagementMigration =
|
||||
"20260728_39_experiment_management";
|
||||
private const string ExperimentGradeManagementMigration =
|
||||
"20260728_40_experiment_grade_management";
|
||||
private const string AppUpdateReleasesMigration =
|
||||
"20260729_41_app_update_releases";
|
||||
private const string IntegratedExperimentSchedulingMigration =
|
||||
"20260802_42_integrated_experiment_scheduling";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -320,6 +336,18 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
AttendanceCheckInMigration,
|
||||
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
|
||||
cancellationToken);
|
||||
var attendanceCheckInAttemptsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'AttendanceCheckInAttempts'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AttendanceCheckInAuditMigration,
|
||||
attendanceCheckInAttemptsExist ? [] : AttendanceCheckInAuditStatements,
|
||||
cancellationToken);
|
||||
|
||||
var approvalTablesExist = await db.Database
|
||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
|
||||
@@ -361,6 +389,22 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var examArrangementJobsExist = await db.Database
|
||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamArrangementJobs'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ExamArrangementJobsMigration,
|
||||
examArrangementJobsExist ? [] : ExamArrangementJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var exportJobsExist = await db.Database
|
||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamSignInExportJobs'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ExamSignInExportJobsMigration,
|
||||
exportJobsExist ? [] : ExamSignInExportJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var academicTermArchivingExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
@@ -453,6 +497,102 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
BackgroundJobOutboxMigration,
|
||||
backgroundJobOutboxExists ? [] : BackgroundJobOutboxStatements,
|
||||
cancellationToken);
|
||||
|
||||
var coursePrerequisitesExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'CoursePrerequisites'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AcademicPlanningPrerequisitesMigration,
|
||||
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
|
||||
cancellationToken);
|
||||
|
||||
var examRoomsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'ExamRooms'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ExamRoomMixingMigration,
|
||||
examRoomsExist ? [] : ExamRoomMixingStatements,
|
||||
cancellationToken);
|
||||
|
||||
var examPublishJobsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'ExamPublishJobs'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ExamPublishJobsMigration,
|
||||
examPublishJobsExist ? [] : ExamPublishJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var experimentProjectsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'ExperimentProjects'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ExperimentManagementMigration,
|
||||
experimentProjectsExist ? [] : ExperimentManagementStatements,
|
||||
cancellationToken);
|
||||
|
||||
var gradeItemSourceExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('GradeItems')
|
||||
WHERE name = 'SourceType'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
var experimentGradeSheetsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'ExperimentGradeSheets'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ExperimentGradeManagementMigration,
|
||||
(gradeItemSourceExists
|
||||
? []
|
||||
: ExperimentGradeItemSourceStatements)
|
||||
.Concat(experimentGradeSheetsExist
|
||||
? []
|
||||
: ExperimentGradeManagementStatements),
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
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);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1628,6 +1768,9 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingIds" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2;
|
||||
""",
|
||||
"""
|
||||
@@ -1642,6 +1785,7 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"StartsAt" TEXT NOT NULL,
|
||||
"EndsAt" TEXT NOT NULL,
|
||||
"RequiredBuildingId" TEXT NULL,
|
||||
"RequiredBuildingIds" TEXT NULL,
|
||||
"RequiredInvigilatorCount" INTEGER NOT NULL,
|
||||
"Notes" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
@@ -1879,6 +2023,87 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] AttendanceCheckInAuditStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "AttendanceCheckInAttempts" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceCheckInAttempts" PRIMARY KEY,
|
||||
"AttendanceSheetId" TEXT NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"CheckInMethod" INTEGER NOT NULL,
|
||||
"IsSuccessful" INTEGER NOT NULL,
|
||||
"FailureCode" TEXT NULL,
|
||||
"DeviceIdentifierHash" TEXT NULL,
|
||||
"DevicePlatform" TEXT NULL,
|
||||
"IpAddress" TEXT NULL,
|
||||
"UserAgent" TEXT NULL,
|
||||
"RiskFlags" TEXT NULL,
|
||||
"Latitude" TEXT NULL,
|
||||
"Longitude" TEXT NULL,
|
||||
"AccuracyMeters" REAL NULL,
|
||||
"DistanceMeters" REAL NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId"
|
||||
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_AttendanceCheckInAttempts_Students_StudentId"
|
||||
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_CreatedAt"
|
||||
ON "AttendanceCheckInAttempts" ("AttendanceSheetId", "StudentId", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt"
|
||||
ON "AttendanceCheckInAttempts" ("DeviceIdentifierHash", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt"
|
||||
ON "AttendanceCheckInAttempts" ("IpAddress", "CreatedAt");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] AppUpdateReleasesStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "AppUpdateReleases" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_AppUpdateReleases" PRIMARY KEY,
|
||||
"Platform" TEXT NOT NULL,
|
||||
"Channel" TEXT NOT NULL,
|
||||
"Version" TEXT NOT NULL,
|
||||
"NativeVersion" TEXT NOT NULL,
|
||||
"Status" TEXT NOT NULL,
|
||||
"ReleaseNotes" TEXT NULL,
|
||||
"FileName" TEXT NOT NULL,
|
||||
"FileSize" INTEGER NOT NULL,
|
||||
"Sha256" TEXT NOT NULL,
|
||||
"BundleContent" BLOB NOT NULL,
|
||||
"CreatedByUserName" TEXT NOT NULL,
|
||||
"PublishedByUserName" TEXT NULL,
|
||||
"PublishedAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version"
|
||||
ON "AppUpdateReleases"
|
||||
("Platform", "Channel", "NativeVersion", "Version");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS
|
||||
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status"
|
||||
ON "AppUpdateReleases"
|
||||
("Platform", "Channel", "NativeVersion", "Status");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AppUpdateReleases_CreatedAt"
|
||||
ON "AppUpdateReleases" ("CreatedAt");
|
||||
"""
|
||||
];
|
||||
|
||||
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);""",
|
||||
@@ -1937,7 +2162,7 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
[
|
||||
"""CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||
"""CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""",
|
||||
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
|
||||
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredBuildingIds" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
|
||||
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""",
|
||||
"""CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""",
|
||||
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""",
|
||||
@@ -1956,6 +2181,27 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
||||
];
|
||||
|
||||
private const string ExamArrangementJobsMigration = "ExamArrangementJobs";
|
||||
|
||||
private static readonly string[] ExamArrangementJobStatements =
|
||||
[
|
||||
"""CREATE TABLE "ExamArrangementJobs" ("Id" TEXT NOT NULL, "Kind" INTEGER NOT NULL, "PlanId" TEXT NOT NULL, "ActivePlanId" TEXT NULL, "RequestedByUserId" TEXT NULL, "SessionIdsJson" TEXT NULL, "AssignClassrooms" INTEGER NOT NULL, "AssignInvigilators" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "TotalSessions" INTEGER NOT NULL, "ProcessedSessions" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ResultMessage" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamArrangementJobs" PRIMARY KEY ("Id"));""",
|
||||
"""CREATE INDEX "IX_ExamArrangementJobs_Kind_Plan_CreatedAt" ON "ExamArrangementJobs" ("Kind", "PlanId", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_ExamArrangementJobs_RequestedByUserId" ON "ExamArrangementJobs" ("RequestedByUserId");""",
|
||||
"""CREATE INDEX "IX_ExamArrangementJobs_Status_CreatedAt" ON "ExamArrangementJobs" ("Status", "CreatedAt");""",
|
||||
"""CREATE UNIQUE INDEX "UX_ExamArrangementJobs_Kind_ActivePlan" ON "ExamArrangementJobs" ("Kind", "ActivePlanId");""",
|
||||
];
|
||||
|
||||
private const string ExamSignInExportJobsMigration = "ExamSignInExportJobs";
|
||||
|
||||
private static readonly string[] ExamSignInExportJobStatements =
|
||||
[
|
||||
"""CREATE TABLE "ExamSignInExportJobs" ("Id" TEXT NOT NULL, "PlanId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "RequestedByUserId" TEXT NULL, "FileName" TEXT NULL, "FileBytes" BLOB NULL, "FileSize" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamSignInExportJobs" PRIMARY KEY ("Id"));""",
|
||||
"""CREATE INDEX "IX_ExamSignInExportJobs_PlanId_CreatedAt" ON "ExamSignInExportJobs" ("PlanId", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_ExamSignInExportJobs_Status_CreatedAt" ON "ExamSignInExportJobs" ("Status", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_ExamSignInExportJobs_RequestedByUserId" ON "ExamSignInExportJobs" ("RequestedByUserId");""",
|
||||
];
|
||||
|
||||
private static readonly string[] UnifiedMessageCenterStatements =
|
||||
[
|
||||
"""
|
||||
@@ -2090,4 +2336,406 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ON "BackgroundJobOutboxMessages" ("State", "CompletedAt");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] AcademicPlanningPrerequisiteStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "CoursePrerequisites" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_CoursePrerequisites" PRIMARY KEY,
|
||||
"CourseId" TEXT NOT NULL,
|
||||
"PrerequisiteCourseId" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CoursePrerequisites_Courses_CourseId"
|
||||
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_CoursePrerequisites_Courses_PrerequisiteCourseId"
|
||||
FOREIGN KEY ("PrerequisiteCourseId") REFERENCES "Courses" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_CoursePrerequisites_CourseId_PrerequisiteCourseId"
|
||||
ON "CoursePrerequisites" ("CourseId", "PrerequisiteCourseId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_CoursePrerequisites_PrerequisiteCourseId"
|
||||
ON "CoursePrerequisites" ("PrerequisiteCourseId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExamRoomMixingStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "ExamRooms" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamRooms" PRIMARY KEY,
|
||||
"ExamPlanId" TEXT NOT NULL,
|
||||
"CourseId" TEXT NOT NULL,
|
||||
"ClassroomId" TEXT NOT NULL,
|
||||
"ExamDate" TEXT NOT NULL,
|
||||
"StartPeriod" INTEGER NOT NULL,
|
||||
"PeriodCount" INTEGER NOT NULL,
|
||||
"StartsAt" TEXT NOT NULL,
|
||||
"EndsAt" TEXT NOT NULL,
|
||||
"RequiredInvigilatorCount" INTEGER NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExamRooms_ExamPlans_ExamPlanId"
|
||||
FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExamRooms_Courses_CourseId"
|
||||
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ExamRooms_Classrooms_ClassroomId"
|
||||
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExamRooms_Plan_Time"
|
||||
ON "ExamRooms" ("ExamPlanId", "StartsAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExamRooms_Plan_Room_Time"
|
||||
ON "ExamRooms" ("ExamPlanId", "ClassroomId", "StartsAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExamRooms_CourseId"
|
||||
ON "ExamRooms" ("CourseId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExamRooms_ClassroomId"
|
||||
ON "ExamRooms" ("ClassroomId");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExamRoomSessions" (
|
||||
"ExamRoomId" TEXT NOT NULL,
|
||||
"ExamSessionId" TEXT NOT NULL,
|
||||
CONSTRAINT "PK_ExamRoomSessions"
|
||||
PRIMARY KEY ("ExamRoomId", "ExamSessionId"),
|
||||
CONSTRAINT "FK_ExamRoomSessions_ExamRooms_ExamRoomId"
|
||||
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExamRoomSessions_ExamSessions_ExamSessionId"
|
||||
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExamRoomSessions_SessionId"
|
||||
ON "ExamRoomSessions" ("ExamSessionId");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExamSeats" (
|
||||
"ExamRoomId" TEXT NOT NULL,
|
||||
"ExamSessionId" TEXT NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"SeatNumber" INTEGER NOT NULL,
|
||||
CONSTRAINT "PK_ExamSeats"
|
||||
PRIMARY KEY ("ExamRoomId", "StudentId"),
|
||||
CONSTRAINT "FK_ExamSeats_ExamRooms_ExamRoomId"
|
||||
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExamSeats_ExamSessions_ExamSessionId"
|
||||
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ExamSeats_Students_StudentId"
|
||||
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "UX_ExamSeats_Session_Student"
|
||||
ON "ExamSeats" ("ExamSessionId", "StudentId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExamSeats_StudentId"
|
||||
ON "ExamSeats" ("StudentId");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExamRoomInvigilators" (
|
||||
"ExamRoomId" TEXT NOT NULL,
|
||||
"TeacherId" TEXT NOT NULL,
|
||||
CONSTRAINT "PK_ExamRoomInvigilators"
|
||||
PRIMARY KEY ("ExamRoomId", "TeacherId"),
|
||||
CONSTRAINT "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId"
|
||||
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExamRoomInvigilators_Teachers_TeacherId"
|
||||
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExamRoomInvigilators_TeacherId"
|
||||
ON "ExamRoomInvigilators" ("TeacherId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExamPublishJobStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "ExamPublishJobs" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamPublishJobs" PRIMARY KEY,
|
||||
"Kind" INTEGER NOT NULL,
|
||||
"PlanId" TEXT NOT NULL,
|
||||
"ActivePlanId" TEXT NULL,
|
||||
"RequestedByUserId" TEXT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"CurrentStep" TEXT NULL,
|
||||
"ErrorMessage" TEXT NULL,
|
||||
"StartedAt" TEXT NULL,
|
||||
"CompletedAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExperimentManagementStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "ExperimentProjects" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentProjects" PRIMARY KEY,
|
||||
"TeachingTaskId" TEXT NOT NULL,
|
||||
"Code" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"ArrangementMode" INTEGER NOT NULL,
|
||||
"Description" TEXT NULL,
|
||||
"Requirements" TEXT NULL,
|
||||
"StartDate" TEXT NOT NULL,
|
||||
"EndDate" TEXT NOT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"PublishedAt" TEXT NULL,
|
||||
"ClosedAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentProjects_TeachingTasks_TeachingTaskId"
|
||||
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_ExperimentProjects_TeachingTaskId_Code"
|
||||
ON "ExperimentProjects" ("TeachingTaskId", "Code");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentProjects_Status_StartDate_EndDate"
|
||||
ON "ExperimentProjects" ("Status", "StartDate", "EndDate");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExperimentSessions" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessions" PRIMARY KEY,
|
||||
"ExperimentProjectId" TEXT NOT NULL,
|
||||
"ClassroomId" TEXT NOT NULL,
|
||||
"SessionDate" TEXT NOT NULL,
|
||||
"StartPeriod" INTEGER NOT NULL,
|
||||
"PeriodCount" INTEGER NOT NULL,
|
||||
"Capacity" INTEGER NOT NULL,
|
||||
"ReservedCount" INTEGER NOT NULL,
|
||||
"Notes" TEXT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"CancelledAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId"
|
||||
FOREIGN KEY ("ExperimentProjectId")
|
||||
REFERENCES "ExperimentProjects" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExperimentSessions_Classrooms_ClassroomId"
|
||||
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeriod"
|
||||
ON "ExperimentSessions" (
|
||||
"ExperimentProjectId",
|
||||
"SessionDate",
|
||||
"StartPeriod"
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod"
|
||||
ON "ExperimentSessions" (
|
||||
"ClassroomId",
|
||||
"SessionDate",
|
||||
"Status",
|
||||
"StartPeriod"
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExperimentBookings" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentBookings" PRIMARY KEY,
|
||||
"ExperimentProjectId" TEXT NOT NULL,
|
||||
"ExperimentSessionId" TEXT NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"BookedAt" TEXT NOT NULL,
|
||||
"CancelledAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId"
|
||||
FOREIGN KEY ("ExperimentProjectId")
|
||||
REFERENCES "ExperimentProjects" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId"
|
||||
FOREIGN KEY ("ExperimentSessionId")
|
||||
REFERENCES "ExperimentSessions" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ExperimentBookings_Students_StudentId"
|
||||
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_ExperimentBookings_ExperimentProjectId_StudentId"
|
||||
ON "ExperimentBookings" ("ExperimentProjectId", "StudentId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt"
|
||||
ON "ExperimentBookings" (
|
||||
"ExperimentSessionId",
|
||||
"Status",
|
||||
"BookedAt"
|
||||
);
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExperimentGradeItemSourceStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "GradeItems"
|
||||
ADD "SourceType" INTEGER NOT NULL DEFAULT 1;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "GradeItems"
|
||||
ADD "SourceSnapshotAt" TEXT NULL;
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExperimentGradeManagementStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "ExperimentGradeSheets" (
|
||||
"Id" TEXT NOT NULL
|
||||
CONSTRAINT "PK_ExperimentGradeSheets" PRIMARY KEY,
|
||||
"ExperimentProjectId" TEXT NOT NULL,
|
||||
"ContributionWeight" TEXT NOT NULL,
|
||||
"PassScore" TEXT NOT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"ReviewComment" TEXT NULL,
|
||||
"SubmittedAt" TEXT NULL,
|
||||
"ReviewedAt" TEXT NULL,
|
||||
"PublishedAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId"
|
||||
FOREIGN KEY ("ExperimentProjectId")
|
||||
REFERENCES "ExperimentProjects" ("Id")
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_ExperimentGradeSheets_ExperimentProjectId"
|
||||
ON "ExperimentGradeSheets" ("ExperimentProjectId");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExperimentGradeItems" (
|
||||
"Id" TEXT NOT NULL
|
||||
CONSTRAINT "PK_ExperimentGradeItems" PRIMARY KEY,
|
||||
"ExperimentGradeSheetId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Kind" INTEGER NOT NULL,
|
||||
"Weight" TEXT NOT NULL,
|
||||
"SortOrder" INTEGER NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSheetId"
|
||||
FOREIGN KEY ("ExperimentGradeSheetId")
|
||||
REFERENCES "ExperimentGradeSheets" ("Id")
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder"
|
||||
ON "ExperimentGradeItems" ("ExperimentGradeSheetId", "SortOrder");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExperimentGradeRecords" (
|
||||
"Id" TEXT NOT NULL
|
||||
CONSTRAINT "PK_ExperimentGradeRecords" PRIMARY KEY,
|
||||
"ExperimentGradeSheetId" TEXT NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"ExperimentSessionId" TEXT NULL,
|
||||
"ParticipationStatus" INTEGER NOT NULL,
|
||||
"TotalScore" TEXT NULL,
|
||||
"IsPassed" INTEGER NULL,
|
||||
"SafetyViolation" INTEGER NOT NULL,
|
||||
"AttemptNumber" INTEGER NOT NULL,
|
||||
"SubmissionReference" TEXT NULL,
|
||||
"SubmittedAt" TEXT NULL,
|
||||
"IsLate" INTEGER NOT NULL,
|
||||
"TeacherComment" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGradeSheetId"
|
||||
FOREIGN KEY ("ExperimentGradeSheetId")
|
||||
REFERENCES "ExperimentGradeSheets" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExperimentGradeRecords_Students_StudentId"
|
||||
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId"
|
||||
FOREIGN KEY ("ExperimentSessionId")
|
||||
REFERENCES "ExperimentSessions" ("Id")
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId"
|
||||
ON "ExperimentGradeRecords" ("ExperimentGradeSheetId", "StudentId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentGradeRecords_StudentId"
|
||||
ON "ExperimentGradeRecords" ("StudentId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentGradeRecords_ExperimentSessionId"
|
||||
ON "ExperimentGradeRecords" ("ExperimentSessionId");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "ExperimentGradeItemScores" (
|
||||
"ExperimentGradeRecordId" TEXT NOT NULL,
|
||||
"ExperimentGradeItemId" TEXT NOT NULL,
|
||||
"Score" TEXT NULL,
|
||||
"Comment" TEXT NULL,
|
||||
CONSTRAINT "PK_ExperimentGradeItemScores"
|
||||
PRIMARY KEY (
|
||||
"ExperimentGradeRecordId",
|
||||
"ExperimentGradeItemId"
|
||||
),
|
||||
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentGradeRecordId"
|
||||
FOREIGN KEY ("ExperimentGradeRecordId")
|
||||
REFERENCES "ExperimentGradeRecords" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGradeItemId"
|
||||
FOREIGN KEY ("ExperimentGradeItemId")
|
||||
REFERENCES "ExperimentGradeItems" ("Id")
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ExperimentGradeItemScores_ExperimentGradeItemId"
|
||||
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] IntegratedExperimentSchedulingStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "ScheduleEntries"
|
||||
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+4921
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AcademicPlanningPrerequisites : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CoursePrerequisites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
PrerequisiteCourseId = table.Column<Guid>(type: "char(36)", 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_CoursePrerequisites", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CoursePrerequisites_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_CoursePrerequisites_Courses_PrerequisiteCourseId",
|
||||
column: x => x.PrerequisiteCourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CoursePrerequisites_CourseId_PrerequisiteCourseId",
|
||||
table: "CoursePrerequisites",
|
||||
columns: new[] { "CourseId", "PrerequisiteCourseId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CoursePrerequisites_PrerequisiteCourseId",
|
||||
table: "CoursePrerequisites",
|
||||
column: "PrerequisiteCourseId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CoursePrerequisites");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4919
File diff suppressed because it is too large
Load Diff
+54
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MessageRichContent : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Content",
|
||||
table: "Notifications",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "varchar(1000)",
|
||||
oldMaxLength: 1000);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Content",
|
||||
table: "MessageDispatches",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "varchar(1000)",
|
||||
oldMaxLength: 1000);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Content",
|
||||
table: "Notifications",
|
||||
type: "varchar(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Content",
|
||||
table: "MessageDispatches",
|
||||
type: "varchar(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext");
|
||||
}
|
||||
}
|
||||
}
|
||||
src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.Designer.cs
Generated
+5139
File diff suppressed because it is too large
Load Diff
+196
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExamRoomMixing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamRooms",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExamPlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
RequiredInvigilatorCount = 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_ExamRooms", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamRooms_Classrooms_ClassroomId",
|
||||
column: x => x.ClassroomId,
|
||||
principalTable: "Classrooms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamRooms_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamRooms_ExamPlans_ExamPlanId",
|
||||
column: x => x.ExamPlanId,
|
||||
principalTable: "ExamPlans",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamRoomInvigilators",
|
||||
columns: table => new
|
||||
{
|
||||
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExamRoomInvigilators", x => new { x.ExamRoomId, x.TeacherId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId",
|
||||
column: x => x.ExamRoomId,
|
||||
principalTable: "ExamRooms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamRoomInvigilators_Teachers_TeacherId",
|
||||
column: x => x.TeacherId,
|
||||
principalTable: "Teachers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamRoomSessions",
|
||||
columns: table => new
|
||||
{
|
||||
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExamRoomSessions", x => new { x.ExamRoomId, x.ExamSessionId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamRoomSessions_ExamRooms_ExamRoomId",
|
||||
column: x => x.ExamRoomId,
|
||||
principalTable: "ExamRooms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamRoomSessions_ExamSessions_ExamSessionId",
|
||||
column: x => x.ExamSessionId,
|
||||
principalTable: "ExamSessions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamSeats",
|
||||
columns: table => new
|
||||
{
|
||||
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
SeatNumber = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExamSeats", x => new { x.ExamRoomId, x.StudentId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamSeats_ExamRooms_ExamRoomId",
|
||||
column: x => x.ExamRoomId,
|
||||
principalTable: "ExamRooms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamSeats_ExamSessions_ExamSessionId",
|
||||
column: x => x.ExamSessionId,
|
||||
principalTable: "ExamSessions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExamSeats_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamRoomInvigilators_TeacherId",
|
||||
table: "ExamRoomInvigilators",
|
||||
column: "TeacherId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamRooms_ClassroomId",
|
||||
table: "ExamRooms",
|
||||
column: "ClassroomId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamRooms_CourseId",
|
||||
table: "ExamRooms",
|
||||
column: "CourseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamRooms_Plan_Room_Time",
|
||||
table: "ExamRooms",
|
||||
columns: new[] { "ExamPlanId", "ClassroomId", "StartsAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamRooms_Plan_Time",
|
||||
table: "ExamRooms",
|
||||
columns: new[] { "ExamPlanId", "StartsAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamRoomSessions_SessionId",
|
||||
table: "ExamRoomSessions",
|
||||
column: "ExamSessionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamSeats_StudentId",
|
||||
table: "ExamSeats",
|
||||
column: "StudentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ExamSeats_Session_Student",
|
||||
table: "ExamSeats",
|
||||
columns: new[] { "ExamSessionId", "StudentId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamRoomInvigilators");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamRoomSessions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamSeats");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamRooms");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5215
File diff suppressed because it is too large
Load Diff
+72
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExamArrangementJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamArrangementJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Kind = table.Column<int>(type: "int", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ActivePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
SessionIdsJson = table.Column<string>(type: "longtext", nullable: true),
|
||||
AssignClassrooms = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
AssignInvigilators = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
TotalSessions = table.Column<int>(type: "int", nullable: false),
|
||||
ProcessedSessions = table.Column<int>(type: "int", nullable: false),
|
||||
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||
ResultMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = 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_ExamArrangementJobs", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamArrangementJobs_Kind_Plan_CreatedAt",
|
||||
table: "ExamArrangementJobs",
|
||||
columns: new[] { "Kind", "PlanId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamArrangementJobs_RequestedByUserId",
|
||||
table: "ExamArrangementJobs",
|
||||
column: "RequestedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamArrangementJobs_Status_CreatedAt",
|
||||
table: "ExamArrangementJobs",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ExamArrangementJobs_Kind_ActivePlan",
|
||||
table: "ExamArrangementJobs",
|
||||
columns: new[] { "Kind", "ActivePlanId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamArrangementJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5221
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRequiredBuildingIds : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "MakeupExamSessions",
|
||||
type: "longtext",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "ExamSessions",
|
||||
type: "longtext",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "MakeupExamSessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequiredBuildingIds",
|
||||
table: "ExamSessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5277
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExamSignInExportJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamSignInExportJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
FileName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||
FileBytes = table.Column<byte[]>(type: "longblob", nullable: true),
|
||||
FileSize = table.Column<int>(type: "int", nullable: false),
|
||||
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = 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_ExamSignInExportJobs", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamSignInExportJobs_PlanId_CreatedAt",
|
||||
table: "ExamSignInExportJobs",
|
||||
columns: new[] { "PlanId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamSignInExportJobs_RequestedByUserId",
|
||||
table: "ExamSignInExportJobs",
|
||||
column: "RequestedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExamSignInExportJobs_Status_CreatedAt",
|
||||
table: "ExamSignInExportJobs",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamSignInExportJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5321
File diff suppressed because it is too large
Load Diff
+45
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExamPublishJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamPublishJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Kind = table.Column<int>(type: "int", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ActivePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CurrentStep = table.Column<string>(type: "longtext", nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "longtext", nullable: true),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = 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_ExamPublishJobs", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamPublishJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5417
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AttendanceCheckInAudit : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AttendanceCheckInAttempts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CheckInMethod = table.Column<int>(type: "int", nullable: false),
|
||||
IsSuccessful = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
FailureCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||
DeviceIdentifierHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||
DevicePlatform = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: true),
|
||||
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
RiskFlags = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||
Latitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||
Longitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||
AccuracyMeters = table.Column<double>(type: "double", nullable: true),
|
||||
DistanceMeters = table.Column<double>(type: "double", 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_AttendanceCheckInAttempts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId",
|
||||
column: x => x.AttendanceSheetId,
|
||||
principalTable: "AttendanceSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AttendanceCheckInAttempts_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_Create~",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
columns: new[] { "AttendanceSheetId", "StudentId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
columns: new[] { "DeviceIdentifierHash", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
columns: new[] { "IpAddress", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_StudentId",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
column: "StudentId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AttendanceCheckInAttempts");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5641
File diff suppressed because it is too large
Load Diff
+170
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExperimentManagement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExperimentProjects",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
|
||||
ArrangementMode = table.Column<int>(type: "int", nullable: false),
|
||||
Description = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||
Requirements = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||
StartDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ClosedAt = 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_ExperimentProjects", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentProjects_TeachingTasks_TeachingTaskId",
|
||||
column: x => x.TeachingTaskId,
|
||||
principalTable: "TeachingTasks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExperimentSessions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
SessionDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||
Capacity = table.Column<int>(type: "int", nullable: false),
|
||||
ReservedCount = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CancelledAt = 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_ExperimentSessions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentSessions_Classrooms_ClassroomId",
|
||||
column: x => x.ClassroomId,
|
||||
principalTable: "Classrooms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId",
|
||||
column: x => x.ExperimentProjectId,
|
||||
principalTable: "ExperimentProjects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExperimentBookings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
BookedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
CancelledAt = 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_ExperimentBookings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId",
|
||||
column: x => x.ExperimentProjectId,
|
||||
principalTable: "ExperimentProjects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId",
|
||||
column: x => x.ExperimentSessionId,
|
||||
principalTable: "ExperimentSessions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentBookings_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentBookings_ExperimentProjectId_StudentId",
|
||||
table: "ExperimentBookings",
|
||||
columns: new[] { "ExperimentProjectId", "StudentId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt",
|
||||
table: "ExperimentBookings",
|
||||
columns: new[] { "ExperimentSessionId", "Status", "BookedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentBookings_StudentId",
|
||||
table: "ExperimentBookings",
|
||||
column: "StudentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_Status_StartDate_EndDate",
|
||||
table: "ExperimentProjects",
|
||||
columns: new[] { "Status", "StartDate", "EndDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||
table: "ExperimentProjects",
|
||||
columns: new[] { "TeachingTaskId", "Code" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod",
|
||||
table: "ExperimentSessions",
|
||||
columns: new[] { "ClassroomId", "SessionDate", "Status", "StartPeriod" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeri~",
|
||||
table: "ExperimentSessions",
|
||||
columns: new[] { "ExperimentProjectId", "SessionDate", "StartPeriod" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExperimentBookings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExperimentSessions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExperimentProjects");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5905
File diff suppressed because it is too large
Load Diff
+208
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExperimentGradeManagement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "SourceSnapshotAt",
|
||||
table: "GradeItems",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SourceType",
|
||||
table: "GradeItems",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExperimentGradeSheets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ContributionWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
PassScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
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_ExperimentGradeSheets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId",
|
||||
column: x => x.ExperimentProjectId,
|
||||
principalTable: "ExperimentProjects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExperimentGradeItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Name = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false),
|
||||
Kind = table.Column<int>(type: "int", nullable: false),
|
||||
Weight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, 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_ExperimentGradeItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSh~",
|
||||
column: x => x.ExperimentGradeSheetId,
|
||||
principalTable: "ExperimentGradeSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExperimentGradeRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
ParticipationStatus = table.Column<int>(type: "int", nullable: false),
|
||||
TotalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
|
||||
SafetyViolation = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
AttemptNumber = table.Column<int>(type: "int", nullable: false),
|
||||
SubmissionReference = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
IsLate = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
TeacherComment = 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_ExperimentGradeRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGrade~",
|
||||
column: x => x.ExperimentGradeSheetId,
|
||||
principalTable: "ExperimentGradeSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId",
|
||||
column: x => x.ExperimentSessionId,
|
||||
principalTable: "ExperimentSessions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentGradeRecords_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExperimentGradeItemScores",
|
||||
columns: table => new
|
||||
{
|
||||
ExperimentGradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ExperimentGradeItemId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Score = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
Comment = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExperimentGradeItemScores", x => new { x.ExperimentGradeRecordId, x.ExperimentGradeItemId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGra~",
|
||||
column: x => x.ExperimentGradeItemId,
|
||||
principalTable: "ExperimentGradeItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentG~",
|
||||
column: x => x.ExperimentGradeRecordId,
|
||||
principalTable: "ExperimentGradeRecords",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder",
|
||||
table: "ExperimentGradeItems",
|
||||
columns: new[] { "ExperimentGradeSheetId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentGradeItemScores_ExperimentGradeItemId",
|
||||
table: "ExperimentGradeItemScores",
|
||||
column: "ExperimentGradeItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId",
|
||||
table: "ExperimentGradeRecords",
|
||||
columns: new[] { "ExperimentGradeSheetId", "StudentId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentGradeRecords_ExperimentSessionId",
|
||||
table: "ExperimentGradeRecords",
|
||||
column: "ExperimentSessionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentGradeRecords_StudentId",
|
||||
table: "ExperimentGradeRecords",
|
||||
column: "StudentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentGradeSheets_ExperimentProjectId",
|
||||
table: "ExperimentGradeSheets",
|
||||
column: "ExperimentProjectId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExperimentGradeItemScores");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExperimentGradeItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExperimentGradeRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExperimentGradeSheets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceSnapshotAt",
|
||||
table: "GradeItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceType",
|
||||
table: "GradeItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5987
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AppUpdateReleases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUpdateReleases",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Platform = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||
Channel = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||
Version = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||
NativeVersion = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||
ReleaseNotes = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||
FileName = table.Column<string>(type: "varchar(180)", maxLength: 180, nullable: false),
|
||||
FileSize = table.Column<long>(type: "bigint", nullable: false),
|
||||
Sha256 = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||
BundleContent = table.Column<byte[]>(type: "longblob", nullable: false),
|
||||
CreatedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||
PublishedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
|
||||
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_AppUpdateReleases", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUpdateReleases_CreatedAt",
|
||||
table: "AppUpdateReleases",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status",
|
||||
table: "AppUpdateReleases",
|
||||
columns: new[] { "Platform", "Channel", "NativeVersion", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version",
|
||||
table: "AppUpdateReleases",
|
||||
columns: new[] { "Platform", "Channel", "NativeVersion", "Version" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUpdateReleases");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1128
-4
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
@@ -34,6 +35,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
.Include(x => x.Classes)
|
||||
.ThenInclude(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Students)
|
||||
.Include(x => x.Course)
|
||||
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
|
||||
.ThenByDescending(x => x.Capacity)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
@@ -72,64 +74,62 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
var scheduledHours = entries
|
||||
.Where(x => x.TeachingTaskId == task.Id)
|
||||
.Sum(x => x.PeriodCount);
|
||||
var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours);
|
||||
if (remainingHours == 0)
|
||||
var taskCompleted = true;
|
||||
foreach (var kind in new[]
|
||||
{
|
||||
ScheduleEntryKind.Lecture,
|
||||
ScheduleEntryKind.Experiment
|
||||
})
|
||||
{
|
||||
completedTasks++;
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
|
||||
var scheduledHours = entries
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == task.Id &&
|
||||
x.Kind == kind)
|
||||
.Sum(TeachingTaskHours.ScheduledHours);
|
||||
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
|
||||
if (scheduledHours > targetHours)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
|
||||
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
|
||||
taskCompleted = false;
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
while (remainingHours > 0)
|
||||
{
|
||||
var desiredBlock = remainingHours >= 2 ? 2 : 1;
|
||||
var candidate = FindBestCandidate(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
desiredBlock,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is null && desiredBlock > 1)
|
||||
var remainingHours = targetHours - scheduledHours;
|
||||
while (remainingHours > 0)
|
||||
{
|
||||
candidate = FindBestCandidate(
|
||||
var candidate = FindBestCandidateForHours(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
1,
|
||||
kind,
|
||||
remainingHours,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is null) break;
|
||||
|
||||
db.ScheduleEntries.Add(candidate);
|
||||
entries.Add(candidate);
|
||||
created++;
|
||||
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
|
||||
}
|
||||
if (candidate is null) break;
|
||||
|
||||
db.ScheduleEntries.Add(candidate);
|
||||
entries.Add(candidate);
|
||||
created++;
|
||||
remainingHours -= candidate.PeriodCount;
|
||||
if (remainingHours > 0)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
|
||||
(kind == ScheduleEntryKind.Experiment
|
||||
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||
taskCompleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingHours == 0)
|
||||
{
|
||||
completedTasks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
||||
}
|
||||
if (taskCompleted) completedTasks++;
|
||||
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
@@ -150,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,
|
||||
@@ -163,50 +203,60 @@ 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)>();
|
||||
foreach (var day in allowedDays)
|
||||
for (var startWeek = task.StartWeek;
|
||||
startWeek + occurrenceCount - 1 <= task.EndWeek;
|
||||
startWeek++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||
foreach (var day in allowedDays)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||
continue;
|
||||
|
||||
var roomOptions = constraint?.RequiresClassroom == false
|
||||
? new Classroom?[] { null }
|
||||
: rooms.Cast<Classroom?>().ToArray();
|
||||
foreach (var room in roomOptions)
|
||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||
{
|
||||
var proposed = new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = task.Id,
|
||||
TeachingTask = task,
|
||||
ClassroomId = room?.Id,
|
||||
DayOfWeek = day,
|
||||
StartPeriod = start,
|
||||
PeriodCount = periodCount,
|
||||
StartWeek = task.StartWeek,
|
||||
EndWeek = task.EndWeek,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = "自动排课"
|
||||
};
|
||||
if (entries.Any(existing =>
|
||||
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
||||
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||
continue;
|
||||
|
||||
var sameTaskDay = entries.Count(x =>
|
||||
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;
|
||||
candidates.Add((proposed, score));
|
||||
var roomOptions = kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiresClassroom == false
|
||||
? new Classroom?[] { null }
|
||||
: rooms.Cast<Classroom?>().ToArray();
|
||||
foreach (var room in roomOptions)
|
||||
{
|
||||
var proposed = new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = task.Id,
|
||||
TeachingTask = task,
|
||||
Kind = kind,
|
||||
ClassroomId = room?.Id,
|
||||
DayOfWeek = day,
|
||||
StartPeriod = start,
|
||||
PeriodCount = periodCount,
|
||||
StartWeek = startWeek,
|
||||
EndWeek = startWeek + occurrenceCount - 1,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = kind == ScheduleEntryKind.Experiment
|
||||
? "自动排课 · 实验课"
|
||||
: "自动排课 · 理论课"
|
||||
};
|
||||
if (entries.Any(existing =>
|
||||
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
||||
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
|
||||
continue;
|
||||
|
||||
var sameTaskDay = entries.Count(x =>
|
||||
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 + startWeek;
|
||||
candidates.Add((proposed, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,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() ?? [];
|
||||
@@ -238,10 +290,17 @@ 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 || IsExperimentRoom(room.RoomType)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsExperimentRoom(string roomType) =>
|
||||
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static int[] ParseAllowedDays(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
@@ -179,19 +180,41 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published &&
|
||||
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
CourseTotalHours = x.Course!.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var scheduledHours = plan.Entries
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
||||
var incomplete = requiredTasks.FirstOrDefault(task =>
|
||||
!scheduledHours.TryGetValue(task.Id, out var hours) ||
|
||||
hours < task.WeeklyHours);
|
||||
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)
|
||||
{
|
||||
throw new SchedulePublishValidationException(
|
||||
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " +
|
||||
$"{incomplete.WeeklyHours} 学时,不能发布。");
|
||||
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(
|
||||
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
|
||||
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
|
||||
}
|
||||
}
|
||||
|
||||
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
||||
@@ -225,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)
|
||||
@@ -246,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, "所选教室不在指定校区");
|
||||
@@ -278,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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||
|
||||
public static class AttendanceCheckInChallenge
|
||||
{
|
||||
public const int LifetimeSeconds = 20;
|
||||
public const int RefreshSeconds = 10;
|
||||
private const int MaximumClockSkewSeconds = 2;
|
||||
private const string Base36Digits = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
public static AttendanceCheckInChallengeResult Create(
|
||||
Guid attendanceSheetId,
|
||||
string secret,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var issuedAt = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc));
|
||||
var payload = CreatePayload(
|
||||
attendanceSheetId,
|
||||
ToBase36(issuedAt.ToUnixTimeSeconds()));
|
||||
var signature = CreateSignature(payload, secret);
|
||||
return new AttendanceCheckInChallengeResult(
|
||||
$"{payload}.{signature}",
|
||||
issuedAt.UtcDateTime,
|
||||
issuedAt.AddSeconds(LifetimeSeconds).UtcDateTime,
|
||||
issuedAt.AddSeconds(RefreshSeconds).UtcDateTime);
|
||||
}
|
||||
|
||||
public static bool TryReadSheetId(string? token, out Guid attendanceSheetId)
|
||||
{
|
||||
attendanceSheetId = Guid.Empty;
|
||||
if (!TryParse(token, out var sheetIdText, out _, out _))
|
||||
return false;
|
||||
return Guid.TryParseExact(sheetIdText, "N", out attendanceSheetId);
|
||||
}
|
||||
|
||||
public static bool IsValid(
|
||||
string? token,
|
||||
Guid attendanceSheetId,
|
||||
string? secret,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret) ||
|
||||
!TryParse(token, out var sheetIdText, out var issuedAtText, out var signature) ||
|
||||
!Guid.TryParseExact(sheetIdText, "N", out var tokenSheetId) ||
|
||||
tokenSheetId != attendanceSheetId ||
|
||||
!TryParseBase36(issuedAtText, out var issuedAtUnixSeconds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var nowUnixSeconds = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
|
||||
var ageSeconds = nowUnixSeconds - issuedAtUnixSeconds;
|
||||
if (ageSeconds < -MaximumClockSkewSeconds || ageSeconds > LifetimeSeconds)
|
||||
return false;
|
||||
|
||||
byte[] providedSignature;
|
||||
try
|
||||
{
|
||||
providedSignature = WebEncoders.Base64UrlDecode(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = WebEncoders.Base64UrlDecode(
|
||||
CreateSignature(
|
||||
CreatePayload(attendanceSheetId, issuedAtText),
|
||||
secret));
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
providedSignature,
|
||||
expectedSignature);
|
||||
}
|
||||
|
||||
private static string CreatePayload(Guid attendanceSheetId, string issuedAtText) =>
|
||||
$"{attendanceSheetId:N}.{issuedAtText}";
|
||||
|
||||
private static string CreateSignature(string payload, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
return WebEncoders.Base64UrlEncode(digest[..16]);
|
||||
}
|
||||
|
||||
private static bool TryParse(
|
||||
string? token,
|
||||
out string sheetIdText,
|
||||
out string issuedAtText,
|
||||
out string signature)
|
||||
{
|
||||
sheetIdText = string.Empty;
|
||||
issuedAtText = string.Empty;
|
||||
signature = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(token) || token.Length > 64)
|
||||
return false;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3 ||
|
||||
parts[0].Length != 32 ||
|
||||
parts[1].Length is < 1 or > 12 ||
|
||||
parts[2].Length != 22)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sheetIdText = parts[0];
|
||||
issuedAtText = parts[1];
|
||||
signature = parts[2];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ToBase36(long value)
|
||||
{
|
||||
if (value == 0) return "0";
|
||||
Span<char> buffer = stackalloc char[13];
|
||||
var index = buffer.Length;
|
||||
while (value > 0)
|
||||
{
|
||||
buffer[--index] = Base36Digits[(int)(value % 36)];
|
||||
value /= 36;
|
||||
}
|
||||
return new string(buffer[index..]);
|
||||
}
|
||||
|
||||
private static bool TryParseBase36(string value, out long result)
|
||||
{
|
||||
result = 0;
|
||||
foreach (var character in value)
|
||||
{
|
||||
var digit = Base36Digits.IndexOf(
|
||||
char.ToLower(character, CultureInfo.InvariantCulture));
|
||||
if (digit < 0) return false;
|
||||
try
|
||||
{
|
||||
result = checked(result * 36 + digit);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AttendanceCheckInChallengeResult(
|
||||
string Token,
|
||||
DateTime IssuedAt,
|
||||
DateTime ExpiresAt,
|
||||
DateTime RefreshAt);
|
||||
@@ -7,15 +7,94 @@ public static class TeachingTaskHours
|
||||
public static int Calculate(int startWeek, int endWeek, int weeklyHours) =>
|
||||
endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours;
|
||||
|
||||
public static int RegularScheduleHours(Course course) =>
|
||||
RegularScheduleHours(course.TotalHours, course.PracticeHours);
|
||||
|
||||
public static int RegularScheduleHours(int totalHours, int practiceHours) =>
|
||||
Math.Max(0, totalHours - practiceHours);
|
||||
|
||||
public static int TargetHours(
|
||||
Course course,
|
||||
TeachingTaskSchedulingMode schedulingMode) =>
|
||||
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,
|
||||
int endWeek,
|
||||
out int weeklyHours) =>
|
||||
TryResolveRegularWeeklyHours(
|
||||
course.TotalHours,
|
||||
course.PracticeHours,
|
||||
startWeek,
|
||||
endWeek,
|
||||
out weeklyHours);
|
||||
|
||||
public static bool TryResolveRegularWeeklyHours(
|
||||
int totalHours,
|
||||
int practiceHours,
|
||||
int startWeek,
|
||||
int endWeek,
|
||||
out int weeklyHours)
|
||||
{
|
||||
weeklyHours = 0;
|
||||
var weekCount = endWeek - startWeek + 1;
|
||||
var regularHours = RegularScheduleHours(totalHours, practiceHours);
|
||||
if (weekCount <= 0 || regularHours % weekCount != 0) return false;
|
||||
|
||||
weeklyHours = regularHours / weekCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string? Validate(
|
||||
Course course,
|
||||
int startWeek,
|
||||
int endWeek,
|
||||
int weeklyHours)
|
||||
int weeklyHours,
|
||||
TeachingTaskSchedulingMode schedulingMode =
|
||||
TeachingTaskSchedulingMode.Standard)
|
||||
{
|
||||
var plannedHours = Calculate(startWeek, endWeek, weeklyHours);
|
||||
return plannedHours == course.TotalHours
|
||||
? null
|
||||
: $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 {startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 {plannedHours} 学时。请调整授课周次或周学时。";
|
||||
var targetHours = TargetHours(course, schedulingMode);
|
||||
if (plannedHours == targetHours) return null;
|
||||
|
||||
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||
{
|
||||
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
|
||||
$"{course.PracticeHours};理论课和实验课均应进入课表。当前第 " +
|
||||
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
||||
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
||||
}
|
||||
|
||||
return $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 " +
|
||||
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
||||
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||
|
||||
@@ -15,4 +16,107 @@ public static class TeachingTaskRosterQuery
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
|
||||
|
||||
public static IQueryable<Guid> TaskIdsForStudent(AppDbContext db, Guid studentId)
|
||||
{
|
||||
var classTaskIds = db.Students
|
||||
.Where(student =>
|
||||
student.Id == studentId &&
|
||||
student.Status == StudentStatus.Active)
|
||||
.SelectMany(student => db.TeachingTaskClasses
|
||||
.Where(assignment =>
|
||||
assignment.AdministrativeClassId ==
|
||||
student.AdministrativeClassId)
|
||||
.Select(assignment => assignment.TeachingTaskId));
|
||||
var enrolledTaskIds = db.CourseEnrollments
|
||||
.Where(enrollment =>
|
||||
enrollment.StudentId == studentId &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.Select(enrollment =>
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId);
|
||||
return classTaskIds.Concat(enrolledTaskIds).Distinct();
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<TeachingTaskRosterEntry>> LoadForTasksAsync(
|
||||
AppDbContext db,
|
||||
IEnumerable<Guid> teachingTaskIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var taskIds = teachingTaskIds.Distinct().ToArray();
|
||||
if (taskIds.Length == 0) return [];
|
||||
|
||||
var assignments = await db.TeachingTaskClasses.AsNoTracking()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.TeachingTaskId,
|
||||
x.AdministrativeClassId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var classIds = assignments
|
||||
.Select(x => x.AdministrativeClassId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var classStudents = classIds.Length == 0
|
||||
? []
|
||||
: await db.Students.AsNoTracking()
|
||||
.Where(x => x.Status == StudentStatus.Active)
|
||||
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||
.Select(x => new RosterStudent(
|
||||
x.Id,
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.AdministrativeClassId,
|
||||
x.AdministrativeClass!.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||
.Select(x => new TeachingTaskRosterEntry(
|
||||
x.CourseSelectionOffering!.TeachingTaskId,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
x.Student.AdministrativeClass!.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = enrolledStudents.ToDictionary(
|
||||
x => (x.TeachingTaskId, x.StudentId));
|
||||
var studentsByClass = classStudents.ToLookup(x => x.AdministrativeClassId);
|
||||
foreach (var assignment in assignments)
|
||||
{
|
||||
foreach (var student in studentsByClass[assignment.AdministrativeClassId])
|
||||
{
|
||||
result.TryAdd(
|
||||
(assignment.TeachingTaskId, student.StudentId),
|
||||
new TeachingTaskRosterEntry(
|
||||
assignment.TeachingTaskId,
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName));
|
||||
}
|
||||
}
|
||||
|
||||
return result.Values
|
||||
.OrderBy(x => x.TeachingTaskId)
|
||||
.ThenBy(x => x.StudentNumber)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record RosterStudent(
|
||||
Guid StudentId,
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
Guid AdministrativeClassId,
|
||||
string ClassName);
|
||||
}
|
||||
|
||||
public sealed record TeachingTaskRosterEntry(
|
||||
Guid TeachingTaskId,
|
||||
Guid StudentId,
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
string ClassName);
|
||||
|
||||
@@ -58,6 +58,17 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
occupiedIds.UnionWith(examRoomIds);
|
||||
|
||||
var mixedExamRoomIds = await db.ExamRooms.AsNoTracking()
|
||||
.Where(room =>
|
||||
room.ExamPlan!.AcademicTermId == term.Id &&
|
||||
room.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||
room.ExamDate == reservationDate &&
|
||||
room.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < room.StartPeriod + room.PeriodCount)
|
||||
.Select(room => room.ClassroomId)
|
||||
.ToListAsync(cancellationToken);
|
||||
occupiedIds.UnionWith(mixedExamRoomIds);
|
||||
|
||||
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(session =>
|
||||
session.ClassroomId.HasValue &&
|
||||
@@ -70,6 +81,20 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
occupiedIds.UnionWith(makeupExamRoomIds);
|
||||
|
||||
var experimentRoomIds = await db.ExperimentSessions.AsNoTracking()
|
||||
.Where(session =>
|
||||
session.ExperimentProject!.TeachingTask!.AcademicTermId ==
|
||||
term.Id &&
|
||||
session.ExperimentProject.Status !=
|
||||
ExperimentProjectStatus.Closed &&
|
||||
session.Status == ExperimentSessionStatus.Scheduled &&
|
||||
session.SessionDate == reservationDate &&
|
||||
session.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||
.Select(session => session.ClassroomId)
|
||||
.ToListAsync(cancellationToken);
|
||||
occupiedIds.UnionWith(experimentRoomIds);
|
||||
|
||||
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
||||
.Where(reservation =>
|
||||
reservation.AcademicTermId == term.Id &&
|
||||
|
||||
@@ -115,6 +115,7 @@ public sealed class PersonalCalendarService(
|
||||
|
||||
AddScheduledCourses(builder, timetable);
|
||||
AddExamEntries(builder, timetable, teacher is not null);
|
||||
AddExperimentEntries(builder, timetable);
|
||||
AddFlexibleCourseReminders(builder, timetable);
|
||||
}
|
||||
|
||||
@@ -246,6 +247,46 @@ public sealed class PersonalCalendarService(
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExperimentEntries(
|
||||
IcsBuilder builder,
|
||||
TimetableData timetable)
|
||||
{
|
||||
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||
foreach (var entry in timetable.ExperimentEntries)
|
||||
{
|
||||
if (entry.ExperimentDate is not DateOnly experimentDate ||
|
||||
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||
!slots.TryGetValue(
|
||||
entry.StartPeriod + entry.PeriodCount - 1,
|
||||
out var endSlot))
|
||||
continue;
|
||||
|
||||
var arrangement = entry.ExperimentArrangementMode switch
|
||||
{
|
||||
ExperimentArrangementMode.Centralized => "集中安排",
|
||||
ExperimentArrangementMode.SelfScheduled => "自主预约",
|
||||
_ => "实验安排"
|
||||
};
|
||||
builder.AddTimedEvent(
|
||||
$"experiment-{entry.Id:N}@jiaowu",
|
||||
$"[实验] {entry.ExperimentProjectName ?? entry.CourseName}",
|
||||
ToUtc(experimentDate, startSlot.StartsAt),
|
||||
ToUtc(experimentDate, endSlot.EndsAt),
|
||||
JoinLocation(
|
||||
entry.CampusName,
|
||||
entry.BuildingName,
|
||||
entry.ClassroomName),
|
||||
JoinDescription(
|
||||
$"{entry.ExperimentProjectCode} · {arrangement}",
|
||||
entry.CourseName,
|
||||
entry.TaskName,
|
||||
$"教师:{string.Join('、', entry.TeacherNames)}",
|
||||
entry.Notes),
|
||||
"实验",
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
|
||||
Guid? studentId,
|
||||
Guid? teacherId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||
@@ -27,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(
|
||||
@@ -75,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)
|
||||
@@ -103,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);
|
||||
}
|
||||
|
||||
@@ -117,6 +127,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
// Load exam sessions for student/teacher timetables
|
||||
var examEntries = await LoadExamEntriesAsync(
|
||||
resourceType, resourceId, term.Id, studentId, slots, cancellationToken);
|
||||
var experimentEntries = await LoadExperimentEntriesAsync(
|
||||
resourceType, term, studentId, cancellationToken);
|
||||
|
||||
return new TimetableData(
|
||||
term,
|
||||
@@ -127,7 +139,10 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
slots,
|
||||
entries,
|
||||
flexibleCourses,
|
||||
examEntries);
|
||||
examEntries)
|
||||
{
|
||||
ExperimentEntries = experimentEntries
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<TimetableSubjectDto?> LoadSubjectAsync(
|
||||
@@ -277,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(
|
||||
@@ -307,69 +323,178 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
IReadOnlyList<TimetableSlotDto> slots,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (resourceType == TimetableResourceType.Classroom) return [];
|
||||
|
||||
var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
|
||||
var result = new List<TimetableEntryDto>();
|
||||
|
||||
IQueryable<ExamSession> source = db.ExamSessions.AsNoTracking()
|
||||
// ── Legacy sessions (ExamSessions with direct ClassroomId) ──
|
||||
var legacyQuery = db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlan!.AcademicTermId == academicTermId &&
|
||||
x.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||
x.ClassroomId != null);
|
||||
|
||||
if (resourceType == TimetableResourceType.Teacher)
|
||||
{
|
||||
source = source.Where(x =>
|
||||
x.Invigilators.Any(i => i.TeacherId == resourceId));
|
||||
}
|
||||
else if (studentId.HasValue)
|
||||
{
|
||||
source = source.Where(x =>
|
||||
db.CourseEnrollments.Any(e =>
|
||||
e.StudentId == studentId.Value &&
|
||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Class timetable: exams for the class's teaching tasks
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.Classes.Any(c =>
|
||||
c.AdministrativeClassId == resourceId));
|
||||
}
|
||||
legacyQuery = ApplyLegacyResourceFilter(
|
||||
legacyQuery, resourceType, resourceId, studentId);
|
||||
|
||||
var sessions = await source
|
||||
var legacySessions = await legacyQuery
|
||||
.TagWith("Timetable.LoadLegacyExamEntries")
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
.Select(x => new ExamSessionProjection(
|
||||
x.Id,
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
x.TeachingTask.Name,
|
||||
x.TeachingTask.Course!.Code,
|
||||
x.TeachingTask.Course.Name,
|
||||
x.TeachingTask.Teachers
|
||||
.OrderByDescending(t => t.IsPrimary)
|
||||
.Select(t => t.Teacher!.Name),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(c => c.AdministrativeClass!.Name),
|
||||
ClassroomName = x.Classroom!.Name,
|
||||
BuildingName = x.Classroom.Building!.Name,
|
||||
CampusName = x.Classroom.Building.Campus!.Name,
|
||||
.Select(t => t.Teacher!.Name).ToList(),
|
||||
x.TeachingTask.Classes
|
||||
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
||||
x.Classroom!.Name,
|
||||
x.Classroom.Building!.Name,
|
||||
x.Classroom.Building.Campus!.Name,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.ExamPlan!.Name,
|
||||
InvigilatorNames = x.Invigilators
|
||||
.Select(i => i.Teacher!.Name),
|
||||
x.Invigilators.Select(i => i.Teacher!.Name).ToList(),
|
||||
x.Notes,
|
||||
x.UpdatedAt
|
||||
})
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
result.AddRange(legacySessions.Select(x =>
|
||||
MapToEntryDto(x, slotLookup)));
|
||||
|
||||
// ── Mixed-room sessions (ExamRoomAssignment) ──
|
||||
// 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()
|
||||
.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(),
|
||||
link.ExamSession.TeachingTask.Classes
|
||||
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
private async Task<List<TimetableEntryDto>> LoadExperimentEntriesAsync(
|
||||
TimetableResourceType resourceType,
|
||||
TimetableTermDto term,
|
||||
Guid? studentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (resourceType != TimetableResourceType.Class || !studentId.HasValue)
|
||||
return [];
|
||||
|
||||
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
|
||||
db,
|
||||
studentId.Value);
|
||||
var sessions = await db.ExperimentSessions.AsNoTracking()
|
||||
.TagWith("Timetable.LoadExperimentEntries")
|
||||
.AsSplitQuery()
|
||||
.Where(x =>
|
||||
x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id &&
|
||||
(x.ExperimentProject.Status ==
|
||||
ExperimentProjectStatus.Published ||
|
||||
x.ExperimentProject.Status ==
|
||||
ExperimentProjectStatus.Closed) &&
|
||||
x.Status == ExperimentSessionStatus.Scheduled)
|
||||
.Where(x =>
|
||||
(x.ExperimentProject!.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized &&
|
||||
rosterTaskIds.Contains(
|
||||
x.ExperimentProject.TeachingTaskId)) ||
|
||||
(x.ExperimentProject.ArrangementMode ==
|
||||
ExperimentArrangementMode.SelfScheduled &&
|
||||
x.Bookings.Any(booking =>
|
||||
booking.StudentId == studentId.Value &&
|
||||
booking.Status == ExperimentBookingStatus.Booked)))
|
||||
.OrderBy(x => x.SessionDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.ExperimentProject!.Code)
|
||||
.Select(x => new ExperimentSessionProjection(
|
||||
x.Id,
|
||||
x.ExperimentProjectId,
|
||||
x.ExperimentProject!.Code,
|
||||
x.ExperimentProject.Name,
|
||||
x.ExperimentProject.ArrangementMode,
|
||||
x.ExperimentProject.TeachingTaskId,
|
||||
x.ExperimentProject.TeachingTask!.TaskNumber,
|
||||
x.ExperimentProject.TeachingTask.Name,
|
||||
x.ExperimentProject.TeachingTask.Course!.Code,
|
||||
x.ExperimentProject.TeachingTask.Course.Name,
|
||||
x.ExperimentProject.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
.ToList(),
|
||||
x.ExperimentProject.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name)
|
||||
.ToList(),
|
||||
x.Classroom!.Name,
|
||||
x.Classroom.Building!.Name,
|
||||
x.Classroom.Building.Campus!.Name,
|
||||
x.SessionDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.Notes,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var termMonday = StartOfWeek(term.StartDate);
|
||||
return sessions.Select(x =>
|
||||
{
|
||||
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek;
|
||||
var week = (x.SessionDate.DayNumber - termMonday.DayNumber) / 7 + 1;
|
||||
var dayOfWeek = x.SessionDate.DayOfWeek == DayOfWeek.Sunday
|
||||
? 7
|
||||
: (int)x.SessionDate.DayOfWeek;
|
||||
return new TimetableEntryDto(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
@@ -377,8 +502,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.TaskName,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.TeacherNames.Concat(
|
||||
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }),
|
||||
x.TeacherNames,
|
||||
x.ClassNames,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
@@ -386,15 +510,208 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
dayOfWeek,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
1, 1,
|
||||
week,
|
||||
week,
|
||||
WeekPattern.All,
|
||||
x.Notes,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
x.UpdatedAt,
|
||||
true,
|
||||
x.Name,
|
||||
x.ExamDate,
|
||||
x.UpdatedAt);
|
||||
x.ExperimentProjectId,
|
||||
x.ProjectCode,
|
||||
x.ProjectName,
|
||||
x.SessionDate,
|
||||
x.ArrangementMode);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static DateOnly StartOfWeek(DateOnly date)
|
||||
{
|
||||
var dayOfWeek = date.DayOfWeek == DayOfWeek.Sunday
|
||||
? 7
|
||||
: (int)date.DayOfWeek;
|
||||
return date.AddDays(1 - dayOfWeek);
|
||||
}
|
||||
|
||||
private static IQueryable<ExamRoomSession> ApplyMixedResourceFilter(
|
||||
IQueryable<ExamRoomSession> source,
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid? studentId)
|
||||
{
|
||||
switch (resourceType)
|
||||
{
|
||||
case TimetableResourceType.Classroom:
|
||||
return source.Where(link =>
|
||||
link.ExamRoom!.ClassroomId == resourceId);
|
||||
case TimetableResourceType.Teacher:
|
||||
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 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 source;
|
||||
}
|
||||
}
|
||||
|
||||
private IQueryable<ExamSession> ApplyLegacyResourceFilter(
|
||||
IQueryable<ExamSession> source,
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid? studentId)
|
||||
{
|
||||
switch (resourceType)
|
||||
{
|
||||
case TimetableResourceType.Classroom:
|
||||
return source.Where(x => x.ClassroomId == resourceId);
|
||||
case TimetableResourceType.Teacher:
|
||||
return source.Where(x =>
|
||||
x.Invigilators.Any(i => i.TeacherId == resourceId) ||
|
||||
x.TeachingTask!.Teachers.Any(t => t.TeacherId == resourceId));
|
||||
case TimetableResourceType.Class:
|
||||
if (studentId.HasValue)
|
||||
{
|
||||
return source.Where(x =>
|
||||
x.TeachingTask!.Classes.Any(c =>
|
||||
c.AdministrativeClassId == resourceId) ||
|
||||
db.CourseEnrollments.Any(e =>
|
||||
e.StudentId == studentId.Value &&
|
||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.CourseSelectionOffering!.TeachingTaskId ==
|
||||
x.TeachingTaskId));
|
||||
}
|
||||
return source.Where(x =>
|
||||
x.TeachingTask!.Classes.Any(c =>
|
||||
c.AdministrativeClassId == resourceId));
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
private static TimetableEntryDto MapToEntryDto(
|
||||
ExamSessionProjection x,
|
||||
IReadOnlyDictionary<int, TimetableSlotDto> slotLookup)
|
||||
{
|
||||
var dayOfWeek = x.ExamDate.DayOfWeek == 0
|
||||
? 7
|
||||
: (int)x.ExamDate.DayOfWeek;
|
||||
var teacherNames = x.InvigilatorNames.Count > 0
|
||||
? x.TeacherNames.Concat(
|
||||
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
|
||||
: x.TeacherNames;
|
||||
return new TimetableEntryDto(
|
||||
x.ExamRoomId,
|
||||
x.TeachingTaskId,
|
||||
x.TaskNumber,
|
||||
x.TaskName,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
teacherNames,
|
||||
x.ClassNames,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.CampusName,
|
||||
dayOfWeek,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
1, 1,
|
||||
WeekPattern.All,
|
||||
x.Notes,
|
||||
true,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
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 ExamSessionId,
|
||||
Guid ExamRoomId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
List<string> TeacherNames,
|
||||
List<string> ClassNames,
|
||||
string ClassroomName,
|
||||
string BuildingName,
|
||||
string CampusName,
|
||||
DateOnly ExamDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
string PlanName,
|
||||
List<string> InvigilatorNames,
|
||||
string? Notes,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
private sealed record ExperimentSessionProjection(
|
||||
Guid Id,
|
||||
Guid ExperimentProjectId,
|
||||
string ProjectCode,
|
||||
string ProjectName,
|
||||
ExperimentArrangementMode ArrangementMode,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
List<string> TeacherNames,
|
||||
List<string> ClassNames,
|
||||
string ClassroomName,
|
||||
string BuildingName,
|
||||
string CampusName,
|
||||
DateOnly SessionDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
string? Notes,
|
||||
DateTime UpdatedAt);
|
||||
}
|
||||
|
||||
public enum TimetableResourceType
|
||||
@@ -413,7 +730,10 @@ public sealed record TimetableData(
|
||||
IReadOnlyList<TimetableSlotDto> Slots,
|
||||
IReadOnlyList<TimetableEntryDto> Entries,
|
||||
IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
|
||||
IReadOnlyList<TimetableEntryDto> ExamEntries);
|
||||
IReadOnlyList<TimetableEntryDto> ExamEntries)
|
||||
{
|
||||
public IReadOnlyList<TimetableEntryDto> ExperimentEntries { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record TimetableTermDto(
|
||||
Guid Id,
|
||||
@@ -476,7 +796,15 @@ public sealed record TimetableEntryDto(
|
||||
bool IsExam = false,
|
||||
string? ExamPlanName = null,
|
||||
DateOnly? ExamDate = null,
|
||||
DateTime UpdatedAt = default);
|
||||
DateTime UpdatedAt = default,
|
||||
bool IsExperiment = false,
|
||||
Guid? ExperimentProjectId = null,
|
||||
string? ExperimentProjectCode = null,
|
||||
string? ExperimentProjectName = null,
|
||||
DateOnly? ExperimentDate = null,
|
||||
ExperimentArrangementMode? ExperimentArrangementMode = null,
|
||||
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture,
|
||||
int ExamRoomCount = 1);
|
||||
|
||||
public sealed record FlexibleCourseDto(
|
||||
Guid Id,
|
||||
|
||||
@@ -25,9 +25,21 @@ public static class TimetableExcelExporter
|
||||
sheet.Row(1).Height = 34;
|
||||
|
||||
sheet.Range("A2:H2").Merge();
|
||||
var activitySummary = string.Concat(
|
||||
timetable.ExamEntries.Count > 0
|
||||
? $" · 已融入 {timetable.ExamEntries.Count} 场考试"
|
||||
: "",
|
||||
timetable.ExperimentEntries.Count > 0
|
||||
? $" · 已融入 {timetable.ExperimentEntries.Count} 场实验"
|
||||
: "");
|
||||
var hasOneOffActivities =
|
||||
timetable.ExamEntries.Count > 0 ||
|
||||
timetable.ExperimentEntries.Count > 0;
|
||||
sheet.Cell("A2").Value = timetable.Plan is null
|
||||
? "本学期暂无可用课表版本"
|
||||
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||
? hasOneOffActivities
|
||||
? $"固定课表尚未发布{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"
|
||||
: "本学期暂无可用课表版本"
|
||||
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)}{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||
sheet.Cell("A2").Style
|
||||
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
|
||||
.Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6"));
|
||||
@@ -48,8 +60,12 @@ public static class TimetableExcelExporter
|
||||
}
|
||||
sheet.Row(headerRow).Height = 26;
|
||||
|
||||
var timedEntries = timetable.Entries
|
||||
.Concat(timetable.ExamEntries)
|
||||
.Concat(timetable.ExperimentEntries)
|
||||
.ToArray();
|
||||
var periods = timetable.Slots.Select(x => x.PeriodNumber)
|
||||
.Concat(timetable.Entries.SelectMany(x =>
|
||||
.Concat(timedEntries.SelectMany(x =>
|
||||
Enumerable.Range(x.StartPeriod, x.PeriodCount)))
|
||||
.Distinct()
|
||||
.Order()
|
||||
@@ -72,7 +88,7 @@ public static class TimetableExcelExporter
|
||||
|
||||
for (var day = 1; day <= 7; day++)
|
||||
{
|
||||
var entries = timetable.Entries
|
||||
var entries = timedEntries
|
||||
.Where(x =>
|
||||
x.DayOfWeek == day &&
|
||||
x.StartPeriod <= period &&
|
||||
@@ -86,7 +102,12 @@ public static class TimetableExcelExporter
|
||||
if (entries.Count > 0)
|
||||
{
|
||||
cell.Style
|
||||
.Fill.SetBackgroundColor(XLColor.FromHtml("#E8F3F2"))
|
||||
.Fill.SetBackgroundColor(XLColor.FromHtml(
|
||||
entries.Any(x => x.IsExam)
|
||||
? "#FFF1E8"
|
||||
: entries.Any(x => x.IsExperiment)
|
||||
? "#E7F5F1"
|
||||
: "#E8F3F2"))
|
||||
.Font.SetFontColor(XLColor.FromHtml("#173F4C"));
|
||||
}
|
||||
}
|
||||
@@ -138,10 +159,32 @@ public static class TimetableExcelExporter
|
||||
}
|
||||
|
||||
private static string EntryText(TimetableEntryDto entry) =>
|
||||
$"{entry.CourseName}\n" +
|
||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||
$"{Location(entry)}\n" +
|
||||
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
||||
entry.IsExperiment
|
||||
? $"【实验】{entry.ExperimentProjectName}\n" +
|
||||
$"{entry.CourseName} · {ArrangementMode(entry)}\n" +
|
||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||
$"{Location(entry)}\n" +
|
||||
$"{entry.ExperimentDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
||||
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
||||
: entry.IsExam
|
||||
? $"【考试】{entry.CourseName}\n" +
|
||||
$"{entry.ExamPlanName}\n" +
|
||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||
$"{Location(entry)}\n" +
|
||||
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
||||
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
||||
: $"{(entry.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "【实验课】" : "")}{entry.CourseName}\n" +
|
||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||
$"{Location(entry)}\n" +
|
||||
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
||||
|
||||
private static string ArrangementMode(TimetableEntryDto entry) =>
|
||||
entry.ExperimentArrangementMode switch
|
||||
{
|
||||
Domain.Academic.ExperimentArrangementMode.Centralized => "集中安排",
|
||||
Domain.Academic.ExperimentArrangementMode.SelfScheduled => "自主预约",
|
||||
_ => "实验安排"
|
||||
};
|
||||
|
||||
private static string Location(TimetableEntryDto entry) =>
|
||||
string.Join(" · ", new[]
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
|
||||
<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" />
|
||||
|
||||
+126
-1
@@ -7,7 +7,9 @@ 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;
|
||||
@@ -18,6 +20,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
EnvironmentFile.Load();
|
||||
@@ -74,6 +79,15 @@ var officialDocumentOptions = builder.Configuration
|
||||
var backgroundJobOptions = builder.Configuration
|
||||
.GetSection(BackgroundJobOptions.SectionName)
|
||||
.Get<BackgroundJobOptions>() ?? new BackgroundJobOptions();
|
||||
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();
|
||||
@@ -106,6 +120,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 ||
|
||||
@@ -140,6 +194,9 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
||||
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||
@@ -167,15 +224,42 @@ if (backgroundJobOptions.UsesRabbitMq &&
|
||||
"生产环境启用 RabbitMQ 时不能使用默认 guest 凭据。");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(operationsOptions.BackupDirectory) ||
|
||||
operationsOptions.BackupWarningHours is < 1 or > 8760 ||
|
||||
operationsOptions.ToolTimeoutMinutes is < 1 or > 240 ||
|
||||
string.IsNullOrWhiteSpace(operationsOptions.MySqlDumpPath) ||
|
||||
string.IsNullOrWhiteSpace(operationsOptions.MySqlClientPath) ||
|
||||
operationsOptions.MySqlAdditionalArguments.Length > 20 ||
|
||||
operationsOptions.MySqlAdditionalArguments.Any(argument =>
|
||||
string.IsNullOrWhiteSpace(argument) ||
|
||||
argument.Length > 300 ||
|
||||
!argument.StartsWith("--", StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("Operations 运维与备份配置超出允许范围。");
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton(databaseOptions);
|
||||
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")
|
||||
@@ -218,6 +302,28 @@ 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))
|
||||
{
|
||||
@@ -275,8 +381,13 @@ builder.Services.AddScoped<ExamArrangementService>();
|
||||
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
||||
builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||
builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
builder.Services.AddSingleton<DatabaseBackupService>();
|
||||
builder.Services.AddSingleton<BackgroundJobRunner>();
|
||||
if (backgroundJobOptions.UsesRabbitMq)
|
||||
{
|
||||
@@ -337,6 +448,16 @@ builder.Services.AddRateLimiter(options =>
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
options.AddPolicy("app-updates", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 120,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
@@ -579,4 +700,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;
|
||||
|
||||
@@ -19,6 +19,34 @@
|
||||
"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,
|
||||
"ToolTimeoutMinutes": 30,
|
||||
"MySqlDumpPath": "mysqldump",
|
||||
"MySqlClientPath": "mysql",
|
||||
"MySqlAdditionalArguments": []
|
||||
},
|
||||
"BackgroundJobs": {
|
||||
"Transport": "InMemory",
|
||||
"PollIntervalMilliseconds": 500,
|
||||
@@ -27,6 +55,7 @@
|
||||
"AutomaticScheduleConcurrency": 1,
|
||||
"SchedulePublishConcurrency": 1,
|
||||
"MakeupExamAutoConcurrency": 1,
|
||||
"ExamArrangementConcurrency": 1,
|
||||
"Exchange": "jiaowu.background-jobs",
|
||||
"QueuePrefix": "jiaowu.background-jobs",
|
||||
"UseQuorumQueues": true,
|
||||
@@ -51,7 +80,11 @@
|
||||
"ExpireMinutes": 60
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": []
|
||||
"Origins": [
|
||||
"capacitor://localhost",
|
||||
"https://localhost",
|
||||
"http://localhost"
|
||||
]
|
||||
},
|
||||
"OfficialDocuments": {
|
||||
"InstitutionName": "明序大学",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Text.Json;
|
||||
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 AcademicPlanningControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Simulation_reuses_published_grade_and_checks_prerequisites()
|
||||
{
|
||||
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 = "student",
|
||||
NormalizedUserName = "STUDENT",
|
||||
DisplayName = "规划学生"
|
||||
};
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var major = new Major
|
||||
{
|
||||
Code = "SE",
|
||||
Name = "软件工程",
|
||||
CollegeId = college.Id,
|
||||
DegreeType = "工学",
|
||||
SchoolingYears = 4
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "SE2501",
|
||||
Name = "软件工程2501班",
|
||||
MajorId = major.Id,
|
||||
Grade = 2025
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "20250001",
|
||||
Name = "规划学生",
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2025,
|
||||
EnrollmentDate = new DateOnly(2025, 9, 1),
|
||||
UserId = user.Id
|
||||
};
|
||||
var pastTerm = new AcademicTerm
|
||||
{
|
||||
Code = "2025-A",
|
||||
Name = "2025—2026 学年秋季学期",
|
||||
AcademicYear = "2025-2026",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2025, 9, 1),
|
||||
EndDate = new DateOnly(2026, 1, 15)
|
||||
};
|
||||
var currentTerm = new AcademicTerm
|
||||
{
|
||||
Code = "2025-S",
|
||||
Name = "2025—2026 学年春季学期",
|
||||
AcademicYear = "2025-2026",
|
||||
Season = TermSeason.Spring,
|
||||
StartDate = new DateOnly(2026, 2, 20),
|
||||
EndDate = new DateOnly(2026, 7, 10),
|
||||
IsCurrent = true
|
||||
};
|
||||
var introduction = new Course
|
||||
{
|
||||
Code = "CS101",
|
||||
Name = "程序设计基础",
|
||||
CollegeId = college.Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
var dataStructures = new Course
|
||||
{
|
||||
Code = "CS201",
|
||||
Name = "数据结构",
|
||||
CollegeId = college.Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination,
|
||||
Prerequisites =
|
||||
[
|
||||
new CoursePrerequisite
|
||||
{
|
||||
PrerequisiteCourseId = introduction.Id
|
||||
}
|
||||
]
|
||||
};
|
||||
var plan = new CurriculumPlan
|
||||
{
|
||||
MajorId = major.Id,
|
||||
Name = "软件工程本科培养方案",
|
||||
Version = "2025",
|
||||
EffectiveGrade = 2025,
|
||||
TotalCredits = 8,
|
||||
Status = CurriculumPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Modules =
|
||||
[
|
||||
new CurriculumModule
|
||||
{
|
||||
Code = "M01",
|
||||
Name = "专业基础",
|
||||
RequiredCredits = 0,
|
||||
Courses =
|
||||
[
|
||||
new CurriculumCourse
|
||||
{
|
||||
CourseId = introduction.Id,
|
||||
RecommendedSemester = 1,
|
||||
Type = CurriculumCourseType.Required
|
||||
},
|
||||
new CurriculumCourse
|
||||
{
|
||||
CourseId = dataStructures.Id,
|
||||
RecommendedSemester = 3,
|
||||
Type = CurriculumCourseType.Required
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
var teachingTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "2025-CS101-01",
|
||||
Name = "程序设计基础",
|
||||
AcademicTermId = pastTerm.Id,
|
||||
CourseId = introduction.Id,
|
||||
Capacity = 50,
|
||||
Status = TeachingTaskStatus.Closed
|
||||
};
|
||||
var gradeSheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = teachingTask.Id,
|
||||
Status = GradeSheetStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Records =
|
||||
[
|
||||
new GradeRecord
|
||||
{
|
||||
StudentId = student.Id,
|
||||
TotalScore = 82,
|
||||
GradePoint = 3.2m
|
||||
}
|
||||
]
|
||||
};
|
||||
db.AddRange(
|
||||
user,
|
||||
college,
|
||||
major,
|
||||
administrativeClass,
|
||||
student,
|
||||
pastTerm,
|
||||
currentTerm,
|
||||
introduction,
|
||||
dataStructures,
|
||||
plan,
|
||||
teachingTask,
|
||||
gradeSheet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new AcademicPlanningController(
|
||||
db,
|
||||
new StudentDataScope(user.Id));
|
||||
var overview = Assert.IsType<OkObjectResult>(
|
||||
await controller.Get(default));
|
||||
using var overviewJson = ToJson(overview.Value);
|
||||
Assert.Equal(
|
||||
4,
|
||||
overviewJson.RootElement
|
||||
.GetProperty("baseline")
|
||||
.GetProperty("earnedCredits")
|
||||
.GetDecimal());
|
||||
Assert.Equal(
|
||||
dataStructures.Id,
|
||||
overviewJson.RootElement
|
||||
.GetProperty("nextSemesterSuggestion")[0]
|
||||
.GetProperty("courseId")
|
||||
.GetGuid());
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(
|
||||
await controller.Simulate(
|
||||
new AcademicPlanningRequest(
|
||||
[
|
||||
new AcademicPlanningTermRequest(
|
||||
3,
|
||||
[dataStructures.Id])
|
||||
]),
|
||||
default));
|
||||
using var resultJson = ToJson(result.Value);
|
||||
Assert.Empty(resultJson.RootElement.GetProperty("conflicts").EnumerateArray());
|
||||
var projected = resultJson.RootElement.GetProperty("projected");
|
||||
Assert.Equal(8, projected.GetProperty("earnedCredits").GetDecimal());
|
||||
Assert.Equal(
|
||||
(int)GraduationAuditConclusion.Eligible,
|
||||
projected.GetProperty("graduationConclusion").GetInt32());
|
||||
}
|
||||
|
||||
private static JsonDocument ToJson(object? value) => JsonDocument.Parse(
|
||||
JsonSerializer.Serialize(
|
||||
value,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
|
||||
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId,
|
||||
"规划学生",
|
||||
null,
|
||||
DataScope.Self,
|
||||
new HashSet<string>([SystemRoles.Student]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Graduation;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AcademicPlanningRulesTests
|
||||
{
|
||||
private static readonly Guid AdvancedCourseId = Guid.NewGuid();
|
||||
private static readonly Guid PrerequisiteCourseId = Guid.NewGuid();
|
||||
|
||||
private static readonly AcademicPlanningCourseSnapshot[] Courses =
|
||||
[
|
||||
new(
|
||||
PrerequisiteCourseId,
|
||||
"程序设计基础",
|
||||
4,
|
||||
1,
|
||||
CurriculumCourseType.Required,
|
||||
[]),
|
||||
new(
|
||||
AdvancedCourseId,
|
||||
"数据结构",
|
||||
4,
|
||||
2,
|
||||
CurriculumCourseType.Required,
|
||||
[PrerequisiteCourseId])
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Prerequisite_must_be_completed_or_planned_in_an_earlier_term()
|
||||
{
|
||||
var sameTerm = AcademicPlanningRules.FindPrerequisiteIssues(
|
||||
Courses,
|
||||
new HashSet<Guid>(),
|
||||
new HashSet<Guid>(),
|
||||
new Dictionary<Guid, int>
|
||||
{
|
||||
[PrerequisiteCourseId] = 3,
|
||||
[AdvancedCourseId] = 3
|
||||
});
|
||||
|
||||
var correctOrder = AcademicPlanningRules.FindPrerequisiteIssues(
|
||||
Courses,
|
||||
new HashSet<Guid>(),
|
||||
new HashSet<Guid>(),
|
||||
new Dictionary<Guid, int>
|
||||
{
|
||||
[PrerequisiteCourseId] = 3,
|
||||
[AdvancedCourseId] = 4
|
||||
});
|
||||
|
||||
Assert.Single(sameTerm);
|
||||
Assert.Empty(correctOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void In_progress_prerequisite_unlocks_next_term_suggestion()
|
||||
{
|
||||
var suggestion = AcademicPlanningRules.SuggestNextSemester(
|
||||
Courses,
|
||||
new HashSet<Guid>(),
|
||||
new HashSet<Guid>([PrerequisiteCourseId]),
|
||||
2);
|
||||
|
||||
Assert.Equal([AdvancedCourseId], suggestion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 5, 4, 4)]
|
||||
[InlineData(25, 0, 5, 4, 6)]
|
||||
[InlineData(0, 7, 5, 4, 6)]
|
||||
public void Completion_estimate_uses_credit_and_requirement_capacity(
|
||||
decimal remainingCredits,
|
||||
int remainingRequirements,
|
||||
int nextSemester,
|
||||
int latestPlannedSemester,
|
||||
int expectedSemester)
|
||||
{
|
||||
Assert.Equal(
|
||||
expectedSemester,
|
||||
AcademicPlanningRules.EstimateCompletionSemester(
|
||||
nextSemester,
|
||||
latestPlannedSemester,
|
||||
remainingCredits,
|
||||
remainingRequirements));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AppUpdatesControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Management_actions_are_restricted_to_super_admin()
|
||||
{
|
||||
var method = typeof(AppUpdatesController)
|
||||
.GetMethod(nameof(AppUpdatesController.UploadRelease));
|
||||
var authorize = method?.GetCustomAttribute<AuthorizeAttribute>();
|
||||
|
||||
Assert.NotNull(authorize);
|
||||
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_publish_check_and_download_form_a_complete_flow()
|
||||
{
|
||||
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||
var content = CreateBundle("first");
|
||||
|
||||
var uploaded = await fixture.Controller.UploadRelease(
|
||||
UploadRequest(content, "1.0.1"),
|
||||
CancellationToken.None);
|
||||
var created = Assert.IsType<CreatedAtActionResult>(uploaded.Result);
|
||||
var release = Assert.IsType<AppUpdateReleaseItem>(created.Value);
|
||||
Assert.Equal(AppUpdateReleaseStatus.Draft, release.Status);
|
||||
Assert.Equal(64, release.Sha256.Length);
|
||||
|
||||
var publishedAction = await fixture.Controller.PublishRelease(
|
||||
release.Id,
|
||||
CancellationToken.None);
|
||||
var publishedResult = Assert.IsType<OkObjectResult>(
|
||||
publishedAction.Result);
|
||||
var published = Assert.IsType<AppUpdateReleaseItem>(
|
||||
publishedResult.Value);
|
||||
Assert.Equal(AppUpdateReleaseStatus.Published, published.Status);
|
||||
|
||||
var checkAction = await fixture.Controller.GetLatest(
|
||||
"android",
|
||||
"1.0",
|
||||
"production",
|
||||
"1.0.0",
|
||||
CancellationToken.None);
|
||||
var checkResult = Assert.IsType<OkObjectResult>(checkAction.Result);
|
||||
var check = Assert.IsType<AppUpdateCheckResponse>(checkResult.Value);
|
||||
Assert.True(check.Available);
|
||||
Assert.Equal("1.0.1", check.Version);
|
||||
Assert.Equal(release.Sha256, check.Sha256);
|
||||
Assert.Equal($"app-updates/releases/{release.Id}/bundle", check.DownloadUrl);
|
||||
|
||||
var downloadAction = await fixture.Controller.DownloadBundle(
|
||||
release.Id,
|
||||
CancellationToken.None);
|
||||
var download = Assert.IsType<FileContentResult>(downloadAction);
|
||||
Assert.Equal(content, download.FileContents);
|
||||
Assert.True(download.EnableRangeProcessing);
|
||||
|
||||
var currentAction = await fixture.Controller.GetLatest(
|
||||
"android",
|
||||
"1.0",
|
||||
"production",
|
||||
"1.0.1",
|
||||
CancellationToken.None);
|
||||
var currentResult = Assert.IsType<OkObjectResult>(
|
||||
currentAction.Result);
|
||||
Assert.False(Assert.IsType<AppUpdateCheckResponse>(
|
||||
currentResult.Value).Available);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publishing_an_archived_release_performs_a_rollback()
|
||||
{
|
||||
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||
var first = await UploadAsync(fixture, "1.0.1", "first");
|
||||
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
|
||||
var second = await UploadAsync(fixture, "1.0.2", "second");
|
||||
await fixture.Controller.PublishRelease(second.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
AppUpdateReleaseStatus.Archived,
|
||||
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
|
||||
Assert.Equal(
|
||||
AppUpdateReleaseStatus.Published,
|
||||
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
|
||||
|
||||
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
|
||||
fixture.Db.ChangeTracker.Clear();
|
||||
|
||||
Assert.Equal(
|
||||
AppUpdateReleaseStatus.Published,
|
||||
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
|
||||
Assert.Equal(
|
||||
AppUpdateReleaseStatus.Archived,
|
||||
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_or_nested_web_bundles_are_rejected()
|
||||
{
|
||||
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||
var invalid = await fixture.Controller.UploadRelease(
|
||||
UploadRequest("not a zip"u8.ToArray(), "1.0.1"),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<ObjectResult>(invalid.Result);
|
||||
|
||||
var nested = CreateBundle("nested", "dist/index.html");
|
||||
var nestedResult = await fixture.Controller.UploadRelease(
|
||||
UploadRequest(nested, "1.0.2"),
|
||||
CancellationToken.None);
|
||||
var problem = Assert.IsType<ObjectResult>(nestedResult.Result);
|
||||
Assert.Contains(
|
||||
"根目录",
|
||||
Assert.IsType<ValidationProblemDetails>(problem.Value).Detail);
|
||||
}
|
||||
|
||||
private static async Task<AppUpdateReleaseItem> UploadAsync(
|
||||
AppUpdatesFixture fixture,
|
||||
string version,
|
||||
string marker)
|
||||
{
|
||||
var action = await fixture.Controller.UploadRelease(
|
||||
UploadRequest(CreateBundle(marker), version),
|
||||
CancellationToken.None);
|
||||
return Assert.IsType<AppUpdateReleaseItem>(
|
||||
Assert.IsType<CreatedAtActionResult>(action.Result).Value);
|
||||
}
|
||||
|
||||
private static AppUpdateUploadRequest UploadRequest(
|
||||
byte[] content,
|
||||
string version)
|
||||
{
|
||||
var stream = new MemoryStream(content);
|
||||
return new AppUpdateUploadRequest
|
||||
{
|
||||
Bundle = new FormFile(
|
||||
stream,
|
||||
0,
|
||||
content.Length,
|
||||
"bundle",
|
||||
$"jiaowu-{version}.zip"),
|
||||
Platform = "android",
|
||||
Channel = "production",
|
||||
Version = version,
|
||||
NativeVersion = "1.0",
|
||||
ReleaseNotes = $"Release {version}"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateBundle(
|
||||
string marker,
|
||||
string indexPath = "index.html")
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var archive = new ZipArchive(
|
||||
output,
|
||||
ZipArchiveMode.Create,
|
||||
leaveOpen: true))
|
||||
{
|
||||
var index = archive.CreateEntry(indexPath);
|
||||
using var writer = new StreamWriter(index.Open());
|
||||
writer.Write($"<!doctype html><title>{marker}</title>");
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private sealed class AppUpdatesFixture : IAsyncDisposable
|
||||
{
|
||||
private AppUpdatesFixture(
|
||||
AppDbContext db,
|
||||
AppUpdatesController controller)
|
||||
{
|
||||
Db = db;
|
||||
Controller = controller;
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
public AppUpdatesController Controller { get; }
|
||||
|
||||
public static async Task<AppUpdatesFixture> CreateAsync()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var controller = new AppUpdatesController(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "root"),
|
||||
new Claim(
|
||||
ClaimTypes.Role,
|
||||
SystemRoles.SuperAdmin)
|
||||
],
|
||||
"test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
return new AppUpdatesFixture(db, controller);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await Db.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AttendanceCheckInChallengeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Challenge_IsBoundToSheetSecretAndTwentySecondLifetime()
|
||||
{
|
||||
var sheetId = Guid.NewGuid();
|
||||
var issuedAt = new DateTime(2026, 7, 28, 8, 0, 0, DateTimeKind.Utc);
|
||||
var challenge = AttendanceCheckInChallenge.Create(
|
||||
sheetId,
|
||||
"sheet-secret",
|
||||
issuedAt);
|
||||
|
||||
Assert.True(AttendanceCheckInChallenge.TryReadSheetId(
|
||||
challenge.Token,
|
||||
out var parsedSheetId));
|
||||
Assert.Equal(sheetId, parsedSheetId);
|
||||
Assert.True(AttendanceCheckInChallenge.IsValid(
|
||||
challenge.Token,
|
||||
sheetId,
|
||||
"sheet-secret",
|
||||
issuedAt.AddSeconds(20)));
|
||||
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||
challenge.Token,
|
||||
sheetId,
|
||||
"sheet-secret",
|
||||
issuedAt.AddSeconds(21)));
|
||||
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||
challenge.Token,
|
||||
Guid.NewGuid(),
|
||||
"sheet-secret",
|
||||
issuedAt));
|
||||
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||
challenge.Token,
|
||||
sheetId,
|
||||
"different-secret",
|
||||
issuedAt));
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,19 @@ public sealed class AttendanceControllerTests
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||
};
|
||||
var secondStudentUserId = Guid.NewGuid();
|
||||
var secondStudentUser = new ApplicationUser
|
||||
{
|
||||
Id = secondStudentUserId,
|
||||
UserName = "202601002",
|
||||
NormalizedUserName = "202601002",
|
||||
DisplayName = "吴同学"
|
||||
};
|
||||
var secondStudent = new Student
|
||||
{
|
||||
StudentNumber = "202601002",
|
||||
Name = "吴同学",
|
||||
UserId = secondStudentUserId,
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||
@@ -95,6 +104,7 @@ public sealed class AttendanceControllerTests
|
||||
};
|
||||
db.AddRange(
|
||||
studentUser,
|
||||
secondStudentUser,
|
||||
college,
|
||||
major,
|
||||
administrativeClass,
|
||||
@@ -198,6 +208,11 @@ public sealed class AttendanceControllerTests
|
||||
StudentId = firstStudent.Id,
|
||||
Status = AttendanceStatus.Absent
|
||||
};
|
||||
var secondQrRecord = new AttendanceRecord
|
||||
{
|
||||
StudentId = secondStudent.Id,
|
||||
Status = AttendanceStatus.Absent
|
||||
};
|
||||
var qrSheet = new AttendanceSheet
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
@@ -208,10 +223,17 @@ public sealed class AttendanceControllerTests
|
||||
CheckInToken = "TEST-QR-TOKEN",
|
||||
CheckInStartsAt = now.AddMinutes(-1),
|
||||
CheckInEndsAt = now.AddMinutes(10),
|
||||
Records = [qrRecord]
|
||||
Records = [qrRecord, secondQrRecord]
|
||||
};
|
||||
db.AttendanceSheets.Add(qrSheet);
|
||||
await db.SaveChangesAsync();
|
||||
var qrChallengeResult = await controller.GetQrChallenge(
|
||||
qrSheet.Id,
|
||||
CancellationToken.None);
|
||||
var qrChallengeOk = Assert.IsType<OkObjectResult>(qrChallengeResult);
|
||||
var qrToken = Assert.IsType<string>(
|
||||
qrChallengeOk.Value!.GetType().GetProperty("Token")!.GetValue(
|
||||
qrChallengeOk.Value));
|
||||
|
||||
var studentController = new AttendanceController(
|
||||
db,
|
||||
@@ -231,23 +253,70 @@ public sealed class AttendanceControllerTests
|
||||
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
|
||||
|
||||
var infoResult = await studentController.GetCheckInInfo(
|
||||
qrSheet.CheckInToken,
|
||||
qrToken,
|
||||
CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(infoResult);
|
||||
Assert.IsType<NotFoundResult>(
|
||||
await studentController.GetCheckInInfo(
|
||||
qrSheet.CheckInToken,
|
||||
CancellationToken.None));
|
||||
|
||||
var qrCheckInResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
null,
|
||||
qrSheet.CheckInToken,
|
||||
qrToken,
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
null,
|
||||
"test-device-1",
|
||||
"android"),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(qrCheckInResult);
|
||||
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
|
||||
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
|
||||
Assert.NotNull(qrRecord.CheckInAt);
|
||||
Assert.Null(qrRecord.CheckInLatitude);
|
||||
var qrAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
|
||||
x => x.AttendanceSheetId == qrSheet.Id);
|
||||
Assert.True(qrAttempt.IsSuccessful);
|
||||
Assert.Equal("android", qrAttempt.DevicePlatform);
|
||||
Assert.NotNull(qrAttempt.DeviceIdentifierHash);
|
||||
|
||||
var secondStudentController = new AttendanceController(
|
||||
db,
|
||||
new StudentDataScope(secondStudentUserId));
|
||||
Assert.IsType<OkObjectResult>(
|
||||
await secondStudentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
null,
|
||||
qrToken,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"test-device-1",
|
||||
"android"),
|
||||
CancellationToken.None));
|
||||
var sharedDeviceAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
|
||||
x => x.AttendanceSheetId == qrSheet.Id &&
|
||||
x.StudentId == secondStudent.Id);
|
||||
Assert.Contains(
|
||||
"SharedDevice",
|
||||
sharedDeviceAttempt.RiskFlags ?? string.Empty);
|
||||
var sheetDetailResult = await controller.GetSheet(
|
||||
qrSheet.Id,
|
||||
CancellationToken.None);
|
||||
var sheetDetailOk = Assert.IsType<OkObjectResult>(sheetDetailResult);
|
||||
var sheetDetail = sheetDetailOk.Value!.GetType()
|
||||
.GetProperty("Sheet")!
|
||||
.GetValue(sheetDetailOk.Value)!;
|
||||
var riskSummary = sheetDetail.GetType()
|
||||
.GetProperty("RiskSummary")!
|
||||
.GetValue(sheetDetail)!;
|
||||
Assert.Equal(
|
||||
2,
|
||||
riskSummary.GetType()
|
||||
.GetProperty("SharedDeviceStudentCount")!
|
||||
.GetValue(riskSummary));
|
||||
|
||||
var locationRecord = new AttendanceRecord
|
||||
{
|
||||
@@ -282,6 +351,40 @@ public sealed class AttendanceControllerTests
|
||||
Assert.IsType<ConflictObjectResult>(outsideResult);
|
||||
Assert.Null(locationRecord.CheckInAt);
|
||||
|
||||
var inaccurateResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
locationSheet.Id,
|
||||
null,
|
||||
39.9001m,
|
||||
116.4m,
|
||||
150),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<ConflictObjectResult>(inaccurateResult);
|
||||
Assert.Null(locationRecord.CheckInAt);
|
||||
|
||||
var missingAccuracyResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
locationSheet.Id,
|
||||
null,
|
||||
39.9001m,
|
||||
116.4m,
|
||||
null),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<ConflictObjectResult>(missingAccuracyResult);
|
||||
Assert.Null(locationRecord.CheckInAt);
|
||||
var locationFailures = await db.AttendanceCheckInAttempts
|
||||
.Where(x =>
|
||||
x.AttendanceSheetId == locationSheet.Id &&
|
||||
!x.IsSuccessful)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
Assert.Equal(3, locationFailures.Count);
|
||||
Assert.Contains(
|
||||
locationFailures,
|
||||
x => (x.RiskFlags ?? string.Empty).Contains(
|
||||
"RepeatedFailures",
|
||||
StringComparison.Ordinal));
|
||||
|
||||
var nearbyResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
locationSheet.Id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user