20 Commits
  • 点名、成绩册统一读取“行政班在籍学生 ∪ 已选课学生”,不再错误显示 0 人。
    教师选课名单改为展示全部本人教学班及完整学生名单,支持 Excel 导出。
    调停课改用教师专用教学班接口,并在服务端限制只能操作本人教学班。
  • 第二阶段优化已完成,重点是提高后台任务吞吐并降低 Outbox 数据库开销。
    RabbitMQ 默认从单一回调通道提升为三类任务独立并行,跨类型并发能力由 1 提升到 3;每类还能独立配置 1–16 个消费者。[RabbitMqBackgroundJobs.cs (line 143)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs:143)
    InMemory 开发模式同步改为按任务类型隔离队列,避免某类长任务堵塞其他任务。
    租约恢复检查从“每发布一条执行一次”改为默认每 60 秒维护一次。
    启动恢复改为数据库 NOT EXISTS 查询,不再把全部历史 Outbox 加载进内存。[BackgroundJobOutboxPublisher.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs:9)
    完成消息默认保留 14 天,之后按每批 500 条清理,并增加对应组合索引。
    /health/messaging 现在返回各状态积压量、过期租约和最老任务等待时间。[BackgroundJobMonitoringService.cs (line 16)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobMonitoringService.cs:16)
    新增 Jiaowu.BackgroundJobs 运行时指标,覆盖发布量、处理量、发布耗时、处理耗时和清理量。[BackgroundJobTelemetry.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobTelemetry.cs:9)
    配置、Compose 和调优建议已更新。[README.md (line 257)](E:/jiaowu/README.md:257)
  • 排课计算、课表发布、补考自动编排三类任务统一接入消息系统。
    业务任务与 Outbox 消息同一事务落库,避免“数据库成功但消息没发出去”。
    RabbitMQ 使用持久化消息、发布确认、手动 ACK、Quorum Queue、死信队列。
    增加处理租约、心跳、异常重试、最大重试次数和幂等状态控制。
    保留 InMemory 模式,开发环境无需安装 RabbitMQ。
    支持多实例竞争消费,后续可以横向扩容。
    新增 /health/messaging 消息系统健康检查。
  • 成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
    手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
    选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
    关闭选课界面会明确提示发送通知,并显示实际通知人数。
  • 学生可在“学籍管理 → 电子成绩单与证明”申请成绩单或学籍证明。
    服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。
    成绩单仅包含正式发布成绩;无已发布成绩时明确提示。
    同类型、同用途 5 分钟内重复申请复用已有有效凭证。
    申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。
    管理员的下载记录、失效、重签能力保持不变。
  • 已完善个人教学日历订阅。
    主要能力:
    教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。
    自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。
    采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。
    支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。
    订阅地址按实际前端 API 地址生成,兼容独立部署域名。
    已增加窄屏弹窗最大宽度和纵向操作布局。
  • 将索引缩短为 IX_CE_Offering_Status_WaitlistedAt。
    将候补迁移改为可重入:先检查列和索引是否存在,再执行 DDL。
    附件中已经成功添加 WaitlistedAt、但索引创建失败的数据库可直接重试,不会报重复列。
    运行时和 dotnet ef 连接统一开启 AllowUserVariables;Connector/NET 默认关闭该选项。Connector/NET 官方说明
    增加所有 MySQL 索引名不得超过 64 字符的回归测试。
  • 已把选课的“年级划分”和限制规则补成完整闭环:
    选课批次可指定多个适用年级;留空兼容原有“全部年级”。
    新增可选的“最多课程门数”,与原有学分上限并行。
    年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。
    普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。
    管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。
    新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
  • 已完成“选课候补与自动递补”完整流程。
    学生端:满员后可加入/取消候补,展示候补顺位和当前候补人数。
    自动递补:退课释放名额后,按候补时间顺序重新校验学籍、范围、学分及时间冲突;不合格者自动失效并继续下一位。
    管理端:教学班显示候补人数,名单抽屉提供候补队列及移出操作。
    状态通知:递补成功、资格失效、批次关闭、管理员移出均会通知学生。
    并发安全:退课和递补放在 Serializable 事务中执行。
    数据库:已补充 SQLite 开发迁移和 MySQL 正式迁移。
  • 仪表盘和 6 类统计摘要接入 HybridCache(内存 + Redis)。
    默认 Redis 缓存 3 分钟、本地缓存 30 秒,可通过环境变量调整。
    缓存键包含数据权限范围、有效学院和全部筛选条件,避免跨学院串数据。
    Excel 导出保持实时查询,不使用摘要缓存。
    无需数据库迁移。
  • 主要变更:
    新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12)
    接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153)
    缓存学生激活选项、基础数据、公开课表和课表选项。
    个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。
    基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。
    新增 /health/cache,Redis 故障不影响 /health/ready。
    Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。
    更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
  • 免修、缓考现在包含当前学期课程,以及未归档且课表已发布的实际修读课程。
    实际开发库验证已返回 CS101 程序设计基础。
    课程替代改为“修读中/未通过课程 → 已发布及格课程”的双栏选择。
    新增搜索、教学班号、学期、教师、学分、课表状态和完整空状态说明。
    新增课程替代申请记录。
    提交接口增加服务端资格校验,不能伪造课程 ID 绕过前端。
  • 修复500
    TimeOnly ↔ TimeSpan
136 changed files with 52748 additions and 1562 deletions
+7
View File
@@ -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
+33
View File
@@ -6,6 +6,31 @@ 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;"
# 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
Jwt__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web
@@ -15,6 +40,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
+1
View File
@@ -17,3 +17,4 @@ src/Jiaowu.Api/wwwroot/
!.env.example
!.env.docker.example
certs/
publish/
+1
View File
@@ -34,6 +34,7 @@ ENV ASPNETCORE_ENVIRONMENT=Production \
EXPOSE 8080
COPY --from=build /app/publish/ ./
ARG UID=10001
RUN apk add --no-cache font-noto-cjk
RUN adduser \
--disabled-password \
--gecos "" \
+78 -1
View File
@@ -2,7 +2,9 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定
官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
@@ -219,6 +221,81 @@ 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` 数据库迁移,再启动应用实例。
## 跨平台发布与 Docker
+62
View File
@@ -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:
@@ -99,3 +160,4 @@ services:
volumes:
mysql-data:
rabbitmq-data:
+224 -15
View File
@@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
{
var studentId = await GetStudentIdAsync(ct);
if (studentId is null) return StudentNotFound();
if (!await IsStudentCourseAvailableForApplicationAsync(
studentId.Value, req.TeachingTaskId, ct))
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有免修申请在审核中。");
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
@@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
if (!await IsStudentCourseAvailableForApplicationAsync(
sid.Value, req.TeachingTaskId, ct))
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有缓考申请在审核中。");
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
@@ -200,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 });
}
@@ -213,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();
}
@@ -221,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;
@@ -229,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();
}
@@ -243,17 +285,87 @@ 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();
}
// ═══════════════ Course Substitution ═══════════════
[HttpGet("substitutions/mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
return Ok(await db.CourseSubstitutions.AsNoTracking()
.Where(x => x.StudentId == sid)
.OrderByDescending(x => x.CreatedAt)
.Select(x => new StudentCourseSubstitutionRecord(
x.Id,
x.Status,
x.Reason,
x.ReviewComment,
x.SubmittedAt,
x.OriginalCourse!.Code,
x.OriginalCourse.Name,
x.SubstituteCourse!.Code,
x.SubstituteCourse.Name))
.ToListAsync(ct));
}
[HttpPost("substitutions")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
if (req.OriginalCourseId == req.SubstituteCourseId)
return ConflictProblem("被替代课程和替代课程不能相同。");
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
.AnyAsync(task =>
task.CourseId == req.OriginalCourseId &&
task.Status == TeachingTaskStatus.Published &&
!task.AcademicTerm!.IsArchived &&
(task.AcademicTerm.IsCurrent ||
db.ScheduleEntries.Any(entry =>
entry.TeachingTaskId == task.Id &&
entry.SchedulePlan!.Status ==
SchedulePlanStatus.Published)) &&
(task.Classes.Any(item =>
item.AdministrativeClass!.Students.Any(student =>
student.Id == sid.Value)) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == sid.Value &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
task.Id)),
ct);
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
.AnyAsync(record =>
record.StudentId == sid.Value &&
record.GradeSheet!.Status == GradeSheetStatus.Published &&
record.GradeSheet.TeachingTask!.CourseId ==
req.OriginalCourseId &&
record.TotalScore < 60,
ct);
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
if (!canReplaceOriginal)
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
.AnyAsync(x =>
x.StudentId == sid.Value &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
x.TotalScore >= 60,
ct);
if (!hasPassedSubstitute)
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有替代申请在审核中。");
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
@@ -319,16 +431,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
return NoContent();
}
// ═══════════════ Student enrolled courses ═══════════════
// ═══════════════ Student course options ═══════════════
[HttpGet("my-courses")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
var courses = await db.CourseEnrollments.AsNoTracking()
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
var courses = await db.TeachingTasks.AsNoTracking()
.Where(task =>
task.Status == TeachingTaskStatus.Published &&
!task.AcademicTerm!.IsArchived &&
(task.AcademicTerm.IsCurrent ||
db.ScheduleEntries.Any(entry =>
entry.TeachingTaskId == task.Id &&
entry.SchedulePlan!.Status ==
SchedulePlanStatus.Published)) &&
(task.Classes.Any(item =>
item.AdministrativeClass!.Students.Any(student =>
student.Id == sid.Value)) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == sid.Value &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
task.Id)))
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
.ThenByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new StudentApprovalCourseOption(
x.Id,
x.CourseId,
x.TaskNumber,
x.Course!.Code,
x.Course.Name,
x.Course.Credits,
x.AcademicTerm!.Name,
x.Teachers
.OrderByDescending(item => item.IsPrimary)
.ThenBy(item => item.Teacher!.Name)
.Select(item => item.Teacher!.Name),
db.ScheduleEntries.Any(entry =>
entry.TeachingTaskId == x.Id &&
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
x.SchedulingMode))
.ToListAsync(ct);
return Ok(courses);
}
@@ -341,8 +487,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
if (sid is null) return StudentNotFound();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
.OrderBy(x => x.CourseCode)
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
.Select(x => new StudentApprovalGradeOption(
x.GradeSheet!.TeachingTask!.CourseId,
x.GradeSheet.TeachingTask.Course!.Code,
x.GradeSheet.TeachingTask.Course.Name,
x.GradeSheet.TeachingTask.Course.Credits,
x.TotalScore,
x.GradePoint,
x.ExamStatus,
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTask.TaskNumber,
x.GradeSheet.PublishedAt))
.ToListAsync(ct);
return Ok(grades);
}
@@ -377,12 +533,33 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
}
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
private async Task<bool> IsStudentCourseAvailableForApplicationAsync(
Guid studentId,
Guid teachingTaskId,
CancellationToken ct) =>
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
task.Id == teachingTaskId &&
task.Status == TeachingTaskStatus.Published &&
!task.AcademicTerm!.IsArchived &&
(task.AcademicTerm.IsCurrent ||
db.ScheduleEntries.Any(entry =>
entry.TeachingTaskId == task.Id &&
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
(task.Classes.Any(item =>
item.AdministrativeClass!.Students.Any(student =>
student.Id == studentId)) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == studentId &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
task.Id)),
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 => "代课", _ => "调停课" };
@@ -390,6 +567,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
}
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
public sealed record StudentApprovalCourseOption(
Guid TeachingTaskId,
Guid CourseId,
string TaskNumber,
string CourseCode,
string CourseName,
decimal Credits,
string AcademicTermName,
IEnumerable<string> TeacherNames,
bool HasPublishedSchedule,
TeachingTaskSchedulingMode SchedulingMode);
public sealed record StudentApprovalGradeOption(
Guid CourseId,
string CourseCode,
string CourseName,
decimal Credits,
decimal? TotalScore,
decimal? GradePoint,
GradeExamStatus ExamStatus,
string AcademicTermName,
string TaskNumber,
DateTime? PublishedAt);
public sealed record StudentCourseSubstitutionRecord(
Guid Id,
ApprovalStatus Status,
string Reason,
string? ReviewComment,
DateTime SubmittedAt,
string OriginalCourseCode,
string OriginalCourseName,
string SubstituteCourseCode,
string SubstituteCourseName);
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
@@ -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()
+62 -24
View File
@@ -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);
+217 -79
View File
@@ -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 + "," +
@@ -273,6 +275,7 @@ public sealed class CoursesController(
try
{
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
@@ -2,6 +2,7 @@ using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/courses")]
public sealed class CoursesExcelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
@@ -141,6 +143,9 @@ public sealed class CoursesExcelController(
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
@@ -1,18 +1,35 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/dashboard")]
public sealed class DashboardController(AppDbContext db) : ControllerBase
public sealed class DashboardController(
AppDbContext db,
IAppCache appCache,
IOptions<JsonOptions> jsonOptions) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
{
var response = await appCache.GetOrCreateAsync(
AppCacheKeys.Dashboard,
LoadAsync,
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
cancellationToken);
return response;
}
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
{
var currentTerm = await db.AcademicTerms
.AsNoTracking()
@@ -20,64 +37,66 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
.FirstOrDefaultAsync(cancellationToken);
return new
{
CurrentTerm = currentTerm,
Counts = new
return JsonSerializer.SerializeToElement(
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,
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),
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,
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),
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)
}
};
GraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(cancellationToken),
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(
x => x.Status == GraduationAuditBatchStatus.Published,
cancellationToken),
DegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(cancellationToken),
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(
x => x.Status == DegreeAwardBatchStatus.Published,
cancellationToken),
GraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(cancellationToken),
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(
x => x.Status == GraduationClearanceBatchStatus.Open,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
},
jsonOptions.Value.JsonSerializerOptions);
}
}
@@ -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}"
+194 -17
View File
@@ -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);
+121 -51
View File
@@ -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,4 @@
using System.Security.Claims;
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -16,18 +16,38 @@ public sealed class NotificationsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
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 +56,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 +99,362 @@ 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))
{
var recipientCount = await db.Users.AsNoTracking()
.CountAsync(
x => x.IsEnabled && x.Id != scope.UserId,
cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.School,
AudienceName = "全校已启用账号",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
}
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("当前账号关联的学院不存在。");
var recipientCount = await db.Users.AsNoTracking()
.CountAsync(
x => x.IsEnabled &&
x.Id != scope.UserId &&
x.CollegeId == collegeId.Value,
cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.College,
AudienceName = $"{college}全院成员",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
}
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();
}
[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 (content.Length == 0)
return ValidationProblem("请填写消息正文。");
var scope = currentUserDataScope.Current;
MessageAudienceType audienceType;
Guid? audienceId = null;
string audienceName;
List<Guid> recipientIds;
if (IsSchoolAdministrator(scope))
{
audienceType = MessageAudienceType.School;
audienceName = "全校已启用账号";
recipientIds = await db.Users.AsNoTracking()
.Where(x => x.IsEnabled && x.Id != scope.UserId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
}
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("当前账号关联的学院不存在。");
audienceType = MessageAudienceType.College;
audienceId = collegeId.Value;
audienceName = $"{collegeName}全院成员";
recipientIds = await db.Users.AsNoTracking()
.Where(x =>
x.IsEnabled &&
x.Id != scope.UserId &&
x.CollegeId == collegeId.Value)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
}
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
};
dispatch.Notifications = recipientIds.Select(userId => new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = NotificationCategory.General,
LinkUrl = null
}).ToList();
db.MessageDispatches.Add(dispatch);
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
dispatch.Id,
dispatch.AudienceName,
dispatch.RecipientCount,
dispatch.CreatedAt
});
}
[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 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(
[property: Required, StringLength(200)] string Title,
[property: Required, StringLength(1000)] string Content,
Guid? TeachingTaskId = null);
/// <summary>
/// Centralized helper to send notifications across the app.
/// </summary>
@@ -90,13 +466,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 +487,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 +534,7 @@ public static class NotificationService
UserId = id,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
}
@@ -144,7 +547,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 +557,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;
@@ -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,42 @@ 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
}
@@ -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
}
@@ -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)
@@ -27,9 +33,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 +64,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 +89,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 +103,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 +131,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 +143,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)
@@ -27,9 +33,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 +64,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 +86,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 +100,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 +128,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 +140,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,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);
@@ -41,8 +41,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 +79,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 +94,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)
@@ -101,6 +111,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
configurationBuilder.Properties<DateOnly>()
.HaveConversion<DateOnlyDateTimeConverter>()
.HaveColumnType("date");
// Connector/NET returns MySQL TIME values as TimeSpan. Convert at the
// provider boundary so schedule time slots can still use TimeOnly.
configurationBuilder.Properties<TimeOnly>()
.HaveConversion<TimeOnlyTimeSpanConverter>()
.HaveColumnType("time");
}
protected override void OnModelCreating(ModelBuilder builder)
@@ -111,6 +127,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);
});
@@ -459,6 +476,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);
@@ -471,6 +531,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);
@@ -494,6 +565,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)
@@ -876,6 +953,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()
@@ -890,7 +968,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Content).HasMaxLength(1000);
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).HasMaxLength(1000);
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 =>
@@ -901,6 +995,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)
@@ -931,3 +1082,8 @@ public sealed class DateOnlyDateTimeConverter()
: ValueConverter<DateOnly, DateTime>(
date => date.ToDateTime(TimeOnly.MinValue),
value => DateOnly.FromDateTime(value));
public sealed class TimeOnlyTimeSpanConverter()
: ValueConverter<TimeOnly, TimeSpan>(
time => time.ToTimeSpan(),
value => TimeOnly.FromTimeSpan(value));
@@ -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,20 @@ 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";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -266,6 +280,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 +373,86 @@ 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);
}
private async Task ApplyMigrationAsync(
@@ -1589,6 +1698,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 +1789,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 +1840,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 +1955,139 @@ 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");
"""
];
}
@@ -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");
}
}
}
@@ -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;");
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
});
@@ -849,6 +961,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 +996,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 +1545,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 +2255,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,12 +2316,73 @@ 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()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
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)
@@ -2194,6 +2398,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 +2416,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 +2721,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 +2735,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 +2761,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 +2889,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 +3314,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 +3443,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 +3685,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")
@@ -3407,6 +3848,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 +4349,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")
@@ -4227,6 +4741,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.Navigation("EligibleGrades");
b.Navigation("Offerings");
});
@@ -4316,6 +4832,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);
+6
View File
@@ -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>
+210 -14
View File
@@ -1,10 +1,13 @@
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.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Timetables;
@@ -12,6 +15,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 +65,26 @@ 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 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 +106,74 @@ 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 凭据。");
}
builder.Services.AddSingleton(databaseOptions);
builder.Services.AddSingleton(cacheOptions);
builder.Services.AddSingleton(officialDocumentOptions);
builder.Services.AddSingleton(backgroundJobOptions);
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 +206,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 +265,36 @@ 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.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 +327,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 +452,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 +517,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;
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Jiaowu.Api.Tests")]
@@ -5,6 +5,9 @@
"ConnectionStrings": {
"SQLite": "Data Source=data/jiaowu-dev.sqlite"
},
"Cache": {
"Enabled": true
},
"Jwt": {
"Key": "jiaowu-development-secret-key-change-before-production",
"ExpireMinutes": 480
+45 -1
View File
@@ -5,7 +5,44 @@
"CommandTimeoutSeconds": 30
},
"ConnectionStrings": {
"MySql": ""
"MySql": "",
"Redis": ""
},
"Cache": {
"Enabled": true,
"KeyPrefix": "jiaowu:v1",
"ReferenceExpirationMinutes": 30,
"ReferenceLocalExpirationSeconds": 120,
"TimetableExpirationMinutes": 10,
"TimetableLocalExpirationSeconds": 30,
"AnalyticsExpirationMinutes": 3,
"AnalyticsLocalExpirationSeconds": 30,
"MaximumPayloadKilobytes": 2048
},
"BackgroundJobs": {
"Transport": "InMemory",
"PollIntervalMilliseconds": 500,
"LeaseSeconds": 120,
"PrefetchCount": 1,
"AutomaticScheduleConcurrency": 1,
"SchedulePublishConcurrency": 1,
"MakeupExamAutoConcurrency": 1,
"Exchange": "jiaowu.background-jobs",
"QueuePrefix": "jiaowu.background-jobs",
"UseQuorumQueues": true,
"ProcessingAttemptLimit": 5,
"MaintenanceIntervalSeconds": 60,
"CompletedRetentionDays": 14,
"CleanupBatchSize": 500
},
"RabbitMq": {
"HostName": "localhost",
"Port": 5672,
"UserName": "guest",
"Password": "guest",
"VirtualHost": "/",
"UseTls": false,
"TlsServerName": ""
},
"Jwt": {
"Issuer": "Jiaowu.Api",
@@ -16,6 +53,13 @@
"Cors": {
"Origins": []
},
"OfficialDocuments": {
"InstitutionName": "明序大学",
"IssuingOffice": "教务处",
"DocumentNumberPrefix": "MXU",
"PublicBaseUrl": "",
"FontPath": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
+354
View File
@@ -0,0 +1,354 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Tests;
public sealed class AppCacheTests
{
[Fact]
public void Statistics_keys_isolate_data_scope_college_and_filters()
{
var firstCollege = Guid.NewGuid();
var secondCollege = Guid.NewGuid();
var term = Guid.NewGuid().ToString("N");
var first = AppCacheKeys.Statistics(
"grades",
"College",
firstCollege,
term,
"-");
var otherCollege = AppCacheKeys.Statistics(
"grades",
"College",
secondCollege,
term,
"-");
var otherScope = AppCacheKeys.Statistics(
"grades",
"All",
firstCollege,
term,
"-");
var otherFilter = AppCacheKeys.Statistics(
"grades",
"College",
firstCollege,
Guid.NewGuid().ToString("N"),
"-");
Assert.NotEqual(first, otherCollege);
Assert.NotEqual(first, otherScope);
Assert.NotEqual(first, otherFilter);
Assert.Equal(
first,
AppCacheKeys.Statistics(
" GRADES ",
"COLLEGE",
firstCollege,
term,
null));
}
[Fact]
public async Task Hybrid_cache_reuses_value_and_tag_invalidation_reloads_source()
{
await using var provider = CreateProvider(enabled: true);
var cache = provider.GetRequiredService<IAppCache>();
var sourceCalls = 0;
var key = $"test:{Guid.NewGuid():N}";
Task<int> Load(CancellationToken _)
{
sourceCalls++;
return Task.FromResult(sourceCalls);
}
var first = await cache.GetOrCreateAsync(
key,
Load,
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
CancellationToken.None);
var second = await cache.GetOrCreateAsync(
key,
Load,
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
CancellationToken.None);
Assert.Equal(1, first);
Assert.Equal(1, second);
Assert.Equal(1, sourceCalls);
await cache.RemoveByTagAsync(AppCacheTags.BaseData);
var afterInvalidation = await cache.GetOrCreateAsync(
key,
Load,
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
CancellationToken.None);
Assert.Equal(2, afterInvalidation);
Assert.Equal(2, sourceCalls);
}
[Fact]
public async Task Disabled_cache_always_uses_source()
{
await using var provider = CreateProvider(enabled: false);
var cache = provider.GetRequiredService<IAppCache>();
var sourceCalls = 0;
Task<int> Load(CancellationToken _)
{
sourceCalls++;
return Task.FromResult(sourceCalls);
}
var first = await cache.GetOrCreateAsync(
"disabled",
Load,
AppCacheProfile.ReferenceData,
[],
CancellationToken.None);
var second = await cache.GetOrCreateAsync(
"disabled",
Load,
AppCacheProfile.ReferenceData,
[],
CancellationToken.None);
Assert.Equal(1, first);
Assert.Equal(2, second);
}
[Fact]
public async Task Timetable_data_round_trips_through_distributed_cache()
{
IDistributedCache distributedCache = new SharedDistributedCache(
new MemoryDistributedCache(
Options.Create(new MemoryDistributedCacheOptions())));
var keyPrefix = $"tests:{Guid.NewGuid():N}";
await using var writer = CreateProvider(true, distributedCache, keyPrefix);
var source = CreateTimetableData();
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
"timetable",
_ => Task.FromResult(source),
AppCacheProfile.PublishedTimetable,
[AppCacheTags.Timetables],
CancellationToken.None);
await using var reader = CreateProvider(true, distributedCache, keyPrefix);
var sourceCalled = false;
var result = await reader.GetRequiredService<IAppCache>().GetOrCreateAsync(
"timetable",
_ =>
{
sourceCalled = true;
return Task.FromResult(source);
},
AppCacheProfile.PublishedTimetable,
[AppCacheTags.Timetables],
CancellationToken.None);
Assert.False(sourceCalled);
Assert.Equal(source.Term.Id, result.Term.Id);
Assert.Equal(source.Plan!.Id, result.Plan!.Id);
Assert.Equal("缓存测试课程", result.Entries.Single().CourseName);
Assert.Equal(new TimeOnly(8, 45), result.Slots.Single().EndsAt);
}
[Fact]
public async Task Analytics_json_round_trips_through_distributed_cache()
{
IDistributedCache distributedCache = new SharedDistributedCache(
new MemoryDistributedCache(
Options.Create(new MemoryDistributedCacheOptions())));
var keyPrefix = $"tests:{Guid.NewGuid():N}";
var source = JsonSerializer.SerializeToElement(new
{
totals = new { totalCourses = 3 }
});
await using (var writer = CreateProvider(
true,
distributedCache,
keyPrefix))
{
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
"statistics:courses",
_ => Task.FromResult(source),
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
CancellationToken.None);
}
await using var reader = CreateProvider(
true,
distributedCache,
keyPrefix);
var sourceCalled = false;
var result = await reader.GetRequiredService<IAppCache>()
.GetOrCreateAsync(
"statistics:courses",
_ =>
{
sourceCalled = true;
return Task.FromResult(source);
},
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
CancellationToken.None);
Assert.False(sourceCalled);
Assert.Equal(
3,
result.GetProperty("totals")
.GetProperty("totalCourses")
.GetInt32());
}
private static ServiceProvider CreateProvider(
bool enabled,
IDistributedCache? distributedCache = null,
string? keyPrefix = null)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddHybridCache();
if (distributedCache is not null)
services.AddSingleton(distributedCache);
services.AddSingleton(new AppCacheOptions
{
Enabled = enabled,
KeyPrefix = keyPrefix ?? $"tests:{Guid.NewGuid():N}"
});
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment());
services.AddSingleton<IAppCache, HybridAppCache>();
return services.BuildServiceProvider();
}
private static TimetableData CreateTimetableData()
{
var termId = Guid.NewGuid();
var planId = Guid.NewGuid();
var taskId = Guid.NewGuid();
return new TimetableData(
new TimetableTermDto(
termId,
"2026-2027 学年第一学期",
"2026-2027",
TermSeason.Autumn,
new DateOnly(2026, 9, 1),
new DateOnly(2027, 1, 20),
true),
new TimetableSubjectDto(
Guid.NewGuid(),
"SE202601",
"软件工程 2026 级 1 班",
TimetableResourceType.Class,
2026,
Guid.NewGuid(),
"软件工程",
Guid.NewGuid(),
"计算机学院",
null,
null,
null),
null,
null,
new TimetablePlanDto(
planId,
"正式课表",
"V1",
SchedulePlanStatus.Published,
DateTime.UtcNow,
DateTime.UtcNow),
[
new TimetableSlotDto(
1,
"第 1 节",
new TimeOnly(8, 0),
new TimeOnly(8, 45))
],
[
new TimetableEntryDto(
Guid.NewGuid(),
taskId,
"TASK-001",
"缓存测试教学班",
"CACHE-01",
"缓存测试课程",
["测试教师"],
["软件工程 2026 级 1 班"],
"第一教学楼 101",
"第一教学楼",
"主校区",
1,
1,
2,
1,
16,
WeekPattern.All,
null)
],
[],
[]);
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = Environments.Development;
public string ApplicationName { get; set; } = nameof(AppCacheTests);
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
private sealed class SharedDistributedCache(IDistributedCache inner)
: IDistributedCache
{
public byte[]? Get(string key) => inner.Get(key);
public Task<byte[]?> GetAsync(
string key,
CancellationToken token = default) =>
inner.GetAsync(key, token);
public void Refresh(string key) => inner.Refresh(key);
public Task RefreshAsync(
string key,
CancellationToken token = default) =>
inner.RefreshAsync(key, token);
public void Remove(string key) => inner.Remove(key);
public Task RemoveAsync(
string key,
CancellationToken token = default) =>
inner.RemoveAsync(key, token);
public void Set(
string key,
byte[] value,
DistributedCacheEntryOptions options) =>
inner.Set(key, value, options);
public Task SetAsync(
string key,
byte[] value,
DistributedCacheEntryOptions options,
CancellationToken token = default) =>
inner.SetAsync(key, value, options, token);
}
}
@@ -0,0 +1,301 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ApprovalsControllerTests
{
[Fact]
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
{
await using var fixture = await ApprovalFixture.CreateAsync();
fixture.CurrentTerm.IsCurrent = false;
fixture.Db.AcademicTerms.Add(new AcademicTerm
{
Code = "2026-2",
Name = "2026—2027 学年第二学期",
AcademicYear = "2026-2027",
Season = TermSeason.Spring,
StartDate = new DateOnly(2027, 2, 20),
EndDate = new DateOnly(2027, 7, 1),
IsCurrent = true
});
fixture.Db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = fixture.CurrentTerm.Id,
Name = "正式课表",
Version = "v1",
Status = SchedulePlanStatus.Published,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = fixture.CurrentTask.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
}
]
});
await fixture.Db.SaveChangesAsync();
var result = await fixture.Controller.GetMyEnrolledCourses(
CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var options = Assert.IsAssignableFrom<
IEnumerable<StudentApprovalCourseOption>>(ok.Value).ToList();
var option = Assert.Single(options);
Assert.Equal(fixture.CurrentTask.Id, option.TeachingTaskId);
Assert.Equal(fixture.CurrentCourse.Id, option.CourseId);
Assert.True(option.HasPublishedSchedule);
}
[Fact]
public async Task Exemption_RejectsTeachingTaskOutsideStudentsCurrentCourses()
{
await using var fixture = await ApprovalFixture.CreateAsync();
var unrelatedTask = new TeachingTask
{
TaskNumber = "2026-1-OTHER-01",
Name = "其他教学班",
AcademicTermId = fixture.CurrentTerm.Id,
CourseId = fixture.SubstituteCourse.Id,
Capacity = 30,
Status = TeachingTaskStatus.Published
};
fixture.Db.TeachingTasks.Add(unrelatedTask);
await fixture.Db.SaveChangesAsync();
var result = await fixture.Controller.ApplyExemption(
new ExemptionRequest(unrelatedTask.Id, "申请原因"),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.Empty(fixture.Db.CourseExemptions);
}
[Fact]
public async Task Substitution_AcceptsCurrentCourseAndPublishedPassedCourse()
{
await using var fixture = await ApprovalFixture.CreateAsync();
var historicalTerm = new AcademicTerm
{
Code = "2025-2",
Name = "2025—2026 学年第二学期",
AcademicYear = "2025-2026",
Season = TermSeason.Spring,
StartDate = new DateOnly(2026, 2, 20),
EndDate = new DateOnly(2026, 7, 1)
};
var historicalTask = new TeachingTask
{
TaskNumber = "2025-2-PASS-01",
Name = "已通过课程教学班",
AcademicTermId = historicalTerm.Id,
CourseId = fixture.SubstituteCourse.Id,
Capacity = 30,
Status = TeachingTaskStatus.Closed
};
var sheet = new GradeSheet
{
TeachingTaskId = historicalTask.Id,
Status = GradeSheetStatus.Published,
PublishedAt = DateTime.UtcNow.AddDays(-30),
Records =
[
new GradeRecord
{
StudentId = fixture.Student.Id,
TotalScore = 86,
GradePoint = 3.6m
}
]
};
fixture.Db.AddRange(historicalTerm, historicalTask, sheet);
await fixture.Db.SaveChangesAsync();
var result = await fixture.Controller.ApplySubstitution(
new SubstitutionRequest(
fixture.CurrentCourse.Id,
fixture.SubstituteCourse.Id,
"课程内容相近"),
CancellationToken.None);
Assert.IsType<CreatedResult>(result);
var substitution = Assert.Single(fixture.Db.CourseSubstitutions);
Assert.Equal(fixture.CurrentCourse.Id, substitution.OriginalCourseId);
Assert.Equal(
fixture.SubstituteCourse.Id,
substitution.SubstituteCourseId);
}
private sealed class ApprovalFixture : IAsyncDisposable
{
private ApprovalFixture(
SqliteConnection connection,
AppDbContext db,
Student student,
AcademicTerm currentTerm,
Course currentCourse,
Course substituteCourse,
TeachingTask currentTask,
ApprovalsController controller)
{
Connection = connection;
Db = db;
Student = student;
CurrentTerm = currentTerm;
CurrentCourse = currentCourse;
SubstituteCourse = substituteCourse;
CurrentTask = currentTask;
Controller = controller;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public Student Student { get; }
public AcademicTerm CurrentTerm { get; }
public Course CurrentCourse { get; }
public Course SubstituteCourse { get; }
public TeachingTask CurrentTask { get; }
public ApprovalsController Controller { get; }
public static async Task<ApprovalFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = "202601001",
NormalizedUserName = "202601001",
DisplayName = "测试学生"
};
var student = new Student
{
StudentNumber = "202601001",
Name = "测试学生",
UserId = user.Id,
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
};
var currentTerm = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20),
IsCurrent = true
};
var currentCourse = CreateCourse(college.Id, "CS101", "程序设计基础");
var substituteCourse = CreateCourse(college.Id, "CS102", "程序设计进阶");
var currentTask = new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = currentTerm.Id,
CourseId = currentCourse.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published,
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
db.AddRange(
college,
major,
administrativeClass,
user,
student,
currentTerm,
currentCourse,
substituteCourse,
currentTask);
await db.SaveChangesAsync();
var controller = new ApprovalsController(
db,
new StudentDataScope(user.Id));
return new ApprovalFixture(
connection,
db,
student,
currentTerm,
currentCourse,
substituteCourse,
currentTask,
controller);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await Connection.DisposeAsync();
}
private static Course CreateCourse(
Guid collegeId,
string code,
string name) =>
new()
{
Code = code,
Name = name,
CollegeId = collegeId,
Credits = 3,
TotalHours = 48,
LectureHours = 32,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
}
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"测试学生",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
}
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -103,7 +104,11 @@ public sealed class AuthControllerTests
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<ApplicationUser>>();
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
var controller = new AuthController(db, userManager, new StubTokenService());
var controller = new AuthController(
db,
userManager,
new StubTokenService(),
NoOpAppCache.Instance);
var request = new StudentActivationRequest(
student.Name,
student.StudentNumber,
@@ -19,6 +19,8 @@ public sealed class AutomaticScheduleGeneratorTests
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"BackgroundJobOutboxMessages\"");
var migrator = new DevelopmentSqliteMigrator(
db,
@@ -27,14 +29,23 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
Assert.True(await db.Database
.SqlQueryRaw<int>(
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_index_list('BackgroundJobOutboxMessages')
WHERE name = 'IX_BackgroundJobOutboxMessages_State_CompletedAt'
""")
.AnyAsync(value => value > 0));
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ScheduleEntries')
WHERE name = 'ClassroomId' AND "notnull" = 0
""")
.AnyAsync(value => value > 0));
.AnyAsync(value => value > 0));
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
@@ -0,0 +1,265 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class BackgroundJobOutboxTests
{
[Fact]
public async Task In_memory_transport_keeps_job_types_on_independent_channels()
{
var transport = new InMemoryBackgroundJobTransport();
var automatic = new BackgroundJobEnvelope(
Guid.NewGuid(),
BackgroundJobKind.AutomaticSchedule,
Guid.NewGuid());
var publish = new BackgroundJobEnvelope(
Guid.NewGuid(),
BackgroundJobKind.SchedulePublish,
Guid.NewGuid());
await transport.PublishAsync(automatic, CancellationToken.None);
await transport.PublishAsync(publish, CancellationToken.None);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await using var automaticReader = transport
.ReadAllAsync(BackgroundJobKind.AutomaticSchedule, timeout.Token)
.GetAsyncEnumerator(timeout.Token);
await using var publishReader = transport
.ReadAllAsync(BackgroundJobKind.SchedulePublish, timeout.Token)
.GetAsyncEnumerator(timeout.Token);
Assert.True(await automaticReader.MoveNextAsync());
Assert.Equal(automatic, automaticReader.Current);
Assert.True(await publishReader.MoveNextAsync());
Assert.Equal(publish, publishReader.Current);
}
[Fact]
public async Task Publisher_recovers_pending_message_and_cleans_old_completion()
{
var databasePath = Path.Combine(
Path.GetTempPath(),
$"jiaowu-outbox-{Guid.NewGuid():N}.sqlite");
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite($"Data Source={databasePath};Pooling=False"));
var options = new BackgroundJobOptions
{
PollIntervalMilliseconds = 100,
CompletedRetentionDays = 1,
CleanupBatchSize = 10
};
services.AddSingleton(options);
services.AddSingleton<InMemoryBackgroundJobTransport>();
services.AddSingleton<IBackgroundJobTransport>(provider =>
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
services.AddSingleton<BackgroundJobTelemetry>();
services.AddSingleton<BackgroundJobOutboxPublisher>();
var provider = services.BuildServiceProvider();
try
{
Guid jobId;
Guid oldCompletedOutboxId;
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "RECOVERY",
Name = "补投测试学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "补投测试排课",
Version = "V1"
};
var job = new AutomaticScheduleJob
{
SchedulePlan = plan,
ActiveSchedulePlanId = plan.Id
};
var oldCompletedOutbox = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.SchedulePublish,
Guid.NewGuid());
oldCompletedOutbox.State = BackgroundJobOutboxState.Completed;
oldCompletedOutbox.CompletedAt = DateTime.UtcNow.AddDays(-2);
oldCompletedOutboxId = oldCompletedOutbox.Id;
jobId = job.Id;
db.AddRange(term, plan, job, oldCompletedOutbox);
await db.SaveChangesAsync();
}
var publisher = provider.GetRequiredService<BackgroundJobOutboxPublisher>();
await publisher.StartAsync(CancellationToken.None);
try
{
var published = false;
for (var attempt = 0; attempt < 50 && !published; attempt++)
{
await Task.Delay(50);
await using var scope = provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
published = await db.BackgroundJobOutboxMessages.AsNoTracking()
.AnyAsync(x =>
x.JobId == jobId &&
x.JobKind == BackgroundJobKind.AutomaticSchedule &&
x.State == BackgroundJobOutboxState.Published) &&
!await db.BackgroundJobOutboxMessages.AsNoTracking()
.AnyAsync(x => x.Id == oldCompletedOutboxId);
}
Assert.True(published);
}
finally
{
await publisher.StopAsync(CancellationToken.None);
}
}
finally
{
await provider.DisposeAsync();
File.Delete(databasePath);
}
}
[Fact]
public async Task Monitoring_snapshot_reports_backlog_and_expired_leases()
{
var databasePath = Path.Combine(
Path.GetTempPath(),
$"jiaowu-monitoring-{Guid.NewGuid():N}.sqlite");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite($"Data Source={databasePath};Pooling=False")
.Options;
try
{
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var pending = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.AutomaticSchedule,
Guid.NewGuid());
pending.CreatedAt = DateTime.UtcNow.AddMinutes(-2);
var processing = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.MakeupExamAuto,
Guid.NewGuid());
processing.State = BackgroundJobOutboxState.Processing;
processing.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(-1);
db.AddRange(pending, processing);
await db.SaveChangesAsync();
var snapshot = await new BackgroundJobMonitoringService(db)
.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(1, snapshot.Pending);
Assert.Equal(1, snapshot.Processing);
Assert.Equal(1, snapshot.ExpiredLeases);
Assert.NotNull(snapshot.OldestUnfinishedAt);
Assert.True(snapshot.OldestUnfinishedAgeSeconds >= 100);
}
finally
{
File.Delete(databasePath);
}
}
[Fact]
public async Task Runner_stops_after_retry_limit_and_ignores_duplicate_delivery()
{
var databasePath = Path.Combine(
Path.GetTempPath(),
$"jiaowu-runner-{Guid.NewGuid():N}.sqlite");
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite($"Data Source={databasePath};Pooling=False"));
services.AddScoped<Jiaowu.Api.Infrastructure.Scheduling.AutomaticScheduleGenerator>();
services.AddScoped<Jiaowu.Api.Infrastructure.Scheduling.AutomaticScheduleJobProcessor>();
services.AddSingleton(new BackgroundJobOptions());
services.AddSingleton<InMemoryBackgroundJobTransport>();
services.AddSingleton<IBackgroundJobTransport>(provider =>
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
services.AddSingleton<BackgroundJobTelemetry>();
services.AddSingleton<BackgroundJobRunner>();
var provider = services.BuildServiceProvider();
try
{
BackgroundJobEnvelope envelope;
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "OUTBOX",
Name = "消息测试学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "消息测试排课",
Version = "V1"
};
var job = new AutomaticScheduleJob
{
SchedulePlan = plan,
ActiveSchedulePlanId = plan.Id,
Status = AutomaticScheduleJobStatus.Queued
};
var outbox = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.AutomaticSchedule,
job.Id);
outbox.State = BackgroundJobOutboxState.Published;
outbox.ProcessingAttempts = 5;
db.AddRange(term, plan, job, outbox);
await db.SaveChangesAsync();
envelope = new BackgroundJobEnvelope(
outbox.Id,
outbox.JobKind,
outbox.JobId);
}
var runner = provider.GetRequiredService<BackgroundJobRunner>();
var first = await runner.RunAsync(envelope, CancellationToken.None);
var duplicate = await runner.RunAsync(envelope, CancellationToken.None);
Assert.Equal(BackgroundJobRunOutcome.Completed, first.Outcome);
Assert.Equal(BackgroundJobRunOutcome.Completed, duplicate.Outcome);
await using var assertScope = provider.CreateAsyncScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
var persistedOutbox =
await assertDb.BackgroundJobOutboxMessages.SingleAsync();
Assert.Equal(BackgroundJobOutboxState.Completed, persistedOutbox.State);
Assert.NotNull(persistedOutbox.CompletedAt);
Assert.Null(persistedOutbox.ProcessingToken);
Assert.Null(persistedOutbox.LeaseExpiresAt);
Assert.Equal(6, persistedOutbox.ProcessingAttempts);
var persistedJob = await assertDb.AutomaticScheduleJobs.SingleAsync();
Assert.Equal(AutomaticScheduleJobStatus.Failed, persistedJob.Status);
Assert.Null(persistedJob.ActiveSchedulePlanId);
Assert.Contains("超过 5 次", persistedJob.ErrorMessage);
}
finally
{
await provider.DisposeAsync();
File.Delete(databasePath);
}
}
}
@@ -1,5 +1,6 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
@@ -21,7 +22,7 @@ public sealed class BaseDataControllerTests : IAsyncDisposable
.Options;
db = new AppDbContext(options);
db.Database.EnsureCreated();
controller = new BaseDataController(db);
controller = new BaseDataController(db, NoOpAppCache.Instance);
}
[Fact]
@@ -0,0 +1,389 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ClassroomReservationsControllerTests
{
[Fact]
public async Task Create_UsesApplicantsCollegeAndRejectsPublishedClassConflict()
{
await using var fixture = await ReservationFixture.CreateAsync();
var controller = fixture.Controller(fixture.ApplicantScope);
var conflictedRequest = fixture.Request(
fixture.ScheduledClassroom.Id,
fixture.ReservationDate,
1,
2);
var conflicted = await controller.Create(
conflictedRequest,
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(conflicted);
Assert.Empty(fixture.Db.ClassroomReservations);
var created = await controller.Create(
fixture.Request(
fixture.AvailableClassroom.Id,
fixture.ReservationDate,
1,
2),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var reservation = Assert.Single(fixture.Db.ClassroomReservations);
Assert.Equal(fixture.Applicant.Id, reservation.ApplicantUserId);
Assert.Equal(fixture.CollegeA.Id, reservation.ApplicantCollegeId);
Assert.Equal(ClassroomReservationStatus.Submitted, reservation.Status);
}
[Fact]
public async Task Review_OnlyAllowsApplicantCollegeAndApprovesAvailableRoom()
{
await using var fixture = await ReservationFixture.CreateAsync();
var reservation = fixture.AddSubmittedReservation(
fixture.AvailableClassroom.Id,
fixture.ReservationDate,
3,
2);
await fixture.Db.SaveChangesAsync();
var wrongCollege = await fixture.Controller(fixture.CollegeBReviewerScope)
.Approve(
reservation.Id,
new ReviewClassroomReservationRequest(""),
CancellationToken.None);
Assert.IsType<ForbidResult>(wrongCollege);
Assert.Equal(
ClassroomReservationStatus.Submitted,
reservation.Status);
var approved = await fixture.Controller(fixture.CollegeAReviewerScope)
.Approve(
reservation.Id,
new ReviewClassroomReservationRequest("同意"),
CancellationToken.None);
Assert.IsType<NoContentResult>(approved);
var stored = await fixture.Db.ClassroomReservations
.AsNoTracking()
.SingleAsync(item => item.Id == reservation.Id);
Assert.Equal(ClassroomReservationStatus.Approved, stored.Status);
Assert.Equal(fixture.CollegeAReviewer.Id, stored.ReviewedByUserId);
}
[Fact]
public async Task Approve_RechecksApprovedReservationConflict()
{
await using var fixture = await ReservationFixture.CreateAsync();
fixture.Db.ClassroomReservations.Add(new ClassroomReservation
{
ApplicantUserId = fixture.Applicant.Id,
ApplicantName = fixture.Applicant.DisplayName,
ApplicantCollegeId = fixture.CollegeA.Id,
AcademicTermId = fixture.Term.Id,
ClassroomId = fixture.AvailableClassroom.Id,
ReservationDate = fixture.ReservationDate,
StartPeriod = 5,
PeriodCount = 2,
AttendeeCount = 20,
Purpose = "已批准活动",
ContactPhone = "13800000000",
Status = ClassroomReservationStatus.Approved
});
var competing = fixture.AddSubmittedReservation(
fixture.AvailableClassroom.Id,
fixture.ReservationDate,
6,
2);
await fixture.Db.SaveChangesAsync();
var result = await fixture.Controller(fixture.CollegeAReviewerScope)
.Approve(
competing.Id,
new ReviewClassroomReservationRequest(null),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
var stored = await fixture.Db.ClassroomReservations
.AsNoTracking()
.SingleAsync(item => item.Id == competing.Id);
Assert.Equal(ClassroomReservationStatus.Submitted, stored.Status);
}
private sealed class ReservationFixture : IAsyncDisposable
{
private ReservationFixture(
SqliteConnection connection,
AppDbContext db,
College collegeA,
ApplicationUser applicant,
ApplicationUser collegeAReviewer,
AcademicTerm term,
Classroom scheduledClassroom,
Classroom availableClassroom,
DateOnly reservationDate,
ICurrentUserDataScope applicantScope,
ICurrentUserDataScope collegeAReviewerScope,
ICurrentUserDataScope collegeBReviewerScope)
{
Connection = connection;
Db = db;
CollegeA = collegeA;
Applicant = applicant;
CollegeAReviewer = collegeAReviewer;
Term = term;
ScheduledClassroom = scheduledClassroom;
AvailableClassroom = availableClassroom;
ReservationDate = reservationDate;
ApplicantScope = applicantScope;
CollegeAReviewerScope = collegeAReviewerScope;
CollegeBReviewerScope = collegeBReviewerScope;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public College CollegeA { get; }
public ApplicationUser Applicant { get; }
public ApplicationUser CollegeAReviewer { get; }
public AcademicTerm Term { get; }
public Classroom ScheduledClassroom { get; }
public Classroom AvailableClassroom { get; }
public DateOnly ReservationDate { get; }
public ICurrentUserDataScope ApplicantScope { get; }
public ICurrentUserDataScope CollegeAReviewerScope { get; }
public ICurrentUserDataScope CollegeBReviewerScope { get; }
public static async Task<ReservationFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building
{
Code = "A",
Name = "明德楼",
CampusId = campus.Id
};
var scheduledClassroom = new Classroom
{
Code = "A101",
Name = "A101",
BuildingId = building.Id,
Capacity = 60
};
var availableClassroom = new Classroom
{
Code = "A102",
Name = "A102",
BuildingId = building.Id,
Capacity = 80
};
var collegeA = new College
{
Code = "CS",
Name = "计算机学院"
};
var collegeB = new College
{
Code = "EE",
Name = "电子工程学院"
};
var applicant = User("student", "测试申请人", collegeA.Id);
var collegeAReviewer = User(
"college-a",
"计算机学院审核人",
collegeA.Id);
var collegeBReviewer = User(
"college-b",
"电子工程学院审核人",
collegeB.Id);
var term = new AcademicTerm
{
Code = "2099-1",
Name = "2099—2100 学年第一学期",
AcademicYear = "2099-2100",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2099, 9, 1),
EndDate = new DateOnly(2100, 1, 31),
IsCurrent = true
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = collegeA.Id,
Credits = 3,
TotalHours = 48,
LectureHours = 32,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var teachingTask = new TeachingTask
{
TaskNumber = "2099-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published
};
var reservationDate = term.StartDate;
var (_, dayOfWeek) =
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
term,
reservationDate);
var plan = new SchedulePlan
{
AcademicTermId = term.Id,
Name = "正式课表",
Version = "v1",
Status = SchedulePlanStatus.Published,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = teachingTask.Id,
ClassroomId = scheduledClassroom.Id,
DayOfWeek = dayOfWeek,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 18,
WeekPattern = WeekPattern.All
}
]
};
db.AddRange(
campus,
building,
scheduledClassroom,
availableClassroom,
collegeA,
collegeB,
applicant,
collegeAReviewer,
collegeBReviewer,
term,
course,
teachingTask,
plan);
await db.SaveChangesAsync();
return new ReservationFixture(
connection,
db,
collegeA,
applicant,
collegeAReviewer,
term,
scheduledClassroom,
availableClassroom,
reservationDate,
Scope(applicant, SystemRoles.Student),
Scope(collegeAReviewer, SystemRoles.CollegeAdmin),
Scope(collegeBReviewer, SystemRoles.CollegeAdmin));
}
public ClassroomReservationsController Controller(
ICurrentUserDataScope currentScope) =>
new(
Db,
currentScope,
new ClassroomReservationAvailabilityService(Db));
public CreateClassroomReservationRequest Request(
Guid classroomId,
DateOnly date,
int startPeriod,
int periodCount) =>
new(
Term.Id,
classroomId,
date,
startPeriod,
periodCount,
30,
"学院学术活动",
"13800000000",
null);
public ClassroomReservation AddSubmittedReservation(
Guid classroomId,
DateOnly date,
int startPeriod,
int periodCount)
{
var reservation = new ClassroomReservation
{
ApplicantUserId = Applicant.Id,
ApplicantName = Applicant.DisplayName,
ApplicantCollegeId = CollegeA.Id,
AcademicTermId = Term.Id,
ClassroomId = classroomId,
ReservationDate = date,
StartPeriod = startPeriod,
PeriodCount = periodCount,
AttendeeCount = 20,
Purpose = "学院交流活动",
ContactPhone = "13800000000"
};
Db.ClassroomReservations.Add(reservation);
return reservation;
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await Connection.DisposeAsync();
}
private static ApplicationUser User(
string userName,
string displayName,
Guid collegeId) =>
new()
{
Id = Guid.NewGuid(),
UserName = userName,
NormalizedUserName = userName.ToUpperInvariant(),
DisplayName = displayName,
CollegeId = collegeId,
IsEnabled = true
};
private static ICurrentUserDataScope Scope(
ApplicationUser user,
string role) =>
new FixedScope(new CurrentUserScope(
user.Id,
user.DisplayName,
user.CollegeId,
role == SystemRoles.CollegeAdmin
? DataScope.College
: DataScope.Self,
new HashSet<string>([role])));
}
private sealed class FixedScope(CurrentUserScope current)
: ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = current;
}
}
@@ -0,0 +1,274 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class CourseAdjustmentsControllerTests
{
[Fact]
public async Task Reschedule_MovesOnlyTheSelectedOccurrence()
{
await using var fixture = await Fixture.CreateAsync();
var teacherController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher));
var targetDate = new DateOnly(2026, 9, 24);
var created = await teacherController.Create(
new CourseAdjustmentRequest(
fixture.Task.Id,
CourseAdjustmentType.Reschedule,
"参加学院教研活动",
true,
targetDate,
null,
3,
null,
fixture.Classroom.Id,
null,
null,
null,
fixture.Entry.Id,
3),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var adjustment = await fixture.Db.CourseAdjustments.SingleAsync();
Assert.Equal(new DateOnly(2026, 9, 16), adjustment.SourceDate);
Assert.Equal(1, adjustment.SourceStartPeriod);
Assert.Equal(2, adjustment.SourcePeriodCount);
var adminController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(Guid.NewGuid(), DataScope.All, SystemRoles.SuperAdmin));
var approved = await adminController.Approve(
adjustment.Id,
CancellationToken.None);
Assert.IsType<NoContentResult>(approved);
var entries = await fixture.Db.ScheduleEntries
.OrderBy(x => x.StartWeek)
.ThenBy(x => x.DayOfWeek)
.ToListAsync();
Assert.Contains(entries, x =>
x.DayOfWeek == 3 && x.StartWeek == 1 && x.EndWeek == 2);
Assert.Contains(entries, x =>
x.DayOfWeek == 3 && x.StartWeek == 4 && x.EndWeek == 5);
Assert.Contains(entries, x =>
x.DayOfWeek == 4 && x.StartWeek == 4 && x.EndWeek == 4 &&
x.StartPeriod == 3 && x.PeriodCount == 2);
Assert.DoesNotContain(entries, x =>
x.DayOfWeek == 3 && x.StartWeek <= 3 && x.EndWeek >= 3);
}
[Fact]
public async Task Cancel_RemovesOnlyTheSelectedMiddleWeek()
{
await using var fixture = await Fixture.CreateAsync();
var teacherController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher));
await teacherController.Create(
new CourseAdjustmentRequest(
fixture.Task.Id,
CourseAdjustmentType.Cancel,
"参加培训",
true,
null,
null,
null,
null,
null,
null,
null,
null,
fixture.Entry.Id,
3),
CancellationToken.None);
var adjustment = await fixture.Db.CourseAdjustments.SingleAsync();
var adminController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(Guid.NewGuid(), DataScope.All, SystemRoles.SuperAdmin));
var approved = await adminController.Approve(
adjustment.Id,
CancellationToken.None);
Assert.IsType<NoContentResult>(approved);
var entries = await fixture.Db.ScheduleEntries
.OrderBy(x => x.StartWeek)
.ToListAsync();
Assert.Equal(2, entries.Count);
Assert.Contains(entries, x => x.StartWeek == 1 && x.EndWeek == 2);
Assert.Contains(entries, x => x.StartWeek == 4 && x.EndWeek == 5);
}
private sealed class Fixture : IAsyncDisposable
{
private readonly SqliteConnection connection;
private Fixture(
SqliteConnection connection,
AppDbContext db,
Guid teacherUserId,
TeachingTask task,
ScheduleEntry entry,
Classroom classroom)
{
this.connection = connection;
Db = db;
TeacherUserId = teacherUserId;
Task = task;
Entry = entry;
Classroom = classroom;
}
public AppDbContext Db { get; }
public Guid TeacherUserId { get; }
public TeachingTask Task { get; }
public ScheduleEntry Entry { get; }
public Classroom Classroom { get; }
public static async Task<Fixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var teacherUserId = Guid.NewGuid();
var user = new ApplicationUser
{
Id = teacherUserId,
UserName = "T001",
NormalizedUserName = "T001",
DisplayName = "张老师"
};
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var college = new College
{
Code = "CS",
Name = "计算机学院",
CampusId = campus.Id
};
var building = new Building
{
Code = "A",
Name = "教学楼 A",
CampusId = campus.Id
};
var classroom = new Classroom
{
Code = "A101",
Name = "A101",
BuildingId = building.Id,
Capacity = 60
};
var teacher = new Teacher
{
TeacherNumber = "T001",
Name = "张老师",
CollegeId = college.Id,
UserId = teacherUserId
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var task = new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
]
};
var plan = new SchedulePlan
{
AcademicTermId = term.Id,
Name = "正式课表",
Version = "1.0",
Status = SchedulePlanStatus.Published
};
var entry = new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = task.Id,
ClassroomId = classroom.Id,
DayOfWeek = 3,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 5,
WeekPattern = WeekPattern.All
};
db.AddRange(
user,
campus,
college,
building,
classroom,
teacher,
course,
term,
task,
plan,
entry);
await db.SaveChangesAsync();
return new Fixture(connection, db, teacherUserId, task, entry, classroom);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
}
private sealed class TestScope(
Guid userId,
DataScope scope,
string role) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"测试用户",
null,
scope,
new HashSet<string>([role]));
}
}

Some files were not shown because too many files have changed in this diff Show More