Compare commits
@@ -8,9 +8,16 @@ MYSQL_USER=jiaowu
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
|
||||
RABBITMQ_USER=jiaowu
|
||||
RABBITMQ_PASSWORD=
|
||||
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||
BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY=1
|
||||
BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY=1
|
||||
|
||||
JWT_KEY=
|
||||
ALLOWED_HOSTS=localhost
|
||||
CORS_ORIGIN=http://localhost:8080
|
||||
OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL=http://localhost:8080
|
||||
|
||||
# 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。
|
||||
SEED_ADMIN_USERNAME=demo-admin
|
||||
|
||||
@@ -6,6 +6,44 @@ 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"
|
||||
|
||||
# 单机可继续使用 InMemory;多实例生产部署建议启用 RabbitMQ。
|
||||
BackgroundJobs__Transport=InMemory
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
# RabbitMq__Port=5671
|
||||
# RabbitMq__UserName=jiaowu
|
||||
# RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||
# RabbitMq__VirtualHost=/jiaowu
|
||||
# RabbitMq__UseTls=true
|
||||
# RabbitMq__TlsServerName=rabbitmq.example.edu.cn
|
||||
|
||||
Cache__Enabled=true
|
||||
Cache__KeyPrefix=jiaowu:v1
|
||||
Cache__ReferenceExpirationMinutes=30
|
||||
Cache__ReferenceLocalExpirationSeconds=120
|
||||
Cache__TimetableExpirationMinutes=10
|
||||
Cache__TimetableLocalExpirationSeconds=30
|
||||
Cache__AnalyticsExpirationMinutes=3
|
||||
Cache__AnalyticsLocalExpirationSeconds=30
|
||||
Cache__MaximumPayloadKilobytes=2048
|
||||
|
||||
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
||||
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
|
||||
@@ -15,6 +53,14 @@ Jwt__ExpireMinutes=60
|
||||
AllowedHosts=jiaowu.example.edu.cn
|
||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||
|
||||
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
||||
OfficialDocuments__InstitutionName=明序大学
|
||||
OfficialDocuments__IssuingOffice=教务处
|
||||
OfficialDocuments__DocumentNumberPrefix=MXU
|
||||
OfficialDocuments__PublicBaseUrl=https://jiaowu.example.edu.cn
|
||||
# 默认使用系统中文字体;仅在自定义字体部署时填写绝对路径。
|
||||
# OfficialDocuments__FontPath=/usr/share/fonts/noto/NotoSansCJK-Regular.ttc
|
||||
|
||||
# 仅首次创建管理员时临时取消注释,创建成功后立即删除。
|
||||
# SeedAdmin__UserName=admin
|
||||
# SeedAdmin__Password=REPLACE_WITH_A_STRONG_ADMIN_PASSWORD
|
||||
|
||||
+9
-1
@@ -30,10 +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 mariadb-client
|
||||
RUN adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
@@ -42,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"]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址。
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
|
||||
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
||||
|
||||
@@ -99,6 +101,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` 通常
|
||||
@@ -219,6 +227,100 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
|
||||
|
||||
- `/health/live`:只检查进程存活。
|
||||
- `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
|
||||
- `/health/cache`:检查可选 Redis;未配置 Redis 时返回 `disabled`,Redis
|
||||
故障不会影响数据库就绪探针。
|
||||
- `/health/messaging`:检查后台任务传输;单机内存队列返回 `memory`,启用
|
||||
RabbitMQ 时实际检查代理连接,同时返回各 Outbox 状态的积压数、过期租约数和
|
||||
最老未完成任务的等待时间。
|
||||
|
||||
### 查询缓存与 Redis
|
||||
|
||||
应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为
|
||||
学生激活/基础数据选项、匿名可访问的已发布课表、仪表盘以及统计分析摘要。统计缓存键
|
||||
包含有效数据范围、学院和规范化筛选条件,避免跨学院复用;统计 Excel 导出仍实时查询。
|
||||
选课容量、成绩写入、考勤、审批、通知未读数、权限和后台任务状态仍直接以 MySQL 为准。
|
||||
|
||||
不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装
|
||||
Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如:
|
||||
|
||||
```text
|
||||
ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false
|
||||
```
|
||||
|
||||
仪表盘和统计摘要默认在 Redis 中缓存 3 分钟、进程内缓存 30 秒,可分别通过
|
||||
`Cache__AnalyticsExpirationMinutes` 和 `Cache__AnalyticsLocalExpirationSeconds`
|
||||
调整。该类汇总采用短 TTL 控制数据新鲜度,不要求每个业务写入点同步清理缓存。
|
||||
|
||||
Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和
|
||||
`/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境,
|
||||
同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用
|
||||
独立实例、私有网络、ACL 和 TLS。
|
||||
|
||||
`compose.example.yml` 包含不暴露宿主机端口的 Redis 服务,限制为 256 MB 并使用
|
||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||
|
||||
### 后台任务与 RabbitMQ
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
Outbox 消息在同一次 MySQL 提交中完成,后台发布器再将消息投递给任务 Worker;重复
|
||||
投递通过 Outbox 处理租约和唯一任务键抑制。任务状态表仍是前端查询进度与错误信息的
|
||||
唯一来源。
|
||||
|
||||
开发和单机部署默认使用有界进程内队列,不需要 RabbitMQ:
|
||||
|
||||
```text
|
||||
BackgroundJobs__Transport=InMemory
|
||||
```
|
||||
|
||||
多实例生产部署应切换为 RabbitMQ,并配置独立账号、虚拟主机和 TLS:
|
||||
|
||||
```text
|
||||
BackgroundJobs__Transport=RabbitMq
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
RabbitMq__Port=5671
|
||||
RabbitMq__UserName=jiaowu
|
||||
RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||
RabbitMq__VirtualHost=/jiaowu
|
||||
RabbitMq__UseTls=true
|
||||
RabbitMq__TlsServerName=rabbitmq.example.edu.cn
|
||||
```
|
||||
|
||||
RabbitMQ 传输使用持久消息、发布确认、手动消费确认、每种任务独立队列和死信队列。
|
||||
默认创建 Quorum Queue,重任务的消费者预取数为 1。三类任务各自至少有一个消费者,
|
||||
因此不同类型的任务不会再互相阻塞;单类任务的并发度可独立设置为 1-16。自动排课
|
||||
通常最消耗 CPU,建议先保持为 1,再根据 CPU、数据库连接池和任务等待时间逐级调到
|
||||
2 或 3;不要只提高预取数。
|
||||
|
||||
Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消息都额外扫描数据库;已完成
|
||||
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
||||
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
||||
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
||||
可接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||
消息会根据 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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -13,12 +13,26 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
Database__ApplyMigrationsOnStartup: "false"
|
||||
Database__CommandTimeoutSeconds: "30"
|
||||
ConnectionStrings__MySql: "Server=mysql;Port=3306;Database=${MYSQL_DATABASE:-jiaowu_demo};User=${MYSQL_USER:-jiaowu};Password=${MYSQL_PASSWORD:?请在 .env.docker 中设置 MYSQL_PASSWORD};SslMode=Disabled;"
|
||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||
Cache__Enabled: "true"
|
||||
Cache__KeyPrefix: "jiaowu:v1"
|
||||
BackgroundJobs__Transport: RabbitMq
|
||||
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}"
|
||||
RabbitMq__HostName: rabbitmq
|
||||
RabbitMq__Port: "5672"
|
||||
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
|
||||
RabbitMq__Password: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}"
|
||||
RabbitMq__VirtualHost: "/"
|
||||
RabbitMq__UseTls: "false"
|
||||
Jwt__Issuer: Jiaowu.Api
|
||||
Jwt__Audience: Jiaowu.Web
|
||||
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
||||
Jwt__ExpireMinutes: "60"
|
||||
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
|
||||
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
|
||||
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
|
||||
SeedAdmin__UserName: "${SEED_ADMIN_USERNAME:-}"
|
||||
SeedAdmin__Password: "${SEED_ADMIN_PASSWORD:-}"
|
||||
SeedAdmin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-}"
|
||||
@@ -30,6 +44,49 @@ x-json-logging: &json-logging
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.2-management-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER:-jiaowu}"
|
||||
RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- rabbitmq-diagnostics
|
||||
- -q
|
||||
- ping
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
volumes:
|
||||
- rabbitmq-data:/var/lib/rabbitmq
|
||||
logging: *json-logging
|
||||
|
||||
redis:
|
||||
image: redis:8.8-alpine
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --save
|
||||
- ""
|
||||
- --appendonly
|
||||
- "no"
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lfu
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
logging: *json-logging
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
@@ -69,6 +126,10 @@ services:
|
||||
<<: *jiaowu-image
|
||||
environment: *jiaowu-environment
|
||||
depends_on:
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
@@ -77,6 +138,8 @@ services:
|
||||
- "${JIAOWU_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
volumes:
|
||||
- backup-data:/var/lib/jiaowu/backups
|
||||
logging: *json-logging
|
||||
|
||||
# 工具型一次性服务:普通 docker compose up 不会执行它。
|
||||
@@ -99,3 +162,5 @@ 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);
|
||||
@@ -206,7 +206,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
|
||||
.FirstAsync(ct);
|
||||
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
|
||||
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct);
|
||||
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}",
|
||||
cancellationToken: ct,
|
||||
category: NotificationCategory.Grade);
|
||||
return Created("", new { gm.Id });
|
||||
}
|
||||
|
||||
@@ -219,7 +221,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
|
||||
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.AcademicAdmin,
|
||||
"成绩修改待校级审核",
|
||||
$"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》",
|
||||
linkUrl: "/approvals",
|
||||
cancellationToken: ct,
|
||||
category: NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -227,7 +236,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
|
||||
{
|
||||
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
var gm = await db.GradeModifications
|
||||
.Include(x => x.GradeRecord)
|
||||
.ThenInclude(x => x!.Student)
|
||||
.Include(x => x.GradeRecord)
|
||||
.ThenInclude(x => x!.GradeSheet)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (gm is null) return NotFound();
|
||||
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
|
||||
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
|
||||
@@ -235,7 +251,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
gm.ApplicantUserId,
|
||||
"成绩修改已通过",
|
||||
"您的成绩修改申请已通过三级审批并生效。",
|
||||
"/approvals",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
var studentUserId = gm.GradeRecord.Student?.UserId;
|
||||
if (studentUserId.HasValue &&
|
||||
studentUserId.Value != gm.ApplicantUserId)
|
||||
{
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
studentUserId.Value,
|
||||
"成绩已更新",
|
||||
$"《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》成绩已由 {gm.CurrentScore} 调整为 {gm.RequestedScore}。",
|
||||
"/grades",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -249,7 +285,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
|
||||
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
gm.ApplicantUserId,
|
||||
"成绩修改已驳回",
|
||||
gm.ReviewComment ?? "审核未通过。",
|
||||
"/approvals",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -513,10 +556,10 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
ct);
|
||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
||||
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct); }
|
||||
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, "/approvals", ct, NotificationCategory.Approval); }
|
||||
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct, category: NotificationCategory.Grade); }
|
||||
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||
|
||||
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
|
||||
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
|
||||
|
||||
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -48,9 +49,15 @@ public sealed class AttendanceController(
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course!.Name,
|
||||
StudentCount = db.CourseEnrollments.Count(enrollment =>
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id),
|
||||
StudentCount = db.Students.Count(student =>
|
||||
(student.Status == StudentStatus.Active &&
|
||||
x.Classes.Any(assignment =>
|
||||
assignment.AdministrativeClassId ==
|
||||
student.AdministrativeClassId)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id)),
|
||||
SheetCount = db.AttendanceSheets.Count(sheet =>
|
||||
sheet.TeachingTaskId == x.Id)
|
||||
})
|
||||
@@ -104,15 +111,13 @@ public sealed class AttendanceController(
|
||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
var studentIds = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
var studentIds = await TeachingTaskRosterQuery
|
||||
.ForTask(db, request.TeachingTaskId)
|
||||
.AsNoTracking()
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (studentIds.Count == 0)
|
||||
return ConflictProblem("该教学班没有有效选课学生。");
|
||||
return ConflictProblem("该教学班没有有效学生。");
|
||||
|
||||
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
|
||||
if (!Enum.IsDefined(checkInMethod))
|
||||
@@ -793,7 +798,8 @@ public sealed class AttendanceController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"考勤申诉待处理",
|
||||
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
|
||||
"/teacher-attendance", cancellationToken);
|
||||
"/teacher-attendance", cancellationToken,
|
||||
NotificationCategory.Attendance);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -905,7 +911,7 @@ public sealed class AttendanceController(
|
||||
request.Comment is not null
|
||||
? $"您的考勤申诉{result}。意见:{request.Comment}"
|
||||
: $"您的考勤申诉{result}。",
|
||||
null, cancellationToken);
|
||||
null, cancellationToken, NotificationCategory.Attendance);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -1031,16 +1037,14 @@ public sealed class AttendanceController(
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (course is null) return null;
|
||||
|
||||
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)
|
||||
var enrolledStudents = await TeachingTaskRosterQuery
|
||||
.ForTask(db, teachingTaskId)
|
||||
.AsNoTracking()
|
||||
.Select(x => new AttendanceStatisticsStudentIdentity(
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
x.Student.AdministrativeClass!.Name))
|
||||
.Distinct()
|
||||
x.Id,
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.AdministrativeClass!.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var submittedSheets = await db.AttendanceSheets.AsNoTracking()
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -17,35 +18,51 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class AuthController(
|
||||
AppDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService) : ControllerBase
|
||||
ITokenService tokenService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("activation-options")]
|
||||
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
|
||||
{
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Colleges = colleges,
|
||||
Majors = majors,
|
||||
Classes = classes,
|
||||
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
|
||||
});
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.ActivationOptions,
|
||||
async token =>
|
||||
{
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
|
||||
.ToListAsync(token);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new ActivationMajorOption(
|
||||
x.Id, x.Code, x.Name, x.CollegeId))
|
||||
.ToListAsync(token);
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.Major!.IsEnabled &&
|
||||
x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new ActivationClassOption(
|
||||
x.Id, x.Code, x.Name, x.Grade, x.MajorId))
|
||||
.ToListAsync(token);
|
||||
return new ActivationOptionsResponse(
|
||||
colleges,
|
||||
majors,
|
||||
classes,
|
||||
classes.Select(x => x.Grade)
|
||||
.Distinct()
|
||||
.OrderByDescending(x => x)
|
||||
.ToList());
|
||||
},
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -219,3 +236,24 @@ public sealed record CurrentUserResponse(
|
||||
IEnumerable<string> Roles,
|
||||
Guid? CollegeId,
|
||||
string EffectiveDataScope);
|
||||
|
||||
public sealed record ActivationOptionsResponse(
|
||||
IReadOnlyList<ActivationCollegeOption> Colleges,
|
||||
IReadOnlyList<ActivationMajorOption> Majors,
|
||||
IReadOnlyList<ActivationClassOption> Classes,
|
||||
IReadOnlyList<int> Grades);
|
||||
|
||||
public sealed record ActivationCollegeOption(Guid Id, string Code, string Name);
|
||||
|
||||
public sealed record ActivationMajorOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId);
|
||||
|
||||
public sealed record ActivationClassOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Grade,
|
||||
Guid MajorId);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/base-data")]
|
||||
public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Administrators =
|
||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||
@@ -20,9 +21,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
[HttpGet("campuses")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.Campuses.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("campuses"),
|
||||
token => db.Campuses.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("campuses")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -52,21 +58,32 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
if (entity is null) return NotFound();
|
||||
ApplyCatalog(entity, request);
|
||||
entity.Address = request.Description?.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("colleges")]
|
||||
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Colleges.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
|
||||
CampusName = x.Campus != null ? x.Campus.Name : null,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("colleges"),
|
||||
token => db.Colleges.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new CollegeListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ShortName,
|
||||
x.CampusId,
|
||||
x.Campus != null ? x.Campus.Name : null,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("colleges")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -104,29 +121,46 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
ApplyCatalog(entity, request);
|
||||
entity.ShortName = request.ShortName?.Trim();
|
||||
entity.CampusId = request.CampusId;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("majors")]
|
||||
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Majors.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.CollegeId,
|
||||
CollegeName = x.College!.Name,
|
||||
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("majors"),
|
||||
token => db.Majors.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new MajorListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.CollegeId,
|
||||
x.College!.Name,
|
||||
x.DegreeType,
|
||||
x.SchoolingYears,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("course-categories")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.CourseCategories.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("course-categories"),
|
||||
token => db.CourseCategories.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("course-categories")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -154,7 +188,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
ApplyCatalog(entity, request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -193,23 +227,35 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.CollegeId = request.CollegeId;
|
||||
entity.DegreeType = request.DegreeType.Trim();
|
||||
entity.SchoolingYears = request.SchoolingYears;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classes")]
|
||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) =>
|
||||
Ok(await db.AdministrativeClasses.AsNoTracking()
|
||||
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
x.Grade, x.CounselorUserId, x.CounselorName,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("classes"),
|
||||
token => db.AdministrativeClasses.AsNoTracking()
|
||||
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||
.Select(x => new AdministrativeClassListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.MajorId,
|
||||
x.Major!.Name,
|
||||
x.Major.College!.Name,
|
||||
x.Grade,
|
||||
x.CounselorUserId,
|
||||
x.CounselorName,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("counselors")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -279,18 +325,23 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.Grade = request.Grade;
|
||||
entity.CounselorUserId = request.CounselorUserId;
|
||||
entity.CounselorName = counselorName;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("terms")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.AcademicTerms.AsNoTracking()
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("terms"),
|
||||
token => db.AcademicTerms.AsNoTracking()
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("terms")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.StartDate = request.StartDate;
|
||||
entity.EndDate = request.EndDate;
|
||||
entity.IsCurrent = request.IsCurrent;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
entity.IsCurrent = true;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
|
||||
entity.IsArchived = true;
|
||||
entity.ArchivedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
|
||||
entity.IsArchived = false;
|
||||
entity.ArchivedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classrooms")]
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Classrooms.AsNoTracking()
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.BuildingId,
|
||||
CampusId = x.Building!.CampusId,
|
||||
BuildingName = x.Building!.Name,
|
||||
CampusName = x.Building.Campus!.Name,
|
||||
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("classrooms"),
|
||||
token => db.Classrooms.AsNoTracking()
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new ClassroomListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.BuildingId,
|
||||
x.Building!.CampusId,
|
||||
x.Building.Name,
|
||||
x.Building.Campus!.Name,
|
||||
x.Capacity,
|
||||
x.RoomType,
|
||||
x.Equipment,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("buildings")]
|
||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Buildings.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.CampusId,
|
||||
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("buildings"),
|
||||
token => db.Buildings.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new BuildingListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.CampusId,
|
||||
x.Campus!.Name,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("buildings")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.Capacity = request.Capacity;
|
||||
entity.RoomType = request.RoomType.Trim();
|
||||
entity.Equipment = request.Equipment?.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
db.Remove(entity);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
db.Add(entity);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
@@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
return CreatedAtAction(action, new { id = entity.Id }, entity);
|
||||
}
|
||||
|
||||
private async Task SaveAndInvalidateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> GetCounselorNameAsync(
|
||||
Guid? userId,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -649,3 +730,60 @@ public sealed record ClassroomRequest(
|
||||
[Required, MaxLength(40)] string RoomType,
|
||||
[MaxLength(300)] string? Equipment)
|
||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||
|
||||
public sealed record CollegeListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? ShortName,
|
||||
Guid? CampusId,
|
||||
string? CampusName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record MajorListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId,
|
||||
string CollegeName,
|
||||
string DegreeType,
|
||||
int SchoolingYears,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record AdministrativeClassListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
string CollegeName,
|
||||
int Grade,
|
||||
Guid? CounselorUserId,
|
||||
string? CounselorName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record BuildingListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CampusId,
|
||||
string CampusName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record ClassroomListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid BuildingId,
|
||||
Guid CampusId,
|
||||
string BuildingName,
|
||||
string CampusName,
|
||||
int Capacity,
|
||||
string RoomType,
|
||||
string? Equipment,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = Administrators)]
|
||||
[Route("api/base-data")]
|
||||
public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Administrators =
|
||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||
@@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.BaseData,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/classroom-reservations")]
|
||||
public sealed class ClassroomReservationsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope scope,
|
||||
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
|
||||
{
|
||||
[HttpGet("options")]
|
||||
public async Task<ActionResult> GetOptions(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(term => term.IsEnabled && !term.IsArchived)
|
||||
.OrderByDescending(term => term.IsCurrent)
|
||||
.ThenByDescending(term => term.StartDate)
|
||||
.Select(term => new
|
||||
{
|
||||
term.Id,
|
||||
term.Name,
|
||||
term.StartDate,
|
||||
term.EndDate,
|
||||
term.IsCurrent,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == term.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var selectedTermId = academicTermId
|
||||
?? terms.FirstOrDefault(term =>
|
||||
term.IsCurrent && term.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault(term => term.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault()?.Id;
|
||||
var campuses = await db.Campuses.AsNoTracking()
|
||||
.Where(campus => campus.IsEnabled)
|
||||
.OrderBy(campus => campus.SortOrder)
|
||||
.ThenBy(campus => campus.Code)
|
||||
.Select(campus => new { campus.Id, campus.Code, campus.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var buildings = await db.Buildings.AsNoTracking()
|
||||
.Where(building =>
|
||||
building.IsEnabled &&
|
||||
building.Campus!.IsEnabled)
|
||||
.OrderBy(building => building.Campus!.SortOrder)
|
||||
.ThenBy(building => building.SortOrder)
|
||||
.ThenBy(building => building.Code)
|
||||
.Select(building => new
|
||||
{
|
||||
building.Id,
|
||||
building.Code,
|
||||
building.Name,
|
||||
building.CampusId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(classroom =>
|
||||
classroom.IsEnabled &&
|
||||
classroom.Building!.IsEnabled &&
|
||||
classroom.Building.Campus!.IsEnabled)
|
||||
.OrderBy(classroom => classroom.Building!.Campus!.SortOrder)
|
||||
.ThenBy(classroom => classroom.Building!.SortOrder)
|
||||
.ThenBy(classroom => classroom.Code)
|
||||
.Select(classroom => new
|
||||
{
|
||||
classroom.Id,
|
||||
classroom.Code,
|
||||
classroom.Name,
|
||||
classroom.BuildingId,
|
||||
classroom.Capacity,
|
||||
classroom.RoomType
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var timeSlots = await LoadTimeSlotsAsync(
|
||||
selectedTermId,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Applicant = applicant.Context is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
applicant.Context.User.Id,
|
||||
applicant.Context.User.DisplayName,
|
||||
CollegeId = applicant.Context.CollegeId,
|
||||
CollegeName = applicant.Context.CollegeName
|
||||
},
|
||||
ApplicantProblem = applicant.Error,
|
||||
Terms = terms,
|
||||
SelectedTermId = selectedTermId,
|
||||
Campuses = campuses,
|
||||
Buildings = buildings,
|
||||
Classrooms = classrooms,
|
||||
TimeSlots = timeSlots
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("availability")]
|
||||
public async Task<ActionResult> GetAvailability(
|
||||
Guid academicTermId,
|
||||
DateOnly reservationDate,
|
||||
[Range(1, 30)] int startPeriod,
|
||||
[Range(1, 6)] int periodCount,
|
||||
Guid? campusId,
|
||||
Guid? buildingId,
|
||||
[Range(1, 10000)] int? attendeeCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ValidateSlotAsync(
|
||||
academicTermId,
|
||||
reservationDate,
|
||||
startPeriod,
|
||||
periodCount,
|
||||
cancellationToken);
|
||||
if (validation.Error is not null) return validation.Error;
|
||||
|
||||
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||
validation.Term!,
|
||||
reservationDate,
|
||||
startPeriod,
|
||||
periodCount,
|
||||
null,
|
||||
cancellationToken);
|
||||
var rooms = db.Classrooms.AsNoTracking()
|
||||
.Where(classroom =>
|
||||
classroom.IsEnabled &&
|
||||
classroom.Building!.IsEnabled &&
|
||||
classroom.Building.Campus!.IsEnabled)
|
||||
.WhereNotIn(occupiedIds, classroom => classroom.Id);
|
||||
if (campusId.HasValue)
|
||||
rooms = rooms.Where(room =>
|
||||
room.Building!.CampusId == campusId.Value);
|
||||
if (buildingId.HasValue)
|
||||
rooms = rooms.Where(room => room.BuildingId == buildingId.Value);
|
||||
if (attendeeCount.HasValue)
|
||||
rooms = rooms.Where(room => room.Capacity >= attendeeCount.Value);
|
||||
|
||||
var items = await rooms
|
||||
.OrderBy(room => room.Building!.Campus!.SortOrder)
|
||||
.ThenBy(room => room.Building!.SortOrder)
|
||||
.ThenBy(room => room.Code)
|
||||
.Select(room => new
|
||||
{
|
||||
room.Id,
|
||||
room.Code,
|
||||
room.Name,
|
||||
room.Capacity,
|
||||
room.RoomType,
|
||||
room.BuildingId,
|
||||
BuildingName = room.Building!.Name,
|
||||
CampusId = room.Building.CampusId,
|
||||
CampusName = room.Building.Campus!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Items = items });
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetMine(
|
||||
ClassroomReservationStatus? status,
|
||||
[Range(1, int.MaxValue)] int page = 1,
|
||||
[Range(1, 100)] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = db.ClassroomReservations.AsNoTracking()
|
||||
.Where(reservation =>
|
||||
reservation.ApplicantUserId == scope.Current.UserId);
|
||||
if (status.HasValue)
|
||||
query = query.Where(reservation => reservation.Status == status.Value);
|
||||
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("review")]
|
||||
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetReviewQueue(
|
||||
ClassroomReservationStatus? status,
|
||||
[Range(1, int.MaxValue)] int page = 1,
|
||||
[Range(1, 100)] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collegeError = ReviewCollegeError();
|
||||
if (collegeError is not null) return collegeError;
|
||||
var collegeId = scope.Current.CollegeId!.Value;
|
||||
var query = db.ClassroomReservations.AsNoTracking()
|
||||
.Where(reservation => reservation.ApplicantCollegeId == collegeId);
|
||||
if (status.HasValue)
|
||||
query = query.Where(reservation => reservation.Status == status.Value);
|
||||
else
|
||||
query = query.Where(reservation =>
|
||||
reservation.Status == ClassroomReservationStatus.Submitted);
|
||||
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(
|
||||
CreateClassroomReservationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Purpose))
|
||||
return ValidationProblem("请填写借用用途。");
|
||||
if (string.IsNullOrWhiteSpace(request.ContactPhone))
|
||||
return ValidationProblem("请填写联系电话。");
|
||||
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||
if (applicant.Context is null)
|
||||
return ConflictProblem(
|
||||
applicant.Error ?? "当前账号未配置申请人学院,无法提交申请。");
|
||||
|
||||
var validation = await ValidateSlotAsync(
|
||||
request.AcademicTermId,
|
||||
request.ReservationDate,
|
||||
request.StartPeriod,
|
||||
request.PeriodCount,
|
||||
cancellationToken);
|
||||
if (validation.Error is not null) return validation.Error;
|
||||
|
||||
var classroom = await db.Classrooms.AsNoTracking()
|
||||
.Where(room =>
|
||||
room.Id == request.ClassroomId &&
|
||||
room.IsEnabled &&
|
||||
room.Building!.IsEnabled &&
|
||||
room.Building.Campus!.IsEnabled)
|
||||
.Select(room => new
|
||||
{
|
||||
room.Id,
|
||||
room.Name,
|
||||
room.Code,
|
||||
room.Capacity,
|
||||
BuildingName = room.Building!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (classroom is null) return NotFound();
|
||||
if (request.AttendeeCount > classroom.Capacity)
|
||||
return ValidationProblem(
|
||||
$"申请人数超过教室容量({classroom.Capacity} 人)。");
|
||||
|
||||
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||
validation.Term!,
|
||||
request.ReservationDate,
|
||||
request.StartPeriod,
|
||||
request.PeriodCount,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (occupiedIds.Contains(classroom.Id))
|
||||
return ConflictProblem("所选教室在该时段已有课程、考试或已批准预约。");
|
||||
|
||||
var duplicate = await db.ClassroomReservations.AsNoTracking()
|
||||
.AnyAsync(reservation =>
|
||||
reservation.ApplicantUserId == applicant.Context.User.Id &&
|
||||
reservation.ClassroomId == classroom.Id &&
|
||||
reservation.ReservationDate == request.ReservationDate &&
|
||||
reservation.Status == ClassroomReservationStatus.Submitted &&
|
||||
reservation.StartPeriod <
|
||||
request.StartPeriod + request.PeriodCount &&
|
||||
request.StartPeriod <
|
||||
reservation.StartPeriod + reservation.PeriodCount,
|
||||
cancellationToken);
|
||||
if (duplicate)
|
||||
return ConflictProblem("您已提交过同一教室、重叠时段的申请。");
|
||||
|
||||
var reservation = new ClassroomReservation
|
||||
{
|
||||
ApplicantUserId = applicant.Context.User.Id,
|
||||
ApplicantName = applicant.Context.User.DisplayName,
|
||||
ApplicantCollegeId = applicant.Context.CollegeId,
|
||||
AcademicTermId = request.AcademicTermId,
|
||||
ClassroomId = request.ClassroomId,
|
||||
ReservationDate = request.ReservationDate,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
AttendeeCount = request.AttendeeCount,
|
||||
Purpose = request.Purpose.Trim(),
|
||||
ContactPhone = request.ContactPhone.Trim(),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
db.ClassroomReservations.Add(reservation);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.CollegeAdmin,
|
||||
"教室借用申请待审核",
|
||||
$"{reservation.ApplicantName} 申请于 {reservation.ReservationDate:yyyy-MM-dd} " +
|
||||
$"借用 {classroom.BuildingName} {classroom.Name}。",
|
||||
reservation.ApplicantCollegeId,
|
||||
"/classroom-reservations",
|
||||
cancellationToken,
|
||||
NotificationCategory.Approval);
|
||||
return Created("", new { reservation.Id });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
public async Task<ActionResult> Cancel(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var reservation = await db.ClassroomReservations
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.Id == id &&
|
||||
item.ApplicantUserId == scope.Current.UserId,
|
||||
cancellationToken);
|
||||
if (reservation is null) return NotFound();
|
||||
if (reservation.Status is not (
|
||||
ClassroomReservationStatus.Submitted or
|
||||
ClassroomReservationStatus.Approved))
|
||||
return ConflictProblem("当前状态不能取消。");
|
||||
if (reservation.ReservationDate < TodayInChina())
|
||||
return ConflictProblem("已过期的预约不能取消。");
|
||||
|
||||
var wasApproved =
|
||||
reservation.Status == ClassroomReservationStatus.Approved;
|
||||
reservation.Status = ClassroomReservationStatus.Cancelled;
|
||||
reservation.CancelledAt = DateTime.UtcNow;
|
||||
reservation.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (wasApproved)
|
||||
{
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.CollegeAdmin,
|
||||
"已批准教室预约被申请人取消",
|
||||
$"{reservation.ApplicantName} 已取消 " +
|
||||
$"{reservation.ReservationDate:yyyy-MM-dd} 的教室预约。",
|
||||
reservation.ApplicantCollegeId,
|
||||
"/classroom-reservations",
|
||||
cancellationToken,
|
||||
NotificationCategory.Approval);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||
public Task<ActionResult> Approve(
|
||||
Guid id,
|
||||
ReviewClassroomReservationRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
ReviewAsync(id, true, request.Comment, cancellationToken);
|
||||
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||
public Task<ActionResult> Reject(
|
||||
Guid id,
|
||||
ReviewClassroomReservationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("驳回时必须填写审核意见。"));
|
||||
return ReviewAsync(id, false, request.Comment, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ActionResult> ReviewAsync(
|
||||
Guid id,
|
||||
bool approve,
|
||||
string? comment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var collegeError = ReviewCollegeError();
|
||||
if (collegeError is not null) return collegeError;
|
||||
var reviewerCollegeId = scope.Current.CollegeId!.Value;
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var reservation = await db.ClassroomReservations
|
||||
.Include(item => item.AcademicTerm)
|
||||
.Include(item => item.Classroom)
|
||||
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
if (reservation is null) return NotFound();
|
||||
if (reservation.ApplicantCollegeId != reviewerCollegeId)
|
||||
return Forbid();
|
||||
if (reservation.Status != ClassroomReservationStatus.Submitted)
|
||||
return ConflictProblem("该申请已处理,不能重复审核。");
|
||||
|
||||
if (approve)
|
||||
{
|
||||
if (reservation.ReservationDate < TodayInChina())
|
||||
return ConflictProblem("预约日期已过,不能批准。");
|
||||
var validation = await ValidateSlotAsync(
|
||||
reservation.AcademicTermId,
|
||||
reservation.ReservationDate,
|
||||
reservation.StartPeriod,
|
||||
reservation.PeriodCount,
|
||||
cancellationToken);
|
||||
if (validation.Error is not null) return validation.Error;
|
||||
if (reservation.AttendeeCount > reservation.Classroom!.Capacity)
|
||||
return ConflictProblem("申请人数已超过教室当前容量,不能批准。");
|
||||
var occupiedIds =
|
||||
await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||
validation.Term!,
|
||||
reservation.ReservationDate,
|
||||
reservation.StartPeriod,
|
||||
reservation.PeriodCount,
|
||||
reservation.Id,
|
||||
cancellationToken);
|
||||
if (occupiedIds.Contains(reservation.ClassroomId))
|
||||
return ConflictProblem(
|
||||
"教室在该时段已被课程、考试或其他已批准预约占用。");
|
||||
reservation.Status = ClassroomReservationStatus.Approved;
|
||||
}
|
||||
else
|
||||
{
|
||||
reservation.Status = ClassroomReservationStatus.Rejected;
|
||||
}
|
||||
|
||||
reservation.ReviewedByUserId = scope.Current.UserId;
|
||||
reservation.ReviewedAt = DateTime.UtcNow;
|
||||
reservation.ReviewComment = Normalize(comment);
|
||||
reservation.UpdatedAt = DateTime.UtcNow;
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
reservation.ApplicantUserId,
|
||||
approve ? "教室借用申请已通过" : "教室借用申请已驳回",
|
||||
approve
|
||||
? $"{reservation.ReservationDate:yyyy-MM-dd} 的教室借用申请已通过。"
|
||||
: reservation.ReviewComment ?? "审核未通过。",
|
||||
"/classroom-reservations",
|
||||
cancellationToken,
|
||||
NotificationCategory.Approval);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
private async Task<PagedResult<ClassroomReservationDto>> BuildPageAsync(
|
||||
IQueryable<ClassroomReservation> query,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(reservation =>
|
||||
reservation.Status == ClassroomReservationStatus.Submitted)
|
||||
.ThenByDescending(reservation => reservation.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(reservation => new ClassroomReservationDto(
|
||||
reservation.Id,
|
||||
reservation.ApplicantUserId,
|
||||
reservation.ApplicantName,
|
||||
reservation.ApplicantCollegeId,
|
||||
reservation.ApplicantCollege!.Name,
|
||||
reservation.AcademicTermId,
|
||||
reservation.AcademicTerm!.Name,
|
||||
reservation.ClassroomId,
|
||||
reservation.Classroom!.Code,
|
||||
reservation.Classroom.Name,
|
||||
reservation.Classroom.Building!.Name,
|
||||
reservation.Classroom.Building.Campus!.Name,
|
||||
reservation.ReservationDate,
|
||||
reservation.StartPeriod,
|
||||
reservation.PeriodCount,
|
||||
reservation.AttendeeCount,
|
||||
reservation.Purpose,
|
||||
reservation.ContactPhone,
|
||||
reservation.Notes,
|
||||
reservation.Status,
|
||||
reservation.ReviewComment,
|
||||
reservation.ReviewedAt,
|
||||
reservation.CancelledAt,
|
||||
reservation.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
return new PagedResult<ClassroomReservationDto>(
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize);
|
||||
}
|
||||
|
||||
private async Task<(
|
||||
AcademicTerm? Term,
|
||||
ActionResult? Error)> ValidateSlotAsync(
|
||||
Guid academicTermId,
|
||||
DateOnly reservationDate,
|
||||
int startPeriod,
|
||||
int periodCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var term = await db.AcademicTerms.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.Id == academicTermId &&
|
||||
item.IsEnabled &&
|
||||
!item.IsArchived,
|
||||
cancellationToken);
|
||||
if (term is null) return (null, NotFound());
|
||||
if (reservationDate < TodayInChina())
|
||||
return (null, ValidationProblem("预约日期不能早于今天。"));
|
||||
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||
return (null, ValidationProblem("预约日期不在所选学期范围内。"));
|
||||
var hasPublishedTimetable = await db.SchedulePlans.AsNoTracking()
|
||||
.AnyAsync(plan =>
|
||||
plan.AcademicTermId == term.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published,
|
||||
cancellationToken);
|
||||
if (!hasPublishedTimetable)
|
||||
return (null, ConflictProblem(
|
||||
"所选学期的正式课表尚未发布,暂不能提交教室预约。"));
|
||||
|
||||
var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray();
|
||||
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.CountAsync(slot =>
|
||||
slot.AcademicTermId == academicTermId,
|
||||
cancellationToken);
|
||||
if (configuredPeriodCount == 0)
|
||||
{
|
||||
if (requestedPeriods.Any(period => period > 12))
|
||||
return (null, ValidationProblem("预约范围包含不存在的节次。"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var enabledPeriods = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(slot =>
|
||||
slot.AcademicTermId == academicTermId &&
|
||||
slot.IsEnabled)
|
||||
.WhereIn(requestedPeriods, slot => slot.PeriodNumber)
|
||||
.CountAsync(cancellationToken);
|
||||
if (enabledPeriods != requestedPeriods.Length)
|
||||
return (null, ValidationProblem("预约范围包含不存在或未启用的节次。"));
|
||||
}
|
||||
return (term, null);
|
||||
}
|
||||
|
||||
private async Task<List<ClassroomReservationTimeSlotDto>> LoadTimeSlotsAsync(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = academicTermId.HasValue
|
||||
? await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(slot =>
|
||||
slot.AcademicTermId == academicTermId.Value &&
|
||||
slot.IsEnabled)
|
||||
.OrderBy(slot => slot.PeriodNumber)
|
||||
.Select(slot => new ClassroomReservationTimeSlotDto(
|
||||
slot.PeriodNumber,
|
||||
slot.Name,
|
||||
slot.StartsAt.ToString("HH:mm"),
|
||||
slot.EndsAt.ToString("HH:mm")))
|
||||
.ToListAsync(cancellationToken)
|
||||
: [];
|
||||
return result.Count > 0
|
||||
? result
|
||||
: Enumerable.Range(1, 12)
|
||||
.Select(period => new ClassroomReservationTimeSlotDto(
|
||||
period,
|
||||
$"第 {period} 节",
|
||||
"",
|
||||
""))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<ApplicantResolution> ResolveApplicantAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (scope.Current.UserId == Guid.Empty)
|
||||
return new(null, "无法识别当前登录账号。");
|
||||
var user = await db.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.Id == scope.Current.UserId &&
|
||||
item.IsEnabled,
|
||||
cancellationToken);
|
||||
if (user is null) return new(null, "当前账号不存在或已停用。");
|
||||
|
||||
var linkedCollegeIds = new List<Guid>();
|
||||
if (user.CollegeId.HasValue) linkedCollegeIds.Add(user.CollegeId.Value);
|
||||
var teacherCollegeId = await db.Teachers.AsNoTracking()
|
||||
.Where(teacher => teacher.UserId == user.Id)
|
||||
.Select(teacher => (Guid?)teacher.CollegeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (teacherCollegeId.HasValue)
|
||||
linkedCollegeIds.Add(teacherCollegeId.Value);
|
||||
var studentCollegeId = await db.Students.AsNoTracking()
|
||||
.Where(student => student.UserId == user.Id)
|
||||
.Select(student =>
|
||||
(Guid?)student.AdministrativeClass!.Major!.CollegeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (studentCollegeId.HasValue)
|
||||
linkedCollegeIds.Add(studentCollegeId.Value);
|
||||
|
||||
var distinctIds = linkedCollegeIds.Distinct().ToArray();
|
||||
if (distinctIds.Length == 0)
|
||||
return new(null, "当前账号未配置所在学院,请联系账号管理员补充学院信息。");
|
||||
if (distinctIds.Length > 1)
|
||||
return new(null, "账号与人员档案的学院信息不一致,请联系管理员修正后再申请。");
|
||||
var college = await db.Colleges.AsNoTracking()
|
||||
.Where(item => item.Id == distinctIds[0] && item.IsEnabled)
|
||||
.Select(item => new { item.Id, item.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return college is null
|
||||
? new(null, "当前账号所属学院不存在或已停用。")
|
||||
: new(
|
||||
new ApplicantContext(user, college.Id, college.Name),
|
||||
null);
|
||||
}
|
||||
|
||||
private ActionResult? ReviewCollegeError()
|
||||
{
|
||||
if (!scope.Current.IsInRole(SystemRoles.CollegeAdmin))
|
||||
return Forbid();
|
||||
return scope.Current.CollegeId.HasValue
|
||||
? null
|
||||
: ConflictProblem("学院管理员账号未配置所属学院,无法审核。");
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法完成教室预约",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static DateOnly TodayInChina()
|
||||
{
|
||||
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var zone = TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
return DateOnly.FromDateTime(
|
||||
TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone).DateTime);
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (InvalidTimeZoneException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return DateOnly.FromDateTime(DateTime.Today);
|
||||
}
|
||||
|
||||
private sealed record ApplicantContext(
|
||||
ApplicationUser User,
|
||||
Guid CollegeId,
|
||||
string CollegeName);
|
||||
|
||||
private sealed record ApplicantResolution(
|
||||
ApplicantContext? Context,
|
||||
string? Error);
|
||||
}
|
||||
|
||||
public sealed record CreateClassroomReservationRequest(
|
||||
Guid AcademicTermId,
|
||||
Guid ClassroomId,
|
||||
DateOnly ReservationDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
[Range(1, 10000)] int AttendeeCount,
|
||||
[Required, StringLength(200)] string Purpose,
|
||||
[Required, StringLength(30)] string ContactPhone,
|
||||
[StringLength(500)] string? Notes);
|
||||
|
||||
public sealed record ReviewClassroomReservationRequest(
|
||||
[StringLength(500)] string? Comment);
|
||||
|
||||
public sealed record ClassroomReservationTimeSlotDto(
|
||||
int PeriodNumber,
|
||||
string Name,
|
||||
string StartTime,
|
||||
string EndTime);
|
||||
|
||||
public sealed record ClassroomReservationDto(
|
||||
Guid Id,
|
||||
Guid ApplicantUserId,
|
||||
string ApplicantName,
|
||||
Guid ApplicantCollegeId,
|
||||
string ApplicantCollegeName,
|
||||
Guid AcademicTermId,
|
||||
string AcademicTermName,
|
||||
Guid ClassroomId,
|
||||
string ClassroomCode,
|
||||
string ClassroomName,
|
||||
string BuildingName,
|
||||
string CampusName,
|
||||
DateOnly ReservationDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
int AttendeeCount,
|
||||
string Purpose,
|
||||
string ContactPhone,
|
||||
string? Notes,
|
||||
ClassroomReservationStatus Status,
|
||||
string? ReviewComment,
|
||||
DateTime? ReviewedAt,
|
||||
DateTime? CancelledAt,
|
||||
DateTime CreatedAt);
|
||||
@@ -3,6 +3,8 @@ using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
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;
|
||||
@@ -27,6 +29,145 @@ public sealed class CourseAdjustmentsController(
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
[HttpGet("task-options")]
|
||||
[Authorize(Roles = Applicants)]
|
||||
public async Task<ActionResult> GetTaskOptions(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = AccessibleTeachingTasks().AsNoTracking()
|
||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
|
||||
return Ok(await source
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
ClassNames = x.Classes
|
||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||
.Select(item => item.AdministrativeClass!.Name)
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{teachingTaskId:guid}/session-options")]
|
||||
[Authorize(Roles = Applicants)]
|
||||
public async Task<ActionResult> GetSessionOptions(
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Id == teachingTaskId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
CourseName = x.Course!.Name,
|
||||
x.AcademicTermId,
|
||||
TermStartDate = x.AcademicTerm!.StartDate,
|
||||
TermEndDate = x.AcademicTerm.EndDate
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
var entries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == teachingTaskId &&
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published)
|
||||
.OrderBy(x => x.DayOfWeek)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.DayOfWeek,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.ClassroomId,
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var occupied = await db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == teachingTaskId &&
|
||||
x.SourceScheduleEntryId != null &&
|
||||
x.SourceWeek != null &&
|
||||
(x.Status == CourseAdjustmentStatus.Submitted ||
|
||||
x.Status == CourseAdjustmentStatus.Approved))
|
||||
.Select(x => new { x.SourceScheduleEntryId, x.SourceWeek })
|
||||
.ToListAsync(cancellationToken);
|
||||
var occupiedOccurrences = occupied
|
||||
.Select(x => (x.SourceScheduleEntryId!.Value, x.SourceWeek!.Value))
|
||||
.ToHashSet();
|
||||
|
||||
var sessions = entries
|
||||
.SelectMany(entry => Enumerable.Range(
|
||||
entry.StartWeek,
|
||||
entry.EndWeek - entry.StartWeek + 1)
|
||||
.Where(week => IncludesWeek(entry.WeekPattern, week))
|
||||
.Select(week => new
|
||||
{
|
||||
ScheduleEntryId = entry.Id,
|
||||
Week = week,
|
||||
Date = ResolveDate(task.TermStartDate, week, entry.DayOfWeek),
|
||||
entry.DayOfWeek,
|
||||
entry.StartPeriod,
|
||||
entry.PeriodCount,
|
||||
entry.ClassroomId,
|
||||
entry.ClassroomName,
|
||||
entry.BuildingName,
|
||||
HasExistingAdjustment = occupiedOccurrences.Contains((entry.Id, week))
|
||||
}))
|
||||
.Where(x => x.Date >= task.TermStartDate && x.Date <= task.TermEndDate)
|
||||
.OrderBy(x => x.Date)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ToList();
|
||||
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Building!.SortOrder)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
BuildingName = x.Building!.Name,
|
||||
CampusName = x.Building.Campus!.Name,
|
||||
x.Capacity
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var periods = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == task.AcademicTermId && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.PeriodNumber,
|
||||
x.Name,
|
||||
StartsAt = x.StartsAt.ToString("HH:mm"),
|
||||
EndsAt = x.EndsAt.ToString("HH:mm")
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
|
||||
}
|
||||
|
||||
// ═══════════════ My adjustments ═══════════════
|
||||
|
||||
[HttpGet("mine")]
|
||||
@@ -100,20 +241,41 @@ public sealed class CourseAdjustmentsController(
|
||||
var validation = await ValidateRequestAsync(request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
var source = RequiresSourceOccurrence(request.Type)
|
||||
? await LoadSourceOccurrenceAsync(
|
||||
request.TeachingTaskId,
|
||||
request.SourceScheduleEntryId,
|
||||
request.SourceWeek,
|
||||
cancellationToken)
|
||||
: null;
|
||||
var targetDayOfWeek = request.TargetDate.HasValue
|
||||
? ToIsoDayOfWeek(request.TargetDate.Value.DayOfWeek)
|
||||
: request.DayOfWeek;
|
||||
|
||||
var adj = new CourseAdjustment
|
||||
{
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
Type = request.Type,
|
||||
ApplicantUserId = userId,
|
||||
Reason = request.Reason.Trim(),
|
||||
SourceScheduleEntryId = source?.Entry.Id,
|
||||
SourceWeek = source?.Week,
|
||||
SourceDate = source?.Date,
|
||||
SourceStartPeriod = source?.Entry.StartPeriod,
|
||||
SourcePeriodCount = source?.Entry.PeriodCount,
|
||||
SourceClassroomId = source?.Entry.ClassroomId,
|
||||
TargetDate = request.TargetDate,
|
||||
DayOfWeek = request.DayOfWeek,
|
||||
DayOfWeek = targetDayOfWeek,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount,
|
||||
ClassroomId = request.ClassroomId,
|
||||
SubstituteTeacherId = request.SubstituteTeacherId,
|
||||
CancelWeek = request.CancelWeek,
|
||||
CancelDate = request.CancelDate
|
||||
CancelWeek = request.Type == CourseAdjustmentType.Cancel
|
||||
? source?.Week
|
||||
: request.CancelWeek,
|
||||
CancelDate = request.Type == CourseAdjustmentType.Cancel
|
||||
? source?.Date
|
||||
: request.CancelDate
|
||||
};
|
||||
|
||||
if (request.Submit)
|
||||
@@ -138,7 +300,8 @@ public sealed class CourseAdjustmentsController(
|
||||
SystemRoles.CollegeAdmin,
|
||||
$"新的{tl}申请",
|
||||
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
|
||||
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
|
||||
taskInfo.CollegeId, "/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +324,25 @@ public sealed class CourseAdjustmentsController(
|
||||
if (adj.Status != CourseAdjustmentStatus.Draft)
|
||||
return ConflictProblem("只有草稿可以提交。");
|
||||
|
||||
var validation = await ValidateRequestAsync(
|
||||
new CourseAdjustmentRequest(
|
||||
adj.TeachingTaskId,
|
||||
adj.Type,
|
||||
adj.Reason,
|
||||
true,
|
||||
adj.TargetDate,
|
||||
adj.DayOfWeek,
|
||||
adj.StartPeriod,
|
||||
adj.PeriodCount,
|
||||
adj.ClassroomId,
|
||||
adj.SubstituteTeacherId,
|
||||
adj.CancelWeek,
|
||||
adj.CancelDate,
|
||||
adj.SourceScheduleEntryId,
|
||||
adj.SourceWeek),
|
||||
cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
adj.Status = CourseAdjustmentStatus.Submitted;
|
||||
adj.SubmittedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -171,12 +353,14 @@ public sealed class CourseAdjustmentsController(
|
||||
$"新的{TypeLabel(adj.Type)}申请",
|
||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
||||
adj.TeachingTask.Course.CollegeId,
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
await NotificationService.SendToRoleAsync(db,
|
||||
SystemRoles.AcademicAdmin,
|
||||
$"新的{TypeLabel(adj.Type)}申请",
|
||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
||||
null, "/course-adjustments", cancellationToken);
|
||||
null, "/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -190,6 +374,12 @@ public sealed class CourseAdjustmentsController(
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.ThenInclude(x => x!.College)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Classes)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (adj is null) return NotFound();
|
||||
|
||||
@@ -200,6 +390,12 @@ public sealed class CourseAdjustmentsController(
|
||||
if (adj.Status != CourseAdjustmentStatus.Submitted)
|
||||
return ConflictProblem("只有待审核的申请可以审批。");
|
||||
|
||||
var scheduleProblem = await ValidateApprovalScheduleAsync(
|
||||
adj,
|
||||
cancellationToken);
|
||||
if (scheduleProblem is not null)
|
||||
return ConflictProblem(scheduleProblem);
|
||||
|
||||
adj.Status = CourseAdjustmentStatus.Approved;
|
||||
adj.ReviewedAt = DateTime.UtcNow;
|
||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||
@@ -213,14 +409,13 @@ public sealed class CourseAdjustmentsController(
|
||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||
"调停课申请已通过",
|
||||
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
|
||||
// Notify affected students
|
||||
var studentUserIds = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.Select(x => x.Student!.UserId)
|
||||
var studentUserIds = await TeachingTaskRosterQuery
|
||||
.ForTask(db, adj.TeachingTaskId)
|
||||
.Select(x => x.UserId)
|
||||
.Where(uid => uid != null)
|
||||
.Select(uid => uid!.Value)
|
||||
.Distinct()
|
||||
@@ -230,7 +425,8 @@ public sealed class CourseAdjustmentsController(
|
||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||
"课程变动通知",
|
||||
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
||||
"/my-timetable", cancellationToken);
|
||||
"/my-timetable", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -242,81 +438,19 @@ public sealed class CourseAdjustmentsController(
|
||||
switch (adj.Type)
|
||||
{
|
||||
case CourseAdjustmentType.Reschedule:
|
||||
// Update existing schedule entries for this teaching task
|
||||
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
|
||||
{
|
||||
var entries = await db.ScheduleEntries
|
||||
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
|
||||
.ToListAsync(ct);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
entry.DayOfWeek = adj.DayOfWeek.Value;
|
||||
entry.StartPeriod = adj.StartPeriod.Value;
|
||||
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
|
||||
}
|
||||
if (adj.ClassroomId.HasValue)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
entry.ClassroomId = adj.ClassroomId.Value;
|
||||
}
|
||||
}
|
||||
var rescheduleSource = await LoadSourceEntryAsync(adj, ct);
|
||||
ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value);
|
||||
db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId));
|
||||
break;
|
||||
|
||||
case CourseAdjustmentType.Cancel:
|
||||
// Cancel: remove schedule entries for the specified week
|
||||
if (adj.CancelWeek.HasValue)
|
||||
{
|
||||
var entries = await db.ScheduleEntries
|
||||
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
|
||||
x.StartWeek <= adj.CancelWeek.Value &&
|
||||
x.EndWeek >= adj.CancelWeek.Value)
|
||||
.ToListAsync(ct);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
// Split the entry to exclude the cancelled week
|
||||
if (entry.StartWeek == adj.CancelWeek.Value &&
|
||||
entry.EndWeek == adj.CancelWeek.Value)
|
||||
{
|
||||
db.ScheduleEntries.Remove(entry);
|
||||
}
|
||||
else if (entry.StartWeek == adj.CancelWeek.Value)
|
||||
{
|
||||
entry.StartWeek = adj.CancelWeek.Value + 1;
|
||||
}
|
||||
else if (entry.EndWeek == adj.CancelWeek.Value)
|
||||
{
|
||||
entry.EndWeek = adj.CancelWeek.Value - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
var cancelSource = await LoadSourceEntryAsync(adj, ct);
|
||||
ExcludeWeek(cancelSource, adj.SourceWeek!.Value);
|
||||
break;
|
||||
|
||||
case CourseAdjustmentType.Makeup:
|
||||
// Makeup: add a temp schedule entry for the makeup date
|
||||
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
|
||||
adj.StartPeriod.HasValue)
|
||||
{
|
||||
var publishedPlan = await db.SchedulePlans
|
||||
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
|
||||
x.Status == SchedulePlanStatus.Published)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (publishedPlan is not null)
|
||||
{
|
||||
db.ScheduleEntries.Add(new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = publishedPlan.Id,
|
||||
TeachingTaskId = adj.TeachingTaskId,
|
||||
ClassroomId = adj.ClassroomId,
|
||||
DayOfWeek = adj.DayOfWeek.Value,
|
||||
StartPeriod = adj.StartPeriod.Value,
|
||||
PeriodCount = adj.PeriodCount ?? 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 1,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd})"
|
||||
});
|
||||
}
|
||||
}
|
||||
var makeupSource = await LoadSourceEntryAsync(adj, ct);
|
||||
db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId));
|
||||
break;
|
||||
|
||||
case CourseAdjustmentType.Substitute:
|
||||
@@ -383,7 +517,8 @@ public sealed class CourseAdjustmentsController(
|
||||
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -393,7 +528,7 @@ public sealed class CourseAdjustmentsController(
|
||||
CourseAdjustmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||||
.Include(x => x.Course)
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == request.TeachingTaskId &&
|
||||
@@ -405,15 +540,13 @@ public sealed class CourseAdjustmentsController(
|
||||
{
|
||||
case CourseAdjustmentType.Reschedule:
|
||||
case CourseAdjustmentType.Makeup:
|
||||
if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7)
|
||||
return ValidationProblem("请选择有效的上课日。");
|
||||
if (!request.TargetDate.HasValue)
|
||||
return ValidationProblem(
|
||||
request.Type == CourseAdjustmentType.Reschedule
|
||||
? "请选择调课后的日期。"
|
||||
: "请选择补课日期。");
|
||||
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
|
||||
return ValidationProblem("请选择起始节次。");
|
||||
if (!request.PeriodCount.HasValue || request.PeriodCount < 1)
|
||||
return ValidationProblem("请选择持续节数。");
|
||||
if (request.Type == CourseAdjustmentType.Makeup &&
|
||||
!request.TargetDate.HasValue)
|
||||
return ValidationProblem("补课必须指定日期。");
|
||||
break;
|
||||
case CourseAdjustmentType.Substitute:
|
||||
if (!request.SubstituteTeacherId.HasValue)
|
||||
@@ -424,15 +557,241 @@ public sealed class CourseAdjustmentsController(
|
||||
return ValidationProblem("代课教师不存在或已离职。");
|
||||
break;
|
||||
case CourseAdjustmentType.Cancel:
|
||||
if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue)
|
||||
return ValidationProblem("停课需指定周次或日期。");
|
||||
break;
|
||||
}
|
||||
|
||||
if (RequiresSourceOccurrence(request.Type))
|
||||
{
|
||||
var source = await LoadSourceOccurrenceAsync(
|
||||
request.TeachingTaskId,
|
||||
request.SourceScheduleEntryId,
|
||||
request.SourceWeek,
|
||||
cancellationToken);
|
||||
if (source is null)
|
||||
return ValidationProblem("请选择当前已发布课表中的具体原课次。");
|
||||
|
||||
var duplicate = await db.CourseAdjustments.AsNoTracking().AnyAsync(x =>
|
||||
x.SourceScheduleEntryId == source.Entry.Id &&
|
||||
x.SourceWeek == source.Week &&
|
||||
(x.Status == CourseAdjustmentStatus.Submitted ||
|
||||
x.Status == CourseAdjustmentStatus.Approved),
|
||||
cancellationToken);
|
||||
if (duplicate)
|
||||
return ConflictProblem("该课次已有待审核或已通过的调停课记录。");
|
||||
|
||||
if (request.TargetDate.HasValue &&
|
||||
(request.TargetDate < source.Term.StartDate ||
|
||||
request.TargetDate > source.Term.EndDate))
|
||||
return ValidationProblem("目标日期必须在本学期起止日期内。");
|
||||
}
|
||||
|
||||
if (request.ClassroomId.HasValue &&
|
||||
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
|
||||
x.Id == request.ClassroomId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("目标教室不存在或已停用。");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateApprovalScheduleAsync(
|
||||
CourseAdjustment adjustment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!RequiresSourceOccurrence(adjustment.Type)) return null;
|
||||
|
||||
var source = await LoadSourceOccurrenceAsync(
|
||||
adjustment.TeachingTaskId,
|
||||
adjustment.SourceScheduleEntryId,
|
||||
adjustment.SourceWeek,
|
||||
cancellationToken);
|
||||
if (source is null)
|
||||
return "原课次已不在当前发布课表中,请退回后重新选择。";
|
||||
if (adjustment.Type == CourseAdjustmentType.Cancel) return null;
|
||||
if (!adjustment.TargetDate.HasValue || !adjustment.StartPeriod.HasValue)
|
||||
return "目标上课日期或节次不完整。";
|
||||
|
||||
var targetWeek = ResolveWeek(source.Term.StartDate, adjustment.TargetDate.Value);
|
||||
var targetDay = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek);
|
||||
var target = new ScheduleEntry
|
||||
{
|
||||
TeachingTaskId = adjustment.TeachingTaskId,
|
||||
TeachingTask = adjustment.TeachingTask,
|
||||
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
||||
DayOfWeek = targetDay,
|
||||
StartPeriod = adjustment.StartPeriod.Value,
|
||||
PeriodCount = adjustment.SourcePeriodCount ?? source.Entry.PeriodCount,
|
||||
StartWeek = targetWeek,
|
||||
EndWeek = targetWeek,
|
||||
WeekPattern = WeekPattern.All
|
||||
};
|
||||
var possibleConflicts = await db.ScheduleEntries.AsNoTracking()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Classes)
|
||||
.Where(x =>
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||
x.Id != adjustment.SourceScheduleEntryId &&
|
||||
x.DayOfWeek == targetDay &&
|
||||
x.StartWeek <= targetWeek &&
|
||||
x.EndWeek >= targetWeek &&
|
||||
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
|
||||
target.StartPeriod < x.StartPeriod + x.PeriodCount)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var existing in possibleConflicts.Where(x =>
|
||||
IncludesWeek(x.WeekPattern, targetWeek)))
|
||||
{
|
||||
var reason = ScheduleConflictDetector.ConflictReason(target, existing);
|
||||
if (reason is not null)
|
||||
return $"目标时间存在{reason}冲突:{existing.TeachingTask!.Name}。";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static System.Linq.Expressions.Expression<
|
||||
private async Task<SourceOccurrence?> LoadSourceOccurrenceAsync(
|
||||
Guid teachingTaskId,
|
||||
Guid? scheduleEntryId,
|
||||
int? week,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!scheduleEntryId.HasValue || !week.HasValue) return null;
|
||||
var entry = await db.ScheduleEntries.AsNoTracking()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == scheduleEntryId.Value &&
|
||||
x.TeachingTaskId == teachingTaskId &&
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
||||
cancellationToken);
|
||||
if (entry?.TeachingTask?.AcademicTerm is null ||
|
||||
week < entry.StartWeek || week > entry.EndWeek ||
|
||||
!IncludesWeek(entry.WeekPattern, week.Value))
|
||||
return null;
|
||||
var date = ResolveDate(
|
||||
entry.TeachingTask.AcademicTerm.StartDate,
|
||||
week.Value,
|
||||
entry.DayOfWeek);
|
||||
if (date < entry.TeachingTask.AcademicTerm.StartDate ||
|
||||
date > entry.TeachingTask.AcademicTerm.EndDate)
|
||||
return null;
|
||||
return new SourceOccurrence(
|
||||
entry,
|
||||
entry.TeachingTask.AcademicTerm,
|
||||
week.Value,
|
||||
date);
|
||||
}
|
||||
|
||||
private Task<ScheduleEntry> LoadSourceEntryAsync(
|
||||
CourseAdjustment adjustment,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ScheduleEntries.SingleAsync(x =>
|
||||
x.Id == adjustment.SourceScheduleEntryId &&
|
||||
x.TeachingTaskId == adjustment.TeachingTaskId &&
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
||||
cancellationToken);
|
||||
|
||||
private void ExcludeWeek(ScheduleEntry entry, int week)
|
||||
{
|
||||
db.ScheduleEntries.Remove(entry);
|
||||
if (entry.StartWeek <= week - 1 &&
|
||||
HasIncludedWeek(entry.WeekPattern, entry.StartWeek, week - 1))
|
||||
db.ScheduleEntries.Add(CloneEntry(entry, entry.StartWeek, week - 1));
|
||||
if (week + 1 <= entry.EndWeek &&
|
||||
HasIncludedWeek(entry.WeekPattern, week + 1, entry.EndWeek))
|
||||
db.ScheduleEntries.Add(CloneEntry(entry, week + 1, entry.EndWeek));
|
||||
}
|
||||
|
||||
private ScheduleEntry CreateTargetEntry(
|
||||
CourseAdjustment adjustment,
|
||||
Guid schedulePlanId)
|
||||
{
|
||||
var targetWeek = ResolveWeek(
|
||||
adjustment.TeachingTask!.AcademicTerm!.StartDate,
|
||||
adjustment.TargetDate!.Value);
|
||||
return new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = schedulePlanId,
|
||||
TeachingTaskId = adjustment.TeachingTaskId,
|
||||
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
||||
DayOfWeek = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek),
|
||||
StartPeriod = adjustment.StartPeriod!.Value,
|
||||
PeriodCount = adjustment.SourcePeriodCount!.Value,
|
||||
StartWeek = targetWeek,
|
||||
EndWeek = targetWeek,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = adjustment.Type == CourseAdjustmentType.Reschedule
|
||||
? $"调课(原第 {adjustment.SourceWeek} 周)"
|
||||
: $"补课(对应原第 {adjustment.SourceWeek} 周课次)"
|
||||
};
|
||||
}
|
||||
|
||||
private static ScheduleEntry CloneEntry(
|
||||
ScheduleEntry source,
|
||||
int startWeek,
|
||||
int endWeek) => new()
|
||||
{
|
||||
SchedulePlanId = source.SchedulePlanId,
|
||||
TeachingTaskId = source.TeachingTaskId,
|
||||
ClassroomId = source.ClassroomId,
|
||||
DayOfWeek = source.DayOfWeek,
|
||||
StartPeriod = source.StartPeriod,
|
||||
PeriodCount = source.PeriodCount,
|
||||
StartWeek = startWeek,
|
||||
EndWeek = endWeek,
|
||||
WeekPattern = source.WeekPattern,
|
||||
Notes = source.Notes
|
||||
};
|
||||
|
||||
private static bool RequiresSourceOccurrence(CourseAdjustmentType type) =>
|
||||
type is CourseAdjustmentType.Reschedule or
|
||||
CourseAdjustmentType.Cancel or
|
||||
CourseAdjustmentType.Makeup;
|
||||
|
||||
private static bool IncludesWeek(WeekPattern pattern, int week) =>
|
||||
pattern == WeekPattern.All ||
|
||||
pattern == WeekPattern.Odd && week % 2 == 1 ||
|
||||
pattern == WeekPattern.Even && week % 2 == 0;
|
||||
|
||||
private static bool HasIncludedWeek(WeekPattern pattern, int startWeek, int endWeek) =>
|
||||
Enumerable.Range(startWeek, endWeek - startWeek + 1)
|
||||
.Any(week => IncludesWeek(pattern, week));
|
||||
|
||||
private static DateOnly ResolveDate(DateOnly termStartDate, int week, int dayOfWeek)
|
||||
{
|
||||
var startDay = (int)termStartDate.DayOfWeek;
|
||||
var daysSinceMonday = (startDay + 6) % 7;
|
||||
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
||||
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
|
||||
}
|
||||
|
||||
private static int ResolveWeek(DateOnly termStartDate, DateOnly date)
|
||||
{
|
||||
var startDay = (int)termStartDate.DayOfWeek;
|
||||
var daysSinceMonday = (startDay + 6) % 7;
|
||||
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
||||
return (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
|
||||
}
|
||||
|
||||
private static int ToIsoDayOfWeek(DayOfWeek dayOfWeek) =>
|
||||
dayOfWeek == System.DayOfWeek.Sunday ? 7 : (int)dayOfWeek;
|
||||
|
||||
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 System.Linq.Expressions.Expression<
|
||||
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -449,6 +808,17 @@ public sealed class CourseAdjustmentsController(
|
||||
x.Status,
|
||||
x.Reason,
|
||||
x.ReviewComment,
|
||||
x.SourceScheduleEntryId,
|
||||
x.SourceWeek,
|
||||
x.SourceDate,
|
||||
x.SourceStartPeriod,
|
||||
x.SourcePeriodCount,
|
||||
SourceClassroomName = x.SourceClassroomId == null
|
||||
? null
|
||||
: db.Classrooms
|
||||
.Where(room => room.Id == x.SourceClassroomId)
|
||||
.Select(room => room.Building!.Name + " " + room.Name)
|
||||
.FirstOrDefault(),
|
||||
x.TargetDate,
|
||||
x.DayOfWeek,
|
||||
x.StartPeriod,
|
||||
@@ -464,6 +834,12 @@ public sealed class CourseAdjustmentsController(
|
||||
x.CreatedAt
|
||||
};
|
||||
|
||||
private sealed record SourceOccurrence(
|
||||
ScheduleEntry Entry,
|
||||
AcademicTerm Term,
|
||||
int Week,
|
||||
DateOnly Date);
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
@@ -487,7 +863,9 @@ public sealed record CourseAdjustmentRequest(
|
||||
Guid? ClassroomId,
|
||||
Guid? SubstituteTeacherId,
|
||||
int? CancelWeek,
|
||||
DateOnly? CancelDate);
|
||||
DateOnly? CancelDate,
|
||||
Guid? SourceScheduleEntryId = null,
|
||||
int? SourceWeek = null);
|
||||
|
||||
public sealed record RejectionRequest(
|
||||
[MaxLength(500)] string? Comment);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/courses")]
|
||||
public sealed class CoursesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string WriteRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -82,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,
|
||||
@@ -144,9 +157,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(),
|
||||
@@ -164,6 +174,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);
|
||||
}
|
||||
@@ -175,10 +195,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();
|
||||
@@ -195,6 +217,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);
|
||||
}
|
||||
|
||||
@@ -210,6 +245,7 @@ public sealed class CoursesController(
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ValidateAsync(
|
||||
Guid courseId,
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -222,9 +258,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;
|
||||
@@ -273,6 +362,7 @@ public sealed class CoursesController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -304,4 +394,5 @@ public sealed record CourseRequest(
|
||||
AssessmentMethod AssessmentMethod,
|
||||
[MaxLength(1000)] string? Description,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
int SortOrder,
|
||||
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/courses")]
|
||||
public sealed class CoursesExcelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string WriteRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -141,6 +143,9 @@ public sealed class CoursesExcelController(
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -9,75 +11,303 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/dashboard")]
|
||||
public sealed class DashboardController(AppDbContext db) : ControllerBase
|
||||
public sealed class DashboardController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<DashboardResponse>> Get(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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;
|
||||
|
||||
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 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)
|
||||
}
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -60,8 +60,16 @@ public sealed class DegreeAwardsController(
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
|
||||
public async Task<ActionResult> GetBatch(
|
||||
Guid id,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? keyword = null,
|
||||
[FromQuery] DegreeAwardConclusion? conclusion = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var batch = await db.DegreeAwardBatches.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||
if (batch is null) return NotFound();
|
||||
@@ -71,6 +79,52 @@ public sealed class DegreeAwardsController(
|
||||
if (collegeId.HasValue)
|
||||
source = source.Where(x =>
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
|
||||
var summary = await source
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
ResultCount = group.Count(),
|
||||
GrantedCount = group.Count(x =>
|
||||
x.Conclusion == DegreeAwardConclusion.Granted),
|
||||
NotGrantedCount = group.Count(x =>
|
||||
x.Conclusion == DegreeAwardConclusion.NotGranted),
|
||||
OverrideCount = group.Count(x => x.IsOverridden)
|
||||
})
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
source = source.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(normalizedKeyword) ||
|
||||
x.Student.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
|
||||
if (conclusion.HasValue)
|
||||
source = source.Where(x => x.Conclusion == conclusion.Value);
|
||||
|
||||
var total = await source.CountAsync(token);
|
||||
var items = await source.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.AverageGradePoint,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id,
|
||||
@@ -82,24 +136,17 @@ public sealed class DegreeAwardsController(
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await source.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.AverageGradePoint,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
ResultCount = summary?.ResultCount ?? 0,
|
||||
GrantedCount = summary?.GrantedCount ?? 0,
|
||||
NotGrantedCount = summary?.NotGrantedCount ?? 0,
|
||||
OverrideCount = summary?.OverrideCount ?? 0,
|
||||
Results = new
|
||||
{
|
||||
Items = items,
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,25 +180,30 @@ public sealed class DegreeAwardsController(
|
||||
return ConflictProblem("已发布批次不能重新计算。");
|
||||
|
||||
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
|
||||
.Include(x => x.GraduationAuditBatch)
|
||||
.Include(x => x.Student)
|
||||
.ThenInclude(x => x!.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Major)
|
||||
.Where(x =>
|
||||
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||
x.Conclusion == GraduationAuditConclusion.Eligible)
|
||||
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
StudentStatus = x.Student!.Status
|
||||
})
|
||||
.ToListAsync(token);
|
||||
var audits = auditCandidates
|
||||
.GroupBy(x => x.StudentId)
|
||||
.Select(x => x.First())
|
||||
.ToList();
|
||||
var studentIds = audits.Select(x => x.StudentId).ToArray();
|
||||
var gradePoints = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradePoint.HasValue)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Where(x => db.GraduationAuditResults.Any(audit =>
|
||||
audit.StudentId == x.StudentId &&
|
||||
audit.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||
audit.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||
audit.Conclusion == GraduationAuditConclusion.Eligible))
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -159,14 +211,17 @@ public sealed class DegreeAwardsController(
|
||||
x.GradeSheet!.TeachingTask!.Course!.Credits
|
||||
}).ToListAsync(token);
|
||||
|
||||
var oldResults = await db.DegreeAwardResults
|
||||
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
|
||||
db.DegreeAwardResults.RemoveRange(oldResults);
|
||||
await db.SaveChangesAsync(token);
|
||||
var gradePointsByStudent = gradePoints
|
||||
.GroupBy(x => x.StudentId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
|
||||
await db.DegreeAwardResults
|
||||
.Where(x => x.DegreeAwardBatchId == id)
|
||||
.ExecuteDeleteAsync(token);
|
||||
|
||||
foreach (var audit in audits)
|
||||
{
|
||||
var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList();
|
||||
var grades = gradePointsByStudent.GetValueOrDefault(audit.StudentId) ?? [];
|
||||
var credits = grades.Sum(x => x.Credits);
|
||||
var average = credits == 0
|
||||
? 0
|
||||
@@ -175,8 +230,8 @@ public sealed class DegreeAwardsController(
|
||||
2,
|
||||
MidpointRounding.AwayFromZero);
|
||||
var conclusion = DegreeAwardRules.Evaluate(
|
||||
true, audit.Student!.Status, average, batch.MinimumGradePoint);
|
||||
var reason = audit.Student.Status != StudentStatus.Graduated
|
||||
true, audit.StudentStatus, average, batch.MinimumGradePoint);
|
||||
var reason = audit.StudentStatus != StudentStatus.Graduated
|
||||
? "学籍状态尚未转为毕业"
|
||||
: average < batch.MinimumGradePoint
|
||||
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -16,7 +17,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class ExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
ExamArrangementService examArrangementService) : ControllerBase
|
||||
ExamArrangementService examArrangementService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
@@ -167,6 +169,95 @@ public sealed class ExamsController(
|
||||
return await SaveAsync(session.Id, true, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("plans/{planId:guid}/sessions/batch")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateSessionsBatch(
|
||||
Guid planId,
|
||||
CreateExamSessionsBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的考试计划不能调整场次。");
|
||||
|
||||
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
|
||||
if (taskIds.Length == 0)
|
||||
return ValidationProblem("请至少选择一个教学班。");
|
||||
if (taskIds.Length > 100)
|
||||
return ValidationProblem("一次最多选择100个教学班。");
|
||||
|
||||
var timeResult = ResolveExamTime(plan.AcademicTermId,
|
||||
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||
if (timeResult.Error is not null) return timeResult.Error;
|
||||
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||
|
||||
var validTaskIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.WhereIn(taskIds, x => x.Id)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (validTaskIds.Count != taskIds.Length)
|
||||
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
|
||||
|
||||
var duplicateTaskIds = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == planId)
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Select(x => x.TeachingTaskId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (duplicateTaskIds.Count > 0)
|
||||
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。");
|
||||
|
||||
var enrollmentPairs = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
x.CourseSelectionOffering!.TeachingTaskId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var conflictedStudentCount = enrollmentPairs
|
||||
.GroupBy(x => x.StudentId)
|
||||
.Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
|
||||
if (conflictedStudentCount > 0)
|
||||
return ConflictProblem(
|
||||
$"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突,请分批选择不同时间。");
|
||||
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
var validation = await ValidateSessionAsync(plan, null,
|
||||
taskId, null, null, startsAt, endsAt, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
}
|
||||
|
||||
db.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession
|
||||
{
|
||||
ExamPlanId = planId,
|
||||
TeachingTaskId = taskId,
|
||||
ExamDate = request.ExamDate,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredBuildingId = request.RequiredBuildingId,
|
||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||
Notes = Normalize(request.Notes)
|
||||
}));
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return Ok(new { createdCount = taskIds.Length });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return ConflictProblem("批量创建考试场次失败,关联数据可能已发生变化。");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateSession(
|
||||
@@ -235,11 +326,19 @@ public sealed class ExamsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> AutoArrange(
|
||||
Guid planId,
|
||||
ExamAutoArrangeRequest? request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await examArrangementService.ArrangeAsync(planId, cancellationToken);
|
||||
request ??= new ExamAutoArrangeRequest();
|
||||
var result = await examArrangementService.ArrangeAsync(
|
||||
planId,
|
||||
request.SessionIds,
|
||||
request.AssignClassrooms,
|
||||
request.AssignInvigilators,
|
||||
cancellationToken);
|
||||
if (!result.Success)
|
||||
return ConflictProblem(result.Message);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return Ok(new { message = result.Message });
|
||||
}
|
||||
|
||||
@@ -402,20 +501,40 @@ public sealed class ExamsController(
|
||||
x.TeachingTask!.TaskNumber,
|
||||
CourseName = x.TeachingTask.Course!.Name,
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||
x.StartsAt,
|
||||
Students = db.CourseEnrollments
|
||||
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
|
||||
.OrderBy(e => e.Student!.StudentNumber)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name
|
||||
}).ToList()
|
||||
x.StartsAt
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
if (session is null) return NotFound();
|
||||
|
||||
var students = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId)
|
||||
.OrderBy(e => e.Student!.StudentNumber)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.TaskNumber,
|
||||
session.CourseName,
|
||||
session.ClassroomName,
|
||||
session.StartsAt,
|
||||
Students = students.Select((student, index) => new
|
||||
{
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
SeatNumber = (index + 1).ToString("D3")
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -432,7 +551,7 @@ public sealed class ExamsController(
|
||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
return Ok(await db.ExamSessions.AsNoTracking()
|
||||
var schedule = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
@@ -453,9 +572,52 @@ public sealed class ExamsController(
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||
x.TeachingTaskId,
|
||||
IsExam = true
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
if (schedule.Count == 0) return Ok(schedule);
|
||||
|
||||
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray();
|
||||
var seatRows = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.CourseSelectionOffering!.TeachingTaskId,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var seatNumbers = seatRows
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.SelectMany(group => group.Select((row, index) => new
|
||||
{
|
||||
row.TeachingTaskId,
|
||||
row.StudentId,
|
||||
SeatNumber = (index + 1).ToString("D3")
|
||||
}))
|
||||
.ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);
|
||||
|
||||
return Ok(schedule.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.InvigilatorNames,
|
||||
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)),
|
||||
x.IsExam
|
||||
}));
|
||||
}
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
@@ -610,6 +772,7 @@ public sealed class ExamsController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(token);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, token);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -647,3 +810,17 @@ public sealed record CreateExamSessionRequest(
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record CreateExamSessionsBatchRequest(
|
||||
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||
DateOnly ExamDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
Guid? RequiredBuildingId,
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record ExamAutoArrangeRequest(
|
||||
IReadOnlyCollection<Guid>? SessionIds = null,
|
||||
bool AssignClassrooms = true,
|
||||
bool AssignInvigilators = true);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
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;
|
||||
@@ -40,18 +42,38 @@ public sealed class GradesController(
|
||||
public async Task<ActionResult> GetSheets(
|
||||
Guid? academicTermId,
|
||||
GradeSheetStatus? status,
|
||||
CancellationToken cancellationToken)
|
||||
string? keyword = null,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||
var source = AccessibleTasks().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == TeachingTaskStatus.Published ||
|
||||
x.Status == TeachingTaskStatus.Closed);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (status.HasValue)
|
||||
source = source.Where(x => db.GradeSheets.Any(sheet =>
|
||||
sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
keyword = keyword.Trim();
|
||||
source = source.Where(x =>
|
||||
x.TaskNumber.Contains(keyword) ||
|
||||
x.Name.Contains(keyword) ||
|
||||
x.Course!.Code.Contains(keyword) ||
|
||||
x.Course.Name.Contains(keyword));
|
||||
}
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -95,10 +117,7 @@ public sealed class GradesController(
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (status.HasValue)
|
||||
items = items.Where(x => x.Sheet?.Status == status.Value).ToList();
|
||||
return Ok(items);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("sheets")]
|
||||
@@ -134,15 +153,13 @@ public sealed class GradesController(
|
||||
cancellationToken))
|
||||
return ConflictProblem("该教学班已经建立成绩单。");
|
||||
|
||||
var studentIds = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
var studentIds = await TeachingTaskRosterQuery
|
||||
.ForTask(db, task.Id)
|
||||
.AsNoTracking()
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (studentIds.Count == 0)
|
||||
return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。");
|
||||
return ConflictProblem("教学班当前没有有效学生,无法建立成绩单。");
|
||||
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
@@ -165,8 +182,18 @@ public sealed class GradesController(
|
||||
|
||||
[HttpGet("sheets/{id:guid}")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
|
||||
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 AccessibleSheets().AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
@@ -200,37 +227,49 @@ public sealed class GradesController(
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt,
|
||||
x.PublishedAt,
|
||||
Records = x.Records
|
||||
.OrderBy(record => record.Student!.StudentNumber)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.StudentId,
|
||||
record.Student!.StudentNumber,
|
||||
record.Student.Name,
|
||||
ClassName = record.Student.AdministrativeClass!.Name,
|
||||
record.RegularScore,
|
||||
record.FinalScore,
|
||||
ItemScores = record.ItemScores
|
||||
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
|
||||
.Select(itemScore => new
|
||||
{
|
||||
itemScore.GradeItemId,
|
||||
itemScore.GradeItem!.Name,
|
||||
itemScore.Score
|
||||
}),
|
||||
record.TotalScore,
|
||||
record.GradePoint,
|
||||
record.ExamStatus,
|
||||
record.Notes,
|
||||
record.UpdatedAt
|
||||
}),
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var recordsSource = db.GradeRecords.AsNoTracking()
|
||||
.Where(record => record.GradeSheetId == id);
|
||||
if (studentKeyword is not null)
|
||||
recordsSource = recordsSource.Where(record =>
|
||||
record.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||
record.Student.Name.Contains(studentKeyword) ||
|
||||
record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
|
||||
var recordTotal = await recordsSource.CountAsync(cancellationToken);
|
||||
var records = await recordsSource
|
||||
.OrderBy(record => record.Student!.StudentNumber)
|
||||
.Skip((recordPage - 1) * recordPageSize)
|
||||
.Take(recordPageSize)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.StudentId,
|
||||
record.Student!.StudentNumber,
|
||||
record.Student.Name,
|
||||
ClassName = record.Student.AdministrativeClass!.Name,
|
||||
record.RegularScore,
|
||||
record.FinalScore,
|
||||
ItemScores = record.ItemScores
|
||||
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
|
||||
.Select(itemScore => new
|
||||
{
|
||||
itemScore.GradeItemId,
|
||||
itemScore.GradeItem!.Name,
|
||||
itemScore.Score
|
||||
}),
|
||||
record.TotalScore,
|
||||
record.GradePoint,
|
||||
record.ExamStatus,
|
||||
record.Notes,
|
||||
record.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Include(x => x.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
@@ -244,7 +283,36 @@ public sealed class GradesController(
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = sheet,
|
||||
Sheet = new
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.TaskNumber,
|
||||
sheet.TaskName,
|
||||
sheet.AcademicTermId,
|
||||
sheet.TermName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
sheet.CourseCollegeId,
|
||||
sheet.CourseCollegeName,
|
||||
sheet.Credits,
|
||||
sheet.TeacherNames,
|
||||
sheet.ClassNames,
|
||||
sheet.RegularWeight,
|
||||
sheet.FinalWeight,
|
||||
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 = CanEditScores(task) &&
|
||||
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
||||
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
|
||||
@@ -411,7 +479,8 @@ public sealed class GradesController(
|
||||
$"《{courseName}》成绩已提交,请及时审核。",
|
||||
collegeId,
|
||||
"/grades",
|
||||
cancellationToken);
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -452,7 +521,7 @@ public sealed class GradesController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"成绩审核通过",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
|
||||
"/grades", cancellationToken);
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -497,7 +566,7 @@ public sealed class GradesController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"成绩被退回",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
|
||||
"/grades", cancellationToken);
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -505,7 +574,10 @@ public sealed class GradesController(
|
||||
[Authorize(Roles = Publishers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
||||
var sheet = await AccessibleSheets()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
@@ -514,13 +586,11 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify enrolled students
|
||||
var studentUserIds = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// covers students added through approved roster corrections.
|
||||
var studentUserIds = await db.GradeRecords
|
||||
.Where(x => x.GradeSheetId == sheet.Id)
|
||||
.Select(x => x.Student!.UserId)
|
||||
.Where(id => id != null)
|
||||
.Select(id => id!.Value)
|
||||
@@ -528,8 +598,8 @@ public sealed class GradesController(
|
||||
.ToListAsync(cancellationToken);
|
||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||
"成绩已发布",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
|
||||
"/grades", cancellationToken);
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,16 @@ public sealed class GraduationAuditsController(
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
|
||||
public async Task<ActionResult> GetBatch(
|
||||
Guid id,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? keyword = null,
|
||||
[FromQuery] GraduationAuditConclusion? conclusion = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var batch = await db.GraduationAuditBatches.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||
if (batch is null) return NotFound();
|
||||
@@ -73,6 +81,59 @@ public sealed class GraduationAuditsController(
|
||||
if (collegeId.HasValue)
|
||||
results = results.Where(x =>
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
|
||||
var summary = await results
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
ResultCount = group.Count(),
|
||||
EligibleCount = group.Count(x =>
|
||||
x.Conclusion == GraduationAuditConclusion.Eligible),
|
||||
IneligibleCount = group.Count(x =>
|
||||
x.Conclusion == GraduationAuditConclusion.Ineligible),
|
||||
OverrideCount = group.Count(x => x.IsOverridden)
|
||||
})
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
results = results.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(normalizedKeyword) ||
|
||||
x.Student.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
|
||||
if (conclusion.HasValue)
|
||||
results = results.Where(x => x.Conclusion == conclusion.Value);
|
||||
|
||||
var total = await results.CountAsync(token);
|
||||
var items = await results
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.StudentStatusSnapshot,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id,
|
||||
@@ -83,31 +144,17 @@ public sealed class GraduationAuditsController(
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await results
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.StudentStatusSnapshot,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
ResultCount = summary?.ResultCount ?? 0,
|
||||
EligibleCount = summary?.EligibleCount ?? 0,
|
||||
IneligibleCount = summary?.IneligibleCount ?? 0,
|
||||
OverrideCount = summary?.OverrideCount ?? 0,
|
||||
Results = new
|
||||
{
|
||||
Items = items,
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,10 +203,11 @@ public sealed class GraduationAuditsController(
|
||||
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.ToListAsync(token);
|
||||
var studentIds = students.Select(x => x.Id).ToArray();
|
||||
var grades = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Where(x => x.Student!.EnrollmentYear == batch.EnrollmentYear &&
|
||||
(x.Student.Status == StudentStatus.Active ||
|
||||
x.Student.Status == StudentStatus.Suspended))
|
||||
.Select(x => new GradeSnapshot(
|
||||
x.StudentId,
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
@@ -169,16 +217,23 @@ public sealed class GraduationAuditsController(
|
||||
x.ExamStatus))
|
||||
.ToListAsync(token);
|
||||
|
||||
var oldResults = await db.GraduationAuditResults
|
||||
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
|
||||
db.GraduationAuditResults.RemoveRange(oldResults);
|
||||
await db.SaveChangesAsync(token);
|
||||
var plansByMajor = plans
|
||||
.GroupBy(x => x.MajorId)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
var gradesByStudent = grades
|
||||
.GroupBy(x => x.StudentId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
|
||||
await db.GraduationAuditResults
|
||||
.Where(x => x.GraduationAuditBatchId == id)
|
||||
.ExecuteDeleteAsync(token);
|
||||
|
||||
foreach (var student in students)
|
||||
{
|
||||
var plan = plans.FirstOrDefault(x =>
|
||||
x.MajorId == student.AdministrativeClass!.MajorId);
|
||||
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
|
||||
plansByMajor.TryGetValue(
|
||||
student.AdministrativeClass!.MajorId,
|
||||
out var plan);
|
||||
var studentGrades = gradesByStudent.GetValueOrDefault(student.Id) ?? [];
|
||||
var passedCourseIds = studentGrades
|
||||
.Where(IsPassed)
|
||||
.Select(x => x.CourseId)
|
||||
|
||||
@@ -34,31 +34,46 @@ public sealed class GraduationClearanceController(
|
||||
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
|
||||
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
|
||||
}).ToListAsync(token);
|
||||
var records = await ScopedRecords().AsNoTracking()
|
||||
.Select(x => new
|
||||
var statistics = await ScopedRecords().AsNoTracking()
|
||||
.GroupBy(x => x.GraduationClearanceItem!.GraduationClearanceBatchId)
|
||||
.Select(group => new
|
||||
{
|
||||
BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId,
|
||||
x.StudentId, x.Status
|
||||
}).ToListAsync(token);
|
||||
BatchId = group.Key,
|
||||
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
|
||||
RecordCount = group.Count(),
|
||||
CompletedCount = group.Count(x =>
|
||||
x.Status != GraduationClearanceRecordStatus.Pending)
|
||||
}).ToDictionaryAsync(x => x.BatchId, token);
|
||||
return Ok(batches.Select(batch =>
|
||||
{
|
||||
var scoped = records.Where(x => x.BatchId == batch.Id).ToList();
|
||||
statistics.TryGetValue(batch.Id, out var summary);
|
||||
return new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
||||
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
|
||||
StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(),
|
||||
RecordCount = scoped.Count,
|
||||
CompletedCount = scoped.Count(x =>
|
||||
x.Status != GraduationClearanceRecordStatus.Pending)
|
||||
StudentCount = summary?.StudentCount ?? 0,
|
||||
RecordCount = summary?.RecordCount ?? 0,
|
||||
CompletedCount = summary?.CompletedCount ?? 0
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Workers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
|
||||
public async Task<ActionResult> GetBatch(
|
||||
Guid id,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? keyword = null,
|
||||
[FromQuery] string? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var normalizedProgress = progress?.Trim();
|
||||
if (normalizedProgress is not null and not "Pending" and not "Completed")
|
||||
return ValidationProblem("离校办理进度筛选值无效。");
|
||||
|
||||
var batch = await db.GraduationClearanceBatches.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
@@ -72,8 +87,56 @@ public sealed class GraduationClearanceController(
|
||||
})
|
||||
}).FirstOrDefaultAsync(token);
|
||||
if (batch is null) return NotFound();
|
||||
var records = await ScopedRecords().AsNoTracking()
|
||||
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
|
||||
|
||||
var records = ScopedRecords().AsNoTracking()
|
||||
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id);
|
||||
var summary = await records
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
|
||||
RecordCount = group.Count(),
|
||||
CompletedCount = group.Count(x =>
|
||||
x.Status != GraduationClearanceRecordStatus.Pending)
|
||||
})
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
var students = db.Students.AsNoTracking()
|
||||
.Where(student => records.Any(record => record.StudentId == student.Id));
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
students = students.Where(student =>
|
||||
student.StudentNumber.Contains(normalizedKeyword) ||
|
||||
student.Name.Contains(normalizedKeyword) ||
|
||||
student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
|
||||
student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
|
||||
if (normalizedProgress == "Pending")
|
||||
students = students.Where(student => records.Any(record =>
|
||||
record.StudentId == student.Id &&
|
||||
record.GraduationClearanceItem!.IsRequired &&
|
||||
record.Status == GraduationClearanceRecordStatus.Pending));
|
||||
else if (normalizedProgress == "Completed")
|
||||
students = students.Where(student => !records.Any(record =>
|
||||
record.StudentId == student.Id &&
|
||||
record.GraduationClearanceItem!.IsRequired &&
|
||||
record.Status == GraduationClearanceRecordStatus.Pending));
|
||||
|
||||
var total = await students.CountAsync(token);
|
||||
var studentPage = await students
|
||||
.OrderBy(student => student.StudentNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(student => new
|
||||
{
|
||||
student.Id,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
ClassName = student.AdministrativeClass!.Name,
|
||||
MajorName = student.AdministrativeClass.Major!.Name
|
||||
}).ToListAsync(token);
|
||||
var studentIds = studentPage.Select(student => student.Id).ToArray();
|
||||
var pageRecords = await records
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
|
||||
.Select(x => new
|
||||
@@ -89,11 +152,32 @@ public sealed class GraduationClearanceController(
|
||||
x.GraduationClearanceItem.IsRequired,
|
||||
x.Status, x.Notes, x.CompletedAt
|
||||
}).ToListAsync(token);
|
||||
var recordsByStudent = pageRecords
|
||||
.GroupBy(record => record.StudentId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
var studentItems = studentPage.Select(student => new
|
||||
{
|
||||
StudentId = student.Id,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
student.MajorName,
|
||||
Records = recordsByStudent.GetValueOrDefault(student.Id) ?? []
|
||||
}).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
||||
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
|
||||
Records = records
|
||||
StudentCount = summary?.StudentCount ?? 0,
|
||||
RecordCount = summary?.RecordCount ?? 0,
|
||||
CompletedCount = summary?.CompletedCount ?? 0,
|
||||
Students = new
|
||||
{
|
||||
Items = studentItems,
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,16 +234,15 @@ public sealed class GraduationClearanceController(
|
||||
if (batch.Status != GraduationClearanceBatchStatus.Open)
|
||||
return ConflictProblem("已关闭批次不能重新生成办理记录。");
|
||||
|
||||
var eligibleAudits = await db.GraduationAuditResults.AsNoTracking()
|
||||
var studentIds = await db.GraduationAuditResults.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||
x.Conclusion == GraduationAuditConclusion.Eligible &&
|
||||
x.Student!.Status == StudentStatus.Graduated)
|
||||
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||
.Select(x => new { x.StudentId })
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(token);
|
||||
var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray();
|
||||
var existing = await db.GraduationClearanceRecords.AsNoTracking()
|
||||
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
|
||||
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
|
||||
@@ -180,7 +263,7 @@ public sealed class GraduationClearanceController(
|
||||
created++;
|
||||
}
|
||||
await db.SaveChangesAsync(token);
|
||||
return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created });
|
||||
return Ok(new { StudentCount = studentIds.Count, CreatedRecordCount = created });
|
||||
}
|
||||
|
||||
[HttpPut("records/{id:guid}")]
|
||||
@@ -221,15 +304,17 @@ public sealed class GraduationClearanceController(
|
||||
public async Task<ActionResult> Close(Guid id, CancellationToken token)
|
||||
{
|
||||
var batch = await db.GraduationClearanceBatches
|
||||
.Include(x => x.Items)
|
||||
.ThenInclude(x => x.Records)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||
if (batch is null) return NotFound();
|
||||
if (batch.Status != GraduationClearanceBatchStatus.Open)
|
||||
return ConflictProblem("该批次已经关闭。");
|
||||
var records = batch.Items.SelectMany(item => item.Records.Select(record =>
|
||||
(item.IsRequired, record.Status)));
|
||||
if (!GraduationClearanceRules.CanClose(records))
|
||||
var hasIncompleteRequiredItem = await db.GraduationClearanceRecords
|
||||
.AnyAsync(record =>
|
||||
record.GraduationClearanceItem!.GraduationClearanceBatchId == id &&
|
||||
record.GraduationClearanceItem.IsRequired &&
|
||||
record.Status == GraduationClearanceRecordStatus.Pending,
|
||||
token);
|
||||
if (hasIncompleteRequiredItem)
|
||||
return ConflictProblem("仍有必办离校事项尚未完成。");
|
||||
batch.Status = GraduationClearanceBatchStatus.Closed;
|
||||
batch.ClosedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
@@ -18,8 +19,7 @@ public sealed class MakeupExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
MakeupExamArrangementService arrangementService,
|
||||
MakeupExamAutoJobQueue autoJobQueue) : ControllerBase
|
||||
MakeupExamArrangementService arrangementService) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
@@ -224,6 +224,78 @@ public sealed class MakeupExamsController(
|
||||
return await SaveAsync(session.Id, true, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("plans/{planId:guid}/sessions/batch")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateSessionsBatch(
|
||||
Guid planId,
|
||||
CreateMakeupExamSessionsBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||
|
||||
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
|
||||
if (taskIds.Length == 0)
|
||||
return ValidationProblem("请至少选择一个教学班。");
|
||||
if (taskIds.Length > 100)
|
||||
return ValidationProblem("一次最多选择100个教学班。");
|
||||
|
||||
var timeResult = ResolveExamTime(plan.AcademicTermId,
|
||||
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||
if (timeResult.Error is not null) return timeResult.Error;
|
||||
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||
|
||||
var validTaskIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.WhereIn(taskIds, x => x.Id)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (validTaskIds.Count != taskIds.Length)
|
||||
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
|
||||
|
||||
var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == planId)
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Select(x => x.TeachingTaskId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (duplicateTaskIds.Count > 0)
|
||||
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。");
|
||||
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
var validation = await ValidateSessionAsync(plan, null,
|
||||
taskId, null, null, startsAt, endsAt, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
}
|
||||
|
||||
db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession
|
||||
{
|
||||
MakeupExamPlanId = planId,
|
||||
TeachingTaskId = taskId,
|
||||
ExamDate = request.ExamDate,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredBuildingId = request.RequiredBuildingId,
|
||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||
Notes = Normalize(request.Notes)
|
||||
}));
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { createdCount = taskIds.Length });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateSession(
|
||||
@@ -292,9 +364,16 @@ public sealed class MakeupExamsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> AutoArrange(
|
||||
Guid planId,
|
||||
ExamAutoArrangeRequest? request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await arrangementService.ArrangeAsync(planId, cancellationToken);
|
||||
request ??= new ExamAutoArrangeRequest();
|
||||
var result = await arrangementService.ArrangeAsync(
|
||||
planId,
|
||||
request.SessionIds,
|
||||
request.AssignClassrooms,
|
||||
request.AssignInvigilators,
|
||||
cancellationToken);
|
||||
if (!result.Success)
|
||||
return ConflictProblem(result.Message);
|
||||
return Ok(new { message = result.Message });
|
||||
@@ -336,10 +415,12 @@ public sealed class MakeupExamsController(
|
||||
MakeupExamPlanId = planId
|
||||
};
|
||||
db.MakeupExamAutoJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
job.Id));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
autoJobQueue.Enqueue(job.Id);
|
||||
|
||||
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
|
||||
new { jobId = job.Id, status = job.Status.ToString() });
|
||||
}
|
||||
@@ -553,19 +634,43 @@ public sealed class MakeupExamsController(
|
||||
x.TeachingTask!.TaskNumber,
|
||||
CourseName = x.TeachingTask.Course!.Name,
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||
x.StartsAt,
|
||||
Students = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name,
|
||||
e.Reason,
|
||||
e.MakeupScore
|
||||
}).ToList()
|
||||
x.StartsAt
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
if (session is null) return NotFound();
|
||||
|
||||
var students = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.Where(e => e.MakeupExamSessionId == id)
|
||||
.OrderBy(e => e.Student!.StudentNumber)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name,
|
||||
e.Reason,
|
||||
e.MakeupScore
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.TaskNumber,
|
||||
session.CourseName,
|
||||
session.ClassroomName,
|
||||
session.StartsAt,
|
||||
Students = students.Select((student, index) => new
|
||||
{
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
student.Reason,
|
||||
student.MakeupScore,
|
||||
SeatNumber = (index + 1).ToString("D3")
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -697,7 +802,7 @@ public sealed class MakeupExamsController(
|
||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
|
||||
var schedule = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.Where(x => x.StudentId == studentId &&
|
||||
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
||||
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
||||
@@ -722,7 +827,49 @@ public sealed class MakeupExamsController(
|
||||
x.MakeupScore,
|
||||
IsMakeup = true
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
if (schedule.Count == 0) return Ok(schedule);
|
||||
|
||||
var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray();
|
||||
var seatRows = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.WhereIn(sessionIds, x => x.MakeupExamSessionId)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.MakeupExamSessionId,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var seatNumbers = seatRows
|
||||
.GroupBy(x => x.MakeupExamSessionId)
|
||||
.SelectMany(group => group.Select((row, index) => new
|
||||
{
|
||||
row.MakeupExamSessionId,
|
||||
row.StudentId,
|
||||
SeatNumber = (index + 1).ToString("D3")
|
||||
}))
|
||||
.ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber);
|
||||
|
||||
return Ok(schedule.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.Reason,
|
||||
x.MakeupScore,
|
||||
SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)),
|
||||
x.IsMakeup
|
||||
}));
|
||||
}
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
@@ -939,6 +1086,15 @@ public sealed record CreateMakeupExamSessionRequest(
|
||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record CreateMakeupExamSessionsBatchRequest(
|
||||
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||
DateOnly ExamDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
Guid? RequiredBuildingId,
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record EnrollStudentsRequest(
|
||||
[Required] IReadOnlyCollection<Guid> StudentIds);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
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,20 +17,43 @@ 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 + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Counselor + "," +
|
||||
SystemRoles.Teacher;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetNotifications(
|
||||
bool? unreadOnly,
|
||||
NotificationCategory? category,
|
||||
string? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var source = db.Notifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId);
|
||||
if (unreadOnly == true)
|
||||
source = source.Where(x => !x.IsRead);
|
||||
if (category.HasValue)
|
||||
source = source.Where(x => x.Category == category);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var value = keyword.Trim();
|
||||
source = source.Where(x =>
|
||||
x.Title.Contains(value) ||
|
||||
x.Content.Contains(value));
|
||||
}
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
@@ -36,14 +62,38 @@ public sealed class NotificationsController(
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Content,
|
||||
x.Category,
|
||||
x.IsRead,
|
||||
x.LinkUrl,
|
||||
SenderName = x.MessageDispatch == null
|
||||
? "教务系统"
|
||||
: x.MessageDispatch.SenderName,
|
||||
AudienceName = x.MessageDispatch == null
|
||||
? null
|
||||
: x.MessageDispatch.AudienceName,
|
||||
IsManual = x.MessageDispatchId != null,
|
||||
x.CreatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var unreadCount = await db.Notifications
|
||||
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
|
||||
var unreadByCategory = await db.Notifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && !x.IsRead)
|
||||
.GroupBy(x => x.Category)
|
||||
.Select(x => new { Category = x.Key, Count = x.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new { Items = items, Total = total, UnreadCount = unreadCount });
|
||||
return Ok(new
|
||||
{
|
||||
Items = items,
|
||||
Total = total,
|
||||
UnreadCount = unreadCount,
|
||||
UnreadByCategory = unreadByCategory
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("unread-count")]
|
||||
@@ -55,30 +105,802 @@ public sealed class NotificationsController(
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
|
||||
[HttpGet("composer")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> GetComposer(CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
null,
|
||||
MessageAudienceType.School,
|
||||
"全校已启用账号",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
var collegeId = await ResolveSenderCollegeIdAsync(
|
||||
scope,
|
||||
cancellationToken);
|
||||
if (!collegeId.HasValue)
|
||||
{
|
||||
return ConflictProblem(
|
||||
"当前账号未关联学院,暂时无法确定发信范围。");
|
||||
}
|
||||
|
||||
var college = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.Id == collegeId.Value)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (college is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
collegeId.Value,
|
||||
MessageAudienceType.College,
|
||||
$"{college}全院成员",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
var tasks = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status != TeachingTaskStatus.Draft &&
|
||||
task.Teachers.Any(item =>
|
||||
item.Teacher!.UserId == scope.UserId))
|
||||
.OrderByDescending(task => task.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(task => task.AcademicTerm!.StartDate)
|
||||
.ThenBy(task => task.TaskNumber)
|
||||
.Select(task => new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskNumber,
|
||||
CourseName = task.Course!.Name,
|
||||
TermName = task.AcademicTerm!.Name,
|
||||
RecipientCount = db.Students.Count(student =>
|
||||
student.Status == StudentStatus.Active &&
|
||||
student.UserId != null &&
|
||||
(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)))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AudienceType = MessageAudienceType.TeachingTask,
|
||||
AudienceName = "所带教学班",
|
||||
RecipientCount = 0,
|
||||
TeachingTasks = tasks
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
SendMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var title = request.Title.Trim();
|
||||
var content = request.Content.Trim();
|
||||
if (title.Length == 0)
|
||||
return ValidationProblem("请填写消息标题。");
|
||||
if (PlainText(content).Length == 0)
|
||||
return ValidationProblem("请填写消息正文。");
|
||||
if (content.Length > 20000)
|
||||
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
|
||||
var scope = currentUserDataScope.Current;
|
||||
MessageAudienceType audienceType;
|
||||
Guid? audienceId = null;
|
||||
string audienceName;
|
||||
List<Guid> recipientIds;
|
||||
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
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))
|
||||
{
|
||||
var collegeId = await ResolveSenderCollegeIdAsync(
|
||||
scope,
|
||||
cancellationToken);
|
||||
if (!collegeId.HasValue)
|
||||
{
|
||||
return ConflictProblem(
|
||||
"当前账号未关联学院,暂时无法确定发信范围。");
|
||||
}
|
||||
|
||||
var collegeName = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.Id == collegeId.Value)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (collegeName is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
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))
|
||||
{
|
||||
if (!request.TeachingTaskId.HasValue)
|
||||
return ValidationProblem("请选择接收消息的教学班。");
|
||||
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Id == request.TeachingTaskId.Value &&
|
||||
x.Status != TeachingTaskStatus.Draft &&
|
||||
x.Teachers.Any(item =>
|
||||
item.Teacher!.UserId == scope.UserId))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
CourseName = x.Course!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (task is null)
|
||||
{
|
||||
return ConflictProblem(
|
||||
"只能向自己所带且已发布的教学班发信。");
|
||||
}
|
||||
|
||||
audienceType = MessageAudienceType.TeachingTask;
|
||||
audienceId = task.Id;
|
||||
audienceName = $"{task.TaskNumber} · {task.CourseName}";
|
||||
recipientIds = await GetTeachingTaskRecipientIdsAsync(
|
||||
task.Id,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
recipientIds = recipientIds.Distinct().ToList();
|
||||
if (recipientIds.Count == 0)
|
||||
return ConflictProblem("当前发信范围内没有可接收消息的已启用账号。");
|
||||
|
||||
var dispatch = new MessageDispatch
|
||||
{
|
||||
SenderUserId = scope.UserId,
|
||||
SenderName = scope.DisplayName ?? "教务用户",
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = NotificationCategory.General,
|
||||
AudienceType = audienceType,
|
||||
AudienceId = audienceId,
|
||||
AudienceName = audienceName,
|
||||
RecipientCount = recipientIds.Count,
|
||||
LinkUrl = null
|
||||
};
|
||||
try
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.MessageDispatches.Add(dispatch);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
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)
|
||||
{
|
||||
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")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> GetSent(
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var source = db.MessageDispatches.AsNoTracking()
|
||||
.Where(x => x.SenderUserId == userId);
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Content,
|
||||
x.Category,
|
||||
x.AudienceType,
|
||||
x.AudienceName,
|
||||
x.RecipientCount,
|
||||
x.CreatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Items = items, Total = total });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/read")]
|
||||
public async Task<ActionResult> MarkRead(Guid id, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult> MarkRead(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var n = await db.Notifications
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
|
||||
if (n is null) return NotFound();
|
||||
n.IsRead = true;
|
||||
var notification = await db.Notifications
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == id && x.UserId == userId,
|
||||
cancellationToken);
|
||||
if (notification is null) return NotFound();
|
||||
notification.IsRead = true;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("read-all")]
|
||||
public async Task<ActionResult> MarkAllRead(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult> MarkAllRead(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
await db.Notifications
|
||||
.Where(x => x.UserId == userId && !x.IsRead)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true),
|
||||
.ExecuteUpdateAsync(
|
||||
updates => updates.SetProperty(x => x.IsRead, true),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> GetTeachingTaskRecipientIdsAsync(
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.Students.AsNoTracking()
|
||||
.Where(student =>
|
||||
student.Status == StudentStatus.Active &&
|
||||
student.UserId != null &&
|
||||
db.Users.Any(user =>
|
||||
user.Id == student.UserId &&
|
||||
user.IsEnabled) &&
|
||||
(db.TeachingTaskClasses.Any(item =>
|
||||
item.TeachingTaskId == teachingTaskId &&
|
||||
item.AdministrativeClassId ==
|
||||
student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
teachingTaskId)))
|
||||
.Select(x => x.UserId!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Guid?> ResolveSenderCollegeIdAsync(
|
||||
CurrentUserScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (scope.CollegeId.HasValue)
|
||||
return scope.CollegeId;
|
||||
if (!scope.IsInRole(SystemRoles.Counselor))
|
||||
return null;
|
||||
|
||||
var collegeIds = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.CounselorUserId == scope.UserId)
|
||||
.Select(x => x.Major!.CollegeId)
|
||||
.Distinct()
|
||||
.Take(2)
|
||||
.ToListAsync(cancellationToken);
|
||||
return collegeIds.Count == 1 ? collegeIds[0] : null;
|
||||
}
|
||||
|
||||
private static bool IsSchoolAdministrator(CurrentUserScope scope) =>
|
||||
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
|
||||
{
|
||||
Title = "无法发送消息",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private ActionResult ValidationProblem(string detail) =>
|
||||
BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "消息内容不完整",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SendMessageRequest(
|
||||
[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.
|
||||
/// </summary>
|
||||
@@ -90,13 +912,15 @@ public static class NotificationService
|
||||
string title,
|
||||
string content,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
db.Notifications.Add(new Notification
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -109,22 +933,46 @@ public static class NotificationService
|
||||
string content,
|
||||
Guid? collegeId = null,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
var query = db.Users
|
||||
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
|
||||
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! });
|
||||
.Join(
|
||||
db.UserRoles,
|
||||
user => user.Id,
|
||||
userRole => userRole.UserId,
|
||||
(user, userRole) => new
|
||||
{
|
||||
user.Id,
|
||||
user.IsEnabled,
|
||||
user.CollegeId,
|
||||
userRole.RoleId
|
||||
})
|
||||
.Join(
|
||||
db.Roles,
|
||||
item => item.RoleId,
|
||||
role => role.Id,
|
||||
(item, role) => new
|
||||
{
|
||||
item.Id,
|
||||
item.IsEnabled,
|
||||
item.CollegeId,
|
||||
RoleName = role.Name!
|
||||
});
|
||||
|
||||
var userIds = query.Where(x => x.RoleName == roleName);
|
||||
var userIds = query.Where(x =>
|
||||
x.RoleName == roleName &&
|
||||
x.IsEnabled);
|
||||
|
||||
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
|
||||
if (collegeId.HasValue)
|
||||
{
|
||||
userIds = userIds.Where(x =>
|
||||
db.Teachers.Any(t =>
|
||||
t.UserId == x.Id && t.CollegeId == collegeId.Value));
|
||||
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
|
||||
}
|
||||
|
||||
var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken);
|
||||
var ids = await userIds
|
||||
.Select(x => x.Id)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
db.Notifications.Add(new Notification
|
||||
@@ -132,6 +980,7 @@ public static class NotificationService
|
||||
UserId = id,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
}
|
||||
@@ -144,7 +993,8 @@ public static class NotificationService
|
||||
string title,
|
||||
string content,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
foreach (var userId in userIds.Distinct())
|
||||
{
|
||||
@@ -153,6 +1003,7 @@ public static class NotificationService
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/official-documents")]
|
||||
public sealed class OfficialDocumentsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
OfficialDocumentService service,
|
||||
IOptions<OfficialDocumentOptions> options) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
private const string Readers = Managers + "," + SystemRoles.Student;
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Roles = Readers)]
|
||||
public async Task<ActionResult> List(
|
||||
OfficialDocumentType? type,
|
||||
OfficialDocumentStatus? status,
|
||||
Guid? studentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = AccessibleDocuments().AsNoTracking();
|
||||
if (type.HasValue) source = source.Where(x => x.Type == type);
|
||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
|
||||
|
||||
return Ok(await source
|
||||
.OrderByDescending(x => x.IssuedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.DocumentNumber,
|
||||
x.Type,
|
||||
x.Status,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
StudentName = x.Student.Name,
|
||||
CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name,
|
||||
x.Purpose,
|
||||
x.IssuedAt,
|
||||
IssuedByName = x.Student.UserId == x.IssuedByUserId
|
||||
? "学生自助服务"
|
||||
: x.IssuedByUser!.DisplayName,
|
||||
x.InvalidatedAt,
|
||||
x.InvalidationReason,
|
||||
x.ReissuedFromDocumentId,
|
||||
DownloadCount = x.Downloads.Count,
|
||||
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("students/options")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> StudentOptions(
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = AccessibleStudents().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var value = keyword.Trim();
|
||||
source = source.Where(x =>
|
||||
x.StudentNumber.Contains(value) || x.Name.Contains(value));
|
||||
}
|
||||
|
||||
return Ok(await source.OrderBy(x => x.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.Status,
|
||||
ClassName = x.AdministrativeClass!.Name,
|
||||
MajorName = x.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Issue(
|
||||
IssueOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await AccessibleStudents().AnyAsync(x => x.Id == request.StudentId, cancellationToken))
|
||||
return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var document = await service.CreateAsync(
|
||||
request.StudentId,
|
||||
request.Type,
|
||||
request.Purpose,
|
||||
currentUserDataScope.Current.UserId,
|
||||
currentUserDataScope.Current.DisplayName ?? "教务管理员",
|
||||
VerificationBaseUrl(),
|
||||
null,
|
||||
cancellationToken);
|
||||
db.OfficialDocuments.Add(document);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return CreatedAtAction(nameof(Download), new { id = document.Id },
|
||||
new { document.Id, document.DocumentNumber });
|
||||
}
|
||||
catch (OfficialDocumentValidationException exception)
|
||||
{
|
||||
return ConflictProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> IssueMine(
|
||||
SelfIssueOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var studentId = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联有效学生档案,不能申请电子凭证。");
|
||||
|
||||
var purpose = string.IsNullOrWhiteSpace(request.Purpose)
|
||||
? null
|
||||
: request.Purpose.Trim();
|
||||
var duplicateThreshold = DateTime.UtcNow.AddMinutes(-5);
|
||||
var recentDocument = await db.OfficialDocuments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.Type == request.Type &&
|
||||
x.Status == OfficialDocumentStatus.Valid &&
|
||||
x.Purpose == purpose &&
|
||||
x.IssuedAt >= duplicateThreshold)
|
||||
.OrderByDescending(x => x.IssuedAt)
|
||||
.Select(x => new { x.Id, x.DocumentNumber })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (recentDocument is not null)
|
||||
return Ok(new
|
||||
{
|
||||
recentDocument.Id,
|
||||
recentDocument.DocumentNumber,
|
||||
Reused = true
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var document = await service.CreateAsync(
|
||||
studentId.Value,
|
||||
request.Type,
|
||||
purpose,
|
||||
userId,
|
||||
$"{options.Value.IssuingOffice}自助签发",
|
||||
VerificationBaseUrl(),
|
||||
null,
|
||||
cancellationToken);
|
||||
db.OfficialDocuments.Add(document);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return CreatedAtAction(nameof(Download), new { id = document.Id },
|
||||
new { document.Id, document.DocumentNumber, Reused = false });
|
||||
}
|
||||
catch (OfficialDocumentValidationException exception)
|
||||
{
|
||||
return ConflictProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/download")]
|
||||
[Authorize(Roles = Readers)]
|
||||
public async Task<IActionResult> Download(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await AccessibleDocuments()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (document is null) return NotFound();
|
||||
if (document.Status != OfficialDocumentStatus.Valid)
|
||||
return ConflictProblem("该凭证已经失效或被新凭证替代,不能继续下载。");
|
||||
|
||||
db.OfficialDocumentDownloads.Add(new OfficialDocumentDownload
|
||||
{
|
||||
OfficialDocumentId = document.Id,
|
||||
DownloadedByUserId = currentUserDataScope.Current.UserId,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
UserAgent = Request.Headers.UserAgent.ToString()
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return File(document.PdfContent, "application/pdf", $"{document.DocumentNumber}.pdf");
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/downloads")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> DownloadHistory(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
|
||||
return NotFound();
|
||||
|
||||
return Ok(await db.OfficialDocumentDownloads.AsNoTracking()
|
||||
.Where(x => x.OfficialDocumentId == id)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.CreatedAt,
|
||||
x.DownloadedByUserId,
|
||||
DownloadedByName = x.DownloadedByUser!.DisplayName,
|
||||
x.IpAddress,
|
||||
x.UserAgent
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/invalidate")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Invalidate(
|
||||
Guid id,
|
||||
InvalidateOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await AccessibleDocuments()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (document is null) return NotFound();
|
||||
if (document.Status != OfficialDocumentStatus.Valid)
|
||||
return ConflictProblem("该凭证当前已不是有效状态。");
|
||||
|
||||
document.Status = OfficialDocumentStatus.Invalidated;
|
||||
document.InvalidatedAt = DateTime.UtcNow;
|
||||
document.InvalidatedByUserId = currentUserDataScope.Current.UserId;
|
||||
document.InvalidationReason = request.Reason.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { document.Id, document.Status, document.InvalidatedAt });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reissue")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Reissue(
|
||||
Guid id,
|
||||
ReissueOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newDocument = await db.ExecuteInRetriableTransactionAsync(async transaction =>
|
||||
{
|
||||
var original = await AccessibleDocuments()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
|
||||
?? throw new OfficialDocumentNotFoundException();
|
||||
if (await db.OfficialDocuments.AnyAsync(
|
||||
x => x.ReissuedFromDocumentId == original.Id,
|
||||
cancellationToken))
|
||||
throw new OfficialDocumentValidationException("该凭证已经重签过,不能重复重签。");
|
||||
|
||||
var replacement = await service.CreateAsync(
|
||||
original.StudentId,
|
||||
original.Type,
|
||||
request.Purpose ?? original.Purpose,
|
||||
currentUserDataScope.Current.UserId,
|
||||
currentUserDataScope.Current.DisplayName ?? "教务管理员",
|
||||
VerificationBaseUrl(),
|
||||
original.Id,
|
||||
cancellationToken);
|
||||
original.Status = OfficialDocumentStatus.Superseded;
|
||||
original.InvalidatedAt = DateTime.UtcNow;
|
||||
original.InvalidatedByUserId = currentUserDataScope.Current.UserId;
|
||||
original.InvalidationReason = request.Reason.Trim();
|
||||
db.OfficialDocuments.Add(replacement);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return replacement;
|
||||
}, cancellationToken);
|
||||
|
||||
return Ok(new { newDocument.Id, newDocument.DocumentNumber });
|
||||
}
|
||||
catch (OfficialDocumentNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (OfficialDocumentValidationException exception)
|
||||
{
|
||||
return ConflictProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("verify/{verificationCode}")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("official-verification")]
|
||||
public async Task<ActionResult> Verify(
|
||||
string verificationCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = verificationCode.Trim();
|
||||
if (normalized.Length is < 12 or > 128)
|
||||
return Ok(new { Found = false, Valid = false });
|
||||
|
||||
var hash = OfficialDocumentService.HashVerificationCode(normalized);
|
||||
var documentNumber = normalized.ToUpperInvariant();
|
||||
var document = await db.OfficialDocuments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.VerificationCodeHash == hash ||
|
||||
x.DocumentNumber == documentNumber,
|
||||
cancellationToken);
|
||||
if (document is null) return Ok(new { Found = false, Valid = false });
|
||||
|
||||
var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson);
|
||||
var replacementNumber = await db.OfficialDocuments.AsNoTracking()
|
||||
.Where(x => x.ReissuedFromDocumentId == document.Id)
|
||||
.Select(x => x.DocumentNumber)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Found = true,
|
||||
Valid = document.Status == OfficialDocumentStatus.Valid,
|
||||
document.Status,
|
||||
document.DocumentNumber,
|
||||
document.Type,
|
||||
snapshot.InstitutionName,
|
||||
StudentName = MaskName(snapshot.Student.Name),
|
||||
StudentNumber = MaskStudentNumber(snapshot.Student.StudentNumber),
|
||||
snapshot.Student.CollegeName,
|
||||
snapshot.Student.MajorName,
|
||||
document.IssuedAt,
|
||||
document.InvalidatedAt,
|
||||
ReplacementDocumentNumber = replacementNumber,
|
||||
PdfSha256 = document.PdfSha256
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<OfficialDocument> AccessibleDocuments()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var source = db.OfficialDocuments.AsQueryable();
|
||||
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||
scope.IsInRole(SystemRoles.CollegeAdmin);
|
||||
if (scope.IsInRole(SystemRoles.Student) && !isManager)
|
||||
return source.Where(x => x.Student!.UserId == scope.UserId);
|
||||
if (scope.RestrictedCollegeId.HasValue)
|
||||
return source.Where(x =>
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
scope.RestrictedCollegeId.Value);
|
||||
return source;
|
||||
}
|
||||
|
||||
private IQueryable<Student> AccessibleStudents()
|
||||
{
|
||||
var source = db.Students.AsQueryable();
|
||||
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
|
||||
return collegeId.HasValue
|
||||
? source.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId.Value)
|
||||
: source;
|
||||
}
|
||||
|
||||
private string VerificationBaseUrl()
|
||||
{
|
||||
var configured = options.Value.PublicBaseUrl;
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.TrimEnd('/');
|
||||
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
}
|
||||
|
||||
private static string MaskName(string name) => name.Length switch
|
||||
{
|
||||
0 => "*",
|
||||
1 => name,
|
||||
2 => name[0] + "*",
|
||||
_ => name[0] + new string('*', name.Length - 2) + name[^1]
|
||||
};
|
||||
|
||||
private static string MaskStudentNumber(string number) => number.Length <= 4
|
||||
? new string('*', number.Length)
|
||||
: number[..2] + new string('*', number.Length - 4) + number[^2..];
|
||||
|
||||
private ConflictObjectResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "当前状态不允许此操作",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record IssueOfficialDocumentRequest(
|
||||
Guid StudentId,
|
||||
OfficialDocumentType Type,
|
||||
[param: StringLength(200)] string? Purpose);
|
||||
|
||||
public sealed record SelfIssueOfficialDocumentRequest(
|
||||
OfficialDocumentType Type,
|
||||
[param: StringLength(200)] string? Purpose);
|
||||
|
||||
public sealed record InvalidateOfficialDocumentRequest(
|
||||
[param: Required, StringLength(500, MinimumLength = 2)] string Reason);
|
||||
|
||||
public sealed record ReissueOfficialDocumentRequest(
|
||||
[param: Required, StringLength(500, MinimumLength = 2)] string Reason,
|
||||
[param: StringLength(200)] string? Purpose);
|
||||
|
||||
file sealed class OfficialDocumentNotFoundException : Exception;
|
||||
@@ -0,0 +1,519 @@
|
||||
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.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,
|
||||
OperationsOptions options) : ControllerBase
|
||||
{
|
||||
[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));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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",
|
||||
_ => 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);
|
||||
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class PersonnelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
UserManager<ApplicationUser> userManager) : ControllerBase
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ReadRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -209,6 +211,9 @@ public sealed class PersonnelController(
|
||||
teacher.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(new { user.Id, UserName = userName });
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -429,6 +434,12 @@ public sealed class PersonnelController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Analytics,
|
||||
cancellationToken);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -442,6 +453,12 @@ public sealed class PersonnelController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Analytics,
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -14,7 +15,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/personnel")]
|
||||
public sealed class PersonnelExcelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ReadRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -142,6 +144,12 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Analytics,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -11,7 +12,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||
[Route("api/schedules")]
|
||||
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet("time-slots")]
|
||||
public async Task<ActionResult> GetTimeSlots(
|
||||
@@ -65,6 +66,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
IsEnabled = request.IsEnabled
|
||||
}));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -15,9 +16,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Authorize(Roles = ManagementRoles)]
|
||||
[Route("api/schedules")]
|
||||
public sealed class SchedulesController(
|
||||
AppDbContext db,
|
||||
AutomaticScheduleJobQueue automaticScheduleJobQueue,
|
||||
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
|
||||
AppDbContext db) : ControllerBase
|
||||
{
|
||||
private const string ManagementRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -241,6 +240,10 @@ public sealed class SchedulesController(
|
||||
CurrentStep = "等待后台检查"
|
||||
};
|
||||
db.SchedulePublishJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -263,7 +266,6 @@ public sealed class SchedulesController(
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
schedulePublishJobQueue.Enqueue(job.Id);
|
||||
return AcceptedAtAction(
|
||||
nameof(GetSchedulePublishJob),
|
||||
new { jobId = job.Id },
|
||||
@@ -321,6 +323,10 @@ public sealed class SchedulesController(
|
||||
RequestedByUserId = requestedByUserId
|
||||
};
|
||||
db.AutomaticScheduleJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -339,7 +345,6 @@ public sealed class SchedulesController(
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
automaticScheduleJobQueue.Enqueue(job.Id);
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = job.Id },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/statistics")]
|
||||
public sealed class StatisticsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache appCache) : ControllerBase
|
||||
{
|
||||
private const string ViewerRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -34,14 +37,49 @@ public sealed class StatisticsController(
|
||||
// ── 1. Student Statistics ────────────────────────────────────────
|
||||
|
||||
[HttpGet("students/summary")]
|
||||
public async Task<ActionResult<object>> GetStudentSummary(
|
||||
public Task<ActionResult<object>> GetStudentSummary(
|
||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
int? grade, int? enrollmentYear,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetStudentSummaryCore(
|
||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
int? grade, int? enrollmentYear,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"students",
|
||||
effectiveCollegeId,
|
||||
KeyPart(majorId),
|
||||
KeyPart(classId),
|
||||
KeyPart(grade),
|
||||
KeyPart(enrollmentYear)),
|
||||
token => GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var baseQuery = db.Students.AsNoTracking()
|
||||
.Where(s => effectiveCollegeId == null ||
|
||||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
||||
@@ -112,8 +150,16 @@ public sealed class StatisticsController(
|
||||
int? grade, int? enrollmentYear,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("学生统计", new()
|
||||
{
|
||||
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
@@ -128,13 +174,41 @@ public sealed class StatisticsController(
|
||||
// ── 2. Course Statistics ─────────────────────────────────────────
|
||||
|
||||
[HttpGet("courses/summary")]
|
||||
public async Task<ActionResult<object>> GetCourseSummary(
|
||||
public Task<ActionResult<object>> GetCourseSummary(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetCourseSummaryCore(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"courses",
|
||||
effectiveCollegeId,
|
||||
KeyPart(categoryId),
|
||||
KeyPart(nature)),
|
||||
token => GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var baseQuery = db.Courses.AsNoTracking()
|
||||
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
||||
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
||||
@@ -184,8 +258,14 @@ public sealed class StatisticsController(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("课程统计", new()
|
||||
{
|
||||
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
@@ -199,13 +279,46 @@ public sealed class StatisticsController(
|
||||
// ── 3. Grade Statistics ──────────────────────────────────────────
|
||||
|
||||
[HttpGet("grades/summary")]
|
||||
public async Task<ActionResult<object>> GetGradeSummary(
|
||||
public Task<ActionResult<object>> GetGradeSummary(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, CancellationToken cancellationToken)
|
||||
Guid? courseId, CancellationToken cancellationToken) =>
|
||||
GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetGradeSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, bool useCache, CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"grades",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(majorId),
|
||||
KeyPart(classId),
|
||||
KeyPart(courseId)),
|
||||
token => GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||
@@ -302,8 +415,16 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("成绩统计", new()
|
||||
{
|
||||
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
||||
@@ -317,13 +438,41 @@ public sealed class StatisticsController(
|
||||
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
||||
|
||||
[HttpGet("pass-rates/summary")]
|
||||
public async Task<ActionResult<object>> GetPassRateSummary(
|
||||
public Task<ActionResult<object>> GetPassRateSummary(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetPassRateSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"pass-rates",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(courseId)),
|
||||
token => GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||
@@ -425,8 +574,14 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("通过率统计", new()
|
||||
{
|
||||
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
||||
@@ -439,13 +594,38 @@ public sealed class StatisticsController(
|
||||
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
||||
|
||||
[HttpGet("teacher-workload/summary")]
|
||||
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetTeacherWorkloadSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"teacher-workload",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId)),
|
||||
token => GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
||||
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
||||
@@ -524,8 +704,13 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("教师工作量统计", new()
|
||||
{
|
||||
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
|
||||
@@ -537,10 +722,39 @@ public sealed class StatisticsController(
|
||||
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
||||
|
||||
[HttpGet("classroom-utilization/summary")]
|
||||
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetClassroomUtilizationSummaryCore(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"classroom-utilization",
|
||||
RestrictedCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(buildingId),
|
||||
KeyPart(campusId)),
|
||||
token => GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Find published schedule plan
|
||||
var planQuery = db.SchedulePlans.AsNoTracking()
|
||||
.Where(p => p.Status == SchedulePlanStatus.Published);
|
||||
@@ -713,8 +927,14 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("教室利用率统计", new()
|
||||
{
|
||||
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
||||
@@ -727,6 +947,54 @@ public sealed class StatisticsController(
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ActionResult<object>> GetCachedSummaryAsync(
|
||||
string key,
|
||||
Func<CancellationToken, Task<ActionResult<object>>> factory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await appCache.GetOrCreateAsync(
|
||||
key,
|
||||
async token =>
|
||||
{
|
||||
var source = await factory(token);
|
||||
if (source.Result is not null || source.Value is null)
|
||||
throw new InvalidOperationException(
|
||||
"Statistics cache source did not return a successful value.");
|
||||
|
||||
return JsonSerializer.SerializeToElement(
|
||||
source.Value,
|
||||
source.Value.GetType());
|
||||
},
|
||||
AppCacheProfile.Analytics,
|
||||
[
|
||||
AppCacheTags.Analytics,
|
||||
AppCacheTags.BaseData,
|
||||
AppCacheTags.Timetables
|
||||
],
|
||||
cancellationToken);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string StatisticsKey(
|
||||
string area,
|
||||
Guid? effectiveCollegeId,
|
||||
params string?[] filters) =>
|
||||
AppCacheKeys.Statistics(
|
||||
area,
|
||||
currentUserDataScope.Current.Scope.ToString(),
|
||||
effectiveCollegeId,
|
||||
filters);
|
||||
|
||||
private static string KeyPart(Guid? value) =>
|
||||
value?.ToString("N") ?? "-";
|
||||
|
||||
private static string KeyPart(int? value) =>
|
||||
value?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-";
|
||||
|
||||
private static string KeyPart<TEnum>(TEnum? value)
|
||||
where TEnum : struct, Enum =>
|
||||
value?.ToString() ?? "-";
|
||||
|
||||
private FileContentResult ExportSummary(
|
||||
string title,
|
||||
Dictionary<string, IEnumerable<object?[]>> sheets)
|
||||
|
||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/teaching-tasks")]
|
||||
public sealed class TeachingTasksController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ManagementRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -389,6 +391,7 @@ public sealed class TeachingTasksController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return Ok(new { AffectedCount = tasks.Count });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -544,6 +547,9 @@ public sealed class TeachingTasksController(
|
||||
db.TeachingTasks.AddRange(created);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
BatchCode = batchCode,
|
||||
@@ -737,6 +743,7 @@ public sealed class TeachingTasksController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -209,7 +209,9 @@ public sealed class TimetableManagementController(
|
||||
|
||||
[ApiController]
|
||||
[Route("api/timetables")]
|
||||
public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
public sealed class FreeClassroomsController(
|
||||
AppDbContext db,
|
||||
ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase
|
||||
{
|
||||
[HttpGet("free-classrooms/options")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
@@ -297,9 +299,9 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var term = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.Id == academicTermId && x.IsEnabled)
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == academicTermId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (term is null) return NotFound();
|
||||
var plan = await db.SchedulePlans.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -329,33 +331,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
if (activePeriodCount != requestedPeriods.Length)
|
||||
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
||||
|
||||
var candidates = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlanId == plan.Id &&
|
||||
x.ClassroomId.HasValue &&
|
||||
x.DayOfWeek == dayOfWeek &&
|
||||
x.StartWeek <= week &&
|
||||
x.EndWeek >= week &&
|
||||
x.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < x.StartPeriod + x.PeriodCount)
|
||||
.Select(x => new
|
||||
{
|
||||
x.ClassroomId,
|
||||
x.WeekPattern,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var occupiedIds = candidates
|
||||
.Where(x =>
|
||||
FreeClassroomRules.MatchesWeek(x.WeekPattern, week) &&
|
||||
FreeClassroomRules.PeriodsOverlap(
|
||||
startPeriod,
|
||||
periodCount,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount))
|
||||
.Select(x => x.ClassroomId.GetValueOrDefault())
|
||||
.ToHashSet();
|
||||
var reservationDate = ResolveReservationDate(term, week, dayOfWeek);
|
||||
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||
return ValidationProblem("所选周次和星期不在学期日期范围内。");
|
||||
var occupiedIds =
|
||||
await reservationAvailability.GetOccupiedClassroomIdsAsync(
|
||||
term,
|
||||
reservationDate,
|
||||
startPeriod,
|
||||
periodCount,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
var rooms = db.Classrooms.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -397,6 +383,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
private static DateOnly ResolveReservationDate(
|
||||
AcademicTerm term,
|
||||
int week,
|
||||
int dayOfWeek)
|
||||
{
|
||||
var startDay = (int)term.StartDate.DayOfWeek;
|
||||
var daysSinceMonday = (startDay + 6) % 7;
|
||||
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
|
||||
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record FreeClassroomTimeSlotDto(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
@@ -14,74 +16,25 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/timetables")]
|
||||
public sealed class TimetablesController(
|
||||
AppDbContext db,
|
||||
TimetableDataService timetableDataService) : ControllerBase
|
||||
TimetableDataService timetableDataService,
|
||||
PersonalCalendarService personalCalendarService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet("options")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
|
||||
{
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicYear,
|
||||
x.Season,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeId = x.Major.CollegeId,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
HasPublishedTimetable = defaultTermId.HasValue &&
|
||||
(db.ScheduleEntries.Any(entry =>
|
||||
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Terms = terms, Colleges = colleges, Majors = majors, Classes = classes });
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.TimetableOptions,
|
||||
LoadOptionsAsync,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[
|
||||
AppCacheTags.BaseData,
|
||||
AppCacheTags.Timetables,
|
||||
AppCacheTags.TimetableOptions
|
||||
],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("classes/{classId:guid}")]
|
||||
@@ -99,14 +52,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacherId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
@@ -118,14 +67,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacherId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
return ExcelFile(result);
|
||||
@@ -138,14 +83,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
return ExcelFile(result);
|
||||
@@ -262,6 +203,101 @@ public sealed class TimetablesController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> EnableMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
{
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("mine/calendar-subscription/rotate")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> RotateMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpDelete("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> DisableMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
user.CalendarSubscriptionStamp = null;
|
||||
user.CalendarSubscriptionCreatedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("mine/calendar.ics")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> DownloadMyCalendar(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
||||
if (result is null) return CalendarProfileNotFound();
|
||||
return File(
|
||||
result.Content,
|
||||
"text/calendar; charset=utf-8",
|
||||
$"{SafeFileName(user.DisplayName)}-个人教学日历.ics");
|
||||
}
|
||||
|
||||
[HttpGet("calendar/{userId:guid}/{accessToken}.ics")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetCalendarFeed(
|
||||
Guid userId,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await db.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
if (user is null ||
|
||||
!personalCalendarService.IsAccessTokenValid(user, accessToken))
|
||||
return NotFound();
|
||||
|
||||
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
var etag = $"\"{Convert.ToHexString(SHA256.HashData(result.Content))}\"";
|
||||
if (Request.Headers.IfNoneMatch.ToString()
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Contains(etag, StringComparer.Ordinal))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
Response.Headers.CacheControl = "private, max-age=300";
|
||||
Response.Headers.ETag = etag;
|
||||
Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
||||
return File(result.Content, "text/calendar; charset=utf-8");
|
||||
}
|
||||
|
||||
private async Task<ActionResult> BuildTimetableAsync(
|
||||
Guid classId,
|
||||
Guid? academicTermId,
|
||||
@@ -269,18 +305,111 @@ public sealed class TimetablesController(
|
||||
CancellationToken cancellationToken,
|
||||
TimetableStudentDto? student = null)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
studentId,
|
||||
student,
|
||||
cancellationToken);
|
||||
var result = studentId.HasValue || student is not null
|
||||
? await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
studentId,
|
||||
student,
|
||||
cancellationToken)
|
||||
: await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
private Task<TimetableData?> GetPublishedTimetableAsync(
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken) =>
|
||||
cache.GetOrCreateAsync(
|
||||
AppCacheKeys.PublishedTimetable(
|
||||
resourceType.ToString().ToLowerInvariant(),
|
||||
resourceId,
|
||||
academicTermId),
|
||||
token => timetableDataService.BuildAsync(
|
||||
resourceType,
|
||||
resourceId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
token),
|
||||
AppCacheProfile.PublishedTimetable,
|
||||
[AppCacheTags.BaseData, AppCacheTags.Timetables],
|
||||
cancellationToken);
|
||||
|
||||
private async Task<TimetableOptionsResponse> LoadOptionsAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.Select(x => new TimetableTermOption(
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicYear,
|
||||
x.Season,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)))
|
||||
.ToListAsync(cancellationToken);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new TimetableClassOption(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
x.MajorId,
|
||||
x.Major!.Name,
|
||||
x.Major.CollegeId,
|
||||
x.Major.College!.Name,
|
||||
defaultTermId.HasValue &&
|
||||
(db.ScheduleEntries.Any(entry =>
|
||||
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))))
|
||||
.ToListAsync(cancellationToken);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new TimetableCollegeOption(x.Id, x.Code, x.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new TimetableMajorOption(
|
||||
x.Id, x.Code, x.Name, x.CollegeId))
|
||||
.ToListAsync(cancellationToken);
|
||||
return new TimetableOptionsResponse(terms, colleges, majors, classes);
|
||||
}
|
||||
|
||||
private ActionResult ExcelFile(TimetableData result)
|
||||
{
|
||||
var bytes = TimetableExcelExporter.Create(result);
|
||||
@@ -295,4 +424,89 @@ public sealed class TimetablesController(
|
||||
value = value.Replace(character, '-');
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser?> CurrentUserAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||
return null;
|
||||
return await db.Users.FirstOrDefaultAsync(
|
||||
x => x.Id == userId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private PersonalCalendarSubscriptionResponse BuildCalendarSubscriptionResponse(
|
||||
ApplicationUser user)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
return new(false, null, null, null, null);
|
||||
|
||||
var accessToken = personalCalendarService.CreateAccessToken(user);
|
||||
var feedPath =
|
||||
$"timetables/calendar/{user.Id:N}/{accessToken}.ics";
|
||||
var feedUrl =
|
||||
$"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
|
||||
$"/api/{feedPath}";
|
||||
var webcalUrl = feedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
? $"webcal://{feedUrl[8..]}"
|
||||
: feedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
? $"webcal://{feedUrl[7..]}"
|
||||
: feedUrl;
|
||||
return new(
|
||||
true,
|
||||
feedUrl,
|
||||
feedPath,
|
||||
webcalUrl,
|
||||
user.CalendarSubscriptionCreatedAt);
|
||||
}
|
||||
|
||||
private ActionResult CalendarProfileNotFound() => Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "档案未关联",
|
||||
Detail = "当前登录账号没有关联有效的教师或学生档案,请联系教务管理员。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record PersonalCalendarSubscriptionResponse(
|
||||
bool IsEnabled,
|
||||
string? FeedUrl,
|
||||
string? FeedPath,
|
||||
string? WebcalUrl,
|
||||
DateTime? CreatedAt);
|
||||
|
||||
public sealed record TimetableOptionsResponse(
|
||||
IReadOnlyList<TimetableTermOption> Terms,
|
||||
IReadOnlyList<TimetableCollegeOption> Colleges,
|
||||
IReadOnlyList<TimetableMajorOption> Majors,
|
||||
IReadOnlyList<TimetableClassOption> Classes);
|
||||
|
||||
public sealed record TimetableTermOption(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string AcademicYear,
|
||||
TermSeason Season,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
bool IsCurrent,
|
||||
bool IsArchived,
|
||||
bool HasPublishedTimetable);
|
||||
|
||||
public sealed record TimetableCollegeOption(Guid Id, string Code, string Name);
|
||||
|
||||
public sealed record TimetableMajorOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId);
|
||||
|
||||
public sealed record TimetableClassOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Grade,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
Guid CollegeId,
|
||||
string CollegeName,
|
||||
bool HasPublishedTimetable);
|
||||
|
||||
@@ -92,12 +92,12 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
var si = studentInfos.First(s => s.Id == w.StudentId);
|
||||
var r = rules.First(r => r.Type == w.Type);
|
||||
if (r.NotifyStudent && si.UserId.HasValue)
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
||||
if (r.NotifyCounselor)
|
||||
{
|
||||
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||
if (counselorId != default)
|
||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ClassroomReservation : EntityBase
|
||||
{
|
||||
public Guid ApplicantUserId { get; set; }
|
||||
public ApplicationUser? ApplicantUser { get; set; }
|
||||
public required string ApplicantName { get; set; }
|
||||
public Guid ApplicantCollegeId { get; set; }
|
||||
public College? ApplicantCollege { get; set; }
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public AcademicTerm? AcademicTerm { get; set; }
|
||||
public Guid ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public DateOnly ReservationDate { get; set; }
|
||||
public int StartPeriod { get; set; }
|
||||
public int PeriodCount { get; set; }
|
||||
public int AttendeeCount { get; set; }
|
||||
public required string Purpose { get; set; }
|
||||
public required string ContactPhone { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public ClassroomReservationStatus Status { get; set; } =
|
||||
ClassroomReservationStatus.Submitted;
|
||||
public Guid? ReviewedByUserId { get; set; }
|
||||
public ApplicationUser? ReviewedByUser { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public string? ReviewComment { get; set; }
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ClassroomReservationStatus
|
||||
{
|
||||
Submitted = 1,
|
||||
Approved = 2,
|
||||
Rejected = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
@@ -10,7 +10,15 @@ public sealed class CourseAdjustment : EntityBase
|
||||
public CourseAdjustmentStatus Status { get; set; } = CourseAdjustmentStatus.Draft;
|
||||
public Guid ApplicantUserId { get; set; }
|
||||
|
||||
// For Reschedule / Makeup
|
||||
// The concrete published timetable occurrence being adjusted.
|
||||
public Guid? SourceScheduleEntryId { get; set; }
|
||||
public int? SourceWeek { get; set; }
|
||||
public DateOnly? SourceDate { get; set; }
|
||||
public int? SourceStartPeriod { get; set; }
|
||||
public int? SourcePeriodCount { get; set; }
|
||||
public Guid? SourceClassroomId { get; set; }
|
||||
|
||||
// Target occurrence for Reschedule / Makeup
|
||||
public DateOnly? TargetDate { get; set; }
|
||||
public int? DayOfWeek { get; set; }
|
||||
public int? StartPeriod { get; set; }
|
||||
|
||||
@@ -11,12 +11,21 @@ public sealed class CourseSelectionRound : EntityBase
|
||||
public DateTime EndsAt { get; set; }
|
||||
public DateTime WithdrawalEndsAt { get; set; }
|
||||
public decimal MaxCredits { get; set; } = 30;
|
||||
public int? MaxCourseCount { get; set; }
|
||||
public CourseSelectionRoundStatus Status { get; set; } =
|
||||
CourseSelectionRoundStatus.Draft;
|
||||
public string? Notes { get; set; }
|
||||
public ICollection<CourseSelectionRoundGrade> EligibleGrades { get; set; } = [];
|
||||
public ICollection<CourseSelectionOffering> Offerings { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CourseSelectionRoundGrade : EntityBase
|
||||
{
|
||||
public Guid CourseSelectionRoundId { get; set; }
|
||||
public CourseSelectionRound? CourseSelectionRound { get; set; }
|
||||
public int Grade { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CourseSelectionOffering : EntityBase
|
||||
{
|
||||
public Guid CourseSelectionRoundId { get; set; }
|
||||
@@ -38,6 +47,7 @@ public sealed class CourseEnrollment : EntityBase
|
||||
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
|
||||
public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal;
|
||||
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? WaitlistedAt { get; set; }
|
||||
public DateTime? WithdrawnAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -51,7 +61,10 @@ public enum CourseSelectionRoundStatus
|
||||
public enum CourseEnrollmentStatus
|
||||
{
|
||||
Enrolled = 1,
|
||||
Withdrawn = 2
|
||||
Withdrawn = 2,
|
||||
Waitlisted = 3,
|
||||
Cancelled = 4,
|
||||
Expired = 5
|
||||
}
|
||||
|
||||
public enum EnrollmentType
|
||||
|
||||
@@ -7,6 +7,43 @@ public sealed class Notification : EntityBase
|
||||
public Guid UserId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public NotificationCategory Category { get; set; } = NotificationCategory.General;
|
||||
public bool IsRead { get; set; }
|
||||
public string? LinkUrl { get; set; }
|
||||
public Guid? MessageDispatchId { get; set; }
|
||||
public MessageDispatch? MessageDispatch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MessageDispatch : EntityBase
|
||||
{
|
||||
public Guid SenderUserId { get; set; }
|
||||
public required string SenderName { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public NotificationCategory Category { get; set; } = NotificationCategory.General;
|
||||
public MessageAudienceType AudienceType { get; set; }
|
||||
public Guid? AudienceId { get; set; }
|
||||
public required string AudienceName { get; set; }
|
||||
public int RecipientCount { get; set; }
|
||||
public string? LinkUrl { get; set; }
|
||||
public ICollection<Notification> Notifications { get; set; } = [];
|
||||
}
|
||||
|
||||
public enum NotificationCategory
|
||||
{
|
||||
General = 1,
|
||||
Approval = 2,
|
||||
Schedule = 3,
|
||||
Grade = 4,
|
||||
Attendance = 5,
|
||||
CourseSelection = 6,
|
||||
Warning = 7
|
||||
}
|
||||
|
||||
public enum MessageAudienceType
|
||||
{
|
||||
School = 1,
|
||||
College = 2,
|
||||
TeachingTask = 3,
|
||||
Custom = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class OfficialDocument : EntityBase
|
||||
{
|
||||
public required string DocumentNumber { get; set; }
|
||||
public required string VerificationCodeHash { get; set; }
|
||||
public OfficialDocumentType Type { get; set; }
|
||||
public OfficialDocumentStatus Status { get; set; } = OfficialDocumentStatus.Valid;
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public Guid IssuedByUserId { get; set; }
|
||||
public ApplicationUser? IssuedByUser { get; set; }
|
||||
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? Purpose { get; set; }
|
||||
public required string SnapshotJson { get; set; }
|
||||
public required byte[] PdfContent { get; set; }
|
||||
public required string PdfSha256 { get; set; }
|
||||
public DateTime? InvalidatedAt { get; set; }
|
||||
public Guid? InvalidatedByUserId { get; set; }
|
||||
public ApplicationUser? InvalidatedByUser { get; set; }
|
||||
public string? InvalidationReason { get; set; }
|
||||
public Guid? ReissuedFromDocumentId { get; set; }
|
||||
public OfficialDocument? ReissuedFromDocument { get; set; }
|
||||
public ICollection<OfficialDocumentDownload> Downloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class OfficialDocumentDownload : EntityBase
|
||||
{
|
||||
public Guid OfficialDocumentId { get; set; }
|
||||
public OfficialDocument? OfficialDocument { get; set; }
|
||||
public Guid DownloadedByUserId { get; set; }
|
||||
public ApplicationUser? DownloadedByUser { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
}
|
||||
|
||||
public enum OfficialDocumentType
|
||||
{
|
||||
Transcript = 1,
|
||||
StudentStatusCertificate = 2
|
||||
}
|
||||
|
||||
public enum OfficialDocumentStatus
|
||||
{
|
||||
Valid = 1,
|
||||
Invalidated = 2,
|
||||
Superseded = 3
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class ApplicationUser : IdentityUser<Guid>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public string? CalendarSubscriptionStamp { get; set; }
|
||||
public DateTime? CalendarSubscriptionCreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ApplicationRole : IdentityRole<Guid>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.System;
|
||||
|
||||
public sealed class BackgroundJobOutboxMessage : EntityBase
|
||||
{
|
||||
public BackgroundJobKind JobKind { get; set; }
|
||||
public Guid JobId { get; set; }
|
||||
public BackgroundJobOutboxState State { get; set; } =
|
||||
BackgroundJobOutboxState.Pending;
|
||||
public int PublishAttempts { get; set; }
|
||||
public int ProcessingAttempts { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public Guid? ProcessingToken { get; set; }
|
||||
public DateTime? LeaseExpiresAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
|
||||
public static BackgroundJobOutboxMessage Create(
|
||||
BackgroundJobKind jobKind,
|
||||
Guid jobId) =>
|
||||
new()
|
||||
{
|
||||
JobKind = jobKind,
|
||||
JobId = jobId
|
||||
};
|
||||
}
|
||||
|
||||
public enum BackgroundJobKind
|
||||
{
|
||||
AutomaticSchedule = 1,
|
||||
SchedulePublish = 2,
|
||||
MakeupExamAuto = 3
|
||||
}
|
||||
|
||||
public enum BackgroundJobOutboxState
|
||||
{
|
||||
Pending = 1,
|
||||
Publishing = 2,
|
||||
Published = 3,
|
||||
Processing = 4,
|
||||
Completed = 5
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed record BackgroundJobBacklogSnapshot(
|
||||
int Pending,
|
||||
int Publishing,
|
||||
int Published,
|
||||
int Processing,
|
||||
int ExpiredLeases,
|
||||
DateTime? OldestUnfinishedAt,
|
||||
double? OldestUnfinishedAgeSeconds);
|
||||
|
||||
public sealed class BackgroundJobMonitoringService(AppDbContext db)
|
||||
{
|
||||
public async Task<BackgroundJobBacklogSnapshot> GetSnapshotAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.State != BackgroundJobOutboxState.Completed)
|
||||
.GroupBy(x => x.State)
|
||||
.Select(group => new { State = group.Key, Count = group.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
var countByState = counts.ToDictionary(x => x.State, x => x.Count);
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredLeases = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => (x.State == BackgroundJobOutboxState.Publishing ||
|
||||
x.State == BackgroundJobOutboxState.Processing) &&
|
||||
x.LeaseExpiresAt != null &&
|
||||
x.LeaseExpiresAt < now,
|
||||
cancellationToken);
|
||||
var oldestUnfinishedAt = await db.BackgroundJobOutboxMessages
|
||||
.AsNoTracking()
|
||||
.Where(x => x.State != BackgroundJobOutboxState.Completed)
|
||||
.Select(x => (DateTime?)x.CreatedAt)
|
||||
.MinAsync(cancellationToken);
|
||||
double? ageSeconds = oldestUnfinishedAt.HasValue
|
||||
? Math.Max(0, (now - oldestUnfinishedAt.Value).TotalSeconds)
|
||||
: null;
|
||||
|
||||
return new BackgroundJobBacklogSnapshot(
|
||||
GetCount(BackgroundJobOutboxState.Pending),
|
||||
GetCount(BackgroundJobOutboxState.Publishing),
|
||||
GetCount(BackgroundJobOutboxState.Published),
|
||||
GetCount(BackgroundJobOutboxState.Processing),
|
||||
expiredLeases,
|
||||
oldestUnfinishedAt,
|
||||
ageSeconds);
|
||||
|
||||
int GetCount(BackgroundJobOutboxState state) =>
|
||||
countByState.GetValueOrDefault(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class BackgroundJobOptions
|
||||
{
|
||||
public const string SectionName = "BackgroundJobs";
|
||||
|
||||
public string Transport { get; set; } = "InMemory";
|
||||
public int PollIntervalMilliseconds { get; set; } = 500;
|
||||
public int LeaseSeconds { get; set; } = 120;
|
||||
public ushort PrefetchCount { get; set; } = 1;
|
||||
public int AutomaticScheduleConcurrency { get; set; } = 1;
|
||||
public int SchedulePublishConcurrency { get; set; } = 1;
|
||||
public int MakeupExamAutoConcurrency { 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;
|
||||
public int ProcessingAttemptLimit { get; set; } = 5;
|
||||
public int MaintenanceIntervalSeconds { get; set; } = 60;
|
||||
public int CompletedRetentionDays { get; set; } = 14;
|
||||
public int CleanupBatchSize { get; set; } = 500;
|
||||
|
||||
public bool UsesRabbitMq =>
|
||||
Transport.Equals("RabbitMq", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int ConsumerConcurrency(BackgroundJobKind kind) => kind switch
|
||||
{
|
||||
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
|
||||
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
|
||||
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class RabbitMqOptions
|
||||
{
|
||||
public const string SectionName = "RabbitMq";
|
||||
|
||||
public string HostName { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 5672;
|
||||
public string UserName { get; set; } = "guest";
|
||||
public string Password { get; set; } = "guest";
|
||||
public string VirtualHost { get; set; } = "/";
|
||||
public bool UseTls { get; set; }
|
||||
public string? TlsServerName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Diagnostics;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class BackgroundJobOutboxPublisher(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IBackgroundJobTransport transport,
|
||||
BackgroundJobOptions options,
|
||||
BackgroundJobTelemetry telemetry,
|
||||
ILogger<BackgroundJobOutboxPublisher> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await PrepareForStartupAsync(stoppingToken);
|
||||
var nextMaintenanceAt = DateTime.MinValue;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.UtcNow >= nextMaintenanceAt)
|
||||
{
|
||||
await RecoverExpiredProcessingAsync(stoppingToken);
|
||||
await CleanupCompletedAsync(stoppingToken);
|
||||
nextMaintenanceAt = DateTime.UtcNow.AddSeconds(
|
||||
options.MaintenanceIntervalSeconds);
|
||||
}
|
||||
|
||||
var claimed = await ClaimNextAsync(stoppingToken);
|
||||
if (claimed is null)
|
||||
{
|
||||
await Task.Delay(options.PollIntervalMilliseconds, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
await PublishClaimedAsync(claimed.Value.Message, claimed.Value.Token,
|
||||
stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Background job outbox publishing failed.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrepareForStartupAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
if (!transport.IsDurable)
|
||||
{
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x => x.State != BackgroundJobOutboxState.Completed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Pending)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await AddMissingOutboxMessagesAsync(db, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddMissingOutboxMessagesAsync(
|
||||
AppDbContext db,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var automaticJobs = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == AutomaticScheduleJobStatus.Queued ||
|
||||
x.Status == AutomaticScheduleJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.AutomaticSchedule &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var publishJobs = await db.SchedulePublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == SchedulePublishJobStatus.Queued ||
|
||||
x.Status == SchedulePublishJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.SchedulePublish &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var makeupJobs = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.MakeupExamAuto &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missingKeys = automaticJobs
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
.Concat(publishJobs.Select(id =>
|
||||
(BackgroundJobKind.SchedulePublish, id)))
|
||||
.Concat(makeupJobs.Select(id =>
|
||||
(BackgroundJobKind.MakeupExamAuto, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(kind, jobId));
|
||||
}
|
||||
|
||||
if (!db.ChangeTracker.HasChanges())
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
var recovered = await db.BackgroundJobOutboxMessages
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
message => message.JobKind == kind &&
|
||||
message.JobId == jobId,
|
||||
cancellationToken);
|
||||
if (!recovered)
|
||||
throw;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Another application instance recovered {Count} missing outbox messages first.",
|
||||
missingKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupCompletedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cutoff = DateTime.UtcNow.AddDays(-options.CompletedRetentionDays);
|
||||
var completedIds = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.State == BackgroundJobOutboxState.Completed &&
|
||||
x.CompletedAt != null &&
|
||||
x.CompletedAt < cutoff)
|
||||
.OrderBy(x => x.CompletedAt)
|
||||
.Select(x => x.Id)
|
||||
.Take(options.CleanupBatchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (completedIds.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var id in completedIds)
|
||||
{
|
||||
db.BackgroundJobOutboxMessages.Remove(
|
||||
new BackgroundJobOutboxMessage { Id = id });
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
telemetry.RecordCleaned(completedIds.Count);
|
||||
logger.LogInformation(
|
||||
"Removed {Count} completed background job outbox messages older than {Cutoff}.",
|
||||
completedIds.Count,
|
||||
cutoff);
|
||||
}
|
||||
|
||||
private async Task RecoverExpiredProcessingAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var recoveredState = transport.IsDurable
|
||||
? BackgroundJobOutboxState.Published
|
||||
: BackgroundJobOutboxState.Pending;
|
||||
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
(x.State == BackgroundJobOutboxState.Publishing ||
|
||||
x.State == BackgroundJobOutboxState.Processing) &&
|
||||
x.LeaseExpiresAt != null &&
|
||||
x.LeaseExpiresAt < now)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, recoveredState)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<(BackgroundJobEnvelope Message, Guid Token)?> ClaimNextAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var candidateId = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.State == BackgroundJobOutboxState.Pending)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (!candidateId.HasValue)
|
||||
return null;
|
||||
|
||||
var token = Guid.NewGuid();
|
||||
var leaseExpiresAt = DateTime.UtcNow.AddSeconds(options.LeaseSeconds);
|
||||
var claimed = await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == candidateId.Value &&
|
||||
x.State == BackgroundJobOutboxState.Pending)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Publishing)
|
||||
.SetProperty(x => x.ProcessingToken, token)
|
||||
.SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt)
|
||||
.SetProperty(x => x.PublishAttempts, x => x.PublishAttempts + 1)
|
||||
.SetProperty(x => x.LastError, (string?)null),
|
||||
cancellationToken);
|
||||
if (claimed == 0)
|
||||
return null;
|
||||
|
||||
var message = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.Id == candidateId.Value)
|
||||
.Select(x => new BackgroundJobEnvelope(x.Id, x.JobKind, x.JobId))
|
||||
.SingleAsync(cancellationToken);
|
||||
return (message, token);
|
||||
}
|
||||
|
||||
private async Task PublishClaimedAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startedAt = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
await transport.PublishAsync(message, cancellationToken);
|
||||
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == message.OutboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Publishing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Published)
|
||||
.SetProperty(x => x.PublishedAt, DateTime.UtcNow)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
telemetry.RecordPublish(
|
||||
message.JobKind,
|
||||
transport.IsDurable,
|
||||
"published",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var messageText = exception.GetBaseException().Message;
|
||||
if (messageText.Length > 2000)
|
||||
messageText = messageText[..2000];
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == message.OutboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Publishing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Pending)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null)
|
||||
.SetProperty(x => x.LastError, messageText),
|
||||
cancellationToken);
|
||||
telemetry.RecordPublish(
|
||||
message.JobKind,
|
||||
transport.IsDurable,
|
||||
"failed",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Diagnostics;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed record BackgroundJobRunResult(
|
||||
BackgroundJobRunOutcome Outcome,
|
||||
TimeSpan RetryAfter)
|
||||
{
|
||||
public static BackgroundJobRunResult Completed { get; } =
|
||||
new(BackgroundJobRunOutcome.Completed, TimeSpan.Zero);
|
||||
|
||||
public static BackgroundJobRunResult Retry(TimeSpan retryAfter) =>
|
||||
new(BackgroundJobRunOutcome.Retry, retryAfter);
|
||||
}
|
||||
|
||||
public enum BackgroundJobRunOutcome
|
||||
{
|
||||
Completed,
|
||||
Retry
|
||||
}
|
||||
|
||||
public sealed class BackgroundJobRunner(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IBackgroundJobTransport transport,
|
||||
BackgroundJobOptions options,
|
||||
BackgroundJobTelemetry telemetry,
|
||||
ILogger<BackgroundJobRunner> logger)
|
||||
{
|
||||
public async Task<BackgroundJobRunResult> RunAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startedAt = Stopwatch.GetTimestamp();
|
||||
var token = Guid.NewGuid();
|
||||
var claim = await TryClaimAsync(message, token, cancellationToken);
|
||||
if (!claim.Claimed)
|
||||
{
|
||||
return claim.Completed
|
||||
? BackgroundJobRunResult.Completed
|
||||
: BackgroundJobRunResult.Retry(claim.RetryAfter);
|
||||
}
|
||||
|
||||
if (claim.AttemptsExceeded)
|
||||
{
|
||||
await MarkJobRetryLimitExceededAsync(message, cancellationToken);
|
||||
await MarkCompletedAsync(message.OutboxMessageId, token,
|
||||
cancellationToken);
|
||||
telemetry.RecordProcessing(
|
||||
message.JobKind,
|
||||
"retry-limit",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
return BackgroundJobRunResult.Completed;
|
||||
}
|
||||
|
||||
using var heartbeatCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var heartbeat = RunHeartbeatAsync(
|
||||
message.OutboxMessageId,
|
||||
token,
|
||||
heartbeatCancellation.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
switch (message.JobKind)
|
||||
{
|
||||
case BackgroundJobKind.AutomaticSchedule:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<AutomaticScheduleJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.SchedulePublish:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<SchedulePublishJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.MakeupExamAuto:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<MakeupExamAutoJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
}
|
||||
|
||||
await MarkCompletedAsync(message.OutboxMessageId, token,
|
||||
cancellationToken);
|
||||
telemetry.RecordProcessing(
|
||||
message.JobKind,
|
||||
"completed",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
return BackgroundJobRunResult.Completed;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Background job {JobKind}/{JobId} will be retried.",
|
||||
message.JobKind,
|
||||
message.JobId);
|
||||
await ReleaseForRetryAsync(
|
||||
message.OutboxMessageId,
|
||||
token,
|
||||
exception,
|
||||
CancellationToken.None);
|
||||
telemetry.RecordProcessing(
|
||||
message.JobKind,
|
||||
"retry",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
return BackgroundJobRunResult.Retry(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await heartbeatCancellation.CancelAsync();
|
||||
try
|
||||
{
|
||||
await heartbeat;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when processing completes or the application stops.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(
|
||||
bool Claimed,
|
||||
bool Completed,
|
||||
bool AttemptsExceeded,
|
||||
TimeSpan RetryAfter)>
|
||||
TryClaimAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var leaseExpiresAt = now.AddSeconds(options.LeaseSeconds);
|
||||
var claimed = await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == message.OutboxMessageId &&
|
||||
x.JobKind == message.JobKind &&
|
||||
x.JobId == message.JobId &&
|
||||
(x.State == BackgroundJobOutboxState.Pending ||
|
||||
x.State == BackgroundJobOutboxState.Publishing ||
|
||||
x.State == BackgroundJobOutboxState.Published ||
|
||||
(x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.LeaseExpiresAt != null &&
|
||||
x.LeaseExpiresAt < now)))
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Processing)
|
||||
.SetProperty(x => x.ProcessingToken, token)
|
||||
.SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt)
|
||||
.SetProperty(
|
||||
x => x.ProcessingAttempts,
|
||||
x => x.ProcessingAttempts + 1)
|
||||
.SetProperty(x => x.LastError, (string?)null),
|
||||
cancellationToken);
|
||||
if (claimed == 1)
|
||||
{
|
||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.Id == message.OutboxMessageId)
|
||||
.Select(x => x.ProcessingAttempts)
|
||||
.SingleAsync(cancellationToken);
|
||||
return (
|
||||
true,
|
||||
false,
|
||||
attempts > options.ProcessingAttemptLimit,
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
var current = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.Id == message.OutboxMessageId)
|
||||
.Select(x => new { x.State, x.LeaseExpiresAt })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (current is null || current.State == BackgroundJobOutboxState.Completed)
|
||||
return (false, true, false, TimeSpan.Zero);
|
||||
|
||||
var retryAfter = current.LeaseExpiresAt.HasValue
|
||||
? current.LeaseExpiresAt.Value - now
|
||||
: TimeSpan.FromSeconds(2);
|
||||
retryAfter = TimeSpan.FromSeconds(Math.Clamp(
|
||||
retryAfter.TotalSeconds,
|
||||
1,
|
||||
options.LeaseSeconds));
|
||||
return (false, false, false, retryAfter);
|
||||
}
|
||||
|
||||
private async Task MarkJobRetryLimitExceededAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var completedAt = DateTime.UtcNow;
|
||||
var error = $"后台任务连续处理失败超过 {options.ProcessingAttemptLimit} 次," +
|
||||
"已停止自动重试,请检查服务日志后重新创建任务。";
|
||||
|
||||
switch (message.JobKind)
|
||||
{
|
||||
case BackgroundJobKind.AutomaticSchedule:
|
||||
await db.AutomaticScheduleJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != AutomaticScheduleJobStatus.Succeeded &&
|
||||
x.Status != AutomaticScheduleJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
AutomaticScheduleJobStatus.Failed)
|
||||
.SetProperty(x => x.ActiveSchedulePlanId, (Guid?)null)
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.SchedulePublish:
|
||||
await db.SchedulePublishJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != SchedulePublishJobStatus.Succeeded &&
|
||||
x.Status != SchedulePublishJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
SchedulePublishJobStatus.Failed)
|
||||
.SetProperty(x => x.ActiveAcademicTermId, (Guid?)null)
|
||||
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.MakeupExamAuto:
|
||||
await db.MakeupExamAutoJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != MakeupExamAutoJobStatus.Succeeded &&
|
||||
x.Status != MakeupExamAutoJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
MakeupExamAutoJobStatus.Failed)
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(message.JobKind),
|
||||
message.JobKind,
|
||||
null);
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Background job {JobKind}/{JobId} exceeded {AttemptLimit} processing attempts.",
|
||||
message.JobKind,
|
||||
message.JobId,
|
||||
options.ProcessingAttemptLimit);
|
||||
}
|
||||
|
||||
private async Task RunHeartbeatAsync(
|
||||
Guid outboxMessageId,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var intervalSeconds = Math.Max(5, options.LeaseSeconds / 3);
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken);
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == outboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(
|
||||
x => x.LeaseExpiresAt,
|
||||
DateTime.UtcNow.AddSeconds(options.LeaseSeconds)),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkCompletedAsync(
|
||||
Guid outboxMessageId,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == outboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Completed)
|
||||
.SetProperty(x => x.CompletedAt, DateTime.UtcNow)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ReleaseForRetryAsync(
|
||||
Guid outboxMessageId,
|
||||
Guid token,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var message = exception.GetBaseException().Message;
|
||||
if (message.Length > 2000)
|
||||
message = message[..2000];
|
||||
var state = transport.IsDurable
|
||||
? BackgroundJobOutboxState.Published
|
||||
: BackgroundJobOutboxState.Pending;
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == outboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, state)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null)
|
||||
.SetProperty(x => x.LastError, message),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class BackgroundJobTelemetry : IDisposable
|
||||
{
|
||||
public const string MeterName = "Jiaowu.BackgroundJobs";
|
||||
|
||||
private readonly Meter _meter = new(MeterName, "1.0.0");
|
||||
private readonly Counter<long> _published;
|
||||
private readonly Counter<long> _processed;
|
||||
private readonly Histogram<double> _publishDuration;
|
||||
private readonly Histogram<double> _processingDuration;
|
||||
private readonly Counter<long> _cleaned;
|
||||
|
||||
public BackgroundJobTelemetry()
|
||||
{
|
||||
_published = _meter.CreateCounter<long>(
|
||||
"jiaowu.background_jobs.published",
|
||||
unit: "{message}");
|
||||
_processed = _meter.CreateCounter<long>(
|
||||
"jiaowu.background_jobs.processed",
|
||||
unit: "{job}");
|
||||
_publishDuration = _meter.CreateHistogram<double>(
|
||||
"jiaowu.background_jobs.publish.duration",
|
||||
unit: "ms");
|
||||
_processingDuration = _meter.CreateHistogram<double>(
|
||||
"jiaowu.background_jobs.processing.duration",
|
||||
unit: "ms");
|
||||
_cleaned = _meter.CreateCounter<long>(
|
||||
"jiaowu.background_jobs.outbox.cleaned",
|
||||
unit: "{message}");
|
||||
}
|
||||
|
||||
public void RecordPublish(
|
||||
BackgroundJobKind kind,
|
||||
bool durable,
|
||||
string outcome,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "job.kind", kind.ToString() },
|
||||
{ "messaging.backend", durable ? "rabbitmq" : "memory" },
|
||||
{ "job.outcome", outcome }
|
||||
};
|
||||
_published.Add(1, tags);
|
||||
_publishDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
public void RecordProcessing(
|
||||
BackgroundJobKind kind,
|
||||
string outcome,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "job.kind", kind.ToString() },
|
||||
{ "job.outcome", outcome }
|
||||
};
|
||||
_processed.Add(1, tags);
|
||||
_processingDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
public void RecordCleaned(int count)
|
||||
{
|
||||
if (count > 0)
|
||||
_cleaned.Add(count);
|
||||
}
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed record BackgroundJobEnvelope(
|
||||
Guid OutboxMessageId,
|
||||
BackgroundJobKind JobKind,
|
||||
Guid JobId);
|
||||
|
||||
public interface IBackgroundJobTransport
|
||||
{
|
||||
bool IsDurable { get; }
|
||||
|
||||
ValueTask PublishAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> CheckHealthAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class InMemoryBackgroundJobTransport : IBackgroundJobTransport
|
||||
{
|
||||
private readonly IReadOnlyDictionary<
|
||||
BackgroundJobKind,
|
||||
Channel<BackgroundJobEnvelope>> _channels =
|
||||
Enum.GetValues<BackgroundJobKind>().ToDictionary(
|
||||
kind => kind,
|
||||
_ => Channel.CreateBounded<BackgroundJobEnvelope>(
|
||||
new BoundedChannelOptions(256)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
}));
|
||||
|
||||
public bool IsDurable => false;
|
||||
|
||||
public ValueTask PublishAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken) =>
|
||||
_channels[message.JobKind].Writer.WriteAsync(message, cancellationToken);
|
||||
|
||||
public Task<bool> CheckHealthAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public IAsyncEnumerable<BackgroundJobEnvelope> ReadAllAsync(
|
||||
BackgroundJobKind kind,
|
||||
CancellationToken cancellationToken) =>
|
||||
_channels[kind].Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class InMemoryBackgroundJobWorker(
|
||||
InMemoryBackgroundJobTransport transport,
|
||||
BackgroundJobRunner runner,
|
||||
BackgroundJobOptions options,
|
||||
ILogger<InMemoryBackgroundJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var workers = Enum.GetValues<BackgroundJobKind>()
|
||||
.SelectMany(kind => Enumerable.Range(
|
||||
0,
|
||||
options.ConsumerConcurrency(kind))
|
||||
.Select(_ => ConsumeAsync(kind, stoppingToken)))
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(workers);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("In-memory background job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConsumeAsync(
|
||||
BackgroundJobKind kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var message in transport.ReadAllAsync(
|
||||
kind,
|
||||
cancellationToken))
|
||||
{
|
||||
var result = await runner.RunAsync(message, cancellationToken);
|
||||
if (result.Outcome != BackgroundJobRunOutcome.Retry)
|
||||
continue;
|
||||
|
||||
await Task.Delay(result.RetryAfter, cancellationToken);
|
||||
await transport.PublishAsync(message, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class RabbitMqBackgroundJobTransport(
|
||||
BackgroundJobOptions jobOptions,
|
||||
RabbitMqOptions rabbitOptions,
|
||||
ILogger<RabbitMqBackgroundJobTransport> logger)
|
||||
: IBackgroundJobTransport, IAsyncDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private IConnection? _connection;
|
||||
private IChannel? _channel;
|
||||
|
||||
public bool IsDurable => true;
|
||||
|
||||
public async ValueTask PublishAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(message);
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var channel = await GetChannelAsync(cancellationToken);
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
Persistent = true,
|
||||
ContentType = "application/json",
|
||||
MessageId = message.OutboxMessageId.ToString("D"),
|
||||
Type = message.JobKind.ToString(),
|
||||
AppId = "jiaowu-api"
|
||||
};
|
||||
await channel.BasicPublishAsync(
|
||||
jobOptions.Exchange,
|
||||
RabbitMqBackgroundJobTopology.RoutingKey(message.JobKind),
|
||||
mandatory: true,
|
||||
properties,
|
||||
body,
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ResetConnectionAsync();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var channel = await GetChannelAsync(cancellationToken);
|
||||
return channel.IsOpen;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "RabbitMQ health check failed.");
|
||||
await ResetConnectionAsync();
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IChannel> GetChannelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_channel is { IsOpen: true })
|
||||
return _channel;
|
||||
|
||||
await ResetConnectionAsync();
|
||||
_connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
|
||||
rabbitOptions,
|
||||
"jiaowu-background-job-publisher",
|
||||
consumerDispatchConcurrency: 1,
|
||||
cancellationToken);
|
||||
_channel = await _connection.CreateChannelAsync(
|
||||
new CreateChannelOptions(
|
||||
publisherConfirmationsEnabled: true,
|
||||
publisherConfirmationTrackingEnabled: true),
|
||||
cancellationToken);
|
||||
await RabbitMqBackgroundJobTopology.DeclareAsync(
|
||||
_channel,
|
||||
jobOptions,
|
||||
cancellationToken);
|
||||
return _channel;
|
||||
}
|
||||
|
||||
private async Task ResetConnectionAsync()
|
||||
{
|
||||
if (_channel is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _channel.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The broker may already have closed the channel.
|
||||
}
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The broker may already have closed the connection.
|
||||
}
|
||||
_connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
await ResetConnectionAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RabbitMqBackgroundJobWorker(
|
||||
BackgroundJobOptions jobOptions,
|
||||
RabbitMqOptions rabbitOptions,
|
||||
BackgroundJobRunner runner,
|
||||
ILogger<RabbitMqBackgroundJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
IConnection? connection = null;
|
||||
var channels = new List<IChannel>();
|
||||
try
|
||||
{
|
||||
var consumerCount = RabbitMqBackgroundJobTopology.JobKinds.Sum(
|
||||
jobOptions.ConsumerConcurrency);
|
||||
connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
|
||||
rabbitOptions,
|
||||
"jiaowu-background-job-worker",
|
||||
(ushort)consumerCount,
|
||||
stoppingToken);
|
||||
|
||||
foreach (var kind in RabbitMqBackgroundJobTopology.JobKinds)
|
||||
{
|
||||
for (var workerIndex = 0;
|
||||
workerIndex < jobOptions.ConsumerConcurrency(kind);
|
||||
workerIndex++)
|
||||
{
|
||||
var channel = await connection.CreateChannelAsync(
|
||||
new CreateChannelOptions(
|
||||
publisherConfirmationsEnabled: false,
|
||||
publisherConfirmationTrackingEnabled: false),
|
||||
stoppingToken);
|
||||
channels.Add(channel);
|
||||
await RabbitMqBackgroundJobTopology.DeclareAsync(
|
||||
channel,
|
||||
jobOptions,
|
||||
stoppingToken);
|
||||
await channel.BasicQosAsync(
|
||||
0,
|
||||
jobOptions.PrefetchCount,
|
||||
global: false,
|
||||
stoppingToken);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.ReceivedAsync += async (_, eventArgs) =>
|
||||
{
|
||||
using var deliveryCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(
|
||||
eventArgs.CancellationToken,
|
||||
stoppingToken);
|
||||
var deliveryToken = deliveryCancellation.Token;
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer
|
||||
.Deserialize<BackgroundJobEnvelope>(
|
||||
eventArgs.Body.Span);
|
||||
if (message is null || message.JobKind != kind)
|
||||
{
|
||||
await channel.BasicNackAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
requeue: false,
|
||||
deliveryToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await runner.RunAsync(
|
||||
message,
|
||||
deliveryToken);
|
||||
if (result.Outcome ==
|
||||
BackgroundJobRunOutcome.Completed)
|
||||
{
|
||||
await channel.BasicAckAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
deliveryToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(
|
||||
result.RetryAfter,
|
||||
deliveryToken);
|
||||
await channel.BasicNackAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
requeue: true,
|
||||
deliveryToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Closing the channel requeues unacknowledged deliveries.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"RabbitMQ delivery for {JobKind} failed and will be requeued.",
|
||||
kind);
|
||||
if (!channel.IsOpen)
|
||||
return;
|
||||
|
||||
await channel.BasicNackAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
requeue: true,
|
||||
CancellationToken.None);
|
||||
}
|
||||
};
|
||||
await channel.BasicConsumeAsync(
|
||||
RabbitMqBackgroundJobTopology.QueueName(
|
||||
jobOptions,
|
||||
kind),
|
||||
autoAck: false,
|
||||
consumer,
|
||||
stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"{ConsumerCount} RabbitMQ background job consumers are connected to {HostName}:{Port}.",
|
||||
consumerCount,
|
||||
rabbitOptions.HostName,
|
||||
rabbitOptions.Port);
|
||||
while (connection.IsOpen && !stoppingToken.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"RabbitMQ background job consumer connection failed; retrying.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
await channel.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The connection may already have disposed its channels.
|
||||
}
|
||||
}
|
||||
|
||||
if (connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The broker may already have closed the connection.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stoppingToken.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RabbitMqBackgroundJobTopology
|
||||
{
|
||||
public static readonly BackgroundJobKind[] JobKinds =
|
||||
[
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
BackgroundJobKind.MakeupExamAuto
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
RabbitMqOptions options,
|
||||
string clientName,
|
||||
ushort consumerDispatchConcurrency,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = options.HostName,
|
||||
Port = options.Port,
|
||||
UserName = options.UserName,
|
||||
Password = options.Password,
|
||||
VirtualHost = options.VirtualHost,
|
||||
ClientProvidedName = clientName,
|
||||
AutomaticRecoveryEnabled = false,
|
||||
TopologyRecoveryEnabled = false,
|
||||
RequestedHeartbeat = TimeSpan.FromSeconds(30),
|
||||
ConsumerDispatchConcurrency = consumerDispatchConcurrency
|
||||
};
|
||||
if (options.UseTls)
|
||||
{
|
||||
factory.Ssl = new SslOption
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = string.IsNullOrWhiteSpace(options.TlsServerName)
|
||||
? options.HostName
|
||||
: options.TlsServerName
|
||||
};
|
||||
}
|
||||
|
||||
return await factory.CreateConnectionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task DeclareAsync(
|
||||
IChannel channel,
|
||||
BackgroundJobOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadExchange = options.Exchange + ".dead";
|
||||
await channel.ExchangeDeclareAsync(
|
||||
options.Exchange,
|
||||
ExchangeType.Direct,
|
||||
durable: true,
|
||||
autoDelete: false,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.ExchangeDeclareAsync(
|
||||
deadExchange,
|
||||
ExchangeType.Direct,
|
||||
durable: true,
|
||||
autoDelete: false,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
foreach (var kind in JobKinds)
|
||||
{
|
||||
var routingKey = RoutingKey(kind);
|
||||
var queueName = QueueName(options, kind);
|
||||
var deadQueueName = queueName + ".dead";
|
||||
var queueArguments = QueueArguments(options);
|
||||
queueArguments["x-dead-letter-exchange"] = deadExchange;
|
||||
queueArguments["x-dead-letter-routing-key"] = routingKey;
|
||||
await channel.QueueDeclareAsync(
|
||||
queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: queueArguments,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(
|
||||
queueName,
|
||||
options.Exchange,
|
||||
routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await channel.QueueDeclareAsync(
|
||||
deadQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: QueueArguments(options),
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(
|
||||
deadQueueName,
|
||||
deadExchange,
|
||||
routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public static string RoutingKey(BackgroundJobKind kind) => kind switch
|
||||
{
|
||||
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
|
||||
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
||||
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
|
||||
public static string QueueName(
|
||||
BackgroundJobOptions options,
|
||||
BackgroundJobKind kind) =>
|
||||
$"{options.QueuePrefix}.{RoutingKey(kind)}";
|
||||
|
||||
private static Dictionary<string, object?> QueueArguments(
|
||||
BackgroundJobOptions options)
|
||||
{
|
||||
var arguments = new Dictionary<string, object?>();
|
||||
if (options.UseQuorumQueues)
|
||||
arguments["x-queue-type"] = "quorum";
|
||||
return arguments;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public enum AppCacheProfile
|
||||
{
|
||||
ReferenceData,
|
||||
PublishedTimetable,
|
||||
Analytics
|
||||
}
|
||||
|
||||
public interface IAppCache
|
||||
{
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class HybridAppCache(
|
||||
HybridCache cache,
|
||||
AppCacheOptions options,
|
||||
IHostEnvironment environment,
|
||||
ILogger<HybridAppCache> logger) : IAppCache
|
||||
{
|
||||
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return await factory(cancellationToken);
|
||||
|
||||
var sourceCompleted = false;
|
||||
T? sourceValue = default;
|
||||
try
|
||||
{
|
||||
return await cache.GetOrCreateAsync(
|
||||
$"{prefix}:{key}",
|
||||
async token =>
|
||||
{
|
||||
sourceValue = await factory(token);
|
||||
sourceCompleted = true;
|
||||
return sourceValue;
|
||||
},
|
||||
GetEntryOptions(profile),
|
||||
tags.Select(tag => $"{prefix}:tag:{tag}"),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Redis cache operation failed for {CacheKey}; using the source directly.",
|
||||
key);
|
||||
if (sourceCompleted)
|
||||
return sourceValue!;
|
||||
return await factory(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await cache.RemoveByTagAsync(
|
||||
$"{prefix}:tag:{tag}",
|
||||
cancellationToken);
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.",
|
||||
tag);
|
||||
}
|
||||
}
|
||||
|
||||
private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) =>
|
||||
profile switch
|
||||
{
|
||||
AppCacheProfile.ReferenceData => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds)
|
||||
},
|
||||
AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
|
||||
},
|
||||
AppCacheProfile.Analytics => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
|
||||
};
|
||||
|
||||
private static string BuildPrefix(string configuredPrefix, string environmentName)
|
||||
{
|
||||
var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix)
|
||||
? "jiaowu:v1"
|
||||
: configuredPrefix.Trim().Trim(':');
|
||||
var environmentValue = string.Concat(
|
||||
environmentName.Trim().ToLowerInvariant()
|
||||
.Select(character => char.IsLetterOrDigit(character) ? character : '-'));
|
||||
return $"{prefixValue}:{environmentValue}";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoOpAppCache : IAppCache
|
||||
{
|
||||
public static NoOpAppCache Instance { get; } = new();
|
||||
|
||||
private NoOpAppCache()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken) =>
|
||||
factory(cancellationToken);
|
||||
|
||||
public ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public static class AppCacheKeys
|
||||
{
|
||||
public const string ActivationOptions = "auth:activation-options";
|
||||
public const string Dashboard = "dashboard:summary";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
public static string BaseData(string kind) => $"base-data:{kind}";
|
||||
|
||||
public static string PublishedTimetable(
|
||||
string resourceType,
|
||||
Guid resourceId,
|
||||
Guid? academicTermId) =>
|
||||
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
|
||||
$"{resourceType}:{resourceId:N}";
|
||||
|
||||
public static string Statistics(
|
||||
string area,
|
||||
string dataScope,
|
||||
Guid? effectiveCollegeId,
|
||||
params string?[] filters)
|
||||
{
|
||||
static string Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? "-"
|
||||
: value.Trim().ToLowerInvariant();
|
||||
|
||||
var filterPart = filters.Length == 0
|
||||
? "all"
|
||||
: string.Join(':', filters.Select(Normalize));
|
||||
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class AppCacheTags
|
||||
{
|
||||
public const string BaseData = "base-data";
|
||||
public const string Analytics = "analytics";
|
||||
public const string Timetables = "timetables";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
public static string Timetable(Guid academicTermId) =>
|
||||
$"timetable:term:{academicTermId:N}";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public sealed class AppCacheOptions
|
||||
{
|
||||
public const string SectionName = "Cache";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string KeyPrefix { get; set; } = "jiaowu:v1";
|
||||
public int ReferenceExpirationMinutes { get; set; } = 30;
|
||||
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
|
||||
public int TimetableExpirationMinutes { get; set; } = 10;
|
||||
public int TimetableLocalExpirationSeconds { get; set; } = 30;
|
||||
public int AnalyticsExpirationMinutes { get; set; } = 3;
|
||||
public int AnalyticsLocalExpirationSeconds { get; set; } = 30;
|
||||
public int MaximumPayloadKilobytes { get; set; } = 2048;
|
||||
}
|
||||
@@ -14,9 +14,23 @@ public static class CourseSelectionRules
|
||||
round.Status == CourseSelectionRoundStatus.Open &&
|
||||
nowUtc <= round.WithdrawalEndsAt;
|
||||
|
||||
public static bool CanPromoteWaitlist(CourseSelectionRound round, DateTime nowUtc) =>
|
||||
round.Status == CourseSelectionRoundStatus.Open &&
|
||||
nowUtc <= round.WithdrawalEndsAt;
|
||||
|
||||
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||
nature == CourseNature.GeneralRequired;
|
||||
|
||||
public static bool IsGradeEligible(
|
||||
IEnumerable<int> eligibleGrades,
|
||||
int studentGrade) =>
|
||||
!eligibleGrades.Any() || eligibleGrades.Contains(studentGrade);
|
||||
|
||||
public static bool HasReachedCourseLimit(
|
||||
int? maxCourseCount,
|
||||
int selectedCourseCount) =>
|
||||
maxCourseCount.HasValue && selectedCourseCount >= maxCourseCount.Value;
|
||||
|
||||
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
||||
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
||||
|
||||
@@ -68,4 +82,7 @@ public static class CourseSelectionRules
|
||||
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
||||
public static int RetakeCapacity(int originalCapacity) =>
|
||||
(int)Math.Ceiling(originalCapacity * 1.15);
|
||||
|
||||
public static int EffectiveCapacity(int originalCapacity, bool isRetake) =>
|
||||
isRetake ? RetakeCapacity(originalCapacity) : originalCapacity;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,14 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
|
||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||
Guid planId,
|
||||
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||
bool assignClassrooms,
|
||||
bool assignInvigilators,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!assignClassrooms && !assignInvigilators)
|
||||
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||
|
||||
var plan = await db.ExamPlans
|
||||
.Include(x => x.AcademicTerm)
|
||||
.Include(x => x.Sessions)
|
||||
@@ -20,6 +26,9 @@ 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)
|
||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||
|
||||
if (plan is null)
|
||||
@@ -27,9 +36,23 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
||||
|
||||
var sessions = plan.Sessions.ToList();
|
||||
var requestedIds = (requestedSessionIds ?? [])
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
if (requestedIds.Count > 100)
|
||||
return ExamArrangementResult.Fail("一次最多处理100个考试场次。");
|
||||
if (requestedIds.Count > 0 &&
|
||||
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("考试计划中没有场次。");
|
||||
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
||||
|
||||
var termId = plan.AcademicTermId;
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
@@ -44,8 +67,8 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
|
||||
int assignedRooms = 0;
|
||||
int assignedInvigilators = 0;
|
||||
int skippedRooms = 0;
|
||||
int skippedInvigilators = 0;
|
||||
int unavailableRooms = 0;
|
||||
int unavailableInvigilators = 0;
|
||||
var messages = new List<string>();
|
||||
|
||||
// Track occupied time slots to avoid conflicts
|
||||
@@ -69,7 +92,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
cancellationToken);
|
||||
|
||||
// ── Auto-assign classroom ──
|
||||
if (!session.ClassroomId.HasValue)
|
||||
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||
{
|
||||
var room = await FindBestClassroomAsync(
|
||||
session, studentCount, occupiedRooms, cancellationToken);
|
||||
@@ -83,20 +106,15 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
unavailableRooms++;
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
}
|
||||
|
||||
// ── Auto-assign invigilators ──
|
||||
var currentInvigilatorCount = session.Invigilators.Count;
|
||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||
if (needed > 0)
|
||||
if (assignInvigilators && needed > 0)
|
||||
{
|
||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||
.Select(x => x.TeacherId).ToHashSet();
|
||||
@@ -116,12 +134,11 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
}
|
||||
|
||||
if (newlyAssigned.Count < needed)
|
||||
{
|
||||
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedInvigilators++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +146,9 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
|
||||
return new ExamArrangementResult(
|
||||
true,
|
||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
||||
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,14 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
|
||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||
Guid planId,
|
||||
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||
bool assignClassrooms,
|
||||
bool assignInvigilators,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!assignClassrooms && !assignInvigilators)
|
||||
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||
|
||||
var plan = await db.MakeupExamPlans
|
||||
.Include(x => x.AcademicTerm)
|
||||
.Include(x => x.Sessions)
|
||||
@@ -20,6 +26,9 @@ 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)
|
||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||
|
||||
if (plan is null)
|
||||
@@ -27,9 +36,23 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。");
|
||||
|
||||
var sessions = plan.Sessions.ToList();
|
||||
var requestedIds = (requestedSessionIds ?? [])
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
if (requestedIds.Count > 100)
|
||||
return ExamArrangementResult.Fail("一次最多处理100个补考场次。");
|
||||
if (requestedIds.Count > 0 &&
|
||||
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("补考计划中没有场次。");
|
||||
return ExamArrangementResult.Fail("没有可处理的补考场次。");
|
||||
|
||||
var termId = plan.AcademicTermId;
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
@@ -44,8 +67,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
|
||||
int assignedRooms = 0;
|
||||
int assignedInvigilators = 0;
|
||||
int skippedRooms = 0;
|
||||
int skippedInvigilators = 0;
|
||||
int unavailableRooms = 0;
|
||||
int unavailableInvigilators = 0;
|
||||
var messages = new List<string>();
|
||||
|
||||
var occupiedRooms = sessions
|
||||
@@ -66,7 +89,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
|
||||
|
||||
// Auto-assign classroom
|
||||
if (!session.ClassroomId.HasValue)
|
||||
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||
{
|
||||
var room = await FindBestClassroomAsync(
|
||||
session, enrolledCount, occupiedRooms, cancellationToken);
|
||||
@@ -80,20 +103,15 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
unavailableRooms++;
|
||||
messages.Add(
|
||||
$"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
}
|
||||
|
||||
// Auto-assign invigilators
|
||||
var currentInvigilatorCount = session.Invigilators.Count;
|
||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||
if (needed > 0)
|
||||
if (assignInvigilators && needed > 0)
|
||||
{
|
||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||
.Select(x => x.TeacherId).ToHashSet();
|
||||
@@ -113,12 +131,11 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
}
|
||||
|
||||
if (newlyAssigned.Count < needed)
|
||||
{
|
||||
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||
messages.Add(
|
||||
$"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedInvigilators++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +143,9 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
|
||||
return new ExamArrangementResult(
|
||||
true,
|
||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
||||
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class MakeupExamAutoJobQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void Enqueue(Guid jobId)
|
||||
{
|
||||
if (!_channel.Writer.TryWrite(jobId))
|
||||
throw new InvalidOperationException("补考自动生成任务队列当前不可用。");
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MakeupExamAutoJobWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
MakeupExamAutoJobQueue queue,
|
||||
ILogger<MakeupExamAutoJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not recover makeup exam auto jobs (table may not exist yet).");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider
|
||||
.GetRequiredService<MakeupExamAutoJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Unexpected failure while dispatching makeup exam auto job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Makeup exam auto job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobs = await db.MakeupExamAutoJobs
|
||||
.Where(x =>
|
||||
x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
job.Status = MakeupExamAutoJobStatus.Queued;
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
}
|
||||
|
||||
if (jobs.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var job in jobs)
|
||||
queue.Enqueue(job.Id);
|
||||
|
||||
if (jobs.Count > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Recovered {JobCount} queued or interrupted makeup exam auto jobs.",
|
||||
jobs.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MakeupExamAutoJobProcessor(
|
||||
AppDbContext db,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
|
||||
@@ -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,12 @@
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public sealed class OfficialDocumentOptions
|
||||
{
|
||||
public const string SectionName = "OfficialDocuments";
|
||||
|
||||
public string InstitutionName { get; set; } = "明序大学";
|
||||
public string IssuingOffice { get; set; } = "教务处";
|
||||
public string DocumentNumberPrefix { get; set; } = "MXU";
|
||||
public string? PublicBaseUrl { get; set; }
|
||||
public string? FontPath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System.Security.Cryptography;
|
||||
using QRCoder;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public interface IOfficialDocumentPdfGenerator
|
||||
{
|
||||
GeneratedOfficialDocument Generate(
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl);
|
||||
}
|
||||
|
||||
public sealed class OfficialDocumentPdfGenerator(
|
||||
OfficialDocumentOptions options) : IOfficialDocumentPdfGenerator
|
||||
{
|
||||
private const float PageWidth = 595;
|
||||
private const float PageHeight = 842;
|
||||
private const float Margin = 44;
|
||||
private const float ContentWidth = PageWidth - Margin * 2;
|
||||
private static readonly SKColor Ink = new(31, 48, 65);
|
||||
private static readonly SKColor Muted = new(91, 107, 120);
|
||||
private static readonly SKColor Accent = new(26, 91, 82);
|
||||
private static readonly SKColor Pale = new(236, 244, 242);
|
||||
private static readonly SKColor Rule = new(208, 219, 220);
|
||||
|
||||
public GeneratedOfficialDocument Generate(
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl)
|
||||
{
|
||||
using var typeface = ResolveTypeface();
|
||||
using var stream = new MemoryStream();
|
||||
using (var document = SKDocument.CreatePdf(stream))
|
||||
{
|
||||
if (snapshot.Type == Domain.Academic.OfficialDocumentType.Transcript)
|
||||
DrawTranscript(document, typeface, snapshot, verificationUrl);
|
||||
else
|
||||
DrawStatusCertificate(document, typeface, snapshot, verificationUrl);
|
||||
document.Close();
|
||||
}
|
||||
|
||||
var content = stream.ToArray();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
|
||||
return new GeneratedOfficialDocument(content, hash);
|
||||
}
|
||||
|
||||
private void DrawTranscript(
|
||||
SKDocument document,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl)
|
||||
{
|
||||
const int rowsPerPage = 19;
|
||||
var pageCount = Math.Max(1, (int)Math.Ceiling(snapshot.Grades.Count / (double)rowsPerPage));
|
||||
for (var pageIndex = 0; pageIndex < pageCount; pageIndex++)
|
||||
{
|
||||
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||
DrawPageFrame(canvas);
|
||||
DrawHeader(canvas, typeface, snapshot, "官方电子成绩单", "OFFICIAL ACADEMIC TRANSCRIPT");
|
||||
|
||||
var y = 145f;
|
||||
if (pageIndex == 0)
|
||||
{
|
||||
y = DrawStudentSummary(canvas, typeface, snapshot.Student, y);
|
||||
y = DrawTranscriptSummary(canvas, typeface, snapshot, y + 12);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawText(canvas, typeface, $"{snapshot.Student.Name} · {snapshot.Student.StudentNumber}",
|
||||
Margin, y, 10, Muted);
|
||||
y += 20;
|
||||
}
|
||||
|
||||
var rows = snapshot.Grades.Skip(pageIndex * rowsPerPage).Take(rowsPerPage).ToArray();
|
||||
y = DrawGradeTable(canvas, typeface, rows, y + 14);
|
||||
|
||||
if (pageIndex == pageCount - 1)
|
||||
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, Math.Min(y + 18, 685));
|
||||
|
||||
DrawFooter(canvas, typeface, snapshot, pageIndex + 1, pageCount);
|
||||
document.EndPage();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawStatusCertificate(
|
||||
SKDocument document,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl)
|
||||
{
|
||||
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||
DrawPageFrame(canvas);
|
||||
DrawHeader(canvas, typeface, snapshot, "学籍状态证明", "CERTIFICATE OF STUDENT STATUS");
|
||||
|
||||
var student = snapshot.Student;
|
||||
var statusText = student.Status switch
|
||||
{
|
||||
"在读" => "现为我校在读学生",
|
||||
"休学" => "现为我校注册学生,当前处于休学状态",
|
||||
"已毕业" => "曾在我校就读,现已毕业",
|
||||
"退学" => "曾在我校就读,现已办理退学",
|
||||
_ => $"在我校的学籍状态为“{student.Status}”"
|
||||
};
|
||||
|
||||
DrawText(canvas, typeface, "证 明", PageWidth / 2, 215, 25, Ink, SKTextAlign.Center, true);
|
||||
var body = $"兹证明,{student.Name}(学号:{student.StudentNumber})," +
|
||||
$"于 {student.EnrollmentDate:yyyy年M月d日} 入学," +
|
||||
$"就读于{student.CollegeName}{student.MajorName}专业,行政班级为{student.ClassName}," +
|
||||
$"{statusText}。";
|
||||
var y = 285f;
|
||||
foreach (var line in WrapText(typeface, body, 16, 448))
|
||||
{
|
||||
DrawText(canvas, typeface, line, 74, y, 16, Ink);
|
||||
y += 32;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.Purpose))
|
||||
{
|
||||
DrawText(canvas, typeface, $"用途:{snapshot.Purpose}", 74, y + 14, 11, Muted);
|
||||
y += 30;
|
||||
}
|
||||
|
||||
DrawText(canvas, typeface, "特此证明。", 74, y + 28, 16, Ink);
|
||||
DrawText(canvas, typeface, snapshot.InstitutionName, 435, y + 100, 14, Ink,
|
||||
SKTextAlign.Center, true);
|
||||
DrawText(canvas, typeface, snapshot.IssuingOffice, 435, y + 126, 12, Ink,
|
||||
SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, snapshot.IssuedAt.ToLocalTime().ToString("yyyy年M月d日"),
|
||||
435, y + 150, 11, Muted, SKTextAlign.Center);
|
||||
|
||||
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, 625);
|
||||
DrawFooter(canvas, typeface, snapshot, 1, 1);
|
||||
document.EndPage();
|
||||
}
|
||||
|
||||
private static void DrawPageFrame(SKCanvas canvas)
|
||||
{
|
||||
canvas.Clear(SKColors.White);
|
||||
using var border = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.4f };
|
||||
using var inner = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
|
||||
canvas.DrawRect(22, 22, PageWidth - 44, PageHeight - 44, border);
|
||||
canvas.DrawRect(27, 27, PageWidth - 54, PageHeight - 54, inner);
|
||||
}
|
||||
|
||||
private static void DrawHeader(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string title,
|
||||
string englishTitle)
|
||||
{
|
||||
DrawText(canvas, typeface, snapshot.InstitutionName, Margin, 66, 16, Accent, bold: true);
|
||||
DrawText(canvas, typeface, snapshot.IssuingOffice, PageWidth - Margin, 66, 10, Muted,
|
||||
SKTextAlign.Right);
|
||||
DrawText(canvas, typeface, title, PageWidth / 2, 99, 23, Ink, SKTextAlign.Center, true);
|
||||
DrawText(canvas, typeface, englishTitle, PageWidth / 2, 119, 8.5f, Muted,
|
||||
SKTextAlign.Center);
|
||||
using var rule = new SKPaint { Color = Accent, StrokeWidth = 1.2f };
|
||||
canvas.DrawLine(Margin, 132, PageWidth - Margin, 132, rule);
|
||||
}
|
||||
|
||||
private static float DrawStudentSummary(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialStudentSnapshot student,
|
||||
float y)
|
||||
{
|
||||
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 86), 5, 5, fill);
|
||||
DrawLabelValue(canvas, typeface, "姓名", student.Name, Margin + 15, y + 24);
|
||||
DrawLabelValue(canvas, typeface, "学号", student.StudentNumber, 210, y + 24);
|
||||
DrawLabelValue(canvas, typeface, "学籍状态", student.Status, 404, y + 24);
|
||||
DrawLabelValue(canvas, typeface, "学院", student.CollegeName, Margin + 15, y + 57);
|
||||
DrawLabelValue(canvas, typeface, "专业", student.MajorName, 210, y + 57);
|
||||
DrawLabelValue(canvas, typeface, "班级", student.ClassName, 404, y + 57);
|
||||
return y + 86;
|
||||
}
|
||||
|
||||
private static float DrawTranscriptSummary(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
float y)
|
||||
{
|
||||
var values = new[]
|
||||
{
|
||||
("课程门数", snapshot.Grades.Count.ToString()),
|
||||
("修读学分", FormatDecimal(snapshot.TotalCredits)),
|
||||
("获得学分", FormatDecimal(snapshot.EarnedCredits)),
|
||||
("平均绩点", snapshot.GradePointAverage?.ToString("0.00") ?? "-")
|
||||
};
|
||||
var width = ContentWidth / values.Length;
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
var center = Margin + width * i + width / 2;
|
||||
DrawText(canvas, typeface, values[i].Item1, center, y + 12, 8.5f, Muted,
|
||||
SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, values[i].Item2, center, y + 33, 15, Accent,
|
||||
SKTextAlign.Center, true);
|
||||
}
|
||||
return y + 42;
|
||||
}
|
||||
|
||||
private static float DrawGradeTable(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
IReadOnlyList<OfficialTranscriptRow> rows,
|
||||
float y)
|
||||
{
|
||||
var widths = new[] { 96f, 64f, 160f, 48f, 55f, 45f, 39f };
|
||||
var headers = new[] { "学期", "课程代码", "课程名称", "学分", "成绩", "绩点", "状态" };
|
||||
const float headerHeight = 27;
|
||||
const float rowHeight = 24;
|
||||
using var headerPaint = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
|
||||
using var stripe = new SKPaint { Color = new SKColor(247, 249, 249), Style = SKPaintStyle.Fill };
|
||||
using var rule = new SKPaint { Color = Rule, StrokeWidth = .5f };
|
||||
canvas.DrawRect(Margin, y, ContentWidth, headerHeight, headerPaint);
|
||||
|
||||
var x = Margin;
|
||||
for (var i = 0; i < headers.Length; i++)
|
||||
{
|
||||
DrawText(canvas, typeface, headers[i], x + widths[i] / 2, y + 18, 8.5f,
|
||||
SKColors.White, SKTextAlign.Center, true);
|
||||
x += widths[i];
|
||||
}
|
||||
|
||||
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
|
||||
{
|
||||
var row = rows[rowIndex];
|
||||
var rowY = y + headerHeight + rowIndex * rowHeight;
|
||||
if (rowIndex % 2 == 1)
|
||||
canvas.DrawRect(Margin, rowY, ContentWidth, rowHeight, stripe);
|
||||
canvas.DrawLine(Margin, rowY + rowHeight, PageWidth - Margin, rowY + rowHeight, rule);
|
||||
var values = new[]
|
||||
{
|
||||
row.AcademicTerm,
|
||||
row.CourseCode,
|
||||
Ellipsize(typeface, row.CourseName, 8.5f, widths[2] - 8),
|
||||
FormatDecimal(row.Credits),
|
||||
row.Score?.ToString("0.#") ?? "-",
|
||||
row.GradePoint?.ToString("0.0#") ?? "-",
|
||||
row.ExamStatus
|
||||
};
|
||||
x = Margin;
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
DrawText(canvas, typeface, values[i], x + widths[i] / 2, rowY + 16, 8.5f,
|
||||
Ink, SKTextAlign.Center);
|
||||
x += widths[i];
|
||||
}
|
||||
}
|
||||
return y + headerHeight + rows.Count * rowHeight;
|
||||
}
|
||||
|
||||
private static void DrawVerificationBlock(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl,
|
||||
float y)
|
||||
{
|
||||
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 84), 5, 5, fill);
|
||||
DrawQrCode(canvas, verificationUrl, Margin + 10, y + 7, 70);
|
||||
DrawText(canvas, typeface, "扫码验真", Margin + 94, y + 22, 11, Accent, bold: true);
|
||||
DrawText(canvas, typeface, $"凭证编号:{snapshot.DocumentNumber}", Margin + 94, y + 43, 9.5f, Ink);
|
||||
DrawText(canvas, typeface, "二维码指向本系统公开验真页;失效或重签后状态会实时更新。",
|
||||
Margin + 94, y + 62, 8.5f, Muted);
|
||||
}
|
||||
|
||||
private static void DrawFooter(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
int page,
|
||||
int pageCount)
|
||||
{
|
||||
using var rule = new SKPaint { Color = Rule, StrokeWidth = .6f };
|
||||
canvas.DrawLine(Margin, 790, PageWidth - Margin, 790, rule);
|
||||
DrawText(canvas, typeface,
|
||||
$"签发:{snapshot.IssuedByName} · {snapshot.IssuedAt.ToLocalTime():yyyy-MM-dd HH:mm}",
|
||||
Margin, 807, 7.5f, Muted);
|
||||
DrawText(canvas, typeface, $"第 {page} / {pageCount} 页", PageWidth - Margin, 807, 7.5f,
|
||||
Muted, SKTextAlign.Right);
|
||||
}
|
||||
|
||||
private static void DrawLabelValue(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
string label,
|
||||
string value,
|
||||
float x,
|
||||
float y)
|
||||
{
|
||||
DrawText(canvas, typeface, label, x, y, 8, Muted);
|
||||
DrawText(canvas, typeface, value, x + 42, y, 10, Ink, bold: true);
|
||||
}
|
||||
|
||||
private static void DrawQrCode(SKCanvas canvas, string content, float x, float y, float size)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
|
||||
var modules = data.ModuleMatrix;
|
||||
const int quiet = 4;
|
||||
var moduleSize = size / (modules.Count + quiet * 2);
|
||||
using var paint = new SKPaint { Color = Ink, Style = SKPaintStyle.Fill, IsAntialias = false };
|
||||
for (var row = 0; row < modules.Count; row++)
|
||||
for (var column = 0; column < modules[row].Count; column++)
|
||||
{
|
||||
if (!modules[row][column]) continue;
|
||||
canvas.DrawRect(
|
||||
x + (column + quiet) * moduleSize,
|
||||
y + (row + quiet) * moduleSize,
|
||||
moduleSize + .08f,
|
||||
moduleSize + .08f,
|
||||
paint);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawText(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
string text,
|
||||
float x,
|
||||
float y,
|
||||
float size,
|
||||
SKColor color,
|
||||
SKTextAlign align = SKTextAlign.Left,
|
||||
bool bold = false)
|
||||
{
|
||||
using var font = new SKFont(typeface, size) { Embolden = bold };
|
||||
using var paint = new SKPaint { Color = color, IsAntialias = true };
|
||||
canvas.DrawText(text, x, y, align, font, paint);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> WrapText(
|
||||
SKTypeface typeface,
|
||||
string text,
|
||||
float size,
|
||||
float maxWidth)
|
||||
{
|
||||
using var font = new SKFont(typeface, size);
|
||||
var lines = new List<string>();
|
||||
var current = "";
|
||||
foreach (var character in text)
|
||||
{
|
||||
var candidate = current + character;
|
||||
if (current.Length > 0 && font.MeasureText(candidate) > maxWidth)
|
||||
{
|
||||
var breakIndex = current.LastIndexOfAny([' ', ',', '。', ';', ':', '、']);
|
||||
if (breakIndex >= current.Length / 2)
|
||||
{
|
||||
lines.Add(current[..(breakIndex + 1)].TrimEnd());
|
||||
current = current[(breakIndex + 1)..].TrimStart() + character;
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add(current);
|
||||
current = character.ToString();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static string Ellipsize(
|
||||
SKTypeface typeface,
|
||||
string text,
|
||||
float size,
|
||||
float maxWidth)
|
||||
{
|
||||
using var font = new SKFont(typeface, size);
|
||||
if (font.MeasureText(text) <= maxWidth) return text;
|
||||
var value = text;
|
||||
while (value.Length > 1 && font.MeasureText(value + "…") > maxWidth)
|
||||
value = value[..^1];
|
||||
return value + "…";
|
||||
}
|
||||
|
||||
private SKTypeface ResolveTypeface()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
options.FontPath,
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
|
||||
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
|
||||
};
|
||||
foreach (var path in candidates.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
{
|
||||
if (!File.Exists(path)) continue;
|
||||
var typeface = SKTypeface.FromFile(path);
|
||||
if (typeface is not null) return typeface;
|
||||
}
|
||||
|
||||
var fallback = SKTypeface.FromFamilyName("Microsoft YaHei") ??
|
||||
SKTypeface.FromFamilyName("Noto Sans CJK SC");
|
||||
return fallback ?? throw new InvalidOperationException(
|
||||
"未找到可用于官方 PDF 的中文字体。请配置 OfficialDocuments:FontPath。");
|
||||
}
|
||||
|
||||
private static string FormatDecimal(decimal value) => value.ToString("0.##");
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public sealed class OfficialDocumentService(
|
||||
AppDbContext db,
|
||||
OfficialDocumentOptions options,
|
||||
IOfficialDocumentPdfGenerator pdfGenerator)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<OfficialDocument> CreateAsync(
|
||||
Guid studentId,
|
||||
OfficialDocumentType type,
|
||||
string? purpose,
|
||||
Guid issuedByUserId,
|
||||
string issuedByName,
|
||||
string verificationBaseUrl,
|
||||
Guid? reissuedFromDocumentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Where(x => x.Id == studentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.Gender,
|
||||
x.EnrollmentYear,
|
||||
x.EnrollmentDate,
|
||||
x.Status,
|
||||
CollegeName = x.AdministrativeClass!.Major!.College!.Name,
|
||||
MajorName = x.AdministrativeClass.Major.Name,
|
||||
ClassName = x.AdministrativeClass.Name
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new OfficialDocumentValidationException("学生档案不存在。");
|
||||
|
||||
var grades = type == OfficialDocumentType.Transcript
|
||||
? await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.OrderBy(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
|
||||
.Select(x => new OfficialTranscriptRow(
|
||||
x.GradeSheet!.TeachingTask!.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTask.Course!.Code,
|
||||
x.GradeSheet.TeachingTask.Course.Name,
|
||||
x.GradeSheet.TeachingTask.Course.Credits,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
GradeExamStatusLabel(x.ExamStatus)))
|
||||
.ToListAsync(cancellationToken)
|
||||
: [];
|
||||
|
||||
if (type == OfficialDocumentType.Transcript && grades.Count == 0)
|
||||
throw new OfficialDocumentValidationException("该学生暂无已发布成绩,不能签发官方成绩单。");
|
||||
|
||||
var issuedAt = DateTime.UtcNow;
|
||||
var documentNumber = CreateDocumentNumber(type, issuedAt);
|
||||
var verificationCode = CreateVerificationCode();
|
||||
var studentSnapshot = new OfficialStudentSnapshot(
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
GenderLabel(student.Gender),
|
||||
student.CollegeName,
|
||||
student.MajorName,
|
||||
student.ClassName,
|
||||
student.EnrollmentYear,
|
||||
student.EnrollmentDate,
|
||||
StudentStatusLabel(student.Status));
|
||||
var totalCredits = grades.Sum(x => x.Credits);
|
||||
var passedGrades = grades
|
||||
.Where(x => x.Score >= 60 || x.ExamStatus == "免修")
|
||||
.ToArray();
|
||||
var gpaGrades = grades.Where(x => x.GradePoint.HasValue).ToArray();
|
||||
var gpaCredits = gpaGrades.Sum(x => x.Credits);
|
||||
var gpa = gpaCredits > 0
|
||||
? gpaGrades.Sum(x => x.GradePoint!.Value * x.Credits) / gpaCredits
|
||||
: (decimal?)null;
|
||||
var snapshot = new OfficialDocumentSnapshot(
|
||||
options.InstitutionName,
|
||||
options.IssuingOffice,
|
||||
documentNumber,
|
||||
type,
|
||||
issuedAt,
|
||||
issuedByName,
|
||||
string.IsNullOrWhiteSpace(purpose) ? null : purpose.Trim(),
|
||||
studentSnapshot,
|
||||
grades,
|
||||
totalCredits,
|
||||
passedGrades.Sum(x => x.Credits),
|
||||
gpa);
|
||||
var verificationUrl = $"{verificationBaseUrl.TrimEnd('/')}/verify/{verificationCode}";
|
||||
var generated = pdfGenerator.Generate(snapshot, verificationUrl);
|
||||
|
||||
return new OfficialDocument
|
||||
{
|
||||
DocumentNumber = documentNumber,
|
||||
VerificationCodeHash = HashVerificationCode(verificationCode),
|
||||
Type = type,
|
||||
Status = OfficialDocumentStatus.Valid,
|
||||
StudentId = studentId,
|
||||
IssuedByUserId = issuedByUserId,
|
||||
IssuedAt = issuedAt,
|
||||
Purpose = snapshot.Purpose,
|
||||
SnapshotJson = JsonSerializer.Serialize(snapshot, SnapshotJsonOptions),
|
||||
PdfContent = generated.Content,
|
||||
PdfSha256 = generated.Sha256,
|
||||
ReissuedFromDocumentId = reissuedFromDocumentId
|
||||
};
|
||||
}
|
||||
|
||||
public static string HashVerificationCode(string verificationCode) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(verificationCode)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
public static OfficialDocumentSnapshot DeserializeSnapshot(string json) =>
|
||||
JsonSerializer.Deserialize<OfficialDocumentSnapshot>(json, SnapshotJsonOptions)
|
||||
?? throw new InvalidOperationException("官方凭证快照无法读取。");
|
||||
|
||||
private string CreateDocumentNumber(OfficialDocumentType type, DateTime issuedAt)
|
||||
{
|
||||
var typeCode = type == OfficialDocumentType.Transcript ? "TR" : "SC";
|
||||
var random = Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
|
||||
return $"{options.DocumentNumberPrefix}-{typeCode}-{issuedAt:yyyyMMdd}-{random}";
|
||||
}
|
||||
|
||||
private static string CreateVerificationCode() =>
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
|
||||
|
||||
private static string GenderLabel(Gender value) => value switch
|
||||
{
|
||||
Gender.Male => "男",
|
||||
Gender.Female => "女",
|
||||
_ => "未登记"
|
||||
};
|
||||
|
||||
private static string StudentStatusLabel(StudentStatus value) => value switch
|
||||
{
|
||||
StudentStatus.Active => "在读",
|
||||
StudentStatus.Suspended => "休学",
|
||||
StudentStatus.Graduated => "已毕业",
|
||||
StudentStatus.Withdrawn => "退学",
|
||||
_ => "未知"
|
||||
};
|
||||
|
||||
private static string GradeExamStatusLabel(GradeExamStatus value) => value switch
|
||||
{
|
||||
GradeExamStatus.Normal => "正常",
|
||||
GradeExamStatus.Absent => "缺考",
|
||||
GradeExamStatus.Deferred => "缓考",
|
||||
GradeExamStatus.Exempt => "免修",
|
||||
GradeExamStatus.Makeup => "补考",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class OfficialDocumentValidationException(string message) : Exception(message);
|
||||
@@ -0,0 +1,41 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public sealed record OfficialDocumentSnapshot(
|
||||
string InstitutionName,
|
||||
string IssuingOffice,
|
||||
string DocumentNumber,
|
||||
OfficialDocumentType Type,
|
||||
DateTime IssuedAt,
|
||||
string IssuedByName,
|
||||
string? Purpose,
|
||||
OfficialStudentSnapshot Student,
|
||||
IReadOnlyList<OfficialTranscriptRow> Grades,
|
||||
decimal TotalCredits,
|
||||
decimal EarnedCredits,
|
||||
decimal? GradePointAverage);
|
||||
|
||||
public sealed record OfficialStudentSnapshot(
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
string Gender,
|
||||
string CollegeName,
|
||||
string MajorName,
|
||||
string ClassName,
|
||||
int EnrollmentYear,
|
||||
DateOnly EnrollmentDate,
|
||||
string Status);
|
||||
|
||||
public sealed record OfficialTranscriptRow(
|
||||
string AcademicTerm,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
decimal? Score,
|
||||
decimal? GradePoint,
|
||||
string ExamStatus);
|
||||
|
||||
public sealed record GeneratedOfficialDocument(
|
||||
byte[] Content,
|
||||
string Sha256);
|
||||
@@ -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>();
|
||||
@@ -41,8 +42,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<AutomaticScheduleJob>();
|
||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||
Set<SchedulePublishJob>();
|
||||
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||
Set<ClassroomReservation>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
Set<CourseSelectionRound>();
|
||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||
Set<CourseSelectionRoundGrade>();
|
||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||
Set<CourseSelectionOffering>();
|
||||
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
||||
@@ -75,6 +80,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
|
||||
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
|
||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||
Set<StudentStatusChange>();
|
||||
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
||||
@@ -89,7 +95,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<GraduationClearanceItem>();
|
||||
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
|
||||
Set<GraduationClearanceRecord>();
|
||||
public DbSet<OfficialDocument> OfficialDocuments => Set<OfficialDocument>();
|
||||
public DbSet<OfficialDocumentDownload> OfficialDocumentDownloads =>
|
||||
Set<OfficialDocumentDownload>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
||||
Set<BackgroundJobOutboxMessage>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
ModelConfigurationBuilder configurationBuilder)
|
||||
@@ -117,6 +128,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.DisplayName).HasMaxLength(50);
|
||||
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
|
||||
entity.HasIndex(x => x.StaffNumber);
|
||||
});
|
||||
|
||||
@@ -236,6 +248,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);
|
||||
@@ -465,6 +491,49 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<ClassroomReservation>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ApplicantName).HasMaxLength(50);
|
||||
entity.Property(x => x.Purpose).HasMaxLength(200);
|
||||
entity.Property(x => x.ContactPhone).HasMaxLength(30);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.ApplicantUserId, x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ApplicantCollegeId,
|
||||
x.Status,
|
||||
x.ReservationDate
|
||||
});
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ClassroomId,
|
||||
x.ReservationDate,
|
||||
x.Status,
|
||||
x.StartPeriod
|
||||
});
|
||||
entity.HasOne(x => x.ApplicantUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApplicantUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.ApplicantCollege)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApplicantCollegeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.AcademicTerm)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.AcademicTermId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Classroom)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ClassroomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.ReviewedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ReviewedByUserId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionRound>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -477,6 +546,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionRoundGrade>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.CourseSelectionRoundId, x.Grade })
|
||||
.IsUnique();
|
||||
entity.HasIndex(x => x.Grade);
|
||||
entity.HasOne(x => x.CourseSelectionRound)
|
||||
.WithMany(x => x.EligibleGrades)
|
||||
.HasForeignKey(x => x.CourseSelectionRoundId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionOffering>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
@@ -500,6 +580,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
|
||||
.IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.Status });
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.CourseSelectionOfferingId,
|
||||
x.Status,
|
||||
x.WaitlistedAt
|
||||
}).HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt");
|
||||
entity.HasOne(x => x.CourseSelectionOffering)
|
||||
.WithMany(x => x.Enrollments)
|
||||
.HasForeignKey(x => x.CourseSelectionOfferingId)
|
||||
@@ -882,6 +968,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
|
||||
entity.HasIndex(x => x.ApplicantUserId);
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.SourceScheduleEntryId, x.SourceWeek });
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Classroom).WithMany()
|
||||
@@ -893,10 +980,26 @@ 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 });
|
||||
entity.HasIndex(x => x.MessageDispatchId);
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
entity.HasOne(x => x.MessageDispatch)
|
||||
.WithMany(x => x.Notifications)
|
||||
.HasForeignKey(x => x.MessageDispatchId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MessageDispatch>(entity =>
|
||||
{
|
||||
entity.Property(x => x.SenderName).HasMaxLength(100);
|
||||
entity.Property(x => x.Title).HasMaxLength(200);
|
||||
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 });
|
||||
});
|
||||
|
||||
builder.Entity<AuditLog>(entity =>
|
||||
@@ -907,6 +1010,63 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
|
||||
builder.Entity<BackgroundJobOutboxMessage>(entity =>
|
||||
{
|
||||
entity.Property(x => x.LastError).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.JobKind, x.JobId }).IsUnique();
|
||||
entity.HasIndex(x => new { x.State, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.State, x.CompletedAt });
|
||||
entity.HasIndex(x => x.LeaseExpiresAt);
|
||||
});
|
||||
|
||||
builder.Entity<OfficialDocument>(entity =>
|
||||
{
|
||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||
entity.Property(x => x.VerificationCodeHash).HasMaxLength(64);
|
||||
entity.Property(x => x.Purpose).HasMaxLength(200);
|
||||
entity.Property(x => x.SnapshotJson).HasColumnType("longtext");
|
||||
entity.Property(x => x.PdfContent).HasColumnType("longblob");
|
||||
entity.Property(x => x.PdfSha256).HasMaxLength(64);
|
||||
entity.Property(x => x.InvalidationReason).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.DocumentNumber).IsUnique();
|
||||
entity.HasIndex(x => x.VerificationCodeHash).IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.IssuedAt });
|
||||
entity.HasIndex(x => new { x.Status, x.IssuedAt });
|
||||
entity.HasOne(x => x.Student)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.IssuedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.IssuedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.InvalidatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.InvalidatedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasIndex(x => x.ReissuedFromDocumentId).IsUnique();
|
||||
entity.HasOne(x => x.ReissuedFromDocument)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ReissuedFromDocumentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<OfficialDocumentDownload>(entity =>
|
||||
{
|
||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.OfficialDocumentId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.DownloadedByUserId, x.CreatedAt });
|
||||
entity.HasOne(x => x.OfficialDocument)
|
||||
.WithMany(x => x.Downloads)
|
||||
.HasForeignKey(x => x.OfficialDocumentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.DownloadedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.DownloadedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbConte
|
||||
?? "Server=localhost;Port=3306;Database=jiaowu;User=__design_time__;Password=__not_used__;";
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseMySQL(connectionString)
|
||||
.UseMySQL(MySqlConnectionStringPolicy.ForApplication(connectionString))
|
||||
.Options;
|
||||
|
||||
return new AppDbContext(options);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.Data;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public sealed class DemoDataSeeder(
|
||||
AppDbContext db,
|
||||
IAppCache cache,
|
||||
ILogger<DemoDataSeeder> logger)
|
||||
{
|
||||
// MySql.EntityFrameworkCore 10 cannot type-map parameterized primitive
|
||||
@@ -47,6 +49,10 @@ public sealed class DemoDataSeeder(
|
||||
await LogSummaryAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
});
|
||||
|
||||
await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Analytics, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureDatabaseIsEmptyAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -48,6 +48,22 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260725_25_academic_warnings";
|
||||
private const string AcademicTermArchivingMigration =
|
||||
"20260726_27_academic_term_archiving";
|
||||
private const string CourseSelectionWaitlistMigration =
|
||||
"20260726_28_course_selection_waitlist";
|
||||
private const string PersonalCalendarSubscriptionMigration =
|
||||
"20260726_29_personal_calendar_subscription";
|
||||
private const string OfficialDocumentsMigration =
|
||||
"20260726_30_official_documents";
|
||||
private const string UnifiedMessageCenterMigration =
|
||||
"20260726_31_unified_message_center";
|
||||
private const string ClassroomReservationsMigration =
|
||||
"20260726_32_classroom_reservations";
|
||||
private const string BackgroundJobOutboxMigration =
|
||||
"20260726_33_background_job_outbox";
|
||||
private const string CourseAdjustmentOccurrencesMigration =
|
||||
"20260727_34_course_adjustment_occurrences";
|
||||
private const string AcademicPlanningPrerequisitesMigration =
|
||||
"20260727_35_academic_planning_prerequisites";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -266,6 +282,21 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
courseAdjustmentsExist ? [] : CourseAdjustmentsStatements,
|
||||
cancellationToken);
|
||||
|
||||
var courseAdjustmentOccurrencesExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('CourseAdjustments')
|
||||
WHERE name = 'SourceScheduleEntryId'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
CourseAdjustmentOccurrencesMigration,
|
||||
courseAdjustmentOccurrencesExist
|
||||
? []
|
||||
: CourseAdjustmentOccurrencesStatements,
|
||||
cancellationToken);
|
||||
|
||||
var attendanceAppealExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
@@ -344,6 +375,99 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
AcademicTermArchivingMigration,
|
||||
academicTermArchivingExists ? [] : AcademicTermArchivingStatements,
|
||||
cancellationToken);
|
||||
|
||||
var courseSelectionWaitlistExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('CourseEnrollments')
|
||||
WHERE name = 'WaitlistedAt'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
CourseSelectionWaitlistMigration,
|
||||
courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements,
|
||||
cancellationToken);
|
||||
|
||||
var personalCalendarSubscriptionExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('AspNetUsers')
|
||||
WHERE name = 'CalendarSubscriptionStamp'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
PersonalCalendarSubscriptionMigration,
|
||||
personalCalendarSubscriptionExists
|
||||
? []
|
||||
: PersonalCalendarSubscriptionStatements,
|
||||
cancellationToken);
|
||||
|
||||
var officialDocumentsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'OfficialDocuments'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
OfficialDocumentsMigration,
|
||||
officialDocumentsExist ? [] : OfficialDocumentStatements,
|
||||
cancellationToken);
|
||||
|
||||
var messageDispatchesExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'MessageDispatches'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
UnifiedMessageCenterMigration,
|
||||
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
|
||||
cancellationToken);
|
||||
|
||||
var classroomReservationsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'ClassroomReservations'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ClassroomReservationsMigration,
|
||||
classroomReservationsExist ? [] : ClassroomReservationStatements,
|
||||
cancellationToken);
|
||||
|
||||
var backgroundJobOutboxExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'BackgroundJobOutboxMessages'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
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);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1589,6 +1713,88 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseSelectionWaitlistStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "CourseEnrollments"
|
||||
ADD COLUMN "WaitlistedAt" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_CE_Offering_Status_WaitlistedAt"
|
||||
ON "CourseEnrollments" ("CourseSelectionOfferingId", "Status", "WaitlistedAt");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] PersonalCalendarSubscriptionStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "AspNetUsers"
|
||||
ADD COLUMN "CalendarSubscriptionStamp" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "AspNetUsers"
|
||||
ADD COLUMN "CalendarSubscriptionCreatedAt" TEXT NULL;
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] OfficialDocumentStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "OfficialDocuments" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_OfficialDocuments" PRIMARY KEY,
|
||||
"DocumentNumber" TEXT NOT NULL,
|
||||
"VerificationCodeHash" TEXT NOT NULL,
|
||||
"Type" INTEGER NOT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"IssuedByUserId" TEXT NOT NULL,
|
||||
"IssuedAt" TEXT NOT NULL,
|
||||
"Purpose" TEXT NULL,
|
||||
"SnapshotJson" TEXT NOT NULL,
|
||||
"PdfContent" BLOB NOT NULL,
|
||||
"PdfSha256" TEXT NOT NULL,
|
||||
"InvalidatedAt" TEXT NULL,
|
||||
"InvalidatedByUserId" TEXT NULL,
|
||||
"InvalidationReason" TEXT NULL,
|
||||
"ReissuedFromDocumentId" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_OfficialDocuments_Students_StudentId"
|
||||
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_OfficialDocuments_AspNetUsers_IssuedByUserId"
|
||||
FOREIGN KEY ("IssuedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_OfficialDocuments_AspNetUsers_InvalidatedByUserId"
|
||||
FOREIGN KEY ("InvalidatedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_OfficialDocuments_ReissuedFromDocumentId"
|
||||
FOREIGN KEY ("ReissuedFromDocumentId") REFERENCES "OfficialDocuments" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""CREATE UNIQUE INDEX "IX_OfficialDocuments_DocumentNumber" ON "OfficialDocuments" ("DocumentNumber");""",
|
||||
"""CREATE UNIQUE INDEX "IX_OfficialDocuments_VerificationCodeHash" ON "OfficialDocuments" ("VerificationCodeHash");""",
|
||||
"""CREATE INDEX "IX_OfficialDocuments_StudentId_IssuedAt" ON "OfficialDocuments" ("StudentId", "IssuedAt");""",
|
||||
"""CREATE INDEX "IX_OfficialDocuments_Status_IssuedAt" ON "OfficialDocuments" ("Status", "IssuedAt");""",
|
||||
"""CREATE INDEX "IX_OfficialDocuments_IssuedByUserId" ON "OfficialDocuments" ("IssuedByUserId");""",
|
||||
"""CREATE INDEX "IX_OfficialDocuments_InvalidatedByUserId" ON "OfficialDocuments" ("InvalidatedByUserId");""",
|
||||
"""CREATE UNIQUE INDEX "IX_OfficialDocuments_ReissuedFromDocumentId" ON "OfficialDocuments" ("ReissuedFromDocumentId");""",
|
||||
"""
|
||||
CREATE TABLE "OfficialDocumentDownloads" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_OfficialDocumentDownloads" PRIMARY KEY,
|
||||
"OfficialDocumentId" TEXT NOT NULL,
|
||||
"DownloadedByUserId" TEXT NOT NULL,
|
||||
"IpAddress" TEXT NULL,
|
||||
"UserAgent" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_OfficialDocumentDownloads_OfficialDocuments"
|
||||
FOREIGN KEY ("OfficialDocumentId") REFERENCES "OfficialDocuments" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_OfficialDocumentDownloads_AspNetUsers"
|
||||
FOREIGN KEY ("DownloadedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""CREATE INDEX "IX_OfficialDocumentDownloads_OfficialDocumentId_CreatedAt" ON "OfficialDocumentDownloads" ("OfficialDocumentId", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_OfficialDocumentDownloads_DownloadedByUserId_CreatedAt" ON "OfficialDocumentDownloads" ("DownloadedByUserId", "CreatedAt");"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseAdjustmentsStatements =
|
||||
[
|
||||
"""
|
||||
@@ -1598,6 +1804,12 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"Type" INTEGER NOT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"ApplicantUserId" TEXT NOT NULL,
|
||||
"SourceScheduleEntryId" TEXT NULL,
|
||||
"SourceWeek" INTEGER NULL,
|
||||
"SourceDate" TEXT NULL,
|
||||
"SourceStartPeriod" INTEGER NULL,
|
||||
"SourcePeriodCount" INTEGER NULL,
|
||||
"SourceClassroomId" TEXT NULL,
|
||||
"TargetDate" TEXT NULL,
|
||||
"DayOfWeek" INTEGER NULL,
|
||||
"StartPeriod" INTEGER NULL,
|
||||
@@ -1643,6 +1855,17 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE INDEX "IX_Notifications_CreatedAt" ON "Notifications" ("CreatedAt");"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseAdjustmentOccurrencesStatements =
|
||||
[
|
||||
"""ALTER TABLE "CourseAdjustments" ADD "SourceScheduleEntryId" TEXT NULL;""",
|
||||
"""ALTER TABLE "CourseAdjustments" ADD "SourceWeek" INTEGER NULL;""",
|
||||
"""ALTER TABLE "CourseAdjustments" ADD "SourceDate" TEXT NULL;""",
|
||||
"""ALTER TABLE "CourseAdjustments" ADD "SourceStartPeriod" INTEGER NULL;""",
|
||||
"""ALTER TABLE "CourseAdjustments" ADD "SourcePeriodCount" INTEGER NULL;""",
|
||||
"""ALTER TABLE "CourseAdjustments" ADD "SourceClassroomId" TEXT NULL;""",
|
||||
"""CREATE INDEX "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek" ON "CourseAdjustments" ("SourceScheduleEntryId", "SourceWeek");"""
|
||||
];
|
||||
|
||||
private static readonly string[] AttendanceAppealStatements =
|
||||
[
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealStatus" INTEGER NOT NULL DEFAULT 0;""",
|
||||
@@ -1747,4 +1970,166 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
||||
];
|
||||
|
||||
private static readonly string[] UnifiedMessageCenterStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "MessageDispatches" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_MessageDispatches" PRIMARY KEY,
|
||||
"SenderUserId" TEXT NOT NULL,
|
||||
"SenderName" TEXT NOT NULL,
|
||||
"Title" TEXT NOT NULL,
|
||||
"Content" TEXT NOT NULL,
|
||||
"Category" INTEGER NOT NULL,
|
||||
"AudienceType" INTEGER NOT NULL,
|
||||
"AudienceId" TEXT NULL,
|
||||
"AudienceName" TEXT NOT NULL,
|
||||
"RecipientCount" INTEGER NOT NULL,
|
||||
"LinkUrl" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""CREATE INDEX "IX_MessageDispatches_SenderUserId_CreatedAt" ON "MessageDispatches" ("SenderUserId", "CreatedAt");""",
|
||||
"""ALTER TABLE "Notifications" ADD COLUMN "Category" INTEGER NOT NULL DEFAULT 1;""",
|
||||
"""ALTER TABLE "Notifications" ADD COLUMN "MessageDispatchId" TEXT NULL REFERENCES "MessageDispatches" ("Id") ON DELETE CASCADE;""",
|
||||
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
|
||||
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
|
||||
];
|
||||
|
||||
private static readonly string[] ClassroomReservationStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "ClassroomReservations" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ClassroomReservations" PRIMARY KEY,
|
||||
"ApplicantUserId" TEXT NOT NULL,
|
||||
"ApplicantName" TEXT NOT NULL,
|
||||
"ApplicantCollegeId" TEXT NOT NULL,
|
||||
"AcademicTermId" TEXT NOT NULL,
|
||||
"ClassroomId" TEXT NOT NULL,
|
||||
"ReservationDate" TEXT NOT NULL,
|
||||
"StartPeriod" INTEGER NOT NULL,
|
||||
"PeriodCount" INTEGER NOT NULL,
|
||||
"AttendeeCount" INTEGER NOT NULL,
|
||||
"Purpose" TEXT NOT NULL,
|
||||
"ContactPhone" TEXT NOT NULL,
|
||||
"Notes" TEXT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"ReviewedByUserId" TEXT NULL,
|
||||
"ReviewedAt" TEXT NULL,
|
||||
"ReviewComment" TEXT NULL,
|
||||
"CancelledAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ApplicantUserId"
|
||||
FOREIGN KEY ("ApplicantUserId") REFERENCES "AspNetUsers" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ClassroomReservations_Colleges_ApplicantCollegeId"
|
||||
FOREIGN KEY ("ApplicantCollegeId") REFERENCES "Colleges" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ClassroomReservations_AcademicTerms_AcademicTermId"
|
||||
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ClassroomReservations_Classrooms_ClassroomId"
|
||||
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId"
|
||||
FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id")
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt"
|
||||
ON "ClassroomReservations" ("ApplicantUserId", "Status", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationDate"
|
||||
ON "ClassroomReservations" (
|
||||
"ApplicantCollegeId",
|
||||
"Status",
|
||||
"ReservationDate"
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_StartPeriod"
|
||||
ON "ClassroomReservations" (
|
||||
"ClassroomId",
|
||||
"ReservationDate",
|
||||
"Status",
|
||||
"StartPeriod"
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ClassroomReservations_AcademicTermId"
|
||||
ON "ClassroomReservations" ("AcademicTermId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_ClassroomReservations_ReviewedByUserId"
|
||||
ON "ClassroomReservations" ("ReviewedByUserId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] BackgroundJobOutboxStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "BackgroundJobOutboxMessages" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_BackgroundJobOutboxMessages" PRIMARY KEY,
|
||||
"JobKind" INTEGER NOT NULL,
|
||||
"JobId" TEXT NOT NULL,
|
||||
"State" INTEGER NOT NULL,
|
||||
"PublishAttempts" INTEGER NOT NULL,
|
||||
"ProcessingAttempts" INTEGER NOT NULL,
|
||||
"PublishedAt" TEXT NULL,
|
||||
"ProcessingToken" TEXT NULL,
|
||||
"LeaseExpiresAt" TEXT NULL,
|
||||
"CompletedAt" TEXT NULL,
|
||||
"LastError" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_BackgroundJobOutboxMessages_JobKind_JobId"
|
||||
ON "BackgroundJobOutboxMessages" ("JobKind", "JobId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_BackgroundJobOutboxMessages_LeaseExpiresAt"
|
||||
ON "BackgroundJobOutboxMessages" ("LeaseExpiresAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_BackgroundJobOutboxMessages_State_CreatedAt"
|
||||
ON "BackgroundJobOutboxMessages" ("State", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_BackgroundJobOutboxMessages_State_CompletedAt"
|
||||
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");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+4579
File diff suppressed because it is too large
Load Diff
+156
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OfficialDocuments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OfficialDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
DocumentNumber = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||
VerificationCodeHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||
Type = table.Column<int>(type: "int", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
IssuedByUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
IssuedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||
SnapshotJson = table.Column<string>(type: "longtext", nullable: false),
|
||||
PdfContent = table.Column<byte[]>(type: "longblob", nullable: false),
|
||||
PdfSha256 = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||
InvalidatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
InvalidatedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
InvalidationReason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
ReissuedFromDocumentId = table.Column<Guid>(type: "char(36)", 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_OfficialDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OfficialDocuments_AspNetUsers_InvalidatedByUserId",
|
||||
column: x => x.InvalidatedByUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_OfficialDocuments_AspNetUsers_IssuedByUserId",
|
||||
column: x => x.IssuedByUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_OfficialDocuments_OfficialDocuments_ReissuedFromDocumentId",
|
||||
column: x => x.ReissuedFromDocumentId,
|
||||
principalTable: "OfficialDocuments",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_OfficialDocuments_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OfficialDocumentDownloads",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
OfficialDocumentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
DownloadedByUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||
UserAgent = 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_OfficialDocumentDownloads", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OfficialDocumentDownloads_AspNetUsers_DownloadedByUserId",
|
||||
column: x => x.DownloadedByUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_OfficialDocumentDownloads_OfficialDocuments_OfficialDocument~",
|
||||
column: x => x.OfficialDocumentId,
|
||||
principalTable: "OfficialDocuments",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocumentDownloads_DownloadedByUserId_CreatedAt",
|
||||
table: "OfficialDocumentDownloads",
|
||||
columns: new[] { "DownloadedByUserId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocumentDownloads_OfficialDocumentId_CreatedAt",
|
||||
table: "OfficialDocumentDownloads",
|
||||
columns: new[] { "OfficialDocumentId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocuments_DocumentNumber",
|
||||
table: "OfficialDocuments",
|
||||
column: "DocumentNumber",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocuments_InvalidatedByUserId",
|
||||
table: "OfficialDocuments",
|
||||
column: "InvalidatedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocuments_IssuedByUserId",
|
||||
table: "OfficialDocuments",
|
||||
column: "IssuedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocuments_ReissuedFromDocumentId",
|
||||
table: "OfficialDocuments",
|
||||
column: "ReissuedFromDocumentId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocuments_Status_IssuedAt",
|
||||
table: "OfficialDocuments",
|
||||
columns: new[] { "Status", "IssuedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocuments_StudentId_IssuedAt",
|
||||
table: "OfficialDocuments",
|
||||
columns: new[] { "StudentId", "IssuedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OfficialDocuments_VerificationCodeHash",
|
||||
table: "OfficialDocuments",
|
||||
column: "VerificationCodeHash",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OfficialDocumentDownloads");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OfficialDocuments");
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260726170000_CourseSelectionWaitlist")]
|
||||
public partial class CourseSelectionWaitlist : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
SET @waitlisted_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM `INFORMATION_SCHEMA`.`COLUMNS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||
AND `COLUMN_NAME` = 'WaitlistedAt'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql("""
|
||||
SET @add_waitlisted_column_sql = IF(
|
||||
@waitlisted_column_exists = 0,
|
||||
'ALTER TABLE `CourseEnrollments` ADD COLUMN `WaitlistedAt` datetime(6) NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql(
|
||||
"PREPARE add_waitlisted_column_stmt FROM @add_waitlisted_column_sql;");
|
||||
migrationBuilder.Sql("EXECUTE add_waitlisted_column_stmt;");
|
||||
migrationBuilder.Sql("DEALLOCATE PREPARE add_waitlisted_column_stmt;");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
SET @waitlist_index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM `INFORMATION_SCHEMA`.`STATISTICS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||
AND `INDEX_NAME` = 'IX_CE_Offering_Status_WaitlistedAt'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql("""
|
||||
SET @add_waitlist_index_sql = IF(
|
||||
@waitlist_index_exists = 0,
|
||||
'CREATE INDEX `IX_CE_Offering_Status_WaitlistedAt` ON `CourseEnrollments` (`CourseSelectionOfferingId`, `Status`, `WaitlistedAt`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql(
|
||||
"PREPARE add_waitlist_index_stmt FROM @add_waitlist_index_sql;");
|
||||
migrationBuilder.Sql("EXECUTE add_waitlist_index_stmt;");
|
||||
migrationBuilder.Sql("DEALLOCATE PREPARE add_waitlist_index_stmt;");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
SET @waitlist_index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM `INFORMATION_SCHEMA`.`STATISTICS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||
AND `INDEX_NAME` = 'IX_CE_Offering_Status_WaitlistedAt'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql("""
|
||||
SET @drop_waitlist_index_sql = IF(
|
||||
@waitlist_index_exists > 0,
|
||||
'DROP INDEX `IX_CE_Offering_Status_WaitlistedAt` ON `CourseEnrollments`',
|
||||
'SELECT 1'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql(
|
||||
"PREPARE drop_waitlist_index_stmt FROM @drop_waitlist_index_sql;");
|
||||
migrationBuilder.Sql("EXECUTE drop_waitlist_index_stmt;");
|
||||
migrationBuilder.Sql("DEALLOCATE PREPARE drop_waitlist_index_stmt;");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
SET @waitlisted_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM `INFORMATION_SCHEMA`.`COLUMNS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||
AND `COLUMN_NAME` = 'WaitlistedAt'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql("""
|
||||
SET @drop_waitlisted_column_sql = IF(
|
||||
@waitlisted_column_exists > 0,
|
||||
'ALTER TABLE `CourseEnrollments` DROP COLUMN `WaitlistedAt`',
|
||||
'SELECT 1'
|
||||
);
|
||||
""");
|
||||
migrationBuilder.Sql(
|
||||
"PREPARE drop_waitlisted_column_stmt FROM @drop_waitlisted_column_sql;");
|
||||
migrationBuilder.Sql("EXECUTE drop_waitlisted_column_stmt;");
|
||||
migrationBuilder.Sql("DEALLOCATE PREPARE drop_waitlisted_column_stmt;");
|
||||
}
|
||||
}
|
||||
+4391
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 CourseSelectionGradeLimits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxCourseCount",
|
||||
table: "CourseSelectionRounds",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CourseSelectionRoundGrades",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseSelectionRoundId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Grade = 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_CourseSelectionRoundGrades", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CourseSelectionRoundGrades_CourseSelectionRounds_CourseSelec~",
|
||||
column: x => x.CourseSelectionRoundId,
|
||||
principalTable: "CourseSelectionRounds",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade",
|
||||
table: "CourseSelectionRoundGrades",
|
||||
columns: new[] { "CourseSelectionRoundId", "Grade" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseSelectionRoundGrades_Grade",
|
||||
table: "CourseSelectionRoundGrades",
|
||||
column: "Grade");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CourseSelectionRoundGrades");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxCourseCount",
|
||||
table: "CourseSelectionRounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4398
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PersonalCalendarSubscription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CalendarSubscriptionCreatedAt",
|
||||
table: "AspNetUsers",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CalendarSubscriptionStamp",
|
||||
table: "AspNetUsers",
|
||||
type: "varchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CalendarSubscriptionCreatedAt",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CalendarSubscriptionStamp",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4662
File diff suppressed because it is too large
Load Diff
+102
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UnifiedMessageCenter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Category",
|
||||
table: "Notifications",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "MessageDispatchId",
|
||||
table: "Notifications",
|
||||
type: "char(36)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MessageDispatches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
SenderUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
SenderName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
|
||||
Category = table.Column<int>(type: "int", nullable: false),
|
||||
AudienceType = table.Column<int>(type: "int", nullable: false),
|
||||
AudienceId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
AudienceName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||
RecipientCount = table.Column<int>(type: "int", nullable: false),
|
||||
LinkUrl = table.Column<string>(type: "varchar(300)", maxLength: 300, 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_MessageDispatches", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notifications_MessageDispatchId",
|
||||
table: "Notifications",
|
||||
column: "MessageDispatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||
table: "Notifications",
|
||||
columns: new[] { "UserId", "Category", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MessageDispatches_SenderUserId_CreatedAt",
|
||||
table: "MessageDispatches",
|
||||
columns: new[] { "SenderUserId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
|
||||
table: "Notifications",
|
||||
column: "MessageDispatchId",
|
||||
principalTable: "MessageDispatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MessageDispatches");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Notifications_MessageDispatchId",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Category",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MessageDispatchId",
|
||||
table: "Notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4790
File diff suppressed because it is too large
Load Diff
+108
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ClassroomReservations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClassroomReservations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ApplicantName = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||
ApplicantCollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ReservationDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||
AttendeeCount = table.Column<int>(type: "int", nullable: false),
|
||||
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||
ContactPhone = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
|
||||
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
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_ClassroomReservations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClassroomReservations_AcademicTerms_AcademicTermId",
|
||||
column: x => x.AcademicTermId,
|
||||
principalTable: "AcademicTerms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClassroomReservations_AspNetUsers_ApplicantUserId",
|
||||
column: x => x.ApplicantUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId",
|
||||
column: x => x.ReviewedByUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClassroomReservations_Classrooms_ClassroomId",
|
||||
column: x => x.ClassroomId,
|
||||
principalTable: "Classrooms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClassroomReservations_Colleges_ApplicantCollegeId",
|
||||
column: x => x.ApplicantCollegeId,
|
||||
principalTable: "Colleges",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClassroomReservations_AcademicTermId",
|
||||
table: "ClassroomReservations",
|
||||
column: "AcademicTermId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationD~",
|
||||
table: "ClassroomReservations",
|
||||
columns: new[] { "ApplicantCollegeId", "Status", "ReservationDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt",
|
||||
table: "ClassroomReservations",
|
||||
columns: new[] { "ApplicantUserId", "Status", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_Sta~",
|
||||
table: "ClassroomReservations",
|
||||
columns: new[] { "ClassroomId", "ReservationDate", "Status", "StartPeriod" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClassroomReservations_ReviewedByUserId",
|
||||
table: "ClassroomReservations",
|
||||
column: "ReviewedByUserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClassroomReservations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4847
File diff suppressed because it is too large
Load Diff
+67
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BackgroundJobOutbox : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BackgroundJobOutboxMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
JobKind = table.Column<int>(type: "int", nullable: false),
|
||||
JobId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
State = table.Column<int>(type: "int", nullable: false),
|
||||
PublishAttempts = table.Column<int>(type: "int", nullable: false),
|
||||
ProcessingAttempts = table.Column<int>(type: "int", nullable: false),
|
||||
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ProcessingToken = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
LeaseExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
LastError = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BackgroundJobOutboxMessages", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BackgroundJobOutboxMessages_JobKind_JobId",
|
||||
table: "BackgroundJobOutboxMessages",
|
||||
columns: new[] { "JobKind", "JobId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BackgroundJobOutboxMessages_LeaseExpiresAt",
|
||||
table: "BackgroundJobOutboxMessages",
|
||||
column: "LeaseExpiresAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BackgroundJobOutboxMessages_State_CreatedAt",
|
||||
table: "BackgroundJobOutboxMessages",
|
||||
columns: new[] { "State", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BackgroundJobOutboxMessages_State_CompletedAt",
|
||||
table: "BackgroundJobOutboxMessages",
|
||||
columns: new[] { "State", "CompletedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BackgroundJobOutboxMessages");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4867
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CourseAdjustmentOccurrences : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "SourceClassroomId",
|
||||
table: "CourseAdjustments",
|
||||
type: "char(36)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "SourceDate",
|
||||
table: "CourseAdjustments",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SourcePeriodCount",
|
||||
table: "CourseAdjustments",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "SourceScheduleEntryId",
|
||||
table: "CourseAdjustments",
|
||||
type: "char(36)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SourceStartPeriod",
|
||||
table: "CourseAdjustments",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SourceWeek",
|
||||
table: "CourseAdjustments",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek",
|
||||
table: "CourseAdjustments",
|
||||
columns: new[] { "SourceScheduleEntryId", "SourceWeek" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek",
|
||||
table: "CourseAdjustments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceClassroomId",
|
||||
table: "CourseAdjustments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceDate",
|
||||
table: "CourseAdjustments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourcePeriodCount",
|
||||
table: "CourseAdjustments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceScheduleEntryId",
|
||||
table: "CourseAdjustments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceStartPeriod",
|
||||
table: "CourseAdjustments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceWeek",
|
||||
table: "CourseAdjustments");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+595
-17
@@ -40,13 +40,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<bool>("IsCurrent")
|
||||
b.Property<bool>("IsArchived")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsArchived")
|
||||
b.Property<bool>("IsCurrent")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
@@ -63,7 +63,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
@@ -74,10 +74,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsCurrent");
|
||||
|
||||
b.HasIndex("IsArchived");
|
||||
|
||||
b.HasIndex("IsCurrent");
|
||||
|
||||
b.HasIndex("IsEnabled", "SortOrder");
|
||||
|
||||
b.ToTable("AcademicTerms");
|
||||
@@ -465,6 +465,92 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("Classrooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("AcademicTermId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("ApplicantCollegeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("ApplicantName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<Guid>("ApplicantUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("AttendeeCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CancelledAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("ClassroomId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("ContactPhone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("varchar(30)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<int>("PeriodCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<DateTime>("ReservationDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("ReviewComment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<DateTime?>("ReviewedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("ReviewedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("StartPeriod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AcademicTermId");
|
||||
|
||||
b.HasIndex("ReviewedByUserId");
|
||||
|
||||
b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate");
|
||||
|
||||
b.HasIndex("ApplicantUserId", "Status", "CreatedAt");
|
||||
|
||||
b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod");
|
||||
|
||||
b.ToTable("ClassroomReservations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -595,7 +681,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid>("ApplicantUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateOnly?>("CancelDate")
|
||||
b.Property<DateTime?>("CancelDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int?>("CancelWeek")
|
||||
@@ -628,6 +714,24 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid?>("ReviewedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("SourceClassroomId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("SourceDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int?>("SourcePeriodCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("SourceScheduleEntryId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int?>("SourceStartPeriod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("SourceWeek")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("StartPeriod")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -640,7 +744,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid?>("SubstituteTeacherId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateOnly?>("TargetDate")
|
||||
b.Property<DateTime?>("TargetDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("TeachingTaskId")
|
||||
@@ -660,6 +764,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("SubstituteTeacherId");
|
||||
|
||||
b.HasIndex("SourceScheduleEntryId", "SourceWeek");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.HasIndex("TeachingTaskId", "Status");
|
||||
@@ -732,6 +838,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("WaitlistedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("WithdrawnAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -742,6 +851,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("StudentId", "Status");
|
||||
|
||||
b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt")
|
||||
.HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt");
|
||||
|
||||
b.ToTable("CourseEnrollments");
|
||||
});
|
||||
|
||||
@@ -796,6 +908,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("CourseExemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("CourseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("PrerequisiteCourseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PrerequisiteCourseId");
|
||||
|
||||
b.HasIndex("CourseId", "PrerequisiteCourseId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CoursePrerequisites");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -849,6 +989,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("MaxCourseCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("MaxCredits")
|
||||
.HasPrecision(6, 1)
|
||||
.HasColumnType("decimal(6,1)");
|
||||
@@ -881,6 +1024,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("CourseSelectionRounds");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("CourseSelectionRoundId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Grade")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Grade");
|
||||
|
||||
b.HasIndex("CourseSelectionRoundId", "Grade")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CourseSelectionRoundGrades");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1402,7 +1573,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateOnly>("ExamDate")
|
||||
b.Property<DateTime>("ExamDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("ExamPlanId")
|
||||
@@ -2112,7 +2283,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateOnly>("ExamDate")
|
||||
b.Property<DateTime>("ExamDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("MakeupExamPlanId")
|
||||
@@ -2173,16 +2344,75 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("MakeupExamSessionInvigilators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("AudienceId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("AudienceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<int>("AudienceType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("LinkUrl")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<int>("RecipientCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SenderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SenderUserId", "CreatedAt");
|
||||
|
||||
b.ToTable("MessageDispatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("varchar(1000)");
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
@@ -2194,6 +2424,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<Guid?>("MessageDispatchId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -2209,11 +2442,139 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("MessageDispatchId");
|
||||
|
||||
b.HasIndex("UserId", "IsRead");
|
||||
|
||||
b.HasIndex("UserId", "Category", "CreatedAt");
|
||||
|
||||
b.ToTable("Notifications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("DocumentNumber")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<DateTime?>("InvalidatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("InvalidatedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("InvalidationReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<DateTime>("IssuedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("IssuedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<byte[]>("PdfContent")
|
||||
.IsRequired()
|
||||
.HasColumnType("longblob");
|
||||
|
||||
b.Property<string>("PdfSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<Guid?>("ReissuedFromDocumentId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("SnapshotJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("StudentId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("VerificationCodeHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("InvalidatedByUserId");
|
||||
|
||||
b.HasIndex("IssuedByUserId");
|
||||
|
||||
b.HasIndex("ReissuedFromDocumentId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("VerificationCodeHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status", "IssuedAt");
|
||||
|
||||
b.HasIndex("StudentId", "IssuedAt");
|
||||
|
||||
b.ToTable("OfficialDocuments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("DownloadedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<Guid>("OfficialDocumentId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DownloadedByUserId", "CreatedAt");
|
||||
|
||||
b.HasIndex("OfficialDocumentId", "CreatedAt");
|
||||
|
||||
b.ToTable("OfficialDocumentDownloads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2386,7 +2747,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<TimeOnly>("EndsAt")
|
||||
b.Property<TimeSpan>("EndsAt")
|
||||
.HasColumnType("time");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
@@ -2400,7 +2761,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("PeriodNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<TimeOnly>("StartsAt")
|
||||
b.Property<TimeSpan>("StartsAt")
|
||||
.HasColumnType("time");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
@@ -2426,14 +2787,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateOnly?>("DateOfBirth")
|
||||
b.Property<DateTime?>("DateOfBirth")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<DateOnly>("EnrollmentDate")
|
||||
b.Property<DateTime>("EnrollmentDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("EnrollmentYear")
|
||||
@@ -2554,7 +2915,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("Gender")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateOnly?>("HireDate")
|
||||
b.Property<DateTime?>("HireDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
@@ -2979,6 +3340,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CalendarSubscriptionCreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CalendarSubscriptionStamp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<Guid?>("CollegeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -3101,6 +3469,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("AuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.System.BackgroundJobOutboxMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("JobId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("JobKind")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("varchar(2000)");
|
||||
|
||||
b.Property<DateTime?>("LeaseExpiresAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("ProcessingAttempts")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("ProcessingToken")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("PublishAttempts")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("PublishedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LeaseExpiresAt");
|
||||
|
||||
b.HasIndex("JobKind", "JobId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("State", "CompletedAt");
|
||||
|
||||
b.HasIndex("State", "CreatedAt");
|
||||
|
||||
b.ToTable("BackgroundJobOutboxMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3286,6 +3711,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Building");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
||||
.WithMany()
|
||||
.HasForeignKey("AcademicTermId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.College", "ApplicantCollege")
|
||||
.WithMany()
|
||||
.HasForeignKey("ApplicantCollegeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ApplicantUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ApplicantUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClassroomId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ReviewedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewedByUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("AcademicTerm");
|
||||
|
||||
b.Navigation("ApplicantCollege");
|
||||
|
||||
b.Navigation("ApplicantUser");
|
||||
|
||||
b.Navigation("Classroom");
|
||||
|
||||
b.Navigation("ReviewedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
||||
@@ -3377,6 +3844,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("TeachingTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||
.WithMany("Prerequisites")
|
||||
.HasForeignKey("CourseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "PrerequisiteCourse")
|
||||
.WithMany("RequiredByCourses")
|
||||
.HasForeignKey("PrerequisiteCourseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Course");
|
||||
|
||||
b.Navigation("PrerequisiteCourse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
|
||||
@@ -3407,6 +3893,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("AcademicTerm");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
|
||||
.WithMany("EligibleGrades")
|
||||
.HasForeignKey("CourseSelectionRoundId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CourseSelectionRound");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse")
|
||||
@@ -3897,6 +4394,68 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Teacher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.MessageDispatch", "MessageDispatch")
|
||||
.WithMany("Notifications")
|
||||
.HasForeignKey("MessageDispatchId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("MessageDispatch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("InvalidatedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "IssuedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("IssuedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "ReissuedFromDocument")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReissuedFromDocumentId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||
.WithMany()
|
||||
.HasForeignKey("StudentId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("InvalidatedByUser");
|
||||
|
||||
b.Navigation("IssuedByUser");
|
||||
|
||||
b.Navigation("ReissuedFromDocument");
|
||||
|
||||
b.Navigation("Student");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "DownloadedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("DownloadedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "OfficialDocument")
|
||||
.WithMany("Downloads")
|
||||
.HasForeignKey("OfficialDocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("DownloadedByUser");
|
||||
|
||||
b.Navigation("OfficialDocument");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||
@@ -4220,6 +4779,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Records");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
|
||||
{
|
||||
b.Navigation("Prerequisites");
|
||||
|
||||
b.Navigation("RequiredByCourses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||
{
|
||||
b.Navigation("Enrollments");
|
||||
@@ -4227,6 +4793,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
|
||||
{
|
||||
b.Navigation("EligibleGrades");
|
||||
|
||||
b.Navigation("Offerings");
|
||||
});
|
||||
|
||||
@@ -4316,6 +4884,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Invigilators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
|
||||
{
|
||||
b.Navigation("Notifications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||
{
|
||||
b.Navigation("Downloads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public static class MySqlConnectionStringPolicy
|
||||
{
|
||||
public static string ForApplication(string connectionString)
|
||||
{
|
||||
var builder = new MySqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
AllowUserVariables = true
|
||||
};
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class AutomaticScheduleJobQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void Enqueue(Guid jobId)
|
||||
{
|
||||
if (!_channel.Writer.TryWrite(jobId))
|
||||
throw new InvalidOperationException("自动排课任务队列当前不可用。");
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AutomaticScheduleJobWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
AutomaticScheduleJobQueue queue,
|
||||
ILogger<AutomaticScheduleJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider
|
||||
.GetRequiredService<AutomaticScheduleJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Unexpected failure while dispatching automatic schedule job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Automatic schedule job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobs = await db.AutomaticScheduleJobs
|
||||
.Where(x =>
|
||||
x.Status == AutomaticScheduleJobStatus.Queued ||
|
||||
x.Status == AutomaticScheduleJobStatus.Running)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
job.Status = AutomaticScheduleJobStatus.Queued;
|
||||
job.ActiveSchedulePlanId = job.SchedulePlanId;
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
}
|
||||
|
||||
if (jobs.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var job in jobs)
|
||||
queue.Enqueue(job.Id);
|
||||
|
||||
if (jobs.Count > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Recovered {JobCount} queued or interrupted automatic schedule jobs.",
|
||||
jobs.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AutomaticScheduleJobProcessor(
|
||||
AppDbContext db,
|
||||
AutomaticScheduleGenerator generator,
|
||||
|
||||
@@ -1,108 +1,15 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class SchedulePublishJobQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void Enqueue(Guid jobId)
|
||||
{
|
||||
if (!_channel.Writer.TryWrite(jobId))
|
||||
throw new InvalidOperationException("课表发布任务队列当前不可用。");
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class SchedulePublishJobWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
SchedulePublishJobQueue queue,
|
||||
ILogger<SchedulePublishJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider
|
||||
.GetRequiredService<SchedulePublishJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Unexpected failure while dispatching schedule publish job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Schedule publish job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobs = await db.SchedulePublishJobs
|
||||
.Where(x =>
|
||||
x.Status == SchedulePublishJobStatus.Queued ||
|
||||
x.Status == SchedulePublishJobStatus.Running)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
job.Status = SchedulePublishJobStatus.Queued;
|
||||
job.ActiveAcademicTermId = job.AcademicTermId;
|
||||
job.CompletedSteps = 0;
|
||||
job.CurrentStep = "等待后台检查";
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
}
|
||||
|
||||
if (jobs.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var job in jobs)
|
||||
queue.Enqueue(job.Id);
|
||||
|
||||
if (jobs.Count > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Recovered {JobCount} queued or interrupted schedule publish jobs.",
|
||||
jobs.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SchedulePublishJobProcessor(
|
||||
AppDbContext db,
|
||||
SchedulePlanPublisher publisher,
|
||||
IAppCache cache,
|
||||
ILogger<SchedulePublishJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
@@ -177,6 +84,7 @@ public sealed class SchedulePublishJobProcessor(
|
||||
},
|
||||
stoppingToken);
|
||||
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
|
||||
logger.LogInformation(
|
||||
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
||||
jobId,
|
||||
|
||||
@@ -75,11 +75,11 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
||||
{
|
||||
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
|
||||
if (rule.NotifyStudent && si.UserId.HasValue)
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
||||
if (rule.NotifyCounselor)
|
||||
{
|
||||
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||
|
||||
public static class TeachingTaskRosterQuery
|
||||
{
|
||||
public static IQueryable<Student> ForTask(AppDbContext db, Guid teachingTaskId) =>
|
||||
db.Students.Where(student =>
|
||||
(student.Status == StudentStatus.Active &&
|
||||
db.TeachingTaskClasses.Any(assignment =>
|
||||
assignment.TeachingTaskId == teachingTaskId &&
|
||||
assignment.AdministrativeClassId == student.AdministrativeClassId)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||
|
||||
public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
||||
{
|
||||
public async Task<HashSet<Guid>> GetOccupiedClassroomIdsAsync(
|
||||
AcademicTerm term,
|
||||
DateOnly reservationDate,
|
||||
int startPeriod,
|
||||
int periodCount,
|
||||
Guid? excludedReservationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var occupiedIds = new HashSet<Guid>();
|
||||
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
|
||||
|
||||
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(entry =>
|
||||
entry.ClassroomId.HasValue &&
|
||||
entry.SchedulePlan!.AcademicTermId == term.Id &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.DayOfWeek == dayOfWeek &&
|
||||
entry.StartWeek <= week &&
|
||||
entry.EndWeek >= week &&
|
||||
entry.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < entry.StartPeriod + entry.PeriodCount)
|
||||
.Select(entry => new
|
||||
{
|
||||
entry.ClassroomId,
|
||||
entry.WeekPattern,
|
||||
entry.StartPeriod,
|
||||
entry.PeriodCount
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var entry in scheduleEntries.Where(entry =>
|
||||
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
|
||||
FreeClassroomRules.PeriodsOverlap(
|
||||
startPeriod,
|
||||
periodCount,
|
||||
entry.StartPeriod,
|
||||
entry.PeriodCount)))
|
||||
{
|
||||
occupiedIds.Add(entry.ClassroomId!.Value);
|
||||
}
|
||||
|
||||
var examRoomIds = await db.ExamSessions.AsNoTracking()
|
||||
.Where(session =>
|
||||
session.ClassroomId.HasValue &&
|
||||
session.ExamPlan!.AcademicTermId == term.Id &&
|
||||
session.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||
session.ExamDate == reservationDate &&
|
||||
session.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||
.Select(session => session.ClassroomId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
occupiedIds.UnionWith(examRoomIds);
|
||||
|
||||
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(session =>
|
||||
session.ClassroomId.HasValue &&
|
||||
session.MakeupExamPlan!.AcademicTermId == term.Id &&
|
||||
session.MakeupExamPlan.Status == MakeupExamPlanStatus.Published &&
|
||||
session.ExamDate == reservationDate &&
|
||||
session.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||
.Select(session => session.ClassroomId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
occupiedIds.UnionWith(makeupExamRoomIds);
|
||||
|
||||
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
||||
.Where(reservation =>
|
||||
reservation.AcademicTermId == term.Id &&
|
||||
reservation.Status == ClassroomReservationStatus.Approved &&
|
||||
reservation.ReservationDate == reservationDate &&
|
||||
reservation.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod <
|
||||
reservation.StartPeriod + reservation.PeriodCount);
|
||||
if (excludedReservationId.HasValue)
|
||||
reservationQuery = reservationQuery.Where(reservation =>
|
||||
reservation.Id != excludedReservationId.Value);
|
||||
var reservationRoomIds = await reservationQuery
|
||||
.Select(reservation => reservation.ClassroomId)
|
||||
.ToListAsync(cancellationToken);
|
||||
occupiedIds.UnionWith(reservationRoomIds);
|
||||
return occupiedIds;
|
||||
}
|
||||
|
||||
public static (int Week, int DayOfWeek) ResolveTeachingWeek(
|
||||
AcademicTerm term,
|
||||
DateOnly date)
|
||||
{
|
||||
var termStartDay = (int)term.StartDate.DayOfWeek;
|
||||
var daysSinceMonday = (termStartDay + 6) % 7;
|
||||
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
|
||||
var week = (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
|
||||
var dayOfWeek = ((int)date.DayOfWeek + 6) % 7 + 1;
|
||||
return (week, dayOfWeek);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||
|
||||
public sealed class PersonalCalendarService(
|
||||
AppDbContext db,
|
||||
TimetableDataService timetableDataService,
|
||||
IOptions<JwtOptions> jwtOptions)
|
||||
{
|
||||
private static readonly TimeSpan ChinaOffset = TimeSpan.FromHours(8);
|
||||
private readonly byte[] _signingKey = SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes($"{jwtOptions.Value.Key}|personal-calendar"));
|
||||
|
||||
public static string CreateStamp() =>
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
public string CreateAccessToken(ApplicationUser user)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
throw new InvalidOperationException("个人日历订阅尚未启用。");
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
$"{user.Id:N}:{user.CalendarSubscriptionStamp}");
|
||||
using var hmac = new HMACSHA256(_signingKey);
|
||||
return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload));
|
||||
}
|
||||
|
||||
public bool IsAccessTokenValid(ApplicationUser user, string accessToken)
|
||||
{
|
||||
if (!user.IsEnabled || string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
return false;
|
||||
|
||||
byte[] provided;
|
||||
try
|
||||
{
|
||||
provided = WebEncoders.Base64UrlDecode(accessToken);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expected = WebEncoders.Base64UrlDecode(CreateAccessToken(user));
|
||||
return provided.Length == expected.Length &&
|
||||
CryptographicOperations.FixedTimeEquals(provided, expected);
|
||||
}
|
||||
|
||||
public async Task<PersonalCalendarResult?> BuildAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == user.Id && x.Status == StudentStatus.Active)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.AdministrativeClassId,
|
||||
x.StudentNumber,
|
||||
x.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var teacher = student is null
|
||||
? await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.UserId == user.Id && x.Status == TeacherStatus.Active)
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
if (student is null && teacher is null) return null;
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.Add(ChinaOffset));
|
||||
var earliestDate = today.AddMonths(-2);
|
||||
var latestDate = today.AddYears(1);
|
||||
var termIds = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.EndDate >= earliestDate &&
|
||||
x.StartDate <= latestDate)
|
||||
.OrderBy(x => x.StartDate)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var calendarName = $"明序教务 · {user.DisplayName}个人教学日历";
|
||||
var builder = new IcsBuilder(calendarName);
|
||||
foreach (var termId in termIds)
|
||||
{
|
||||
var timetable = student is not null
|
||||
? await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
student.AdministrativeClassId,
|
||||
termId,
|
||||
null,
|
||||
false,
|
||||
student.Id,
|
||||
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||
cancellationToken)
|
||||
: await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacher!.Id,
|
||||
termId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (timetable is null) continue;
|
||||
|
||||
AddScheduledCourses(builder, timetable);
|
||||
AddExamEntries(builder, timetable, teacher is not null);
|
||||
AddFlexibleCourseReminders(builder, timetable);
|
||||
}
|
||||
|
||||
var makeupSessions = await LoadMakeupSessionsAsync(
|
||||
student?.Id,
|
||||
teacher?.Id,
|
||||
earliestDate,
|
||||
latestDate,
|
||||
cancellationToken);
|
||||
foreach (var session in makeupSessions)
|
||||
{
|
||||
builder.AddTimedEvent(
|
||||
$"makeup-{session.Id:N}@jiaowu",
|
||||
teacher is null
|
||||
? $"[补考] {session.CourseName}"
|
||||
: $"[补考监考] {session.CourseName}",
|
||||
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.StartsAt)),
|
||||
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.EndsAt)),
|
||||
JoinLocation(
|
||||
session.CampusName,
|
||||
session.BuildingName,
|
||||
session.ClassroomName),
|
||||
JoinDescription(
|
||||
session.PlanName,
|
||||
session.TaskName,
|
||||
session.Notes),
|
||||
"补考",
|
||||
session.UpdatedAt);
|
||||
}
|
||||
|
||||
return new PersonalCalendarResult(
|
||||
builder.Build(),
|
||||
calendarName,
|
||||
builder.EventCount);
|
||||
}
|
||||
|
||||
private static void AddScheduledCourses(IcsBuilder builder, TimetableData timetable)
|
||||
{
|
||||
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||
var termMonday = StartOfWeek(timetable.Term.StartDate);
|
||||
foreach (var entry in timetable.Entries)
|
||||
{
|
||||
if (!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||
!slots.TryGetValue(
|
||||
entry.StartPeriod + entry.PeriodCount - 1,
|
||||
out var endSlot))
|
||||
continue;
|
||||
|
||||
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
|
||||
{
|
||||
if (!OccursInWeek(entry.WeekPattern, week)) continue;
|
||||
var date = termMonday.AddDays((week - 1) * 7 + entry.DayOfWeek - 1);
|
||||
if (date < timetable.Term.StartDate || date > timetable.Term.EndDate)
|
||||
continue;
|
||||
|
||||
builder.AddTimedEvent(
|
||||
$"course-{entry.Id:N}-{date:yyyyMMdd}@jiaowu",
|
||||
entry.CourseName,
|
||||
ToUtc(date, startSlot.StartsAt),
|
||||
ToUtc(date, endSlot.EndsAt),
|
||||
JoinLocation(
|
||||
entry.CampusName,
|
||||
entry.BuildingName,
|
||||
entry.ClassroomName),
|
||||
JoinDescription(
|
||||
entry.TaskName,
|
||||
$"教师:{string.Join('、', entry.TeacherNames)}",
|
||||
$"教学班:{string.Join('、', entry.ClassNames)}",
|
||||
entry.Notes),
|
||||
"课程",
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExamEntries(
|
||||
IcsBuilder builder,
|
||||
TimetableData timetable,
|
||||
bool isTeacher)
|
||||
{
|
||||
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||
foreach (var entry in timetable.ExamEntries)
|
||||
{
|
||||
if (entry.ExamDate is not DateOnly examDate ||
|
||||
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||
!slots.TryGetValue(
|
||||
entry.StartPeriod + entry.PeriodCount - 1,
|
||||
out var endSlot))
|
||||
continue;
|
||||
|
||||
builder.AddTimedEvent(
|
||||
$"exam-{entry.Id:N}@jiaowu",
|
||||
isTeacher
|
||||
? $"[监考] {entry.CourseName}"
|
||||
: $"[考试] {entry.CourseName}",
|
||||
ToUtc(examDate, startSlot.StartsAt),
|
||||
ToUtc(examDate, endSlot.EndsAt),
|
||||
JoinLocation(
|
||||
entry.CampusName,
|
||||
entry.BuildingName,
|
||||
entry.ClassroomName),
|
||||
JoinDescription(
|
||||
entry.ExamPlanName,
|
||||
entry.TaskName,
|
||||
string.Join('、', entry.TeacherNames),
|
||||
entry.Notes),
|
||||
"考试",
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFlexibleCourseReminders(
|
||||
IcsBuilder builder,
|
||||
TimetableData timetable)
|
||||
{
|
||||
foreach (var course in timetable.FlexibleCourses)
|
||||
{
|
||||
builder.AddAllDayEvent(
|
||||
$"flexible-{course.Id:N}-{timetable.Term.Id:N}@jiaowu",
|
||||
$"[灵活安排] {course.CourseName}",
|
||||
timetable.Term.StartDate,
|
||||
JoinDescription(
|
||||
$"{course.StartWeek}—{course.EndWeek} 周",
|
||||
$"每周 {course.WeeklyHours} 学时",
|
||||
$"教师:{string.Join('、', course.TeacherNames)}",
|
||||
course.Notes),
|
||||
"灵活课程",
|
||||
course.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
|
||||
Guid? studentId,
|
||||
Guid? teacherId,
|
||||
DateOnly earliestDate,
|
||||
DateOnly latestDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!studentId.HasValue && !teacherId.HasValue) return [];
|
||||
var source = db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||
x.ExamDate >= earliestDate &&
|
||||
x.ExamDate <= latestDate);
|
||||
source = studentId.HasValue
|
||||
? source.Where(x => x.Enrollments.Any(e => e.StudentId == studentId.Value))
|
||||
: source.Where(x => x.Invigilators.Any(i => i.TeacherId == teacherId!.Value));
|
||||
|
||||
return await source
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartsAt)
|
||||
.Select(x => new PersonalMakeupSession(
|
||||
x.Id,
|
||||
x.MakeupExamPlan!.Name,
|
||||
x.TeachingTask!.Name,
|
||||
x.TeachingTask.Course!.Name,
|
||||
x.ExamDate,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.Classroom == null ? null : x.Classroom.Name,
|
||||
x.Classroom == null ? null : x.Classroom.Building!.Name,
|
||||
x.Classroom == null ? null : x.Classroom.Building!.Campus!.Name,
|
||||
x.Notes,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static DateOnly StartOfWeek(DateOnly date)
|
||||
{
|
||||
var day = date.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)date.DayOfWeek;
|
||||
return date.AddDays(1 - day);
|
||||
}
|
||||
|
||||
private static bool OccursInWeek(WeekPattern pattern, int week) => pattern switch
|
||||
{
|
||||
WeekPattern.Odd => week % 2 == 1,
|
||||
WeekPattern.Even => week % 2 == 0,
|
||||
_ => true
|
||||
};
|
||||
|
||||
private static DateTime ToUtc(DateOnly date, TimeOnly time)
|
||||
{
|
||||
var local = date.ToDateTime(time, DateTimeKind.Unspecified);
|
||||
return new DateTimeOffset(local, ChinaOffset).UtcDateTime;
|
||||
}
|
||||
|
||||
private static string JoinLocation(params string?[] parts) =>
|
||||
string.Join(" · ", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private static string JoinDescription(params string?[] parts) =>
|
||||
string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private sealed record PersonalMakeupSession(
|
||||
Guid Id,
|
||||
string PlanName,
|
||||
string TaskName,
|
||||
string CourseName,
|
||||
DateOnly ExamDate,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt,
|
||||
string? ClassroomName,
|
||||
string? BuildingName,
|
||||
string? CampusName,
|
||||
string? Notes,
|
||||
DateTime UpdatedAt);
|
||||
}
|
||||
|
||||
public sealed record PersonalCalendarResult(
|
||||
byte[] Content,
|
||||
string Name,
|
||||
int EventCount);
|
||||
|
||||
internal sealed class IcsBuilder
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8 = new(false);
|
||||
private readonly StringBuilder _content = new();
|
||||
private bool _built;
|
||||
|
||||
public IcsBuilder(string name)
|
||||
{
|
||||
AddLine("BEGIN:VCALENDAR");
|
||||
AddLine("VERSION:2.0");
|
||||
AddLine("PRODID:-//Mingxu Jiaowu//Personal Teaching Calendar//ZH-CN");
|
||||
AddLine("CALSCALE:GREGORIAN");
|
||||
AddLine("METHOD:PUBLISH");
|
||||
AddLine($"X-WR-CALNAME:{Escape(name)}");
|
||||
AddLine("X-WR-TIMEZONE:Asia/Shanghai");
|
||||
AddLine("REFRESH-INTERVAL;VALUE=DURATION:PT1H");
|
||||
AddLine("X-PUBLISHED-TTL:PT1H");
|
||||
}
|
||||
|
||||
public int EventCount { get; private set; }
|
||||
|
||||
public void AddTimedEvent(
|
||||
string uid,
|
||||
string summary,
|
||||
DateTime startsAtUtc,
|
||||
DateTime endsAtUtc,
|
||||
string location,
|
||||
string description,
|
||||
string category,
|
||||
DateTime updatedAt)
|
||||
{
|
||||
AddLine("BEGIN:VEVENT");
|
||||
AddLine($"UID:{uid}");
|
||||
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"DTSTART:{FormatUtc(startsAtUtc)}");
|
||||
AddLine($"DTEND:{FormatUtc(endsAtUtc)}");
|
||||
AddLine($"SUMMARY:{Escape(summary)}");
|
||||
if (!string.IsNullOrWhiteSpace(location))
|
||||
AddLine($"LOCATION:{Escape(location)}");
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||
AddLine($"CATEGORIES:{Escape(category)}");
|
||||
AddLine("STATUS:CONFIRMED");
|
||||
AddLine("END:VEVENT");
|
||||
EventCount++;
|
||||
}
|
||||
|
||||
public void AddAllDayEvent(
|
||||
string uid,
|
||||
string summary,
|
||||
DateOnly date,
|
||||
string description,
|
||||
string category,
|
||||
DateTime updatedAt)
|
||||
{
|
||||
AddLine("BEGIN:VEVENT");
|
||||
AddLine($"UID:{uid}");
|
||||
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"DTSTART;VALUE=DATE:{date:yyyyMMdd}");
|
||||
AddLine($"DTEND;VALUE=DATE:{date.AddDays(1):yyyyMMdd}");
|
||||
AddLine($"SUMMARY:{Escape(summary)}");
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||
AddLine($"CATEGORIES:{Escape(category)}");
|
||||
AddLine("STATUS:CONFIRMED");
|
||||
AddLine("TRANSP:TRANSPARENT");
|
||||
AddLine("END:VEVENT");
|
||||
EventCount++;
|
||||
}
|
||||
|
||||
public byte[] Build()
|
||||
{
|
||||
if (!_built)
|
||||
{
|
||||
AddLine("END:VCALENDAR");
|
||||
_built = true;
|
||||
}
|
||||
return Utf8.GetBytes(_content.ToString());
|
||||
}
|
||||
|
||||
private void AddLine(string line)
|
||||
{
|
||||
var current = new StringBuilder();
|
||||
var byteCount = 0;
|
||||
foreach (var rune in line.EnumerateRunes())
|
||||
{
|
||||
var value = rune.ToString();
|
||||
var runeBytes = Utf8.GetByteCount(value);
|
||||
if (byteCount > 0 && byteCount + runeBytes > 74)
|
||||
{
|
||||
_content.Append(current).Append("\r\n ");
|
||||
current.Clear();
|
||||
byteCount = 1;
|
||||
}
|
||||
current.Append(value);
|
||||
byteCount += runeBytes;
|
||||
}
|
||||
_content.Append(current).Append("\r\n");
|
||||
}
|
||||
|
||||
private static string Escape(string value) => value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal);
|
||||
|
||||
private static string FormatUtc(DateTime value) =>
|
||||
value.ToUniversalTime().ToString(
|
||||
"yyyyMMdd'T'HHmmss'Z'",
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
private static DateTime NormalizeUpdatedAt(DateTime value) =>
|
||||
value == default
|
||||
? new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
: value.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
: value.ToUniversalTime();
|
||||
}
|
||||
@@ -99,7 +99,11 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.Notes))
|
||||
x.Notes,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -290,7 +294,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
x.Classes.Select(item => item.AdministrativeClass!.Name),
|
||||
x.Notes))
|
||||
x.Notes,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -357,7 +362,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.ExamPlan!.Name,
|
||||
InvigilatorNames = x.Invigilators
|
||||
.Select(i => i.Teacher!.Name),
|
||||
x.Notes
|
||||
x.Notes,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -385,7 +391,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.Notes,
|
||||
true,
|
||||
x.Name,
|
||||
x.ExamDate);
|
||||
x.ExamDate,
|
||||
x.UpdatedAt);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -468,7 +475,8 @@ public sealed record TimetableEntryDto(
|
||||
string? Notes,
|
||||
bool IsExam = false,
|
||||
string? ExamPlanName = null,
|
||||
DateOnly? ExamDate = null);
|
||||
DateOnly? ExamDate = null,
|
||||
DateTime UpdatedAt = default);
|
||||
|
||||
public sealed record FlexibleCourseDto(
|
||||
Guid Id,
|
||||
@@ -483,4 +491,5 @@ public sealed record FlexibleCourseDto(
|
||||
int WeeklyHours,
|
||||
IEnumerable<string> TeacherNames,
|
||||
IEnumerable<string> ClassNames,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
DateTime UpdatedAt = default);
|
||||
|
||||
@@ -23,12 +23,18 @@
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.2" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.2" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
+231
-14
@@ -1,10 +1,14 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Configuration;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Middleware;
|
||||
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
@@ -12,6 +16,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Threading.RateLimiting;
|
||||
@@ -61,6 +66,29 @@ if (seedDemoData && builder.Environment.IsDevelopment())
|
||||
var databaseOptions = builder.Configuration
|
||||
.GetSection(DatabaseOptions.SectionName)
|
||||
.Get<DatabaseOptions>() ?? new DatabaseOptions();
|
||||
var cacheOptions = builder.Configuration
|
||||
.GetSection(AppCacheOptions.SectionName)
|
||||
.Get<AppCacheOptions>() ?? new AppCacheOptions();
|
||||
var officialDocumentOptions = builder.Configuration
|
||||
.GetSection(OfficialDocumentOptions.SectionName)
|
||||
.Get<OfficialDocumentOptions>() ?? new OfficialDocumentOptions();
|
||||
var backgroundJobOptions = builder.Configuration
|
||||
.GetSection(BackgroundJobOptions.SectionName)
|
||||
.Get<BackgroundJobOptions>() ?? new BackgroundJobOptions();
|
||||
var operationsOptions = builder.Configuration
|
||||
.GetSection(OperationsOptions.SectionName)
|
||||
.Get<OperationsOptions>() ?? new OperationsOptions();
|
||||
var rabbitMqOptions = builder.Configuration
|
||||
.GetSection(RabbitMqOptions.SectionName)
|
||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
|
||||
string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) ||
|
||||
string.IsNullOrWhiteSpace(officialDocumentOptions.DocumentNumberPrefix))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OfficialDocuments 的 InstitutionName、IssuingOffice 和 DocumentNumberPrefix 不能为空。");
|
||||
}
|
||||
|
||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) &&
|
||||
!builder.Environment.IsDevelopment())
|
||||
@@ -82,7 +110,89 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
||||
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
||||
}
|
||||
|
||||
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 ||
|
||||
cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 ||
|
||||
cacheOptions.AnalyticsLocalExpirationSeconds is < 1 or > 3600 ||
|
||||
cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 ||
|
||||
cacheOptions.ReferenceLocalExpirationSeconds >
|
||||
cacheOptions.ReferenceExpirationMinutes * 60 ||
|
||||
cacheOptions.TimetableLocalExpirationSeconds >
|
||||
cacheOptions.TimetableExpirationMinutes * 60 ||
|
||||
cacheOptions.AnalyticsLocalExpirationSeconds >
|
||||
cacheOptions.AnalyticsExpirationMinutes * 60 ||
|
||||
string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) ||
|
||||
cacheOptions.KeyPrefix.Length > 100)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cache 缓存时间或 MaximumPayloadKilobytes 超出允许范围。");
|
||||
}
|
||||
|
||||
if (!backgroundJobOptions.Transport.Equals(
|
||||
"InMemory",
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
!backgroundJobOptions.UsesRabbitMq)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"BackgroundJobs:Transport 仅支持 InMemory 或 RabbitMq。");
|
||||
}
|
||||
if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
||||
backgroundJobOptions.LeaseSeconds is < 30 or > 3600 ||
|
||||
backgroundJobOptions.PrefetchCount is < 1 or > 100 ||
|
||||
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||
backgroundJobOptions.CleanupBatchSize is < 10 or > 5000 ||
|
||||
string.IsNullOrWhiteSpace(backgroundJobOptions.Exchange) ||
|
||||
string.IsNullOrWhiteSpace(backgroundJobOptions.QueuePrefix))
|
||||
{
|
||||
throw new InvalidOperationException("BackgroundJobs 配置超出允许范围。");
|
||||
}
|
||||
if (backgroundJobOptions.UsesRabbitMq &&
|
||||
(string.IsNullOrWhiteSpace(rabbitMqOptions.HostName) ||
|
||||
rabbitMqOptions.Port is < 1 or > 65535 ||
|
||||
string.IsNullOrWhiteSpace(rabbitMqOptions.UserName) ||
|
||||
string.IsNullOrWhiteSpace(rabbitMqOptions.Password) ||
|
||||
string.IsNullOrWhiteSpace(rabbitMqOptions.VirtualHost)))
|
||||
{
|
||||
throw new InvalidOperationException("RabbitMq 连接配置不完整。");
|
||||
}
|
||||
if (backgroundJobOptions.UsesRabbitMq &&
|
||||
!builder.Environment.IsDevelopment() &&
|
||||
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
||||
rabbitMqOptions.Password == "guest"))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"生产环境启用 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(rabbitMqOptions);
|
||||
builder.Services.Configure<OfficialDocumentOptions>(
|
||||
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||
builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||
{
|
||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -115,16 +225,31 @@ builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||
throw new InvalidOperationException(
|
||||
"缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。");
|
||||
}
|
||||
options.UseMySQL(connectionString, mySqlOptions =>
|
||||
{
|
||||
mySqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds);
|
||||
mySqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(10),
|
||||
errorNumbersToAdd: null);
|
||||
});
|
||||
options.UseMySQL(
|
||||
MySqlConnectionStringPolicy.ForApplication(connectionString),
|
||||
mySqlOptions =>
|
||||
{
|
||||
mySqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds);
|
||||
mySqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(10),
|
||||
errorNumbersToAdd: null);
|
||||
});
|
||||
});
|
||||
|
||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
options.Configuration = redisConnectionString);
|
||||
}
|
||||
builder.Services.AddHybridCache(options =>
|
||||
{
|
||||
options.MaximumKeyLength = 512;
|
||||
options.MaximumPayloadBytes = cacheOptions.MaximumPayloadKilobytes * 1024;
|
||||
});
|
||||
builder.Services.AddSingleton<IAppCache, HybridAppCache>();
|
||||
|
||||
builder.Services
|
||||
.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
@@ -159,20 +284,38 @@ builder.Services.AddScoped<DemoDataSeeder>();
|
||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||
builder.Services.AddScoped<TimetableDataService>();
|
||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||
builder.Services.AddScoped<PersonalCalendarService>();
|
||||
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
|
||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
||||
builder.Services.AddScoped<SchedulePlanPublisher>();
|
||||
builder.Services.AddScoped<SchedulePublishJobProcessor>();
|
||||
builder.Services.AddSingleton<SchedulePublishJobQueue>();
|
||||
builder.Services.AddHostedService<SchedulePublishJobWorker>();
|
||||
builder.Services.AddHostedService<WarningCheckWorker>();
|
||||
builder.Services.AddScoped<ExamArrangementService>();
|
||||
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
||||
builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||
builder.Services.AddSingleton<MakeupExamAutoJobQueue>();
|
||||
builder.Services.AddHostedService<MakeupExamAutoJobWorker>();
|
||||
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
builder.Services.AddSingleton<DatabaseBackupService>();
|
||||
builder.Services.AddSingleton<BackgroundJobRunner>();
|
||||
if (backgroundJobOptions.UsesRabbitMq)
|
||||
{
|
||||
builder.Services.AddSingleton<
|
||||
IBackgroundJobTransport,
|
||||
RabbitMqBackgroundJobTransport>();
|
||||
builder.Services.AddHostedService<RabbitMqBackgroundJobWorker>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<InMemoryBackgroundJobTransport>();
|
||||
builder.Services.AddSingleton<IBackgroundJobTransport>(services =>
|
||||
services.GetRequiredService<InMemoryBackgroundJobTransport>());
|
||||
builder.Services.AddHostedService<InMemoryBackgroundJobWorker>();
|
||||
}
|
||||
builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
|
||||
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
|
||||
builder.Services.AddScoped<OfficialDocumentService>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
@@ -205,6 +348,16 @@ builder.Services.AddRateLimiter(options =>
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
options.AddPolicy("official-verification", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 30,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
@@ -320,6 +473,8 @@ app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
|
||||
.AllowAnonymous();
|
||||
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||
app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous();
|
||||
app.MapGet("/health/messaging", CheckMessagingHealthAsync).AllowAnonymous();
|
||||
app.MapFallback(async context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api") ||
|
||||
@@ -383,4 +538,66 @@ static async Task<IResult> CheckDatabaseHealthAsync(
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IResult> CheckCacheHealthAsync(
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var distributedCache = services.GetService<IDistributedCache>();
|
||||
if (distributedCache is null)
|
||||
return Results.Ok(new { Status = "disabled", Backend = "memory" });
|
||||
|
||||
try
|
||||
{
|
||||
await distributedCache.GetAsync(
|
||||
"jiaowu:health:probe",
|
||||
cancellationToken);
|
||||
return Results.Ok(new { Status = "healthy", Backend = "redis" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(
|
||||
new { Status = "unhealthy", Backend = "redis" },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IResult> CheckMessagingHealthAsync(
|
||||
IBackgroundJobTransport transport,
|
||||
BackgroundJobMonitoringService monitoring,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var healthy = await transport.CheckHealthAsync(cancellationToken);
|
||||
var backend = transport.IsDurable ? "rabbitmq" : "memory";
|
||||
try
|
||||
{
|
||||
var backlog = await monitoring.GetSnapshotAsync(cancellationToken);
|
||||
return healthy
|
||||
? Results.Ok(new
|
||||
{
|
||||
Status = "healthy",
|
||||
Backend = backend,
|
||||
Backlog = backlog
|
||||
})
|
||||
: Results.Json(
|
||||
new
|
||||
{
|
||||
Status = "unhealthy",
|
||||
Backend = backend,
|
||||
Backlog = backlog
|
||||
},
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(
|
||||
new
|
||||
{
|
||||
Status = "unhealthy",
|
||||
Backend = backend,
|
||||
Backlog = (object?)null
|
||||
},
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user