26 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
  • 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
    切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1)
    桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211)
    归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。
    已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
  • 已完成,学生端考勤记录现在按课程展示。
    每门课程会独立显示:
    课程名称、代码、教学班和任课教师。
    课程总体出勤率。
    出勤、迟到、缺勤、请假、免修次数。
    按日期排列的历次点名记录。
    缺勤或迟到记录仍可单独申诉。
164 changed files with 57533 additions and 2911 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);
@@ -1,11 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -47,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)
})
@@ -76,8 +84,12 @@ public sealed class AttendanceController(
x.Name,
x.AttendanceDate,
x.Status,
x.CheckInMethod,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.Notes,
x.SubmittedAt,
CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
@@ -99,30 +111,79 @@ 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))
return ConflictProblem("不支持该签到方式。");
var now = DateTime.UtcNow;
DateTime? checkInEndsAt = null;
string? checkInToken = null;
if (checkInMethod != AttendanceCheckInMethod.Manual)
{
if (request.CheckInDurationMinutes is < 1 or > 180)
return ConflictProblem("签到时长应为 1 至 180 分钟。");
checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value);
}
if (checkInMethod == AttendanceCheckInMethod.QrCode)
checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
if (checkInMethod == AttendanceCheckInMethod.Location)
{
if (request.TargetLatitude is < -90 or > 90 ||
request.TargetLongitude is < -180 or > 180 ||
request.TargetLatitude is null ||
request.TargetLongitude is null)
return ConflictProblem("请获取有效的签到位置。");
if (request.LocationRadiusMeters is < 20 or > 1000)
return ConflictProblem("定位签到范围应为 20 至 1000 米。");
}
var sheet = new AttendanceSheet
{
TeachingTaskId = request.TeachingTaskId,
Name = request.Name.Trim(),
AttendanceDate = request.AttendanceDate,
CheckInMethod = checkInMethod,
CheckInToken = checkInToken,
CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual
? null
: now,
CheckInEndsAt = checkInEndsAt,
TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location
? request.TargetLatitude
: null,
TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location
? request.TargetLongitude
: null,
LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location
? request.LocationRadiusMeters
: null,
Notes = Normalize(request.Notes),
Records = studentIds.Select(studentId => new AttendanceRecord
{
StudentId = studentId
StudentId = studentId,
Status = checkInMethod == AttendanceCheckInMethod.Manual
? AttendanceStatus.Present
: AttendanceStatus.Absent
}).ToList()
};
db.AttendanceSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
return Created(string.Empty, new
{
sheet.Id,
sheet.CheckInMethod,
sheet.CheckInToken,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt
});
}
[HttpGet("sheets/{id:guid}")]
@@ -138,6 +199,13 @@ public sealed class AttendanceController(
x.Name,
x.AttendanceDate,
x.Status,
x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.TargetLatitude,
x.TargetLongitude,
x.LocationRadiusMeters,
x.Notes,
x.SubmittedAt,
TaskNumber = x.TeachingTask!.TaskNumber,
@@ -153,7 +221,11 @@ public sealed class AttendanceController(
r.Student.Name,
ClassName = r.Student.AdministrativeClass!.Name,
r.Status,
r.Notes
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters
})
})
.FirstOrDefaultAsync(cancellationToken);
@@ -170,21 +242,52 @@ public sealed class AttendanceController(
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft &&
await CanManageTaskAsync(
sheet.TeachingTaskId,
cancellationToken);
var canManage = await CanManageTaskAsync(
sheet.TeachingTaskId,
cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
var now = DateTime.UtcNow;
return Ok(new
{
Sheet = new
{
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate,
sheet.Status, sheet.Notes, sheet.SubmittedAt,
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName,
sheet.Id,
sheet.TeachingTaskId,
sheet.Name,
sheet.AttendanceDate,
sheet.Status,
sheet.CheckInMethod,
CheckInToken = canManage ? sheet.CheckInToken : null,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
sheet.TargetLatitude,
sheet.TargetLongitude,
sheet.LocationRadiusMeters,
IsCheckInOpen = IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now),
CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null),
sheet.Notes,
sheet.SubmittedAt,
sheet.TaskNumber,
sheet.TaskName,
sheet.CourseCode,
sheet.CourseName,
Records = sheet.Records.Select(r => new
{
r.StudentId, r.StudentNumber, r.Name, r.ClassName,
r.Status, r.Notes,
r.StudentId,
r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
@@ -375,8 +478,231 @@ public sealed class AttendanceController(
return NoContent();
}
[HttpPost("sheets/{id:guid}/close-check-in")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> CloseCheckIn(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交,签到活动已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return ConflictProblem("普通点名没有在线签到活动。");
var now = DateTime.UtcNow;
if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now)
sheet.CheckInEndsAt = now;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
// ═══════════════ Student endpoints ═══════════════
[HttpGet("check-in-info")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetCheckInInfo(
[FromQuery, MaxLength(64)] string token,
CancellationToken cancellationToken)
{
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
if (string.IsNullOrWhiteSpace(token))
return NotFound();
var activity = await db.AttendanceRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.AttendanceSheet!.CheckInToken == token.Trim())
.Select(x => new
{
SheetId = x.AttendanceSheetId,
SheetName = x.AttendanceSheet!.Name,
x.AttendanceSheet.AttendanceDate,
x.AttendanceSheet.Status,
x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
x.CheckInAt
})
.FirstOrDefaultAsync(cancellationToken);
if (activity is null) return NotFound();
var now = DateTime.UtcNow;
return Ok(new
{
activity.SheetId,
activity.SheetName,
activity.AttendanceDate,
activity.CheckInMethod,
activity.CheckInStartsAt,
activity.CheckInEndsAt,
activity.CourseCode,
activity.CourseName,
activity.TaskNumber,
activity.CheckInAt,
IsOpen = IsCheckInOpen(
activity.Status,
activity.CheckInMethod,
activity.CheckInStartsAt,
activity.CheckInEndsAt,
now)
});
}
[HttpGet("open-check-ins")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetOpenCheckIns(
CancellationToken cancellationToken)
{
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var now = DateTime.UtcNow;
return Ok(await db.AttendanceRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft &&
x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location &&
x.AttendanceSheet.CheckInStartsAt <= now &&
x.AttendanceSheet.CheckInEndsAt >= now)
.OrderBy(x => x.AttendanceSheet!.CheckInEndsAt)
.Select(x => new
{
SheetId = x.AttendanceSheetId,
SheetName = x.AttendanceSheet!.Name,
x.AttendanceSheet.AttendanceDate,
x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt,
x.AttendanceSheet.LocationRadiusMeters,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
x.CheckInAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("check-in")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> CheckIn(
AttendanceCheckInRequest request,
CancellationToken cancellationToken)
{
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var source = db.AttendanceRecords
.Include(x => x.AttendanceSheet)
.Where(x => x.StudentId == studentId.Value);
if (!string.IsNullOrWhiteSpace(request.Token))
{
var token = request.Token.Trim();
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
}
else if (request.AttendanceSheetId.HasValue)
{
source = source.Where(x =>
x.AttendanceSheetId == request.AttendanceSheetId.Value);
}
else
{
return ConflictProblem("缺少签到活动信息。");
}
var record = await source.FirstOrDefaultAsync(cancellationToken);
if (record?.AttendanceSheet is null) return NotFound();
var sheet = record.AttendanceSheet;
var now = DateTime.UtcNow;
if (record.CheckInAt.HasValue)
{
return Ok(new
{
AlreadyCheckedIn = true,
record.CheckInAt,
record.CheckInDistanceMeters
});
}
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return ConflictProblem("该考勤表不支持学生在线签到。");
double? distanceMeters = null;
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
{
if (string.IsNullOrWhiteSpace(request.Token) ||
!string.Equals(
sheet.CheckInToken,
request.Token.Trim(),
StringComparison.Ordinal))
return NotFound();
}
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
{
if (request.Latitude is < -90 or > 90 ||
request.Longitude is < -180 or > 180 ||
request.Latitude is null ||
request.Longitude is null)
return ConflictProblem("未获取到有效的当前位置。");
if (sheet.TargetLatitude is null ||
sheet.TargetLongitude is null ||
sheet.LocationRadiusMeters is null)
return ConflictProblem("签到活动没有配置有效的位置范围。");
distanceMeters = CalculateDistanceMeters(
(double)sheet.TargetLatitude.Value,
(double)sheet.TargetLongitude.Value,
(double)request.Latitude.Value,
(double)request.Longitude.Value);
if (distanceMeters > sheet.LocationRadiusMeters.Value)
{
return ConflictProblem(
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
}
}
record.Status = AttendanceStatus.Present;
record.CheckInAt = now;
record.CheckedInMethod = sheet.CheckInMethod;
record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
? request.Latitude
: null;
record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
? request.Longitude
: null;
record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location
? request.AccuracyMeters
: null;
record.CheckInDistanceMeters = distanceMeters;
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
AlreadyCheckedIn = false,
record.CheckInAt,
record.CheckInDistanceMeters
});
}
[HttpGet("my-records")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyRecords(
@@ -403,6 +729,7 @@ public sealed class AttendanceController(
.Select(r => new
{
r.AttendanceSheetId,
r.AttendanceSheet!.TeachingTaskId,
SheetName = r.AttendanceSheet!.Name,
r.AttendanceSheet.AttendanceDate,
TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
@@ -471,7 +798,8 @@ public sealed class AttendanceController(
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"考勤申诉待处理",
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
"/teacher-attendance", cancellationToken);
"/teacher-attendance", cancellationToken,
NotificationCategory.Attendance);
}
return NoContent();
@@ -504,8 +832,8 @@ public sealed class AttendanceController(
var source = db.AttendanceRecords.AsNoTracking()
.Where(r =>
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted &&
targetClassIds.Contains(r.Student!.AdministrativeClassId));
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted)
.WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId);
if (academicTermId.HasValue)
source = source.Where(r =>
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
@@ -583,7 +911,7 @@ public sealed class AttendanceController(
request.Comment is not null
? $"您的考勤申诉{result}。意见:{request.Comment}"
: $"您的考勤申诉{result}。",
null, cancellationToken);
null, cancellationToken, NotificationCategory.Attendance);
}
return NoContent();
@@ -645,9 +973,54 @@ public sealed class AttendanceController(
.AnyAsync(
x => x.TeachingTaskId == teachingTaskId &&
x.Teacher!.UserId == scope.UserId,
cancellationToken);
cancellationToken);
}
private async Task<Guid?> GetCurrentStudentIdAsync(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
}
private static bool IsCheckInOpen(
AttendanceSheetStatus status,
AttendanceCheckInMethod method,
DateTime? startsAt,
DateTime? endsAt,
DateTime now) =>
status == AttendanceSheetStatus.Draft &&
method != AttendanceCheckInMethod.Manual &&
startsAt.HasValue &&
endsAt.HasValue &&
startsAt.Value <= now &&
endsAt.Value >= now;
internal static double CalculateDistanceMeters(
double latitude1,
double longitude1,
double latitude2,
double longitude2)
{
const double earthRadiusMeters = 6_371_000;
var latitudeDelta = DegreesToRadians(latitude2 - latitude1);
var longitudeDelta = DegreesToRadians(longitude2 - longitude1);
var startLatitude = DegreesToRadians(latitude1);
var endLatitude = DegreesToRadians(latitude2);
var haversine =
Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) +
Math.Cos(startLatitude) * Math.Cos(endLatitude) *
Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2);
return earthRadiusMeters * 2 *
Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine));
}
private static double DegreesToRadians(double degrees) =>
degrees * Math.PI / 180;
private async Task<AttendanceCourseStatistics?> LoadTaskStatisticsAsync(
Guid teachingTaskId,
CancellationToken cancellationToken)
@@ -664,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()
@@ -981,7 +1352,12 @@ public sealed record AttendanceSheetRequest(
Guid TeachingTaskId,
[MaxLength(120)] string Name,
DateTime AttendanceDate,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
AttendanceCheckInMethod? CheckInMethod,
[Range(1, 180)] int? CheckInDurationMinutes,
[Range(-90, 90)] decimal? TargetLatitude,
[Range(-180, 180)] decimal? TargetLongitude,
[Range(20, 1000)] int? LocationRadiusMeters);
public sealed record AttendanceRecordsRequest(
IReadOnlyCollection<AttendanceRecordRequest> Records);
@@ -999,6 +1375,13 @@ public sealed record AttendanceAppealReviewRequest(
bool Approve,
[MaxLength(300)] string? Comment);
public sealed record AttendanceCheckInRequest(
Guid? AttendanceSheetId,
[MaxLength(64)] string? Token,
[Range(-90, 90)] decimal? Latitude,
[Range(-180, 180)] decimal? Longitude,
[Range(0, 5000)] double? AccuracyMeters);
public sealed record AttendanceCourseStatistics(
AttendanceStatisticsCourse Course,
AttendanceStatisticsSummary Summary,
+118 -75
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]
@@ -57,59 +74,64 @@ public sealed class AuthController(
{
var name = request.Name.Trim();
var studentNumber = request.StudentNumber.Trim();
var student = await db.Students
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x =>
x.Status == StudentStatus.Active &&
x.Name == name &&
x.StudentNumber == studentNumber &&
x.EnrollmentYear == request.Grade &&
x.AdministrativeClassId == request.AdministrativeClassId &&
x.AdministrativeClass!.Grade == request.Grade &&
x.AdministrativeClass.MajorId == request.MajorId &&
x.AdministrativeClass.Major!.CollegeId == request.CollegeId,
cancellationToken);
if (student is null)
return ActivationProblem(
"填写的信息与在籍学生档案不完全一致,请核对后重试。",
StatusCodes.Status400BadRequest);
if (student.UserId.HasValue)
return ActivationProblem(
"该学号已经激活,请直接登录;如忘记密码请联系管理员重置。",
StatusCodes.Status409Conflict);
if (await userManager.FindByNameAsync(studentNumber) is not null)
return ActivationProblem(
"该学号已有登录账号但未正确关联,请联系管理员处理。",
StatusCodes.Status409Conflict);
var executionStrategy = db.Database.CreateExecutionStrategy();
return await executionStrategy.ExecuteAsync<ActionResult>(async () =>
{
var student = await db.Students
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x =>
x.Status == StudentStatus.Active &&
x.Name == name &&
x.StudentNumber == studentNumber &&
x.EnrollmentYear == request.Grade &&
x.AdministrativeClassId == request.AdministrativeClassId &&
x.AdministrativeClass!.Grade == request.Grade &&
x.AdministrativeClass.MajorId == request.MajorId &&
x.AdministrativeClass.Major!.CollegeId == request.CollegeId,
cancellationToken);
if (student is null)
return ActivationProblem(
"填写的信息与在籍学生档案不完全一致,请核对后重试。",
StatusCodes.Status400BadRequest);
if (student.UserId.HasValue)
return ActivationProblem(
"该学号已经激活,请直接登录;如忘记密码请联系管理员重置。",
StatusCodes.Status409Conflict);
if (await userManager.FindByNameAsync(studentNumber) is not null)
return ActivationProblem(
"该学号已有登录账号但未正确关联,请联系管理员处理。",
StatusCodes.Status409Conflict);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var user = new ApplicationUser
{
UserName = studentNumber,
DisplayName = student.Name,
StaffNumber = studentNumber,
CollegeId = request.CollegeId,
IsEnabled = true,
LockoutEnabled = true
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
result = await userManager.AddToRoleAsync(user, SystemRoles.Student);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
var user = new ApplicationUser
{
UserName = studentNumber,
DisplayName = student.Name,
StaffNumber = studentNumber,
CollegeId = request.CollegeId,
IsEnabled = true,
LockoutEnabled = true
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
result = await userManager.AddToRoleAsync(user, SystemRoles.Student);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { UserName = studentNumber });
student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { UserName = studentNumber });
});
}
[AllowAnonymous]
@@ -214,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);
+311 -82
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,16 +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.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)]
@@ -298,11 +351,20 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
{
if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
if (request.IsCurrent)
await db.AcademicTerms.ExecuteUpdateAsync(
setters => setters.SetProperty(x => x.IsCurrent, false),
cancellationToken);
var hasCurrentTerm = await db.AcademicTerms
.AnyAsync(x => x.IsCurrent, cancellationToken);
var shouldBeCurrent = request.IsCurrent || (!hasCurrentTerm && request.IsEnabled);
if (shouldBeCurrent)
{
var currentTerms = await db.AcademicTerms
.Where(x => x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
}
var entity = new AcademicTerm
{
@@ -312,7 +374,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
Season = request.Season,
StartDate = request.StartDate,
EndDate = request.EndDate,
IsCurrent = request.IsCurrent,
IsCurrent = shouldBeCurrent,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, "GetTerms", cancellationToken);
@@ -327,45 +389,139 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (request.EndDate <= request.StartDate)
return ValidationProblem("学期结束日期必须晚于开始日期。");
if (entity.IsCurrent && !request.IsCurrent)
return ConflictProblem("不能直接取消当前学期,请将另一个学期设为当前。");
if (request.IsCurrent && entity.IsArchived)
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
if (request.IsCurrent)
await db.AcademicTerms.Where(x => x.Id != id).ExecuteUpdateAsync(
setters => setters.SetProperty(x => x.IsCurrent, false),
cancellationToken);
{
var currentTerms = await db.AcademicTerms
.Where(x => x.Id != id && x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
}
ApplyCatalog(entity, request);
entity.AcademicYear = request.AcademicYear.Trim();
entity.Season = request.Season;
entity.StartDate = request.StartDate;
entity.EndDate = request.EndDate;
entity.IsCurrent = request.IsCurrent;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/set-current")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> SetCurrentTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (entity.IsArchived)
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
if (!entity.IsEnabled)
return ConflictProblem("停用学期不能设为当前,请先启用。");
if (entity.IsCurrent) return entity;
var currentTerms = await db.AcademicTerms
.Where(x => x.Id != id && x.IsCurrent)
.ToListAsync(cancellationToken);
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
entity.IsCurrent = true;
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/archive")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> ArchiveTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (entity.IsCurrent)
return ConflictProblem("当前学期不能归档,请先切换到新的当前学期。");
if (entity.IsArchived) return entity;
entity.IsArchived = true;
entity.ArchivedAt = DateTime.UtcNow;
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpPost("terms/{id:guid}/unarchive")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> UnarchiveTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!entity.IsArchived) return entity;
entity.IsArchived = false;
entity.ArchivedAt = null;
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)]
@@ -422,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;
}
@@ -447,10 +603,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
};
if (entity is null) return NotFound();
if (entity is AcademicTerm { IsCurrent: true })
return ConflictProblem("当前学期不能删除,请先切换到其他学期。");
db.Remove(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
@@ -473,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
db.Add(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
}
catch (DbUpdateException)
{
@@ -483,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)
@@ -508,6 +672,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.SortOrder = request.SortOrder;
entity.IsEnabled = request.IsEnabled;
}
private ObjectResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "当前操作无法完成",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public record CatalogRequest(
@@ -558,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}";
@@ -77,45 +78,60 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
}
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
var errors = new List<string>();
ExcelImportResult result;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
try
{
result = kind.ToLowerInvariant() switch
return await db.ExecuteInRetriableTransactionAsync<
ActionResult<ExcelImportResult>>(
async transaction =>
{
"campuses" => await ImportCampusesAsync(rows, errors, cancellationToken),
"colleges" => await ImportCollegesAsync(rows, errors, cancellationToken),
"majors" => await ImportMajorsAsync(rows, errors, cancellationToken),
"classes" => await ImportClassesAsync(rows, errors, cancellationToken),
"terms" => await ImportTermsAsync(rows, errors, cancellationToken),
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken),
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken),
"course-categories" => await ImportCourseCategoriesAsync(
rows, errors, cancellationToken),
_ => throw new InvalidOperationException()
};
var errors = new List<string>();
ExcelImportResult result;
try
{
result = kind.ToLowerInvariant() switch
{
"campuses" => await ImportCampusesAsync(
rows, errors, cancellationToken),
"colleges" => await ImportCollegesAsync(
rows, errors, cancellationToken),
"majors" => await ImportMajorsAsync(
rows, errors, cancellationToken),
"classes" => await ImportClassesAsync(
rows, errors, cancellationToken),
"terms" => await ImportTermsAsync(
rows, errors, cancellationToken),
"buildings" => await ImportBuildingsAsync(
rows, errors, cancellationToken),
"classrooms" => await ImportClassroomsAsync(
rows, errors, cancellationToken),
"course-categories" => await ImportCourseCategoriesAsync(
rows, errors, cancellationToken),
_ => throw new InvalidOperationException()
};
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编码或无效关联,未写入任何数据。",
Status = StatusCodes.Status409Conflict
});
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.BaseData,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编码或无效关联,未写入任何数据。",
Status = StatusCodes.Status409Conflict
});
}
},
cancellationToken);
}
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
@@ -256,7 +272,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{
entity = new Major
{
Code = code, Name = name, CollegeId = college.Id,
Code = code,
Name = name,
CollegeId = college.Id,
DegreeType = degreeType
};
db.Majors.Add(entity);
@@ -316,7 +334,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{
entity = new AdministrativeClass
{
Code = code, Name = name, MajorId = major.Id, Grade = grade.Value
Code = code,
Name = name,
MajorId = major.Id,
Grade = grade.Value
};
db.AdministrativeClasses.Add(entity);
existing[code] = entity;
@@ -362,8 +383,11 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{
entity = new AcademicTerm
{
Code = code, Name = name, AcademicYear = academicYear,
Season = season.Value, StartDate = startDate.Value,
Code = code,
Name = name,
AcademicYear = academicYear,
Season = season.Value,
StartDate = startDate.Value,
EndDate = endDate.Value
};
db.AcademicTerms.Add(entity);
@@ -461,7 +485,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{
entity = new Classroom
{
Code = code, Name = name, BuildingId = building.Id,
Code = code,
Name = name,
BuildingId = building.Id,
RoomType = roomType
};
db.Classrooms.Add(entity);
@@ -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 + "," +
@@ -122,32 +124,42 @@ public sealed class CoursesExcelController(
if (rows.Count == 0)
return ValidationProblem("Excel 中没有可导入的课程数据。");
var errors = new List<string>();
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
try
{
var result = await ImportRowsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
return await db.ExecuteInRetriableTransactionAsync<
ActionResult<ExcelImportResult>>(
async transaction =>
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
var errors = new List<string>();
try
{
var result = await ImportRowsAsync(
rows,
errors,
cancellationToken);
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复课程编码或无效关联,未写入任何课程。",
Status = StatusCodes.Status409Conflict
});
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复课程编码或无效关联,未写入任何课程。",
Status = StatusCodes.Status409Conflict
});
}
},
cancellationToken);
}
private async Task<ExcelImportResult> ImportRowsAsync(
@@ -103,12 +103,14 @@ public sealed class CurriculumPlansController(
})
.ToListAsync(cancellationToken);
var accessibleCollegeIds = majorOptions
.Select(x => x.CollegeId)
.Distinct()
.ToArray();
var accessibleMajors = db.Majors.AsNoTracking().Where(x => x.IsEnabled);
if (collegeId.HasValue)
accessibleMajors = accessibleMajors.Where(
x => x.CollegeId == collegeId.Value);
var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled && accessibleCollegeIds.Contains(x.Id))
.Where(x =>
x.IsEnabled &&
accessibleMajors.Any(major => major.CollegeId == x.Id))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name })
@@ -288,46 +290,57 @@ public sealed class CurriculumPlansController(
[HttpPost("{id:guid}/publish")]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var plan = await ScopedPlans()
.Include(x => x.Major)
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != CurriculumPlanStatus.Draft)
return ConflictProblem("只有草稿方案可以发布。");
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0))
return ConflictProblem("发布前每个课程模块都必须配置课程。");
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
if (plan.Modules.Any(module =>
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits))
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。");
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1))
return ConflictProblem("同一门课程不能重复加入多个模块。");
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
x.RecommendedSemester > plan.Major!.SchoolingYears * 2))
return ConflictProblem("建议学期超出了该专业学制。");
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var plan = await ScopedPlans()
.Include(x => x.Major)
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != CurriculumPlanStatus.Draft)
return ConflictProblem("只有草稿方案可以发布。");
if (plan.Modules.Count == 0 ||
plan.Modules.Any(x => x.Courses.Count == 0))
return ConflictProblem("发布前每个课程模块都必须配置课程。");
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
if (plan.Modules.Any(module =>
module.Courses.Sum(x => x.Course!.Credits) <
module.RequiredCredits))
return ConflictProblem(
"存在课程学分合计低于最低学分要求的模块。");
if (plan.Modules.SelectMany(x => x.Courses)
.GroupBy(x => x.CourseId)
.Any(x => x.Count() > 1))
return ConflictProblem("同一门课程不能重复加入多个模块。");
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
x.RecommendedSemester >
plan.Major!.SchoolingYears * 2))
return ConflictProblem("建议学期超出了该专业学制。");
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var previousPlans = await ScopedPlans()
.Where(x =>
x.Id != plan.Id &&
x.MajorId == plan.MajorId &&
x.EffectiveGrade == plan.EffectiveGrade &&
x.Status == CurriculumPlanStatus.Published)
.ToListAsync(cancellationToken);
foreach (var previous in previousPlans)
{
previous.Status = CurriculumPlanStatus.Archived;
}
var previousPlans = await ScopedPlans()
.Where(x =>
x.Id != plan.Id &&
x.MajorId == plan.MajorId &&
x.EffectiveGrade == plan.EffectiveGrade &&
x.Status == CurriculumPlanStatus.Published)
.ToListAsync(cancellationToken);
foreach (var previous in previousPlans)
{
previous.Status = CurriculumPlanStatus.Archived;
}
plan.Status = CurriculumPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
plan.Status = CurriculumPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken);
}
[HttpPost("{planId:guid}/modules")]
@@ -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);
}
}
@@ -31,9 +31,15 @@ public sealed class DegreeAwardsController(
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.DegreeName,
x.MinimumGradePoint, x.Status, x.Notes,
x.CalculatedAt, x.PublishedAt,
x.Id,
x.Name,
x.GraduationYear,
x.DegreeName,
x.MinimumGradePoint,
x.Status,
x.Notes,
x.CalculatedAt,
x.PublishedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
@@ -54,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();
@@ -65,22 +79,74 @@ 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, batch.Name, batch.GraduationYear, batch.DegreeName,
batch.MinimumGradePoint, batch.Status, 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)
batch.Id,
batch.Name,
batch.GraduationYear,
batch.DegreeName,
batch.MinimumGradePoint,
batch.Status,
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
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
}
});
}
@@ -114,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 => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue)
.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,
@@ -140,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
@@ -156,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}"
@@ -241,10 +315,14 @@ public sealed class DegreeAwardsController(
x.DegreeAwardBatch.GraduationYear,
x.DegreeAwardBatch.DegreeName,
x.DegreeAwardBatch.MinimumGradePoint,
x.Student!.StudentNumber, x.Student.Name,
x.Student!.StudentNumber,
x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
x.AverageGradePoint, x.Conclusion, x.ExceptionReason,
x.IsOverridden, x.ReviewComment,
x.AverageGradePoint,
x.Conclusion,
x.ExceptionReason,
x.IsOverridden,
x.ReviewComment,
x.DegreeAwardBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
@@ -39,12 +39,21 @@ public sealed class EvaluationsController(
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.Select(x => new
{
x.Id, x.Name, x.Status,
x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.StartsAt, x.EndsAt,
x.Id,
x.Name,
x.Status,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.StartsAt,
x.EndsAt,
Dimensions = x.Dimensions.Select(d => new
{
d.Id, d.Name, d.MaxScore, d.SortOrder
d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}),
RecordCount = x.Records.Count,
x.CreatedAt
@@ -194,8 +203,10 @@ public sealed class EvaluationsController(
var enrollments = await db.CourseEnrollments.AsNoTracking()
.Where(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.StudentId == studentId.Value &&
termIds.Contains(e.CourseSelectionOffering!.TeachingTask!.AcademicTermId))
e.StudentId == studentId.Value)
.WhereIn(
termIds,
e => e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)
.Select(e => new
{
e.CourseSelectionOffering!.TeachingTaskId,
@@ -211,9 +222,10 @@ public sealed class EvaluationsController(
.ToListAsync(cancellationToken);
// Check which tasks have already been evaluated
var setupIds = openSetups.Select(s => s.Id).ToArray();
var evaluatedIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.StudentId == studentId.Value &&
openSetups.Select(s => s.Id).Contains(r.EvaluationSetupId))
.Where(r => r.StudentId == studentId.Value)
.WhereIn(setupIds, r => r.EvaluationSetupId)
.Select(r => r.TeachingTaskId)
.ToHashSetAsync(cancellationToken);
@@ -251,7 +263,9 @@ public sealed class EvaluationsController(
.Where(x => x.Id == teachingTaskId)
.Select(x => new
{
x.Id, x.TaskNumber, x.Name,
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
@@ -283,7 +297,10 @@ public sealed class EvaluationsController(
setup.Name,
Dimensions = setup.Dimensions.Select(d => new
{
d.Id, d.Name, d.MaxScore, d.SortOrder
d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}),
Task = task
});
@@ -384,8 +401,8 @@ public sealed class EvaluationsController(
foreach (var setup in setups)
{
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.EvaluationSetupId == setup.Id &&
taskIds.Contains(r.TeachingTaskId))
.Where(r => r.EvaluationSetupId == setup.Id)
.WhereIn(taskIds, r => r.TeachingTaskId)
.Select(r => r.TeachingTaskId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -396,7 +413,9 @@ public sealed class EvaluationsController(
.Where(x => x.Id == taskId)
.Select(x => new
{
x.Id, x.TaskNumber, x.Name,
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name
@@ -525,12 +544,18 @@ public sealed class EvaluationsController(
{
Setup = new
{
setup.Id, setup.Name, setup.Status,
setup.Id,
setup.Name,
setup.Status,
TermName = setup.AcademicTerm!.Name,
setup.StartsAt, setup.EndsAt,
setup.StartsAt,
setup.EndsAt,
Dimensions = setup.Dimensions.Select(d => new
{
d.Id, d.Name, d.MaxScore, d.SortOrder
d.Id,
d.Name,
d.MaxScore,
d.SortOrder
})
},
ByTask = byTask,
+248 -55
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;
@@ -39,8 +41,16 @@ public sealed class ExamsController(
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.Status,
SessionCount = x.Sessions.Count,
x.Notes,
x.PublishedAt
}).ToListAsync(cancellationToken));
}
@@ -72,37 +82,42 @@ public sealed class ExamsController(
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
.Select(x => new
{
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.Status, x.Notes, x.PublishedAt,
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new
{
item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
{
item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
})
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
})
}).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan);
}
@@ -154,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(
@@ -222,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 });
}
@@ -270,7 +382,7 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken);
if (occupiedIds.Count > 0)
query = query.Where(x => !occupiedIds.Contains(x.Id));
query = query.WhereNotIn(occupiedIds, x => x.Id);
return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity)
@@ -313,8 +425,8 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active &&
!busyIds.Contains(x.Id))
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyIds, x => x.Id)
.OrderBy(x => x.Name)
.Select(x => new
{
@@ -389,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")
})
});
}
// ═══════════════════════════════════════════
@@ -419,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 &&
@@ -440,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))
{
@@ -547,8 +722,10 @@ public sealed class ExamsController(
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0)
{
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
if (await db.Teachers
.Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。");
}
@@ -564,9 +741,10 @@ public sealed class ExamsController(
if (teacherIds.Length > 0)
{
if (await overlaps.AnyAsync(x =>
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
cancellationToken))
if (await db.ExamSessionInvigilators
.Where(i => overlaps.Any(x => x.Id == i.ExamSessionId))
.WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有考试任务。");
}
@@ -594,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)
@@ -631,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();
}
@@ -32,8 +32,15 @@ public sealed class GraduationAuditsController(
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status,
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt,
x.Id,
x.Name,
x.GraduationYear,
x.EnrollmentYear,
x.Status,
x.Notes,
x.CalculatedAt,
x.PublishedAt,
x.CreatedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
@@ -54,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();
@@ -66,26 +81,80 @@ 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, batch.Name, batch.GraduationYear, batch.EnrollmentYear,
batch.Status, 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)
batch.Id,
batch.Name,
batch.GraduationYear,
batch.EnrollmentYear,
batch.Status,
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
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
}
});
}
@@ -134,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 => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
.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,
@@ -147,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)
@@ -275,13 +352,19 @@ public sealed class GraduationAuditsController(
x.Id,
BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear,
x.Student!.StudentNumber, x.Student.Name,
x.Student!.StudentNumber,
x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount,
x.FailedCourseCount, x.MissingCourseNames,
x.Conclusion, x.IsOverridden, x.ReviewComment,
x.RequiredCredits,
x.EarnedCredits,
x.RequiredCourseCount,
x.PassedRequiredCourseCount,
x.FailedCourseCount,
x.MissingCourseNames,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.GraduationAuditBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
@@ -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;
@@ -44,8 +44,16 @@ public sealed class MakeupExamsController(
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
TermIsCurrent = x.AcademicTerm.IsCurrent,
TermIsArchived = x.AcademicTerm.IsArchived,
x.Status,
SessionCount = x.Sessions.Count,
x.Notes,
x.PublishedAt
}).ToListAsync(cancellationToken));
}
@@ -77,46 +85,51 @@ public sealed class MakeupExamsController(
.Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
.Select(x => new
{
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.Status, x.Notes, x.PublishedAt,
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new
{
item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = item.Enrollments.Count,
Enrollments = item.Enrollments.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.SourceGradeRecordId,
e.SourceDeferredExamId,
e.MakeupScore
item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = item.Enrollments.Count,
Enrollments = item.Enrollments.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.SourceGradeRecordId,
e.SourceDeferredExamId,
e.MakeupScore
})
})
})
}).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan);
}
@@ -211,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(
@@ -279,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 });
@@ -311,18 +403,24 @@ public sealed class MakeupExamsController(
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null)
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(),
message = "该计划已有正在执行的任务。" });
return Ok(new
{
jobId = existing.Id,
status = existing.Status.ToString(),
message = "该计划已有正在执行的任务。"
});
var job = new MakeupExamAutoJob
{
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() });
}
@@ -404,9 +502,9 @@ public sealed class MakeupExamsController(
// Check for time conflicts with other makeup sessions
var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
.Where(x => validIds.Contains(x.StudentId) &&
x.MakeupExamSession!.StartsAt < session.EndsAt &&
.Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.MakeupExamSession.EndsAt)
.WhereIn(validIds, x => x.StudentId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -414,7 +512,7 @@ public sealed class MakeupExamsController(
if (conflictIds.Count > 0)
{
var conflictNumbers = await db.Students
.Where(s => conflictIds.Contains(s.Id))
.WhereIn(conflictIds, s => s.Id)
.Select(s => s.StudentNumber)
.ToListAsync(cancellationToken);
return ConflictProblem(
@@ -536,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")
})
});
}
// ═══════════════════════════════════════════
@@ -590,7 +712,7 @@ public sealed class MakeupExamsController(
.ToListAsync(cancellationToken);
if (occupiedIds.Count > 0)
query = query.Where(x => !occupiedIds.Contains(x.Id));
query = query.WhereNotIn(occupiedIds, x => x.Id);
return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity)
@@ -634,8 +756,8 @@ public sealed class MakeupExamsController(
.ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active &&
!busyIds.Contains(x.Id))
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyIds, x => x.Id)
.OrderBy(x => x.Name)
.Select(x => new
{
@@ -680,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)
@@ -705,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))
{
@@ -845,8 +1009,10 @@ public sealed class MakeupExamsController(
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0)
{
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
if (await db.Teachers
.Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。");
}
@@ -862,9 +1028,10 @@ public sealed class MakeupExamsController(
if (teacherIds.Length > 0)
{
if (await overlaps.AnyAsync(x =>
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
cancellationToken))
if (await db.MakeupExamSessionInvigilators
.Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId))
.WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有补考任务。");
}
@@ -919,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 + "," +
@@ -158,52 +160,63 @@ public sealed class PersonnelController(
TeacherAccountActivationRequest request,
CancellationToken cancellationToken)
{
var teacher = await db.Teachers.FindAsync([id], cancellationToken);
if (teacher is null) return NotFound();
if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
if (teacher.Status != TeacherStatus.Active)
{
return ConflictProblem("仅在职教师可以激活登录账号。");
}
if (teacher.UserId.HasValue)
{
return ConflictProblem("该教师档案已经关联登录账号。");
}
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var teacher = await db.Teachers.FindAsync([id], cancellationToken);
if (teacher is null) return NotFound();
if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
if (teacher.Status != TeacherStatus.Active)
{
return ConflictProblem("仅在职教师可以激活登录账号。");
}
if (teacher.UserId.HasValue)
{
return ConflictProblem("该教师档案已经关联登录账号。");
}
var userName = teacher.TeacherNumber.Trim();
if (await userManager.FindByNameAsync(userName) is not null)
{
return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。");
}
var userName = teacher.TeacherNumber.Trim();
if (await userManager.FindByNameAsync(userName) is not null)
{
return ConflictProblem(
"该工号已有登录账号但未正确关联,请到账号管理中核对。");
}
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var user = new ApplicationUser
{
UserName = userName,
DisplayName = teacher.Name,
StaffNumber = userName,
CollegeId = teacher.CollegeId,
IsEnabled = true,
LockoutEnabled = true
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
var user = new ApplicationUser
{
UserName = userName,
DisplayName = teacher.Name,
StaffNumber = userName,
CollegeId = teacher.CollegeId,
IsEnabled = true,
LockoutEnabled = true
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
result = await userManager.AddToRoleAsync(
user,
SystemRoles.Teacher);
if (!result.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
teacher.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { user.Id, UserName = userName });
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);
}
[HttpGet("students")]
@@ -421,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)
@@ -434,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 + "," +
@@ -123,32 +125,45 @@ public sealed class PersonnelExcelController(
}
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
var errors = new List<string>();
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
try
{
var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)
? await ImportTeachersAsync(rows, errors, cancellationToken)
: await ImportStudentsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
return await db.ExecuteInRetriableTransactionAsync<
ActionResult<ExcelImportResult>>(
async transaction =>
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编号或无效关联,未写入任何档案。",
Status = StatusCodes.Status409Conflict
});
}
var errors = new List<string>();
try
{
var result = kind.Equals(
"teachers",
StringComparison.OrdinalIgnoreCase)
? await ImportTeachersAsync(rows, errors, cancellationToken)
: await ImportStudentsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
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)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编号或无效关联,未写入任何档案。",
Status = StatusCodes.Status409Conflict
});
}
},
cancellationToken);
}
private async Task<ExcelImportResult> ImportTeachersAsync(
@@ -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();
}
@@ -82,24 +84,24 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
x.Id,
x.TaskNumber,
x.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course!.Name,
CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name,
TeacherNames = x.Teachers
x.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course!.Name,
CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode
})
.ToListAsync(cancellationToken);
var taskIds = tasks.Select(x => x.Id).ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId))
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task =>
@@ -109,19 +111,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
task.Id,
task.TaskNumber,
task.Name,
task.CourseCode,
task.CourseName,
task.CollegeId,
task.CollegeName,
task.TeacherNames,
task.Capacity,
task.StartWeek,
task.EndWeek,
task.WeeklyHours,
task.SchedulingMode,
HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
task.Name,
task.CourseCode,
task.CourseName,
task.CollegeId,
task.CollegeName,
task.TeacherNames,
task.Capacity,
task.StartWeek,
task.EndWeek,
task.WeeklyHours,
task.SchedulingMode,
HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
? false
: constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId,
@@ -143,34 +145,34 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7))
return ValidationProblem("允许上课日必须位于星期一至星期日。");
if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。");
if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。");
var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound();
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。");
if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。");
var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound();
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
task.SchedulingMode = request.SchedulingMode;
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
{
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null)
{
ClearConstraint(flexibleConstraint);
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
task.SchedulingMode = request.SchedulingMode;
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
{
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null)
{
ClearConstraint(flexibleConstraint);
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
Building? building = null;
Building? building = null;
if (request.RequiredBuildingId.HasValue)
{
building = await db.Buildings.AsNoTracking()
@@ -189,7 +191,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
return ValidationProblem("指定校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled)
.Where(x => x.IsEnabled)
.WhereIn(request.AllowedClassroomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count())
@@ -225,9 +228,9 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPut("constraints/batch")]
public async Task<ActionResult> SaveConstraintsBatch(
@@ -246,22 +249,22 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
if (request.SchedulingMode.HasValue &&
!Enum.IsDefined(request.SchedulingMode.Value))
return ValidationProblem("授课方式无效。");
if (!request.SchedulingMode.HasValue &&
!request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope &&
!request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue)
if (!request.SchedulingMode.HasValue &&
!request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope &&
!request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。");
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
var tasks = await db.TeachingTasks
.Where(x =>
taskIds.Contains(x.Id) &&
x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
@@ -297,7 +300,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? [];
allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => roomIds.Contains(x.Id) && x.IsEnabled)
.Where(x => x.IsEnabled)
.WhereIn(roomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedRooms.Count != roomIds.Length)
@@ -311,12 +315,12 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
return ValidationProblem("指定教室必须位于所选校区。");
}
var constraints = await db.TeachingTaskScheduleConstraints
.Where(x => taskIds.Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks)
{
var constraints = await db.TeachingTaskScheduleConstraints
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks)
{
if (request.SchedulingMode.HasValue)
task.SchedulingMode = request.SchedulingMode.Value;
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
@@ -384,7 +388,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken) =>
db.ScheduleEntries.AsNoTracking()
.AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken);
.WhereIn(taskIds, x => x.TeachingTaskId)
.AnyAsync(cancellationToken);
private void ClearConstraint(TeachingTaskScheduleConstraint constraint)
{
@@ -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)
@@ -95,7 +133,12 @@ public sealed class StatisticsController(
return new
{
byCollege, byMajor, byClass, byGrade, byStatus, byGender,
byCollege,
byMajor,
byClass,
byGrade,
byStatus,
byGender,
enrollmentTrend,
totals = new { totalStudents }
};
@@ -107,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 }) },
@@ -123,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)
@@ -179,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 }) },
@@ -194,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)
@@ -255,7 +373,8 @@ public sealed class StatisticsController(
var passRateByCollegeResult = passRateByCollege.Select(x => new
{
x.collegeName, x.totalRecords,
x.collegeName,
x.totalRecords,
passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0,
averageScore = Math.Round(x.averageScore, 1)
}).ToList();
@@ -296,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 }) },
@@ -311,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)
@@ -336,7 +491,8 @@ public sealed class StatisticsController(
var byCollegeResult = byCollege.Select(x => new
{
x.collegeName, x.total,
x.collegeName,
x.total,
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
}).OrderByDescending(x => x.passRate).ToList();
@@ -353,7 +509,9 @@ public sealed class StatisticsController(
var byCourseResult = byCourse.Select(x => new
{
x.courseCode, x.courseName, x.total,
x.courseCode,
x.courseName,
x.total,
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
}).OrderByDescending(x => x.passRate).ToList();
@@ -416,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 }) },
@@ -430,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)
@@ -515,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 }) },
@@ -528,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);
@@ -567,7 +790,8 @@ public sealed class StatisticsController(
// get entries
var entries = await db.ScheduleEntries.AsNoTracking()
.Where(e => e.SchedulePlanId == plan.Id)
.Where(e => e.ClassroomId != null && classroomIds.Contains(e.ClassroomId.Value))
.Where(e => e.ClassroomId != null)
.WhereIn(classroomIds, e => e.ClassroomId!.Value)
.Select(e => new
{
e.ClassroomId,
@@ -633,7 +857,8 @@ public sealed class StatisticsController(
var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount);
return new
{
day, dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
day,
dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0
};
}).ToList();
@@ -702,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 }) },
@@ -716,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)
@@ -74,8 +74,8 @@ public sealed class StudentCurriculumController(
var gradeAttempts = await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
planCourseIds.Contains(x.GradeSheet!.TeachingTask!.CourseId) &&
x.GradeSheet.Status == GradeSheetStatus.Published)
x.GradeSheet!.Status == GradeSheetStatus.Published)
.WhereIn(planCourseIds, x => x.GradeSheet!.TeachingTask!.CourseId)
.Select(x => new StudentGradeAttempt(
x.GradeSheet!.TeachingTask!.CourseId,
x.TotalScore,
@@ -88,15 +88,15 @@ public sealed class StudentCurriculumController(
var currentCourseIds = await db.TeachingTasks.AsNoTracking()
.Where(task =>
planCourseIds.Contains(task.CourseId) &&
task.Status == TeachingTaskStatus.Published &&
task.AcademicTerm!.IsCurrent &&
(task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
.WhereIn(planCourseIds, task => task.CourseId)
.Select(x => x.CourseId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -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 + "," +
@@ -76,11 +78,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name,
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode,
x.Status,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
@@ -157,11 +159,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name,
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode,
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode,
x.Status,
x.Notes,
x.PublishedAt,
@@ -215,10 +217,10 @@ public sealed class TeachingTasksController(
CourseId = request.CourseId,
Capacity = request.Capacity,
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
SchedulingMode = request.SchedulingMode,
Notes = Normalize(request.Notes)
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
SchedulingMode = request.SchedulingMode,
Notes = Normalize(request.Notes)
};
SetAssignments(task, request);
db.TeachingTasks.Add(task);
@@ -249,10 +251,10 @@ public sealed class TeachingTasksController(
task.CourseId = request.CourseId;
task.Capacity = request.Capacity;
task.StartWeek = request.StartWeek;
task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours;
task.SchedulingMode = request.SchedulingMode;
task.Notes = Normalize(request.Notes);
task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours;
task.SchedulingMode = request.SchedulingMode;
task.Notes = Normalize(request.Notes);
db.TeachingTaskTeachers.RemoveRange(task.Teachers);
db.TeachingTaskClasses.RemoveRange(task.Classes);
task.Teachers = [];
@@ -342,7 +344,7 @@ public sealed class TeachingTasksController(
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.Where(x => ids.Contains(x.Id))
.WhereIn(ids, x => x.Id)
.OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
if (tasks.Count != ids.Length)
@@ -352,17 +354,17 @@ public sealed class TeachingTasksController(
switch (request.Operation)
{
case TeachingTaskBatchOperation.Publish:
{
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
if (validation is not null) return ConflictProblem(validation);
var publishedAt = DateTime.UtcNow;
foreach (var task in tasks)
{
task.Status = TeachingTaskStatus.Published;
task.PublishedAt = publishedAt;
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
if (validation is not null) return ConflictProblem(validation);
var publishedAt = DateTime.UtcNow;
foreach (var task in tasks)
{
task.Status = TeachingTaskStatus.Published;
task.PublishedAt = publishedAt;
}
break;
}
break;
}
case TeachingTaskBatchOperation.Delete:
if (tasks.Any(x =>
x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)))
@@ -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)
@@ -414,14 +417,14 @@ public sealed class TeachingTasksController(
.FirstOrDefaultAsync(
x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
var hoursProblem = TeachingTaskHours.Validate(
course,
request.StartWeek,
request.EndWeek,
request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
var hoursProblem = TeachingTaskHours.Validate(
course,
request.StartWeek,
request.EndWeek,
request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
var scopedCollegeId = ScopedCollegeId();
@@ -431,7 +434,8 @@ public sealed class TeachingTasksController(
var classIds = request.ClassIds.Distinct().ToArray();
if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。");
var classesQuery = db.AdministrativeClasses
.Where(x => classIds.Contains(x.Id) && x.IsEnabled)
.Where(x => x.IsEnabled)
.WhereIn(classIds, x => x.Id)
.Include(x => x.Major)
.Include(x => x.Students)
.AsQueryable();
@@ -443,123 +447,130 @@ public sealed class TeachingTasksController(
if (classes.Count != classIds.Length)
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
.Where(x =>
classIds.Contains(x.AdministrativeClassId) &&
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.CourseId == request.CourseId)
.Select(x => x.AdministrativeClassId)
.Distinct()
.ToListAsync(cancellationToken);
if (assignedClassIds.Count > 0)
{
var names = classes
.Where(x => assignedClassIds.Contains(x.Id))
.Select(x => x.Name);
return ConflictProblem(
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
}
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId &&
x.Status == TeacherCourseApplicationStatus.Approved &&
x.Teacher!.Status == TeacherStatus.Active)
.Where(x => !scopedCollegeId.HasValue ||
x.Teacher!.CollegeId == scopedCollegeId.Value)
.Select(x => x.Teacher!)
.ToListAsync(cancellationToken);
if (eligibleTeachers.Count == 0)
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x =>
eligibleTeacherIds.Contains(x.TeacherId) &&
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.Status != TeachingTaskStatus.Closed)
.GroupBy(x => x.TeacherId)
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
var existingNumbers = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == request.AcademicTermId)
.Select(x => x.TaskNumber)
.ToHashSetAsync(cancellationToken);
var batchCode =
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
var groups = classes.Chunk(request.ClassesPerTask).ToList();
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
eligibleTeacherIds,
existingLoads,
groups.Count);
var created = new List<TeachingTask>();
for (var index = 0; index < groups.Count; index++)
{
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
var group = groups[index];
var taskNumber = NextTaskNumber(
term.Code,
course.Code,
index + 1,
existingNumbers);
existingNumbers.Add(taskNumber);
var studentCount = group.Sum(administrativeClass =>
administrativeClass.Students.Count(student =>
student.Status == StudentStatus.Active));
var task = new TeachingTask
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
TaskNumber = taskNumber,
Name = $"{course.Name}教学班 {index + 1:D2}",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = Math.Max(1, studentCount),
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
GenerationBatchCode = batchCode,
Notes = "公共课合班自动生成,发布前可继续调整。",
Teachers =
[
new TeachingTaskTeacher
db.ChangeTracker.Clear();
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
.Where(x =>
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.CourseId == request.CourseId)
.WhereIn(classIds, x => x.AdministrativeClassId)
.Select(x => x.AdministrativeClassId)
.Distinct()
.ToListAsync(cancellationToken);
if (assignedClassIds.Count > 0)
{
var names = classes
.Where(x => assignedClassIds.Contains(x.Id))
.Select(x => x.Name);
return ConflictProblem(
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
}
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId &&
x.Status == TeacherCourseApplicationStatus.Approved &&
x.Teacher!.Status == TeacherStatus.Active)
.Where(x => !scopedCollegeId.HasValue ||
x.Teacher!.CollegeId == scopedCollegeId.Value)
.Select(x => x.Teacher!)
.ToListAsync(cancellationToken);
if (eligibleTeachers.Count == 0)
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x =>
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.Status != TeachingTaskStatus.Closed)
.WhereIn(eligibleTeacherIds, x => x.TeacherId)
.GroupBy(x => x.TeacherId)
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
var existingNumbers = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == request.AcademicTermId)
.Select(x => x.TaskNumber)
.ToHashSetAsync(cancellationToken);
var batchCode =
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
var groups = classes.Chunk(request.ClassesPerTask).ToList();
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
eligibleTeacherIds,
existingLoads,
groups.Count);
var created = new List<TeachingTask>();
for (var index = 0; index < groups.Count; index++)
{
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
var group = groups[index];
var taskNumber = NextTaskNumber(
term.Code,
course.Code,
index + 1,
existingNumbers);
existingNumbers.Add(taskNumber);
var studentCount = group.Sum(administrativeClass =>
administrativeClass.Students.Count(student =>
student.Status == StudentStatus.Active));
var task = new TeachingTask
{
TaskNumber = taskNumber,
Name = $"{course.Name}教学班 {index + 1:D2}",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = Math.Max(1, studentCount),
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
GenerationBatchCode = batchCode,
Notes = "公共课合班自动生成,发布前可继续调整。",
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes = group.Select(administrativeClass =>
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}).ToList()
};
created.Add(task);
}
],
Classes = group.Select(administrativeClass =>
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}).ToList()
};
created.Add(task);
}
db.TeachingTasks.AddRange(created);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new
{
BatchCode = batchCode,
CreatedCount = created.Count,
Tasks = created.Select(task => new
{
task.Id,
task.TaskNumber,
task.Name,
TeacherName = eligibleTeachers
.First(x => x.Id == task.Teachers.Single().TeacherId).Name,
ClassNames = classes
.Where(x => task.Classes.Any(item =>
item.AdministrativeClassId == x.Id))
.Select(x => x.Name),
task.Capacity
})
});
db.TeachingTasks.AddRange(created);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(new
{
BatchCode = batchCode,
CreatedCount = created.Count,
Tasks = created.Select(task => new
{
task.Id,
task.TaskNumber,
task.Name,
TeacherName = eligibleTeachers
.First(x => x.Id == task.Teachers.Single().TeacherId).Name,
ClassNames = classes
.Where(x => task.Classes.Any(item =>
item.AdministrativeClassId == x.Id))
.Select(x => x.Name),
task.Capacity
})
});
},
cancellationToken,
IsolationLevel.Serializable);
}
private IQueryable<TeachingTask> ScopedTasks()
@@ -579,8 +590,8 @@ public sealed class TeachingTasksController(
return ValidationProblem("开始周不能晚于结束周。");
var course = await db.Courses.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid();
if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。");
var hoursProblem = TeachingTaskHours.Validate(
@@ -589,7 +600,7 @@ public sealed class TeachingTasksController(
request.EndWeek,
request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId();
var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
@@ -599,25 +610,26 @@ public sealed class TeachingTasksController(
if (request.PrimaryTeacherId.HasValue &&
!teacherIds.Contains(request.PrimaryTeacherId.Value))
return ValidationProblem("主讲教师必须包含在授课教师中。");
if (await db.Teachers.CountAsync(
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
cancellationToken) != teacherIds.Length)
if (await db.Teachers
.Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效或非在职授课教师。");
if (teacherIds.Length > 0 &&
await db.TeacherCourseApplications.CountAsync(
x =>
await db.TeacherCourseApplications
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId &&
teacherIds.Contains(x.TeacherId) &&
x.Status == TeacherCourseApplicationStatus.Approved,
cancellationToken) != teacherIds.Length)
x.Status == TeacherCourseApplicationStatus.Approved)
.WhereIn(teacherIds, x => x.TeacherId)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。");
var classIds = request.ClassIds.Distinct().ToArray();
if (classIds.Length > 0)
{
var classes = db.AdministrativeClasses.AsNoTracking()
.Where(x => classIds.Contains(x.Id));
.WhereIn(classIds, x => x.Id);
if (collegeId.HasValue)
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
if (await classes.CountAsync(cancellationToken) != classIds.Length)
@@ -673,11 +685,10 @@ public sealed class TeachingTasksController(
.ToArray();
var approvedApplications = await db.TeacherCourseApplications
.AsNoTracking()
.Where(x =>
termIds.Contains(x.AcademicTermId) &&
courseIds.Contains(x.CourseId) &&
teacherIds.Contains(x.TeacherId) &&
x.Status == TeacherCourseApplicationStatus.Approved)
.Where(x => x.Status == TeacherCourseApplicationStatus.Approved)
.WhereIn(termIds, x => x.AcademicTermId)
.WhereIn(courseIds, x => x.CourseId)
.WhereIn(teacherIds, x => x.TeacherId)
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
.ToListAsync(cancellationToken);
var approvedAssignments = approvedApplications
@@ -732,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)]
@@ -220,6 +222,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
var terms = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate)
.Select(x => new
{
@@ -228,6 +231,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
x.StartDate,
x.EndDate,
x.IsCurrent,
x.IsArchived,
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published)
@@ -235,6 +239,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.ToListAsync(cancellationToken);
var selectedTermId = academicTermId
?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(x => !x.IsArchived && x.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
var campuses = await db.Campuses.AsNoTracking()
.Where(x => x.IsEnabled)
@@ -294,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 =>
@@ -317,48 +322,33 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken);
var activePeriodCount = configuredPeriodCount == 0
? requestedPeriods.Length
: await db.ScheduleTimeSlots.AsNoTracking().CountAsync(x =>
x.AcademicTermId == academicTermId &&
x.IsEnabled &&
requestedPeriods.Contains(x.PeriodNumber),
cancellationToken);
: await db.ScheduleTimeSlots.AsNoTracking()
.Where(x =>
x.AcademicTermId == academicTermId &&
x.IsEnabled)
.WhereIn(requestedPeriods, x => x.PeriodNumber)
.CountAsync(cancellationToken);
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 =>
x.IsEnabled &&
x.Building!.IsEnabled &&
x.Building.Campus!.IsEnabled &&
!occupiedIds.Contains(x.Id));
x.Building.Campus!.IsEnabled)
.WhereNotIn(occupiedIds, x => x.Id);
if (campusId.HasValue)
rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value);
if (buildingId.HasValue)
@@ -393,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,73 +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,
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}")]
@@ -98,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);
}
@@ -117,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);
@@ -137,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);
@@ -261,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,
@@ -268,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);
@@ -294,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);
+117 -65
View File
@@ -25,8 +25,14 @@ public sealed class UsersController(
.OrderBy(x => x.UserName)
.Select(x => new
{
x.Id, x.UserName, x.DisplayName, x.StaffNumber,
x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt
x.Id,
x.UserName,
x.DisplayName,
x.StaffNumber,
x.CollegeId,
x.IsEnabled,
x.LastLoginAt,
x.CreatedAt
})
.ToListAsync(cancellationToken);
@@ -36,8 +42,14 @@ public sealed class UsersController(
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
result.Add(new
{
user.Id, user.UserName, user.DisplayName, user.StaffNumber,
user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt,
user.Id,
user.UserName,
user.DisplayName,
user.StaffNumber,
user.CollegeId,
user.IsEnabled,
user.LastLoginAt,
user.CreatedAt,
Roles = identityUser is null
? []
: await userManager.GetRolesAsync(identityUser)
@@ -54,38 +66,50 @@ public sealed class UsersController(
.ToListAsync(cancellationToken));
[HttpPost]
public async Task<ActionResult> Create(CreateUserRequest request)
public async Task<ActionResult> Create(
CreateUserRequest request,
CancellationToken cancellationToken)
{
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber);
var profiles = await ResolveProfilesAsync(staffNumber, roles);
if (profiles.Error is not null) return profiles.Error;
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var profiles = await ResolveProfilesAsync(staffNumber, roles);
if (profiles.Error is not null) return profiles.Error;
var user = new ApplicationUser
{
UserName = request.UserName.Trim(),
DisplayName = request.DisplayName.Trim(),
StaffNumber = staffNumber,
CollegeId = request.CollegeId,
LockoutEnabled = true,
IsEnabled = true
};
await using var transaction = await db.Database.BeginTransactionAsync();
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
return IdentityValidationProblem(result);
var user = new ApplicationUser
{
UserName = request.UserName.Trim(),
DisplayName = request.DisplayName.Trim(),
StaffNumber = staffNumber,
CollegeId = request.CollegeId,
LockoutEnabled = true,
IsEnabled = true
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
return IdentityValidationProblem(result);
result = await userManager.AddToRolesAsync(user, roles);
if (!result.Succeeded)
return IdentityValidationProblem(result);
result = await userManager.AddToRolesAsync(user, roles);
if (!result.Succeeded)
return IdentityValidationProblem(result);
if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id;
if (profiles.Student is not null) profiles.Student.UserId = user.Id;
await db.SaveChangesAsync();
await transaction.CommitAsync();
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id });
if (profiles.Teacher is not null)
profiles.Teacher.UserId = user.Id;
if (profiles.Student is not null)
profiles.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return CreatedAtAction(
nameof(GetUsers),
new { id = user.Id },
new { user.Id });
},
cancellationToken);
}
[HttpPut("{id:guid}/status")]
@@ -103,51 +127,79 @@ public sealed class UsersController(
}
[HttpPut("{id:guid}/roles")]
public async Task<IActionResult> SetRoles(Guid id, SetRolesRequest request)
public async Task<IActionResult> SetRoles(
Guid id,
SetRolesRequest request,
CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber);
var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id);
if (profiles.Error is not null) return profiles.Error;
return await db.ExecuteInRetriableTransactionAsync<IActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var profiles = await ResolveProfilesAsync(
staffNumber,
roles,
user.Id);
if (profiles.Error is not null) return profiles.Error;
var existing = await userManager.GetRolesAsync(user);
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
existing.Contains(SystemRoles.SuperAdmin) &&
!roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
{
return ValidationProblem("不能移除当前账号的超级管理员角色。");
}
var existing = await userManager.GetRolesAsync(user);
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
existing.Contains(SystemRoles.SuperAdmin) &&
!roles.Contains(
SystemRoles.SuperAdmin,
StringComparer.OrdinalIgnoreCase))
{
return ValidationProblem(
"不能移除当前账号的超级管理员角色。");
}
await using var transaction = await db.Database.BeginTransactionAsync();
user.StaffNumber = staffNumber;
user.CollegeId = request.CollegeId;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult);
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
var addResult = await userManager.AddToRolesAsync(
user,
roles.Except(existing, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded) return IdentityValidationProblem(addResult);
user.StaffNumber = staffNumber;
user.CollegeId = request.CollegeId;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return IdentityValidationProblem(updateResult);
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded)
return IdentityValidationProblem(removeResult);
var addResult = await userManager.AddToRolesAsync(
user,
roles.Except(existing, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
return IdentityValidationProblem(addResult);
var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync();
var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync();
if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
foreach (var teacher in linkedTeachers) teacher.UserId = null;
if (!roles.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
foreach (var student in linkedStudents) student.UserId = null;
if (profiles.Teacher is not null) profiles.Teacher.UserId = id;
if (profiles.Student is not null) profiles.Student.UserId = id;
await db.SaveChangesAsync();
await transaction.CommitAsync();
return NoContent();
var linkedTeachers = await db.Teachers
.Where(x => x.UserId == id)
.ToListAsync();
var linkedStudents = await db.Students
.Where(x => x.UserId == id)
.ToListAsync();
if (!roles.Contains(
SystemRoles.Teacher,
StringComparer.OrdinalIgnoreCase))
foreach (var teacher in linkedTeachers)
teacher.UserId = null;
if (!roles.Contains(
SystemRoles.Student,
StringComparer.OrdinalIgnoreCase))
foreach (var student in linkedStudents)
student.UserId = null;
if (profiles.Teacher is not null)
profiles.Teacher.UserId = id;
if (profiles.Student is not null)
profiles.Student.UserId = id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken);
}
private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles)
+196 -153
View File
@@ -83,169 +83,212 @@ public sealed class UsersExcelController(
}
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。");
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var users = await userManager.Users.ToDictionaryAsync(
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
var teachers = await db.Teachers.ToDictionaryAsync(
x => x.TeacherNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var students = await db.Students.ToDictionaryAsync(
x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var errors = new List<string>();
var plans = new List<UserImportPlan>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
var userName = Required(row, "登录账号", errors);
var displayName = Required(row, "姓名", errors);
if (userName is null || displayName is null) continue;
if (!seenNames.Add(userName))
return await db.ExecuteInRetriableTransactionAsync<
ActionResult<ExcelImportResult>>(
async transaction =>
{
errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。");
continue;
}
db.ChangeTracker.Clear();
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var users = await userManager.Users.ToDictionaryAsync(
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
var teachers = await db.Teachers.ToDictionaryAsync(
x => x.TeacherNumber,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var students = await db.Students.ToDictionaryAsync(
x => x.StudentNumber,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var errors = new List<string>();
var plans = new List<UserImportPlan>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = users.GetValueOrDefault(userName);
var password = Optional(row, "初始密码");
if (existing is null && (password?.Length ?? 0) < 8)
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
if (password is { Length: > 0 and < 8 })
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
var roleNames = ParseRoles(row, errors);
var enabled = ParseEnabled(row, errors);
var staffNumber = Optional(row, "工号/学号");
Guid? collegeId = null;
var collegeCode = Optional(row, "学院编码");
if (collegeCode is not null)
{
if (!colleges.TryGetValue(collegeCode, out var college))
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
else collegeId = college.Id;
}
Teacher? teacher = null;
Student? student = null;
if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher))
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
else if (teacher.UserId.HasValue && teacher.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
else if (!plannedTeachers.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
}
if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !students.TryGetValue(staffNumber, out student))
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
else if (student.UserId.HasValue && student.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
else if (!plannedStudents.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
}
if (existing?.Id.ToString() == currentUserId)
{
if (!enabled)
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
if (!roleNames.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
errors.Add($"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
}
plans.Add(new UserImportPlan(
row.RowNumber, existing, userName, displayName, password,
staffNumber, collegeId, roleNames, enabled, teacher, student));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var created = 0;
var updated = 0;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
foreach (var plan in plans)
{
var user = plan.Existing;
if (user is null)
{
user = new ApplicationUser
foreach (var row in rows)
{
UserName = plan.UserName,
DisplayName = plan.DisplayName,
StaffNumber = plan.StaffNumber,
CollegeId = plan.CollegeId,
IsEnabled = plan.IsEnabled,
LockoutEnabled = true
};
var createResult = await userManager.CreateAsync(user, plan.Password!);
if (!createResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(createResult, plan.RowNumber);
var userName = Required(row, "登录账号", errors);
var displayName = Required(row, "姓名", errors);
if (userName is null || displayName is null) continue;
if (!seenNames.Add(userName))
{
errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。");
continue;
}
var existing = users.GetValueOrDefault(userName);
var password = Optional(row, "初始密码");
if (existing is null && (password?.Length ?? 0) < 8)
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
if (password is { Length: > 0 and < 8 })
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
var roleNames = ParseRoles(row, errors);
var enabled = ParseEnabled(row, errors);
var staffNumber = Optional(row, "工号/学号");
Guid? collegeId = null;
var collegeCode = Optional(row, "学院编码");
if (collegeCode is not null)
{
if (!colleges.TryGetValue(collegeCode, out var college))
errors.Add(
$"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
else collegeId = college.Id;
}
Teacher? teacher = null;
Student? student = null;
if (roleNames.Contains(
SystemRoles.Teacher,
StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null ||
!teachers.TryGetValue(staffNumber, out teacher))
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
else if (teacher.UserId.HasValue &&
teacher.UserId != existing?.Id)
errors.Add(
$"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
else if (!plannedTeachers.Add(staffNumber))
errors.Add(
$"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
}
if (roleNames.Contains(
SystemRoles.Student,
StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null ||
!students.TryGetValue(staffNumber, out student))
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
else if (student.UserId.HasValue &&
student.UserId != existing?.Id)
errors.Add(
$"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
else if (!plannedStudents.Add(staffNumber))
errors.Add(
$"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
}
if (existing?.Id.ToString() == currentUserId)
{
if (!enabled)
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
if (!roleNames.Contains(
SystemRoles.SuperAdmin,
StringComparer.OrdinalIgnoreCase))
errors.Add(
$"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
}
plans.Add(new UserImportPlan(
row.RowNumber, existing, userName, displayName, password,
staffNumber, collegeId, roleNames, enabled, teacher, student));
}
created++;
}
else
{
user.DisplayName = plan.DisplayName;
user.StaffNumber = plan.StaffNumber;
user.CollegeId = plan.CollegeId;
user.IsEnabled = plan.IsEnabled;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
if (errors.Count > 0) return ImportValidationProblem(errors);
var created = 0;
var updated = 0;
foreach (var plan in plans)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(updateResult, plan.RowNumber);
}
if (plan.Password is not null)
{
var token = await userManager.GeneratePasswordResetTokenAsync(user);
var passwordResult = await userManager.ResetPasswordAsync(
user, token, plan.Password);
if (!passwordResult.Succeeded)
var user = plan.Existing;
if (user is null)
{
user = new ApplicationUser
{
UserName = plan.UserName,
DisplayName = plan.DisplayName,
StaffNumber = plan.StaffNumber,
CollegeId = plan.CollegeId,
IsEnabled = plan.IsEnabled,
LockoutEnabled = true
};
var createResult = await userManager.CreateAsync(
user,
plan.Password!);
if (!createResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
createResult,
plan.RowNumber);
}
created++;
}
else
{
user.DisplayName = plan.DisplayName;
user.StaffNumber = plan.StaffNumber;
user.CollegeId = plan.CollegeId;
user.IsEnabled = plan.IsEnabled;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
updateResult,
plan.RowNumber);
}
if (plan.Password is not null)
{
var token = await userManager
.GeneratePasswordResetTokenAsync(user);
var passwordResult = await userManager.ResetPasswordAsync(
user,
token,
plan.Password);
if (!passwordResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
passwordResult,
plan.RowNumber);
}
}
updated++;
}
var existingRoles = await userManager.GetRolesAsync(user);
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existingRoles.Except(
plan.Roles,
StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(passwordResult, plan.RowNumber);
return IdentityValidationProblem(removeResult, plan.RowNumber);
}
var addResult = await userManager.AddToRolesAsync(
user,
plan.Roles.Except(
existingRoles,
StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(addResult, plan.RowNumber);
}
foreach (var linked in await db.Teachers
.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Teacher) linked.UserId = null;
foreach (var linked in await db.Students
.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Student) linked.UserId = null;
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
if (plan.Student is not null) plan.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
}
updated++;
}
var existingRoles = await userManager.GetRolesAsync(user);
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(removeResult, plan.RowNumber);
}
var addResult = await userManager.AddToRolesAsync(
user,
plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(addResult, plan.RowNumber);
}
foreach (var linked in await db.Teachers.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Teacher) linked.UserId = null;
foreach (var linked in await db.Students.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Student) linked.UserId = null;
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
if (plan.Student is not null) plan.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return Ok(new ExcelImportResult(created, updated, plans.Count));
await transaction.CommitAsync(cancellationToken);
return Ok(new ExcelImportResult(created, updated, plans.Count));
},
cancellationToken);
}
[HttpPut("{id:guid}/password")]
@@ -34,9 +34,13 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
{
db.WarningRules.Add(new WarningRule
{
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(),
Threshold = r.Threshold, IsEnabled = r.IsEnabled,
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor,
AcademicTermId = academicTermId,
Type = r.Type,
Name = r.Name.Trim(),
Threshold = r.Threshold,
IsEnabled = r.IsEnabled,
NotifyStudent = r.NotifyStudent,
NotifyCounselor = r.NotifyCounselor,
Description = r.Description?.Trim(),
AutoCheckEnabled = r.AutoCheckEnabled,
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
@@ -88,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);
}
}
}
@@ -110,7 +114,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
{
var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct);
if (classIds.Count > 0)
q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId));
q = q.WhereIn(classIds, x => x.Student!.AdministrativeClassId);
else if (scope.Current.RestrictedCollegeId.HasValue)
q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId);
}
@@ -9,6 +9,14 @@ public sealed class AttendanceSheet : EntityBase
public required string Name { get; set; }
public DateTime AttendanceDate { get; set; }
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
public AttendanceCheckInMethod CheckInMethod { get; set; } =
AttendanceCheckInMethod.Manual;
public string? CheckInToken { get; set; }
public DateTime? CheckInStartsAt { get; set; }
public DateTime? CheckInEndsAt { get; set; }
public decimal? TargetLatitude { get; set; }
public decimal? TargetLongitude { get; set; }
public int? LocationRadiusMeters { get; set; }
public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = [];
@@ -22,6 +30,12 @@ public sealed class AttendanceRecord
public Student? Student { get; set; }
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
public string? Notes { get; set; }
public DateTime? CheckInAt { get; set; }
public AttendanceCheckInMethod? CheckedInMethod { get; set; }
public decimal? CheckInLatitude { get; set; }
public decimal? CheckInLongitude { get; set; }
public double? CheckInAccuracyMeters { get; set; }
public double? CheckInDistanceMeters { get; set; }
public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None;
public string? AppealReason { get; set; }
public DateTime? AppealSubmittedAt { get; set; }
@@ -44,6 +58,13 @@ public enum AttendanceStatus
Excused = 5
}
public enum AttendanceCheckInMethod
{
Manual = 1,
QrCode = 2,
Location = 3
}
public enum AttendanceAppealStatus
{
None = 0,
@@ -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
}
@@ -56,6 +56,8 @@ public sealed class AcademicTerm : CatalogEntity
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public bool IsCurrent { get; set; }
public bool IsArchived { get; set; }
public DateTime? ArchivedAt { get; set; }
}
public enum TermSeason
@@ -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))}" : ""));
}
@@ -168,7 +183,7 @@ public sealed class ExamArrangementService(AppDbContext db)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
// Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
@@ -181,7 +196,7 @@ public sealed class ExamArrangementService(AppDbContext db)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query
.OrderBy(x => x.Capacity)
@@ -213,8 +228,8 @@ public sealed class ExamArrangementService(AppDbContext db)
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active &&
!busyTeacherIds.Contains(x.Id))
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken);
@@ -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))}" : ""));
}
@@ -164,7 +179,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId &&
@@ -176,7 +191,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query
.OrderBy(x => x.Capacity)
@@ -208,8 +223,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active &&
!busyTeacherIds.Contains(x.Id))
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken);
@@ -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,
@@ -32,8 +32,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
// 1. Approved deferred exams (highest priority)
var deferredStudents = await db.DeferredExams.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId &&
x.Status == ApprovalStatus.Approved &&
!enrolledSet.Contains(x.StudentId))
x.Status == ApprovalStatus.Approved)
.WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new
{
x.StudentId,
@@ -58,8 +58,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
// 2. Absent students
var absentStudents = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == gradeSheet.Id &&
x.ExamStatus == GradeExamStatus.Absent &&
!enrolledSet.Contains(x.StudentId))
x.ExamStatus == GradeExamStatus.Absent)
.WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new
{
x.StudentId,
@@ -85,8 +85,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
var failedStudents = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == gradeSheet.Id &&
x.ExamStatus == GradeExamStatus.Normal &&
x.TotalScore < 60 &&
!enrolledSet.Contains(x.StudentId))
x.TotalScore < 60)
.WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new
{
x.StudentId,
@@ -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);
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Jiaowu.Api.Infrastructure.Persistence;
@@ -40,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>();
@@ -74,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 =>
@@ -88,7 +94,30 @@ 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)
{
base.ConfigureConventions(configurationBuilder);
// Connector/NET returns MySQL DATE values as DateTime. An explicit
// provider conversion prevents EF from asking the reader for DateOnly.
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)
{
@@ -98,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);
});
@@ -154,8 +184,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(x => x.BuildingId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<AcademicTerm>()
.HasIndex(x => x.IsCurrent);
builder.Entity<AcademicTerm>(entity =>
{
entity.HasIndex(x => x.IsCurrent);
entity.HasIndex(x => x.IsArchived);
});
builder.Entity<Teacher>(entity =>
{
@@ -443,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);
@@ -455,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);
@@ -478,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)
@@ -548,8 +641,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<AttendanceSheet>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.CheckInToken).HasMaxLength(64);
entity.Property(x => x.TargetLatitude).HasPrecision(10, 7);
entity.Property(x => x.TargetLongitude).HasPrecision(10, 7);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
entity.HasIndex(x => x.CheckInToken).IsUnique();
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
@@ -562,6 +659,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Notes).HasMaxLength(300);
entity.Property(x => x.AppealReason).HasMaxLength(500);
entity.Property(x => x.AppealReviewComment).HasMaxLength(300);
entity.Property(x => x.CheckInLatitude).HasPrecision(10, 7);
entity.Property(x => x.CheckInLongitude).HasPrecision(10, 7);
entity.HasIndex(x => x.AppealStatus);
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.Records)
@@ -854,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()
@@ -868,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 =>
@@ -879,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)
@@ -904,3 +1077,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
});
}
}
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,13 +1,17 @@
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
// collections, so membership in definition/ID sets is checked after loading.
private const int Grade = 2026;
private const int ClassesPerMajor = 2;
private const int StudentsPerClass = 35;
@@ -45,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)
@@ -176,14 +184,18 @@ public sealed class DemoDataSeeder(
.SelectMany(x => x.Majors)
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var majors = await db.Majors
var majors = (await db.Majors
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken))
.Where(x => targetMajorCodes.Contains(x.Code))
.ToList();
var majorIds = majors.Select(x => x.Id).ToHashSet();
var existingClasses = (await db.AdministrativeClasses
.Where(x => x.Grade == Grade)
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var existingClasses = await db.AdministrativeClasses
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
.ToListAsync(cancellationToken))
.Where(x => majorIds.Contains(x.MajorId))
.ToList();
var existingCodes = (await db.AdministrativeClasses
.Select(x => x.Code)
.ToListAsync(cancellationToken))
@@ -218,13 +230,16 @@ public sealed class DemoDataSeeder(
var collegeCodes = CollegeDefinitions
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var colleges = await db.Colleges
.Where(x => collegeCodes.Contains(x.Code))
var colleges = (await db.Colleges
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var existingTeachers = await db.Teachers
.Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId))
.ToListAsync(cancellationToken);
.ToListAsync(cancellationToken))
.Where(x => collegeCodes.Contains(x.Code))
.ToList();
var collegeIds = colleges.Select(x => x.Id).ToHashSet();
var existingTeachers = (await db.Teachers
.ToListAsync(cancellationToken))
.Where(x => collegeIds.Contains(x.CollegeId))
.ToList();
var existingNumbers = (await db.Teachers
.Select(x => x.TeacherNumber)
.ToListAsync(cancellationToken))
@@ -264,14 +279,23 @@ public sealed class DemoDataSeeder(
.SelectMany(x => x.Majors)
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var classes = await db.AdministrativeClasses
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
var targetMajorIds = (await db.Majors
.Select(x => new { x.Id, x.Code })
.ToListAsync(cancellationToken))
.Where(x => targetMajorCodes.Contains(x.Code))
.Select(x => x.Id)
.ToHashSet();
var classes = (await db.AdministrativeClasses
.Where(x => x.Grade == Grade)
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
.ToListAsync(cancellationToken))
.Where(x => targetMajorIds.Contains(x.MajorId))
.ToList();
var classIds = classes.Select(x => x.Id).ToHashSet();
var existingStudents = await db.Students
var existingStudents = (await db.Students
.ToListAsync(cancellationToken))
.Where(x => classIds.Contains(x.AdministrativeClassId))
.ToListAsync(cancellationToken);
.ToList();
var existingNumbers = (await db.Students
.Select(x => x.StudentNumber)
.ToListAsync(cancellationToken))
@@ -404,19 +428,23 @@ public sealed class DemoDataSeeder(
var collegeCodes = CollegeDefinitions
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var colleges = await db.Colleges
var colleges = (await db.Colleges
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken))
.Where(x => collegeCodes.Contains(x.Code))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
.ToList();
var collegeIds = colleges.Select(x => x.Id).ToHashSet();
var teachers = await db.Teachers
.Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active)
var teachers = (await db.Teachers
.Where(x => x.Status == TeacherStatus.Active)
.OrderBy(x => x.TeacherNumber)
.ToListAsync(cancellationToken);
var courses = await db.Courses
.ToListAsync(cancellationToken))
.Where(x => collegeIds.Contains(x.CollegeId))
.ToList();
var courses = (await db.Courses
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
.ToListAsync(cancellationToken))
.Where(x => collegeIds.Contains(x.CollegeId))
.ToList();
var publicCourses = courses
.Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective)
.ToList();
@@ -40,10 +40,28 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_22_course_adjustments";
private const string AttendanceAppealMigration =
"20260725_23_attendance_appeal";
private const string AttendanceCheckInMigration =
"20260726_26_attendance_check_in";
private const string ApprovalTablesMigration =
"20260725_24_approval_tables";
private const string AcademicWarningsMigration =
"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)
{
@@ -262,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>(
"""
@@ -275,6 +308,19 @@ public sealed class DevelopmentSqliteMigrator(
attendanceAppealExists ? [] : AttendanceAppealStatements,
cancellationToken);
var attendanceCheckInExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('AttendanceSheets')
WHERE name = 'CheckInMethod'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AttendanceCheckInMigration,
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
cancellationToken);
var approvalTablesExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
.AnyAsync(value => value > 0, cancellationToken);
@@ -314,6 +360,99 @@ public sealed class DevelopmentSqliteMigrator(
MakeupExamAutoJobsMigration,
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
cancellationToken);
var academicTermArchivingExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('AcademicTerms')
WHERE name = 'IsArchived'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
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(
@@ -1559,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 =
[
"""
@@ -1568,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,
@@ -1613,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;""",
@@ -1623,6 +1861,24 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");"""
];
private static readonly string[] AttendanceCheckInStatements =
[
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInMethod" INTEGER NOT NULL DEFAULT 1;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInToken" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInStartsAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInEndsAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLatitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLongitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "LocationRadiusMeters" INTEGER NULL;""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_AttendanceSheets_CheckInToken" ON "AttendanceSheets" ("CheckInToken");""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAt" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckedInMethod" INTEGER NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLatitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLongitude" TEXT NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAccuracyMeters" REAL NULL;""",
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
];
private static readonly string[] ApprovalTableStatements =
[
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
@@ -1655,6 +1911,13 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;"""
];
private static readonly string[] AcademicTermArchivingStatements =
[
"""ALTER TABLE "AcademicTerms" ADD COLUMN "IsArchived" INTEGER NOT NULL DEFAULT 0;""",
"""ALTER TABLE "AcademicTerms" ADD COLUMN "ArchivedAt" TEXT NULL;""",
"""CREATE INDEX IF NOT EXISTS "IX_AcademicTerms_IsArchived" ON "AcademicTerms" ("IsArchived");"""
];
private const string TeachingEvaluationMigration = "TeachingEvaluation";
private static readonly string[] TeachingEvaluationStatements =
@@ -1692,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,131 @@
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("20260726022634_AttendanceCheckIn")]
public partial class AttendanceCheckIn : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CheckInMethod",
table: "AttendanceSheets",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<string>(
name: "CheckInToken",
table: "AttendanceSheets",
type: "varchar(64)",
maxLength: 64,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<DateTime>(
name: "CheckInStartsAt",
table: "AttendanceSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CheckInEndsAt",
table: "AttendanceSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TargetLatitude",
table: "AttendanceSheets",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TargetLongitude",
table: "AttendanceSheets",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LocationRadiusMeters",
table: "AttendanceSheets",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CheckInAt",
table: "AttendanceRecords",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CheckedInMethod",
table: "AttendanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CheckInLatitude",
table: "AttendanceRecords",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CheckInLongitude",
table: "AttendanceRecords",
type: "decimal(10,7)",
precision: 10,
scale: 7,
nullable: true);
migrationBuilder.AddColumn<double>(
name: "CheckInAccuracyMeters",
table: "AttendanceRecords",
type: "double",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "CheckInDistanceMeters",
table: "AttendanceRecords",
type: "double",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AttendanceSheets_CheckInToken",
table: "AttendanceSheets",
column: "CheckInToken",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_AttendanceSheets_CheckInToken",
table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInMethod", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInToken", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInStartsAt", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInEndsAt", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "TargetLatitude", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "TargetLongitude", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "LocationRadiusMeters", table: "AttendanceSheets");
migrationBuilder.DropColumn(name: "CheckInAt", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckedInMethod", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInLatitude", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInLongitude", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInAccuracyMeters", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "CheckInDistanceMeters", table: "AttendanceRecords");
}
}
@@ -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,48 @@
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("20260726143000_AcademicTermArchiving")]
public partial class AcademicTermArchiving : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ArchivedAt",
table: "AcademicTerms",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsArchived",
table: "AcademicTerms",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "IX_AcademicTerms_IsArchived",
table: "AcademicTerms",
column: "IsArchived");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_AcademicTerms_IsArchived",
table: "AcademicTerms");
migrationBuilder.DropColumn(
name: "ArchivedAt",
table: "AcademicTerms");
migrationBuilder.DropColumn(
name: "IsArchived",
table: "AcademicTerms");
}
}
@@ -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");
}
}
}
@@ -29,6 +29,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime?>("ArchivedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
@@ -37,9 +40,12 @@ 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>("IsArchived")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsCurrent")
.HasColumnType("tinyint(1)");
@@ -57,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")
@@ -68,6 +74,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsArchived");
b.HasIndex("IsCurrent");
b.HasIndex("IsEnabled", "SortOrder");
@@ -154,6 +162,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime?>("AppealSubmittedAt")
.HasColumnType("datetime(6)");
b.Property<double?>("CheckInAccuracyMeters")
.HasColumnType("double");
b.Property<DateTime?>("CheckInAt")
.HasColumnType("datetime(6)");
b.Property<double?>("CheckInDistanceMeters")
.HasColumnType("double");
b.Property<decimal?>("CheckInLatitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("CheckInLongitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<int?>("CheckedInMethod")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
@@ -179,9 +207,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime>("AttendanceDate")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("CheckInEndsAt")
.HasColumnType("datetime(6)");
b.Property<int>("CheckInMethod")
.HasColumnType("int");
b.Property<DateTime?>("CheckInStartsAt")
.HasColumnType("datetime(6)");
b.Property<string>("CheckInToken")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("LocationRadiusMeters")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
@@ -197,6 +241,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime(6)");
b.Property<decimal?>("TargetLatitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("TargetLongitude")
.HasPrecision(10, 7)
.HasColumnType("decimal(10,7)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
@@ -205,6 +257,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasKey("Id");
b.HasIndex("CheckInToken")
.IsUnique();
b.HasIndex("TeachingTaskId", "AttendanceDate");
b.ToTable("AttendanceSheets");
@@ -410,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")
@@ -540,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")
@@ -573,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");
@@ -585,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")
@@ -605,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");
@@ -677,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)");
@@ -687,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");
});
@@ -794,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)");
@@ -826,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")
@@ -1347,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")
@@ -2057,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")
@@ -2118,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)
@@ -2139,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)
@@ -2154,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")
@@ -2331,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")
@@ -2345,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")
@@ -2371,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")
@@ -2499,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")
@@ -2924,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)");
@@ -3046,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")
@@ -3231,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")
@@ -3352,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")
@@ -3842,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")
@@ -4172,6 +4741,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.Navigation("EligibleGrades");
b.Navigation("Offerings");
});
@@ -4261,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;
}
}
@@ -0,0 +1,47 @@
using System.Linq.Expressions;
namespace Jiaowu.Api.Infrastructure.Persistence;
public static class QueryableCollectionExtensions
{
public static IQueryable<TEntity> WhereIn<TEntity, TValue>(
this IQueryable<TEntity> source,
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector)
{
var predicate = BuildPredicate(values, valueSelector, Expression.OrElse, false);
return source.Where(predicate);
}
public static IQueryable<TEntity> WhereNotIn<TEntity, TValue>(
this IQueryable<TEntity> source,
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector)
{
var predicate = BuildPredicate(values, valueSelector, Expression.AndAlso, true);
return source.Where(predicate);
}
private static Expression<Func<TEntity, bool>> BuildPredicate<TEntity, TValue>(
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector,
Func<Expression, Expression, BinaryExpression> combine,
bool negate)
{
Expression? body = null;
foreach (var value in values.Distinct())
{
Expression comparison = Expression.Equal(
valueSelector.Body,
Expression.Constant(value, typeof(TValue)));
if (negate)
comparison = Expression.Not(comparison);
body = body is null ? comparison : combine(body, comparison);
}
body ??= Expression.Constant(negate);
return Expression.Lambda<Func<TEntity, bool>>(
body,
valueSelector.Parameters);
}
}
@@ -0,0 +1,44 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace Jiaowu.Api.Infrastructure.Persistence;
public static class RetriableTransactionExtensions
{
public static Task<TResult> ExecuteInRetriableTransactionAsync<TResult>(
this DbContext db,
Func<IDbContextTransaction, Task<TResult>> operation,
CancellationToken cancellationToken,
IsolationLevel? isolationLevel = null)
{
var strategy = db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var transaction = isolationLevel.HasValue
? await db.Database.BeginTransactionAsync(
isolationLevel.Value,
cancellationToken)
: await db.Database.BeginTransactionAsync(cancellationToken);
return await operation(transaction);
});
}
public static Task ExecuteInRetriableTransactionAsync(
this DbContext db,
Func<IDbContextTransaction, Task> operation,
CancellationToken cancellationToken,
IsolationLevel? isolationLevel = null)
{
var strategy = db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var transaction = isolationLevel.HasValue
? await db.Database.BeginTransactionAsync(
isolationLevel.Value,
cancellationToken)
: await db.Database.BeginTransactionAsync(cancellationToken);
await operation(transaction);
});
}
}
@@ -38,8 +38,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
.ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
var taskIds = tasks.Select(task => task.Id).ToArray();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId))
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking()
@@ -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,
@@ -169,10 +76,7 @@ public sealed class AutomaticScheduleJobProcessor(
job.CompletedTasks = result.CompletedTasks;
job.MessagesJson = JsonSerializer.Serialize(result.Messages);
job.CompletedAt = DateTime.UtcNow;
await using var transaction =
await db.Database.BeginTransactionAsync(stoppingToken);
await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
logger.LogInformation(
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
@@ -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)
@@ -141,35 +48,47 @@ public sealed class SchedulePublishJobProcessor(
ReportProgress,
stoppingToken);
await using var transaction =
await db.Database.BeginTransactionAsync(stoppingToken);
if (plan.Status != SchedulePlanStatus.Draft)
throw new SchedulePublishValidationException(
"排课草稿状态已发生变化,请刷新后重试。");
var publishedPlanId = plan.Id;
await db.ExecuteInRetriableTransactionAsync(
async transaction =>
{
db.ChangeTracker.Clear();
var publishJob = await db.SchedulePublishJobs
.FirstAsync(x => x.Id == jobId, stoppingToken);
var publishPlan = await db.SchedulePlans
.FirstAsync(x => x.Id == publishedPlanId, stoppingToken);
if (publishPlan.Status != SchedulePlanStatus.Draft)
{
throw new SchedulePublishValidationException(
"排课草稿状态已发生变化,请刷新后重试。");
}
var previous = await db.SchedulePlans
.Where(x =>
x.Id != plan.Id &&
x.AcademicTermId == plan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.ToListAsync(stoppingToken);
foreach (var oldPlan in previous)
oldPlan.Status = SchedulePlanStatus.Archived;
var previous = await db.SchedulePlans
.Where(x =>
x.Id != publishPlan.Id &&
x.AcademicTermId == publishPlan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.ToListAsync(stoppingToken);
foreach (var oldPlan in previous)
oldPlan.Status = SchedulePlanStatus.Archived;
plan.Status = SchedulePlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
job.Status = SchedulePublishJobStatus.Succeeded;
job.ActiveAcademicTermId = null;
job.CompletedSteps = job.TotalSteps;
job.CurrentStep = "课表已发布";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
publishPlan.Status = SchedulePlanStatus.Published;
publishPlan.PublishedAt = DateTime.UtcNow;
publishJob.Status = SchedulePublishJobStatus.Succeeded;
publishJob.ActiveAcademicTermId = null;
publishJob.CompletedSteps = publishJob.TotalSteps;
publishJob.CurrentStep = "课表已发布";
publishJob.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
},
stoppingToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
logger.LogInformation(
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
job.Id,
plan.Id);
jobId,
publishedPlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -245,7 +164,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId))
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
@@ -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);
}
@@ -194,6 +198,7 @@ public sealed class TimetableDataService(AppDbContext db)
query = query.Where(x => x.Id == academicTermId);
else
query = query.OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate);
return await query.Select(x => new TimetableTermDto(
x.Id,
@@ -289,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);
}
@@ -356,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);
@@ -384,7 +391,8 @@ public sealed class TimetableDataService(AppDbContext db)
x.Notes,
true,
x.Name,
x.ExamDate);
x.ExamDate,
x.UpdatedAt);
}).ToList();
}
}
@@ -467,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,
@@ -482,4 +491,5 @@ public sealed record FlexibleCourseDto(
int WeeklyHours,
IEnumerable<string> TeacherNames,
IEnumerable<string> ClassNames,
string? Notes);
string? Notes,
DateTime UpdatedAt = default);

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