23 Commits
  • 智能开屏:显示“姓名+早上/中午/下午/晚上问候”,支持春节、端午、中秋、国庆等节日文案,可点击跳过:[SmartLaunchScreen.vue](E:/jiaowu/web/src/components/SmartLaunchScreen.vue)
    长按 App 图标:提供“我的课表、考试安排、课堂签到、消息中心”四个入口。签到会按学生/教师角色自动分流:[shortcuts.xml](E:/jiaowu/web/native/android/app/src/main/res/xml/shortcuts.xml)
    桌面小组件:新增“今日课表”和“近期考试”,展示最近同步的数据,点击可进入对应页面:[WidgetRenderer.java](E:/jiaowu/web/native/android/app/src/main/java/edu/mingxu/jiaowu/WidgetRenderer.java)
    安全处理:组件只缓存课程和考试摘要,不保存登录令牌;退出账号会清空组件,隔天未刷新时不会继续展示旧的“今日课表”。
    原生模板已纳入版本管理,每次 npm run cap:sync 会自动恢复到被忽略的 Android 工程:[configure-capacitor.mjs](E:/jiaowu/web/scripts/configure-capacitor.mjs)
  • 已改成“普通排课与实验学时分离”:
    普通课表学时 = 总学时 − 实践学时。
    自动排课、手工排课、课表发布都会阻止把实践学时重复排入。
    全部为实践学时的课程应设为“非排时课程”,再由实验管理安排。
    教学任务和排课页面会显示“常规 / 实践学时”。
    已有教学任务即使仍保存旧周学时,排课时也会按拆分后的课程学时计算。
    无需数据库迁移。
  • 已按“实验成绩独立管理”完成修改。
    每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。
    流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。
    自主预约实验支持同步有效预约名单,已评分记录不会被误删。
    课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。
    实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。
    一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。
    已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。
    界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
  • 考试按实际日期、周次和节次融入周/日/总视图,并用橙色“考试”卡片区分。
    即使固定课表尚未发布,只要考试已发布,也能正常显示。
    考试计划、考场、监考教师和日期都会显示。
    Excel 导出同步包含考试安排。
  • app
  • 变更摘要:考试安排注入课表
    问题根因
    
      TimetableDataService.LoadExamEntriesAsync 只查询旧的 ExamSession(legacy 直接分配教室),完全忽略自动编排生成的
      ExamRoomAssignment(混排考场),导致:
    
      ┌──────────┬──────────────────────────┬───────────────────────────────────┐
      │ 课表类型 │           之前           │               之后                │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 教室课表 │ return [] 硬编码空数组   │ 查询 legacy + mixed room 两类数据 │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 教师课表 │ 只显示监考               │ 监考 + 自己授课的考试             │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 学生课表 │ 只通过 CourseEnrollments │ 同时查 ExamSeats(座位分配)      │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 空闲教室 │ 只排除 legacy 考试占用   │ 同时排除混排考场占用              │
      └──────────┴──────────────────────────┴───────────────────────────────────┘
    
      修改的文件
    
      1. TimetableDataService.cs — 重写 LoadExamEntriesAsync
    
      - 拆分为两个数据源:legacy ExamSessions + mixed ExamRoomAssignment(通过 ExamRoomSession 关联)
      - 新增 ApplyLegacyResourceFilter / ApplyMixedResourceFilter
      两个过滤方法,按资源类型(Classroom/Teacher/Student/Class)分别过滤
      - 新增 MapToEntryDto 统一映射到 TimetableEntryDto
      - 新增 ExamSessionProjection record 统一两个数据源的投影
    
      各资源类型的数据来源:
    
      ┌───────────┬────────────────────────────────┬──────────────────────────────────────┐
      │ 资源类型  │          Legacy 过滤           │           Mixed-room 过滤            │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Classroom │ ExamSessions.ClassroomId == id │ ExamRoom.ClassroomId == id           │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Teacher   │ 监考 + 授课教师                │ 监考 + 授课教师                      │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Student   │ CourseEnrollments + 行政班     │ ExamSeats.StudentId                  │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Class     │ TeachingTask.Classes           │ SessionLinks → ExamSession → Classes │
      └───────────┴────────────────────────────────┴──────────────────────────────────────┘
    
      2. ClassroomReservationAvailabilityService.cs — 补充混排考场占用
    
      在 GetOccupiedClassroomIdsAsync 新增 ExamRoomAssignment 查询,空闲教室查询现在会正确排除混排考场占用的教室。
    
      3. TimetablesController.cs — 课表选项含考试学期
    
      - 学期级的 HasPublishedTimetable 增加 ExamPlans.Status == Published 判断
      - 班级级的 HasPublishedTimetable 增加 ExamSessions 关联判断
  • 参照现有的 ExamArrangementJob / SchedulePublishJob 后台任务模式,将发布改为通过 RabbitMQ
    队列异步执行,拆分查询消除笛卡尔积。
    
      修改的文件(共 13 个)
    
      ┌───────────────────────────────────────────────────────────────┬──────────────────────────────────────────────────┐
      │                             文件                              │                       变更                       │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Domain/Academic/ExamEntities.cs                               │ 新增 ExamPublishJob 实体 + ExamPublishJobStatus  │
      │                                                               │ / ExamPublishJobKind 枚举                        │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Domain/System/BackgroundJobOutboxMessage.cs                   │ BackgroundJobKind 新增 ExamPublish = 6           │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/BackgroundJobOptions.cs         │ 新增 ExamPublishConcurrency 配置项               │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/BackgroundJobRunner.cs          │ RunAsync 和 MarkJobRetryLimitExceeded 添加       │
      │                                                               │ ExamPublish 分支                                 │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs       │ JobKinds 数组和 RoutingKey 添加 ExamPublish →    │
      │                                                               │ "exam.publish"                                   │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs │ 启动恢复逻辑添加 ExamPublishJobs                 │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/Persistence/AppDbContext.cs                    │ 新增 ExamPublishJobs DbSet                       │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Controllers/OperationsController.cs                           │ CountFailedJobsAsync / GetFailedJobs             │
      │                                                               │ 添加考试发布失败统计和筛选                       │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Program.cs                                                    │ 校验 ExamPublishConcurrency + 注册               │
      │                                                               │ ExamPublishJobProcessor                          │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/Exams/ExamPublishJobs.cs                       │ 新文件 —                                         │
      │                                                               │ ExamPublishJobProcessor,拆分查询校验后发布      │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Controllers/ExamsController.cs                                │ Publish 改为创建后台任务 + 202 返回;新增 GET    │
      │                                                               │ publish-jobs/{id} / GET plans/{id}/publish-job   │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Controllers/MakeupExamsController.cs                          │ 同上改造                                         │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ tests/.../TeachingWorkflowRosterTests.cs                      │ 更新测试适配新的异步发布模式                     │
      └───────────────────────────────────────────────────────────────┴──────────────────────────────────────────────────┘
    
      笛卡尔积消除
    
      之前:一个 Include 链拉全部 → EF Core 生成 Sessions × Invigilators × RoomLinks × Seats 笛卡尔积
    
      之后:
      - 场次计数:db.ExamSessions.CountAsync(无 JOIN)
      - 场次摘要:Select new { Id, ClassroomId, InvigilatorCount, RoomLinkCount }(只查所需列)
      - 容量超限:db.ExamRooms.Select(r => new { SeatCount = r.Seats.Count, Capacity })(单表 JOIN)
      - 课程冲突:db.ExamRoomSessions.Where(link => ...CourseId != link.ExamRoom!.CourseId)(独立查询)
      - 每个查询只做自己需要的 JOIN,互不干扰
    
      测试结果
    
      213 通过,0 失败,0 跳过
    
      配置方式
    
      - BackgroundJobs__Transport=RabbitMq → 走 RabbitMQ 队列 jiaowu.background-jobs.exam.publish
      - BackgroundJobs__Transport=InMemory(默认) → 走内存 Channel
      - BackgroundJobs__ExamPublishConcurrency=1(默认,可调 1-16)
  • 1. 教室容量按 1/2 计算
    - ExamArrangementService.cs: SelectRooms() 使用 Capacity / 2 选择房间、计算剩余座位和总容量
      - MakeupExamArrangementService.cs: 数据库查询用 x.Capacity >= enrolledCount * 2(等价于 Capacity/2 >=
      enrolledCount),消息显示有效座位数
    
      2. 教学楼限制多选
    
      - Domain: ExamSession 和 MakeupExamSession 新增 RequiredBuildingIds (JSON string),保留旧 RequiredBuildingId 向后兼容
      - Service: RoomGroupKey 改为字符串键确保值相等;GroupKey() 合并新旧字段
      - Controller: 请求 DTO 增加 RequiredBuildingIds (Guid 数组),响应包含该字段
      - DB: MySQL 迁移 + SQLite migrator 添加新列
      - Frontend: <el-select> 改为 multiple,新增 parseBuildingIds() 解析服务器返回的 JSON
    
      3. 导出签名单后台任务
    
      - 新增: ExamSignInExportJob 实体、ExamSignInExportJobProcessor、ExamSignInExportJobStatus 枚举
      - BackgroundJobKind: 新增 ExamSignInExport = 5
      - RabbitMQ: routing key exam.sign-in-export,队列 jiaowu.background-jobs.exam.sign-in-export
      - API: POST /sign-in-export 创建任务返回 202;GET /sign-in-exports/{jobId} 查询状态;GET
      /sign-in-exports/{jobId}/download 下载文件
      - Frontend: 导出改为异步任务 + 轮询 + 自动下载,显示进度条
      - 恢复: OutboxPublisher 启动时恢复未完成的任务,重试超限自动标记失败
  • 自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
    任务参数、状态和结果持久化到 MySQL。
    接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
    服务重启后可恢复未完成任务。
    前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
    编排期间禁止修改、删除或发布对应计划。
    补考原有“一键生成”保留,并与编排任务互斥。
    运维后台增加“考试与补考编排”失败任务筛选。
  • 草稿计划支持选择场次后“批量移除已选场次”,一次最多 100 个。
    批量操作会完整校验:包含不存在、其他计划或已发布计划的场次时整批拒绝,不会部分删除。[ExamsController.cs (line 384)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:384)
    草稿计划新增“删除草稿”按钮,删除前显示计划名称及场次数量确认。[ExamsView.vue (line 292)](E:/jiaowu/web/src/views/ExamsView.vue:292)
    删除草稿计划会级联清理场次和监考关联;已发布计划不能删除。[ExamsController.cs (line 81)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:81)
    删除后会自动切换到其他计划;没有剩余计划时正确显示空状态。
    新增草稿、已发布、跨计划混选及级联删除测试。[ExamDeletionControllerTests.cs (line 17)](E:/jiaowu/tests/Jiaowu.Api.Tests/ExamDeletionControllerTests.cs:17)
  • 已修改完成,同时解决了“考生名单都是 0 人”的根因。
    考生范围统一为“行政班关联学生 + 已选课学生”,并自动去重;考场分配、人数显示、冲突检测、学生考试安排均使用同一口径。[TeachingTaskRosterQuery.cs (line 40)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs:40)
    新增考场签名单 Excel 导出接口。[ExamsController.cs (line 568)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:568)
    考试计划页面新增“导出考场签名单”按钮。[ExamsView.vue (line 255)](E:/jiaowu/web/src/views/ExamsView.vue:255)
    工作簿包含“考场汇总”以及每个考试场次独立的 A4 签到表,字段包括座位号、学号、姓名、行政班、考生签名和备注。[ExamSignInWorkbookExporter.cs (line 12)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs:12)
    未分配考场或监考教师时,也能导出并显示“待分配”。
142 changed files with 69341 additions and 667 deletions
+4
View File
@@ -17,6 +17,7 @@ BackgroundJobs__Transport=InMemory
BackgroundJobs__AutomaticScheduleConcurrency=1 BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1 BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1 BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
# RabbitMq__HostName=rabbitmq.example.edu.cn # RabbitMq__HostName=rabbitmq.example.edu.cn
# RabbitMq__Port=5671 # RabbitMq__Port=5671
# RabbitMq__UserName=jiaowu # RabbitMq__UserName=jiaowu
@@ -52,6 +53,9 @@ Jwt__ExpireMinutes=60
AllowedHosts=jiaowu.example.edu.cn AllowedHosts=jiaowu.example.edu.cn
Cors__Origins__0=https://jiaowu.example.edu.cn Cors__Origins__0=https://jiaowu.example.edu.cn
Cors__Origins__1=capacitor://localhost
Cors__Origins__2=https://localhost
Cors__Origins__3=http://localhost
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。 # 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
OfficialDocuments__InstitutionName=明序大学 OfficialDocuments__InstitutionName=明序大学
+63
View File
@@ -47,6 +47,68 @@ dotnet run --project src/Jiaowu.Api
访问 `http://localhost:5255``/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html` 访问 `http://localhost:5255``/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`
## Capacitor Android App
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
Android 工程前先构建并同步原生插件:
```powershell
Set-Location web
npm ci
npm run build:capacitor
npm run cap:sync
npm run cap:open:android
```
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
最低版本为 API 26;相机和精确位置权限均按需申请。
### App 前端热更新
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
前端修复不再需要重新打包。
生成更新 ZIP
```powershell
Set-Location web
npm ci
npm run ota:package -- --version 1.0.1
```
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName`
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
`--migrate-only`
### Android 开屏、快捷入口与桌面组件
Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
遵守系统“减少动画”设置。
长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
重新构建 App,不能通过前端 OTA 单独下发。
## MySQL 8.4 生产部署 ## MySQL 8.4 生产部署
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立: 非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
@@ -280,6 +342,7 @@ BackgroundJobs__Transport=RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency=1 BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1 BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1 BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
RabbitMq__HostName=rabbitmq.example.edu.cn RabbitMq__HostName=rabbitmq.example.edu.cn
RabbitMq__Port=5671 RabbitMq__Port=5671
RabbitMq__UserName=jiaowu RabbitMq__UserName=jiaowu
+1
View File
@@ -20,6 +20,7 @@ x-jiaowu-environment: &jiaowu-environment
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}" BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}" BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}" BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
RabbitMq__HostName: rabbitmq RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672" RabbitMq__Port: "5672"
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}" RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
@@ -0,0 +1,440 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/app-updates")]
public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBase
{
private const long MaximumBundleBytes = 30 * 1024 * 1024;
private const long MaximumExpandedBytes = 150 * 1024 * 1024;
private const int MaximumZipEntries = 10_000;
[AllowAnonymous]
[EnableRateLimiting("app-updates")]
[HttpGet("latest")]
public async Task<ActionResult<AppUpdateCheckResponse>> GetLatest(
[FromQuery, Required] string platform,
[FromQuery, Required] string nativeVersion,
[FromQuery] string channel = "production",
[FromQuery] string? currentVersion = null,
CancellationToken cancellationToken = default)
{
if (!TryParsePlatform(platform, out var parsedPlatform))
return ValidationProblem("平台必须为 android 或 ios。");
if (!TryParseChannel(channel, out var parsedChannel))
return ValidationProblem("更新通道必须为 production 或 staging。");
var normalizedNativeVersion = nativeVersion.Trim();
if (!NativeVersionRegex().IsMatch(normalizedNativeVersion))
return ValidationProblem("原生版本格式无效。");
var release = await db.AppUpdateReleases.AsNoTracking()
.Where(x =>
x.Platform == parsedPlatform &&
x.Channel == parsedChannel &&
x.NativeVersion == normalizedNativeVersion &&
x.Status == AppUpdateReleaseStatus.Published)
.OrderByDescending(x => x.PublishedAt)
.FirstOrDefaultAsync(cancellationToken);
if (release is null ||
string.Equals(
release.Version,
currentVersion?.Trim(),
StringComparison.OrdinalIgnoreCase))
{
return Ok(new AppUpdateCheckResponse(
false,
release?.Version,
normalizedNativeVersion,
parsedPlatform,
parsedChannel,
null,
release?.ReleaseNotes,
release?.FileSize,
release?.Sha256,
release?.PublishedAt));
}
return Ok(new AppUpdateCheckResponse(
true,
release.Version,
release.NativeVersion,
release.Platform,
release.Channel,
$"app-updates/releases/{release.Id}/bundle",
release.ReleaseNotes,
release.FileSize,
release.Sha256,
release.PublishedAt));
}
[AllowAnonymous]
[EnableRateLimiting("app-updates")]
[HttpGet("releases/{id:guid}/bundle")]
public async Task<IActionResult> DownloadBundle(
Guid id,
CancellationToken cancellationToken)
{
var release = await db.AppUpdateReleases.AsNoTracking()
.Where(x =>
x.Id == id &&
x.Status != AppUpdateReleaseStatus.Draft)
.Select(x => new
{
x.BundleContent,
x.FileName,
x.Sha256
})
.FirstOrDefaultAsync(cancellationToken);
if (release is null) return NotFound();
Response.Headers.ETag = $"\"sha256-{release.Sha256}\"";
Response.Headers.CacheControl = "public,max-age=31536000,immutable";
return File(
release.BundleContent,
"application/zip",
release.FileName,
enableRangeProcessing: true);
}
[Authorize(Roles = SystemRoles.SuperAdmin)]
[HttpGet("releases")]
public async Task<ActionResult<PagedResult<AppUpdateReleaseItem>>> GetReleases(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? platform = null,
[FromQuery] string? channel = null,
CancellationToken cancellationToken = default)
{
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。");
var query = db.AppUpdateReleases.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(platform))
{
if (!TryParsePlatform(platform, out var parsedPlatform))
return ValidationProblem("平台必须为 android 或 ios。");
query = query.Where(x => x.Platform == parsedPlatform);
}
if (!string.IsNullOrWhiteSpace(channel))
{
if (!TryParseChannel(channel, out var parsedChannel))
return ValidationProblem("更新通道必须为 production 或 staging。");
query = query.Where(x => x.Channel == parsedChannel);
}
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new AppUpdateReleaseItem(
x.Id,
x.Platform,
x.Channel,
x.Version,
x.NativeVersion,
x.Status,
x.ReleaseNotes,
x.FileName,
x.FileSize,
x.Sha256,
x.CreatedByUserName,
x.CreatedAt,
x.PublishedByUserName,
x.PublishedAt))
.ToArrayAsync(cancellationToken);
return Ok(new PagedResult<AppUpdateReleaseItem>(
rows,
total,
page,
pageSize));
}
[Authorize(Roles = SystemRoles.SuperAdmin)]
[Consumes("multipart/form-data")]
[RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)]
[HttpPost("releases")]
public async Task<ActionResult<AppUpdateReleaseItem>> UploadRelease(
[FromForm] AppUpdateUploadRequest request,
CancellationToken cancellationToken)
{
if (!TryParsePlatform(request.Platform, out var platform))
return ValidationProblem("平台必须为 android 或 ios。");
if (!TryParseChannel(request.Channel, out var channel))
return ValidationProblem("更新通道必须为 production 或 staging。");
var version = request.Version.Trim();
var nativeVersion = request.NativeVersion.Trim();
if (!ReleaseVersionRegex().IsMatch(version))
return ValidationProblem(
"热更新版本必须使用语义版本,例如 1.0.1 或 1.0.1-beta.1。");
if (!NativeVersionRegex().IsMatch(nativeVersion))
return ValidationProblem("原生版本格式无效,例如 1.0 或 1.0.0。");
if (request.ReleaseNotes?.Trim().Length > 1000)
return ValidationProblem("更新说明不能超过 1000 字。");
if (request.Bundle.Length is <= 0 or > MaximumBundleBytes)
return ValidationProblem("更新包必须大于 0 字节且不超过 30 MB。");
var exists = await db.AppUpdateReleases.AsNoTracking()
.AnyAsync(
x =>
x.Platform == platform &&
x.Channel == channel &&
x.NativeVersion == nativeVersion &&
x.Version == version,
cancellationToken);
if (exists)
return Conflict(new ProblemDetails
{
Title = "更新版本已存在",
Detail = "同一平台、通道和原生版本下不能重复上传相同版本。",
Status = StatusCodes.Status409Conflict
});
await using var source = request.Bundle.OpenReadStream();
await using var buffer = new MemoryStream(
checked((int)request.Bundle.Length));
await source.CopyToAsync(buffer, cancellationToken);
var content = buffer.ToArray();
var archiveError = ValidateArchive(content);
if (archiveError is not null) return ValidationProblem(archiveError);
var fileName = Path.GetFileName(request.Bundle.FileName.Trim());
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length > 180)
fileName = $"jiaowu-web-{version}.zip";
var release = new AppUpdateRelease
{
Platform = platform,
Channel = channel,
Version = version,
NativeVersion = nativeVersion,
ReleaseNotes = NullIfWhiteSpace(request.ReleaseNotes),
FileName = fileName,
FileSize = content.LongLength,
Sha256 = Convert.ToHexString(
SHA256.HashData(content))
.ToLowerInvariant(),
BundleContent = content,
CreatedByUserName = User.Identity?.Name ?? "unknown"
};
db.AppUpdateReleases.Add(release);
await db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(
nameof(GetReleases),
new { id = release.Id },
ToItem(release));
}
[Authorize(Roles = SystemRoles.SuperAdmin)]
[HttpPost("releases/{id:guid}/publish")]
public async Task<ActionResult<AppUpdateReleaseItem>> PublishRelease(
Guid id,
CancellationToken cancellationToken)
{
var strategy = db.Database.CreateExecutionStrategy();
var published = await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var target = await db.AppUpdateReleases
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (target is null) return null;
var currentlyPublished = await db.AppUpdateReleases
.Where(x =>
x.Id != target.Id &&
x.Platform == target.Platform &&
x.Channel == target.Channel &&
x.NativeVersion == target.NativeVersion &&
x.Status == AppUpdateReleaseStatus.Published)
.ToListAsync(cancellationToken);
foreach (var release in currentlyPublished)
release.Status = AppUpdateReleaseStatus.Archived;
target.Status = AppUpdateReleaseStatus.Published;
target.PublishedAt = DateTime.UtcNow;
target.PublishedByUserName = User.Identity?.Name ?? "unknown";
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return ToItem(target);
});
return published is null ? NotFound() : Ok(published);
}
[Authorize(Roles = SystemRoles.SuperAdmin)]
[HttpDelete("releases/{id:guid}")]
public async Task<IActionResult> DeleteRelease(
Guid id,
CancellationToken cancellationToken)
{
var release = await db.AppUpdateReleases
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (release is null) return NotFound();
if (release.Status == AppUpdateReleaseStatus.Published)
return Conflict(new ProblemDetails
{
Title = "正式版本不能删除",
Detail = "请先发布另一个兼容版本,再删除已归档的更新包。",
Status = StatusCodes.Status409Conflict
});
db.AppUpdateReleases.Remove(release);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private static string? ValidateArchive(byte[] content)
{
try
{
using var stream = new MemoryStream(content, writable: false);
using var archive = new ZipArchive(
stream,
ZipArchiveMode.Read,
leaveOpen: false);
if (archive.Entries.Count is 0 or > MaximumZipEntries)
return $"更新包文件数量必须在 1 到 {MaximumZipEntries} 之间。";
long expandedBytes = 0;
var hasRootIndex = false;
foreach (var entry in archive.Entries)
{
var normalized = entry.FullName.Replace('\\', '/');
if (normalized.StartsWith('/') ||
normalized.Split('/').Any(part => part == ".."))
{
return "更新包包含不安全的文件路径。";
}
if (string.Equals(
normalized,
"index.html",
StringComparison.OrdinalIgnoreCase))
{
hasRootIndex = true;
}
expandedBytes = checked(expandedBytes + entry.Length);
if (expandedBytes > MaximumExpandedBytes)
return "更新包解压后的总大小不能超过 150 MB。";
}
return hasRootIndex
? null
: "更新包根目录必须包含 index.html;请直接压缩 dist 目录中的内容。";
}
catch (Exception exception) when (
exception is InvalidDataException or IOException or OverflowException)
{
return "更新包不是有效的 ZIP 文件,或文件结构已损坏。";
}
}
private static bool TryParsePlatform(
string value,
out AppUpdatePlatform platform) =>
Enum.TryParse(value.Trim(), ignoreCase: true, out platform) &&
Enum.IsDefined(platform);
private static bool TryParseChannel(
string value,
out AppUpdateChannel channel) =>
Enum.TryParse(value.Trim(), ignoreCase: true, out channel) &&
Enum.IsDefined(channel);
private static string? NullIfWhiteSpace(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static AppUpdateReleaseItem ToItem(AppUpdateRelease release) =>
new(
release.Id,
release.Platform,
release.Channel,
release.Version,
release.NativeVersion,
release.Status,
release.ReleaseNotes,
release.FileName,
release.FileSize,
release.Sha256,
release.CreatedByUserName,
release.CreatedAt,
release.PublishedByUserName,
release.PublishedAt);
[GeneratedRegex(
@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
RegexOptions.CultureInvariant)]
private static partial Regex ReleaseVersionRegex();
[GeneratedRegex(
@"^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
RegexOptions.CultureInvariant)]
private static partial Regex NativeVersionRegex();
}
public sealed class AppUpdateUploadRequest
{
[Required]
public required IFormFile Bundle { get; init; }
[Required, MaxLength(20)]
public required string Platform { get; init; }
[Required, MaxLength(20)]
public required string Channel { get; init; }
[Required, MaxLength(40)]
public required string Version { get; init; }
[Required, MaxLength(40)]
public required string NativeVersion { get; init; }
[MaxLength(1000)]
public string? ReleaseNotes { get; init; }
}
public sealed record AppUpdateCheckResponse(
bool Available,
string? Version,
string NativeVersion,
AppUpdatePlatform Platform,
AppUpdateChannel Channel,
string? DownloadUrl,
string? ReleaseNotes,
long? FileSize,
string? Sha256,
DateTime? PublishedAt);
public sealed record AppUpdateReleaseItem(
Guid Id,
AppUpdatePlatform Platform,
AppUpdateChannel Channel,
string Version,
string NativeVersion,
AppUpdateReleaseStatus Status,
string? ReleaseNotes,
string FileName,
long FileSize,
string Sha256,
string CreatedByUserName,
DateTime CreatedAt,
string? PublishedByUserName,
DateTime? PublishedAt);
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text;
using ClosedXML.Excel; using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
{ {
sheet.Id, sheet.Id,
sheet.CheckInMethod, sheet.CheckInMethod,
sheet.CheckInToken,
sheet.CheckInStartsAt, sheet.CheckInStartsAt,
sheet.CheckInEndsAt sheet.CheckInEndsAt
}); });
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
x.AttendanceDate, x.AttendanceDate,
x.Status, x.Status,
x.CheckInMethod, x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt, x.CheckInStartsAt,
x.CheckInEndsAt, x.CheckInEndsAt,
x.TargetLatitude, x.TargetLatitude,
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
cancellationToken); cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage; var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x => x.AttendanceSheetId == id)
.Select(x => new
{
x.StudentId,
x.CreatedAt,
x.IsSuccessful,
x.FailureCode,
x.DeviceIdentifierHash,
x.DevicePlatform,
x.IpAddress,
x.RiskFlags
})
.ToListAsync(cancellationToken);
var attemptsByStudent = attempts
.GroupBy(x => x.StudentId)
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
var deviceHashes = attempts
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
.Select(x => x.DeviceIdentifierHash!)
.Distinct(StringComparer.Ordinal)
.ToList();
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
if (deviceHashes.Count > 0)
{
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x =>
x.IsSuccessful &&
x.CreatedAt >= now.AddHours(-24) &&
x.DeviceIdentifierHash != null)
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
.GroupBy(x => x.DeviceIdentifierHash!)
.Select(x => new
{
DeviceIdentifierHash = x.Key,
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
})
.ToListAsync(cancellationToken);
deviceReuseCounts = reuseRows.ToDictionary(
x => x.DeviceIdentifierHash,
x => x.StudentCount,
StringComparer.Ordinal);
}
var responseRecords = sheet.Records.Select(r =>
{
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
var riskFlags = studentAttempts
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
.ToHashSet(StringComparer.Ordinal);
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
riskFlags.Add("RepeatedFailures");
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
riskFlags.Add("HighFrequency");
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
? deviceReuseCounts.GetValueOrDefault(deviceHash)
: 0;
if (sharedDeviceStudentCount > 1)
riskFlags.Add("SharedDevice");
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
? attempts
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
.Select(x => x.StudentId)
.Distinct()
.Count()
: 0;
return new
{
r.StudentId,
r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId),
CheckInAudit = latestSuccess is null
? null
: new
{
latestSuccess.IpAddress,
latestSuccess.DevicePlatform,
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
SharedDeviceStudentCount = sharedDeviceStudentCount,
SameIpStudentCount = sameIpStudentCount
},
AttemptCount = studentAttempts.Count,
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
};
}).ToList();
return Ok(new return Ok(new
{ {
Sheet = new Sheet = new
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
sheet.AttendanceDate, sheet.AttendanceDate,
sheet.Status, sheet.Status,
sheet.CheckInMethod, sheet.CheckInMethod,
CheckInToken = canManage ? sheet.CheckInToken : null,
sheet.CheckInStartsAt, sheet.CheckInStartsAt,
sheet.CheckInEndsAt, sheet.CheckInEndsAt,
sheet.TargetLatitude, sheet.TargetLatitude,
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
sheet.TaskName, sheet.TaskName,
sheet.CourseCode, sheet.CourseCode,
sheet.CourseName, sheet.CourseName,
Records = sheet.Records.Select(r => new Records = responseRecords,
RiskSummary = new
{ {
r.StudentId, RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
r.StudentNumber, SharedDeviceStudentCount = responseRecords.Count(
r.Name, x => x.RiskFlags.Contains("SharedDevice")),
r.ClassName, FrequentAttemptStudentCount = responseRecords.Count(
r.Status, x => x.RiskFlags.Contains("HighFrequency") ||
r.Notes, x.RiskFlags.Contains("RepeatedFailures"))
r.CheckInAt, }
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
}, },
CanEdit = canEdit CanEdit = canEdit
}); });
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
return NoContent(); return NoContent();
} }
[HttpGet("sheets/{id:guid}/qr-challenge")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetQrChallenge(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
string.IsNullOrWhiteSpace(sheet.CheckInToken))
return ConflictProblem("该考勤表不是扫码签到。");
var now = DateTime.UtcNow;
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
var challenge = AttendanceCheckInChallenge.Create(
sheet.Id,
sheet.CheckInToken,
now);
return Ok(challenge);
}
// ═══════════════ Student endpoints ═══════════════ // ═══════════════ Student endpoints ═══════════════
[HttpGet("check-in-info")] [HttpGet("check-in-info")]
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
return ConflictProblem("当前账号未关联学生档案。"); return ConflictProblem("当前账号未关联学生档案。");
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
return NotFound(); return NotFound();
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
return NotFound();
var activity = await db.AttendanceRecords.AsNoTracking() var activity = await db.AttendanceRecords.AsNoTracking()
.Where(x => .Where(x =>
x.StudentId == studentId.Value && x.StudentId == studentId.Value &&
x.AttendanceSheet!.CheckInToken == token.Trim()) x.AttendanceSheetId == sheetId)
.Select(x => new .Select(x => new
{ {
SheetId = x.AttendanceSheetId, SheetId = x.AttendanceSheetId,
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
x.AttendanceSheet.CheckInMethod, x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt, x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt, x.AttendanceSheet.CheckInEndsAt,
x.AttendanceSheet.CheckInToken,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code, CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name, CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber, TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
if (activity is null) return NotFound(); if (activity is null) return NotFound();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
!AttendanceCheckInChallenge.IsValid(
token,
activity.SheetId,
activity.CheckInToken,
now))
return NotFound();
return Ok(new return Ok(new
{ {
activity.SheetId, activity.SheetId,
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
.Where(x => x.StudentId == studentId.Value); .Where(x => x.StudentId == studentId.Value);
if (!string.IsNullOrWhiteSpace(request.Token)) if (!string.IsNullOrWhiteSpace(request.Token))
{ {
var token = request.Token.Trim(); if (!AttendanceCheckInChallenge.TryReadSheetId(
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token); request.Token.Trim(),
out var tokenSheetId))
return NotFound();
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
} }
else if (request.AttendanceSheetId.HasValue) else if (request.AttendanceSheetId.HasValue)
{ {
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
if (record?.AttendanceSheet is null) return NotFound(); if (record?.AttendanceSheet is null) return NotFound();
var sheet = record.AttendanceSheet; var sheet = record.AttendanceSheet;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
async Task<ActionResult> RejectAttemptAsync(
string failureCode,
string detail,
double? distanceMeters = null)
{
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
false,
failureCode,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return ConflictProblem(detail);
}
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
!AttendanceCheckInChallenge.IsValid(
request.Token,
sheet.Id,
sheet.CheckInToken,
now))
return await RejectAttemptAsync(
"InvalidQrChallenge",
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return await RejectAttemptAsync(
"CheckInClosed",
"签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return await RejectAttemptAsync(
"ManualSheet",
"该考勤表不支持学生在线签到。");
if (record.CheckInAt.HasValue) if (record.CheckInAt.HasValue)
{ {
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
record.CheckInDistanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return Ok(new return Ok(new
{ {
AlreadyCheckedIn = true, AlreadyCheckedIn = true,
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
record.CheckInDistanceMeters 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; double? distanceMeters = null;
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode) if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
{
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 || if (request.Latitude is < -90 or > 90 ||
request.Longitude is < -180 or > 180 || request.Longitude is < -180 or > 180 ||
request.Latitude is null || request.Latitude is null ||
request.Longitude is null) request.Longitude is null)
return ConflictProblem("未获取到有效的当前位置。"); return await RejectAttemptAsync(
"InvalidLocation",
"未获取到有效的当前位置。");
if (sheet.TargetLatitude is null || if (sheet.TargetLatitude is null ||
sheet.TargetLongitude is null || sheet.TargetLongitude is null ||
sheet.LocationRadiusMeters is null) sheet.LocationRadiusMeters is null)
return ConflictProblem("签到活动没有配置有效的位置范围。"); return await RejectAttemptAsync(
"LocationNotConfigured",
"签到活动没有配置有效的位置范围。");
var maximumAllowedAccuracyMeters = Math.Min(
100d,
sheet.LocationRadiusMeters.Value);
if (request.AccuracyMeters is null ||
!double.IsFinite(request.AccuracyMeters.Value) ||
request.AccuracyMeters <= 0 ||
request.AccuracyMeters > maximumAllowedAccuracyMeters)
{
return await RejectAttemptAsync(
"InsufficientAccuracy",
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
}
distanceMeters = CalculateDistanceMeters( distanceMeters = CalculateDistanceMeters(
(double)sheet.TargetLatitude.Value, (double)sheet.TargetLatitude.Value,
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
(double)request.Longitude.Value); (double)request.Longitude.Value);
if (distanceMeters > sheet.LocationRadiusMeters.Value) if (distanceMeters > sheet.LocationRadiusMeters.Value)
{ {
return ConflictProblem( return await RejectAttemptAsync(
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。"); "OutsideGeofence",
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
distanceMeters);
} }
} }
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
? request.AccuracyMeters ? request.AccuracyMeters
: null; : null;
record.CheckInDistanceMeters = distanceMeters; record.CheckInDistanceMeters = distanceMeters;
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Ok(new return Ok(new
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
} }
private async Task AddCheckInAttemptAsync(
AttendanceSheet sheet,
Guid studentId,
AttendanceCheckInRequest request,
bool isSuccessful,
string? failureCode,
double? distanceMeters,
DateTime now,
CancellationToken cancellationToken)
{
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
if (deviceIdentifierHash is null)
{
riskFlags.Add("MissingDeviceId");
}
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
x =>
x.IsSuccessful &&
x.StudentId != studentId &&
x.DeviceIdentifierHash == deviceIdentifierHash &&
x.CreatedAt >= now.AddHours(-24),
cancellationToken))
{
riskFlags.Add("SharedDevice");
}
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
x.CreatedAt >= now.AddMinutes(-2),
cancellationToken);
if (recentAttemptCount >= 5)
riskFlags.Add("HighFrequency");
if (!isSuccessful)
{
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
!x.IsSuccessful &&
x.CreatedAt >= now.AddMinutes(-5),
cancellationToken);
if (recentFailureCount >= 2)
riskFlags.Add("RepeatedFailures");
}
var context = ControllerContext.HttpContext;
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
{
AttendanceSheetId = sheet.Id,
StudentId = studentId,
CheckInMethod = sheet.CheckInMethod,
IsSuccessful = isSuccessful,
FailureCode = failureCode,
DeviceIdentifierHash = deviceIdentifierHash,
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
IpAddress = Limit(
context?.Connection.RemoteIpAddress?.ToString(),
64),
UserAgent = Limit(
context?.Request.Headers.UserAgent.ToString(),
500),
RiskFlags = riskFlags.Count == 0
? null
: string.Join(',', riskFlags.OrderBy(x => x)),
Latitude = request.Latitude,
Longitude = request.Longitude,
AccuracyMeters = request.AccuracyMeters,
DistanceMeters = distanceMeters,
CreatedAt = now,
UpdatedAt = now
});
}
private static string? HashDeviceIdentifier(string? deviceId)
{
var normalized = Normalize(deviceId)?.ToLowerInvariant();
return normalized is null
? null
: Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
}
private static IEnumerable<string> ParseRiskFlags(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(
',',
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries);
private static string? Limit(string? value, int maximumLength) =>
value is null || value.Length <= maximumLength
? value
: value[..maximumLength];
private static bool IsCheckInOpen( private static bool IsCheckInOpen(
AttendanceSheetStatus status, AttendanceSheetStatus status,
AttendanceCheckInMethod method, AttendanceCheckInMethod method,
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
[MaxLength(64)] string? Token, [MaxLength(64)] string? Token,
[Range(-90, 90)] decimal? Latitude, [Range(-90, 90)] decimal? Latitude,
[Range(-180, 180)] decimal? Longitude, [Range(-180, 180)] decimal? Longitude,
[Range(0, 5000)] double? AccuracyMeters); [Range(0, 5000)] double? AccuracyMeters,
[MaxLength(128)] string? DeviceId = null,
[MaxLength(32)] string? DevicePlatform = null);
public sealed record AttendanceCourseStatistics( public sealed record AttendanceCourseStatistics(
AttendanceStatisticsCourse Course, AttendanceStatisticsCourse Course,
@@ -145,6 +145,8 @@ public sealed class CoursesController(
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits, x.Credits,
x.TotalHours, x.TotalHours,
x.LectureHours,
x.PracticeHours,
x.Nature, x.Nature,
x.AssessmentMethod x.AssessmentMethod
}) })
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,945 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/experiment-grades")]
public sealed class ExperimentGradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
private const string Reviewers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string Publishers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet("management")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetManagement(
Guid? academicTermId,
ExperimentGradeSheetStatus? status,
CancellationToken cancellationToken)
{
var source = ScopedProjects().AsNoTracking()
.Where(x =>
x.Status == ExperimentProjectStatus.Published ||
x.Status == ExperimentProjectStatus.Closed);
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x =>
x.GradeSheet != null &&
x.GradeSheet.Status == status.Value);
return Ok(await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.ArrangementMode,
ProjectStatus = x.Status,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
AcademicTermId = x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
Sheet = x.GradeSheet == null
? null
: new
{
x.GradeSheet.Id,
x.GradeSheet.Status,
x.GradeSheet.ContributionWeight,
x.GradeSheet.PassScore,
ItemCount = x.GradeSheet.Items.Count,
StudentCount = x.GradeSheet.Records.Count,
CompletedCount = x.GradeSheet.Records.Count(record =>
record.ParticipationStatus !=
ExperimentParticipationStatus.Pending),
ScoredCount = x.GradeSheet.Records.Count(record =>
record.TotalScore != null),
PassedCount = x.GradeSheet.Records.Count(record =>
record.IsPassed == true),
x.GradeSheet.SubmittedAt,
x.GradeSheet.ReviewedAt,
x.GradeSheet.PublishedAt
}
})
.ToListAsync(cancellationToken));
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.ExperimentGradeSheet!.Status ==
ExperimentGradeSheetStatus.Published)
.OrderByDescending(x =>
x.ExperimentGradeSheet!.PublishedAt)
.ThenBy(x =>
x.ExperimentGradeSheet!.ExperimentProject!.Code)
.Select(x => new
{
x.Id,
x.ExperimentGradeSheetId,
ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
ProjectCode =
x.ExperimentGradeSheet.ExperimentProject!.Code,
ProjectName =
x.ExperimentGradeSheet.ExperimentProject.Name,
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.PassScore,
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
Items = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.ExperimentGradeItem.Kind,
score.ExperimentGradeItem.Weight,
score.Score,
score.Comment
}),
x.ExperimentGradeSheet.PublishedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("sheets")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSheet(
ExperimentGradeSheetRequest request,
CancellationToken cancellationToken)
{
var items = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
items))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x =>
x.Id == request.ExperimentProjectId,
cancellationToken);
if (project is null) return NotFound();
if (project.Status is not (
ExperimentProjectStatus.Published or
ExperimentProjectStatus.Closed))
return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
if (!CanInitialize(project.TeachingTask!)) return Forbid();
if (await db.ExperimentGradeSheets.AnyAsync(x =>
x.ExperimentProjectId == project.Id,
cancellationToken))
return ConflictProblem("该实验项目已经建立成绩单。");
var participants = await LoadParticipantsAsync(
project,
cancellationToken);
if (participants.Count == 0)
return ConflictProblem("实验项目当前没有可评分学生。");
var sheet = new ExperimentGradeSheet
{
ExperimentProjectId = project.Id,
ContributionWeight = request.ContributionWeight,
PassScore = request.PassScore,
Items = items,
Records = participants.Select(participant =>
new ExperimentGradeRecord
{
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId,
ItemScores = items.Select(item =>
new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
}).ToList()
}).ToList()
};
db.ExperimentGradeSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
}
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetSheet(
Guid id,
int recordPage = 1,
int recordPageSize = 50,
string? studentKeyword = null,
CancellationToken cancellationToken = default)
{
recordPage = Math.Max(1, recordPage);
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
? null
: studentKeyword.Trim();
var sheet = await ScopedSheets().AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.ExperimentProjectId,
ProjectCode = x.ExperimentProject!.Code,
ProjectName = x.ExperimentProject.Name,
x.ExperimentProject.ArrangementMode,
x.ExperimentProject.TeachingTaskId,
x.ExperimentProject.TeachingTask!.TaskNumber,
TaskName = x.ExperimentProject.TeachingTask.Name,
AcademicTermId =
x.ExperimentProject.TeachingTask.AcademicTermId,
TermName = x.ExperimentProject.TeachingTask
.AcademicTerm!.Name,
CourseCollegeId = x.ExperimentProject.TeachingTask
.Course!.CollegeId,
CourseCollegeName = x.ExperimentProject.TeachingTask
.Course.College!.Name,
CourseCode =
x.ExperimentProject.TeachingTask.Course.Code,
CourseName =
x.ExperimentProject.TeachingTask.Course.Name,
TeacherNames = x.ExperimentProject.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.ContributionWeight,
x.PassScore,
Items = x.Items.OrderBy(item => item.SortOrder)
.Select(item => new
{
item.Id,
item.Name,
item.Kind,
item.Weight
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.PublishedAt,
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
.Where(x => x.ExperimentGradeSheetId == id);
if (studentKeyword is not null)
recordsSource = recordsSource.Where(x =>
x.Student!.StudentNumber.Contains(studentKeyword) ||
x.Student.Name.Contains(studentKeyword) ||
x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
var recordTotal = await recordsSource.CountAsync(cancellationToken);
var records = await recordsSource
.OrderBy(x => x.Student!.StudentNumber)
.Skip((recordPage - 1) * recordPageSize)
.Take(recordPageSize)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
x.ExperimentSessionId,
Session = x.ExperimentSession == null
? null
: new
{
x.ExperimentSession.SessionDate,
x.ExperimentSession.StartPeriod,
x.ExperimentSession.PeriodCount,
ClassroomName =
x.ExperimentSession.Classroom!.Name
},
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
ItemScores = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.Score,
score.Comment
}),
x.UpdatedAt
})
.ToListAsync(cancellationToken);
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.SingleAsync(
x => x.Id == sheet.TeachingTaskId,
cancellationToken);
var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
sheet.Status ==
ExperimentGradeSheetStatus.Submitted;
return Ok(new
{
Sheet = new
{
sheet.Id,
sheet.ExperimentProjectId,
sheet.ProjectCode,
sheet.ProjectName,
sheet.ArrangementMode,
sheet.TeachingTaskId,
sheet.TaskNumber,
sheet.TaskName,
sheet.AcademicTermId,
sheet.TermName,
sheet.CourseCollegeName,
sheet.CourseCode,
sheet.CourseName,
sheet.TeacherNames,
sheet.ContributionWeight,
sheet.PassScore,
sheet.Items,
sheet.Status,
sheet.ReviewComment,
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
RecordTotal = recordTotal,
RecordPage = recordPage,
RecordPageSize = recordPageSize,
Records = records,
sheet.CreatedAt,
sheet.UpdatedAt
},
CanEdit = CanEdit(task) &&
sheet.Status is
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned,
CanReview = canReview,
CanPublish = IsPublisher() &&
sheet.Status == ExperimentGradeSheetStatus.Approved
});
}
[HttpPut("sheets/{id:guid}/settings")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateSettings(
Guid id,
ExperimentGradeSettingsRequest request,
CancellationToken cancellationToken)
{
var replacementItems = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
replacementItems))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("当前状态不能修改实验评分方案。");
sheet.ContributionWeight = request.ContributionWeight;
sheet.PassScore = request.PassScore;
db.ExperimentGradeItems.RemoveRange(sheet.Items);
foreach (var record in sheet.Records)
{
record.ItemScores.Clear();
record.TotalScore = null;
record.IsPassed = null;
}
sheet.Items = replacementItems;
foreach (var record in sheet.Records)
foreach (var item in replacementItems)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPut("sheets/{id:guid}/records")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateRecords(
Guid id,
ExperimentGradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能继续修改。");
var records = sheet.Records.ToDictionary(x => x.Id);
if (request.Records.Select(x => x.Id).Distinct().Count() !=
request.Records.Count ||
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的实验成绩记录。");
var itemIds = sheet.Items.Select(x => x.Id).ToHashSet();
foreach (var item in request.Records)
{
if (item.AttemptNumber is < 1 or > 20)
return ValidationProblem("实验尝试次数应在 1—20 次之间。");
if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
.Distinct().Count() != item.ItemScores.Count ||
item.ItemScores.Any(x =>
!itemIds.Contains(x.ExperimentGradeItemId) ||
!ValidScore(x.Score)))
return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");
var record = records[item.Id];
record.ParticipationStatus = item.ParticipationStatus;
record.SafetyViolation = item.SafetyViolation;
record.AttemptNumber = item.AttemptNumber;
record.SubmissionReference =
Normalize(item.SubmissionReference);
record.SubmittedAt = item.SubmittedAt;
record.IsLate = item.IsLate;
record.TeacherComment = Normalize(item.TeacherComment);
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
foreach (var score in item.ItemScores)
{
var target = scores[score.ExperimentGradeItemId];
target.Score = score.Score;
target.Comment = Normalize(score.Comment);
}
Recalculate(sheet, record);
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/sync-participants")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SyncParticipants(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能同步参与学生。");
var expected = await LoadParticipantsAsync(
sheet.ExperimentProject,
cancellationToken);
var expectedByStudent = expected.ToDictionary(x => x.StudentId);
var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
var added = 0;
var removed = 0;
var retained = 0;
var recordsToRemove = new List<ExperimentGradeRecord>();
var recordsToAdd = new List<ExperimentGradeRecord>();
foreach (var participant in expected)
{
if (existingByStudent.TryGetValue(
participant.StudentId,
out var record))
{
if (record.ParticipationStatus ==
ExperimentParticipationStatus.Pending &&
record.ItemScores.All(x => !x.Score.HasValue))
record.ExperimentSessionId = participant.SessionId;
retained++;
continue;
}
var newRecord = new ExperimentGradeRecord
{
ExperimentGradeSheetId = sheet.Id,
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId
};
db.ExperimentGradeRecords.Add(newRecord);
recordsToAdd.Add(newRecord);
added++;
}
foreach (var record in sheet.Records
.Where(x => !expectedByStudent.ContainsKey(x.StudentId))
.ToList())
{
if (record.ParticipationStatus !=
ExperimentParticipationStatus.Pending ||
record.ItemScores.Any(x => x.Score.HasValue))
{
retained++;
continue;
}
db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
recordsToRemove.Add(record);
removed++;
}
if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
{
await db.SaveChangesAsync(cancellationToken);
foreach (var record in recordsToRemove)
sheet.Records.Remove(record);
db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
}
foreach (var record in recordsToAdd)
foreach (var item in sheet.Items)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
Added = added,
Removed = removed,
Retained = retained,
Total = await db.ExperimentGradeRecords.CountAsync(
x => x.ExperimentGradeSheetId == sheet.Id,
cancellationToken)
});
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Submit(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("只有草稿或已退回实验成绩单可以提交。");
if (sheet.Records.Count == 0)
return ConflictProblem("实验成绩单没有学生记录。");
foreach (var record in sheet.Records) Recalculate(sheet, record);
if (sheet.Records.Any(x =>
x.ParticipationStatus ==
ExperimentParticipationStatus.Pending))
return ConflictProblem("仍有学生未登记实验参与状态。");
if (sheet.Records.Any(x =>
x.ParticipationStatus is
ExperimentParticipationStatus.Completed or
ExperimentParticipationStatus.Makeup &&
!x.TotalScore.HasValue))
return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");
sheet.Status = ExperimentGradeSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"实验成绩待审核",
$"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。",
sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
[HttpPost("sheets/{id:guid}/approve")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以通过审核。");
sheet.Status = ExperimentGradeSheetStatus.Approved;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩审核通过",
$"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/return")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Return(
Guid id,
ExperimentGradeReviewRequest request,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以退回。");
if (string.IsNullOrWhiteSpace(request.Comment))
return ValidationProblem("退回时必须填写修改意见。");
sheet.Status = ExperimentGradeSheetStatus.Returned;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = request.Comment.Trim();
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩被退回",
$"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/publish")]
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!IsPublisher()) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Approved)
return ConflictProblem("只有审核通过的实验成绩单可以发布。");
sheet.Status = ExperimentGradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var userIds = await db.ExperimentGradeRecords
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验成绩已发布",
$"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
Guid id,
CancellationToken cancellationToken)
{
if (!await ScopedSheets().AnyAsync(
x => x.Id == id,
cancellationToken))
return null;
return await db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
if (project.ArrangementMode ==
ExperimentArrangementMode.Centralized)
{
return await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.AsNoTracking()
.OrderBy(x => x.StudentNumber)
.Select(x => new ExperimentParticipantSeed(x.Id, null))
.ToListAsync(cancellationToken);
}
return await db.ExperimentBookings.AsNoTracking()
.Where(x =>
x.ExperimentProjectId == project.Id &&
x.Status == ExperimentBookingStatus.Booked &&
x.Student!.Status == StudentStatus.Active)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new ExperimentParticipantSeed(
x.StudentId,
x.ExperimentSessionId))
.ToListAsync(cancellationToken);
}
private static List<ExperimentGradeItem> BuildItems(
IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
requests?.Select((item, index) => new ExperimentGradeItem
{
Name = item.Name.Trim(),
Kind = item.Kind,
Weight = item.Weight,
SortOrder = index
}).ToList() ?? [];
private static void Recalculate(
ExperimentGradeSheet sheet,
ExperimentGradeRecord record)
{
record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
record,
sheet.Items.ToList());
record.IsPassed = record.TotalScore.HasValue
? record.TotalScore.Value >= sheet.PassScore &&
!record.SafetyViolation
: null;
}
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x => x.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private IQueryable<ExperimentProject> ScopedProjects()
{
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
return db.ExperimentProjects.Where(x =>
taskIds.Contains(x.TeachingTaskId));
}
private IQueryable<ExperimentGradeSheet> ScopedSheets()
{
var projectIds = ScopedProjects().Select(x => x.Id);
return db.ExperimentGradeSheets.Where(x =>
projectIds.Contains(x.ExperimentProjectId));
}
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
IsAssignedTeacher(task);
private bool CanEdit(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
IsAssignedTeacher(task);
private bool IsAssignedTeacher(TeachingTask task) =>
task.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
private bool CanReviewCollege(Guid collegeId)
{
var scope = currentUserDataScope.Current;
if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
!scope.IsInRole(SystemRoles.AcademicAdmin) &&
!scope.IsInRole(SystemRoles.CollegeAdmin))
return false;
return scope.Scope == DataScope.All ||
scope.RestrictedCollegeId == collegeId;
}
private bool IsPublisher() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x =>
x.UserId == currentUserDataScope.Current.UserId &&
x.Status == StudentStatus.Active,
cancellationToken);
private async Task NotifyTeachersAsync(
Guid teachingTaskId,
string title,
string content,
CancellationToken cancellationToken)
{
var userIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == teachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
title,
content,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
}
private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100;
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成实验成绩操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private sealed record ExperimentParticipantSeed(
Guid StudentId,
Guid? SessionId);
}
public sealed record ExperimentGradeSheetRequest(
Guid ExperimentProjectId,
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeSettingsRequest(
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeItemRequest(
[Required, MaxLength(60)] string Name,
ExperimentGradeItemKind Kind,
[Range(typeof(decimal), "0.1", "100")] decimal Weight);
public sealed record ExperimentGradeRecordsRequest(
IReadOnlyCollection<ExperimentGradeRecordRequest> Records);
public sealed record ExperimentGradeRecordRequest(
Guid Id,
ExperimentParticipationStatus ParticipationStatus,
bool SafetyViolation,
[Range(1, 20)] int AttemptNumber,
[MaxLength(500)] string? SubmissionReference,
DateTime? SubmittedAt,
bool IsLate,
[MaxLength(500)] string? TeacherComment,
IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);
public sealed record ExperimentGradeItemScoreRequest(
Guid ExperimentGradeItemId,
decimal? Score,
[MaxLength(300)] string? Comment);
public sealed record ExperimentGradeReviewRequest(
[MaxLength(500)] string? Comment);
File diff suppressed because it is too large Load Diff
+81 -4
View File
@@ -101,7 +101,9 @@ public sealed class GradesController(
{ {
item.Id, item.Id,
item.Name, item.Name,
item.Weight item.Weight,
item.SourceType,
item.SourceSnapshotAt
}), }),
StudentCount = sheet.Records.Count, StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record => CompletedCount = sheet.Records.Count(record =>
@@ -220,7 +222,9 @@ public sealed class GradesController(
{ {
item.Id, item.Id,
item.Name, item.Name,
item.Weight item.Weight,
item.SourceType,
item.SourceSnapshotAt
}), }),
x.Status, x.Status,
x.ReviewComment, x.ReviewComment,
@@ -408,7 +412,7 @@ public sealed class GradesController(
request.Records.Any(x => !records.ContainsKey(x.Id))) request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。"); return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet(); var itemsById = sheet.Items.ToDictionary(item => item.Id);
foreach (var item in request.Records) foreach (var item in request.Records)
{ {
if (!ValidScore(item.RegularScore) || if (!ValidScore(item.RegularScore) ||
@@ -428,7 +432,18 @@ public sealed class GradesController(
{ {
if (!ValidScore(scoreEntry.Score)) if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。"); return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing)) if (!itemsById.TryGetValue(
scoreEntry.GradeItemId,
out var gradeItem) ||
!scoreMap.TryGetValue(
scoreEntry.GradeItemId,
out var existing))
continue;
if (gradeItem.SourceType ==
GradeItemSourceType.ExperimentSummary &&
existing.Score != scoreEntry.Score)
return ConflictProblem(
$"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。");
existing.Score = scoreEntry.Score; existing.Score = scoreEntry.Score;
} }
} }
@@ -438,6 +453,66 @@ public sealed class GradesController(
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> ImportExperimentScores(
Guid id,
ImportExperimentScoresRequest request,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (
GradeSheetStatus.Draft or
GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能导入实验成绩。");
var targetItem = sheet.Items.FirstOrDefault(x =>
x.Id == request.GradeItemId);
if (targetItem is null)
return ValidationProblem("请选择当前成绩单中的有效分项。");
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
db,
sheet.TeachingTaskId,
sheet.Records.Select(x => x.StudentId).ToArray(),
cancellationToken);
if (aggregate.PublishedProjectCount == 0)
return ConflictProblem(
"该教学任务尚无已发布的实验项目成绩,无法导入。");
var imported = 0;
var missing = 0;
foreach (var record in sheet.Records)
{
var targetScore = record.ItemScores.First(x =>
x.GradeItemId == targetItem.Id);
targetScore.Score = aggregate.Scores[record.StudentId];
if (targetScore.Score.HasValue) imported++;
else missing++;
Recalculate(sheet, record);
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
aggregate.PublishedProjectCount,
aggregate.Projects,
ImportedCount = imported,
MissingCount = missing,
targetItem.SourceSnapshotAt
});
}
[HttpPost("sheets/{id:guid}/submit")] [HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = SheetUsers)] [Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
@@ -998,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
Guid GradeItemId, Guid GradeItemId,
decimal? Score); decimal? Score);
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest( public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment); [MaxLength(500)] string? Comment);
@@ -18,8 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class MakeupExamsController( public sealed class MakeupExamsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService, MakeupExamEligibilityService eligibilityService) : ControllerBase
MakeupExamArrangementService arrangementService) : ControllerBase
{ {
private const string Managers = private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
@@ -111,6 +110,7 @@ public sealed class MakeupExamsController(
item.StartsAt, item.StartsAt,
item.EndsAt, item.EndsAt,
item.RequiredBuildingId, item.RequiredBuildingId,
item.RequiredBuildingIds,
RequiredBuildingName = item.RequiredBuilding != null RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null, ? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount, item.RequiredInvigilatorCount,
@@ -138,38 +138,90 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{ {
var plan = await db.MakeupExamPlans if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
.Include(x => x.Sessions) return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
.ThenInclude(x => x.Invigilators) if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
.Include(x => x.Sessions) return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
.ThenInclude(x => x.Enrollments)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Status,
HasSessions = x.Sessions.Any()
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿补考计划可以发布。"); return ConflictProblem("只有草稿补考计划可以发布。");
if (plan.Sessions.Count == 0) if (!plan.HasSessions)
return ConflictProblem("至少安排一个考试场次后才能发布。"); return ConflictProblem("至少安排一个考试场次后才能发布。");
var unassigned = plan.Sessions.Count(x => var userId = currentUserDataScope.Current.UserId;
!x.ClassroomId.HasValue || x.Invigilators.Count == 0); var job = new ExamPublishJob
if (unassigned > 0) {
return ConflictProblem( Kind = ExamPublishJobKind.MakeupExam,
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。"); PlanId = id,
ActivePlanId = id,
RequestedByUserId = userId == Guid.Empty ? null : userId,
CurrentStep = "等待后台校验"
};
db.ExamPublishJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamPublish,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
var existing = await FindActivePublishJobAsync(id, cancellationToken);
if (existing is not null)
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
throw;
}
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0); return AcceptedPublishJob(job, "补考发布任务已提交。");
if (empty > 0) }
return ConflictProblem(
$"还有 {empty} 个场次没有登记补考学生。");
plan.Status = MakeupExamPlanStatus.Published; [HttpGet("publish-jobs/{jobId:guid}")]
plan.PublishedAt = DateTime.UtcNow; [Authorize(Roles = Managers)]
return await SaveAsync(id, false, cancellationToken); public async Task<ActionResult> GetPublishJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamPublishJobKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
}
[HttpGet("plans/{planId:guid}/publish-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestPublishJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamPublishJobKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
} }
[HttpPost("plans/{id:guid}/archive")] [HttpPost("plans/{id:guid}/archive")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Published) if (plan.Status != MakeupExamPlanStatus.Published)
@@ -189,6 +241,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request, CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -215,6 +269,7 @@ public sealed class MakeupExamsController(
StartsAt = startsAt, StartsAt = startsAt,
EndsAt = endsAt, EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId, RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount, RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes), Notes = Normalize(request.Notes),
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id => Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
@@ -231,6 +286,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionsBatchRequest request, CreateMakeupExamSessionsBatchRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -281,6 +338,7 @@ public sealed class MakeupExamsController(
StartsAt = startsAt, StartsAt = startsAt,
EndsAt = endsAt, EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId, RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount, RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes) Notes = Normalize(request.Notes)
})); }));
@@ -304,6 +362,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request, CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan) .Include(x => x.MakeupExamPlan)
.Include(x => x.Invigilators) .Include(x => x.Invigilators)
@@ -330,6 +390,7 @@ public sealed class MakeupExamsController(
session.StartsAt = startsAt; session.StartsAt = startsAt;
session.EndsAt = endsAt; session.EndsAt = endsAt;
session.RequiredBuildingId = request.RequiredBuildingId; session.RequiredBuildingId = request.RequiredBuildingId;
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes); session.Notes = Normalize(request.Notes);
@@ -347,6 +408,8 @@ public sealed class MakeupExamsController(
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan) var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
if (session is null) return NotFound(); if (session is null) return NotFound();
@@ -368,15 +431,117 @@ public sealed class MakeupExamsController(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
request ??= new ExamAutoArrangeRequest(); request ??= new ExamAutoArrangeRequest();
var result = await arrangementService.ArrangeAsync( if (!request.AssignClassrooms && !request.AssignInvigilators)
planId, return ValidationProblem("请至少选择分配考场或分配监考教师。");
request.SessionIds,
request.AssignClassrooms, var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
request.AssignInvigilators, if (sessionIds.Length > 100)
return ValidationProblem("一次最多处理100个补考场次。");
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == planId)
.Select(x => new
{
x.Status,
TotalSessions = x.Sessions.Count
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null)
return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
if (sessionIds.Length > 0)
{
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId)
.WhereIn(sessionIds, x => x.Id)
.CountAsync(cancellationToken);
if (selectedSessionCount != sessionIds.Length)
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
}
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
.AnyAsync(
x => x.MakeupExamPlanId == planId &&
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running),
cancellationToken); cancellationToken);
if (!result.Success) if (autoCreateActive)
return ConflictProblem(result.Message); return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
return Ok(new { message = result.Message });
var existing = await FindActiveArrangementJobAsync(
planId,
cancellationToken);
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
var userId = currentUserDataScope.Current.UserId;
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.MakeupExam,
PlanId = planId,
ActivePlanId = planId,
RequestedByUserId = userId == Guid.Empty ? null : userId,
SessionIdsJson = sessionIds.Length == 0
? null
: System.Text.Json.JsonSerializer.Serialize(sessionIds),
AssignClassrooms = request.AssignClassrooms,
AssignInvigilators = request.AssignInvigilators,
TotalSessions = sessionIds.Length == 0
? plan.TotalSessions
: sessionIds.Length,
CurrentStep = "等待后台编排"
};
db.ExamArrangementJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamArrangement,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
existing = await FindActiveArrangementJobAsync(
planId,
cancellationToken);
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
throw;
}
return AcceptedArrangementJob(job, "补考编排任务已提交。");
}
[HttpGet("arrangement-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetArrangementJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamArrangementKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
}
[HttpGet("plans/{planId:guid}/arrangement-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestArrangementJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamArrangementKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -394,6 +559,8 @@ public sealed class MakeupExamsController(
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动生成。"); return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
// Check for existing active job // Check for existing active job
var existing = await db.MakeupExamAutoJobs.AsNoTracking() var existing = await db.MakeupExamAutoJobs.AsNoTracking()
@@ -1042,6 +1209,87 @@ public sealed class MakeupExamsController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamArrangementKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedArrangementJob(
ExamArrangementJob job,
string message) =>
AcceptedAtAction(
nameof(GetArrangementJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.TotalSessions,
job.ProcessedSessions,
job.CurrentStep,
job.ResultMessage,
job.ErrorMessage,
job.AssignClassrooms,
job.AssignInvigilators,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private Task<ExamPublishJob?> FindActivePublishJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamPublishJobKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedPublishJob(
ExamPublishJob job,
string message) =>
AcceptedAtAction(
nameof(GetPublishJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToPublishJobResponse(ExamPublishJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.CurrentStep,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private async Task<ActionResult> SaveAsync(Guid id, bool created, private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token) CancellationToken token)
{ {
@@ -1064,6 +1312,10 @@ public sealed class MakeupExamsController(
}); });
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
ids is { Count: > 0 }
? System.Text.Json.JsonSerializer.Serialize(ids)
: null;
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -1082,6 +1334,7 @@ public sealed record CreateMakeupExamSessionRequest(
[Range(1, 30)] int StartPeriod, [Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount, [Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId, Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount, [Range(1, 10)] int RequiredInvigilatorCount,
IReadOnlyCollection<Guid>? InvigilatorIds, IReadOnlyCollection<Guid>? InvigilatorIds,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
@@ -1092,6 +1345,7 @@ public sealed record CreateMakeupExamSessionsBatchRequest(
[Range(1, 30)] int StartPeriod, [Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount, [Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId, Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount, [Range(1, 10)] int RequiredInvigilatorCount,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
@@ -213,6 +213,62 @@ public sealed class OperationsController(
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
} }
if (normalizedKind is null or "ExamArrangement")
{
var query = db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Status == ExamArrangementJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"ExamArrangement",
x.Kind == ExamArrangementKind.FormalExam
? "正式考试编排"
: "补考编排",
x.Kind == ExamArrangementKind.FormalExam
? "正式考试计划"
: "补考计划",
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
if (normalizedKind is null or "ExamPublish")
{
var query = db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.Status == ExamPublishJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"ExamPublish",
x.Kind == ExamPublishJobKind.FormalExam
? "正式考试发布"
: "补考发布",
x.Kind == ExamPublishJobKind.FormalExam
? "正式考试计划"
: "补考计划",
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
var pageItems = rows var pageItems = rows
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt) .OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
@@ -435,6 +491,16 @@ public sealed class OperationsController(
.CountAsync( .CountAsync(
x => x.Status == MakeupExamAutoJobStatus.Failed && x => x.Status == MakeupExamAutoJobStatus.Failed &&
x.CreatedAt >= from, x.CreatedAt >= from,
cancellationToken) +
await db.ExamArrangementJobs.AsNoTracking()
.CountAsync(
x => x.Status == ExamArrangementJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
await db.ExamPublishJobs.AsNoTracking()
.CountAsync(
x => x.Status == ExamPublishJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken); cancellationToken);
private ActionResult? ValidatePaging(int page, int pageSize) private ActionResult? ValidatePaging(int page, int pageSize)
@@ -462,6 +528,7 @@ public sealed class OperationsController(
"AutomaticSchedule" => "AutomaticSchedule", "AutomaticSchedule" => "AutomaticSchedule",
"SchedulePublish" => "SchedulePublish", "SchedulePublish" => "SchedulePublish",
"MakeupExamAuto" => "MakeupExamAuto", "MakeupExamAuto" => "MakeupExamAuto",
"ExamArrangement" => "ExamArrangement",
_ => null _ => null
}; };
} }
@@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -96,6 +97,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
x.SchedulingMode x.SchedulingMode
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -107,6 +110,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
return Ok(tasks.Select(task => return Ok(tasks.Select(task =>
{ {
constraints.TryGetValue(task.Id, out var constraint); constraints.TryGetValue(task.Id, out var constraint);
var weeklyHours = task.WeeklyHours;
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out var regularWeeklyHours))
weeklyHours = regularWeeklyHours;
return new return new
{ {
task.Id, task.Id,
@@ -120,7 +132,9 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
task.Capacity, task.Capacity,
task.StartWeek, task.StartWeek,
task.EndWeek, task.EndWeek,
task.WeeklyHours, WeeklyHours = weeklyHours,
task.CourseTotalHours,
task.CoursePracticeHours,
task.SchedulingMode, task.SchedulingMode,
HasCustomConstraint = constraint is not null, HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -497,6 +498,7 @@ public sealed class SchedulesController(
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null || if (task is null ||
task.Status != TeachingTaskStatus.Published || task.Status != TeachingTaskStatus.Published ||
@@ -506,6 +508,27 @@ public sealed class SchedulesController(
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。"); return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.Course!,
task.StartWeek,
task.EndWeek,
out var requiredWeeklyHours))
return ValidationProblem(
"该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。");
if (requiredWeeklyHours == 0)
return ValidationProblem(
"该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。");
var existingHours = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
x.TeachingTaskId == request.TeachingTaskId &&
x.Id != entryId)
.SumAsync(x => x.PeriodCount, cancellationToken);
if (existingHours + request.PeriodCount > requiredWeeklyHours)
return ValidationProblem(
$"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" +
$"当前操作后将达到 {existingHours + request.PeriodCount} 学时," +
"实践学时请在实验管理中安排。");
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
@@ -25,7 +25,12 @@ public sealed class StatisticsController(
SystemRoles.CollegeAdmin + "," + SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader; SystemRoles.Leader;
private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId; // HybridCache may execute its source factory without the request's ambient
// HttpContext. Keep the authorization scope stable for the whole controller
// invocation instead of resolving it again inside that background factory.
private readonly CurrentUserScope currentUser = currentUserDataScope.Current;
private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId;
private Guid? ResolveCollegeId(Guid? requestedCollegeId) private Guid? ResolveCollegeId(Guid? requestedCollegeId)
{ {
@@ -957,13 +962,14 @@ public sealed class StatisticsController(
async token => async token =>
{ {
var source = await factory(token); var source = await factory(token);
if (source.Result is not null || source.Value is null) var data = source.Result is ObjectResult { Value: not null } objectResult
? objectResult.Value
: source.Value;
if (data is null)
throw new InvalidOperationException( throw new InvalidOperationException(
"Statistics cache source did not return a successful value."); "Statistics cache source did not return a successful value.");
return JsonSerializer.SerializeToElement( return JsonSerializer.SerializeToElement(data, data.GetType());
source.Value,
source.Value.GetType());
}, },
AppCacheProfile.Analytics, AppCacheProfile.Analytics,
[ [
@@ -981,7 +987,7 @@ public sealed class StatisticsController(
params string?[] filters) => params string?[] filters) =>
AppCacheKeys.Statistics( AppCacheKeys.Statistics(
area, area,
currentUserDataScope.Current.Scope.ToString(), currentUser.Scope.ToString(),
effectiveCollegeId, effectiveCollegeId,
filters); filters);
@@ -82,6 +82,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
TeacherNames = x.Teachers TeacherNames = x.Teachers
@@ -127,6 +130,10 @@ public sealed class TeachingTasksController(
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course!.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
@@ -163,6 +170,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
x.Notes, x.Notes,
@@ -423,7 +433,8 @@ public sealed class TeachingTasksController(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours,
TeachingTaskSchedulingMode.Standard);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
@@ -598,7 +609,8 @@ public sealed class TeachingTasksController(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours,
request.SchedulingMode);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId(); var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
@@ -367,7 +367,10 @@ public sealed class TimetablesController(
db.TeachingTasks.Any(task => db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id && task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible))) task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
db.ExamPlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == ExamPlanStatus.Published)))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
@@ -394,7 +397,12 @@ public sealed class TimetablesController(
task.AcademicTermId == defaultTermId.Value && task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id))))) task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
db.ExamSessions.Any(session =>
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
session.ExamPlan.Status == ExamPlanStatus.Published &&
session.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)))))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var colleges = await db.Colleges.AsNoTracking() var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; } public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = []; public ICollection<AttendanceRecord> Records { get; set; } = [];
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
} }
public sealed class AttendanceRecord public sealed class AttendanceRecord
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
public DateTime? AppealReviewedAt { get; set; } public DateTime? AppealReviewedAt { get; set; }
} }
public sealed class AttendanceCheckInAttempt : EntityBase
{
public Guid AttendanceSheetId { get; set; }
public AttendanceSheet? AttendanceSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public AttendanceCheckInMethod CheckInMethod { get; set; }
public bool IsSuccessful { get; set; }
public string? FailureCode { get; set; }
public string? DeviceIdentifierHash { get; set; }
public string? DevicePlatform { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? RiskFlags { get; set; }
public decimal? Latitude { get; set; }
public decimal? Longitude { get; set; }
public double? AccuracyMeters { get; set; }
public double? DistanceMeters { get; set; }
}
public enum AttendanceSheetStatus public enum AttendanceSheetStatus
{ {
Draft = 1, Draft = 1,
@@ -11,6 +11,41 @@ public sealed class ExamPlan : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; } public DateTime? PublishedAt { get; set; }
public ICollection<ExamSession> Sessions { get; set; } = []; public ICollection<ExamSession> Sessions { get; set; } = [];
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
}
public sealed class ExamArrangementJob : EntityBase
{
public ExamArrangementKind Kind { get; set; }
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? SessionIdsJson { get; set; }
public bool AssignClassrooms { get; set; }
public bool AssignInvigilators { get; set; }
public ExamArrangementJobStatus Status { get; set; } =
ExamArrangementJobStatus.Queued;
public int TotalSessions { get; set; }
public int ProcessedSessions { get; set; }
public string? CurrentStep { get; set; }
public string? ResultMessage { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamArrangementKind
{
FormalExam = 1,
MakeupExam = 2
}
public enum ExamArrangementJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
} }
public sealed class ExamSession : EntityBase public sealed class ExamSession : EntityBase
@@ -28,9 +63,12 @@ public sealed class ExamSession : EntityBase
public DateTime EndsAt { get; set; } public DateTime EndsAt { get; set; }
public Guid? RequiredBuildingId { get; set; } public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; } public Building? RequiredBuilding { get; set; }
public string? RequiredBuildingIds { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2; public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; } public string? Notes { get; set; }
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = []; public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
} }
public sealed class ExamSessionInvigilator public sealed class ExamSessionInvigilator
@@ -41,9 +79,82 @@ public sealed class ExamSessionInvigilator
public Teacher? Teacher { get; set; } public Teacher? Teacher { get; set; }
} }
public sealed class ExamRoomAssignment : EntityBase
{
public Guid ExamPlanId { get; set; }
public ExamPlan? ExamPlan { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly ExamDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2;
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
}
public sealed class ExamRoomSession
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
}
public sealed class ExamSeatAssignment
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int SeatNumber { get; set; }
}
public sealed class ExamRoomInvigilator
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
}
public enum ExamPlanStatus public enum ExamPlanStatus
{ {
Draft = 1, Draft = 1,
Published = 2, Published = 2,
Archived = 3 Archived = 3
} }
public sealed class ExamPublishJob : EntityBase
{
public ExamPublishJobKind Kind { get; set; }
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamPublishJobKind
{
FormalExam = 1,
MakeupExam = 2
}
public enum ExamPublishJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
@@ -0,0 +1,26 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExamSignInExportJob : EntityBase
{
public Guid PlanId { get; set; }
public ExamSignInExportJobStatus Status { get; set; } =
ExamSignInExportJobStatus.Queued;
public Guid? RequestedByUserId { get; set; }
public string? FileName { get; set; }
public byte[]? FileBytes { get; set; }
public int FileSize { get; set; }
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamSignInExportJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
@@ -0,0 +1,80 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExperimentProject : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
public ExperimentArrangementMode ArrangementMode { get; set; }
public string? Description { get; set; }
public string? Requirements { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public ExperimentProjectStatus Status { get; set; } =
ExperimentProjectStatus.Draft;
public DateTime? PublishedAt { get; set; }
public DateTime? ClosedAt { get; set; }
public ICollection<ExperimentSession> Sessions { get; set; } = [];
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
public ExperimentGradeSheet? GradeSheet { get; set; }
}
public sealed class ExperimentSession : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly SessionDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public int Capacity { get; set; }
public int ReservedCount { get; set; }
public string? Notes { get; set; }
public ExperimentSessionStatus Status { get; set; } =
ExperimentSessionStatus.Scheduled;
public DateTime? CancelledAt { get; set; }
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
}
public sealed class ExperimentBooking : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public Guid ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public ExperimentBookingStatus Status { get; set; } =
ExperimentBookingStatus.Booked;
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
public DateTime? CancelledAt { get; set; }
}
public enum ExperimentArrangementMode
{
Centralized = 1,
SelfScheduled = 2
}
public enum ExperimentProjectStatus
{
Draft = 1,
Published = 2,
Closed = 3
}
public enum ExperimentSessionStatus
{
Scheduled = 1,
Cancelled = 2
}
public enum ExperimentBookingStatus
{
Booked = 1,
Cancelled = 2
}
@@ -0,0 +1,92 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExperimentGradeSheet : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public decimal ContributionWeight { get; set; } = 1;
public decimal PassScore { get; set; } = 60;
public ExperimentGradeSheetStatus Status { get; set; } =
ExperimentGradeSheetStatus.Draft;
public string? ReviewComment { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
}
public sealed class ExperimentGradeItem : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public required string Name { get; set; }
public ExperimentGradeItemKind Kind { get; set; } =
ExperimentGradeItemKind.Other;
public decimal Weight { get; set; }
public int SortOrder { get; set; }
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
}
public sealed class ExperimentGradeRecord : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid? ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
ExperimentParticipationStatus.Pending;
public decimal? TotalScore { get; set; }
public bool? IsPassed { get; set; }
public bool SafetyViolation { get; set; }
public int AttemptNumber { get; set; } = 1;
public string? SubmissionReference { get; set; }
public DateTime? SubmittedAt { get; set; }
public bool IsLate { get; set; }
public string? TeacherComment { get; set; }
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
}
public sealed class ExperimentGradeItemScore
{
public Guid ExperimentGradeRecordId { get; set; }
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
public Guid ExperimentGradeItemId { get; set; }
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
public decimal? Score { get; set; }
public string? Comment { get; set; }
}
public enum ExperimentGradeSheetStatus
{
Draft = 1,
Submitted = 2,
Approved = 3,
Published = 4,
Returned = 5
}
public enum ExperimentGradeItemKind
{
Operation = 1,
Report = 2,
Result = 3,
Defense = 4,
Attendance = 5,
Safety = 6,
Other = 7
}
public enum ExperimentParticipationStatus
{
Pending = 1,
Completed = 2,
Absent = 3,
Excused = 4,
Makeup = 5,
Exempt = 6
}
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
public required string Name { get; set; } public required string Name { get; set; }
public decimal Weight { get; set; } public decimal Weight { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public GradeItemSourceType SourceType { get; set; } =
GradeItemSourceType.Manual;
public DateTime? SourceSnapshotAt { get; set; }
public ICollection<GradeItemScore> Scores { get; set; } = []; public ICollection<GradeItemScore> Scores { get; set; } = [];
} }
@@ -68,3 +71,9 @@ public enum GradeExamStatus
Exempt = 4, Exempt = 4,
Makeup = 5 Makeup = 5
} }
public enum GradeItemSourceType
{
Manual = 1,
ExperimentSummary = 2
}
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
public DateTime EndsAt { get; set; } public DateTime EndsAt { get; set; }
public Guid? RequiredBuildingId { get; set; } public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; } public Building? RequiredBuilding { get; set; }
public string? RequiredBuildingIds { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2; public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; } public string? Notes { get; set; }
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = []; public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
@@ -0,0 +1,40 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.System;
public enum AppUpdatePlatform
{
Android,
Ios
}
public enum AppUpdateChannel
{
Production,
Staging
}
public enum AppUpdateReleaseStatus
{
Draft,
Published,
Archived
}
public sealed class AppUpdateRelease : EntityBase
{
public AppUpdatePlatform Platform { get; set; }
public AppUpdateChannel Channel { get; set; }
public required string Version { get; set; }
public required string NativeVersion { get; set; }
public AppUpdateReleaseStatus Status { get; set; } =
AppUpdateReleaseStatus.Draft;
public string? ReleaseNotes { get; set; }
public required string FileName { get; set; }
public long FileSize { get; set; }
public required string Sha256 { get; set; }
public required byte[] BundleContent { get; set; }
public required string CreatedByUserName { get; set; }
public string? PublishedByUserName { get; set; }
public DateTime? PublishedAt { get; set; }
}
@@ -30,7 +30,10 @@ public enum BackgroundJobKind
{ {
AutomaticSchedule = 1, AutomaticSchedule = 1,
SchedulePublish = 2, SchedulePublish = 2,
MakeupExamAuto = 3 MakeupExamAuto = 3,
ExamArrangement = 4,
ExamSignInExport = 5,
ExamPublish = 6
} }
public enum BackgroundJobOutboxState public enum BackgroundJobOutboxState
@@ -13,6 +13,9 @@ public sealed class BackgroundJobOptions
public int AutomaticScheduleConcurrency { get; set; } = 1; public int AutomaticScheduleConcurrency { get; set; } = 1;
public int SchedulePublishConcurrency { get; set; } = 1; public int SchedulePublishConcurrency { get; set; } = 1;
public int MakeupExamAutoConcurrency { get; set; } = 1; public int MakeupExamAutoConcurrency { get; set; } = 1;
public int ExamArrangementConcurrency { get; set; } = 1;
public int ExamSignInExportConcurrency { get; set; } = 1;
public int ExamPublishConcurrency { get; set; } = 1;
public string Exchange { get; set; } = "jiaowu.background-jobs"; public string Exchange { get; set; } = "jiaowu.background-jobs";
public string QueuePrefix { get; set; } = "jiaowu.background-jobs"; public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
public bool UseQuorumQueues { get; set; } = true; public bool UseQuorumQueues { get; set; } = true;
@@ -29,6 +32,9 @@ public sealed class BackgroundJobOptions
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency, BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency, BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency, BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
}; };
} }
@@ -103,6 +103,33 @@ public sealed class BackgroundJobOutboxPublisher(
message.JobId == x.Id)) message.JobId == x.Id))
.Select(x => x.Id) .Select(x => x.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamArrangement &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamSignInExportJobStatus.Queued ||
x.Status == ExamSignInExportJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamSignInExport &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamPublish &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var missingKeys = automaticJobs var missingKeys = automaticJobs
.Select(id => (BackgroundJobKind.AutomaticSchedule, id)) .Select(id => (BackgroundJobKind.AutomaticSchedule, id))
@@ -110,6 +137,12 @@ public sealed class BackgroundJobOutboxPublisher(
(BackgroundJobKind.SchedulePublish, id))) (BackgroundJobKind.SchedulePublish, id)))
.Concat(makeupJobs.Select(id => .Concat(makeupJobs.Select(id =>
(BackgroundJobKind.MakeupExamAuto, id))) (BackgroundJobKind.MakeupExamAuto, id)))
.Concat(arrangementJobs.Select(id =>
(BackgroundJobKind.ExamArrangement, id)))
.Concat(exportJobs.Select(id =>
(BackgroundJobKind.ExamSignInExport, id)))
.Concat(publishJobs2.Select(id =>
(BackgroundJobKind.ExamPublish, id)))
.ToList(); .ToList();
foreach (var (kind, jobId) in missingKeys) foreach (var (kind, jobId) in missingKeys)
{ {
@@ -85,6 +85,21 @@ public sealed class BackgroundJobRunner(
.GetRequiredService<MakeupExamAutoJobProcessor>() .GetRequiredService<MakeupExamAutoJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken); .ProcessAsync(message.JobId, cancellationToken);
break; break;
case BackgroundJobKind.ExamArrangement:
await scope.ServiceProvider
.GetRequiredService<ExamArrangementJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamSignInExport:
await scope.ServiceProvider
.GetRequiredService<ExamSignInExportJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await scope.ServiceProvider
.GetRequiredService<ExamPublishJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
$"Unsupported background job kind '{message.JobKind}'."); $"Unsupported background job kind '{message.JobKind}'.");
@@ -259,6 +274,40 @@ public sealed class BackgroundJobRunner(
.SetProperty(x => x.CompletedAt, completedAt), .SetProperty(x => x.CompletedAt, completedAt),
cancellationToken); cancellationToken);
break; break;
case BackgroundJobKind.ExamArrangement:
await db.ExamArrangementJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamArrangementJobStatus.Succeeded &&
x.Status != ExamArrangementJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamArrangementJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await db.ExamPublishJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamPublishJobStatus.Succeeded &&
x.Status != ExamPublishJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamPublishJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
default: default:
throw new ArgumentOutOfRangeException( throw new ArgumentOutOfRangeException(
nameof(message.JobKind), nameof(message.JobKind),
@@ -316,7 +316,10 @@ internal static class RabbitMqBackgroundJobTopology
[ [
BackgroundJobKind.AutomaticSchedule, BackgroundJobKind.AutomaticSchedule,
BackgroundJobKind.SchedulePublish, BackgroundJobKind.SchedulePublish,
BackgroundJobKind.MakeupExamAuto BackgroundJobKind.MakeupExamAuto,
BackgroundJobKind.ExamArrangement,
BackgroundJobKind.ExamSignInExport,
BackgroundJobKind.ExamPublish
]; ];
public static async Task<IConnection> CreateConnectionAsync( public static async Task<IConnection> CreateConnectionAsync(
@@ -412,6 +415,9 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.AutomaticSchedule => "schedule.automatic", BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
BackgroundJobKind.SchedulePublish => "schedule.publish", BackgroundJobKind.SchedulePublish => "schedule.publish",
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic", BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
BackgroundJobKind.ExamArrangement => "exam.arrangement",
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
BackgroundJobKind.ExamPublish => "exam.publish",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
}; };
@@ -0,0 +1,126 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementJobProcessor(
AppDbContext db,
ExamArrangementService examArrangementService,
MakeupExamArrangementService makeupExamArrangementService,
IAppCache cache,
ILogger<ExamArrangementJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamArrangementJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamArrangementJobStatus.Succeeded
or ExamArrangementJobStatus.Failed)
{
return;
}
job.Status = ExamArrangementJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.ResultMessage = null;
job.CurrentStep = job.Kind == ExamArrangementKind.FormalExam
? "正在编排正式考试"
: "正在编排补考";
await db.SaveChangesAsync(stoppingToken);
var sessionIds = DeserializeSessionIds(job.SessionIdsJson);
var result = job.Kind switch
{
ExamArrangementKind.FormalExam =>
await examArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
ExamArrangementKind.MakeupExam =>
await makeupExamArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
_ => throw new InvalidOperationException(
$"不支持的考试编排类型:{job.Kind}。")
};
if (!result.Success)
{
await MarkFailedAsync(jobId, result.Message);
return;
}
job.Status = ExamArrangementJobStatus.Succeeded;
job.ProcessedSessions = job.TotalSessions;
job.CurrentStep = "编排完成";
job.ResultMessage = result.Message;
job.ActivePlanId = null;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
if (job.Kind == ExamArrangementKind.FormalExam)
{
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
stoppingToken);
}
logger.LogInformation(
"Exam arrangement job {JobId} for {Kind}/{PlanId} completed.",
job.Id,
job.Kind,
job.PlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Exam arrangement job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Exam arrangement job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private static IReadOnlyCollection<Guid>? DeserializeSessionIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
var sessionIds = JsonSerializer.Deserialize<Guid[]>(json);
return sessionIds is { Length: > 0 } ? sessionIds : null;
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamArrangementJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamArrangementJobStatus.Failed;
job.ActivePlanId = null;
job.CurrentStep = "编排失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
@@ -1,13 +1,33 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams; namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementService(AppDbContext db) public sealed class ExamArrangementService(AppDbContext db)
{ {
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt); private sealed record RoomGroupKey(
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt); Guid CourseId,
DateOnly ExamDate,
int StartPeriod,
int PeriodCount,
string RequiredBuildingIdsKey);
private sealed record RoomOccupancy(
Guid ClassroomId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record InvigilatorOccupancy(
Guid TeacherId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record CandidateSeat(
Guid ExamSessionId,
Guid StudentId,
string StudentNumber);
public async Task<ExamArrangementResult> ArrangeAsync( public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId, Guid planId,
@@ -29,6 +49,13 @@ public sealed class ExamArrangementService(AppDbContext db)
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask) .ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course) .ThenInclude(x => x!.Course)
.Include(x => x.Rooms)
.ThenInclude(x => x.SessionLinks)
.Include(x => x.Rooms)
.ThenInclude(x => x.Seats)
.Include(x => x.Rooms)
.ThenInclude(x => x.Invigilators)
.AsSplitQuery()
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null) if (plan is null)
@@ -45,201 +72,549 @@ public sealed class ExamArrangementService(AppDbContext db)
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count) plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。"); return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
var sessions = plan.Sessions
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenByDescending(x => x.RequiredInvigilatorCount)
.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var termId = plan.AcademicTermId;
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == termId && x.IsEnabled) .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber) .OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (timeSlots.Count == 0) if (timeSlots.Count == 0)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
foreach (var session in plan.Sessions)
ComputeTimesFromSlots(session, timeSlotLookup);
int assignedRooms = 0; var explicitlySelected = plan.Sessions
int assignedInvigilators = 0; .Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
int unavailableRooms = 0; .ToList();
int unavailableInvigilators = 0; if (explicitlySelected.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var selectedKeys = explicitlySelected
.Select(GroupKey)
.ToHashSet();
var sessions = plan.Sessions
.Where(x => selectedKeys.Contains(GroupKey(x)))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ToList();
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
sessions.Select(x => x.TeachingTaskId),
cancellationToken);
var sessionByTaskId = sessions
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.First());
var replacedRooms = assignClassrooms
? plan.Rooms
.Where(room => room.SessionLinks.Any(link =>
sessionIds.Contains(link.ExamSessionId)))
.ToList()
: [];
var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet();
if (replacedRooms.Count > 0)
db.ExamRooms.RemoveRange(replacedRooms);
var occupiedRooms = await LoadOccupiedRoomsAsync(
plan,
sessionIds,
replacedRoomIds,
cancellationToken);
var rooms = await db.Classrooms.AsNoTracking()
.Include(x => x.Building)
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
var targetRooms = new List<ExamRoomAssignment>();
var messages = new List<string>(); var messages = new List<string>();
var seatedStudents = 0;
var unavailableStudents = 0;
// Track occupied time slots to avoid conflicts if (assignClassrooms)
var occupiedRooms = sessions {
.Where(x => x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
.ToList();
var occupiedInvigilators = sessions
.SelectMany(x => x.Invigilators.Select(i =>
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
.ToList();
foreach (var session in sessions) foreach (var session in sessions)
{ {
// Compute StartsAt/EndsAt from time slots session.ClassroomId = null;
ComputeTimesFromSlots(session, timeSlotLookup); if (session.Invigilators.Count > 0)
var studentCount = await db.CourseEnrollments.CountAsync( {
x => x.Status == CourseEnrollmentStatus.Enrolled && db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId, session.Invigilators.Clear();
cancellationToken); }
}
// ── Auto-assign classroom ── foreach (var group in sessions
if (assignClassrooms && !session.ClassroomId.HasValue) .GroupBy(GroupKey)
.OrderBy(x => x.Key.ExamDate)
.ThenBy(x => x.Key.StartPeriod)
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
{ {
var room = await FindBestClassroomAsync( var groupSessions = group.ToList();
session, studentCount, occupiedRooms, cancellationToken); var candidates = InterleaveCandidates(
if (room is not null) groupSessions,
roster,
sessionByTaskId);
if (candidates.Count == 0)
{ {
session.ClassroomId = room.Id;
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++;
messages.Add( messages.Add(
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)"); $"“{groupSessions[0].TeachingTask!.Course!.Name}”没有有效考生。");
continue;
}
var availableRooms = rooms
.Where(room =>
(group.Key.RequiredBuildingIdsKey.Length == 0 ||
group.Key.RequiredBuildingIdsKey
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Contains(room.BuildingId.ToString())) &&
occupiedRooms.All(occupied =>
occupied.ClassroomId != room.Id ||
!ExamConflictRules.TimeOverlaps(
occupied.StartsAt,
occupied.EndsAt,
groupSessions[0].StartsAt,
groupSessions[0].EndsAt)))
.ToList();
var selectedRooms = SelectRooms(
availableRooms,
candidates.Count);
if (selectedRooms.Sum(x => x.Capacity / 2) < candidates.Count)
{
unavailableStudents += candidates.Count;
messages.Add(
$"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," +
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity / 2)} 座。");
continue;
}
var offset = 0;
foreach (var classroom in selectedRooms)
{
var roomCandidates = candidates
.Skip(offset)
.Take(classroom.Capacity / 2)
.ToList();
if (roomCandidates.Count == 0) break;
offset += roomCandidates.Count;
var room = new ExamRoomAssignment
{
ExamPlanId = plan.Id,
CourseId = group.Key.CourseId,
ClassroomId = classroom.Id,
ExamDate = group.Key.ExamDate,
StartPeriod = group.Key.StartPeriod,
PeriodCount = group.Key.PeriodCount,
StartsAt = groupSessions[0].StartsAt,
EndsAt = groupSessions[0].EndsAt,
RequiredInvigilatorCount =
groupSessions.Max(x => x.RequiredInvigilatorCount),
SessionLinks = roomCandidates
.Select(x => x.ExamSessionId)
.Distinct()
.Select(sessionId => new ExamRoomSession
{
ExamSessionId = sessionId
})
.ToList(),
Seats = roomCandidates
.Select((candidate, index) =>
new ExamSeatAssignment
{
ExamSessionId = candidate.ExamSessionId,
StudentId = candidate.StudentId,
SeatNumber = index + 1
})
.ToList()
};
db.ExamRooms.Add(room);
targetRooms.Add(room);
occupiedRooms.Add(new RoomOccupancy(
classroom.Id,
room.StartsAt,
room.EndsAt));
seatedStudents += roomCandidates.Count;
}
messages.Add(
$"“{groupSessions[0].TeachingTask!.Course!.Name}”" +
$"{groupSessions.Count}个教学班混排至{selectedRooms.Count}个考场。");
}
} }
else else
{ {
unavailableRooms++; targetRooms.AddRange(plan.Rooms.Where(room =>
messages.Add( room.SessionLinks.Any(link =>
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)"); sessionIds.Contains(link.ExamSessionId))));
var linkedSessionIds = targetRooms
.SelectMany(x => x.SessionLinks)
.Select(x => x.ExamSessionId)
.ToHashSet();
foreach (var session in sessions.Where(x =>
!linkedSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue))
{
var legacyRoom = CreateLegacyRoom(
plan,
session,
roster.Where(x =>
x.TeachingTaskId == session.TeachingTaskId));
db.ExamRooms.Add(legacyRoom);
targetRooms.Add(legacyRoom);
session.ClassroomId = null;
if (session.Invigilators.Count > 0)
{
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators.Clear();
} }
} }
// ── Auto-assign invigilators ── seatedStudents = targetRooms.Sum(x => x.Seats.Count);
var currentInvigilatorCount = session.Invigilators.Count; }
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (assignInvigilators && needed > 0) var assignedInvigilators = 0;
var unavailableInvigilators = 0;
if (assignInvigilators)
{ {
var courseTeacherIds = session.TeachingTask!.Teachers if (targetRooms.Count == 0)
.Select(x => x.TeacherId).ToHashSet(); return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
var newlyAssigned = await FindInvigilatorsAsync(
session, needed, courseTeacherIds, var occupiedInvigilators = plan.Rooms
occupiedInvigilators, cancellationToken); .Where(x => !replacedRoomIds.Contains(x.Id) &&
foreach (var teacher in newlyAssigned) !targetRooms.Any(target => target.Id == x.Id))
.SelectMany(room => room.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
room.StartsAt,
room.EndsAt)))
.Concat(plan.Sessions
.Where(x => !sessionIds.Contains(x.Id))
.SelectMany(session => session.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
session.StartsAt,
session.EndsAt))))
.ToList();
foreach (var room in targetRooms
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.ClassroomId))
{ {
session.Invigilators.Add(new ExamSessionInvigilator var needed = room.RequiredInvigilatorCount -
room.Invigilators.Count;
if (needed <= 0) continue;
var linkedSessionIds = room.SessionLinks
.Select(x => x.ExamSessionId)
.ToHashSet();
var excludedTeacherIds = sessions
.Where(x => linkedSessionIds.Contains(x.Id))
.SelectMany(x => x.TeachingTask!.Teachers)
.Select(x => x.TeacherId)
.ToHashSet();
var teachers = await FindInvigilatorsAsync(
room,
needed,
excludedTeacherIds,
occupiedInvigilators,
cancellationToken);
foreach (var teacher in teachers)
{
room.Invigilators.Add(new ExamRoomInvigilator
{ {
ExamSessionId = session.Id,
TeacherId = teacher.Id TeacherId = teacher.Id
}); });
occupiedInvigilators.Add(new InvigilatorOccupancy( occupiedInvigilators.Add(new InvigilatorOccupancy(
teacher.Id, session.StartsAt, session.EndsAt)); teacher.Id,
room.StartsAt,
room.EndsAt));
assignedInvigilators++; assignedInvigilators++;
} }
if (newlyAssigned.Count < needed) if (teachers.Count < needed)
{ unavailableInvigilators += needed - teachers.Count;
unavailableInvigilators += needed - newlyAssigned.Count;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
} }
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
var expandedCount = sessions.Count - explicitlySelected.Count;
var detail = messages.Count > 0
? $" 详情:{string.Join("", messages.Take(10))}"
: "";
return new ExamArrangementResult( return new ExamArrangementResult(
true, true,
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" + $"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") + $"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") + (expandedCount > 0
(messages.Count > 0 ? $" 详情:{string.Join("", messages.Take(10))}" : "")); ? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。"
: "") +
(unavailableStudents > 0
? $" {unavailableStudents}名考生尚未安排考场。"
: "") +
(unavailableInvigilators > 0
? $" 仍缺{unavailableInvigilators}名监考教师。"
: "") +
detail);
}
private async Task<List<RoomOccupancy>> LoadOccupiedRoomsAsync(
ExamPlan plan,
IReadOnlySet<Guid> targetSessionIds,
IReadOnlySet<Guid> replacedRoomIds,
CancellationToken cancellationToken)
{
var occupied = plan.Rooms
.Where(x => !replacedRoomIds.Contains(x.Id))
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToList();
occupied.AddRange(plan.Sessions
.Where(x =>
!targetSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt)));
var externalRooms = await db.ExamRooms.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived)
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalRooms);
var externalLegacyRooms = await db.ExamSessions.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ClassroomId != null)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalLegacyRooms);
return occupied;
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamRoomAssignment room,
int needed,
HashSet<Guid> excludedTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt,
x.EndsAt,
room.StartsAt,
room.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking()
.Where(x =>
x.ExamRoomId != room.Id &&
x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamRoom.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamRoom.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in databaseBusyIds)
busyTeacherIds.Add(teacherId);
var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x =>
x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamSession.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in legacyBusyIds)
busyTeacherIds.Add(teacherId);
foreach (var teacherId in excludedTeacherIds)
busyTeacherIds.Add(teacherId);
var candidates = await InvigilatorCandidateQuery
.Create(db, busyTeacherIds)
.ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
}
private static List<Classroom> SelectRooms(
IReadOnlyCollection<Classroom> availableRooms,
int candidateCount)
{
var remainingRooms = availableRooms.ToList();
var selected = new List<Classroom>();
var remainingSeats = candidateCount;
while (remainingSeats > 0 && remainingRooms.Count > 0)
{
var room = remainingRooms
.Where(x => x.Capacity / 2 >= remainingSeats)
.OrderBy(x => x.Capacity / 2)
.ThenBy(x => x.Name)
.FirstOrDefault()
?? remainingRooms
.OrderByDescending(x => x.Capacity / 2)
.ThenBy(x => x.Name)
.First();
selected.Add(room);
remainingRooms.Remove(room);
remainingSeats -= room.Capacity / 2;
}
return selected;
}
private static List<CandidateSeat> InterleaveCandidates(
IReadOnlyCollection<ExamSession> sessions,
IReadOnlyCollection<TeachingTaskRosterEntry> roster,
IReadOnlyDictionary<Guid, ExamSession> sessionByTaskId)
{
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var queues = roster
.Where(x =>
sessionByTaskId.TryGetValue(
x.TeachingTaskId,
out var session) &&
sessionIds.Contains(session.Id))
.GroupBy(x => x.TeachingTaskId)
.OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber)
.Select(group => new Queue<CandidateSeat>(
group.OrderBy(x => x.StudentNumber)
.Select(x => new CandidateSeat(
sessionByTaskId[x.TeachingTaskId].Id,
x.StudentId,
x.StudentNumber))))
.ToList();
var result = new List<CandidateSeat>();
var assignedStudentIds = new HashSet<Guid>();
while (queues.Any(x => x.Count > 0))
{
foreach (var queue in queues)
{
while (queue.Count > 0)
{
var candidate = queue.Dequeue();
if (!assignedStudentIds.Add(candidate.StudentId))
continue;
result.Add(candidate);
break;
}
}
}
return result;
}
private static ExamRoomAssignment CreateLegacyRoom(
ExamPlan plan,
ExamSession session,
IEnumerable<TeachingTaskRosterEntry> roster)
{
var students = roster
.OrderBy(x => x.StudentNumber)
.ToList();
return new ExamRoomAssignment
{
ExamPlanId = plan.Id,
CourseId = session.TeachingTask!.CourseId,
ClassroomId = session.ClassroomId!.Value,
ExamDate = session.ExamDate,
StartPeriod = session.StartPeriod,
PeriodCount = session.PeriodCount,
StartsAt = session.StartsAt,
EndsAt = session.EndsAt,
RequiredInvigilatorCount = session.RequiredInvigilatorCount,
SessionLinks =
[
new ExamRoomSession
{
ExamSessionId = session.Id
}
],
Seats = students.Select((student, index) =>
new ExamSeatAssignment
{
ExamSessionId = session.Id,
StudentId = student.StudentId,
SeatNumber = index + 1
}).ToList(),
Invigilators = session.Invigilators.Select(x =>
new ExamRoomInvigilator
{
TeacherId = x.TeacherId
}).ToList()
};
}
private static RoomGroupKey GroupKey(ExamSession session)
{
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
if (session.RequiredBuildingId.HasValue)
buildingIds.Add(session.RequiredBuildingId.Value);
var key = buildingIds.Count == 0
? string.Empty
: string.Join(",", buildingIds.OrderBy(x => x));
return new(
session.TeachingTask!.CourseId,
session.ExamDate,
session.StartPeriod,
session.PeriodCount,
key);
}
private static HashSet<Guid> ParseBuildingIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
}
catch
{
return [];
}
} }
private static void ComputeTimesFromSlots( private static void ComputeTimesFromSlots(
ExamSession session, ExamSession session,
Dictionary<int, ScheduleTimeSlot> timeSlotLookup) IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
{ {
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod); var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
var endSlot = timeSlotLookup.GetValueOrDefault( var endSlot = timeSlotLookup.GetValueOrDefault(
session.StartPeriod + session.PeriodCount - 1); session.StartPeriod + session.PeriodCount - 1);
if (startSlot is null || endSlot is null) return; if (startSlot is null || endSlot is null) return;
var examDate = session.ExamDate; session.StartsAt = session.ExamDate.ToDateTime(
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc); startSlot.StartsAt,
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc); DateTimeKind.Utc);
} session.EndsAt = session.ExamDate.ToDateTime(
endSlot.EndsAt,
private async Task<Classroom?> FindBestClassroomAsync( DateTimeKind.Utc);
ExamSession session,
int studentCount,
List<RoomOccupancy> occupied,
CancellationToken cancellationToken)
{
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
// Exclude classrooms already occupied in-memory
var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.ClassroomId)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
// Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == session.ExamPlanId &&
x.Id != session.Id &&
x.ClassroomId != null &&
x.StartsAt < session.EndsAt &&
session.StartsAt < x.EndsAt)
.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query
.OrderBy(x => x.Capacity)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamSession session,
int needed,
HashSet<Guid> excludeTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
x.ExamSessionId != session.Id &&
x.ExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken);
} }
} }
public sealed record ExamArrangementResult(bool Success, string Message) public sealed record ExamArrangementResult(bool Success, string Message)
{ {
public static ExamArrangementResult Fail(string message) => new(false, message); public static ExamArrangementResult Fail(string message) =>
new(false, message);
} }
@@ -0,0 +1,270 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamPublishJobProcessor(
AppDbContext db,
IAppCache cache,
ILogger<ExamPublishJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamPublishJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamPublishJobStatus.Succeeded
or ExamPublishJobStatus.Failed)
{
return;
}
job.Status = ExamPublishJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.CurrentStep = "正在校验考试计划";
await db.SaveChangesAsync(stoppingToken);
switch (job.Kind)
{
case ExamPublishJobKind.FormalExam:
await PublishFormalExamAsync(job, stoppingToken);
break;
case ExamPublishJobKind.MakeupExam:
await PublishMakeupExamAsync(job, stoppingToken);
break;
default:
throw new InvalidOperationException(
$"不支持的考试发布类型:{job.Kind}。");
}
job.Status = ExamPublishJobStatus.Succeeded;
job.ActivePlanId = null;
job.CurrentStep = "发布完成";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
stoppingToken);
logger.LogInformation(
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
job.Id,
job.Kind,
job.PlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Exam publish job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (ExamPublishValidationException validationException)
{
logger.LogWarning(
validationException,
"Exam publish job {JobId} validation failed.",
jobId);
await MarkFailedAsync(jobId, validationException.Message);
}
catch (Exception exception)
{
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private async Task PublishFormalExamAsync(
ExamPublishJob job,
CancellationToken ct)
{
var plan = await db.ExamPlans
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
if (plan is null)
throw new ExamPublishValidationException("考试计划不存在。");
if (plan.Status != ExamPlanStatus.Draft)
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
// Step 1: check sessions exist — simple count, no JOIN
var sessionCount = await db.ExamSessions
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
if (sessionCount == 0)
throw new ExamPublishValidationException(
"至少安排一个考试场次后才能发布。");
// Step 2: load session summaries — only necessary columns, no Include chains
var sessions = await db.ExamSessions
.AsNoTracking()
.Where(x => x.ExamPlanId == job.PlanId)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.ClassroomId,
InvigilatorCount = x.Invigilators.Count,
RoomLinkCount = x.RoomLinks.Count
})
.ToListAsync(ct);
// Step 3: load roster counts — independent query
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
var rosterCounts = (await TeachingTaskRosterQuery
.LoadForTasksAsync(db, taskIds, ct))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
// Step 4: validate each session's assignment completeness
var unassignedSessionIds = new List<Guid>();
foreach (var session in sessions)
{
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
if (session.RoomLinkCount > 0)
{
// Has room links — check seat assignment via separate query
var assignedSeatCount = await db.ExamSeats
.CountAsync(seat =>
seat.ExamSessionId == session.Id,
ct);
if (assignedSeatCount != rosterCount)
unassignedSessionIds.Add(session.Id);
}
else if (!session.ClassroomId.HasValue ||
session.InvigilatorCount == 0)
{
unassignedSessionIds.Add(session.Id);
}
}
if (unassignedSessionIds.Count > 0)
throw new ExamPublishValidationException(
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
"请先完成自动编排。");
// Step 5: check room invigilator sufficiency — separate query
var insufficientInvigilatorCount = await db.ExamRoomSessions
.Where(link =>
link.ExamRoom!.ExamPlanId == job.PlanId &&
link.ExamRoom.Invigilators.Count <
link.ExamRoom.RequiredInvigilatorCount)
.Select(link => link.ExamSessionId)
.Distinct()
.CountAsync(ct);
if (insufficientInvigilatorCount > 0)
throw new ExamPublishValidationException(
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
"请先完成自动编排。");
// Step 6: validate mixed rooms — split into two independent queries
// to avoid Seats × SessionLinks Cartesian product
// 6a: capacity overflow
var overCapacityCount = await db.ExamRooms
.Where(r => r.ExamPlanId == job.PlanId)
.Select(r => new
{
r.Id,
SeatCount = r.Seats.Count,
Capacity = r.Classroom!.Capacity
})
.CountAsync(x => x.SeatCount > x.Capacity, ct);
if (overCapacityCount > 0)
throw new ExamPublishValidationException(
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
// 6b: course mismatch
var courseMismatchCount = await db.ExamRoomSessions
.Where(link =>
link.ExamRoom!.ExamPlanId == job.PlanId &&
link.ExamSession!.TeachingTask!.CourseId !=
link.ExamRoom.CourseId)
.Select(link => link.ExamRoomId)
.Distinct()
.CountAsync(ct);
if (courseMismatchCount > 0)
throw new ExamPublishValidationException(
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
// Step 7: publish
plan.Status = ExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
private async Task PublishMakeupExamAsync(
ExamPublishJob job,
CancellationToken ct)
{
var plan = await db.MakeupExamPlans
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
if (plan is null)
throw new ExamPublishValidationException("补考计划不存在。");
if (plan.Status != MakeupExamPlanStatus.Draft)
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
// Step 1: check sessions exist — simple count
var sessionCount = await db.MakeupExamSessions
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
if (sessionCount == 0)
throw new ExamPublishValidationException(
"至少安排一个考试场次后才能发布。");
// Step 2: load session summaries — only necessary columns
var sessions = await db.MakeupExamSessions
.AsNoTracking()
.Where(x => x.MakeupExamPlanId == job.PlanId)
.Select(x => new
{
x.Id,
x.ClassroomId,
InvigilatorCount = x.Invigilators.Count,
EnrollmentCount = x.Enrollments.Count
})
.ToListAsync(ct);
// Step 3: validate completeness — no Include chain needed
var unassigned = sessions.Count(x =>
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
if (unassigned > 0)
throw new ExamPublishValidationException(
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
// Step 4: validate enrollments
var empty = sessions.Count(x => x.EnrollmentCount == 0);
if (empty > 0)
throw new ExamPublishValidationException(
$"还有 {empty} 个场次没有登记补考学生。");
// Step 5: publish
plan.Status = MakeupExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamPublishJobStatus.Failed;
job.ActivePlanId = null;
job.CurrentStep = "发布失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
public sealed class ExamPublishValidationException(string message)
: InvalidOperationException(message);
@@ -0,0 +1,212 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamSignInExportJobProcessor(
AppDbContext db,
ILogger<ExamSignInExportJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamSignInExportJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamSignInExportJobStatus.Succeeded
or ExamSignInExportJobStatus.Failed)
{
return;
}
job.Status = ExamSignInExportJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.CurrentStep = "正在生成考场签名单";
await db.SaveChangesAsync(stoppingToken);
var plan = await db.ExamPlans.AsNoTracking()
.Where(x => x.Id == job.PlanId)
.Select(x => new
{
x.Name,
TermName = x.AcademicTerm!.Name
})
.FirstOrDefaultAsync(stoppingToken);
if (plan is null)
{
await MarkFailedAsync(jobId, "考试计划不存在。");
return;
}
var rooms = await db.ExamRooms.AsNoTracking()
.Include(x => x.Course)
.Include(x => x.Classroom)
.ThenInclude(x => x!.Building)
.Include(x => x.Invigilators)
.ThenInclude(x => x.Teacher)
.Include(x => x.SessionLinks)
.ThenInclude(x => x.ExamSession)
.ThenInclude(x => x!.TeachingTask)
.Include(x => x.Seats)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Where(x => x.ExamPlanId == job.PlanId)
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.Classroom!.Building!.Name)
.ThenBy(x => x.Classroom!.Name)
.AsSplitQuery()
.ToListAsync(stoppingToken);
var legacySessions = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == job.PlanId)
.Where(x => !x.RoomLinks.Any())
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.Select(session => new
{
session.Id,
session.TeachingTaskId,
session.ExamDate,
session.StartsAt,
session.EndsAt,
CourseCode = session.TeachingTask!.Course!.Code,
CourseName = session.TeachingTask.Course.Name,
session.TeachingTask.TaskNumber,
BuildingName = session.Classroom != null
? session.Classroom.Building!.Name
: null,
ClassroomName = session.Classroom != null
? session.Classroom.Name
: null,
InvigilatorNames = session.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
})
.ToListAsync(stoppingToken);
if (rooms.Count == 0 && legacySessions.Count == 0)
{
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
return;
}
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
legacySessions.Select(x => x.TeachingTaskId),
stoppingToken);
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
var sheets = rooms.Select(room => new ExamSignInSessionData(
room.Id,
room.ExamDate,
room.StartsAt,
room.EndsAt,
room.Course!.Code,
room.Course.Name,
string.Join(
"、",
room.SessionLinks
.Select(link =>
link.ExamSession!.TeachingTask!.TaskNumber)
.Distinct()
.OrderBy(x => x)),
room.Classroom!.Building!.Name,
room.Classroom.Name,
room.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
.ToList(),
room.Seats
.OrderBy(seat => seat.SeatNumber)
.Select(seat => new ExamSignInStudentData(
seat.StudentId,
seat.Student!.StudentNumber,
seat.Student.Name,
seat.Student.AdministrativeClass!.Name,
seat.SeatNumber))
.ToList()))
.Concat(legacySessions.Select(session =>
new ExamSignInSessionData(
session.Id,
session.ExamDate,
session.StartsAt,
session.EndsAt,
session.CourseCode,
session.CourseName,
session.TaskNumber,
session.BuildingName,
session.ClassroomName,
session.InvigilatorNames.ToList(),
studentsByTask[session.TeachingTaskId]
.Select((student, index) =>
new ExamSignInStudentData(
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName,
index + 1))
.ToList())))
.ToList();
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
var bytes = ExamSignInWorkbookExporter.Create(data);
job.Status = ExamSignInExportJobStatus.Succeeded;
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
job.FileBytes = bytes;
job.FileSize = bytes.Length;
job.CurrentStep = "生成完成";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
logger.LogInformation(
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
job.Id,
job.PlanId,
bytes.Length);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Sign-in export job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamSignInExportJobStatus.Failed;
job.CurrentStep = "生成失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
private static string SanitizeFileName(string value)
{
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
var normalized = new string(value
.Select(c => invalid.Contains(c) ? '_' : c)
.ToArray()).Trim();
return normalized.Length > 100 ? normalized[..100] : normalized;
}
}
@@ -0,0 +1,305 @@
using ClosedXML.Excel;
namespace Jiaowu.Api.Infrastructure.Exams;
public static class ExamSignInWorkbookExporter
{
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
public static byte[] Create(ExamSignInWorkbookData data)
{
using var workbook = new XLWorkbook();
AddSummarySheet(workbook, data);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"考场汇总"
};
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName)
.ThenBy(x => x.CourseCode))
{
AddSignInSheet(
workbook,
data,
session,
UniqueSheetName(session, usedNames));
}
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
private static void AddSummarySheet(
XLWorkbook workbook,
ExamSignInWorkbookData data)
{
var sheet = workbook.Worksheets.Add("考场汇总");
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Row(1).Height = 34;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value =
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Row(2).Height = 24;
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(4, column).Value = headers[column - 1];
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
var row = 5;
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName))
{
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Cell(row, 3).Value = Location(session);
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
sheet.Cell(row, 5).Value = session.TaskNumber;
sheet.Cell(row, 6).Value = session.Students.Count;
sheet.Cell(row, 7).Value = Invigilators(session);
row++;
}
if (row > 5)
{
var table = sheet.Range(4, 1, row - 1, headers.Length);
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
table.Style.Border.InsideBorderColor = Rule;
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
table.Style.Border.OutsideBorderColor = Rule;
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Rows(5, row - 1).Height = 25;
}
sheet.Column(1).Width = 13;
sheet.Column(2).Width = 15;
sheet.Column(3).Width = 24;
sheet.Column(4).Width = 30;
sheet.Column(5).Width = 22;
sheet.Column(6).Width = 11;
sheet.Column(7).Width = 24;
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
sheet.SheetView.FreezeRows(4);
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
}
private static void AddSignInSheet(
XLWorkbook workbook,
ExamSignInWorkbookData data,
ExamSignInSessionData session,
string sheetName)
{
var sheet = workbook.Worksheets.Add(sheetName);
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = "考试考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Cell("A1").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(1).Height = 36;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Cell("A2").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(2).Height = 23;
sheet.Range("A3:D3").Merge();
sheet.Cell("A3").Value =
$"课程:{session.CourseCode} {session.CourseName}{session.TaskNumber}";
sheet.Range("E3:G3").Merge();
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
sheet.Range("A4:D4").Merge();
sheet.Cell("A4").Value =
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Range("E4:G4").Merge();
sheet.Cell("E4").Value = $"考场:{Location(session)}";
sheet.Range("A5:D5").Merge();
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
sheet.Range("E5:G5").Merge();
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
var metadata = sheet.Range("A3:G5");
metadata.Style.Fill.BackgroundColor = XLColor.White;
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
metadata.Style.Alignment.WrapText = true;
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
metadata.Style.Border.BottomBorderColor = Rule;
sheet.Rows(3, 5).Height = 24;
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(7, column).Value = headers[column - 1];
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
sheet.Row(7).Height = 27;
var row = 8;
foreach (var student in session.Students
.OrderBy(x => x.SeatNumber)
.ThenBy(x => x.StudentNumber))
{
var index = row - 7;
sheet.Cell(row, 1).Value = index;
sheet.Cell(row, 2).Value =
(student.SeatNumber > 0 ? student.SeatNumber : index)
.ToString("D3");
sheet.Cell(row, 3).Value = student.StudentNumber;
sheet.Cell(row, 4).Value = student.Name;
sheet.Cell(row, 5).Value = student.ClassName;
sheet.Row(row).Height = 28;
row++;
}
if (session.Students.Count == 0)
{
sheet.Range("A8:G8").Merge();
sheet.Cell("A8").Value = "本场次暂无考生";
sheet.Cell("A8").Style
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
sheet.Row(8).Height = 28;
row = 9;
}
var roster = sheet.Range(7, 1, row - 1, headers.Length);
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
roster.Style.Border.InsideBorderColor = Rule;
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
sheet.Column(1).Width = 7;
sheet.Column(2).Width = 10;
sheet.Column(3).Width = 16;
sheet.Column(4).Width = 12;
sheet.Column(5).Width = 20;
sheet.Column(6).Width = 22;
sheet.Column(7).Width = 17;
sheet.SheetView.FreezeRows(7);
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
}
private static void StyleTitle(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontSize(18)
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Navy)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleSubTitle(IXLRange range) =>
range.Style
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(Pale)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleHeader(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Teal)
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static string Location(ExamSignInSessionData session) =>
string.Join(" · ", new[]
{
session.BuildingName,
session.ClassroomName
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
? value
: "待分配考场";
private static string Invigilators(ExamSignInSessionData session) =>
session.InvigilatorNames.Count > 0
? string.Join('、', session.InvigilatorNames)
: "待分配";
private static string UniqueSheetName(
ExamSignInSessionData session,
ISet<string> usedNames)
{
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
? "待分配"
: session.ClassroomName;
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
var invalidCharacters = new HashSet<char>(":\\/?*[]");
var sanitized = new string(raw
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
.ToArray()).Trim(' ', '\'');
if (sanitized.Length == 0) sanitized = "考场";
if (sanitized.Length > 31) sanitized = sanitized[..31];
var candidate = sanitized;
var suffix = 2;
while (!usedNames.Add(candidate))
{
var marker = $"-{suffix++}";
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
candidate = sanitized[..prefixLength] + marker;
}
return candidate;
}
}
public sealed record ExamSignInWorkbookData(
string PlanName,
string TermName,
IReadOnlyList<ExamSignInSessionData> Sessions);
public sealed record ExamSignInSessionData(
Guid SessionId,
DateOnly ExamDate,
DateTime StartsAt,
DateTime EndsAt,
string CourseCode,
string CourseName,
string TaskNumber,
string? BuildingName,
string? ClassroomName,
IReadOnlyList<string> InvigilatorNames,
IReadOnlyList<ExamSignInStudentData> Students);
public sealed record ExamSignInStudentData(
Guid StudentId,
string StudentNumber,
string Name,
string ClassName,
int SeatNumber = 0);
@@ -0,0 +1,15 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
internal static class InvigilatorCandidateQuery
{
public static IQueryable<Teacher> Create(
AppDbContext db,
IEnumerable<Guid> excludedTeacherIds) =>
db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(excludedTeacherIds, x => x.Id);
}
@@ -29,6 +29,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask) .ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course) .ThenInclude(x => x!.Course)
.AsSplitQuery()
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null) if (plan is null)
@@ -99,7 +100,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt)); occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++; assignedRooms++;
messages.Add( messages.Add(
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)"); $"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
} }
else else
{ {
@@ -170,10 +171,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var query = db.Classrooms.AsNoTracking() var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount); .Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
if (session.RequiredBuildingId.HasValue) if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value); buildingIds.Add(session.RequiredBuildingId.Value);
if (buildingIds.Count > 0)
query = query.Where(x => buildingIds.Contains(x.BuildingId));
var occupiedRoomIds = occupied var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps( .Where(x => ExamConflictRules.TimeOverlaps(
@@ -225,11 +229,27 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
foreach (var id in dbBusyIds) busyTeacherIds.Add(id); foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking() var candidates = await InvigilatorCandidateQuery
.Where(x => x.Status == TeacherStatus.Active) .Create(db, busyTeacherIds)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
}
private static HashSet<Guid> ParseBuildingIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
}
catch
{
return [];
}
} }
} }
@@ -0,0 +1,59 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Experiments;
internal static class CentralizedExperimentConflictQuery
{
public static IQueryable<Guid> TaskIdsForTeachers(
AppDbContext db,
IReadOnlyCollection<Guid> teacherIds) =>
db.TeachingTaskTeachers.AsNoTracking()
.WhereIn(teacherIds, x => x.TeacherId)
.Select(x => x.TeachingTaskId);
public static IQueryable<Guid> TaskIdsForClasses(
AppDbContext db,
IReadOnlyCollection<Guid> classIds) =>
db.TeachingTaskClasses.AsNoTracking()
.WhereIn(classIds, x => x.AdministrativeClassId)
.Select(x => x.TeachingTaskId);
public static IQueryable<ScheduleEntry> ScheduleEntries(
AppDbContext db,
IReadOnlyCollection<Guid> relatedTaskIds,
Guid academicTermId,
int dayOfWeek,
int week,
int startPeriod,
int periodCount) =>
db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.SchedulePlan.AcademicTermId == academicTermId &&
x.DayOfWeek == dayOfWeek &&
x.StartWeek <= week &&
x.EndWeek >= week &&
x.StartPeriod < startPeriod + periodCount &&
startPeriod < x.StartPeriod + x.PeriodCount)
.WhereIn(relatedTaskIds, x => x.TeachingTaskId);
public static IQueryable<ExperimentSession> ExperimentSessions(
AppDbContext db,
IReadOnlyCollection<Guid> relatedTaskIds,
DateOnly date,
int startPeriod,
int periodCount) =>
db.ExperimentSessions.AsNoTracking()
.Where(x =>
x.Status == ExperimentSessionStatus.Scheduled &&
x.SessionDate == date &&
x.StartPeriod < startPeriod + periodCount &&
startPeriod < x.StartPeriod + x.PeriodCount &&
x.ExperimentProject!.ArrangementMode ==
ExperimentArrangementMode.Centralized)
.WhereIn(
relatedTaskIds,
x => x.ExperimentProject!.TeachingTaskId);
}
@@ -0,0 +1,88 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService
{
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db,
Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken)
{
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
.Where(x =>
x.Status ==
Domain.Academic.ExperimentGradeSheetStatus.Published &&
x.ExperimentProject!.TeachingTaskId == teachingTaskId)
.OrderBy(x => x.ExperimentProject!.Code)
.Select(x => new PublishedExperimentSheet(
x.Id,
x.ExperimentProject!.Code,
x.ExperimentProject.Name,
x.ContributionWeight,
x.Records.Select(record => new PublishedExperimentScore(
record.StudentId,
record.TotalScore)).ToList()))
.AsSplitQuery()
.ToListAsync(cancellationToken);
var scores = new Dictionary<Guid, decimal?>();
foreach (var studentId in studentIds.Distinct())
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
var complete = sheets.Count > 0;
foreach (var sheet in sheets)
{
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore)
{
complete = false;
break;
}
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
scores[studentId] = complete && totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
}
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private sealed record PublishedExperimentSheet(
Guid Id,
string Code,
string Name,
decimal ContributionWeight,
List<PublishedExperimentScore> Scores);
private sealed record PublishedExperimentScore(
Guid StudentId,
decimal? TotalScore);
}
public sealed record ExperimentGradeAggregateResult(
int PublishedProjectCount,
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
IReadOnlyDictionary<Guid, decimal?> Scores);
public sealed record ExperimentGradeAggregateProject(
Guid Id,
string Code,
string Name,
decimal ContributionWeight);
@@ -0,0 +1,45 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeCalculator
{
public static bool AreWeightsValid(
decimal contributionWeight,
decimal passScore,
IEnumerable<ExperimentGradeItem> items)
{
var itemList = items.ToList();
return contributionWeight is > 0 and <= 100 &&
passScore is >= 0 and <= 100 &&
itemList.Count > 0 &&
itemList.All(item => item.Weight is > 0 and <= 100) &&
itemList.Sum(item => item.Weight) == 100;
}
public static decimal? CalculateTotal(
ExperimentGradeRecord record,
IReadOnlyCollection<ExperimentGradeItem> items)
{
if (record.SafetyViolation ||
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
return 0;
if (record.ParticipationStatus is
ExperimentParticipationStatus.Pending or
ExperimentParticipationStatus.Excused or
ExperimentParticipationStatus.Exempt)
return null;
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
decimal total = 0;
foreach (var item in items)
{
if (!scores.TryGetValue(item.Id, out var score) ||
!score.Score.HasValue)
return null;
total += score.Score.Value * item.Weight / 100;
}
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
}
}
@@ -44,6 +44,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<SchedulePublishJob>(); Set<SchedulePublishJob>();
public DbSet<ClassroomReservation> ClassroomReservations => public DbSet<ClassroomReservation> ClassroomReservations =>
Set<ClassroomReservation>(); Set<ClassroomReservation>();
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
Set<ExperimentGradeSheet>();
public DbSet<ExperimentGradeItem> ExperimentGradeItems =>
Set<ExperimentGradeItem>();
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
Set<ExperimentGradeRecord>();
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>(); Set<CourseSelectionRound>();
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades => public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
@@ -57,10 +68,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>(); public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>(); public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>(); public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
Set<AttendanceCheckInAttempt>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>(); public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
Set<ExamArrangementJob>();
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
Set<ExamSignInExportJob>();
public DbSet<ExamPublishJob> ExamPublishJobs =>
Set<ExamPublishJob>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>(); public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators => public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>(); Set<ExamSessionInvigilator>();
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
Set<ExamRoomInvigilator>();
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>(); public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>(); public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators => public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
@@ -101,6 +125,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages => public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
Set<BackgroundJobOutboxMessage>(); Set<BackgroundJobOutboxMessage>();
public DbSet<AppUpdateRelease> AppUpdateReleases =>
Set<AppUpdateRelease>();
protected override void ConfigureConventions( protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder) ModelConfigurationBuilder configurationBuilder)
@@ -534,6 +560,133 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
}); });
builder.Entity<ExperimentProject>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(40);
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.Property(x => x.Requirements).HasMaxLength(1000);
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentSession>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.ExperimentProjectId,
x.SessionDate,
x.StartPeriod
});
entity.HasIndex(x => new
{
x.ClassroomId,
x.SessionDate,
x.Status,
x.StartPeriod
});
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentBooking>(entity =>
{
entity.HasIndex(x => new { x.ExperimentProjectId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new
{
x.ExperimentSessionId,
x.Status,
x.BookedAt
});
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Bookings)
.HasForeignKey(x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Bookings)
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeSheet>(entity =>
{
entity.Property(x => x.ContributionWeight).HasPrecision(5, 1);
entity.Property(x => x.PassScore).HasPrecision(5, 1);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => x.ExperimentProjectId).IsUnique();
entity.HasOne(x => x.ExperimentProject)
.WithOne(x => x.GradeSheet)
.HasForeignKey<ExperimentGradeSheet>(
x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeItem>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.SortOrder
});
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Items)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeRecord>(entity =>
{
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
entity.Property(x => x.SubmissionReference).HasMaxLength(500);
entity.Property(x => x.TeacherComment).HasMaxLength(500);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.StudentId
}).IsUnique();
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Records)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentSession).WithMany()
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity =>
{
entity.HasKey(x => new
{
x.ExperimentGradeRecordId,
x.ExperimentGradeItemId
});
entity.Property(x => x.Score).HasPrecision(5, 1);
entity.Property(x => x.Comment).HasMaxLength(300);
entity.HasOne(x => x.ExperimentGradeRecord)
.WithMany(x => x.ItemScores)
.HasForeignKey(x => x.ExperimentGradeRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentGradeItem)
.WithMany(x => x.Scores)
.HasForeignKey(x => x.ExperimentGradeItemId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseSelectionRound>(entity => builder.Entity<CourseSelectionRound>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -613,6 +766,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{ {
entity.Property(x => x.Name).HasMaxLength(60); entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1); entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.Property(x => x.SourceType)
.HasDefaultValue(GradeItemSourceType.Manual);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder }); entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet) entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items) .WithMany(x => x.Items)
@@ -687,6 +842,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<AttendanceCheckInAttempt>(entity =>
{
entity.Property(x => x.FailureCode).HasMaxLength(64);
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.Property(x => x.UserAgent).HasMaxLength(500);
entity.Property(x => x.RiskFlags).HasMaxLength(300);
entity.Property(x => x.Latitude).HasPrecision(10, 7);
entity.Property(x => x.Longitude).HasPrecision(10, 7);
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.CheckInAttempts)
.HasForeignKey(x => x.AttendanceSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamPlan>(entity => builder.Entity<ExamPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -702,6 +880,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate }); entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId); entity.HasIndex(x => x.RequiredBuildingId);
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions) entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany() entity.HasOne(x => x.TeachingTask).WithMany()
@@ -719,6 +898,87 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany() entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExamRoomAssignment>(entity =>
{
entity.ToTable("ExamRooms");
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
.HasDatabaseName("IX_ExamRooms_Plan_Time");
entity.HasIndex(x => new
{
x.ExamPlanId,
x.ClassroomId,
x.StartsAt
})
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
entity.HasIndex(x => x.CourseId)
.HasDatabaseName("IX_ExamRooms_CourseId");
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course).WithMany()
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomSession>(entity =>
{
entity.ToTable("ExamRoomSessions");
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
entity.HasIndex(x => x.ExamSessionId)
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSeatAssignment>(entity =>
{
entity.ToTable("ExamSeats");
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
.IsUnique()
.HasDatabaseName("UX_ExamSeats_Session_Student");
entity.HasIndex(x => x.StudentId)
.HasDatabaseName("IX_ExamSeats_StudentId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomInvigilator>(entity =>
{
entity.ToTable("ExamRoomInvigilators");
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
entity.HasIndex(x => x.TeacherId)
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamArrangementJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
.IsUnique()
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
});
builder.Entity<ExamSignInExportJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.FileName).HasMaxLength(200);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
});
builder.Entity<MakeupExamPlan>(entity => builder.Entity<MakeupExamPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -734,6 +994,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate }); entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId); entity.HasIndex(x => x.RequiredBuildingId);
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions) entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany() entity.HasOne(x => x.TeachingTask).WithMany()
@@ -1020,6 +1281,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.LeaseExpiresAt); entity.HasIndex(x => x.LeaseExpiresAt);
}); });
builder.Entity<AppUpdateRelease>(entity =>
{
entity.Property(x => x.Platform)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Channel)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Version).HasMaxLength(40);
entity.Property(x => x.NativeVersion).HasMaxLength(40);
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
entity.Property(x => x.FileName).HasMaxLength(180);
entity.Property(x => x.Sha256).HasMaxLength(64);
entity.Property(x => x.BundleContent).HasColumnType("longblob");
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Version
}).IsUnique();
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Status
});
entity.HasIndex(x => x.CreatedAt);
});
builder.Entity<OfficialDocument>(entity => builder.Entity<OfficialDocument>(entity =>
{ {
entity.Property(x => x.DocumentNumber).HasMaxLength(50); entity.Property(x => x.DocumentNumber).HasMaxLength(50);
@@ -64,6 +64,18 @@ public sealed class DevelopmentSqliteMigrator(
"20260727_34_course_adjustment_occurrences"; "20260727_34_course_adjustment_occurrences";
private const string AcademicPlanningPrerequisitesMigration = private const string AcademicPlanningPrerequisitesMigration =
"20260727_35_academic_planning_prerequisites"; "20260727_35_academic_planning_prerequisites";
private const string ExamRoomMixingMigration =
"20260727_36_exam_room_mixing";
private const string AttendanceCheckInAuditMigration =
"20260728_37_attendance_check_in_audit";
private const string ExamPublishJobsMigration =
"20260728_38_exam_publish_jobs";
private const string ExperimentManagementMigration =
"20260728_39_experiment_management";
private const string ExperimentGradeManagementMigration =
"20260728_40_experiment_grade_management";
private const string AppUpdateReleasesMigration =
"20260729_41_app_update_releases";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -322,6 +334,18 @@ public sealed class DevelopmentSqliteMigrator(
AttendanceCheckInMigration, AttendanceCheckInMigration,
attendanceCheckInExists ? [] : AttendanceCheckInStatements, attendanceCheckInExists ? [] : AttendanceCheckInStatements,
cancellationToken); cancellationToken);
var attendanceCheckInAttemptsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'AttendanceCheckInAttempts'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AttendanceCheckInAuditMigration,
attendanceCheckInAttemptsExist ? [] : AttendanceCheckInAuditStatements,
cancellationToken);
var approvalTablesExist = await db.Database var approvalTablesExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'") .SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
@@ -363,6 +387,22 @@ public sealed class DevelopmentSqliteMigrator(
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements, makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
cancellationToken); cancellationToken);
var examArrangementJobsExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamArrangementJobs'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamArrangementJobsMigration,
examArrangementJobsExist ? [] : ExamArrangementJobStatements,
cancellationToken);
var exportJobsExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamSignInExportJobs'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamSignInExportJobsMigration,
exportJobsExist ? [] : ExamSignInExportJobStatements,
cancellationToken);
var academicTermArchivingExists = await db.Database var academicTermArchivingExists = await db.Database
.SqlQueryRaw<int>( .SqlQueryRaw<int>(
""" """
@@ -468,6 +508,75 @@ public sealed class DevelopmentSqliteMigrator(
AcademicPlanningPrerequisitesMigration, AcademicPlanningPrerequisitesMigration,
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements, coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
cancellationToken); cancellationToken);
var examRoomsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ExamRooms'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamRoomMixingMigration,
examRoomsExist ? [] : ExamRoomMixingStatements,
cancellationToken);
var examPublishJobsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ExamPublishJobs'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamPublishJobsMigration,
examPublishJobsExist ? [] : ExamPublishJobStatements,
cancellationToken);
var experimentProjectsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ExperimentProjects'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentManagementMigration,
experimentProjectsExist ? [] : ExperimentManagementStatements,
cancellationToken);
var gradeItemSourceExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('GradeItems')
WHERE name = 'SourceType'
""")
.AnyAsync(value => value > 0, cancellationToken);
var experimentGradeSheetsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ExperimentGradeSheets'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentGradeManagementMigration,
(gradeItemSourceExists
? []
: ExperimentGradeItemSourceStatements)
.Concat(experimentGradeSheetsExist
? []
: ExperimentGradeManagementStatements),
cancellationToken);
await ApplyMigrationAsync(
AppUpdateReleasesMigration,
AppUpdateReleasesStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -1643,6 +1752,9 @@ public sealed class DevelopmentSqliteMigrator(
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL; ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL;
""", """,
""" """
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingIds" TEXT NULL;
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2; ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2;
""", """,
""" """
@@ -1657,6 +1769,7 @@ public sealed class DevelopmentSqliteMigrator(
"StartsAt" TEXT NOT NULL, "StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL,
"RequiredBuildingId" TEXT NULL, "RequiredBuildingId" TEXT NULL,
"RequiredBuildingIds" TEXT NULL,
"RequiredInvigilatorCount" INTEGER NOT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL,
"Notes" TEXT NULL, "Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL,
@@ -1894,6 +2007,87 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;""" """ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
]; ];
private static readonly string[] AttendanceCheckInAuditStatements =
[
"""
CREATE TABLE IF NOT EXISTS "AttendanceCheckInAttempts" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceCheckInAttempts" PRIMARY KEY,
"AttendanceSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"CheckInMethod" INTEGER NOT NULL,
"IsSuccessful" INTEGER NOT NULL,
"FailureCode" TEXT NULL,
"DeviceIdentifierHash" TEXT NULL,
"DevicePlatform" TEXT NULL,
"IpAddress" TEXT NULL,
"UserAgent" TEXT NULL,
"RiskFlags" TEXT NULL,
"Latitude" TEXT NULL,
"Longitude" TEXT NULL,
"AccuracyMeters" REAL NULL,
"DistanceMeters" REAL NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId"
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_AttendanceCheckInAttempts_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_CreatedAt"
ON "AttendanceCheckInAttempts" ("AttendanceSheetId", "StudentId", "CreatedAt");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt"
ON "AttendanceCheckInAttempts" ("DeviceIdentifierHash", "CreatedAt");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt"
ON "AttendanceCheckInAttempts" ("IpAddress", "CreatedAt");
"""
];
private static readonly string[] AppUpdateReleasesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "AppUpdateReleases" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AppUpdateReleases" PRIMARY KEY,
"Platform" TEXT NOT NULL,
"Channel" TEXT NOT NULL,
"Version" TEXT NOT NULL,
"NativeVersion" TEXT NOT NULL,
"Status" TEXT NOT NULL,
"ReleaseNotes" TEXT NULL,
"FileName" TEXT NOT NULL,
"FileSize" INTEGER NOT NULL,
"Sha256" TEXT NOT NULL,
"BundleContent" BLOB NOT NULL,
"CreatedByUserName" TEXT NOT NULL,
"PublishedByUserName" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version"
ON "AppUpdateReleases"
("Platform", "Channel", "NativeVersion", "Version");
""",
"""
CREATE INDEX IF NOT EXISTS
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status"
ON "AppUpdateReleases"
("Platform", "Channel", "NativeVersion", "Status");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AppUpdateReleases_CreatedAt"
ON "AppUpdateReleases" ("CreatedAt");
"""
];
private static readonly string[] ApprovalTableStatements = 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);""", """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);""",
@@ -1952,7 +2146,7 @@ public sealed class DevelopmentSqliteMigrator(
[ [
"""CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""", """CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
"""CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""", """CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""",
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""", """CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredBuildingIds" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""", """CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""",
"""CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""", """CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""",
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""", """CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""",
@@ -1971,6 +2165,27 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""", """CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
]; ];
private const string ExamArrangementJobsMigration = "ExamArrangementJobs";
private static readonly string[] ExamArrangementJobStatements =
[
"""CREATE TABLE "ExamArrangementJobs" ("Id" TEXT NOT NULL, "Kind" INTEGER NOT NULL, "PlanId" TEXT NOT NULL, "ActivePlanId" TEXT NULL, "RequestedByUserId" TEXT NULL, "SessionIdsJson" TEXT NULL, "AssignClassrooms" INTEGER NOT NULL, "AssignInvigilators" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "TotalSessions" INTEGER NOT NULL, "ProcessedSessions" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ResultMessage" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamArrangementJobs" PRIMARY KEY ("Id"));""",
"""CREATE INDEX "IX_ExamArrangementJobs_Kind_Plan_CreatedAt" ON "ExamArrangementJobs" ("Kind", "PlanId", "CreatedAt");""",
"""CREATE INDEX "IX_ExamArrangementJobs_RequestedByUserId" ON "ExamArrangementJobs" ("RequestedByUserId");""",
"""CREATE INDEX "IX_ExamArrangementJobs_Status_CreatedAt" ON "ExamArrangementJobs" ("Status", "CreatedAt");""",
"""CREATE UNIQUE INDEX "UX_ExamArrangementJobs_Kind_ActivePlan" ON "ExamArrangementJobs" ("Kind", "ActivePlanId");""",
];
private const string ExamSignInExportJobsMigration = "ExamSignInExportJobs";
private static readonly string[] ExamSignInExportJobStatements =
[
"""CREATE TABLE "ExamSignInExportJobs" ("Id" TEXT NOT NULL, "PlanId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "RequestedByUserId" TEXT NULL, "FileName" TEXT NULL, "FileBytes" BLOB NULL, "FileSize" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamSignInExportJobs" PRIMARY KEY ("Id"));""",
"""CREATE INDEX "IX_ExamSignInExportJobs_PlanId_CreatedAt" ON "ExamSignInExportJobs" ("PlanId", "CreatedAt");""",
"""CREATE INDEX "IX_ExamSignInExportJobs_Status_CreatedAt" ON "ExamSignInExportJobs" ("Status", "CreatedAt");""",
"""CREATE INDEX "IX_ExamSignInExportJobs_RequestedByUserId" ON "ExamSignInExportJobs" ("RequestedByUserId");""",
];
private static readonly string[] UnifiedMessageCenterStatements = private static readonly string[] UnifiedMessageCenterStatements =
[ [
""" """
@@ -2132,4 +2347,371 @@ public sealed class DevelopmentSqliteMigrator(
ON "CoursePrerequisites" ("PrerequisiteCourseId"); ON "CoursePrerequisites" ("PrerequisiteCourseId");
""" """
]; ];
private static readonly string[] ExamRoomMixingStatements =
[
"""
CREATE TABLE "ExamRooms" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamRooms" PRIMARY KEY,
"ExamPlanId" TEXT NOT NULL,
"CourseId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
"ExamDate" TEXT NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"RequiredInvigilatorCount" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamRooms_ExamPlans_ExamPlanId"
FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamRooms_Courses_CourseId"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ExamRooms_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_ExamRooms_Plan_Time"
ON "ExamRooms" ("ExamPlanId", "StartsAt");
""",
"""
CREATE INDEX "IX_ExamRooms_Plan_Room_Time"
ON "ExamRooms" ("ExamPlanId", "ClassroomId", "StartsAt");
""",
"""
CREATE INDEX "IX_ExamRooms_CourseId"
ON "ExamRooms" ("CourseId");
""",
"""
CREATE INDEX "IX_ExamRooms_ClassroomId"
ON "ExamRooms" ("ClassroomId");
""",
"""
CREATE TABLE "ExamRoomSessions" (
"ExamRoomId" TEXT NOT NULL,
"ExamSessionId" TEXT NOT NULL,
CONSTRAINT "PK_ExamRoomSessions"
PRIMARY KEY ("ExamRoomId", "ExamSessionId"),
CONSTRAINT "FK_ExamRoomSessions_ExamRooms_ExamRoomId"
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamRoomSessions_ExamSessions_ExamSessionId"
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_ExamRoomSessions_SessionId"
ON "ExamRoomSessions" ("ExamSessionId");
""",
"""
CREATE TABLE "ExamSeats" (
"ExamRoomId" TEXT NOT NULL,
"ExamSessionId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"SeatNumber" INTEGER NOT NULL,
CONSTRAINT "PK_ExamSeats"
PRIMARY KEY ("ExamRoomId", "StudentId"),
CONSTRAINT "FK_ExamSeats_ExamRooms_ExamRoomId"
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamSeats_ExamSessions_ExamSessionId"
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ExamSeats_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "UX_ExamSeats_Session_Student"
ON "ExamSeats" ("ExamSessionId", "StudentId");
""",
"""
CREATE INDEX "IX_ExamSeats_StudentId"
ON "ExamSeats" ("StudentId");
""",
"""
CREATE TABLE "ExamRoomInvigilators" (
"ExamRoomId" TEXT NOT NULL,
"TeacherId" TEXT NOT NULL,
CONSTRAINT "PK_ExamRoomInvigilators"
PRIMARY KEY ("ExamRoomId", "TeacherId"),
CONSTRAINT "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId"
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamRoomInvigilators_Teachers_TeacherId"
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_ExamRoomInvigilators_TeacherId"
ON "ExamRoomInvigilators" ("TeacherId");
"""
];
private static readonly string[] ExamPublishJobStatements =
[
"""
CREATE TABLE "ExamPublishJobs" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamPublishJobs" PRIMARY KEY,
"Kind" INTEGER NOT NULL,
"PlanId" TEXT NOT NULL,
"ActivePlanId" TEXT NULL,
"RequestedByUserId" TEXT NULL,
"Status" INTEGER NOT NULL,
"CurrentStep" TEXT NULL,
"ErrorMessage" TEXT NULL,
"StartedAt" TEXT NULL,
"CompletedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
"""
];
private static readonly string[] ExperimentManagementStatements =
[
"""
CREATE TABLE "ExperimentProjects" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentProjects" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"Code" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"ArrangementMode" INTEGER NOT NULL,
"Description" TEXT NULL,
"Requirements" TEXT NULL,
"StartDate" TEXT NOT NULL,
"EndDate" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"PublishedAt" TEXT NULL,
"ClosedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentProjects_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_ExperimentProjects_TeachingTaskId_Code"
ON "ExperimentProjects" ("TeachingTaskId", "Code");
""",
"""
CREATE INDEX "IX_ExperimentProjects_Status_StartDate_EndDate"
ON "ExperimentProjects" ("Status", "StartDate", "EndDate");
""",
"""
CREATE TABLE "ExperimentSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessions" PRIMARY KEY,
"ExperimentProjectId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
"SessionDate" TEXT NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"Capacity" INTEGER NOT NULL,
"ReservedCount" INTEGER NOT NULL,
"Notes" TEXT NULL,
"Status" INTEGER NOT NULL,
"CancelledAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId"
FOREIGN KEY ("ExperimentProjectId")
REFERENCES "ExperimentProjects" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentSessions_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeriod"
ON "ExperimentSessions" (
"ExperimentProjectId",
"SessionDate",
"StartPeriod"
);
""",
"""
CREATE INDEX "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod"
ON "ExperimentSessions" (
"ClassroomId",
"SessionDate",
"Status",
"StartPeriod"
);
""",
"""
CREATE TABLE "ExperimentBookings" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentBookings" PRIMARY KEY,
"ExperimentProjectId" TEXT NOT NULL,
"ExperimentSessionId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"BookedAt" TEXT NOT NULL,
"CancelledAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId"
FOREIGN KEY ("ExperimentProjectId")
REFERENCES "ExperimentProjects" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId"
FOREIGN KEY ("ExperimentSessionId")
REFERENCES "ExperimentSessions" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ExperimentBookings_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_ExperimentBookings_ExperimentProjectId_StudentId"
ON "ExperimentBookings" ("ExperimentProjectId", "StudentId");
""",
"""
CREATE INDEX "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt"
ON "ExperimentBookings" (
"ExperimentSessionId",
"Status",
"BookedAt"
);
"""
];
private static readonly string[] ExperimentGradeItemSourceStatements =
[
"""
ALTER TABLE "GradeItems"
ADD "SourceType" INTEGER NOT NULL DEFAULT 1;
""",
"""
ALTER TABLE "GradeItems"
ADD "SourceSnapshotAt" TEXT NULL;
"""
];
private static readonly string[] ExperimentGradeManagementStatements =
[
"""
CREATE TABLE "ExperimentGradeSheets" (
"Id" TEXT NOT NULL
CONSTRAINT "PK_ExperimentGradeSheets" PRIMARY KEY,
"ExperimentProjectId" TEXT NOT NULL,
"ContributionWeight" TEXT NOT NULL,
"PassScore" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"ReviewComment" TEXT NULL,
"SubmittedAt" TEXT NULL,
"ReviewedAt" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId"
FOREIGN KEY ("ExperimentProjectId")
REFERENCES "ExperimentProjects" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE UNIQUE INDEX "IX_ExperimentGradeSheets_ExperimentProjectId"
ON "ExperimentGradeSheets" ("ExperimentProjectId");
""",
"""
CREATE TABLE "ExperimentGradeItems" (
"Id" TEXT NOT NULL
CONSTRAINT "PK_ExperimentGradeItems" PRIMARY KEY,
"ExperimentGradeSheetId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Kind" INTEGER NOT NULL,
"Weight" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSheetId"
FOREIGN KEY ("ExperimentGradeSheetId")
REFERENCES "ExperimentGradeSheets" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE INDEX "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder"
ON "ExperimentGradeItems" ("ExperimentGradeSheetId", "SortOrder");
""",
"""
CREATE TABLE "ExperimentGradeRecords" (
"Id" TEXT NOT NULL
CONSTRAINT "PK_ExperimentGradeRecords" PRIMARY KEY,
"ExperimentGradeSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"ExperimentSessionId" TEXT NULL,
"ParticipationStatus" INTEGER NOT NULL,
"TotalScore" TEXT NULL,
"IsPassed" INTEGER NULL,
"SafetyViolation" INTEGER NOT NULL,
"AttemptNumber" INTEGER NOT NULL,
"SubmissionReference" TEXT NULL,
"SubmittedAt" TEXT NULL,
"IsLate" INTEGER NOT NULL,
"TeacherComment" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGradeSheetId"
FOREIGN KEY ("ExperimentGradeSheetId")
REFERENCES "ExperimentGradeSheets" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentGradeRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId"
FOREIGN KEY ("ExperimentSessionId")
REFERENCES "ExperimentSessions" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId"
ON "ExperimentGradeRecords" ("ExperimentGradeSheetId", "StudentId");
""",
"""
CREATE INDEX "IX_ExperimentGradeRecords_StudentId"
ON "ExperimentGradeRecords" ("StudentId");
""",
"""
CREATE INDEX "IX_ExperimentGradeRecords_ExperimentSessionId"
ON "ExperimentGradeRecords" ("ExperimentSessionId");
""",
"""
CREATE TABLE "ExperimentGradeItemScores" (
"ExperimentGradeRecordId" TEXT NOT NULL,
"ExperimentGradeItemId" TEXT NOT NULL,
"Score" TEXT NULL,
"Comment" TEXT NULL,
CONSTRAINT "PK_ExperimentGradeItemScores"
PRIMARY KEY (
"ExperimentGradeRecordId",
"ExperimentGradeItemId"
),
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentGradeRecordId"
FOREIGN KEY ("ExperimentGradeRecordId")
REFERENCES "ExperimentGradeRecords" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGradeItemId"
FOREIGN KEY ("ExperimentGradeItemId")
REFERENCES "ExperimentGradeItems" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE INDEX "IX_ExperimentGradeItemScores_ExperimentGradeItemId"
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
"""
];
} }
@@ -0,0 +1,196 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamRoomMixing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamRooms",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExamPlanId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
RequiredInvigilatorCount = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamRooms", x => x.Id);
table.ForeignKey(
name: "FK_ExamRooms_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamRooms_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamRooms_ExamPlans_ExamPlanId",
column: x => x.ExamPlanId,
principalTable: "ExamPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamRoomInvigilators",
columns: table => new
{
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamRoomInvigilators", x => new { x.ExamRoomId, x.TeacherId });
table.ForeignKey(
name: "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId",
column: x => x.ExamRoomId,
principalTable: "ExamRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamRoomInvigilators_Teachers_TeacherId",
column: x => x.TeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamRoomSessions",
columns: table => new
{
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamRoomSessions", x => new { x.ExamRoomId, x.ExamSessionId });
table.ForeignKey(
name: "FK_ExamRoomSessions_ExamRooms_ExamRoomId",
column: x => x.ExamRoomId,
principalTable: "ExamRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamRoomSessions_ExamSessions_ExamSessionId",
column: x => x.ExamSessionId,
principalTable: "ExamSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamSeats",
columns: table => new
{
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
SeatNumber = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamSeats", x => new { x.ExamRoomId, x.StudentId });
table.ForeignKey(
name: "FK_ExamSeats_ExamRooms_ExamRoomId",
column: x => x.ExamRoomId,
principalTable: "ExamRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamSeats_ExamSessions_ExamSessionId",
column: x => x.ExamSessionId,
principalTable: "ExamSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamSeats_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExamRoomInvigilators_TeacherId",
table: "ExamRoomInvigilators",
column: "TeacherId");
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_ClassroomId",
table: "ExamRooms",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_CourseId",
table: "ExamRooms",
column: "CourseId");
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_Plan_Room_Time",
table: "ExamRooms",
columns: new[] { "ExamPlanId", "ClassroomId", "StartsAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_Plan_Time",
table: "ExamRooms",
columns: new[] { "ExamPlanId", "StartsAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamRoomSessions_SessionId",
table: "ExamRoomSessions",
column: "ExamSessionId");
migrationBuilder.CreateIndex(
name: "IX_ExamSeats_StudentId",
table: "ExamSeats",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "UX_ExamSeats_Session_Student",
table: "ExamSeats",
columns: new[] { "ExamSessionId", "StudentId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamRoomInvigilators");
migrationBuilder.DropTable(
name: "ExamRoomSessions");
migrationBuilder.DropTable(
name: "ExamSeats");
migrationBuilder.DropTable(
name: "ExamRooms");
}
}
}
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamArrangementJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamArrangementJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
ActivePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
SessionIdsJson = table.Column<string>(type: "longtext", nullable: true),
AssignClassrooms = table.Column<bool>(type: "tinyint(1)", nullable: false),
AssignInvigilators = table.Column<bool>(type: "tinyint(1)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TotalSessions = table.Column<int>(type: "int", nullable: false),
ProcessedSessions = table.Column<int>(type: "int", nullable: false),
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
ResultMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamArrangementJobs", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExamArrangementJobs_Kind_Plan_CreatedAt",
table: "ExamArrangementJobs",
columns: new[] { "Kind", "PlanId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamArrangementJobs_RequestedByUserId",
table: "ExamArrangementJobs",
column: "RequestedByUserId");
migrationBuilder.CreateIndex(
name: "IX_ExamArrangementJobs_Status_CreatedAt",
table: "ExamArrangementJobs",
columns: new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "UX_ExamArrangementJobs_Kind_ActivePlan",
table: "ExamArrangementJobs",
columns: new[] { "Kind", "ActivePlanId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamArrangementJobs");
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AddRequiredBuildingIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RequiredBuildingIds",
table: "MakeupExamSessions",
type: "longtext",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RequiredBuildingIds",
table: "ExamSessions",
type: "longtext",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RequiredBuildingIds",
table: "MakeupExamSessions");
migrationBuilder.DropColumn(
name: "RequiredBuildingIds",
table: "ExamSessions");
}
}
}
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamSignInExportJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamSignInExportJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
FileName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
FileBytes = table.Column<byte[]>(type: "longblob", nullable: true),
FileSize = table.Column<int>(type: "int", nullable: false),
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamSignInExportJobs", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExamSignInExportJobs_PlanId_CreatedAt",
table: "ExamSignInExportJobs",
columns: new[] { "PlanId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamSignInExportJobs_RequestedByUserId",
table: "ExamSignInExportJobs",
column: "RequestedByUserId");
migrationBuilder.CreateIndex(
name: "IX_ExamSignInExportJobs_Status_CreatedAt",
table: "ExamSignInExportJobs",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamSignInExportJobs");
}
}
}
@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamPublishJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamPublishJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
ActivePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
CurrentStep = table.Column<string>(type: "longtext", nullable: true),
ErrorMessage = table.Column<string>(type: "longtext", nullable: true),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamPublishJobs", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamPublishJobs");
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AttendanceCheckInAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AttendanceCheckInAttempts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
CheckInMethod = table.Column<int>(type: "int", nullable: false),
IsSuccessful = table.Column<bool>(type: "tinyint(1)", nullable: false),
FailureCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
DeviceIdentifierHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
DevicePlatform = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: true),
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
RiskFlags = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
Latitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
Longitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
AccuracyMeters = table.Column<double>(type: "double", nullable: true),
DistanceMeters = table.Column<double>(type: "double", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceCheckInAttempts", x => x.Id);
table.ForeignKey(
name: "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId",
column: x => x.AttendanceSheetId,
principalTable: "AttendanceSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AttendanceCheckInAttempts_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_Create~",
table: "AttendanceCheckInAttempts",
columns: new[] { "AttendanceSheetId", "StudentId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt",
table: "AttendanceCheckInAttempts",
columns: new[] { "DeviceIdentifierHash", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt",
table: "AttendanceCheckInAttempts",
columns: new[] { "IpAddress", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceCheckInAttempts_StudentId",
table: "AttendanceCheckInAttempts",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AttendanceCheckInAttempts");
}
}
}
@@ -0,0 +1,170 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExperimentManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExperimentProjects",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
ArrangementMode = table.Column<int>(type: "int", nullable: false),
Description = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
Requirements = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
StartDate = table.Column<DateTime>(type: "date", nullable: false),
EndDate = table.Column<DateTime>(type: "date", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentProjects", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentProjects_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
SessionDate = table.Column<DateTime>(type: "date", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
Capacity = table.Column<int>(type: "int", nullable: false),
ReservedCount = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentSessions", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentSessions_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId",
column: x => x.ExperimentProjectId,
principalTable: "ExperimentProjects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentBookings",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
BookedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentBookings", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId",
column: x => x.ExperimentProjectId,
principalTable: "ExperimentProjects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId",
column: x => x.ExperimentSessionId,
principalTable: "ExperimentSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExperimentBookings_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExperimentBookings_ExperimentProjectId_StudentId",
table: "ExperimentBookings",
columns: new[] { "ExperimentProjectId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt",
table: "ExperimentBookings",
columns: new[] { "ExperimentSessionId", "Status", "BookedAt" });
migrationBuilder.CreateIndex(
name: "IX_ExperimentBookings_StudentId",
table: "ExperimentBookings",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentProjects_Status_StartDate_EndDate",
table: "ExperimentProjects",
columns: new[] { "Status", "StartDate", "EndDate" });
migrationBuilder.CreateIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code",
table: "ExperimentProjects",
columns: new[] { "TeachingTaskId", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod",
table: "ExperimentSessions",
columns: new[] { "ClassroomId", "SessionDate", "Status", "StartPeriod" });
migrationBuilder.CreateIndex(
name: "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeri~",
table: "ExperimentSessions",
columns: new[] { "ExperimentProjectId", "SessionDate", "StartPeriod" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExperimentBookings");
migrationBuilder.DropTable(
name: "ExperimentSessions");
migrationBuilder.DropTable(
name: "ExperimentProjects");
}
}
}
@@ -0,0 +1,208 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExperimentGradeManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "SourceSnapshotAt",
table: "GradeItems",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SourceType",
table: "GradeItems",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.CreateTable(
name: "ExperimentGradeSheets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
ContributionWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
PassScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentGradeSheets", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId",
column: x => x.ExperimentProjectId,
principalTable: "ExperimentProjects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentGradeItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
Weight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentGradeItems", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSh~",
column: x => x.ExperimentGradeSheetId,
principalTable: "ExperimentGradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentGradeRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: true),
ParticipationStatus = table.Column<int>(type: "int", nullable: false),
TotalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
SafetyViolation = table.Column<bool>(type: "tinyint(1)", nullable: false),
AttemptNumber = table.Column<int>(type: "int", nullable: false),
SubmissionReference = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsLate = table.Column<bool>(type: "tinyint(1)", nullable: false),
TeacherComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentGradeRecords", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGrade~",
column: x => x.ExperimentGradeSheetId,
principalTable: "ExperimentGradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId",
column: x => x.ExperimentSessionId,
principalTable: "ExperimentSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExperimentGradeRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentGradeItemScores",
columns: table => new
{
ExperimentGradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentGradeItemId = table.Column<Guid>(type: "char(36)", nullable: false),
Score = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
Comment = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentGradeItemScores", x => new { x.ExperimentGradeRecordId, x.ExperimentGradeItemId });
table.ForeignKey(
name: "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGra~",
column: x => x.ExperimentGradeItemId,
principalTable: "ExperimentGradeItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentG~",
column: x => x.ExperimentGradeRecordId,
principalTable: "ExperimentGradeRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder",
table: "ExperimentGradeItems",
columns: new[] { "ExperimentGradeSheetId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeItemScores_ExperimentGradeItemId",
table: "ExperimentGradeItemScores",
column: "ExperimentGradeItemId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId",
table: "ExperimentGradeRecords",
columns: new[] { "ExperimentGradeSheetId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeRecords_ExperimentSessionId",
table: "ExperimentGradeRecords",
column: "ExperimentSessionId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeRecords_StudentId",
table: "ExperimentGradeRecords",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeSheets_ExperimentProjectId",
table: "ExperimentGradeSheets",
column: "ExperimentProjectId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExperimentGradeItemScores");
migrationBuilder.DropTable(
name: "ExperimentGradeItems");
migrationBuilder.DropTable(
name: "ExperimentGradeRecords");
migrationBuilder.DropTable(
name: "ExperimentGradeSheets");
migrationBuilder.DropColumn(
name: "SourceSnapshotAt",
table: "GradeItems");
migrationBuilder.DropColumn(
name: "SourceType",
table: "GradeItems");
}
}
}
@@ -0,0 +1,65 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AppUpdateReleases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUpdateReleases",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Platform = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
Channel = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
Version = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
NativeVersion = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
ReleaseNotes = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
FileName = table.Column<string>(type: "varchar(180)", maxLength: 180, nullable: false),
FileSize = table.Column<long>(type: "bigint", nullable: false),
Sha256 = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
BundleContent = table.Column<byte[]>(type: "longblob", nullable: false),
CreatedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
PublishedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUpdateReleases", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AppUpdateReleases_CreatedAt",
table: "AppUpdateReleases",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status",
table: "AppUpdateReleases",
columns: new[] { "Platform", "Channel", "NativeVersion", "Status" });
migrationBuilder.CreateIndex(
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version",
table: "AppUpdateReleases",
columns: new[] { "Platform", "Channel", "NativeVersion", "Version" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUpdateReleases");
}
}
}
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling; namespace Jiaowu.Api.Infrastructure.Scheduling;
@@ -34,6 +35,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.Include(x => x.Course)
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count) .OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
.ThenByDescending(x => x.Capacity) .ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber) .ThenBy(x => x.TaskNumber)
@@ -72,10 +74,43 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
constraints.TryGetValue(task.Id, out var constraint); constraints.TryGetValue(task.Id, out var constraint);
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.Course!,
task.StartWeek,
task.EndWeek,
out var requiredWeeklyHours))
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
processedTasks++;
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
}
continue;
}
var scheduledHours = entries var scheduledHours = entries
.Where(x => x.TeachingTaskId == task.Id) .Where(x => x.TeachingTaskId == task.Id)
.Sum(x => x.PeriodCount); .Sum(x => x.PeriodCount);
var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours); if (scheduledHours > requiredWeeklyHours)
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
processedTasks++;
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
}
continue;
}
var remainingHours = requiredWeeklyHours - scheduledHours;
if (remainingHours == 0) if (remainingHours == 0)
{ {
completedTasks++; completedTasks++;
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling; namespace Jiaowu.Api.Infrastructure.Scheduling;
@@ -179,19 +180,68 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
x.AcademicTermId == plan.AcademicTermId && x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published && x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard) x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) .Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.StartWeek,
x.EndWeek,
CourseTotalHours = x.Course!.TotalHours,
CoursePracticeHours = x.Course.PracticeHours
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var invalidHours = requiredTasks.FirstOrDefault(task =>
!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out _));
if (invalidHours is not null)
{
throw new SchedulePublishValidationException(
$"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" +
"不能按授课周次整除,请先调整教学任务周次。");
}
var requiredWeeklyHours = requiredTasks
.Select(task =>
{
TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out var hours);
return new
{
Task = task,
Hours = hours
};
})
.ToList();
var scheduledHours = plan.Entries var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId) .GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); .ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredTasks.FirstOrDefault(task => var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
!scheduledHours.TryGetValue(task.Id, out var hours) || scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
hours < task.WeeklyHours);
if (incomplete is not null) if (incomplete is not null)
{ {
throw new SchedulePublishValidationException( throw new SchedulePublishValidationException(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " + $"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
$"{incomplete.WeeklyHours} 学时,不能发布。"); $"{incomplete.Hours} 个普通排课学时,不能发布。");
}
var excessive = requiredWeeklyHours.FirstOrDefault(item =>
scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours);
if (excessive is not null)
{
var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id);
throw new SchedulePublishValidationException(
$"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " +
$"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" +
"请删除已包含的实践学时后再发布。");
} }
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken); await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
@@ -0,0 +1,156 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace Jiaowu.Api.Infrastructure.Teaching;
public static class AttendanceCheckInChallenge
{
public const int LifetimeSeconds = 20;
public const int RefreshSeconds = 10;
private const int MaximumClockSkewSeconds = 2;
private const string Base36Digits = "0123456789abcdefghijklmnopqrstuvwxyz";
public static AttendanceCheckInChallengeResult Create(
Guid attendanceSheetId,
string secret,
DateTime nowUtc)
{
var issuedAt = new DateTimeOffset(
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc));
var payload = CreatePayload(
attendanceSheetId,
ToBase36(issuedAt.ToUnixTimeSeconds()));
var signature = CreateSignature(payload, secret);
return new AttendanceCheckInChallengeResult(
$"{payload}.{signature}",
issuedAt.UtcDateTime,
issuedAt.AddSeconds(LifetimeSeconds).UtcDateTime,
issuedAt.AddSeconds(RefreshSeconds).UtcDateTime);
}
public static bool TryReadSheetId(string? token, out Guid attendanceSheetId)
{
attendanceSheetId = Guid.Empty;
if (!TryParse(token, out var sheetIdText, out _, out _))
return false;
return Guid.TryParseExact(sheetIdText, "N", out attendanceSheetId);
}
public static bool IsValid(
string? token,
Guid attendanceSheetId,
string? secret,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(secret) ||
!TryParse(token, out var sheetIdText, out var issuedAtText, out var signature) ||
!Guid.TryParseExact(sheetIdText, "N", out var tokenSheetId) ||
tokenSheetId != attendanceSheetId ||
!TryParseBase36(issuedAtText, out var issuedAtUnixSeconds))
{
return false;
}
var nowUnixSeconds = new DateTimeOffset(
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
var ageSeconds = nowUnixSeconds - issuedAtUnixSeconds;
if (ageSeconds < -MaximumClockSkewSeconds || ageSeconds > LifetimeSeconds)
return false;
byte[] providedSignature;
try
{
providedSignature = WebEncoders.Base64UrlDecode(signature);
}
catch (FormatException)
{
return false;
}
var expectedSignature = WebEncoders.Base64UrlDecode(
CreateSignature(
CreatePayload(attendanceSheetId, issuedAtText),
secret));
return CryptographicOperations.FixedTimeEquals(
providedSignature,
expectedSignature);
}
private static string CreatePayload(Guid attendanceSheetId, string issuedAtText) =>
$"{attendanceSheetId:N}.{issuedAtText}";
private static string CreateSignature(string payload, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return WebEncoders.Base64UrlEncode(digest[..16]);
}
private static bool TryParse(
string? token,
out string sheetIdText,
out string issuedAtText,
out string signature)
{
sheetIdText = string.Empty;
issuedAtText = string.Empty;
signature = string.Empty;
if (string.IsNullOrWhiteSpace(token) || token.Length > 64)
return false;
var parts = token.Split('.');
if (parts.Length != 3 ||
parts[0].Length != 32 ||
parts[1].Length is < 1 or > 12 ||
parts[2].Length != 22)
{
return false;
}
sheetIdText = parts[0];
issuedAtText = parts[1];
signature = parts[2];
return true;
}
private static string ToBase36(long value)
{
if (value == 0) return "0";
Span<char> buffer = stackalloc char[13];
var index = buffer.Length;
while (value > 0)
{
buffer[--index] = Base36Digits[(int)(value % 36)];
value /= 36;
}
return new string(buffer[index..]);
}
private static bool TryParseBase36(string value, out long result)
{
result = 0;
foreach (var character in value)
{
var digit = Base36Digits.IndexOf(
char.ToLower(character, CultureInfo.InvariantCulture));
if (digit < 0) return false;
try
{
result = checked(result * 36 + digit);
}
catch (OverflowException)
{
return false;
}
}
return true;
}
}
public sealed record AttendanceCheckInChallengeResult(
string Token,
DateTime IssuedAt,
DateTime ExpiresAt,
DateTime RefreshAt);
@@ -7,15 +7,75 @@ public static class TeachingTaskHours
public static int Calculate(int startWeek, int endWeek, int weeklyHours) => public static int Calculate(int startWeek, int endWeek, int weeklyHours) =>
endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours; endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours;
public static int RegularScheduleHours(Course course) =>
RegularScheduleHours(course.TotalHours, course.PracticeHours);
public static int RegularScheduleHours(int totalHours, int practiceHours) =>
Math.Max(0, totalHours - practiceHours);
public static int TargetHours(
Course course,
TeachingTaskSchedulingMode schedulingMode) =>
schedulingMode == TeachingTaskSchedulingMode.Flexible
? course.TotalHours
: RegularScheduleHours(course);
public static bool TryResolveRegularWeeklyHours(
Course course,
int startWeek,
int endWeek,
out int weeklyHours) =>
TryResolveRegularWeeklyHours(
course.TotalHours,
course.PracticeHours,
startWeek,
endWeek,
out weeklyHours);
public static bool TryResolveRegularWeeklyHours(
int totalHours,
int practiceHours,
int startWeek,
int endWeek,
out int weeklyHours)
{
weeklyHours = 0;
var weekCount = endWeek - startWeek + 1;
var regularHours = RegularScheduleHours(totalHours, practiceHours);
if (weekCount <= 0 || regularHours % weekCount != 0) return false;
weeklyHours = regularHours / weekCount;
return true;
}
public static string? Validate( public static string? Validate(
Course course, Course course,
int startWeek, int startWeek,
int endWeek, int endWeek,
int weeklyHours) int weeklyHours,
TeachingTaskSchedulingMode schedulingMode =
TeachingTaskSchedulingMode.Standard)
{ {
var plannedHours = Calculate(startWeek, endWeek, weeklyHours); var plannedHours = Calculate(startWeek, endWeek, weeklyHours);
return plannedHours == course.TotalHours var targetHours = TargetHours(course, schedulingMode);
? null if (plannedHours == targetHours) return null;
: $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 {startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 {plannedHours} 学时。请调整授课周次或周学时。";
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
{
if (targetHours == 0)
{
return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," +
"无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。";
}
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
$"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " +
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
$"{plannedHours} 学时。请调整授课周次或周学时。";
}
return $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 " +
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
$"{plannedHours} 学时。请调整授课周次或周学时。";
} }
} }
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Teaching; namespace Jiaowu.Api.Infrastructure.Teaching;
@@ -15,4 +16,107 @@ public static class TeachingTaskRosterQuery
enrollment.StudentId == student.Id && enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)); enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
public static IQueryable<Guid> TaskIdsForStudent(AppDbContext db, Guid studentId)
{
var classTaskIds = db.Students
.Where(student =>
student.Id == studentId &&
student.Status == StudentStatus.Active)
.SelectMany(student => db.TeachingTaskClasses
.Where(assignment =>
assignment.AdministrativeClassId ==
student.AdministrativeClassId)
.Select(assignment => assignment.TeachingTaskId));
var enrolledTaskIds = db.CourseEnrollments
.Where(enrollment =>
enrollment.StudentId == studentId &&
enrollment.Status == CourseEnrollmentStatus.Enrolled)
.Select(enrollment =>
enrollment.CourseSelectionOffering!.TeachingTaskId);
return classTaskIds.Concat(enrolledTaskIds).Distinct();
}
public static async Task<IReadOnlyList<TeachingTaskRosterEntry>> LoadForTasksAsync(
AppDbContext db,
IEnumerable<Guid> teachingTaskIds,
CancellationToken cancellationToken)
{
var taskIds = teachingTaskIds.Distinct().ToArray();
if (taskIds.Length == 0) return [];
var assignments = await db.TeachingTaskClasses.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Select(x => new
{
x.TeachingTaskId,
x.AdministrativeClassId
})
.ToListAsync(cancellationToken);
var classIds = assignments
.Select(x => x.AdministrativeClassId)
.Distinct()
.ToArray();
var classStudents = classIds.Length == 0
? []
: await db.Students.AsNoTracking()
.Where(x => x.Status == StudentStatus.Active)
.WhereIn(classIds, x => x.AdministrativeClassId)
.Select(x => new RosterStudent(
x.Id,
x.StudentNumber,
x.Name,
x.AdministrativeClassId,
x.AdministrativeClass!.Name))
.ToListAsync(cancellationToken);
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
.Select(x => new TeachingTaskRosterEntry(
x.CourseSelectionOffering!.TeachingTaskId,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
x.Student.AdministrativeClass!.Name))
.ToListAsync(cancellationToken);
var result = enrolledStudents.ToDictionary(
x => (x.TeachingTaskId, x.StudentId));
var studentsByClass = classStudents.ToLookup(x => x.AdministrativeClassId);
foreach (var assignment in assignments)
{
foreach (var student in studentsByClass[assignment.AdministrativeClassId])
{
result.TryAdd(
(assignment.TeachingTaskId, student.StudentId),
new TeachingTaskRosterEntry(
assignment.TeachingTaskId,
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName));
}
}
return result.Values
.OrderBy(x => x.TeachingTaskId)
.ThenBy(x => x.StudentNumber)
.ToList();
}
private sealed record RosterStudent(
Guid StudentId,
string StudentNumber,
string Name,
Guid AdministrativeClassId,
string ClassName);
} }
public sealed record TeachingTaskRosterEntry(
Guid TeachingTaskId,
Guid StudentId,
string StudentNumber,
string Name,
string ClassName);
@@ -58,6 +58,17 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
occupiedIds.UnionWith(examRoomIds); occupiedIds.UnionWith(examRoomIds);
var mixedExamRoomIds = await db.ExamRooms.AsNoTracking()
.Where(room =>
room.ExamPlan!.AcademicTermId == term.Id &&
room.ExamPlan.Status == ExamPlanStatus.Published &&
room.ExamDate == reservationDate &&
room.StartPeriod < startPeriod + periodCount &&
startPeriod < room.StartPeriod + room.PeriodCount)
.Select(room => room.ClassroomId)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(mixedExamRoomIds);
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking() var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
.Where(session => .Where(session =>
session.ClassroomId.HasValue && session.ClassroomId.HasValue &&
@@ -70,6 +81,20 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
occupiedIds.UnionWith(makeupExamRoomIds); occupiedIds.UnionWith(makeupExamRoomIds);
var experimentRoomIds = await db.ExperimentSessions.AsNoTracking()
.Where(session =>
session.ExperimentProject!.TeachingTask!.AcademicTermId ==
term.Id &&
session.ExperimentProject.Status !=
ExperimentProjectStatus.Closed &&
session.Status == ExperimentSessionStatus.Scheduled &&
session.SessionDate == reservationDate &&
session.StartPeriod < startPeriod + periodCount &&
startPeriod < session.StartPeriod + session.PeriodCount)
.Select(session => session.ClassroomId)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(experimentRoomIds);
var reservationQuery = db.ClassroomReservations.AsNoTracking() var reservationQuery = db.ClassroomReservations.AsNoTracking()
.Where(reservation => .Where(reservation =>
reservation.AcademicTermId == term.Id && reservation.AcademicTermId == term.Id &&
@@ -115,6 +115,7 @@ public sealed class PersonalCalendarService(
AddScheduledCourses(builder, timetable); AddScheduledCourses(builder, timetable);
AddExamEntries(builder, timetable, teacher is not null); AddExamEntries(builder, timetable, teacher is not null);
AddExperimentEntries(builder, timetable);
AddFlexibleCourseReminders(builder, timetable); AddFlexibleCourseReminders(builder, timetable);
} }
@@ -246,6 +247,46 @@ public sealed class PersonalCalendarService(
} }
} }
private static void AddExperimentEntries(
IcsBuilder builder,
TimetableData timetable)
{
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
foreach (var entry in timetable.ExperimentEntries)
{
if (entry.ExperimentDate is not DateOnly experimentDate ||
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
!slots.TryGetValue(
entry.StartPeriod + entry.PeriodCount - 1,
out var endSlot))
continue;
var arrangement = entry.ExperimentArrangementMode switch
{
ExperimentArrangementMode.Centralized => "集中安排",
ExperimentArrangementMode.SelfScheduled => "自主预约",
_ => "实验安排"
};
builder.AddTimedEvent(
$"experiment-{entry.Id:N}@jiaowu",
$"[实验] {entry.ExperimentProjectName ?? entry.CourseName}",
ToUtc(experimentDate, startSlot.StartsAt),
ToUtc(experimentDate, endSlot.EndsAt),
JoinLocation(
entry.CampusName,
entry.BuildingName,
entry.ClassroomName),
JoinDescription(
$"{entry.ExperimentProjectCode} · {arrangement}",
entry.CourseName,
entry.TaskName,
$"教师:{string.Join('、', entry.TeacherNames)}",
entry.Notes),
"实验",
entry.UpdatedAt);
}
}
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync( private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
Guid? studentId, Guid? studentId,
Guid? teacherId, Guid? teacherId,
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Timetables; namespace Jiaowu.Api.Infrastructure.Timetables;
@@ -117,6 +118,8 @@ public sealed class TimetableDataService(AppDbContext db)
// Load exam sessions for student/teacher timetables // Load exam sessions for student/teacher timetables
var examEntries = await LoadExamEntriesAsync( var examEntries = await LoadExamEntriesAsync(
resourceType, resourceId, term.Id, studentId, slots, cancellationToken); resourceType, resourceId, term.Id, studentId, slots, cancellationToken);
var experimentEntries = await LoadExperimentEntriesAsync(
resourceType, term, studentId, cancellationToken);
return new TimetableData( return new TimetableData(
term, term,
@@ -127,7 +130,10 @@ public sealed class TimetableDataService(AppDbContext db)
slots, slots,
entries, entries,
flexibleCourses, flexibleCourses,
examEntries); examEntries)
{
ExperimentEntries = experimentEntries
};
} }
private async Task<TimetableSubjectDto?> LoadSubjectAsync( private async Task<TimetableSubjectDto?> LoadSubjectAsync(
@@ -307,69 +313,188 @@ public sealed class TimetableDataService(AppDbContext db)
IReadOnlyList<TimetableSlotDto> slots, IReadOnlyList<TimetableSlotDto> slots,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (resourceType == TimetableResourceType.Classroom) return [];
var slotLookup = slots.ToDictionary(x => x.PeriodNumber); var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
var result = new List<TimetableEntryDto>();
IQueryable<ExamSession> source = db.ExamSessions.AsNoTracking() // ── Legacy sessions (ExamSessions with direct ClassroomId) ──
var legacyQuery = db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.AcademicTermId == academicTermId && .Where(x => x.ExamPlan!.AcademicTermId == academicTermId &&
x.ExamPlan.Status == ExamPlanStatus.Published && x.ExamPlan.Status == ExamPlanStatus.Published &&
x.ClassroomId != null); x.ClassroomId != null);
if (resourceType == TimetableResourceType.Teacher) legacyQuery = ApplyLegacyResourceFilter(
{ legacyQuery, resourceType, resourceId, studentId);
source = source.Where(x =>
x.Invigilators.Any(i => i.TeacherId == resourceId));
}
else if (studentId.HasValue)
{
source = source.Where(x =>
db.CourseEnrollments.Any(e =>
e.StudentId == studentId.Value &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId));
}
else
{
// Class timetable: exams for the class's teaching tasks
source = source.Where(x =>
x.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
}
var sessions = await source var legacySessions = await legacyQuery
.OrderBy(x => x.ExamDate) .OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod) .ThenBy(x => x.StartPeriod)
.Select(x => new .Select(x => new ExamSessionProjection(
{
x.Id, x.Id,
x.TeachingTaskId, x.TeachingTaskId,
x.TeachingTask!.TaskNumber, x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name, x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code, x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name, x.TeachingTask.Course.Name,
TeacherNames = x.TeachingTask.Teachers x.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary) .OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name), .Select(t => t.Teacher!.Name).ToList(),
ClassNames = x.TeachingTask.Classes x.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name), .Select(c => c.AdministrativeClass!.Name).ToList(),
ClassroomName = x.Classroom!.Name, x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name, x.Classroom.Building!.Name,
CampusName = x.Classroom.Building.Campus!.Name, x.Classroom.Building.Campus!.Name,
x.ExamDate, x.ExamDate,
x.StartPeriod, x.StartPeriod,
x.PeriodCount, x.PeriodCount,
x.ExamPlan!.Name, x.ExamPlan!.Name,
InvigilatorNames = x.Invigilators x.Invigilators.Select(i => i.Teacher!.Name).ToList(),
.Select(i => i.Teacher!.Name),
x.Notes, x.Notes,
x.UpdatedAt x.UpdatedAt))
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
result.AddRange(legacySessions.Select(x =>
MapToEntryDto(x, slotLookup)));
// ── Mixed-room sessions (ExamRoomAssignment) ──
// Load all mixed rooms for this term into memory, then filter
var allMixedRooms = await db.ExamRooms.AsNoTracking()
.Where(room =>
room.ExamPlan!.AcademicTermId == academicTermId &&
room.ExamPlan.Status == ExamPlanStatus.Published)
.Include(room => room.ExamPlan)
.Include(room => room.Classroom)
.ThenInclude(c => c!.Building)
.ThenInclude(b => b!.Campus)
.Include(room => room.Invigilators)
.ThenInclude(i => i.Teacher)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Course)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Teachers)
.ThenInclude(tt => tt.Teacher)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Classes)
.ThenInclude(tc => tc.AdministrativeClass)
.Include(room => room.Seats)
.AsSplitQuery()
.ToListAsync(cancellationToken);
foreach (var room in allMixedRooms)
{
foreach (var link in room.SessionLinks)
{
var session = link.ExamSession;
if (session == null || session.TeachingTask == null) continue;
// Apply resource filter in memory
if (!MatchesMixedResource(
room, session, resourceType, resourceId, studentId))
continue;
result.Add(MapToEntryDto(new ExamSessionProjection(
room.Id,
session.TeachingTaskId,
session.TeachingTask.TaskNumber,
session.TeachingTask.Name,
session.TeachingTask.Course!.Code,
session.TeachingTask.Course.Name,
session.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name).ToList(),
session.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name).ToList(),
room.Classroom!.Name,
room.Classroom.Building!.Name,
room.Classroom.Building.Campus!.Name,
room.ExamDate,
room.StartPeriod,
room.PeriodCount,
room.ExamPlan!.Name,
room.Invigilators
.Select(i => i.Teacher!.Name).ToList(),
null,
room.UpdatedAt), slotLookup));
}
}
return result;
}
private async Task<List<TimetableEntryDto>> LoadExperimentEntriesAsync(
TimetableResourceType resourceType,
TimetableTermDto term,
Guid? studentId,
CancellationToken cancellationToken)
{
if (resourceType != TimetableResourceType.Class || !studentId.HasValue)
return [];
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
db,
studentId.Value);
var sessions = await db.ExperimentSessions.AsNoTracking()
.AsSplitQuery()
.Where(x =>
x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id &&
(x.ExperimentProject.Status ==
ExperimentProjectStatus.Published ||
x.ExperimentProject.Status ==
ExperimentProjectStatus.Closed) &&
x.Status == ExperimentSessionStatus.Scheduled)
.Where(x =>
(x.ExperimentProject!.ArrangementMode ==
ExperimentArrangementMode.Centralized &&
rosterTaskIds.Contains(
x.ExperimentProject.TeachingTaskId)) ||
(x.ExperimentProject.ArrangementMode ==
ExperimentArrangementMode.SelfScheduled &&
x.Bookings.Any(booking =>
booking.StudentId == studentId.Value &&
booking.Status == ExperimentBookingStatus.Booked)))
.OrderBy(x => x.SessionDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.ExperimentProject!.Code)
.Select(x => new ExperimentSessionProjection(
x.Id,
x.ExperimentProjectId,
x.ExperimentProject!.Code,
x.ExperimentProject.Name,
x.ExperimentProject.ArrangementMode,
x.ExperimentProject.TeachingTaskId,
x.ExperimentProject.TeachingTask!.TaskNumber,
x.ExperimentProject.TeachingTask.Name,
x.ExperimentProject.TeachingTask.Course!.Code,
x.ExperimentProject.TeachingTask.Course.Name,
x.ExperimentProject.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name)
.ToList(),
x.ExperimentProject.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name)
.ToList(),
x.Classroom!.Name,
x.Classroom.Building!.Name,
x.Classroom.Building.Campus!.Name,
x.SessionDate,
x.StartPeriod,
x.PeriodCount,
x.Notes,
x.UpdatedAt))
.ToListAsync(cancellationToken);
var termMonday = StartOfWeek(term.StartDate);
return sessions.Select(x => return sessions.Select(x =>
{ {
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek; var week = (x.SessionDate.DayNumber - termMonday.DayNumber) / 7 + 1;
var dayOfWeek = x.SessionDate.DayOfWeek == DayOfWeek.Sunday
? 7
: (int)x.SessionDate.DayOfWeek;
return new TimetableEntryDto( return new TimetableEntryDto(
x.Id, x.Id,
x.TeachingTaskId, x.TeachingTaskId,
@@ -377,8 +502,119 @@ public sealed class TimetableDataService(AppDbContext db)
x.TaskName, x.TaskName,
x.CourseCode, x.CourseCode,
x.CourseName, x.CourseName,
x.TeacherNames.Concat( x.TeacherNames,
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }), x.ClassNames,
x.ClassroomName,
x.BuildingName,
x.CampusName,
dayOfWeek,
x.StartPeriod,
x.PeriodCount,
week,
week,
WeekPattern.All,
x.Notes,
false,
null,
null,
x.UpdatedAt,
true,
x.ExperimentProjectId,
x.ProjectCode,
x.ProjectName,
x.SessionDate,
x.ArrangementMode);
}).ToList();
}
private static DateOnly StartOfWeek(DateOnly date)
{
var dayOfWeek = date.DayOfWeek == DayOfWeek.Sunday
? 7
: (int)date.DayOfWeek;
return date.AddDays(1 - dayOfWeek);
}
private static bool MatchesMixedResource(
ExamRoomAssignment room,
ExamSession session,
TimetableResourceType resourceType,
Guid resourceId,
Guid? studentId)
{
switch (resourceType)
{
case TimetableResourceType.Classroom:
return room.ClassroomId == resourceId;
case TimetableResourceType.Teacher:
return room.Invigilators.Any(i => i.TeacherId == resourceId) ||
session.TeachingTask!.Teachers.Any(
t => t.TeacherId == resourceId);
case TimetableResourceType.Class:
if (studentId.HasValue)
return session.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId) ||
room.Seats.Any(s => s.StudentId == studentId.Value);
return session.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId);
default:
return true;
}
}
private IQueryable<ExamSession> ApplyLegacyResourceFilter(
IQueryable<ExamSession> source,
TimetableResourceType resourceType,
Guid resourceId,
Guid? studentId)
{
switch (resourceType)
{
case TimetableResourceType.Classroom:
return source.Where(x => x.ClassroomId == resourceId);
case TimetableResourceType.Teacher:
return source.Where(x =>
x.Invigilators.Any(i => i.TeacherId == resourceId) ||
x.TeachingTask!.Teachers.Any(t => t.TeacherId == resourceId));
case TimetableResourceType.Class:
if (studentId.HasValue)
{
return source.Where(x =>
x.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId) ||
db.CourseEnrollments.Any(e =>
e.StudentId == studentId.Value &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId ==
x.TeachingTaskId));
}
return source.Where(x =>
x.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
default:
return source;
}
}
private static TimetableEntryDto MapToEntryDto(
ExamSessionProjection x,
IReadOnlyDictionary<int, TimetableSlotDto> slotLookup)
{
var dayOfWeek = x.ExamDate.DayOfWeek == 0
? 7
: (int)x.ExamDate.DayOfWeek;
var teacherNames = x.InvigilatorNames.Count > 0
? x.TeacherNames.Concat(
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
: x.TeacherNames;
return new TimetableEntryDto(
x.Id,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
x.CourseCode,
x.CourseName,
teacherNames,
x.ClassNames, x.ClassNames,
x.ClassroomName, x.ClassroomName,
x.BuildingName, x.BuildingName,
@@ -390,11 +626,52 @@ public sealed class TimetableDataService(AppDbContext db)
WeekPattern.All, WeekPattern.All,
x.Notes, x.Notes,
true, true,
x.Name, x.PlanName,
x.ExamDate, x.ExamDate,
x.UpdatedAt); x.UpdatedAt);
}).ToList();
} }
private sealed record ExamSessionProjection(
Guid Id,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
List<string> TeacherNames,
List<string> ClassNames,
string ClassroomName,
string BuildingName,
string CampusName,
DateOnly ExamDate,
int StartPeriod,
int PeriodCount,
string PlanName,
List<string> InvigilatorNames,
string? Notes,
DateTime UpdatedAt);
private sealed record ExperimentSessionProjection(
Guid Id,
Guid ExperimentProjectId,
string ProjectCode,
string ProjectName,
ExperimentArrangementMode ArrangementMode,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
List<string> TeacherNames,
List<string> ClassNames,
string ClassroomName,
string BuildingName,
string CampusName,
DateOnly SessionDate,
int StartPeriod,
int PeriodCount,
string? Notes,
DateTime UpdatedAt);
} }
public enum TimetableResourceType public enum TimetableResourceType
@@ -413,7 +690,10 @@ public sealed record TimetableData(
IReadOnlyList<TimetableSlotDto> Slots, IReadOnlyList<TimetableSlotDto> Slots,
IReadOnlyList<TimetableEntryDto> Entries, IReadOnlyList<TimetableEntryDto> Entries,
IReadOnlyList<FlexibleCourseDto> FlexibleCourses, IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
IReadOnlyList<TimetableEntryDto> ExamEntries); IReadOnlyList<TimetableEntryDto> ExamEntries)
{
public IReadOnlyList<TimetableEntryDto> ExperimentEntries { get; init; } = [];
}
public sealed record TimetableTermDto( public sealed record TimetableTermDto(
Guid Id, Guid Id,
@@ -476,7 +756,13 @@ public sealed record TimetableEntryDto(
bool IsExam = false, bool IsExam = false,
string? ExamPlanName = null, string? ExamPlanName = null,
DateOnly? ExamDate = null, DateOnly? ExamDate = null,
DateTime UpdatedAt = default); DateTime UpdatedAt = default,
bool IsExperiment = false,
Guid? ExperimentProjectId = null,
string? ExperimentProjectCode = null,
string? ExperimentProjectName = null,
DateOnly? ExperimentDate = null,
ExperimentArrangementMode? ExperimentArrangementMode = null);
public sealed record FlexibleCourseDto( public sealed record FlexibleCourseDto(
Guid Id, Guid Id,
@@ -25,9 +25,21 @@ public static class TimetableExcelExporter
sheet.Row(1).Height = 34; sheet.Row(1).Height = 34;
sheet.Range("A2:H2").Merge(); sheet.Range("A2:H2").Merge();
var activitySummary = string.Concat(
timetable.ExamEntries.Count > 0
? $" · 已融入 {timetable.ExamEntries.Count} 场考试"
: "",
timetable.ExperimentEntries.Count > 0
? $" · 已融入 {timetable.ExperimentEntries.Count} 场实验"
: "");
var hasOneOffActivities =
timetable.ExamEntries.Count > 0 ||
timetable.ExperimentEntries.Count > 0;
sheet.Cell("A2").Value = timetable.Plan is null sheet.Cell("A2").Value = timetable.Plan is null
? "本学期暂无可用课表版本" ? hasOneOffActivities
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"; ? $"固定课表尚未发布{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"
: "本学期暂无可用课表版本"
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)}{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
sheet.Cell("A2").Style sheet.Cell("A2").Style
.Font.SetFontColor(XLColor.FromHtml("#52677A")) .Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6")); .Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6"));
@@ -48,8 +60,12 @@ public static class TimetableExcelExporter
} }
sheet.Row(headerRow).Height = 26; sheet.Row(headerRow).Height = 26;
var timedEntries = timetable.Entries
.Concat(timetable.ExamEntries)
.Concat(timetable.ExperimentEntries)
.ToArray();
var periods = timetable.Slots.Select(x => x.PeriodNumber) var periods = timetable.Slots.Select(x => x.PeriodNumber)
.Concat(timetable.Entries.SelectMany(x => .Concat(timedEntries.SelectMany(x =>
Enumerable.Range(x.StartPeriod, x.PeriodCount))) Enumerable.Range(x.StartPeriod, x.PeriodCount)))
.Distinct() .Distinct()
.Order() .Order()
@@ -72,7 +88,7 @@ public static class TimetableExcelExporter
for (var day = 1; day <= 7; day++) for (var day = 1; day <= 7; day++)
{ {
var entries = timetable.Entries var entries = timedEntries
.Where(x => .Where(x =>
x.DayOfWeek == day && x.DayOfWeek == day &&
x.StartPeriod <= period && x.StartPeriod <= period &&
@@ -86,7 +102,12 @@ public static class TimetableExcelExporter
if (entries.Count > 0) if (entries.Count > 0)
{ {
cell.Style cell.Style
.Fill.SetBackgroundColor(XLColor.FromHtml("#E8F3F2")) .Fill.SetBackgroundColor(XLColor.FromHtml(
entries.Any(x => x.IsExam)
? "#FFF1E8"
: entries.Any(x => x.IsExperiment)
? "#E7F5F1"
: "#E8F3F2"))
.Font.SetFontColor(XLColor.FromHtml("#173F4C")); .Font.SetFontColor(XLColor.FromHtml("#173F4C"));
} }
} }
@@ -138,11 +159,33 @@ public static class TimetableExcelExporter
} }
private static string EntryText(TimetableEntryDto entry) => private static string EntryText(TimetableEntryDto entry) =>
$"{entry.CourseName}\n" + entry.IsExperiment
? $"【实验】{entry.ExperimentProjectName}\n" +
$"{entry.CourseName} · {ArrangementMode(entry)}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" +
$"{entry.ExperimentDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
: entry.IsExam
? $"【考试】{entry.CourseName}\n" +
$"{entry.ExamPlanName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" +
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
: $"{entry.CourseName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" + $"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" + $"{Location(entry)}\n" +
$"{entry.StartWeek}-{entry.EndWeek} 周"; $"{entry.StartWeek}-{entry.EndWeek} 周";
private static string ArrangementMode(TimetableEntryDto entry) =>
entry.ExperimentArrangementMode switch
{
Domain.Academic.ExperimentArrangementMode.Centralized => "集中安排",
Domain.Academic.ExperimentArrangementMode.SelfScheduled => "自主预约",
_ => "实验安排"
};
private static string Location(TimetableEntryDto entry) => private static string Location(TimetableEntryDto entry) =>
string.Join(" · ", new[] string.Join(" · ", new[]
{ {
+16
View File
@@ -144,6 +144,9 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 || backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 || backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 || backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 || backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 || backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 || backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
@@ -294,6 +297,9 @@ builder.Services.AddScoped<ExamArrangementService>();
builder.Services.AddScoped<MakeupExamEligibilityService>(); builder.Services.AddScoped<MakeupExamEligibilityService>();
builder.Services.AddScoped<MakeupExamArrangementService>(); builder.Services.AddScoped<MakeupExamArrangementService>();
builder.Services.AddScoped<MakeupExamAutoJobProcessor>(); builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddScoped<ExamArrangementJobProcessor>();
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
builder.Services.AddScoped<ExamPublishJobProcessor>();
builder.Services.AddSingleton<BackgroundJobTelemetry>(); builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>(); builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>(); builder.Services.AddScoped<OperationalHealthService>();
@@ -358,6 +364,16 @@ builder.Services.AddRateLimiter(options =>
QueueLimit = 0, QueueLimit = 0,
AutoReplenishment = true AutoReplenishment = true
})); }));
options.AddPolicy("app-updates", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
}); });
builder.Services.AddCors(options => builder.Services.AddCors(options =>
+6 -1
View File
@@ -35,6 +35,7 @@
"AutomaticScheduleConcurrency": 1, "AutomaticScheduleConcurrency": 1,
"SchedulePublishConcurrency": 1, "SchedulePublishConcurrency": 1,
"MakeupExamAutoConcurrency": 1, "MakeupExamAutoConcurrency": 1,
"ExamArrangementConcurrency": 1,
"Exchange": "jiaowu.background-jobs", "Exchange": "jiaowu.background-jobs",
"QueuePrefix": "jiaowu.background-jobs", "QueuePrefix": "jiaowu.background-jobs",
"UseQuorumQueues": true, "UseQuorumQueues": true,
@@ -59,7 +60,11 @@
"ExpireMinutes": 60 "ExpireMinutes": 60
}, },
"Cors": { "Cors": {
"Origins": [] "Origins": [
"capacitor://localhost",
"https://localhost",
"http://localhost"
]
}, },
"OfficialDocuments": { "OfficialDocuments": {
"InstitutionName": "明序大学", "InstitutionName": "明序大学",
@@ -0,0 +1,222 @@
using System.IO.Compression;
using System.Reflection;
using System.Security.Claims;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class AppUpdatesControllerTests
{
[Fact]
public void Management_actions_are_restricted_to_super_admin()
{
var method = typeof(AppUpdatesController)
.GetMethod(nameof(AppUpdatesController.UploadRelease));
var authorize = method?.GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(authorize);
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
}
[Fact]
public async Task Upload_publish_check_and_download_form_a_complete_flow()
{
await using var fixture = await AppUpdatesFixture.CreateAsync();
var content = CreateBundle("first");
var uploaded = await fixture.Controller.UploadRelease(
UploadRequest(content, "1.0.1"),
CancellationToken.None);
var created = Assert.IsType<CreatedAtActionResult>(uploaded.Result);
var release = Assert.IsType<AppUpdateReleaseItem>(created.Value);
Assert.Equal(AppUpdateReleaseStatus.Draft, release.Status);
Assert.Equal(64, release.Sha256.Length);
var publishedAction = await fixture.Controller.PublishRelease(
release.Id,
CancellationToken.None);
var publishedResult = Assert.IsType<OkObjectResult>(
publishedAction.Result);
var published = Assert.IsType<AppUpdateReleaseItem>(
publishedResult.Value);
Assert.Equal(AppUpdateReleaseStatus.Published, published.Status);
var checkAction = await fixture.Controller.GetLatest(
"android",
"1.0",
"production",
"1.0.0",
CancellationToken.None);
var checkResult = Assert.IsType<OkObjectResult>(checkAction.Result);
var check = Assert.IsType<AppUpdateCheckResponse>(checkResult.Value);
Assert.True(check.Available);
Assert.Equal("1.0.1", check.Version);
Assert.Equal(release.Sha256, check.Sha256);
Assert.Equal($"app-updates/releases/{release.Id}/bundle", check.DownloadUrl);
var downloadAction = await fixture.Controller.DownloadBundle(
release.Id,
CancellationToken.None);
var download = Assert.IsType<FileContentResult>(downloadAction);
Assert.Equal(content, download.FileContents);
Assert.True(download.EnableRangeProcessing);
var currentAction = await fixture.Controller.GetLatest(
"android",
"1.0",
"production",
"1.0.1",
CancellationToken.None);
var currentResult = Assert.IsType<OkObjectResult>(
currentAction.Result);
Assert.False(Assert.IsType<AppUpdateCheckResponse>(
currentResult.Value).Available);
}
[Fact]
public async Task Publishing_an_archived_release_performs_a_rollback()
{
await using var fixture = await AppUpdatesFixture.CreateAsync();
var first = await UploadAsync(fixture, "1.0.1", "first");
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
var second = await UploadAsync(fixture, "1.0.2", "second");
await fixture.Controller.PublishRelease(second.Id, CancellationToken.None);
Assert.Equal(
AppUpdateReleaseStatus.Archived,
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
Assert.Equal(
AppUpdateReleaseStatus.Published,
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
fixture.Db.ChangeTracker.Clear();
Assert.Equal(
AppUpdateReleaseStatus.Published,
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
Assert.Equal(
AppUpdateReleaseStatus.Archived,
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
}
[Fact]
public async Task Invalid_or_nested_web_bundles_are_rejected()
{
await using var fixture = await AppUpdatesFixture.CreateAsync();
var invalid = await fixture.Controller.UploadRelease(
UploadRequest("not a zip"u8.ToArray(), "1.0.1"),
CancellationToken.None);
Assert.IsType<ObjectResult>(invalid.Result);
var nested = CreateBundle("nested", "dist/index.html");
var nestedResult = await fixture.Controller.UploadRelease(
UploadRequest(nested, "1.0.2"),
CancellationToken.None);
var problem = Assert.IsType<ObjectResult>(nestedResult.Result);
Assert.Contains(
"根目录",
Assert.IsType<ValidationProblemDetails>(problem.Value).Detail);
}
private static async Task<AppUpdateReleaseItem> UploadAsync(
AppUpdatesFixture fixture,
string version,
string marker)
{
var action = await fixture.Controller.UploadRelease(
UploadRequest(CreateBundle(marker), version),
CancellationToken.None);
return Assert.IsType<AppUpdateReleaseItem>(
Assert.IsType<CreatedAtActionResult>(action.Result).Value);
}
private static AppUpdateUploadRequest UploadRequest(
byte[] content,
string version)
{
var stream = new MemoryStream(content);
return new AppUpdateUploadRequest
{
Bundle = new FormFile(
stream,
0,
content.Length,
"bundle",
$"jiaowu-{version}.zip"),
Platform = "android",
Channel = "production",
Version = version,
NativeVersion = "1.0",
ReleaseNotes = $"Release {version}"
};
}
private static byte[] CreateBundle(
string marker,
string indexPath = "index.html")
{
using var output = new MemoryStream();
using (var archive = new ZipArchive(
output,
ZipArchiveMode.Create,
leaveOpen: true))
{
var index = archive.CreateEntry(indexPath);
using var writer = new StreamWriter(index.Open());
writer.Write($"<!doctype html><title>{marker}</title>");
}
return output.ToArray();
}
private sealed class AppUpdatesFixture : IAsyncDisposable
{
private AppUpdatesFixture(
AppDbContext db,
AppUpdatesController controller)
{
Db = db;
Controller = controller;
}
public AppDbContext Db { get; }
public AppUpdatesController Controller { get; }
public static async Task<AppUpdatesFixture> CreateAsync()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var controller = new AppUpdatesController(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.Name, "root"),
new Claim(
ClaimTypes.Role,
SystemRoles.SuperAdmin)
],
"test"))
}
}
};
return new AppUpdatesFixture(db, controller);
}
public async ValueTask DisposeAsync() => await Db.DisposeAsync();
}
}
@@ -0,0 +1,42 @@
using Jiaowu.Api.Infrastructure.Teaching;
namespace Jiaowu.Api.Tests;
public sealed class AttendanceCheckInChallengeTests
{
[Fact]
public void Challenge_IsBoundToSheetSecretAndTwentySecondLifetime()
{
var sheetId = Guid.NewGuid();
var issuedAt = new DateTime(2026, 7, 28, 8, 0, 0, DateTimeKind.Utc);
var challenge = AttendanceCheckInChallenge.Create(
sheetId,
"sheet-secret",
issuedAt);
Assert.True(AttendanceCheckInChallenge.TryReadSheetId(
challenge.Token,
out var parsedSheetId));
Assert.Equal(sheetId, parsedSheetId);
Assert.True(AttendanceCheckInChallenge.IsValid(
challenge.Token,
sheetId,
"sheet-secret",
issuedAt.AddSeconds(20)));
Assert.False(AttendanceCheckInChallenge.IsValid(
challenge.Token,
sheetId,
"sheet-secret",
issuedAt.AddSeconds(21)));
Assert.False(AttendanceCheckInChallenge.IsValid(
challenge.Token,
Guid.NewGuid(),
"sheet-secret",
issuedAt));
Assert.False(AttendanceCheckInChallenge.IsValid(
challenge.Token,
sheetId,
"different-secret",
issuedAt));
}
}
@@ -55,10 +55,19 @@ public sealed class AttendanceControllerTests
EnrollmentYear = 2026, EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1) EnrollmentDate = new DateOnly(2026, 9, 1)
}; };
var secondStudentUserId = Guid.NewGuid();
var secondStudentUser = new ApplicationUser
{
Id = secondStudentUserId,
UserName = "202601002",
NormalizedUserName = "202601002",
DisplayName = "吴同学"
};
var secondStudent = new Student var secondStudent = new Student
{ {
StudentNumber = "202601002", StudentNumber = "202601002",
Name = "吴同学", Name = "吴同学",
UserId = secondStudentUserId,
AdministrativeClassId = administrativeClass.Id, AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026, EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1) EnrollmentDate = new DateOnly(2026, 9, 1)
@@ -95,6 +104,7 @@ public sealed class AttendanceControllerTests
}; };
db.AddRange( db.AddRange(
studentUser, studentUser,
secondStudentUser,
college, college,
major, major,
administrativeClass, administrativeClass,
@@ -198,6 +208,11 @@ public sealed class AttendanceControllerTests
StudentId = firstStudent.Id, StudentId = firstStudent.Id,
Status = AttendanceStatus.Absent Status = AttendanceStatus.Absent
}; };
var secondQrRecord = new AttendanceRecord
{
StudentId = secondStudent.Id,
Status = AttendanceStatus.Absent
};
var qrSheet = new AttendanceSheet var qrSheet = new AttendanceSheet
{ {
TeachingTaskId = task.Id, TeachingTaskId = task.Id,
@@ -208,10 +223,17 @@ public sealed class AttendanceControllerTests
CheckInToken = "TEST-QR-TOKEN", CheckInToken = "TEST-QR-TOKEN",
CheckInStartsAt = now.AddMinutes(-1), CheckInStartsAt = now.AddMinutes(-1),
CheckInEndsAt = now.AddMinutes(10), CheckInEndsAt = now.AddMinutes(10),
Records = [qrRecord] Records = [qrRecord, secondQrRecord]
}; };
db.AttendanceSheets.Add(qrSheet); db.AttendanceSheets.Add(qrSheet);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var qrChallengeResult = await controller.GetQrChallenge(
qrSheet.Id,
CancellationToken.None);
var qrChallengeOk = Assert.IsType<OkObjectResult>(qrChallengeResult);
var qrToken = Assert.IsType<string>(
qrChallengeOk.Value!.GetType().GetProperty("Token")!.GetValue(
qrChallengeOk.Value));
var studentController = new AttendanceController( var studentController = new AttendanceController(
db, db,
@@ -231,23 +253,70 @@ public sealed class AttendanceControllerTests
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item))); item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
var infoResult = await studentController.GetCheckInInfo( var infoResult = await studentController.GetCheckInInfo(
qrSheet.CheckInToken, qrToken,
CancellationToken.None); CancellationToken.None);
Assert.IsType<OkObjectResult>(infoResult); Assert.IsType<OkObjectResult>(infoResult);
Assert.IsType<NotFoundResult>(
await studentController.GetCheckInInfo(
qrSheet.CheckInToken,
CancellationToken.None));
var qrCheckInResult = await studentController.CheckIn( var qrCheckInResult = await studentController.CheckIn(
new AttendanceCheckInRequest( new AttendanceCheckInRequest(
null, null,
qrSheet.CheckInToken, qrToken,
null, null,
null, null,
null), null,
"test-device-1",
"android"),
CancellationToken.None); CancellationToken.None);
Assert.IsType<OkObjectResult>(qrCheckInResult); Assert.IsType<OkObjectResult>(qrCheckInResult);
Assert.Equal(AttendanceStatus.Present, qrRecord.Status); Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod); Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
Assert.NotNull(qrRecord.CheckInAt); Assert.NotNull(qrRecord.CheckInAt);
Assert.Null(qrRecord.CheckInLatitude); Assert.Null(qrRecord.CheckInLatitude);
var qrAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
x => x.AttendanceSheetId == qrSheet.Id);
Assert.True(qrAttempt.IsSuccessful);
Assert.Equal("android", qrAttempt.DevicePlatform);
Assert.NotNull(qrAttempt.DeviceIdentifierHash);
var secondStudentController = new AttendanceController(
db,
new StudentDataScope(secondStudentUserId));
Assert.IsType<OkObjectResult>(
await secondStudentController.CheckIn(
new AttendanceCheckInRequest(
null,
qrToken,
null,
null,
null,
"test-device-1",
"android"),
CancellationToken.None));
var sharedDeviceAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
x => x.AttendanceSheetId == qrSheet.Id &&
x.StudentId == secondStudent.Id);
Assert.Contains(
"SharedDevice",
sharedDeviceAttempt.RiskFlags ?? string.Empty);
var sheetDetailResult = await controller.GetSheet(
qrSheet.Id,
CancellationToken.None);
var sheetDetailOk = Assert.IsType<OkObjectResult>(sheetDetailResult);
var sheetDetail = sheetDetailOk.Value!.GetType()
.GetProperty("Sheet")!
.GetValue(sheetDetailOk.Value)!;
var riskSummary = sheetDetail.GetType()
.GetProperty("RiskSummary")!
.GetValue(sheetDetail)!;
Assert.Equal(
2,
riskSummary.GetType()
.GetProperty("SharedDeviceStudentCount")!
.GetValue(riskSummary));
var locationRecord = new AttendanceRecord var locationRecord = new AttendanceRecord
{ {
@@ -282,6 +351,40 @@ public sealed class AttendanceControllerTests
Assert.IsType<ConflictObjectResult>(outsideResult); Assert.IsType<ConflictObjectResult>(outsideResult);
Assert.Null(locationRecord.CheckInAt); Assert.Null(locationRecord.CheckInAt);
var inaccurateResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.9001m,
116.4m,
150),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(inaccurateResult);
Assert.Null(locationRecord.CheckInAt);
var missingAccuracyResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.9001m,
116.4m,
null),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(missingAccuracyResult);
Assert.Null(locationRecord.CheckInAt);
var locationFailures = await db.AttendanceCheckInAttempts
.Where(x =>
x.AttendanceSheetId == locationSheet.Id &&
!x.IsSuccessful)
.OrderBy(x => x.CreatedAt)
.ToListAsync();
Assert.Equal(3, locationFailures.Count);
Assert.Contains(
locationFailures,
x => (x.RiskFlags ?? string.Empty).Contains(
"RepeatedFailures",
StringComparison.Ordinal));
var nearbyResult = await studentController.CheckIn( var nearbyResult = await studentController.CheckIn(
new AttendanceCheckInRequest( new AttendanceCheckInRequest(
locationSheet.Id, locationSheet.Id,
@@ -21,6 +21,14 @@ public sealed class AutomaticScheduleGeneratorTests
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await db.Database.ExecuteSqlRawAsync( await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"BackgroundJobOutboxMessages\""); "DROP TABLE \"BackgroundJobOutboxMessages\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamRoomInvigilators\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamSeats\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamRoomSessions\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamRooms\"");
var migrator = new DevelopmentSqliteMigrator( var migrator = new DevelopmentSqliteMigrator(
db, db,
@@ -30,6 +38,10 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0); Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0); Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
Assert.True(await db.ExamRooms.CountAsync() == 0);
Assert.True(await db.ExamRoomSessions.CountAsync() == 0);
Assert.True(await db.ExamSeats.CountAsync() == 0);
Assert.True(await db.ExamRoomInvigilators.CountAsync() == 0);
Assert.True(await db.Database Assert.True(await db.Database
.SqlQueryRaw<int>( .SqlQueryRaw<int>(
""" """
@@ -124,6 +136,91 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.Empty(await db.ScheduleEntries.ToListAsync()); Assert.Empty(await db.ScheduleEntries.ToListAsync());
} }
[Fact]
public async Task Generator_excludes_practice_hours_from_regular_schedule()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-LAB",
Name = "2026 实验学时测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
var college = new College { Code = "LAB", Name = "实验学院" };
var course = new Course
{
Code = "LAB-01",
Name = "实验混合课程",
College = college,
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16
};
var task = new TeachingTask
{
TaskNumber = "TASK-LAB",
Name = "实验混合教学班",
AcademicTerm = term,
Course = course,
Capacity = 30,
StartWeek = 1,
EndWeek = 16,
WeeklyHours = 2,
Status = TeachingTaskStatus.Published
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "实验学时拆分测试",
Version = "V1"
};
db.AddRange(term, college, course, task, plan);
db.ScheduleTimeSlots.AddRange(
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 1,
Name = "第 1 节",
StartsAt = new TimeOnly(8, 0),
EndsAt = new TimeOnly(8, 45)
},
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 2,
Name = "第 2 节",
StartsAt = new TimeOnly(8, 55),
EndsAt = new TimeOnly(9, 40)
});
db.TeachingTaskScheduleConstraints.Add(
new TeachingTaskScheduleConstraint
{
TeachingTask = task,
RequiresClassroom = false
});
await db.SaveChangesAsync();
var result = await new AutomaticScheduleGenerator(db)
.GenerateAsync(plan, CancellationToken.None);
Assert.Equal(1, result.CreatedEntries);
Assert.Equal(1, result.CompletedTasks);
Assert.Equal(
1,
(await db.ScheduleEntries.SingleAsync()).PeriodCount);
}
[Fact] [Fact]
public async Task Generator_schedules_roomless_course_within_allowed_time() public async Task Generator_schedules_roomless_course_within_allowed_time()
{ {
@@ -0,0 +1,205 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
public sealed class ExamArrangementJobControllerTests
{
[Fact]
public async Task Formal_and_makeup_arrangement_are_enqueued_with_outbox_messages()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-EXAM-JOB",
Name = "考试后台任务测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var formalPlan = new ExamPlan
{
AcademicTerm = term,
Name = "正式考试草稿"
};
var makeupPlan = new MakeupExamPlan
{
AcademicTerm = term,
Name = "补考草稿"
};
db.AddRange(term, formalPlan, makeupPlan);
await db.SaveChangesAsync();
var scope = new ManagerDataScope();
var exams = new ExamsController(db, scope, NoOpAppCache.Instance);
var makeup = new MakeupExamsController(
db,
scope,
new MakeupExamEligibilityService(db));
var formalResult = await exams.AutoArrange(
formalPlan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None);
var makeupResult = await makeup.AutoArrange(
makeupPlan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None);
Assert.IsType<AcceptedAtActionResult>(formalResult);
Assert.IsType<AcceptedAtActionResult>(makeupResult);
var jobs = await db.ExamArrangementJobs
.OrderBy(x => x.Kind)
.ToListAsync();
Assert.Equal(2, jobs.Count);
Assert.Equal(ExamArrangementKind.FormalExam, jobs[0].Kind);
Assert.Equal(formalPlan.Id, jobs[0].ActivePlanId);
Assert.Equal(ExamArrangementKind.MakeupExam, jobs[1].Kind);
Assert.Equal(makeupPlan.Id, jobs[1].ActivePlanId);
Assert.All(jobs, job =>
Assert.Equal(ExamArrangementJobStatus.Queued, job.Status));
var outbox = await db.BackgroundJobOutboxMessages.ToListAsync();
Assert.Equal(2, outbox.Count);
Assert.All(outbox, message =>
{
Assert.Equal(BackgroundJobKind.ExamArrangement, message.JobKind);
Assert.Equal(BackgroundJobOutboxState.Pending, message.State);
});
Assert.Equal(
jobs.Select(x => x.Id).Order(),
outbox.Select(x => x.JobId).Order());
}
[Fact]
public async Task Repeated_formal_arrangement_returns_the_existing_active_job()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-EXAM-DUP",
Name = "考试任务去重测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var plan = new ExamPlan
{
AcademicTerm = term,
Name = "正式考试草稿"
};
db.AddRange(term, plan);
await db.SaveChangesAsync();
var controller = new ExamsController(
db,
new ManagerDataScope(),
NoOpAppCache.Instance);
var first = Assert.IsType<AcceptedAtActionResult>(
await controller.AutoArrange(
plan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None));
var second = Assert.IsType<AcceptedAtActionResult>(
await controller.AutoArrange(
plan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None));
Assert.Equal(202, first.StatusCode);
Assert.Equal(202, second.StatusCode);
Assert.Equal(1, await db.ExamArrangementJobs.CountAsync());
Assert.Equal(1, await db.BackgroundJobOutboxMessages.CountAsync());
}
[Fact]
public async Task Processor_records_business_failure_and_releases_active_plan()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-EXAM-FAIL",
Name = "考试任务失败测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var plan = new ExamPlan
{
AcademicTerm = term,
Name = "缺少节次配置的考试计划"
};
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.FormalExam,
PlanId = plan.Id,
ActivePlanId = plan.Id,
AssignClassrooms = true,
AssignInvigilators = true
};
db.AddRange(term, plan, job);
await db.SaveChangesAsync();
var processor = new ExamArrangementJobProcessor(
db,
new ExamArrangementService(db),
new MakeupExamArrangementService(db),
NoOpAppCache.Instance,
NullLogger<ExamArrangementJobProcessor>.Instance);
await processor.ProcessAsync(job.Id, CancellationToken.None);
db.ChangeTracker.Clear();
var failed = await db.ExamArrangementJobs.SingleAsync();
Assert.Equal(ExamArrangementJobStatus.Failed, failed.Status);
Assert.Null(failed.ActivePlanId);
Assert.Equal("编排失败", failed.CurrentStep);
Assert.False(string.IsNullOrWhiteSpace(failed.ErrorMessage));
Assert.NotNull(failed.CompletedAt);
}
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"考试管理员",
null,
DataScope.All,
new HashSet<string> { SystemRoles.AcademicAdmin });
}
}
@@ -9,7 +9,7 @@ namespace Jiaowu.Api.Tests;
public sealed class ExamArrangementServiceTests public sealed class ExamArrangementServiceTests
{ {
[Fact] [Fact]
public async Task Normal_arrangement_only_updates_selected_sessions_and_requested_resource() public async Task Normal_arrangement_creates_room_seats_for_selected_session()
{ {
await using var connection = new SqliteConnection("Data Source=:memory:"); await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(); await connection.OpenAsync();
@@ -37,21 +37,81 @@ public sealed class ExamArrangementServiceTests
plan.Id, plan.Id,
[selected.Id], [selected.Id],
assignClassrooms: true, assignClassrooms: true,
assignInvigilators: false, assignInvigilators: true,
CancellationToken.None); CancellationToken.None);
var persistedSelected = await db.ExamSessions.FindAsync(selected.Id); var room = await db.ExamRooms
var persistedUntouched = await db.ExamSessions.FindAsync(untouched.Id); .Include(x => x.SessionLinks)
.Include(x => x.Seats)
.Include(x => x.Invigilators)
.SingleAsync();
Assert.True(result.Success); Assert.True(result.Success);
Assert.NotNull(persistedSelected!.ClassroomId); Assert.Equal(seed.LargeClassroom.Id, room.ClassroomId);
Assert.Null(persistedUntouched!.ClassroomId); Assert.Equal(selected.Id, Assert.Single(room.SessionLinks).ExamSessionId);
Assert.Empty(persistedSelected.Invigilators); Assert.Equal(3, room.Seats.Count);
Assert.Contains("1个场次处理完成", result.Message); Assert.Single(room.Invigilators);
Assert.False(await db.ExamRoomSessions.AnyAsync(
x => x.ExamSessionId == untouched.Id));
Assert.Contains("1个教学班场次处理完成", result.Message);
Assert.Contains("1名监考教师", result.Message);
Assert.Contains("程序设计", result.Message); Assert.Contains("程序设计", result.Message);
} }
[Fact] [Fact]
public async Task Makeup_arrangement_only_updates_selected_sessions_and_requested_resource() public async Task Same_course_sessions_are_interleaved_and_split_across_rooms()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var seed = await SeedBaseDataAsync(db, "MIXED");
seed.LargeClassroom.Capacity = 8;
seed.SmallClassroom.Capacity = 4;
var plan = new ExamPlan
{
AcademicTermId = seed.Term.Id,
Name = "期末考试"
};
var first = NewExamSession(plan.Id, seed.FirstTask.Id);
var second = NewExamSession(plan.Id, seed.SameCourseTask.Id);
plan.Sessions.Add(first);
plan.Sessions.Add(second);
db.ExamPlans.Add(plan);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var result = await new ExamArrangementService(db).ArrangeAsync(
plan.Id,
[first.Id],
assignClassrooms: true,
assignInvigilators: false,
CancellationToken.None);
var rooms = await db.ExamRooms
.Include(x => x.SessionLinks)
.Include(x => x.Seats)
.OrderByDescending(x => x.Seats.Count)
.ToListAsync();
Assert.True(result.Success);
Assert.Equal(2, rooms.Count);
Assert.Equal([4, 2], rooms.Select(x => x.Seats.Count).ToArray());
Assert.All(rooms, room =>
{
Assert.Equal(2, room.SessionLinks.Count);
Assert.Equal(2, room.Seats.Select(x => x.ExamSessionId).Distinct().Count());
Assert.True(room.Seats.Count <= 4);
});
Assert.Equal(6, await db.ExamSeats.CountAsync());
Assert.Contains("2个教学班混排至2个考场", result.Message);
Assert.Contains("自动包含同组1个场次", result.Message);
}
[Fact]
public async Task Makeup_arrangement_only_updates_selected_session()
{ {
await using var connection = new SqliteConnection("Data Source=:memory:"); await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(); await connection.OpenAsync();
@@ -79,16 +139,22 @@ public sealed class ExamArrangementServiceTests
plan.Id, plan.Id,
[selected.Id], [selected.Id],
assignClassrooms: true, assignClassrooms: true,
assignInvigilators: false, assignInvigilators: true,
CancellationToken.None); CancellationToken.None);
var persistedSelected = await db.MakeupExamSessions.FindAsync(selected.Id); var persistedSelected = await db.MakeupExamSessions
var persistedUntouched = await db.MakeupExamSessions.FindAsync(untouched.Id); .Include(x => x.Invigilators)
.SingleAsync(x => x.Id == selected.Id);
var persistedUntouched = await db.MakeupExamSessions
.Include(x => x.Invigilators)
.SingleAsync(x => x.Id == untouched.Id);
Assert.True(result.Success); Assert.True(result.Success);
Assert.NotNull(persistedSelected!.ClassroomId); Assert.NotNull(persistedSelected.ClassroomId);
Assert.Null(persistedUntouched!.ClassroomId); Assert.Null(persistedUntouched.ClassroomId);
Assert.Empty(persistedSelected.Invigilators); Assert.Single(persistedSelected.Invigilators);
Assert.Empty(persistedUntouched.Invigilators);
Assert.Contains("1个场次处理完成", result.Message); Assert.Contains("1个场次处理完成", result.Message);
Assert.Contains("1名监考教师", result.Message);
Assert.Contains("程序设计", result.Message); Assert.Contains("程序设计", result.Message);
} }
@@ -147,6 +213,34 @@ public sealed class ExamArrangementServiceTests
BuildingId = building.Id, BuildingId = building.Id,
Capacity = 80 Capacity = 80
}; };
var smallClassroom = new Classroom
{
Code = $"R-SMALL-{suffix}",
Name = "102",
BuildingId = building.Id,
Capacity = 1
};
var major = new Major
{
Code = $"MAJOR-{suffix}",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = $"CLASS-{suffix}",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
var secondAdministrativeClass = new AdministrativeClass
{
Code = $"CLASS-2-{suffix}",
Name = "计科 2026-2 班",
MajorId = major.Id,
Grade = 2026
};
var firstCourse = new Course var firstCourse = new Course
{ {
Code = $"COURSE-1-{suffix}", Code = $"COURSE-1-{suffix}",
@@ -165,7 +259,14 @@ public sealed class ExamArrangementServiceTests
Name = "程序设计教学班", Name = "程序设计教学班",
AcademicTermId = term.Id, AcademicTermId = term.Id,
CourseId = firstCourse.Id, CourseId = firstCourse.Id,
Status = TeachingTaskStatus.Published Status = TeachingTaskStatus.Published,
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
}; };
var secondTask = new TeachingTask var secondTask = new TeachingTask
{ {
@@ -175,6 +276,28 @@ public sealed class ExamArrangementServiceTests
CourseId = secondCourse.Id, CourseId = secondCourse.Id,
Status = TeachingTaskStatus.Published Status = TeachingTaskStatus.Published
}; };
var sameCourseTask = new TeachingTask
{
TaskNumber = $"TASK-3-{suffix}",
Name = "程序设计第二教学班",
AcademicTermId = term.Id,
CourseId = firstCourse.Id,
Status = TeachingTaskStatus.Published,
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = secondAdministrativeClass.Id
}
]
};
var invigilator = new Teacher
{
TeacherNumber = $"INVIGILATOR-{suffix}",
Name = "监考教师",
CollegeId = college.Id,
Status = TeacherStatus.Active
};
db.AddRange( db.AddRange(
term, term,
@@ -182,10 +305,64 @@ public sealed class ExamArrangementServiceTests
college, college,
building, building,
classroom, classroom,
smallClassroom,
major,
administrativeClass,
secondAdministrativeClass,
new Student
{
StudentNumber = $"STUDENT-1-{suffix}",
Name = "学生甲",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-2-{suffix}",
Name = "学生乙",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-3-{suffix}",
Name = "学生丙",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-4-{suffix}",
Name = "学生丁",
AdministrativeClassId = secondAdministrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-5-{suffix}",
Name = "学生戊",
AdministrativeClassId = secondAdministrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-6-{suffix}",
Name = "学生己",
AdministrativeClassId = secondAdministrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
firstCourse, firstCourse,
secondCourse, secondCourse,
firstTask, firstTask,
secondTask, secondTask,
sameCourseTask,
invigilator,
new ScheduleTimeSlot new ScheduleTimeSlot
{ {
AcademicTermId = term.Id, AcademicTermId = term.Id,
@@ -203,11 +380,20 @@ public sealed class ExamArrangementServiceTests
EndsAt = new TimeOnly(9, 50) EndsAt = new TimeOnly(9, 50)
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new SeedData(term, firstTask, secondTask); return new SeedData(
term,
firstTask,
secondTask,
sameCourseTask,
classroom,
smallClassroom);
} }
private sealed record SeedData( private sealed record SeedData(
AcademicTerm Term, AcademicTerm Term,
TeachingTask FirstTask, TeachingTask FirstTask,
TeachingTask SecondTask); TeachingTask SecondTask,
TeachingTask SameCourseTask,
Classroom LargeClassroom,
Classroom SmallClassroom);
} }
@@ -0,0 +1,272 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExamDeletionControllerTests
{
[Fact]
public async Task RemoveSessionsBatch_ValidatesCompleteSelectionBeforeRemoving()
{
await using var fixture = await Fixture.CreateAsync();
var controller = fixture.CreateController();
var selectedIds = fixture.DraftPlan.Sessions
.Select(x => x.Id)
.ToArray();
var otherPlanSessionId = fixture.PublishedPlan.Sessions.Single().Id;
var invalidResult = await controller.RemoveSessionsBatch(
fixture.DraftPlan.Id,
new RemoveExamSessionsBatchRequest(
[selectedIds[0], otherPlanSessionId]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(invalidResult);
Assert.Equal(2, await fixture.Db.ExamSessions.CountAsync(
x => x.ExamPlanId == fixture.DraftPlan.Id));
var result = Assert.IsType<OkObjectResult>(
await controller.RemoveSessionsBatch(
fixture.DraftPlan.Id,
new RemoveExamSessionsBatchRequest(selectedIds),
CancellationToken.None));
Assert.Equal(2, ReadIntProperty(result.Value!, "removedCount"));
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
x => x.ExamPlanId == fixture.DraftPlan.Id));
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
x => selectedIds.Contains(x.ExamSessionId)));
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == fixture.DraftPlan.Id));
}
[Fact]
public async Task RemoveSessionsBatch_RejectsPublishedPlan()
{
await using var fixture = await Fixture.CreateAsync();
var controller = fixture.CreateController();
var sessionId = fixture.PublishedPlan.Sessions.Single().Id;
var result = await controller.RemoveSessionsBatch(
fixture.PublishedPlan.Id,
new RemoveExamSessionsBatchRequest([sessionId]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.True(await fixture.Db.ExamSessions.AnyAsync(
x => x.Id == sessionId));
}
[Fact]
public async Task DeletePlan_DeletesDraftWithSessions_AndRejectsPublishedPlan()
{
await using var fixture = await Fixture.CreateAsync();
var controller = fixture.CreateController();
var draftPlanId = fixture.DraftPlan.Id;
var publishedPlanId = fixture.PublishedPlan.Id;
var draftSessionIds = fixture.DraftPlan.Sessions
.Select(x => x.Id)
.ToArray();
var publishedResult = await controller.DeletePlan(
publishedPlanId,
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(publishedResult);
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == publishedPlanId));
var draftResult = await controller.DeletePlan(
draftPlanId,
CancellationToken.None);
Assert.IsType<NoContentResult>(draftResult);
Assert.False(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == draftPlanId));
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
x => x.ExamPlanId == draftPlanId));
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
x => draftSessionIds.Contains(x.ExamSessionId)));
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == publishedPlanId));
}
private static int ReadIntProperty(object value, string name) =>
(int)value.GetType().GetProperty(name)!.GetValue(value)!;
private sealed class Fixture : IAsyncDisposable
{
private readonly SqliteConnection connection;
private Fixture(
SqliteConnection connection,
AppDbContext db,
ExamPlan draftPlan,
ExamPlan publishedPlan)
{
this.connection = connection;
Db = db;
DraftPlan = draftPlan;
PublishedPlan = publishedPlan;
}
public AppDbContext Db { get; }
public ExamPlan DraftPlan { get; }
public ExamPlan PublishedPlan { get; }
public static async Task<Fixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var college = new College
{
Code = "CS",
Name = "计算机学院"
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var firstTask = CreateTask(
term.Id,
course.Id,
"2026-1-CS101-01");
var secondTask = CreateTask(
term.Id,
course.Id,
"2026-1-CS101-02");
var invigilator = new Teacher
{
TeacherNumber = "T001",
Name = "张老师",
CollegeId = college.Id
};
var firstDraftSession = CreateSession(
firstTask.Id,
new DateOnly(2027, 1, 8));
firstDraftSession.Invigilators =
[
new ExamSessionInvigilator
{
TeacherId = invigilator.Id
}
];
var draftPlan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "期末考试草稿",
Sessions =
[
firstDraftSession,
CreateSession(secondTask.Id, new DateOnly(2027, 1, 9))
]
};
var publishedPlan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "已发布期末考试",
Status = ExamPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Sessions =
[
CreateSession(firstTask.Id, new DateOnly(2027, 1, 10))
]
};
db.AddRange(
college,
term,
course,
firstTask,
secondTask,
invigilator,
draftPlan,
publishedPlan);
await db.SaveChangesAsync();
return new Fixture(
connection,
db,
draftPlan,
publishedPlan);
}
public ExamsController CreateController() => new(
Db,
new ManagerDataScope(),
NoOpAppCache.Instance);
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
private static TeachingTask CreateTask(
Guid termId,
Guid courseId,
string number) => new()
{
AcademicTermId = termId,
CourseId = courseId,
TaskNumber = number,
Name = $"教学班 {number}",
Capacity = 60,
Status = TeachingTaskStatus.Published
};
private static ExamSession CreateSession(
Guid taskId,
DateOnly examDate) => new()
{
TeachingTaskId = taskId,
ExamDate = examDate,
StartPeriod = 1,
PeriodCount = 2,
StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)),
EndsAt = examDate.ToDateTime(new TimeOnly(9, 50)),
RequiredInvigilatorCount = 2
};
}
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"考试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.AcademicAdmin]));
}
}
@@ -0,0 +1,136 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExamPlanPaginationTests
{
[Fact]
public async Task GetPlan_PaginatesSearchesAndFiltersSessionsOnServer()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var tasks = Enumerable.Range(1, 11)
.Select(number => new TeachingTask
{
TaskNumber = $"2026-1-CS101-{number:00}",
Name = number == 11 ? "专项课程教学班" : $"程序设计教学班 {number}",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published
})
.ToList();
var plan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "期末考试",
Status = ExamPlanStatus.Draft,
Sessions = tasks.Select((task, index) => new ExamSession
{
TeachingTaskId = task.Id,
ExamDate = new DateOnly(2027, 1, 8).AddDays(index),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc)
.AddDays(index),
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc)
.AddDays(index),
RequiredInvigilatorCount = 1
}).ToList()
};
db.AddRange(college, course, term);
db.AddRange(tasks);
db.Add(plan);
await db.SaveChangesAsync();
var controller = new ExamsController(
db,
new AllScope(),
NoOpAppCache.Instance);
var secondPage = ReadPlan(await controller.GetPlan(
plan.Id,
page: 2,
pageSize: 10,
cancellationToken: CancellationToken.None));
Assert.Equal(11, ReadInt(secondPage, "TotalSessionCount"));
Assert.Equal(11, ReadInt(secondPage, "FilteredSessionCount"));
Assert.Equal(2, ReadInt(secondPage, "SessionPage"));
Assert.Single(ReadItems(secondPage, "Sessions"));
Assert.Equal(11, ReadItems(secondPage, "ScheduledTeachingTaskIds").Count);
var searchPage = ReadPlan(await controller.GetPlan(
plan.Id,
keyword: "专项",
cancellationToken: CancellationToken.None));
Assert.Equal(11, ReadInt(searchPage, "TotalSessionCount"));
Assert.Equal(1, ReadInt(searchPage, "FilteredSessionCount"));
Assert.Single(ReadItems(searchPage, "Sessions"));
var completedPage = ReadPlan(await controller.GetPlan(
plan.Id,
allocation: "complete",
cancellationToken: CancellationToken.None));
Assert.Equal(0, ReadInt(completedPage, "FilteredSessionCount"));
Assert.Empty(ReadItems(completedPage, "Sessions"));
}
private static object ReadPlan(ActionResult result) =>
Assert.IsType<OkObjectResult>(result).Value!;
private static int ReadInt(object value, string property) =>
(int)value.GetType().GetProperty(property)!.GetValue(value)!;
private static List<object> ReadItems(object value, string property) =>
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
value.GetType().GetProperty(property)!.GetValue(value))
.Cast<object>()
.ToList();
private sealed class AllScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
}
@@ -0,0 +1,60 @@
using ClosedXML.Excel;
using Jiaowu.Api.Infrastructure.Exams;
namespace Jiaowu.Api.Tests;
public sealed class ExamSignInWorkbookExporterTests
{
[Fact]
public void Create_builds_summary_and_printable_session_sheets()
{
var sessionId = Guid.NewGuid();
var session = new ExamSignInSessionData(
sessionId,
new DateOnly(2027, 1, 8),
new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
"CS101",
"程序设计基础",
"2026-1-CS101-01",
"第一教学楼",
"101",
["张老师", "李老师"],
[
new ExamSignInStudentData(
Guid.NewGuid(),
"202601001",
"周同学",
"计科 2026-1 班")
]);
var data = new ExamSignInWorkbookData(
"2026—2027 学年第一学期期末考试",
"2026—2027 学年第一学期",
[
session,
session with { SessionId = Guid.NewGuid() }
]);
var bytes = ExamSignInWorkbookExporter.Create(data);
using var stream = new MemoryStream(bytes);
using var workbook = new XLWorkbook(stream);
Assert.Equal(3, workbook.Worksheets.Count);
Assert.Equal("考场汇总", workbook.Worksheet(1).Name);
Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F5").GetDouble());
Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F6").GetDouble());
Assert.Equal(
workbook.Worksheets.Count,
workbook.Worksheets.Select(x => x.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count());
var signInSheet = workbook.Worksheet(2);
Assert.Equal("考试考场签名单", signInSheet.Cell("A1").GetString());
Assert.Contains("第一教学楼", signInSheet.Cell("E4").GetString());
Assert.Equal("202601001", signInSheet.Cell("C8").GetString());
Assert.Equal("周同学", signInSheet.Cell("D8").GetString());
Assert.Equal(XLPageOrientation.Portrait, signInSheet.PageSetup.PageOrientation);
Assert.Equal(XLPaperSize.A4Paper, signInSheet.PageSetup.PaperSize);
}
}
@@ -0,0 +1,454 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExperimentGradesControllerTests
{
[Fact]
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
fixture.Db.ExperimentProjects.Add(new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-C",
Name = "集中实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
});
await fixture.Db.SaveChangesAsync();
var project = await fixture.Db.ExperimentProjects.SingleAsync();
var experimentGrades = fixture.ExperimentGrades(fixture.AdminScope);
var created = await experimentGrades.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[
new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
50),
new ExperimentGradeItemRequest(
"报告",
ExperimentGradeItemKind.Report,
50)
]),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var sheet = await fixture.Db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.SingleAsync();
var record = Assert.Single(sheet.Records);
var operation = sheet.Items.Single(x => x.Name == "操作");
var report = sheet.Items.Single(x => x.Name == "报告");
Assert.IsType<NoContentResult>(
await experimentGrades.UpdateRecords(
sheet.Id,
new ExperimentGradeRecordsRequest(
[
new ExperimentGradeRecordRequest(
record.Id,
ExperimentParticipationStatus.Completed,
false,
1,
"https://example.test/report",
DateTime.UtcNow,
false,
"完成规范",
[
new ExperimentGradeItemScoreRequest(
operation.Id,
80,
"操作正确"),
new ExperimentGradeItemScoreRequest(
report.Id,
90,
"报告完整")
])
]),
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Submit(
sheet.Id,
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Approve(
sheet.Id,
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Publish(
sheet.Id,
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var publishedRecord = await fixture.Db.ExperimentGradeRecords
.SingleAsync();
Assert.Equal(85m, publishedRecord.TotalScore);
Assert.True(publishedRecord.IsPassed);
Assert.Equal(
ExperimentGradeSheetStatus.Published,
(await fixture.Db.ExperimentGradeSheets.SingleAsync()).Status);
var studentResult = await fixture
.ExperimentGrades(fixture.StudentScope)
.GetMine(CancellationToken.None);
Assert.IsType<OkObjectResult>(studentResult);
var courseSheet = new GradeSheet
{
TeachingTaskId = fixture.Task.Id,
RegularWeight = 0,
FinalWeight = 50,
Items =
[
new GradeItem
{
Name = "实验",
Weight = 50,
SortOrder = 0
}
],
Records =
[
new GradeRecord
{
StudentId = fixture.Student.Id,
FinalScore = 80
}
]
};
var courseItem = courseSheet.Items.Single();
courseSheet.Records.Single().ItemScores.Add(new GradeItemScore
{
GradeItemId = courseItem.Id
});
fixture.Db.GradeSheets.Add(courseSheet);
await fixture.Db.SaveChangesAsync();
var grades = new GradesController(fixture.Db, fixture.AdminScope);
Assert.IsType<OkObjectResult>(
await grades.ImportExperimentScores(
courseSheet.Id,
new ImportExperimentScoresRequest(courseItem.Id),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var importedItem = await fixture.Db.GradeItems.SingleAsync();
var importedRecord = await fixture.Db.GradeRecords
.Include(x => x.ItemScores)
.SingleAsync();
Assert.Equal(
GradeItemSourceType.ExperimentSummary,
importedItem.SourceType);
Assert.NotNull(importedItem.SourceSnapshotAt);
Assert.Equal(85m, importedRecord.ItemScores.Single().Score);
Assert.Equal(82.5m, importedRecord.TotalScore);
var manualOverride = await grades.UpdateRecords(
courseSheet.Id,
new GradeRecordsRequest(
[
new GradeRecordRequest(
importedRecord.Id,
null,
80,
[new GradeItemScoreRequest(importedItem.Id, 90)],
GradeExamStatus.Normal,
null)
]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(manualOverride);
}
[Fact]
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-S",
Name = "自主实验",
ArrangementMode = ExperimentArrangementMode.SelfScheduled,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
var session = new ExperimentSession
{
ExperimentProjectId = project.Id,
ClassroomId = fixture.Classroom.Id,
SessionDate = fixture.Term.StartDate,
StartPeriod = 1,
PeriodCount = 2,
Capacity = 10,
ReservedCount = 1
};
var booking = new ExperimentBooking
{
ExperimentProjectId = project.Id,
ExperimentSessionId = session.Id,
StudentId = fixture.Student.Id,
Status = ExperimentBookingStatus.Booked,
BookedAt = DateTime.UtcNow
};
fixture.Db.AddRange(project, session, booking);
await fixture.Db.SaveChangesAsync();
var controller = fixture.ExperimentGrades(fixture.AdminScope);
await controller.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[
new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
100)
]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
Assert.Single(fixture.Db.ExperimentGradeRecords);
booking.Status = ExperimentBookingStatus.Cancelled;
booking.CancelledAt = DateTime.UtcNow;
await fixture.Db.SaveChangesAsync();
Assert.IsType<OkObjectResult>(
await controller.SyncParticipants(
sheet.Id,
CancellationToken.None));
Assert.Empty(fixture.Db.ExperimentGradeRecords);
fixture.Db.ChangeTracker.Clear();
booking = await fixture.Db.ExperimentBookings.SingleAsync();
controller = fixture.ExperimentGrades(fixture.AdminScope);
booking.Status = ExperimentBookingStatus.Booked;
booking.CancelledAt = null;
await fixture.Db.SaveChangesAsync();
Assert.IsType<OkObjectResult>(
await controller.SyncParticipants(
sheet.Id,
CancellationToken.None));
Assert.Single(fixture.Db.ExperimentGradeRecords);
}
private sealed class ExperimentGradeFixture : IAsyncDisposable
{
private ExperimentGradeFixture(
SqliteConnection connection,
AppDbContext db,
AcademicTerm term,
TeachingTask task,
Student student,
Classroom classroom,
ICurrentUserDataScope adminScope,
ICurrentUserDataScope studentScope)
{
Connection = connection;
Db = db;
Term = term;
Task = task;
Student = student;
Classroom = classroom;
AdminScope = adminScope;
StudentScope = studentScope;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public AcademicTerm Term { get; }
public TeachingTask Task { get; }
public Student Student { get; }
public Classroom Classroom { get; }
public ICurrentUserDataScope AdminScope { get; }
public ICurrentUserDataScope StudentScope { get; }
public static async Task<ExperimentGradeFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var admin = User("lab-admin", "实验管理员");
var studentUser = User("lab-student", "实验学生");
var teacherUser = User("lab-teacher", "实验教师");
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building
{
CampusId = campus.Id,
Code = "LAB",
Name = "实验楼"
};
var classroom = new Classroom
{
BuildingId = building.Id,
Code = "LAB201",
Name = "实验室 201",
Capacity = 30
};
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
CollegeId = college.Id,
Code = "CS",
Name = "计算机科学与技术",
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
MajorId = major.Id,
Code = "CS2099",
Name = "计科 2099",
Grade = 2099
};
var student = new Student
{
UserId = studentUser.Id,
AdministrativeClassId = administrativeClass.Id,
StudentNumber = "20990001",
Name = "实验学生",
EnrollmentYear = 2099,
EnrollmentDate = new DateOnly(2099, 9, 1)
};
var teacher = new Teacher
{
UserId = teacherUser.Id,
CollegeId = college.Id,
TeacherNumber = "T2099",
Name = "实验教师"
};
var term = new AcademicTerm
{
Code = "2099-1",
Name = "2099—2100 学年第一学期",
AcademicYear = "2099-2100",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2099, 9, 7),
EndDate = new DateOnly(2100, 1, 17),
IsCurrent = true
};
var course = new Course
{
CollegeId = college.Id,
Code = "CSLAB",
Name = "系统实验",
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var task = new TeachingTask
{
AcademicTermId = term.Id,
CourseId = course.Id,
TaskNumber = "2099-1-CSLAB-01",
Name = "系统实验教学班",
Capacity = 30,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
db.AddRange(
admin,
studentUser,
teacherUser,
campus,
building,
classroom,
college,
major,
administrativeClass,
student,
teacher,
term,
course,
task);
await db.SaveChangesAsync();
return new ExperimentGradeFixture(
connection,
db,
term,
task,
student,
classroom,
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
Scope(studentUser, SystemRoles.Student, DataScope.Self));
}
public ExperimentGradesController ExperimentGrades(
ICurrentUserDataScope scope) => new(Db, scope);
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await Connection.DisposeAsync();
}
private static ApplicationUser User(
string userName,
string displayName) =>
new()
{
Id = Guid.NewGuid(),
UserName = userName,
NormalizedUserName = userName.ToUpperInvariant(),
DisplayName = displayName,
IsEnabled = true
};
private static ICurrentUserDataScope Scope(
ApplicationUser user,
string role,
DataScope dataScope) =>
new FixedScope(new CurrentUserScope(
user.Id,
user.DisplayName,
user.CollegeId,
dataScope,
new HashSet<string>([role])));
}
private sealed class FixedScope(CurrentUserScope current)
: ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = current;
}
}
@@ -0,0 +1,536 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExperimentsControllerTests
{
[Fact]
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
var created = await controller.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
var sessionResult = await controller.CreateSession(
project.Id,
fixture.SessionRequest(1, 2, 10),
CancellationToken.None);
Assert.IsType<CreatedResult>(sessionResult);
Assert.Equal(
1,
(await fixture.Db.ExperimentSessions.SingleAsync()).Capacity);
var published = await controller.PublishProject(
project.Id,
CancellationToken.None);
Assert.IsType<NoContentResult>(published);
Assert.Equal(
ExperimentProjectStatus.Published,
(await fixture.Db.ExperimentProjects.SingleAsync()).Status);
var session = await fixture.Db.ExperimentSessions.SingleAsync();
var participants = await controller.GetParticipants(
session.Id,
CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(participants);
var rows = Assert.IsAssignableFrom<IEnumerable<object>>(ok.Value);
Assert.Single(rows);
}
[Fact]
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
await manager.CreateSession(
project.Id,
fixture.SessionRequest(3, 2),
CancellationToken.None);
await manager.CreateSession(
project.Id,
fixture.SessionRequest(5, 2),
CancellationToken.None);
await manager.PublishProject(project.Id, CancellationToken.None);
var sessions = await fixture.Db.ExperimentSessions
.OrderBy(x => x.StartPeriod)
.ToListAsync();
var student = fixture.Controller(fixture.StudentScope);
Assert.IsType<NoContentResult>(
await student.Book(sessions[0].Id, CancellationToken.None));
Assert.IsType<ConflictObjectResult>(
await student.Book(sessions[1].Id, CancellationToken.None));
var booking = await fixture.Db.ExperimentBookings.SingleAsync();
Assert.IsType<NoContentResult>(
await student.CancelBooking(booking.Id, CancellationToken.None));
Assert.IsType<NoContentResult>(
await student.Book(sessions[1].Id, CancellationToken.None));
var active = await fixture.Db.ExperimentBookings.SingleAsync();
Assert.Equal(ExperimentBookingStatus.Booked, active.Status);
Assert.Equal(sessions[1].Id, active.ExperimentSessionId);
Assert.Equal(0, (await fixture.Db.ExperimentSessions.FindAsync(
sessions[0].Id))!.ReservedCount);
Assert.Equal(1, (await fixture.Db.ExperimentSessions.FindAsync(
sessions[1].Id))!.ReservedCount);
}
[Fact]
public async Task StudentBooking_RejectsPublishedTimetableConflict()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
await manager.CreateSession(
project.Id,
fixture.SessionRequest(1, 2),
CancellationToken.None);
await manager.PublishProject(project.Id, CancellationToken.None);
fixture.Db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = fixture.Term.Id,
Name = "正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.SecondClassroom.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 18,
WeekPattern = WeekPattern.All
}
]
});
await fixture.Db.SaveChangesAsync();
var session = await fixture.Db.ExperimentSessions.SingleAsync();
var result = await fixture.Controller(fixture.StudentScope)
.Book(session.Id, CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.Empty(fixture.Db.ExperimentBookings);
}
[Fact]
public async Task CentralizedSession_RejectsRelatedPublishedTimetableConflict()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
fixture.Db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = fixture.Term.Id,
Name = "正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.SecondClassroom.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 18,
WeekPattern = WeekPattern.All
}
]
});
await fixture.Db.SaveChangesAsync();
var result = await manager.CreateSession(
project.Id,
fixture.SessionRequest(1, 2),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.Empty(fixture.Db.ExperimentSessions);
}
[Fact]
public async Task StudentTimetable_IncludesCentralizedAndBookedSelfScheduledSessions()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
var centralized = await fixture.Db.ExperimentProjects.SingleAsync();
await manager.CreateSession(
centralized.Id,
fixture.SessionRequest(1, 2),
CancellationToken.None);
await manager.PublishProject(centralized.Id, CancellationToken.None);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var selfScheduled = await fixture.Db.ExperimentProjects.SingleAsync(
x => x.ArrangementMode == ExperimentArrangementMode.SelfScheduled);
await manager.CreateSession(
selfScheduled.Id,
fixture.SessionRequest(4, 2),
CancellationToken.None);
await manager.PublishProject(selfScheduled.Id, CancellationToken.None);
var selfSession = await fixture.Db.ExperimentSessions.SingleAsync(
x => x.ExperimentProjectId == selfScheduled.Id);
var studentController = fixture.Controller(fixture.StudentScope);
Assert.IsType<NoContentResult>(
await studentController.Book(
selfSession.Id,
CancellationToken.None));
var student = await fixture.Db.Students.AsNoTracking().SingleAsync();
var service = new TimetableDataService(fixture.Db);
var timetable = await service.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
fixture.Term.Id,
null,
false,
student.Id,
new TimetableStudentDto(student.StudentNumber, student.Name),
CancellationToken.None);
Assert.NotNull(timetable);
Assert.Equal(2, timetable.ExperimentEntries.Count);
Assert.Contains(
timetable.ExperimentEntries,
x => x.ExperimentArrangementMode ==
ExperimentArrangementMode.Centralized);
Assert.Contains(
timetable.ExperimentEntries,
x => x.Id == selfSession.Id &&
x.ExperimentArrangementMode ==
ExperimentArrangementMode.SelfScheduled);
Assert.All(
timetable.ExperimentEntries,
x =>
{
Assert.True(x.IsExperiment);
Assert.Equal(fixture.Term.StartDate, x.ExperimentDate);
Assert.Equal(1, x.StartWeek);
});
var publicClassTimetable = await service.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
fixture.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
Assert.NotNull(publicClassTimetable);
Assert.Empty(publicClassTimetable.ExperimentEntries);
var booking = await fixture.Db.ExperimentBookings.SingleAsync();
Assert.IsType<NoContentResult>(
await studentController.CancelBooking(
booking.Id,
CancellationToken.None));
timetable = await service.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
fixture.Term.Id,
null,
false,
student.Id,
new TimetableStudentDto(student.StudentNumber, student.Name),
CancellationToken.None);
Assert.NotNull(timetable);
Assert.Single(timetable.ExperimentEntries);
Assert.Equal(
ExperimentArrangementMode.Centralized,
timetable.ExperimentEntries[0].ExperimentArrangementMode);
}
private sealed class ExperimentFixture : IAsyncDisposable
{
private ExperimentFixture(
SqliteConnection connection,
AppDbContext db,
AcademicTerm term,
TeachingTask task,
Classroom classroom,
Classroom secondClassroom,
ICurrentUserDataScope managerScope,
ICurrentUserDataScope studentScope)
{
Connection = connection;
Db = db;
Term = term;
Task = task;
Classroom = classroom;
SecondClassroom = secondClassroom;
ManagerScope = managerScope;
StudentScope = studentScope;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public AcademicTerm Term { get; }
public TeachingTask Task { get; }
public Classroom Classroom { get; }
public Classroom SecondClassroom { get; }
public ICurrentUserDataScope ManagerScope { get; }
public ICurrentUserDataScope StudentScope { get; }
public static async Task<ExperimentFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var manager = User("manager", "学院实验管理员");
var studentUser = User("student", "实验学生");
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building
{
Code = "LAB",
Name = "实验中心",
CampusId = campus.Id
};
var classroom = new Classroom
{
Code = "LAB101",
Name = "实验室 101",
BuildingId = building.Id,
Capacity = 40
};
var secondClassroom = new Classroom
{
Code = "LAB102",
Name = "实验室 102",
BuildingId = building.Id,
Capacity = 40
};
var college = new College { Code = "CS", Name = "计算机学院" };
manager.CollegeId = college.Id;
var major = new Major
{
Code = "CS",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2099",
Name = "计科 2099",
MajorId = major.Id,
Grade = 2099
};
var student = new Student
{
StudentNumber = "20990001",
Name = "实验学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2099,
EnrollmentDate = new DateOnly(2099, 9, 1),
UserId = studentUser.Id
};
var teacher = new Teacher
{
TeacherNumber = "T2099",
Name = "实验教师",
CollegeId = college.Id
};
var term = new AcademicTerm
{
Code = "2099-1",
Name = "2099—2100 学年第一学期",
AcademicYear = "2099-2100",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2099, 9, 7),
EndDate = new DateOnly(2100, 1, 17),
IsCurrent = true
};
var course = new Course
{
Code = "CSLAB",
Name = "系统实验",
CollegeId = college.Id,
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var task = new TeachingTask
{
TaskNumber = "2099-1-CSLAB-01",
Name = "系统实验教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 40,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
db.AddRange(
manager,
studentUser,
campus,
building,
classroom,
secondClassroom,
college,
major,
administrativeClass,
student,
teacher,
term,
course,
task);
for (var period = 1; period <= 12; period++)
{
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
{
AcademicTermId = term.Id,
PeriodNumber = period,
Name = $"第 {period} 节",
StartsAt = new TimeOnly(8, 0).AddMinutes((period - 1) * 50),
EndsAt = new TimeOnly(8, 45).AddMinutes((period - 1) * 50)
});
}
await db.SaveChangesAsync();
return new ExperimentFixture(
connection,
db,
term,
task,
classroom,
secondClassroom,
Scope(
manager,
SystemRoles.CollegeAdmin,
DataScope.College),
Scope(studentUser, SystemRoles.Student, DataScope.Self));
}
public ExperimentsController Controller(
ICurrentUserDataScope currentScope) =>
new(
Db,
currentScope,
new ClassroomReservationAvailabilityService(Db));
public ExperimentProjectRequest ProjectRequest(
ExperimentArrangementMode mode) =>
new(
Task.Id,
mode == ExperimentArrangementMode.Centralized
? "LAB-C"
: "LAB-S",
mode == ExperimentArrangementMode.Centralized
? "集中上机实验"
: "自主上机实验",
mode,
"完成规定实验项目。",
"携带校园卡。",
Term.StartDate,
Term.StartDate.AddDays(14));
public ExperimentSessionRequest SessionRequest(
int startPeriod,
int periodCount,
int capacity = 1) =>
new(
Classroom.Id,
Term.StartDate,
startPeriod,
periodCount,
capacity,
null);
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await Connection.DisposeAsync();
}
private static ApplicationUser User(
string userName,
string displayName) =>
new()
{
Id = Guid.NewGuid(),
UserName = userName,
NormalizedUserName = userName.ToUpperInvariant(),
DisplayName = displayName,
IsEnabled = true
};
private static ICurrentUserDataScope Scope(
ApplicationUser user,
string role,
DataScope dataScope) =>
new FixedScope(new CurrentUserScope(
user.Id,
user.DisplayName,
user.CollegeId,
dataScope,
new HashSet<string>([role])));
}
private sealed class FixedScope(CurrentUserScope current)
: ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = current;
}
}
+122 -1
View File
@@ -1,3 +1,5 @@
using Jiaowu.Api.Infrastructure.Experiments;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -10,7 +12,7 @@ namespace Jiaowu.Api.Tests;
public sealed class MySqlMigrationTests public sealed class MySqlMigrationTests
{ {
private const string LatestMigration = private const string LatestMigration =
"20260726190000_PersonalCalendarSubscription"; "20260727133346_ExamArrangementJobs";
[Fact] [Fact]
public void Production_migration_is_discoverable_and_generates_mysql_sql() public void Production_migration_is_discoverable_and_generates_mysql_sql()
@@ -53,6 +55,14 @@ public sealed class MySqlMigrationTests
Assert.Contains( Assert.Contains(
"ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL", "ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL",
script); script);
Assert.Contains("CREATE TABLE `ExamRooms`", script);
Assert.Contains("CREATE TABLE `ExamRoomSessions`", script);
Assert.Contains("CREATE TABLE `ExamSeats`", script);
Assert.Contains("CREATE TABLE `ExamRoomInvigilators`", script);
Assert.Contains("CREATE TABLE `ExamArrangementJobs`", script);
Assert.Contains(
"CREATE UNIQUE INDEX `UX_ExamArrangementJobs_Kind_ActivePlan`",
script);
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
Assert.Contains("DEFAULT 1", script); Assert.Contains("DEFAULT 1", script);
Assert.DoesNotContain("0001-01-01", script); Assert.DoesNotContain("0001-01-01", script);
@@ -99,19 +109,130 @@ public sealed class MySqlMigrationTests
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public void MySql_attendance_device_hash_query_uses_provider_safe_predicates()
{
using var db = new AppDbContext(CreateMySqlOptions());
var deviceHashes = new[] { "device-hash-one", "device-hash-two" };
var sql = db.AttendanceCheckInAttempts
.Where(x =>
x.IsSuccessful &&
x.CreatedAt >= DateTime.UtcNow.AddHours(-24) &&
x.DeviceIdentifierHash != null)
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
.GroupBy(x => x.DeviceIdentifierHash!)
.Select(x => new
{
DeviceIdentifierHash = x.Key,
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
})
.ToQueryString();
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(deviceHashes[0], sql, StringComparison.Ordinal);
Assert.Contains(deviceHashes[1], sql, StringComparison.Ordinal);
}
[Fact]
public void MySql_invigilator_candidate_query_is_translatable_without_uuid_ordering()
{
using var db = new AppDbContext(CreateMySqlOptions());
var excludedTeacherId =
Guid.Parse("11111111-1111-1111-1111-111111111111");
var sql = InvigilatorCandidateQuery
.Create(db, [excludedTeacherId])
.ToQueryString();
Assert.Contains("Teachers", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("UUID(", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public void MySql_teaching_task_roster_query_is_translatable() public void MySql_teaching_task_roster_query_is_translatable()
{ {
using var db = new AppDbContext(CreateMySqlOptions()); using var db = new AppDbContext(CreateMySqlOptions());
var studentId =
Guid.Parse("22222222-2222-2222-2222-222222222222");
var sql = TeachingTaskRosterQuery var sql = TeachingTaskRosterQuery
.ForTask( .ForTask(
db, db,
Guid.Parse("11111111-1111-1111-1111-111111111111")) Guid.Parse("11111111-1111-1111-1111-111111111111"))
.ToQueryString(); .ToQueryString();
var taskIdsSql = TeachingTaskRosterQuery
.TaskIdsForStudent(db, studentId)
.ToQueryString();
Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"TeachingTaskClasses",
taskIdsSql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"CourseEnrollments",
taskIdsSql,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MySql_centralized_experiment_conflict_queries_are_translatable()
{
using var db = new AppDbContext(CreateMySqlOptions());
var firstId =
Guid.Parse("11111111-1111-1111-1111-111111111111");
var secondId =
Guid.Parse("22222222-2222-2222-2222-222222222222");
Guid[] relatedIds = [firstId, secondId];
var teacherTaskSql = CentralizedExperimentConflictQuery
.TaskIdsForTeachers(db, relatedIds)
.ToQueryString();
var classTaskSql = CentralizedExperimentConflictQuery
.TaskIdsForClasses(db, relatedIds)
.ToQueryString();
var scheduleSql = CentralizedExperimentConflictQuery
.ScheduleEntries(
db,
relatedIds,
firstId,
1,
2,
1,
2)
.ToQueryString();
var experimentSql = CentralizedExperimentConflictQuery
.ExperimentSessions(
db,
relatedIds,
new DateOnly(2026, 8, 1),
1,
2)
.ToQueryString();
Assert.Contains(
"TeachingTaskTeachers",
teacherTaskSql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"TeachingTaskClasses",
classTaskSql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"ScheduleEntries",
scheduleSql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"ExperimentSessions",
experimentSql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(" IN (", scheduleSql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
" IN (",
experimentSql,
StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
@@ -251,6 +251,198 @@ public sealed class PersonalCalendarServiceTests : IAsyncLifetime
$"ICS line exceeds 75 octets: {line}")); $"ICS line exceeds 75 octets: {line}"));
} }
[Fact]
public async Task Build_creates_student_feed_with_booked_experiment()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var mondayOffset = today.DayOfWeek == DayOfWeek.Sunday
? 6
: (int)today.DayOfWeek - 1;
var termStart = today.AddDays(-mondayOffset);
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = "calendar-student",
NormalizedUserName = "CALENDAR-STUDENT",
DisplayName = "实验学生",
CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(),
CalendarSubscriptionCreatedAt = DateTime.UtcNow
};
var campus = new Campus { Code = "LAB-MAIN", Name = "主校区" };
var college = new College
{
Code = "LAB-CS",
Name = "计算机学院",
CampusId = campus.Id
};
var major = new Major
{
Code = "LAB-SE",
Name = "软件工程",
CollegeId = college.Id,
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
Code = "LAB-CLASS",
Name = "软件工程实验班",
MajorId = major.Id,
Grade = today.Year
};
var student = new Student
{
StudentNumber = "LAB-STUDENT-01",
Name = "实验学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = today.Year,
EnrollmentDate = termStart,
UserId = user.Id,
Status = StudentStatus.Active
};
var teacher = new Teacher
{
TeacherNumber = "LAB-TEACHER-01",
Name = "实验教师",
CollegeId = college.Id,
Status = TeacherStatus.Active
};
var course = new Course
{
Code = "LAB-CALENDAR",
Name = "嵌入式系统实验",
CollegeId = college.Id,
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var term = new AcademicTerm
{
Code = "LAB-CALENDAR-TERM",
Name = "实验日历测试学期",
AcademicYear = $"{today.Year}-{today.Year + 1}",
Season = TermSeason.Autumn,
StartDate = termStart,
EndDate = termStart.AddDays(40),
IsCurrent = true
};
var building = new Building
{
Code = "LAB-CENTER",
Name = "实验中心",
CampusId = campus.Id
};
var classroom = new Classroom
{
Code = "LAB-CENTER-301",
Name = "301",
BuildingId = building.Id,
Capacity = 30
};
_db.AddRange(
user,
campus,
college,
major,
administrativeClass,
student,
teacher,
course,
term,
building,
classroom);
await _db.SaveChangesAsync();
var task = new TeachingTask
{
TaskNumber = "LAB-CALENDAR-TASK",
Name = "嵌入式系统实验教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 30,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
_db.AddRange(
task,
new ScheduleTimeSlot
{
AcademicTermId = term.Id,
PeriodNumber = 3,
Name = "第三节",
StartsAt = new TimeOnly(10, 0),
EndsAt = new TimeOnly(10, 45)
},
new ScheduleTimeSlot
{
AcademicTermId = term.Id,
PeriodNumber = 4,
Name = "第四节",
StartsAt = new TimeOnly(10, 55),
EndsAt = new TimeOnly(11, 40)
});
await _db.SaveChangesAsync();
var project = new ExperimentProject
{
TeachingTaskId = task.Id,
Code = "LAB-SELF-01",
Name = "自主硬件实验",
ArrangementMode = ExperimentArrangementMode.SelfScheduled,
StartDate = termStart,
EndDate = termStart.AddDays(14),
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
var session = new ExperimentSession
{
ExperimentProject = project,
ClassroomId = classroom.Id,
SessionDate = termStart.AddDays(2),
StartPeriod = 3,
PeriodCount = 2,
Capacity = 30,
ReservedCount = 1
};
session.Bookings.Add(new ExperimentBooking
{
ExperimentProject = project,
StudentId = student.Id,
Status = ExperimentBookingStatus.Booked,
BookedAt = DateTime.UtcNow
});
_db.Add(session);
await _db.SaveChangesAsync();
var result = await CreateService().BuildAsync(
user,
CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(1, result.EventCount);
var ics = Encoding.UTF8.GetString(result.Content);
Assert.Contains("SUMMARY:[实验] 自主硬件实验", ics);
Assert.Contains("CATEGORIES:实验", ics);
Assert.Contains("LOCATION:主校区 · 实验中心 · 301", ics);
Assert.Contains("LAB-SELF-01 · 自主预约", ics);
}
[Fact] [Fact]
public void Access_token_is_bound_to_user_stamp_and_can_be_rotated() public void Access_token_is_bound_to_user_stamp_and_can_be_rotated()
{ {
@@ -12,7 +12,7 @@ public sealed class SchedulePublishJobProcessorTests
[Fact] [Fact]
public async Task Processor_validates_and_publishes_plan_atomically() public async Task Processor_validates_and_publishes_plan_atomically()
{ {
var result = await RunPublishAsync(weeklyHours: 2); var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 2);
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus); Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus); Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
@@ -26,17 +26,46 @@ public sealed class SchedulePublishJobProcessorTests
[Fact] [Fact]
public async Task Processor_keeps_draft_when_validation_fails() public async Task Processor_keeps_draft_when_validation_fails()
{ {
var result = await RunPublishAsync(weeklyHours: 4); var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 1);
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus); Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus); Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
Assert.Null(result.ActiveAcademicTermId); Assert.Null(result.ActiveAcademicTermId);
Assert.Equal("检查未通过", result.CurrentStep); Assert.Equal("检查未通过", result.CurrentStep);
Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage); Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage);
Assert.Null(result.PublishedAt); Assert.Null(result.PublishedAt);
} }
private static async Task<PublishResult> RunPublishAsync(int weeklyHours) [Fact]
public async Task Processor_excludes_practice_hours_for_existing_tasks()
{
var result = await RunPublishAsync(
weeklyHours: 2,
scheduledHours: 1,
practiceHours: 16);
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
}
[Fact]
public async Task Processor_rejects_practice_hours_already_added_to_draft()
{
var result = await RunPublishAsync(
weeklyHours: 2,
scheduledHours: 2,
practiceHours: 16);
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
Assert.Contains("普通课表应为 1 学时", result.ErrorMessage);
Assert.Contains("删除已包含的实践学时", result.ErrorMessage);
}
private static async Task<PublishResult> RunPublishAsync(
int weeklyHours,
int scheduledHours,
int practiceHours = 0)
{ {
var databasePath = Path.Combine( var databasePath = Path.Combine(
Path.GetTempPath(), Path.GetTempPath(),
@@ -75,7 +104,8 @@ public sealed class SchedulePublishJobProcessorTests
College = college, College = college,
Credits = 1, Credits = 1,
TotalHours = 32, TotalHours = 32,
LectureHours = 32 LectureHours = 32 - practiceHours,
PracticeHours = practiceHours
}; };
var task = new TeachingTask var task = new TeachingTask
{ {
@@ -100,7 +130,7 @@ public sealed class SchedulePublishJobProcessorTests
TeachingTask = task, TeachingTask = task,
DayOfWeek = 1, DayOfWeek = 1,
StartPeriod = 1, StartPeriod = 1,
PeriodCount = 2, PeriodCount = scheduledHours,
StartWeek = 1, StartWeek = 1,
EndWeek = 16, EndWeek = 16,
WeekPattern = WeekPattern.All WeekPattern = WeekPattern.All
@@ -189,6 +189,8 @@ public sealed class ScheduleSettingsControllerTests
}); });
Assert.Contains("测试教师", json); Assert.Contains("测试教师", json);
Assert.Contains(classroom.Id.ToString(), json); Assert.Contains(classroom.Id.ToString(), json);
Assert.Contains("\"WeeklyHours\":3", json);
Assert.Contains("\"CoursePracticeHours\":16", json);
} }
[Fact] [Fact]
@@ -288,7 +290,6 @@ public sealed class ScheduleSettingsControllerTests
var examTimeSlotsResult = await new ExamsController( var examTimeSlotsResult = await new ExamsController(
db, db,
null!, null!,
null!,
NoOpAppCache.Instance) NoOpAppCache.Instance)
.GetTimeSlotsForTerm(term.Id, CancellationToken.None); .GetTimeSlotsForTerm(term.Id, CancellationToken.None);
var examTimeSlotsOk = var examTimeSlotsOk =
@@ -300,7 +301,6 @@ public sealed class ScheduleSettingsControllerTests
var makeupTimeSlotsResult = await new MakeupExamsController( var makeupTimeSlotsResult = await new MakeupExamsController(
db, db,
null!, null!,
null!,
null!) null!)
.GetTimeSlotsForTerm(term.Id, CancellationToken.None); .GetTimeSlotsForTerm(term.Id, CancellationToken.None);
var makeupTimeSlotsOk = var makeupTimeSlotsOk =
@@ -12,6 +12,114 @@ namespace Jiaowu.Api.Tests;
public sealed class SchedulesControllerTests public sealed class SchedulesControllerTests
{ {
[Fact]
public async Task Manual_entry_rejects_hours_reserved_for_experiments()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-LAB",
Name = "2026 实验学时测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var college = new College { Code = "LAB", Name = "实验学院" };
var course = new Course
{
Code = "LAB-01",
Name = "实验混合课程",
College = college,
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16
};
var task = new TeachingTask
{
TaskNumber = "TASK-LAB",
Name = "实验混合教学班",
AcademicTerm = term,
Course = course,
Capacity = 30,
StartWeek = 1,
EndWeek = 16,
WeeklyHours = 2,
Status = TeachingTaskStatus.Published
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "排课草稿",
Version = "V1"
};
plan.Entries.Add(new ScheduleEntry
{
TeachingTask = task,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 1,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
});
db.AddRange(term, college, course, task, plan);
db.ScheduleTimeSlots.AddRange(
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 1,
Name = "第 1 节",
StartsAt = new TimeOnly(8, 0),
EndsAt = new TimeOnly(8, 45)
},
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 2,
Name = "第 2 节",
StartsAt = new TimeOnly(8, 55),
EndsAt = new TimeOnly(9, 40)
});
db.TeachingTaskScheduleConstraints.Add(
new TeachingTaskScheduleConstraint
{
TeachingTask = task,
RequiresClassroom = false
});
await db.SaveChangesAsync();
var controller = new SchedulesController(db);
var result = await controller.CreateEntry(
plan.Id,
new ScheduleEntryRequest(
task.Id,
null,
2,
2,
1,
1,
16,
WeekPattern.All,
null),
CancellationToken.None);
var problem = Assert.IsType<ObjectResult>(result);
var details = Assert.IsType<ValidationProblemDetails>(problem.Value);
Assert.Contains(
"实践学时请在实验管理中安排",
details.Detail);
Assert.Single(await db.ScheduleEntries.ToListAsync());
}
[Fact] [Fact]
public async Task Publish_returns_accepted_before_background_validation_runs() public async Task Publish_returns_accepted_before_background_validation_runs()
{ {
@@ -94,6 +94,36 @@ public sealed class StatisticsControllerTests
Assert.Equal(2, cache.SourceCalls); Assert.Equal(2, cache.SourceCalls);
} }
[Fact]
public async Task Course_summary_keeps_request_scope_when_cache_factory_loses_http_context()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "C01", Name = "第一学院" };
var category = new CourseCategory { Code = "CAT", Name = "测试分类" };
db.AddRange(college, category, CreateCourse("C001", college, category));
await db.SaveChangesAsync();
var scope = new MutableDataScope(college.Id);
var cache = new RecordingCache(() => scope.LoseHttpContext());
var controller = new StatisticsController(db, scope, cache);
var result = await controller.GetCourseSummary(
college.Id,
null,
null,
CancellationToken.None);
Assert.Equal(1, TotalCourses(result));
Assert.Equal(1, cache.SourceCalls);
}
private static int TotalCourses(ActionResult<object> result) private static int TotalCourses(ActionResult<object> result)
{ {
var json = Assert.IsType<JsonElement>(result.Value); var json = Assert.IsType<JsonElement>(result.Value);
@@ -117,7 +147,7 @@ public sealed class StatisticsControllerTests
AssessmentMethod = AssessmentMethod.Examination AssessmentMethod = AssessmentMethod.Examination
}; };
private sealed class RecordingCache : IAppCache private sealed class RecordingCache(Action? beforeFactory = null) : IAppCache
{ {
private readonly Dictionary<string, object> values = []; private readonly Dictionary<string, object> values = [];
@@ -137,6 +167,7 @@ public sealed class StatisticsControllerTests
SourceCalls++; SourceCalls++;
Tags.Add(tags.ToArray()); Tags.Add(tags.ToArray());
beforeFactory?.Invoke();
var loaded = await factory(cancellationToken); var loaded = await factory(cancellationToken);
values[key] = loaded!; values[key] = loaded!;
return loaded; return loaded;
@@ -157,4 +188,29 @@ public sealed class StatisticsControllerTests
DataScope.College, DataScope.College,
new HashSet<string> { SystemRoles.CollegeAdmin }); new HashSet<string> { SystemRoles.CollegeAdmin });
} }
private sealed class MutableDataScope(Guid collegeId) : ICurrentUserDataScope
{
private CurrentUserScope current = CreateCollegeScope(collegeId);
public CurrentUserScope Current => current;
public void LoseHttpContext()
{
current = new CurrentUserScope(
Guid.Empty,
null,
null,
DataScope.Self,
new HashSet<string>());
}
private static CurrentUserScope CreateCollegeScope(Guid value) =>
new(
Guid.NewGuid(),
"学院管理员",
value,
DataScope.College,
new HashSet<string> { SystemRoles.CollegeAdmin });
}
} }
@@ -30,4 +30,57 @@ public sealed class TeachingTaskHoursTests
Assert.Contains("总学时为 32", result); Assert.Contains("总学时为 32", result);
Assert.Contains("共 16 学时", result); Assert.Contains("共 16 学时", result);
} }
[Fact]
public void Standard_schedule_excludes_practice_hours()
{
var course = new Course
{
Code = "TEST-LAB",
Name = "实验混合课程",
CollegeId = Guid.NewGuid(),
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16
};
Assert.Equal(48, TeachingTaskHours.RegularScheduleHours(course));
Assert.True(TeachingTaskHours.TryResolveRegularWeeklyHours(
course,
1,
16,
out var weeklyHours));
Assert.Equal(3, weeklyHours);
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 3));
var result = TeachingTaskHours.Validate(course, 1, 16, 4);
Assert.NotNull(result);
Assert.Contains("普通课表应安排 48 学时", result);
Assert.Contains("实践学时 16", result);
}
[Fact]
public void Flexible_schedule_keeps_total_workload_hours()
{
var course = new Course
{
Code = "TEST-PRACTICE",
Name = "实践课程",
CollegeId = Guid.NewGuid(),
Credits = 1,
TotalHours = 16,
PracticeHours = 16
};
Assert.Null(TeachingTaskHours.Validate(
course,
1,
16,
1,
TeachingTaskSchedulingMode.Flexible));
Assert.Contains(
"无需进入普通课表",
TeachingTaskHours.Validate(course, 1, 16, 1)!);
}
} }
@@ -1,18 +1,22 @@
using ClosedXML.Excel;
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests; namespace Jiaowu.Api.Tests;
public sealed class TeachingWorkflowRosterTests public sealed class TeachingWorkflowRosterTests
{ {
[Fact] [Fact]
public async Task ClassAssignedStudents_AppearAcrossTeacherWorkflows() public async Task ClassAssignedStudents_AppearAcrossTeachingAndExamWorkflows()
{ {
await using var connection = new SqliteConnection("Data Source=:memory:"); await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(); await connection.OpenAsync();
@@ -23,6 +27,7 @@ public sealed class TeachingWorkflowRosterTests
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
var teacherUserId = Guid.NewGuid(); var teacherUserId = Guid.NewGuid();
var studentUserId = Guid.NewGuid();
var teacherUser = new ApplicationUser var teacherUser = new ApplicationUser
{ {
Id = teacherUserId, Id = teacherUserId,
@@ -30,7 +35,33 @@ public sealed class TeachingWorkflowRosterTests
NormalizedUserName = "T001", NormalizedUserName = "T001",
DisplayName = "张老师" DisplayName = "张老师"
}; };
var college = new College { Code = "CS", Name = "计算机学院" }; var studentUser = new ApplicationUser
{
Id = studentUserId,
UserName = "202601001",
NormalizedUserName = "202601001",
DisplayName = "周同学"
};
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var college = new College
{
Code = "CS",
Name = "计算机学院",
CampusId = campus.Id
};
var building = new Building
{
Code = "B01",
Name = "第一教学楼",
CampusId = campus.Id
};
var classroom = new Classroom
{
Code = "B01-101",
Name = "101",
BuildingId = building.Id,
Capacity = 60
};
var major = new Major var major = new Major
{ {
Code = "080901", Code = "080901",
@@ -51,7 +82,8 @@ public sealed class TeachingWorkflowRosterTests
Name = "周同学", Name = "周同学",
AdministrativeClassId = administrativeClass.Id, AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026, EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1) EnrollmentDate = new DateOnly(2026, 9, 1),
UserId = studentUserId
}; };
var teacher = new Teacher var teacher = new Teacher
{ {
@@ -107,7 +139,11 @@ public sealed class TeachingWorkflowRosterTests
}; };
db.AddRange( db.AddRange(
teacherUser, teacherUser,
studentUser,
campus,
college, college,
building,
classroom,
major, major,
administrativeClass, administrativeClass,
student, student,
@@ -117,6 +153,63 @@ public sealed class TeachingWorkflowRosterTests
task); task);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var examSession = new ExamSession
{
TeachingTaskId = task.Id,
ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 1
};
var examPlan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "期末考试",
Status = ExamPlanStatus.Draft,
Sessions = [examSession],
Rooms =
[
new ExamRoomAssignment
{
CourseId = course.Id,
ClassroomId = classroom.Id,
ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 1,
SessionLinks =
[
new ExamRoomSession
{
ExamSessionId = examSession.Id
}
],
Seats =
[
new ExamSeatAssignment
{
ExamSessionId = examSession.Id,
StudentId = student.Id,
SeatNumber = 1
}
],
Invigilators =
[
new ExamRoomInvigilator
{
TeacherId = teacher.Id
}
]
}
]
};
db.ExamPlans.Add(examPlan);
await db.SaveChangesAsync();
var scope = new TeacherDataScope(teacherUserId); var scope = new TeacherDataScope(teacherUserId);
var attendance = new AttendanceController(db, scope); var attendance = new AttendanceController(db, scope);
var attendanceResult = await attendance.CreateSheet( var attendanceResult = await attendance.CreateSheet(
@@ -153,6 +246,71 @@ public sealed class TeachingWorkflowRosterTests
Assert.Single(ReadItems(await adjustments.GetTaskOptions( Assert.Single(ReadItems(await adjustments.GetTaskOptions(
term.Id, term.Id,
CancellationToken.None))); CancellationToken.None)));
var managerScope = new ManagerDataScope();
var exams = new ExamsController(
db,
managerScope,
NoOpAppCache.Instance);
var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None);
var acceptedPublish = Assert.IsType<AcceptedAtActionResult>(publishResult);
var publishJobId = (Guid)acceptedPublish.Value!
.GetType().GetProperty("jobId")!.GetValue(acceptedPublish.Value)!;
var publishProcessor = new ExamPublishJobProcessor(
db,
NoOpAppCache.Instance,
NullLogger<ExamPublishJobProcessor>.Instance);
await publishProcessor.ProcessAsync(publishJobId, CancellationToken.None);
var planResult = Assert.IsType<OkObjectResult>(
await exams.GetPlan(
examPlan.Id,
cancellationToken: CancellationToken.None));
var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions");
Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount"));
var returnedRoom = Assert.Single(
ReadEnumerableProperty(planSessions.Single(), "ExamRooms"));
Assert.Equal(
classroom.Id,
returnedRoom.GetType().GetProperty("ClassroomId")!.GetValue(returnedRoom));
Assert.Equal(1, ReadIntProperty(returnedRoom, "SeatCount"));
Assert.Equal(1, ReadIntProperty(returnedRoom, "TotalSeatCount"));
Assert.False(
(bool)returnedRoom.GetType().GetProperty("IsMixed")!
.GetValue(returnedRoom)!);
Assert.Equal(
[teacher.Id],
ReadEnumerableProperty(returnedRoom, "InvigilatorIds").Cast<Guid>());
Assert.Equal(
[teacher.Name],
ReadEnumerableProperty(returnedRoom, "InvigilatorNames").Cast<string>());
var examSessionId = examPlan.Sessions.Single().Id;
var rosterResult = Assert.IsType<OkObjectResult>(
await exams.GetRoster(examSessionId, CancellationToken.None));
Assert.Single(ReadEnumerableProperty(rosterResult.Value!, "Students"));
var studentExams = new ExamsController(
db,
new StudentDataScope(studentUserId),
NoOpAppCache.Instance);
Assert.Single(ReadItems(
await studentExams.GetMySchedule(CancellationToken.None)));
var teacherExams = new ExamsController(
db,
scope,
NoOpAppCache.Instance);
Assert.Single(ReadItems(
await teacherExams.GetMySchedule(CancellationToken.None)));
var exportResult = Assert.IsType<FileContentResult>(
await exams.ExportSignInSheets(examPlan.Id, CancellationToken.None));
using var workbookStream = new MemoryStream(exportResult.FileContents);
using var workbook = new XLWorkbook(workbookStream);
Assert.Equal(2, workbook.Worksheets.Count);
var signInSheet = workbook.Worksheets.Single(x => x.Name != "考场汇总");
Assert.Equal("202601001", signInSheet.Cell("C8").GetString());
Assert.Equal("周同学", signInSheet.Cell("D8").GetString());
} }
private static List<object> ReadItems(ActionResult result) private static List<object> ReadItems(ActionResult result)
@@ -166,6 +324,12 @@ public sealed class TeachingWorkflowRosterTests
private static int ReadIntProperty(object value, string name) => private static int ReadIntProperty(object value, string name) =>
(int)value.GetType().GetProperty(name)!.GetValue(value)!; (int)value.GetType().GetProperty(name)!.GetValue(value)!;
private static List<object> ReadEnumerableProperty(object value, string name) =>
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
value.GetType().GetProperty(name)!.GetValue(value))
.Cast<object>()
.ToList();
private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope
{ {
public CurrentUserScope Current { get; } = new( public CurrentUserScope Current { get; } = new(
@@ -175,4 +339,24 @@ public sealed class TeachingWorkflowRosterTests
DataScope.Self, DataScope.Self,
new HashSet<string>([SystemRoles.Teacher])); new HashSet<string>([SystemRoles.Teacher]));
} }
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"周同学",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"考试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.AcademicAdmin]));
}
} }
@@ -7,7 +7,7 @@ namespace Jiaowu.Api.Tests;
public sealed class TimetableExcelExporterTests public sealed class TimetableExcelExporterTests
{ {
[Fact] [Fact]
public void Create_builds_week_grid_and_flexible_course_section() public void Create_builds_week_grid_with_exam_and_flexible_course_section()
{ {
var timetable = new TimetableData( var timetable = new TimetableData(
new TimetableTermDto( new TimetableTermDto(
@@ -84,7 +84,65 @@ public sealed class TimetableExcelExporterTests
["计算机科学 2601 班"], ["计算机科学 2601 班"],
null) null)
], ],
[]); [
new TimetableEntryDto(
Guid.NewGuid(),
Guid.NewGuid(),
"TASK-EXAM",
"高等数学期末考试",
"MATH101",
"高等数学",
["张老师", "监考:李老师"],
["计算机科学 2601 班"],
"201",
"第二教学楼",
"主校区",
2,
1,
1,
1,
1,
WeekPattern.All,
null,
true,
"2026-2027 学年第一学期期末考试",
new DateOnly(2027, 1, 5),
DateTime.UtcNow)
])
{
ExperimentEntries =
[
new TimetableEntryDto(
Guid.NewGuid(),
Guid.NewGuid(),
"TASK-LAB",
"程序设计实验教学班",
"CSLAB101",
"程序设计实验",
["陈老师"],
["计算机科学 2601 班"],
"301",
"实验中心",
"主校区",
4,
1,
2,
2,
2,
WeekPattern.All,
null,
false,
null,
null,
DateTime.UtcNow,
true,
Guid.NewGuid(),
"LAB-01",
"基础控制实验",
new DateOnly(2026, 9, 10),
ExperimentArrangementMode.SelfScheduled)
]
};
var bytes = TimetableExcelExporter.Create(timetable); var bytes = TimetableExcelExporter.Create(timetable);
@@ -92,7 +150,13 @@ public sealed class TimetableExcelExporterTests
using var workbook = new XLWorkbook(stream); using var workbook = new XLWorkbook(stream);
var sheet = workbook.Worksheet("课表"); var sheet = workbook.Worksheet("课表");
Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString()); Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString());
Assert.Contains("已融入 1 场考试", sheet.Cell("A2").GetString());
Assert.Contains("已融入 1 场实验", sheet.Cell("A2").GetString());
Assert.Contains("高等数学", sheet.Cell(5, 2).GetString()); Assert.Contains("高等数学", sheet.Cell(5, 2).GetString());
Assert.Contains("【考试】高等数学", sheet.Cell(5, 3).GetString());
Assert.Contains("【实验】基础控制实验", sheet.Cell(5, 5).GetString());
Assert.Contains("自主预约", sheet.Cell(5, 5).GetString());
Assert.Contains("2027-01-05", sheet.Cell(5, 3).GetString());
Assert.Contains( Assert.Contains(
sheet.CellsUsed(), sheet.CellsUsed(),
cell => cell.GetString().Contains("非排时课程")); cell => cell.GetString().Contains("非排时课程"));
+5
View File
@@ -1,3 +1,8 @@
# Capacitor
/android
/ios
.env.capacitor
# Logs # Logs
logs logs
*.log *.log
+20
View File
@@ -0,0 +1,20 @@
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'edu.mingxu.jiaowu',
appName: '明序教务',
webDir: 'dist',
bundledWebRuntime: false,
android: {
allowMixedContent: true,
},
plugins: {
CapacitorUpdater: {
autoUpdate: 'off',
version: '1.0.0',
appReadyTimeout: 10000,
},
},
}
export default config
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#152550" /> <meta name="theme-color" content="#152550" />
<meta name="description" content="明序大学教务管理系统" /> <meta name="description" content="明序大学教务管理系统" />
<title>明序教务管理系统</title> <title>明序教务管理系统</title>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mingxu" android:host="open" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<receiver
android:name=".ScheduleWidgetProvider"
android:exported="false"
android:label="@string/widget_schedule_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/schedule_widget_info" />
</receiver>
<receiver
android:name=".ExamWidgetProvider"
android:exported="false"
android:label="@string/widget_exam_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/exam_widget_info" />
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
</manifest>
@@ -0,0 +1,16 @@
package edu.mingxu.jiaowu;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
public class ExamWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(
Context context,
AppWidgetManager appWidgetManager,
int[] appWidgetIds
) {
WidgetRenderer.updateExams(context, appWidgetManager, appWidgetIds);
}
}
@@ -0,0 +1,55 @@
package edu.mingxu.jiaowu;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.JSObject;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
public class MainActivity extends BridgeActivity {
private static final Set<String> SHORTCUT_ROUTES =
new HashSet<>(Arrays.asList(
"timetable",
"exams",
"attendance",
"notifications"));
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(NativeHomePlugin.class);
super.onCreate(savedInstanceState);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
dispatchShortcut(intent);
}
private void dispatchShortcut(Intent intent) {
Uri data = intent == null ? null : intent.getData();
if (data == null ||
!"mingxu".equals(data.getScheme()) ||
!"open".equals(data.getHost())) {
return;
}
String route = data.getLastPathSegment();
if (route == null || !SHORTCUT_ROUTES.contains(route)) {
return;
}
NativeHomePlugin.savePendingRoute(this, route);
if (bridge != null) {
JSObject detail = new JSObject();
detail.put("route", route);
bridge.triggerWindowJSEvent("mingxuShortcut", detail.toString());
}
}
}
@@ -0,0 +1,61 @@
package edu.mingxu.jiaowu;
import android.content.Context;
import android.content.SharedPreferences;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "NativeHome")
public class NativeHomePlugin extends Plugin {
static final String PREFERENCES = "mingxu_native_home";
static final String WIDGET_PAYLOAD = "widget_payload";
private static final String PENDING_ROUTE = "pending_route";
@PluginMethod
public void updateWidgets(PluginCall call) {
JSObject payload = call.getObject("payload");
if (payload == null) {
call.reject("缺少桌面组件数据。");
return;
}
preferences(getContext()).edit()
.putString(WIDGET_PAYLOAD, payload.toString())
.apply();
WidgetRenderer.updateAll(getContext());
call.resolve();
}
@PluginMethod
public void clearWidgets(PluginCall call) {
preferences(getContext()).edit()
.remove(WIDGET_PAYLOAD)
.apply();
WidgetRenderer.updateAll(getContext());
call.resolve();
}
@PluginMethod
public void getLaunchRoute(PluginCall call) {
SharedPreferences preferences = preferences(getContext());
String route = preferences.getString(PENDING_ROUTE, null);
preferences.edit().remove(PENDING_ROUTE).apply();
JSObject result = new JSObject();
if (route != null) {
result.put("route", route);
}
call.resolve(result);
}
static void savePendingRoute(Context context, String route) {
preferences(context).edit().putString(PENDING_ROUTE, route).apply();
}
static SharedPreferences preferences(Context context) {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
}
}
@@ -0,0 +1,16 @@
package edu.mingxu.jiaowu;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
public class ScheduleWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(
Context context,
AppWidgetManager appWidgetManager,
int[] appWidgetIds
) {
WidgetRenderer.updateSchedule(context, appWidgetManager, appWidgetIds);
}
}

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