70 Commits
  • 修复实验成绩管理:
    管理列表改为服务端分页,支持 10/20/50 条切换。
    增加学期、成绩状态、开课学院以及项目/课程/教学班/教师关键词筛选。
    数据范围分级:校级管理员可查看全校,院级管理员仅限本学院,教师仅限本人任课项目。
    修复任课教师提交审核时报“无权限”:工作流查询现在会加载任课教师关系。
    增加权限、学院隔离、分页和筛选回归测试。
  • “教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
    配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。
    后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。
    扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
  • 同学期、同课程、同教学班的实验项目合并展示,组内连续排列。
    管理列表改为数据库筛选、按教学任务服务端分页(10–100 条/页)。
    增加开课学院、课程名称/编码、教学班、任课教师、教学任务号筛选。
    增加当前页草稿项目的批量选中、批量发布、批量删除;后端会再次校验权限、状态和发布条件。
    确认框改为固定居中白色底板与阴影,不再出现只有文字按钮、没有底色的情况。
  • 已修复:
    实验课场地区块改为独立卡片布局,校区、教学楼和场地选择不再被嵌套表单挤压。
    批量设置新增独立的“批量指定实验课场地”入口,包含场地性质、实验校区、教学楼和具体场地,并会联动筛选。
    后端会保存并校验批量实验场地范围,避免跨校区/教学楼的无效设置。
  • 发布课表投影改为数据库端删除旧数据、每 2,000 条分批写入并及时释放 EF 跟踪对象,避免大规模发布时内存持续增长。
    为“空闲教室/预约占用”新增 学期 + 周次 + 星期 + 节次 + 场地 查询索引;新发布课表的该查询已优先走课次明细投影。
  • 空闲教室与场地预约占用检查,现在对新发布课表优先查询 PublishedScheduleOccurrences。
    查询直接按“学期 + 周次 + 星期 + 节次”命中投影索引,不再扫描周期规则并在应用层判断单双周。
    历史已发布课表若尚未生成投影,自动回退到原规则表,兼容现有数据。
    调停课审批后会重建受影响教学任务的已发布投影,因此占用结果同步更新。
  • 发布课表时,在同一事务内生成实际周次课次明细。
    已审批的调课、停课、补课、代课后,会重建受影响教学任务所在已发布课表的明细投影。
  • 新增 PublishedScheduleOccurrences:发布时将周期规则按实际周次展开为只读课次明细。
    明细保留课表版本、学期、来源排课条目、教学任务、场地、周次、星期和节次,并建立面向教学任务/周次、课表/场地/周次的索引。
    明细在“发布课表”的同一事务内生成;发布失败不会留下半成品。
  • Merge branch 'codex/shiyan' into master
    # Conflicts:
    #	web/package-lock.json
  • 滑动续期与自动刷新:
    Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。
    App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。
    普通登录和 SSO 使用同一策略。
    刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。
    网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
  • 班级查询按“考试场次”合并,显示为“分散至 N 个考场”,不再生成大量重复卡片。[后端实现 (line 416)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs:416)
    学生登录后只显示本人座位对应的考场,同时修复混排考场可能带出其他课程的问题。[筛选逻辑 (line 561)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs:561)
    多考场卡片提示学生登录查看本人考场及监考教师。[前端展示 (line 1122)](E:/jiaowu/web/src/views/TimetableView.vue:1122)
    教室课表、监考教师课表仍按具体考场显示,不受合并影响。
    已增加班级合并和学生精准考场回归测试。[测试 (line 183)](E:/jiaowu/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs:183)
  • 实验课现在直接进入普通排课流程,不再要求先到实验排课模块逐个安排。
    普通“添加排课”新增“理论课 / 实验课”类型。
    自动排课会分别补足理论学时和实验学时。
    实验课只能安排到实验室、实训室、机房、语音室等场地。
    发布课表时分别校验理论、实验学时;任一未排足都不能发布。
    普通课表及 Excel 导出会标注“实验课”。
    历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
  • 支持为同一学期、同一课程的多个教学任务批量创建相同实验项目。
    新增批量排课表,可为多个实验项目分别设置日期、节次、实验室和容量。
    批量排课采用原子事务:任一项目发生教室、教师、班级或课表冲突,整批不写入。
    已发布项目批量新增场次时,继续按原逻辑通知学生。
    后端继续执行管理范围、教学任务状态、开放日期和容量校验。
    页面在 390px 手机宽度下无横向溢出。
  • 超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:
    请求量、5xx 比例、HTTP/数据库 P95
    请求速率与延迟趋势
    最慢接口排行
    慢查询与数据库查询排行
    Grafana 原始调用链入口
  • 接入 OpenTelemetry 1.17.0,覆盖 HTTP、HttpClient、.NET Runtime 和数据库链路。
    新增 EF Core 数据库拦截器,记录耗时、失败数、慢查询数、TraceId、查询标签和 SQL 哈希;默认不记录完整 SQL及参数。
    未配置 OTLP Collector 时不启动 SDK,避免无收益的性能开销。
    为课表的作息、课程、灵活课程、考试、实验等六类查询增加稳定标签。
    增加可配置的 500ms 慢查询阈值,以及生产环境变量示例。
    README 补充 MySQL 慢查询与 EXPLAIN ANALYZE 操作规范。
  • 课表查询中最明显的性能优化。
    将混合考场查询从“加载整个学期的考场、座位、教师、班级后在内存筛选”,改为在数据库中按班级、教师或教室直接筛选。
    改为窄字段投影,避免创建大量 EF 实体和关系对象。
    添加查询标签,便于后续在 MySQL 慢查询和链路追踪中定位。
    保留现有 HybridCache/可选 Redis 缓存体系,无需新增依赖或数据库迁移。
    增加教室、无关教室、监考教师三种回归场景。
  • 智能开屏:显示“姓名+早上/中午/下午/晚上问候”,支持春节、端午、中秋、国庆等节日文案,可点击跳过:[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)
    未分配考场或监考教师时,也能导出并显示“待分配”。
260 changed files with 210301 additions and 1978 deletions
+43 -1
View File
@@ -17,6 +17,8 @@ BackgroundJobs__Transport=InMemory
BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
BackgroundJobs__CourseGradeStatisticsRefreshConcurrency=1
# RabbitMq__HostName=rabbitmq.example.edu.cn
# RabbitMq__Port=5671
# RabbitMq__UserName=jiaowu
@@ -35,6 +37,24 @@ Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
Observability__Enabled=true
Observability__ServiceName=jiaowu-api
Observability__SlowQueryThresholdMilliseconds=500
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
Observability__IncludeSqlText=false
Observability__MaximumSqlTextLength=2000
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
PerformanceReporting__Enabled=false
# PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
# PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
# PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
PerformanceReporting__CacheSeconds=30
PerformanceReporting__TimeoutSeconds=10
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
Operations__BackupDirectory=/var/lib/jiaowu/backups
Operations__BackupWarningHours=24
@@ -48,10 +68,32 @@ Operations__MySqlClientPath=mysql
Jwt__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
Jwt__ExpireMinutes=60
Jwt__AccessTokenMinutes=10
Jwt__WebIdleMinutes=30
Jwt__AppIdleMinutes=4320
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
# https://sso.example.edu.cn/realms/mingxu
Sso__Enabled=false
# Sso__DisplayName=学校统一身份认证
# Sso__Authority=https://sso.example.edu.cn/realms/mingxu
# Sso__ClientId=jiaowu-web
# Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
# Sso__UserNameClaim=preferred_username
# Sso__RequireHttpsMetadata=true
# 首次 SSO 登录优先绑定同名本地账号;用户名不同时由用户输入现有账号密码完成绑定。
# 不会自动创建账号或授予角色。
# Sso__LinkExistingUsersByUserName=true
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
# 必须与 Keycloak 客户端的 Valid redirect URI 完全一致。
# Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
AllowedHosts=jiaowu.example.edu.cn
Cors__Origins__0=https://jiaowu.example.edu.cn
Cors__Origins__1=capacitor://localhost
Cors__Origins__2=https://localhost
Cors__Origins__3=http://localhost
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
OfficialDocuments__InstitutionName=明序大学
+160 -3
View File
@@ -47,6 +47,68 @@ dotnet run --project src/Jiaowu.Api
访问 `http://localhost:5255``/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`
## Capacitor Android App
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
Android 工程前先构建并同步原生插件:
```powershell
Set-Location web
npm ci
npm run build:capacitor
npm run cap:sync
npm run cap:open:android
```
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
最低版本为 API 26;相机和精确位置权限均按需申请。
### App 前端热更新
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
前端修复不再需要重新打包。
生成更新 ZIP
```powershell
Set-Location web
npm ci
npm run ota:package -- --version 1.0.1
```
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName`
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
`--migrate-only`
### Android 开屏、快捷入口与桌面组件
Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
遵守系统“减少动画”设置。
长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
重新构建 App,不能通过前端 OTA 单独下发。
## MySQL 8.4 生产部署
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
@@ -82,6 +144,50 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
`chmod 600 .env`Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
路径必须是运行服务器上的实际路径。
### Keycloak 单点登录(可选)
系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否
启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用
`preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录
外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户
需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会
自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能
绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生
自助激活入口不受影响。
在 Keycloak 中创建 OpenID Connect 客户端,并至少配置:
- Valid redirect URI`https://jiaowu.example.edu.cn/signin-keycloak`
- Valid post logout redirect URI`https://jiaowu.example.edu.cn/*`(若后续启用 Keycloak 全局退出)
- Standard flow:开启;Implicit flow:关闭;PKCE`S256`
然后在 `.env` 中配置:
```dotenv
Sso__Enabled=true
Sso__DisplayName=学校统一身份认证
Sso__Authority=https://sso.example.edu.cn/realms/mingxu
Sso__ClientId=jiaowu-web
Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
Sso__UserNameClaim=preferred_username
Sso__RequireHttpsMetadata=true
Sso__LinkExistingUsersByUserName=true
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
```
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
`http://localhost:5173`,Keycloak 测试客户端需同时允许
`http://localhost:5255/signin-keycloak``Sso__CallbackUrl` 是应用实际发送给 Keycloak
`redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式
配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会
显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
### Linux systemd 服务
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
@@ -260,6 +366,55 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
### OpenTelemetry 与慢查询定位
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
仍然有效。
```text
Observability__Enabled=true
Observability__ServiceName=jiaowu-api
Observability__SlowQueryThresholdMilliseconds=500
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
```
数据库指标包括 `jiaowu.db.command.duration``jiaowu.db.command.slow`
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
Grafana 中下钻,配置其地址后页面会显示跳转入口。
```text
PerformanceReporting__Enabled=true
PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
PerformanceReporting__CacheSeconds=30
PerformanceReporting__TimeoutSeconds=10
```
`PrometheusBaseUrl` 必须指向可访问 `/api/v1/query``/api/v1/query_range`
Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置或指标源暂时不可用时,页面
会显示明确的空状态,不会改查业务数据库或拖慢正常请求。若 Collector/Prometheus 对
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
`*MetricName``ServiceNameLabel` 配置项适配,无需改前端。
### 后台任务与 RabbitMQ
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
@@ -280,6 +435,7 @@ BackgroundJobs__Transport=RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
RabbitMq__HostName=rabbitmq.example.edu.cn
RabbitMq__Port=5671
RabbitMq__UserName=jiaowu
@@ -305,9 +461,10 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
### 运维与审计控制台
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
前端菜单隐藏。
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump`
`mysql`(容器镜像已包含对应的 `mariadb-dump``mariadb` 客户端),并配置独立的
+4 -1
View File
@@ -20,6 +20,7 @@ x-jiaowu-environment: &jiaowu-environment
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672"
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
@@ -29,7 +30,9 @@ x-jiaowu-environment: &jiaowu-environment
Jwt__Issuer: Jiaowu.Api
Jwt__Audience: Jiaowu.Web
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
Jwt__ExpireMinutes: "60"
Jwt__AccessTokenMinutes: "10"
Jwt__WebIdleMinutes: "30"
Jwt__AppIdleMinutes: "4320"
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
@@ -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 Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
{
sheet.Id,
sheet.CheckInMethod,
sheet.CheckInToken,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt
});
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
x.AttendanceDate,
x.Status,
x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.TargetLatitude,
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
var now = DateTime.UtcNow;
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x => x.AttendanceSheetId == id)
.Select(x => new
{
x.StudentId,
x.CreatedAt,
x.IsSuccessful,
x.FailureCode,
x.DeviceIdentifierHash,
x.DevicePlatform,
x.IpAddress,
x.RiskFlags
})
.ToListAsync(cancellationToken);
var attemptsByStudent = attempts
.GroupBy(x => x.StudentId)
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
var deviceHashes = attempts
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
.Select(x => x.DeviceIdentifierHash!)
.Distinct(StringComparer.Ordinal)
.ToList();
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
if (deviceHashes.Count > 0)
{
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x =>
x.IsSuccessful &&
x.CreatedAt >= now.AddHours(-24) &&
x.DeviceIdentifierHash != null)
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
.GroupBy(x => x.DeviceIdentifierHash!)
.Select(x => new
{
DeviceIdentifierHash = x.Key,
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
})
.ToListAsync(cancellationToken);
deviceReuseCounts = reuseRows.ToDictionary(
x => x.DeviceIdentifierHash,
x => x.StudentCount,
StringComparer.Ordinal);
}
var responseRecords = sheet.Records.Select(r =>
{
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
var riskFlags = studentAttempts
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
.ToHashSet(StringComparer.Ordinal);
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
riskFlags.Add("RepeatedFailures");
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
riskFlags.Add("HighFrequency");
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
? deviceReuseCounts.GetValueOrDefault(deviceHash)
: 0;
if (sharedDeviceStudentCount > 1)
riskFlags.Add("SharedDevice");
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
? attempts
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
.Select(x => x.StudentId)
.Distinct()
.Count()
: 0;
return new
{
r.StudentId,
r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId),
CheckInAudit = latestSuccess is null
? null
: new
{
latestSuccess.IpAddress,
latestSuccess.DevicePlatform,
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
SharedDeviceStudentCount = sharedDeviceStudentCount,
SameIpStudentCount = sameIpStudentCount
},
AttemptCount = studentAttempts.Count,
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
};
}).ToList();
return Ok(new
{
Sheet = new
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
sheet.AttendanceDate,
sheet.Status,
sheet.CheckInMethod,
CheckInToken = canManage ? sheet.CheckInToken : null,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
sheet.TargetLatitude,
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
sheet.TaskName,
sheet.CourseCode,
sheet.CourseName,
Records = sheet.Records.Select(r => new
Records = responseRecords,
RiskSummary = new
{
r.StudentId,
r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
SharedDeviceStudentCount = responseRecords.Count(
x => x.RiskFlags.Contains("SharedDevice")),
FrequentAttemptStudentCount = responseRecords.Count(
x => x.RiskFlags.Contains("HighFrequency") ||
x.RiskFlags.Contains("RepeatedFailures"))
}
},
CanEdit = canEdit
});
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
return NoContent();
}
[HttpGet("sheets/{id:guid}/qr-challenge")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetQrChallenge(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
string.IsNullOrWhiteSpace(sheet.CheckInToken))
return ConflictProblem("该考勤表不是扫码签到。");
var now = DateTime.UtcNow;
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
var challenge = AttendanceCheckInChallenge.Create(
sheet.Id,
sheet.CheckInToken,
now);
return Ok(challenge);
}
// ═══════════════ Student endpoints ═══════════════
[HttpGet("check-in-info")]
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
return ConflictProblem("当前账号未关联学生档案。");
if (string.IsNullOrWhiteSpace(token))
return NotFound();
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
return NotFound();
var activity = await db.AttendanceRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.AttendanceSheet!.CheckInToken == token.Trim())
x.AttendanceSheetId == sheetId)
.Select(x => new
{
SheetId = x.AttendanceSheetId,
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt,
x.AttendanceSheet.CheckInToken,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
if (activity is null) return NotFound();
var now = DateTime.UtcNow;
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
!AttendanceCheckInChallenge.IsValid(
token,
activity.SheetId,
activity.CheckInToken,
now))
return NotFound();
return Ok(new
{
activity.SheetId,
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
.Where(x => x.StudentId == studentId.Value);
if (!string.IsNullOrWhiteSpace(request.Token))
{
var token = request.Token.Trim();
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
if (!AttendanceCheckInChallenge.TryReadSheetId(
request.Token.Trim(),
out var tokenSheetId))
return NotFound();
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
}
else if (request.AttendanceSheetId.HasValue)
{
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
if (record?.AttendanceSheet is null) return NotFound();
var sheet = record.AttendanceSheet;
var now = DateTime.UtcNow;
async Task<ActionResult> RejectAttemptAsync(
string failureCode,
string detail,
double? distanceMeters = null)
{
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
false,
failureCode,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return ConflictProblem(detail);
}
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
!AttendanceCheckInChallenge.IsValid(
request.Token,
sheet.Id,
sheet.CheckInToken,
now))
return await RejectAttemptAsync(
"InvalidQrChallenge",
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return await RejectAttemptAsync(
"CheckInClosed",
"签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return await RejectAttemptAsync(
"ManualSheet",
"该考勤表不支持学生在线签到。");
if (record.CheckInAt.HasValue)
{
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
record.CheckInDistanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
AlreadyCheckedIn = true,
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
record.CheckInDistanceMeters
});
}
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return ConflictProblem("该考勤表不支持学生在线签到。");
double? distanceMeters = null;
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
{
if (string.IsNullOrWhiteSpace(request.Token) ||
!string.Equals(
sheet.CheckInToken,
request.Token.Trim(),
StringComparison.Ordinal))
return NotFound();
}
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
{
if (request.Latitude is < -90 or > 90 ||
request.Longitude is < -180 or > 180 ||
request.Latitude is null ||
request.Longitude is null)
return ConflictProblem("未获取到有效的当前位置。");
return await RejectAttemptAsync(
"InvalidLocation",
"未获取到有效的当前位置。");
if (sheet.TargetLatitude is null ||
sheet.TargetLongitude is null ||
sheet.LocationRadiusMeters is null)
return ConflictProblem("签到活动没有配置有效的位置范围。");
return await RejectAttemptAsync(
"LocationNotConfigured",
"签到活动没有配置有效的位置范围。");
var maximumAllowedAccuracyMeters = Math.Min(
100d,
sheet.LocationRadiusMeters.Value);
if (request.AccuracyMeters is null ||
!double.IsFinite(request.AccuracyMeters.Value) ||
request.AccuracyMeters <= 0 ||
request.AccuracyMeters > maximumAllowedAccuracyMeters)
{
return await RejectAttemptAsync(
"InsufficientAccuracy",
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
}
distanceMeters = CalculateDistanceMeters(
(double)sheet.TargetLatitude.Value,
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
(double)request.Longitude.Value);
if (distanceMeters > sheet.LocationRadiusMeters.Value)
{
return ConflictProblem(
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
return await RejectAttemptAsync(
"OutsideGeofence",
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
distanceMeters);
}
}
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
? request.AccuracyMeters
: null;
record.CheckInDistanceMeters = distanceMeters;
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return Ok(new
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken);
}
private async Task AddCheckInAttemptAsync(
AttendanceSheet sheet,
Guid studentId,
AttendanceCheckInRequest request,
bool isSuccessful,
string? failureCode,
double? distanceMeters,
DateTime now,
CancellationToken cancellationToken)
{
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
if (deviceIdentifierHash is null)
{
riskFlags.Add("MissingDeviceId");
}
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
x =>
x.IsSuccessful &&
x.StudentId != studentId &&
x.DeviceIdentifierHash == deviceIdentifierHash &&
x.CreatedAt >= now.AddHours(-24),
cancellationToken))
{
riskFlags.Add("SharedDevice");
}
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
x.CreatedAt >= now.AddMinutes(-2),
cancellationToken);
if (recentAttemptCount >= 5)
riskFlags.Add("HighFrequency");
if (!isSuccessful)
{
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
!x.IsSuccessful &&
x.CreatedAt >= now.AddMinutes(-5),
cancellationToken);
if (recentFailureCount >= 2)
riskFlags.Add("RepeatedFailures");
}
var context = ControllerContext.HttpContext;
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
{
AttendanceSheetId = sheet.Id,
StudentId = studentId,
CheckInMethod = sheet.CheckInMethod,
IsSuccessful = isSuccessful,
FailureCode = failureCode,
DeviceIdentifierHash = deviceIdentifierHash,
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
IpAddress = Limit(
context?.Connection.RemoteIpAddress?.ToString(),
64),
UserAgent = Limit(
context?.Request.Headers.UserAgent.ToString(),
500),
RiskFlags = riskFlags.Count == 0
? null
: string.Join(',', riskFlags.OrderBy(x => x)),
Latitude = request.Latitude,
Longitude = request.Longitude,
AccuracyMeters = request.AccuracyMeters,
DistanceMeters = distanceMeters,
CreatedAt = now,
UpdatedAt = now
});
}
private static string? HashDeviceIdentifier(string? deviceId)
{
var normalized = Normalize(deviceId)?.ToLowerInvariant();
return normalized is null
? null
: Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
}
private static IEnumerable<string> ParseRiskFlags(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(
',',
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries);
private static string? Limit(string? value, int maximumLength) =>
value is null || value.Length <= maximumLength
? value
: value[..maximumLength];
private static bool IsCheckInOpen(
AttendanceSheetStatus status,
AttendanceCheckInMethod method,
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
[MaxLength(64)] string? Token,
[Range(-90, 90)] decimal? Latitude,
[Range(-180, 180)] decimal? Longitude,
[Range(0, 5000)] double? AccuracyMeters);
[Range(0, 5000)] double? AccuracyMeters,
[MaxLength(128)] string? DeviceId = null,
[MaxLength(32)] string? DevicePlatform = null);
public sealed record AttendanceCourseStatistics(
AttendanceStatisticsCourse Course,
+70 -12
View File
@@ -18,7 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class AuthController(
AppDbContext db,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IAuthSessionService authSessionService,
IAppCache cache) : ControllerBase
{
[AllowAnonymous]
@@ -135,8 +135,11 @@ public sealed class AuthController(
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
public async Task<ActionResult<LoginResponse>> Login(
LoginRequest request,
CancellationToken cancellationToken)
{
var user = await userManager.FindByNameAsync(request.UserName);
if (user is null || !user.IsEnabled)
@@ -166,15 +169,47 @@ public sealed class AuthController(
await userManager.UpdateAsync(user);
var roles = await userManager.GetRolesAsync(user);
return new LoginResponse(
tokenService.Create(user, roles),
new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
var session = await authSessionService.CreateAsync(
user,
roles,
user.CollegeId,
EffectiveDataScopeResolver.Resolve(roles).ToString()));
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return CreateLoginResponse(session);
}
[AllowAnonymous]
[EnableRateLimiting("token-refresh")]
[HttpPost("refresh")]
public async Task<ActionResult<LoginResponse>> Refresh(
RefreshTokenRequest request,
CancellationToken cancellationToken)
{
var session = await authSessionService.RefreshAsync(
request.RefreshToken,
cancellationToken);
if (session is null)
{
return Unauthorized(new ProblemDetails
{
Title = "登录已过期",
Detail = "登录已过期或刷新令牌已失效,请重新登录。",
Status = StatusCodes.Status401Unauthorized
});
}
return CreateLoginResponse(session);
}
[AllowAnonymous]
[HttpPost("logout")]
public async Task<IActionResult> Logout(
RefreshTokenRequest request,
CancellationToken cancellationToken)
{
await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken);
return NoContent();
}
[Authorize]
@@ -212,11 +247,29 @@ public sealed class AuthController(
Detail = detail,
Status = status
});
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
new(
session.AccessToken,
session.AccessTokenExpiresAt,
session.RefreshToken,
session.SessionExpiresAt,
new CurrentUserResponse(
session.User.Id,
session.User.UserName!,
session.User.DisplayName,
session.Roles,
session.User.CollegeId,
EffectiveDataScopeResolver.Resolve(session.Roles).ToString()));
}
public sealed record LoginRequest(
[Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password);
[Required, MaxLength(100)] string Password,
bool IsNativeApp = false);
public sealed record RefreshTokenRequest(
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
public sealed record StudentActivationRequest(
[Required, MaxLength(50)] string Name,
@@ -227,7 +280,12 @@ public sealed record StudentActivationRequest(
Guid AdministrativeClassId,
[Required, MinLength(8), MaxLength(100)] string Password);
public sealed record LoginResponse(string Token, CurrentUserResponse User);
public sealed record LoginResponse(
string Token,
DateTime AccessTokenExpiresAt,
string RefreshToken,
DateTime SessionExpiresAt,
CurrentUserResponse User);
public sealed record CurrentUserResponse(
Guid Id,
@@ -491,6 +491,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
x.Building.Campus!.Name,
x.Capacity,
x.RoomType,
x.TeachingVenueNature,
x.Equipment,
x.IsEnabled,
x.SortOrder))
@@ -557,6 +558,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
BuildingId = request.BuildingId,
Capacity = request.Capacity,
RoomType = request.RoomType.Trim(),
TeachingVenueNature = request.TeachingVenueNature == 0
? TeachingVenueNature.GeneralClassroom
: request.TeachingVenueNature,
Equipment = request.Equipment?.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
@@ -577,6 +581,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
entity.BuildingId = request.BuildingId;
entity.Capacity = request.Capacity;
entity.RoomType = request.RoomType.Trim();
entity.TeachingVenueNature = request.TeachingVenueNature == 0
? TeachingVenueNature.GeneralClassroom
: request.TeachingVenueNature;
entity.Equipment = request.Equipment?.Trim();
await SaveAndInvalidateAsync(cancellationToken);
return entity;
@@ -728,6 +735,7 @@ public sealed record ClassroomRequest(
Guid BuildingId,
[Range(1, 1000)] int Capacity,
[Required, MaxLength(40)] string RoomType,
TeachingVenueNature TeachingVenueNature,
[MaxLength(300)] string? Equipment)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
@@ -784,6 +792,7 @@ public sealed record ClassroomListItem(
string CampusName,
int Capacity,
string RoomType,
TeachingVenueNature TeachingVenueNature,
string? Equipment,
bool IsEnabled,
int SortOrder);
@@ -27,7 +27,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "教学场地性质", "设备", "排序", "状态"],
["course-categories"] = ["编码", "名称", "排序", "状态"]
};
@@ -70,7 +70,10 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken);
var requiredHeaders = kind.Equals("classrooms", StringComparison.OrdinalIgnoreCase)
? headers.Where(x => x != "教学场地性质").ToArray()
: headers;
rows = await ExcelWorkbookHelper.ReadAsync(file, requiredHeaders, cancellationToken);
}
catch (InvalidDataException exception)
{
@@ -165,6 +168,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
"classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building)
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
VenueNatureName(x.TeachingVenueNature),
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
"course-categories" => (await db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
@@ -473,8 +477,9 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
var buildingCode = Required(row, "所属教学楼编码", errors);
var capacity = ParseInt(row, "容量", 1, 1000, errors);
var roomType = Required(row, "教室类型", errors);
var venueNature = ParseVenueNature(row, roomType, errors);
if (code is null || name is null || buildingCode is null ||
capacity is null || roomType is null) continue;
capacity is null || roomType is null || venueNature is null) continue;
if (!buildings.TryGetValue(buildingCode, out var building))
{
errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。");
@@ -488,7 +493,8 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
Code = code,
Name = name,
BuildingId = building.Id,
RoomType = roomType
RoomType = roomType,
TeachingVenueNature = venueNature.Value
};
db.Classrooms.Add(entity);
existing[code] = entity;
@@ -499,6 +505,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
entity.BuildingId = building.Id;
entity.Capacity = capacity.Value;
entity.RoomType = roomType;
entity.TeachingVenueNature = venueNature.Value;
entity.Equipment = Optional(row, "设备");
}
return new(created, updated, rows.Count);
@@ -597,6 +604,57 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
return true;
}
private static TeachingVenueNature? ParseVenueNature(
ExcelRow row,
string? roomType,
List<string> errors)
{
var value = Optional(row, "教学场地性质");
if (value is null) return InferVenueNature(roomType ?? string.Empty);
var result = (TeachingVenueNature)0;
foreach (var part in value.Split(['、', '', ',', ';', ''],
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
result |= part switch
{
"普通教室" => TeachingVenueNature.GeneralClassroom,
"实验室" => TeachingVenueNature.Laboratory,
"实训室" => TeachingVenueNature.TrainingRoom,
"计算机机房" or "机房" => TeachingVenueNature.ComputerLab,
"语音室" => TeachingVenueNature.LanguageLab,
"体育场地" => TeachingVenueNature.SportsVenue,
"艺术场地" => TeachingVenueNature.ArtsVenue,
_ => (TeachingVenueNature)0
};
if (part is not ("普通教室" or "实验室" or "实训室" or "计算机机房" or "机房" or "语音室" or "体育场地" or "艺术场地"))
errors.Add($"第 {row.RowNumber} 行:“教学场地性质”包含不支持的值“{part}”。");
}
return result == 0 ? null : result;
}
private static TeachingVenueNature InferVenueNature(string roomType) =>
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.Laboratory | TeachingVenueNature.ComputerLab
: roomType.Contains("语音", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.Laboratory | TeachingVenueNature.LanguageLab
: roomType.Contains("实训", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.TrainingRoom
: roomType.Contains("实验", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.Laboratory
: TeachingVenueNature.GeneralClassroom;
private static string VenueNatureName(TeachingVenueNature value) => string.Join("、",
new[]
{
(TeachingVenueNature.GeneralClassroom, "普通教室"),
(TeachingVenueNature.Laboratory, "实验室"),
(TeachingVenueNature.TrainingRoom, "实训室"),
(TeachingVenueNature.ComputerLab, "计算机机房"),
(TeachingVenueNature.LanguageLab, "语音室"),
(TeachingVenueNature.SportsVenue, "体育场地"),
(TeachingVenueNature.ArtsVenue, "艺术场地")
}.Where(x => (value & x.Item1) != 0).Select(x => x.Item2));
private static bool ParseBoolean(
ExcelRow row, string header, bool defaultValue, List<string> errors)
{
@@ -3,6 +3,7 @@ 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 Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
@@ -286,6 +287,8 @@ public sealed class CourseAdjustmentsController(
db.CourseAdjustments.Add(adj);
await db.SaveChangesAsync(cancellationToken);
await new PublishedTimetableProjectionService(db)
.RebuildPublishedPlansForTaskAsync(adj.TeachingTaskId, cancellationToken);
if (request.Submit)
{
@@ -0,0 +1,161 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/course-groups")]
public sealed class CourseGroupsController(AppDbContext db) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ManageRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet]
public async Task<ActionResult> GetAll(CancellationToken cancellationToken)
{
var groups = await db.CourseGroups.AsNoTracking()
.OrderBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Description,
CourseCount = x.Courses.Count,
Courses = x.Courses.OrderBy(item => item.Course!.Code).Select(item => new
{
item.Id,
item.CourseId,
CourseCode = item.Course!.Code,
CourseName = item.Course.Name,
item.Course.Credits,
item.Course.TotalHours,
item.Course.Nature
})
})
.ToListAsync(cancellationToken);
return Ok(groups);
}
[HttpPost]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Create(
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = new CourseGroup
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Description = Normalize(request.Description)
};
db.CourseGroups.Add(group);
return await SaveCreatedAsync(group.Id, cancellationToken);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Update(
Guid id,
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
group.Code = request.Code.Trim();
group.Name = request.Name.Trim();
group.Description = Normalize(request.Description);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
db.CourseGroups.Remove(group);
return await SaveNoContentAsync(cancellationToken);
}
[HttpPost("{id:guid}/courses")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> AddCourse(
Guid id,
CourseGroupCourseRequest request,
CancellationToken cancellationToken)
{
if (!await db.CourseGroups.AnyAsync(x => x.Id == id, cancellationToken)) return NotFound();
if (!await db.Courses.AnyAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken))
return ValidationProblem("所选课程不存在或已停用。");
db.CourseGroupCourses.Add(new CourseGroupCourse { CourseGroupId = id, CourseId = request.CourseId });
return await SaveCreatedAsync(id, cancellationToken);
}
[HttpDelete("{id:guid}/courses/{courseId:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> RemoveCourse(
Guid id,
Guid courseId,
CancellationToken cancellationToken)
{
var item = await db.CourseGroupCourses.FirstOrDefaultAsync(
x => x.CourseGroupId == id && x.CourseId == courseId,
cancellationToken);
if (item is null) return NotFound();
db.CourseGroupCourses.Remove(item);
return await SaveNoContentAsync(cancellationToken);
}
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record CourseGroupRequest(
[Required, MaxLength(30)] string Code,
[Required, MaxLength(100)] string Name,
[MaxLength(500)] string? Description);
public sealed record CourseGroupCourseRequest(Guid CourseId);
@@ -1011,7 +1011,11 @@ public sealed class CourseSelectionsController(
x.TeachingTask!.Status == TeachingTaskStatus.Published &&
(x.IsOpenToAll ||
x.TeachingTask.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)))
item.AdministrativeClassId == student.AdministrativeClassId) ||
x.Enrollments.Any(item =>
item.StudentId == student.Id &&
(item.Status == CourseEnrollmentStatus.Enrolled ||
item.Status == CourseEnrollmentStatus.Waitlisted))))
.OrderBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new StudentOfferingDto(
x.Id,
@@ -1639,13 +1643,25 @@ public sealed class CourseSelectionsController(
var students = await LoadTeachingTaskRosterAsync(id, cancellationToken);
var bytes = ExcelWorkbookHelper.Create(
"教学班名单",
["学号", "姓名", "班级", "专业", "进入方式", "选课时间"],
[
"学号", "姓名", "班级", "专业", "联系电话", "电子邮箱",
"微信", "紧急联系人", "与本人关系", "紧急联系电话",
"特殊标记", "特殊情况说明", "进入方式", "选课时间"
],
students.Select(student => new List<object?>
{
student.StudentNumber,
student.Name,
student.ClassName,
student.MajorName,
student.Phone,
student.Email,
student.WeChat,
student.EmergencyContactName,
student.EmergencyContactRelationship,
student.EmergencyContactPhone,
student.SpecialTags,
student.SpecialNeeds,
student.EnrolledAt.HasValue ? "选课" : "行政班关联",
student.EnrolledAt?.ToString("yyyy-MM-dd HH:mm") ?? "-"
}).ToList<IReadOnlyList<object?>>());
@@ -1754,6 +1770,14 @@ public sealed class CourseSelectionsController(
x.Name,
x.AdministrativeClass!.Name,
x.AdministrativeClass.Major!.Name,
x.Phone,
x.Email,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
db.CourseEnrollments
.Where(enrollment =>
enrollment.StudentId == x.Id &&
@@ -2154,6 +2178,14 @@ public sealed class CourseSelectionsController(
string Name,
string ClassName,
string MajorName,
string? Phone,
string? Email,
string? WeChat,
string? EmergencyContactName,
string? EmergencyContactRelationship,
string? EmergencyContactPhone,
string? SpecialTags,
string? SpecialNeeds,
DateTime? EnrolledAt);
}
@@ -145,6 +145,8 @@ public sealed class CoursesController(
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits,
x.TotalHours,
x.LectureHours,
x.PracticeHours,
x.Nature,
x.AssessmentMethod
})
@@ -432,6 +432,48 @@ public sealed class CurriculumPlansController(
return await SaveCreatedAsync(item.Id, cancellationToken);
}
[HttpPost("{planId:guid}/modules/{moduleId:guid}/course-groups/{groupId:guid}")]
public async Task<ActionResult> AddCourseGroup(
Guid planId,
Guid moduleId,
Guid groupId,
CurriculumCourseGroupImportRequest request,
CancellationToken cancellationToken)
{
var plan = await ModifiablePlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
return ValidationProblem("建议学期超出了该专业学制。");
if (!await db.CurriculumModules.AnyAsync(
x => x.Id == moduleId && x.CurriculumPlanId == planId,
cancellationToken))
return NotFound();
var courseIds = await db.CourseGroupCourses.AsNoTracking()
.Where(x => x.CourseGroupId == groupId && x.Course!.IsEnabled)
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (courseIds.Count == 0)
return ValidationProblem("课程组不存在,或其中没有可用课程。");
var existingCourseIds = await db.CurriculumCourses.AsNoTracking()
.Where(x => x.CurriculumModule!.CurriculumPlanId == planId &&
courseIds.Contains(x.CourseId))
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (existingCourseIds.Count > 0)
return ConflictProblem("课程组中有课程已存在于该培养方案,请先移除重复课程后再导入。");
db.CurriculumCourses.AddRange(courseIds.Select(courseId => new CurriculumCourse
{
CurriculumModuleId = moduleId,
CourseId = courseId,
RecommendedSemester = request.RecommendedSemester,
Type = request.Type,
Notes = Normalize(request.Notes)
}));
return await SaveNoContentAsync(cancellationToken);
}
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
public async Task<ActionResult> UpdateCourse(
Guid planId,
@@ -586,3 +628,8 @@ public sealed record CurriculumCourseRequest(
[Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type,
[MaxLength(500)] string? Notes);
public sealed record CurriculumCourseGroupImportRequest(
[Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type,
[MaxLength(500)] string? Notes);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,596 @@
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.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/grade-analytics")]
public sealed class GradeAnalyticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader + "," +
SystemRoles.Teacher;
private const string ScheduleManagers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
{
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
var defaults = new CourseGradeStatisticsRefreshSetting();
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
var lastRunAt = setting?.LastRunAt;
return Ok(new
{
IsEnabled = enabled,
IntervalSeconds = intervalSeconds,
BatchSize = batchSize,
LastRunAt = lastRunAt,
NextRunAt = enabled && lastRunAt.HasValue
? lastRunAt.Value.AddSeconds(intervalSeconds)
: null as DateTime?
});
}
[HttpPut("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> SaveRefreshSchedule(
SaveGradeStatisticsRefreshScheduleRequest request,
CancellationToken cancellationToken)
{
if (request.IntervalSeconds is < 10 or > 86400)
return BadRequest(new ProblemDetails
{
Title = "刷新间隔应在 10 秒到 24 小时之间。",
Status = StatusCodes.Status400BadRequest
});
if (request.BatchSize is < 1 or > 5000)
return BadRequest(new ProblemDetails
{
Title = "单次刷新批量应在 1 到 5000 之间。",
Status = StatusCodes.Status400BadRequest
});
var setting = await db.CourseGradeStatisticsRefreshSettings
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
if (setting is null)
{
setting = new CourseGradeStatisticsRefreshSetting();
db.CourseGradeStatisticsRefreshSettings.Add(setting);
}
setting.IsEnabled = request.IsEnabled;
setting.IntervalSeconds = request.IntervalSeconds;
setting.BatchSize = request.BatchSize;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId,
string? keyword,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (keyword is not null)
source = source.Where(x =>
x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword));
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TermName = x.TeachingTask.AcademicTerm!.Name,
x.AcademicTermId,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
public async Task<ActionResult> GetTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
return Ok(report);
}
[HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")]
public async Task<ActionResult> ExportTeachingClassAnalysisReport(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
if (report.IsRefreshing || report.Summary is null)
return Conflict(new ProblemDetails
{
Title = "成绩统计尚未生成",
Detail = "请先重新计算当前教学班,待统计完成后再导出。",
Status = StatusCodes.Status409Conflict
});
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx";
return File(
content,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileName);
}
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
public async Task<ActionResult> RefreshTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var exists = await db.GradeSheets.AsNoTracking()
.AnyAsync(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
cancellationToken);
if (!exists) return NotFound();
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
await db.SaveChangesAsync(cancellationToken);
return Accepted(new { job.Id });
}
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
AnalysisTarget target,
CancellationToken cancellationToken)
{
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new TeachingClassMetrics(
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt,
x.ScoreBands.OrderBy(band => band.SortOrder)
.Select(band => new ScoreBand(
band.Label,
band.LowerBound,
band.UpperBound,
band.StudentCount))
.ToArray()))
.FirstOrDefaultAsync(cancellationToken);
if (statistic is null)
return new TeachingClassAnalysisReport(
true,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
null,
[],
[],
[],
null);
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.OrderByDescending(x => x.AverageScore)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name).ToArray(),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name).ToArray(),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate
})
.ToListAsync(cancellationToken);
var peers = peerRows.Select(x => new TeachingClassComparison(
x.GradeSheetId,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
string.Join("、", x.TeacherNames),
string.Join("、", x.ClassNames),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.GradeSheetId == target.GradeSheetId)).ToArray();
var classProfiles = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new ClassProfile(
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.Name,
x.Student.AdministrativeClass.MajorId,
x.Student.AdministrativeClass.Major!.Name,
x.Student.AdministrativeClass.Major.CollegeId,
x.Student.AdministrativeClass.Major.College!.Name))
.Distinct()
.ToListAsync(cancellationToken);
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ToListAsync(cancellationToken);
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.TeachingTask!.Teachers.Any(link =>
selectedTeacherIds.Contains(link.TeacherId)))
.Select(x => new
{
x.AcademicTermId,
TermName = x.TeachingTask!.AcademicTerm!.Name,
x.TeachingTask.AcademicTerm.StartDate,
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.AverageScore
})
.ToListAsync(cancellationToken);
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.Scope == CourseGradeStatisticScope.University)
.Select(x => new
{
x.AcademicTermId,
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.Name).First(),
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.StartDate).First(),
x.StudentCount,
x.AverageScore,
x.PassRate,
ExcellentRate = x.StudentCount == 0 ? 0m :
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
})
.ToListAsync(cancellationToken);
var teacherByTerm = historicalTaskRows
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
.ToDictionary(group => group.Key.AcademicTermId, group =>
{
var count = group.Sum(x => x.StudentCount);
return new HistoricalSeriesValue(
count,
count == 0 ? 0m : Math.Round(
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
});
var history = courseHistory
.OrderBy(x => x.StartDate)
.Select(x => new HistoricalComparison(
x.AcademicTermId,
x.TermName,
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
.ToArray();
var university = scopeStatistics.FirstOrDefault(x =>
x.Scope == CourseGradeStatisticScope.University);
return new TeachingClassAnalysisReport(
false,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
statistic,
peers,
benchmarks,
history,
university is null ? null : new ComparisonDelta(
Math.Round(statistic.AverageScore - university.AverageScore, 1),
Math.Round(statistic.PassRate - university.PassRate, 2),
university.AverageScore,
university.PassRate));
}
private static ScopeBenchmark[] BuildBenchmarks(
IEnumerable<ClassProfile> classProfiles,
IReadOnlyCollection<CourseGradeStatistic> statistics)
{
var profiles = classProfiles.ToArray();
var rows = new List<ScopeBenchmark>();
foreach (var profile in profiles)
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
profile.ClassId, "行政班", profile.ClassName);
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
profile.MajorId, "专业", profile.MajorName);
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
profile.CollegeId, "学院", profile.CollegeName);
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
null, "全校", "全校同课程");
return rows.ToArray();
}
private static void AddBenchmark(
ICollection<ScopeBenchmark> target,
IEnumerable<CourseGradeStatistic> source,
CourseGradeStatisticScope scope,
Guid? entityId,
string scopeLabel,
string name)
{
var item = source.FirstOrDefault(x =>
x.Scope == scope && x.ScopeEntityId == entityId);
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
target.Add(new ScopeBenchmark(
scopeLabel,
name,
item.StudentCount,
item.HighestScore,
item.AverageScore,
item.LowestScore,
item.PassRate));
}
private IQueryable<TeachingTask> VisibleTeachingTasks()
{
var scope = currentUserDataScope.Current;
var source = db.TeachingTasks.AsQueryable();
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(link => link.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars();
return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character));
}
private sealed record AnalysisTarget(
Guid GradeSheetId,
Guid TeachingTaskId,
Guid CourseId,
Guid AcademicTermId,
Guid CourseCollegeId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName);
private sealed record ClassProfile(
Guid ClassId,
string ClassName,
Guid MajorId,
string MajorName,
Guid CollegeId,
string CollegeName);
public sealed record ScoreBand(
string Label,
decimal LowerBound,
decimal? UpperBound,
int StudentCount);
public sealed record TeachingClassMetrics(
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
DateTime CalculatedAt,
IReadOnlyList<ScoreBand> ScoreBands);
public sealed record TeachingClassComparison(
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string TeacherNames,
string ClassNames,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
bool IsSelected);
public sealed record ScopeBenchmark(
string Scope,
string Name,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal LowestScore,
decimal PassRate);
public sealed record HistoricalSeriesValue(
int StudentCount,
decimal AverageScore,
decimal PassRate,
decimal ExcellentRate);
public sealed record HistoricalComparison(
Guid AcademicTermId,
string TermName,
int CourseStudentCount,
decimal CourseAverageScore,
decimal CoursePassRate,
decimal CourseExcellentRate,
HistoricalSeriesValue? Instructor);
public sealed record ComparisonDelta(
decimal AverageScoreDifference,
decimal PassRateDifference,
decimal UniversityAverageScore,
decimal UniversityPassRate);
public sealed record TeachingClassAnalysisReport(
bool IsRefreshing,
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName,
TeachingClassMetrics? Summary,
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
IReadOnlyList<HistoricalComparison> History,
ComparisonDelta? UniversityDelta);
}
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
bool IsEnabled,
int IntervalSeconds,
int BatchSize);
+268 -10
View File
@@ -3,7 +3,9 @@ using System.Globalization;
using Jiaowu.Api.Contracts;
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.Excel;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/grades")]
public sealed class GradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string SheetUsers =
SystemRoles.SuperAdmin + "," +
@@ -37,6 +40,9 @@ public sealed class GradesController(
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
private const string StatisticsUsers =
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
[HttpGet("sheets")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheets(
@@ -101,7 +107,9 @@ public sealed class GradesController(
{
item.Id,
item.Name,
item.Weight
item.Weight,
item.SourceType,
item.SourceSnapshotAt
}),
StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record =>
@@ -143,6 +151,7 @@ public sealed class GradesController(
var task = await AccessibleTasks()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound();
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
@@ -220,7 +229,9 @@ public sealed class GradesController(
{
item.Id,
item.Name,
item.Weight
item.Weight,
item.SourceType,
item.SourceSnapshotAt
}),
x.Status,
x.ReviewComment,
@@ -408,7 +419,7 @@ public sealed class GradesController(
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
var itemsById = sheet.Items.ToDictionary(item => item.Id);
foreach (var item in request.Records)
{
if (!ValidScore(item.RegularScore) ||
@@ -428,7 +439,18 @@ public sealed class GradesController(
{
if (!ValidScore(scoreEntry.Score))
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;
}
}
@@ -438,6 +460,66 @@ public sealed class GradesController(
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> ImportExperimentScores(
Guid id,
ImportExperimentScoresRequest request,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (
GradeSheetStatus.Draft or
GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能导入实验成绩。");
var targetItem = sheet.Items.FirstOrDefault(x =>
x.Id == request.GradeItemId);
if (targetItem is null)
return ValidationProblem("请选择当前成绩单中的有效分项。");
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
db,
sheet.TeachingTaskId,
sheet.Records.Select(x => x.StudentId).ToArray(),
cancellationToken);
if (aggregate.PublishedProjectCount == 0)
return ConflictProblem(
"该教学任务尚无已发布的实验项目成绩,无法导入。");
var imported = 0;
var missing = 0;
foreach (var record in sheet.Records)
{
var targetScore = record.ItemScores.First(x =>
x.GradeItemId == targetItem.Id);
targetScore.Score = aggregate.Scores[record.StudentId];
if (targetScore.Score.HasValue) imported++;
else missing++;
Recalculate(sheet, record);
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
aggregate.PublishedProjectCount,
aggregate.Projects,
ImportedCount = imported,
MissingCount = missing,
targetItem.SourceSnapshotAt
});
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
@@ -624,7 +706,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
var rows = sheet.Records.Select(record =>
{
@@ -641,6 +723,7 @@ public sealed class GradesController(
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
values.Add(score);
}
values.Add(null);
values.Add(record.FinalScore);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
@@ -654,13 +737,56 @@ public sealed class GradesController(
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
"成绩列填写 0—100 的数值,留空表示暂未录入。",
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("", itemNames)}。",
"导入后会自动重新计算总评成绩和绩点。"
};
var regularColumn = 4;
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
var totalColumn = regularColumn + itemColumns.Length + 1;
var finalColumn = totalColumn + 1;
var statusColumn = finalColumn + 1;
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
(itemColumns[index], item.Weight)));
weightedColumns.Add((finalColumn, sheet.FinalWeight));
var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
var scoreColumns = weightedColumns.Select(x => x.Column)
.Append(totalColumn)
.ToArray();
var bytes = ExcelWorkbookHelper.Create(
"成绩导入", headers, rows, instructions);
"成绩导入", headers, rows, instructions,
(worksheet, rowNumber) =>
{
var componentReferences = requiredColumns
.Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
.ToArray();
var weightedExpression = string.Join("+", weightedColumns.Select(x =>
$"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
var formula =
$"=IF(OR({statusReference}=\"\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
var cell = worksheet.Cell(rowNumber, totalColumn);
cell.FormulaA1 = formula;
cell.Style.NumberFormat.Format = "0.0";
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
foreach (var scoreColumn in scoreColumns)
{
var conditionalFormat = worksheet
.Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
.AddConditionalFormat();
var failingScoreFormat = conditionalFormat.WhenLessThan(60);
failingScoreFormat.Fill.BackgroundColor =
ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
failingScoreFormat.Font.FontColor =
ClosedXML.Excel.XLColor.FromHtml("#B42318");
}
});
var taskName = sheet.TeachingTask!.Name;
return File(bytes, ExcelWorkbookHelper.ContentType,
$"成绩导入模板-{taskName}.xlsx");
@@ -693,13 +819,15 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(
file, headers, cancellationToken);
file,
headers.Where(x => x != "总分(自动计算)").ToArray(),
cancellationToken);
}
catch (InvalidDataException exception)
{
@@ -735,7 +863,7 @@ public sealed class GradesController(
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse final score
// Parse final score. The formula-driven total column is intentionally ignored.
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
@@ -827,6 +955,7 @@ public sealed class GradesController(
.Select(x => new
{
x.Id,
GradeSheetId = x.GradeSheetId,
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTaskId,
@@ -843,6 +972,121 @@ public sealed class GradesController(
return Ok(new { Student = student, Records = records });
}
[HttpGet("sheets/{id:guid}/statistics")]
[Authorize(Roles = StatisticsUsers)]
public async Task<ActionResult> GetCourseStatistics(
Guid id,
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.Status,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
CourseName = x.TeachingTask.Course!.Name,
CourseCode = x.TeachingTask.Course.Code,
TermName = x.TeachingTask.AcademicTerm!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
Guid? classId = null;
Guid? majorId = null;
Guid? collegeId = null;
if (scope.IsInRole(SystemRoles.Student))
{
if (sheet.Status != GradeSheetStatus.Published)
return NotFound();
var student = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
.Select(x => new
{
x.Student!.AdministrativeClassId,
MajorId = x.Student.AdministrativeClass!.MajorId,
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
})
.FirstOrDefaultAsync(cancellationToken);
if (student is null) return Forbid();
classId = student.AdministrativeClassId;
majorId = student.MajorId;
collegeId = student.CollegeId;
}
else if (scope.Scope == DataScope.College)
{
collegeId = scope.RestrictedCollegeId;
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
await db.Courses.Where(x => x.Id == sheet.CourseId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
return Forbid();
}
else if (scope.IsInRole(SystemRoles.Counselor))
{
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.CounselorUserId == scope.UserId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (allowedClassIds.Count == 0) return Forbid();
// A counselor sees their classes plus the matching major/college
// benchmarks, never an unrelated class-level statistic.
classId = allowedClassIds.First();
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
.Select(x => x.MajorId).FirstAsync(cancellationToken);
collegeId = await db.Majors.Where(x => x.Id == majorId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
}
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
{
var source = db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == sheet.CourseId &&
x.AcademicTermId == sheet.AcademicTermId);
return await source.Select(x => new
{
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
x.Below60Count, x.From60To69Count, x.From70To79Count,
x.From80To89Count, x.From90To100Count,
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
x.CalculatedAt
}).ToListAsync(token);
}, AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics], cancellationToken);
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
{
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
x.ScopeEntityId == entityId);
return item is null ? null : new
{
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
item.CalculatedAt,
Distribution = new[]
{
new { Range = "059", Count = item.Below60Count },
new { Range = "6069", Count = item.From60To69Count },
new { Range = "7079", Count = item.From70To79Count },
new { Range = "8089", Count = item.From80To89Count },
new { Range = "90100", Count = item.From90To100Count }
}
};
}
return Ok(new
{
sheet.CourseName, sheet.CourseCode, sheet.TermName,
IsRefreshing = !statistics.Any(),
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
? Find(CourseGradeStatisticScope.University, null) : null
});
}
private IQueryable<TeachingTask> AccessibleTasks()
{
var source = db.TeachingTasks.AsQueryable();
@@ -966,6 +1210,18 @@ public sealed class GradesController(
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string ColumnLetter(int column)
{
var result = string.Empty;
while (column > 0)
{
column--;
result = (char)('A' + column % 26) + result;
column /= 26;
}
return result;
}
}
public sealed record GradeSheetRequest(
@@ -998,5 +1254,7 @@ public sealed record GradeItemScoreRequest(
Guid GradeItemId,
decimal? Score);
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);
@@ -18,8 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class MakeupExamsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService,
MakeupExamArrangementService arrangementService) : ControllerBase
MakeupExamEligibilityService eligibilityService) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
@@ -111,6 +110,7 @@ public sealed class MakeupExamsController(
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
item.RequiredBuildingIds,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
@@ -138,38 +138,90 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var plan = await db.MakeupExamPlans
.Include(x => x.Sessions)
.ThenInclude(x => x.Invigilators)
.Include(x => x.Sessions)
.ThenInclude(x => x.Enrollments)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Status,
HasSessions = x.Sessions.Any()
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿补考计划可以发布。");
if (plan.Sessions.Count == 0)
if (!plan.HasSessions)
return ConflictProblem("至少安排一个考试场次后才能发布。");
var unassigned = plan.Sessions.Count(x =>
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
if (unassigned > 0)
return ConflictProblem(
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
var userId = currentUserDataScope.Current.UserId;
var job = new ExamPublishJob
{
Kind = ExamPublishJobKind.MakeupExam,
PlanId = id,
ActivePlanId = id,
RequestedByUserId = userId == Guid.Empty ? null : userId,
CurrentStep = "等待后台校验"
};
db.ExamPublishJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamPublish,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
var existing = await FindActivePublishJobAsync(id, cancellationToken);
if (existing is not null)
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
throw;
}
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
if (empty > 0)
return ConflictProblem(
$"还有 {empty} 个场次没有登记补考学生。");
return AcceptedPublishJob(job, "补考发布任务已提交。");
}
plan.Status = MakeupExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken);
[HttpGet("publish-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetPublishJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamPublishJobKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
}
[HttpGet("plans/{planId:guid}/publish-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestPublishJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamPublishJobKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
}
[HttpPost("plans/{id:guid}/archive")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Published)
@@ -189,6 +241,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -215,6 +269,7 @@ public sealed class MakeupExamsController(
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes),
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
@@ -231,6 +286,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionsBatchRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -281,6 +338,7 @@ public sealed class MakeupExamsController(
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes)
}));
@@ -304,6 +362,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan)
.Include(x => x.Invigilators)
@@ -330,6 +390,7 @@ public sealed class MakeupExamsController(
session.StartsAt = startsAt;
session.EndsAt = endsAt;
session.RequiredBuildingId = request.RequiredBuildingId;
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes);
@@ -347,6 +408,8 @@ public sealed class MakeupExamsController(
Guid id,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
if (session is null) return NotFound();
@@ -368,15 +431,117 @@ public sealed class MakeupExamsController(
CancellationToken cancellationToken)
{
request ??= new ExamAutoArrangeRequest();
var result = await arrangementService.ArrangeAsync(
planId,
request.SessionIds,
request.AssignClassrooms,
request.AssignInvigilators,
if (!request.AssignClassrooms && !request.AssignInvigilators)
return ValidationProblem("请至少选择分配考场或分配监考教师。");
var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
if (sessionIds.Length > 100)
return ValidationProblem("一次最多处理100个补考场次。");
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == planId)
.Select(x => new
{
x.Status,
TotalSessions = x.Sessions.Count
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null)
return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
if (sessionIds.Length > 0)
{
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId)
.WhereIn(sessionIds, x => x.Id)
.CountAsync(cancellationToken);
if (selectedSessionCount != sessionIds.Length)
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
}
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
.AnyAsync(
x => x.MakeupExamPlanId == planId &&
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running),
cancellationToken);
if (!result.Success)
return ConflictProblem(result.Message);
return Ok(new { message = result.Message });
if (autoCreateActive)
return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
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.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
// Check for existing active job
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
@@ -570,6 +737,7 @@ public sealed class MakeupExamsController(
.Include(x => x.Enrollments)
.Include(x => x.TeachingTask!)
.ThenInclude(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
@@ -1042,6 +1210,87 @@ public sealed class MakeupExamsController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamArrangementKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedArrangementJob(
ExamArrangementJob job,
string message) =>
AcceptedAtAction(
nameof(GetArrangementJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.TotalSessions,
job.ProcessedSessions,
job.CurrentStep,
job.ResultMessage,
job.ErrorMessage,
job.AssignClassrooms,
job.AssignInvigilators,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private Task<ExamPublishJob?> FindActivePublishJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamPublishJobKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedPublishJob(
ExamPublishJob job,
string message) =>
AcceptedAtAction(
nameof(GetPublishJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToPublishJobResponse(ExamPublishJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.CurrentStep,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token)
{
@@ -1064,6 +1313,10 @@ public sealed class MakeupExamsController(
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
ids is { Count: > 0 }
? System.Text.Json.JsonSerializer.Serialize(ids)
: null;
}
// ═══════════════════════════════════════════
@@ -1082,6 +1335,7 @@ public sealed record CreateMakeupExamSessionRequest(
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount,
IReadOnlyCollection<Guid>? InvigilatorIds,
[MaxLength(500)] string? Notes);
@@ -1092,6 +1346,7 @@ public sealed record CreateMakeupExamSessionsBatchRequest(
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount,
[MaxLength(500)] string? Notes);
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Observability;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -18,8 +19,27 @@ public sealed class OperationsController(
AppDbContext db,
OperationalHealthService healthService,
DatabaseBackupService backupService,
PerformanceReportService performanceReportService,
OperationsOptions options) : ControllerBase
{
[HttpGet("performance")]
public async Task<ActionResult<PerformanceReport>> GetPerformance(
[FromQuery] string? range = "1h",
CancellationToken cancellationToken = default)
{
try
{
return Ok(await performanceReportService.GetAsync(
range,
cancellationToken));
}
catch (ArgumentOutOfRangeException)
{
return ValidationProblem(
"性能报表范围仅支持 15m、1h、24h 或 7d。");
}
}
[HttpGet("summary")]
public async Task<ActionResult<OperationsSummary>> GetSummary(
CancellationToken cancellationToken)
@@ -59,6 +79,38 @@ public sealed class OperationsController(
CancellationToken cancellationToken) =>
Ok(await healthService.CheckAsync(cancellationToken));
[HttpGet("swagger")]
public async Task<ActionResult<SwaggerDocumentationSettings>> GetSwaggerSettings(
CancellationToken cancellationToken) =>
Ok(new SwaggerDocumentationSettings(await IsSwaggerEnabledAsync(cancellationToken)));
[HttpPut("swagger")]
public async Task<ActionResult<SwaggerDocumentationSettings>> UpdateSwaggerSettings(
UpdateSwaggerDocumentationSettings request,
CancellationToken cancellationToken)
{
var setting = await db.SystemFeatureSettings.SingleOrDefaultAsync(
x => x.Key == SystemFeatureKeys.SwaggerDocumentation,
cancellationToken);
if (setting is null)
{
setting = new SystemFeatureSetting
{
Key = SystemFeatureKeys.SwaggerDocumentation,
IsEnabled = request.IsEnabled
};
db.SystemFeatureSettings.Add(setting);
}
else
{
setting.IsEnabled = request.IsEnabled;
setting.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
return Ok(new SwaggerDocumentationSettings(setting.IsEnabled));
}
[HttpGet("audit-logs")]
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
[FromQuery] int page = 1,
@@ -213,6 +265,62 @@ public sealed class OperationsController(
.ToListAsync(cancellationToken));
}
if (normalizedKind is null or "ExamArrangement")
{
var query = db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Status == ExamArrangementJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"ExamArrangement",
x.Kind == ExamArrangementKind.FormalExam
? "正式考试编排"
: "补考编排",
x.Kind == ExamArrangementKind.FormalExam
? "正式考试计划"
: "补考计划",
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
if (normalizedKind is null or "ExamPublish")
{
var query = db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.Status == ExamPublishJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"ExamPublish",
x.Kind == ExamPublishJobKind.FormalExam
? "正式考试发布"
: "补考发布",
x.Kind == ExamPublishJobKind.FormalExam
? "正式考试计划"
: "补考计划",
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
var pageItems = rows
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
.Skip((page - 1) * pageSize)
@@ -435,8 +543,24 @@ public sealed class OperationsController(
.CountAsync(
x => x.Status == MakeupExamAutoJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
await db.ExamArrangementJobs.AsNoTracking()
.CountAsync(
x => x.Status == ExamArrangementJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
await db.ExamPublishJobs.AsNoTracking()
.CountAsync(
x => x.Status == ExamPublishJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken);
private async Task<bool> IsSwaggerEnabledAsync(CancellationToken cancellationToken) =>
await db.SystemFeatureSettings.AsNoTracking()
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
.Select(x => (bool?)x.IsEnabled)
.SingleOrDefaultAsync(cancellationToken) ?? false;
private ActionResult? ValidatePaging(int page, int pageSize)
{
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
@@ -462,6 +586,7 @@ public sealed class OperationsController(
"AutomaticSchedule" => "AutomaticSchedule",
"SchedulePublish" => "SchedulePublish",
"MakeupExamAuto" => "MakeupExamAuto",
"ExamArrangement" => "ExamArrangement",
_ => null
};
}
@@ -517,3 +642,7 @@ public sealed record OperationsSummary(
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
public sealed record RestoreDrillRequest([Required] string Confirmation);
public sealed record SwaggerDocumentationSettings(bool IsEnabled);
public sealed record UpdateSwaggerDocumentationSettings(bool IsEnabled);
@@ -0,0 +1,238 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/other-exams")]
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
.ToListAsync(ct));
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
{
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
var name = Normalize(request.Name);
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
if (error is not null) return ValidationProblem(error);
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
db.OtherExamBatches.Add(batch);
await db.SaveChangesAsync(ct);
return Ok(new { batch.Id });
}
[HttpGet("students/lookup")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
{
var number = Normalize(studentNumber);
if (number is null) return ValidationProblem("请输入学号。");
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
if (batch is null) return NotFound();
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
return Ok(new { Batch = batch, Results = results });
}
[HttpPut("batches/{id:guid}/results")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
var result = await ReplaceResultsAsync(batch, request.Results, ct);
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
}
[HttpGet("batches/{id:guid}/template")]
[Authorize(Roles = Managers)]
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
var headers = HeadersFor(batch);
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
}
[HttpPost("batches/{id:guid}/import")]
[Authorize(Roles = Managers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
IReadOnlyList<ExcelRow> rows;
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
var inputs = new List<OtherExamResultRequest>();
var errors = new List<string>();
foreach (var row in rows)
{
var number = row["学号"].Trim();
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var result = await ReplaceResultsAsync(batch, inputs, ct);
return result ?? Ok(new { updated = inputs.Count });
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
batch.Status = OtherExamBatchStatus.Published;
batch.PublicationCount++;
batch.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Ok(new { batch.PublicationCount, batch.PublishedAt });
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Mine(CancellationToken ct)
{
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
return Ok(new { Best = best, History = history });
}
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
{
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
var studentRows = await db.Students
.Where(x => numbers.Contains(x.StudentNumber))
.ToListAsync(ct);
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
foreach (var item in inputs)
{
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
if (error is not null) return ValidationProblem(error);
}
var studentIds = students.Values.Select(x => x.Id).ToList();
var beforeCount = await db.OtherExamResults.AsNoTracking()
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
{
db.OtherExamResults.RemoveRange(batch.Results);
batch.Status = OtherExamBatchStatus.Draft;
await db.SaveChangesAsync(ct);
batch.Results = inputs.Select(x =>
{
var student = students[x.StudentNumber.Trim()];
return new OtherExamResult
{
OtherExamBatchId = batch.Id,
StudentId = student.Id,
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
Score = x.Score,
Level = Normalize(x.Level),
IsPassed = x.IsPassed,
Notes = Normalize(x.Notes)
};
}).ToList();
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return null;
}, ct);
}
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
{
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
_ => ["学号", "是否合格", "备注"]
};
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
{
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
}
private static bool? ParsePass(string value, int row, List<string> errors)
{
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
}
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
{
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
_ => null
};
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
{
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
_ => null
};
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
{
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var index = Array.IndexOf(levels, level ?? "");
return index >= 0 ? levels.Length - index : -1;
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
return ValidationProblem(ModelState);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
}
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
@@ -224,6 +224,12 @@ public sealed class PersonnelController(
[FromQuery] PersonnelQuery query,
CancellationToken cancellationToken)
{
var roles = currentUserDataScope.Current.Roles;
var canReadFullProfile = roles.Any(role =>
role is SystemRoles.SuperAdmin or
SystemRoles.AcademicAdmin or
SystemRoles.CollegeAdmin or
SystemRoles.Counselor);
var page = NormalizePage(query.Page);
var pageSize = NormalizePageSize(query.PageSize);
var source = ApplyStudentScope(db.Students.AsNoTracking());
@@ -268,9 +274,26 @@ public sealed class PersonnelController(
x.EnrollmentDate,
x.Status,
x.DateOfBirth,
EnglishName = canReadFullProfile ? x.EnglishName : null,
IdCardNumber = canReadFullProfile ? x.IdCardNumber : null,
Nationality = canReadFullProfile ? x.Nationality : null,
Ethnicity = canReadFullProfile ? x.Ethnicity : null,
PoliticalStatus = canReadFullProfile ? x.PoliticalStatus : null,
NativePlace = canReadFullProfile ? x.NativePlace : null,
HouseholdAddress = canReadFullProfile ? x.HouseholdAddress : null,
CurrentAddress = canReadFullProfile ? x.CurrentAddress : null,
PostalCode = canReadFullProfile ? x.PostalCode : null,
x.Phone,
x.Email,
x.Notes,
Qq = canReadFullProfile ? x.Qq : null,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
Biography = canReadFullProfile ? x.Biography : null,
Notes = canReadFullProfile ? x.Notes : null,
x.UserId,
x.CreatedAt
})
@@ -304,8 +327,26 @@ public sealed class PersonnelController(
EnrollmentDate = request.EnrollmentDate,
Status = request.Status,
DateOfBirth = request.DateOfBirth,
EnglishName = Normalize(request.EnglishName),
IdCardNumber = Normalize(request.IdCardNumber),
Nationality = Normalize(request.Nationality),
Ethnicity = Normalize(request.Ethnicity),
PoliticalStatus = Normalize(request.PoliticalStatus),
NativePlace = Normalize(request.NativePlace),
HouseholdAddress = Normalize(request.HouseholdAddress),
CurrentAddress = Normalize(request.CurrentAddress),
PostalCode = Normalize(request.PostalCode),
Phone = Normalize(request.Phone),
Email = Normalize(request.Email),
Qq = Normalize(request.Qq),
WeChat = Normalize(request.WeChat),
EmergencyContactName = Normalize(request.EmergencyContactName),
EmergencyContactRelationship = Normalize(
request.EmergencyContactRelationship),
EmergencyContactPhone = Normalize(request.EmergencyContactPhone),
SpecialTags = Normalize(request.SpecialTags),
SpecialNeeds = Normalize(request.SpecialNeeds),
Biography = Normalize(request.Biography),
Notes = Normalize(request.Notes)
};
db.Students.Add(entity);
@@ -343,8 +384,26 @@ public sealed class PersonnelController(
entity.EnrollmentDate = request.EnrollmentDate;
entity.Status = request.Status;
entity.DateOfBirth = request.DateOfBirth;
entity.EnglishName = Normalize(request.EnglishName);
entity.IdCardNumber = Normalize(request.IdCardNumber);
entity.Nationality = Normalize(request.Nationality);
entity.Ethnicity = Normalize(request.Ethnicity);
entity.PoliticalStatus = Normalize(request.PoliticalStatus);
entity.NativePlace = Normalize(request.NativePlace);
entity.HouseholdAddress = Normalize(request.HouseholdAddress);
entity.CurrentAddress = Normalize(request.CurrentAddress);
entity.PostalCode = Normalize(request.PostalCode);
entity.Phone = Normalize(request.Phone);
entity.Email = Normalize(request.Email);
entity.Qq = Normalize(request.Qq);
entity.WeChat = Normalize(request.WeChat);
entity.EmergencyContactName = Normalize(request.EmergencyContactName);
entity.EmergencyContactRelationship = Normalize(
request.EmergencyContactRelationship);
entity.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
entity.SpecialTags = Normalize(request.SpecialTags);
entity.SpecialNeeds = Normalize(request.SpecialNeeds);
entity.Biography = Normalize(request.Biography);
entity.Notes = Normalize(request.Notes);
return await SaveNoContentAsync(cancellationToken);
}
@@ -522,8 +581,25 @@ public sealed record StudentRequest(
DateOnly EnrollmentDate,
StudentStatus Status,
DateOnly? DateOfBirth,
[MaxLength(100)] string? EnglishName,
[MaxLength(30)] string? IdCardNumber,
[MaxLength(50)] string? Nationality,
[MaxLength(50)] string? Ethnicity,
[MaxLength(50)] string? PoliticalStatus,
[MaxLength(100)] string? NativePlace,
[MaxLength(300)] string? HouseholdAddress,
[MaxLength(300)] string? CurrentAddress,
[MaxLength(20)] string? PostalCode,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(30)] string? Qq,
[MaxLength(60)] string? WeChat,
[MaxLength(50)] string? EmergencyContactName,
[MaxLength(30)] string? EmergencyContactRelationship,
[MaxLength(30)] string? EmergencyContactPhone,
[MaxLength(300)] string? SpecialTags,
[MaxLength(1000)] string? SpecialNeeds,
[MaxLength(1000)] string? Biography,
[MaxLength(500)] string? Notes);
public sealed record TeacherAccountActivationRequest(
@@ -31,6 +31,9 @@ public sealed class PersonnelExcelController(
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ExportRoles =
WriteRoles + "," + SystemRoles.Counselor;
private static readonly string[] TeacherHeaders =
[
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
@@ -40,7 +43,10 @@ public sealed class PersonnelExcelController(
private static readonly string[] StudentHeaders =
[
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
"学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
];
[HttpGet("{kind}/template")]
@@ -66,6 +72,7 @@ public sealed class PersonnelExcelController(
}
[HttpGet("{kind}/export")]
[Authorize(Roles = ExportRoles)]
public async Task<IActionResult> Export(
string kind,
[FromQuery] PersonnelQuery query,
@@ -96,7 +103,12 @@ public sealed class PersonnelExcelController(
.Select(x => Row(
x.StudentNumber, x.Name, GenderName(x.Gender),
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes))
StudentStatusName(x.Status), x.DateOfBirth, x.EnglishName,
x.IdCardNumber, x.Nationality, x.Ethnicity, x.PoliticalStatus,
x.NativePlace, x.HouseholdAddress, x.CurrentAddress, x.PostalCode,
x.Phone, x.Email, x.Qq, x.WeChat, x.EmergencyContactName,
x.EmergencyContactRelationship, x.EmergencyContactPhone,
x.SpecialTags, x.SpecialNeeds, x.Biography, x.Notes))
.ToList();
}
@@ -296,8 +308,25 @@ public sealed class PersonnelExcelController(
entity.EnrollmentDate = enrollmentDate.Value;
entity.Status = status.Value;
entity.DateOfBirth = dateOfBirth;
entity.EnglishName = Optional(row, "英文姓名");
entity.IdCardNumber = Optional(row, "证件号码");
entity.Nationality = Optional(row, "国籍");
entity.Ethnicity = Optional(row, "民族");
entity.PoliticalStatus = Optional(row, "政治面貌");
entity.NativePlace = Optional(row, "籍贯");
entity.HouseholdAddress = Optional(row, "户籍地址");
entity.CurrentAddress = Optional(row, "现居住地址");
entity.PostalCode = Optional(row, "邮政编码");
entity.Phone = Optional(row, "联系电话");
entity.Email = Optional(row, "电子邮箱");
entity.Qq = Optional(row, "QQ");
entity.WeChat = Optional(row, "微信");
entity.EmergencyContactName = Optional(row, "紧急联系人");
entity.EmergencyContactRelationship = Optional(row, "与本人关系");
entity.EmergencyContactPhone = Optional(row, "紧急联系电话");
entity.SpecialTags = Optional(row, "特殊标记");
entity.SpecialNeeds = Optional(row, "特殊情况说明");
entity.Biography = Optional(row, "个人简介");
entity.Notes = Optional(row, "备注");
}
return new(created, updated, rows.Count);
@@ -96,6 +96,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
x.SchedulingMode
})
.ToListAsync(cancellationToken);
@@ -103,6 +105,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task =>
{
@@ -121,6 +124,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
task.StartWeek,
task.EndWeek,
task.WeeklyHours,
task.CourseTotalHours,
task.CoursePracticeHours,
task.SchedulingMode,
HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
@@ -128,11 +133,16 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
: constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId,
constraint?.RequiredBuildingId,
constraint?.ExperimentRequiredCampusId,
constraint?.ExperimentRequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
constraint?.EarliestPeriod,
constraint?.LatestPeriod,
AllowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) ?? []
.Select(x => x.ClassroomId) ?? [],
AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId) ?? [],
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
};
}));
}
@@ -163,6 +173,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
{
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null)
{
@@ -190,6 +201,22 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
cancellationToken))
return ValidationProblem("指定校区不存在或已停用。");
Building? experimentBuilding = null;
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null) return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(request.AllowedClassroomIds, x => x.Id)
@@ -203,8 +230,23 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。");
var allowedExperimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
var allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(allowedExperimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != allowedExperimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null && allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
var constraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (constraint is null)
{
@@ -218,16 +260,28 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredBuildingId = request.RequiresClassroom
? request.RequiredBuildingId
: null;
constraint.ExperimentRequiredCampusId = request.RequiresClassroom
? request.ExperimentRequiredCampusId
: null;
constraint.ExperimentRequiredBuildingId = request.RequiresClassroom
? request.ExperimentRequiredBuildingId
: null;
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
? null
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
constraint.EarliestPeriod = request.EarliestPeriod;
constraint.LatestPeriod = request.LatestPeriod;
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = request.RequiresClassroom
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: [];
constraint.AllowedExperimentClassrooms = request.RequiresClassroom
? allowedExperimentRoomIds.Select(classroomId =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
@@ -253,18 +307,23 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
!request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope &&
!request.UpdateExperimentClassroomScope &&
!request.AllowedExperimentVenueNatures.HasValue &&
!request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。");
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
if (request.UpdateExperimentClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定实验场地时,场地要求不能设置为不占用教室。");
var tasks = await db.TeachingTasks
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.Include(x => x.Course)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
@@ -275,9 +334,16 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
(request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
if (request.UpdateExperimentClassroomScope && tasks.Any(task =>
(request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定实验场地,请先将当前筛选结果限定为正常排课课程。");
Building? building = null;
List<Classroom> allowedRooms = [];
var experimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
Building? experimentBuilding = null;
List<Classroom> allowedExperimentRooms = [];
if (request.UpdateClassroomScope)
{
if (request.RequiredBuildingId.HasValue)
@@ -314,10 +380,42 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。");
}
if (request.UpdateExperimentClassroomScope)
{
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null)
return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选实验校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(experimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != experimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null &&
allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
}
var constraints = await db.TeachingTaskScheduleConstraints
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks)
{
@@ -336,6 +434,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
request.RequiresClassroom.HasValue ||
request.AllowedDayOfWeeks is not null ||
request.UpdateClassroomScope ||
request.UpdateExperimentClassroomScope ||
request.AllowedExperimentVenueNatures.HasValue ||
request.UpdatePeriodRange;
if (!changesConstraint) continue;
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
@@ -351,7 +451,10 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
}
}
if (request.AllowedDayOfWeeks is not null)
@@ -373,6 +476,17 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
ClassroomId = room.Id
}).ToList();
}
if (task.Course?.PracticeHours > 0 && request.AllowedExperimentVenueNatures.HasValue)
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures.Value;
if (task.Course?.PracticeHours > 0 && request.UpdateExperimentClassroomScope)
{
constraint.ExperimentRequiredCampusId = request.ExperimentRequiredCampusId;
constraint.ExperimentRequiredBuildingId = request.ExperimentRequiredBuildingId;
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedExperimentClassrooms = allowedExperimentRooms.Select(room =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = room.Id }).ToList();
}
if (request.UpdatePeriodRange)
{
constraint.EarliestPeriod = request.EarliestPeriod;
@@ -396,11 +510,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiresClassroom = false;
constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null;
constraint.ExperimentRequiredCampusId = null;
constraint.ExperimentRequiredBuildingId = null;
constraint.AllowedDayOfWeeks = null;
constraint.EarliestPeriod = null;
constraint.LatestPeriod = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
}
private ActionResult ConflictProblem(string detail) =>
@@ -434,7 +552,11 @@ public sealed record TeachingTaskScheduleConstraintRequest(
IReadOnlyList<Guid> AllowedClassroomIds,
IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod);
[Range(1, 30)] int? LatestPeriod,
TeachingVenueNature AllowedExperimentVenueNatures = 0,
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
public sealed record TeachingTaskScheduleConstraintBatchRequest(
Guid AcademicTermId,
@@ -448,4 +570,9 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
IReadOnlyList<Guid>? AllowedClassroomIds,
bool UpdatePeriodRange,
[Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod);
[Range(1, 30)] int? LatestPeriod,
bool UpdateExperimentClassroomScope = false,
TeachingVenueNature? AllowedExperimentVenueNatures = null,
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -70,6 +71,7 @@ public sealed class SchedulesController(
{
entry.Id,
entry.TeachingTaskId,
entry.Kind,
TaskNumber = entry.TeachingTask!.TaskNumber,
TaskName = entry.TeachingTask.Name,
CourseCode = entry.TeachingTask.Course!.Code,
@@ -166,6 +168,7 @@ public sealed class SchedulesController(
Entries = source.Entries.Select(entry => new ScheduleEntry
{
TeachingTaskId = entry.TeachingTaskId,
Kind = entry.Kind,
ClassroomId = entry.ClassroomId,
DayOfWeek = entry.DayOfWeek,
StartPeriod = entry.StartPeriod,
@@ -418,6 +421,7 @@ public sealed class SchedulesController(
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
if (validation is not null) return validation;
entry.TeachingTaskId = request.TeachingTaskId;
entry.Kind = request.Kind;
entry.ClassroomId = request.ClassroomId;
entry.DayOfWeek = request.DayOfWeek;
entry.StartPeriod = request.StartPeriod;
@@ -497,6 +501,7 @@ public sealed class SchedulesController(
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null ||
task.Status != TeachingTaskStatus.Published ||
@@ -506,13 +511,53 @@ public sealed class SchedulesController(
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
var targetHours = TeachingTaskHours.TargetHours(
task.Course!,
request.Kind);
if (targetHours == 0)
return ValidationProblem(
request.Kind == ScheduleEntryKind.Experiment
? "该课程没有实践学时,不能安排实验课。"
: "该课程没有理论学时,不能安排理论课。");
var existingEntries = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
x.TeachingTaskId == request.TeachingTaskId &&
x.Kind == request.Kind &&
x.Id != entryId)
.Select(x => new
{
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.PeriodCount
})
.ToListAsync(cancellationToken);
var existingHours = existingEntries.Sum(x =>
TeachingTaskHours.ScheduledHours(
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.PeriodCount));
var proposedHours = TeachingTaskHours.ScheduledHours(
request.StartWeek,
request.EndWeek,
request.WeekPattern,
request.PeriodCount);
if (existingHours + proposedHours > targetHours)
return ValidationProblem(
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "" : "")}课" +
$"共需 {targetHours} 学时;当前操作后将达到 " +
$"{existingHours + proposedHours} 学时。");
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(
x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken);
var requiresClassroom = constraint?.RequiresClassroom ?? true;
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
constraint?.RequiresClassroom != false;
if (requiresClassroom && !request.ClassroomId.HasValue)
return ValidationProblem("该课程需要占用教室,请选择教室。");
if (!requiresClassroom && request.ClassroomId.HasValue)
@@ -536,18 +581,40 @@ public sealed class SchedulesController(
x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (constraint?.RequiredCampusId is Guid campusId &&
if (request.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。");
if (constraint?.RequiredBuildingId is Guid buildingId &&
if (request.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId)
return ValidationProblem("所选教室不在该课程指定的教学楼。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
classroom.Building!.CampusId != experimentCampusId)
return ValidationProblem("所选场地不在该实验课指定的校区。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
classroom.BuildingId != experimentBuildingId)
return ValidationProblem("所选场地不在该实验课指定的教学楼。");
var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (allowedClassroomIds.Count > 0 &&
if (request.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
allowedNatures != 0 &&
(classroom.TeachingVenueNature & allowedNatures) == 0)
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (request.Kind == ScheduleEntryKind.Experiment &&
allowedExperimentClassroomIds.Count > 0 &&
!allowedExperimentClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选场地不在该实验课指定的场地范围内。");
}
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
@@ -589,6 +656,7 @@ public sealed class SchedulesController(
{
SchedulePlanId = planId,
TeachingTaskId = request.TeachingTaskId,
Kind = request.Kind,
ClassroomId = request.ClassroomId,
DayOfWeek = request.DayOfWeek,
StartPeriod = request.StartPeriod,
@@ -606,6 +674,7 @@ public sealed class SchedulesController(
.Select(int.Parse)
.ToHashSet();
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
@@ -703,7 +772,8 @@ public sealed record ScheduleEntryRequest(
[Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek,
WeekPattern WeekPattern,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
public sealed record AutomaticScheduleJobResponse(
Guid Id,
+550
View File
@@ -0,0 +1,550 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/auth/sso")]
public sealed class SsoController(
UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService,
IDistributedCache cache,
IOptions<SsoOptions> options,
ILogger<SsoController> logger) : ControllerBase
{
private const string BindingIntentProperty = "sso-binding-intent";
private readonly SsoOptions _options = options.Value;
[AllowAnonymous]
[HttpGet("settings")]
public ActionResult<SsoSettingsResponse> Settings() =>
new SsoSettingsResponse(
_options.Enabled,
_options.DisplayName,
EffectiveCallbackUrl());
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpGet("login")]
public async Task<IActionResult> Login(
[FromQuery] string? returnUrl = null,
[FromQuery] string? bindingIntent = null,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var properties = new AuthenticationProperties();
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
if (targetUserId is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
properties.Items[BindingIntentProperty] = bindingIntent;
}
var completeUrl = Url.Action(
nameof(Complete),
values: new { returnUrl = safeReturnUrl })!;
properties.RedirectUri = completeUrl;
try
{
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
return new EmptyResult();
}
catch (OpenIdConnectProtocolException exception)
{
logger.LogWarning(
exception,
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
EffectiveCallbackUrl());
return RedirectToFrontendError(
"configuration_error",
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
}
}
[AllowAnonymous]
[ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("complete")]
public async Task<ActionResult> Complete(
[FromQuery] string? returnUrl,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var authentication = await HttpContext.AuthenticateAsync(
SsoAuthSchemes.ExternalCookie);
if (!authentication.Succeeded || authentication.Principal is null)
return RedirectToFrontendError("authentication_failed");
var principal = authentication.Principal;
var subject = principal.FindFirstValue("sub") ??
principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(subject))
return RedirectToFrontendError("missing_subject");
ApplicationUser? user = null;
var bindingIntent =
authentication.Properties is { } authenticationProperties &&
authenticationProperties.Items.TryGetValue(
BindingIntentProperty,
out var storedBindingIntent)
? storedBindingIntent
: null;
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
user = targetUserId is null
? null
: await userManager.FindByIdAsync(targetUserId);
if (user is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled", "/account");
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError, "/account");
await cache.RemoveAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
}
user ??= await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (user is null && _options.LinkExistingUsersByUserName)
{
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
if (!string.IsNullOrWhiteSpace(userName))
{
user = await userManager.FindByNameAsync(userName);
if (user is not null)
{
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError);
}
}
}
if (user is null)
{
var bindingCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
var externalUserName =
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
principal.FindFirstValue("name")?.Trim() ??
subject;
await cache.SetStringAsync(
BindingCacheKey(bindingCode),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var bindingPage = BuildFrontendUrl("/sso/bind") +
$"?code={Uri.EscapeDataString(bindingCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(bindingPage);
}
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled");
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return RedirectToFrontendError("account_update_failed");
var exchangeCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
ExchangeCacheKey(exchangeCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var callback = BuildFrontendUrl("/sso/callback") +
$"?code={Uri.EscapeDataString(exchangeCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(callback);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("exchange")]
public async Task<ActionResult<LoginResponse>> Exchange(
SsoExchangeRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var cacheKey = ExchangeCacheKey(request.Code);
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
if (userId is null)
return SsoProblem(
"统一身份认证结果已失效,请重新登录。",
StatusCodes.Status401Unauthorized);
await cache.RemoveAsync(cacheKey, cancellationToken);
var user = await userManager.FindByIdAsync(userId);
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return SsoProblem(
"本地账号不存在、已停用或已锁定。",
StatusCodes.Status401Unauthorized);
var roles = await userManager.GetRolesAsync(user);
var session = await authSessionService.CreateAsync(
user,
roles,
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return AuthController.CreateLoginResponse(session);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpGet("binding")]
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("bind")]
public async Task<ActionResult<LoginResponse>> Bind(
SsoBindRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
var user = await userManager.FindByNameAsync(request.UserName.Trim());
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return InvalidLocalCredentials();
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner is not null && subjectOwner.Id != user.Id)
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
var keycloakLogins = (await userManager.GetLoginsAsync(user))
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
.ToList();
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
if (subjectOwner is null)
{
var linkResult = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
ticket.Subject,
_options.DisplayName));
if (!linkResult.Succeeded)
{
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner?.Id != user.Id)
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
}
}
await userManager.ResetAccessFailedCountAsync(user);
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
var roles = await userManager.GetRolesAsync(user);
var session = await authSessionService.CreateAsync(
user,
roles,
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return AuthController.CreateLoginResponse(session);
}
[Authorize]
[HttpGet("account")]
public async Task<ActionResult<SsoAccountResponse>> Account()
{
var user = await CurrentUserAsync();
if (user is null)
return Unauthorized();
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
return new SsoAccountResponse(
_options.Enabled,
_options.DisplayName,
login is not null,
EffectiveCallbackUrl());
}
[Authorize]
[HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if ((await userManager.GetLoginsAsync(user))
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
{
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
}
var intentCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
BindingIntentCacheKey(intentCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
var loginUrl = Url.Action(
nameof(Login),
values: new
{
returnUrl = "/account",
bindingIntent = intentCode
})!;
return new SsoBindingStartResponse(loginUrl);
}
[Authorize]
[EnableRateLimiting("public-auth")]
[HttpPost("unbind")]
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
{
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if (!await userManager.HasPasswordAsync(user))
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
if (login is null)
return NoContent();
var result = await userManager.RemoveLoginAsync(
user,
login.LoginProvider,
login.ProviderKey);
if (!result.Succeeded)
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await userManager.ResetAccessFailedCountAsync(user);
return NoContent();
}
internal static string NormalizeReturnUrl(string? returnUrl) =>
!string.IsNullOrWhiteSpace(returnUrl) &&
returnUrl.StartsWith('/') &&
!returnUrl.StartsWith("//", StringComparison.Ordinal)
? returnUrl
: "/dashboard";
private string BuildFrontendUrl(string path) =>
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
? path
: _options.FrontendBaseUrl.TrimEnd('/') + path;
private RedirectResult RedirectToFrontendError(
string error,
string path = "/login") =>
Redirect(BuildFrontendUrl(path) +
$"?ssoError={Uri.EscapeDataString(error)}");
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
private static string BindingIntentCacheKey(string code) =>
$"sso:binding-intent:{code}";
private async Task<ApplicationUser?> CurrentUserAsync()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return userId is null ? null : await userManager.FindByIdAsync(userId);
}
private async Task<string?> LinkSsoIdentityAsync(
ApplicationUser user,
string subject)
{
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (subjectOwner is not null)
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
if ((await userManager.GetLoginsAsync(user)).Any(x =>
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
x.ProviderKey != subject))
{
return "account_already_bound";
}
var result = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
subject,
_options.DisplayName));
if (result.Succeeded)
return null;
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
}
private string EffectiveCallbackUrl()
{
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
return _options.CallbackUrl;
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
}
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
string code,
CancellationToken cancellationToken)
{
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
if (json is null)
return null;
try
{
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
}
catch (JsonException)
{
return null;
}
}
private UnauthorizedObjectResult InvalidLocalCredentials() =>
Unauthorized(new ProblemDetails
{
Title = "账户绑定失败",
Detail = "教务系统账号或密码不正确,或账号已停用。",
Status = StatusCodes.Status401Unauthorized
});
private ObjectResult BindingConflict(string detail) =>
SsoProblem(detail, StatusCodes.Status409Conflict);
private ObjectResult SsoProblem(string detail, int status) =>
StatusCode(status, new ProblemDetails
{
Title = "统一身份认证失败",
Detail = detail,
Status = status
});
}
public sealed record SsoSettingsResponse(
bool Enabled,
string DisplayName,
string CallbackUrl);
public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
bool IsNativeApp = false);
public sealed record SsoBindingInfoResponse(
string ProviderDisplayName,
string ExternalUserName);
public sealed record SsoBindRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
[Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password,
bool IsNativeApp = false);
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
public sealed record SsoAccountResponse(
bool Enabled,
string ProviderDisplayName,
bool IsBound,
string CallbackUrl);
public sealed record SsoBindingStartResponse(string LoginUrl);
public sealed record SsoUnbindRequest(
[Required, MaxLength(100)] string Password);
@@ -25,7 +25,12 @@ public sealed class StatisticsController(
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader;
private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId;
// HybridCache may execute its source factory without the request's ambient
// HttpContext. Keep the authorization scope stable for the whole controller
// invocation instead of resolving it again inside that background factory.
private readonly CurrentUserScope currentUser = currentUserDataScope.Current;
private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId;
private Guid? ResolveCollegeId(Guid? requestedCollegeId)
{
@@ -957,13 +962,14 @@ public sealed class StatisticsController(
async token =>
{
var source = await factory(token);
if (source.Result is not null || source.Value is null)
var data = source.Result is ObjectResult { Value: not null } objectResult
? objectResult.Value
: source.Value;
if (data is null)
throw new InvalidOperationException(
"Statistics cache source did not return a successful value.");
return JsonSerializer.SerializeToElement(
source.Value,
source.Value.GetType());
return JsonSerializer.SerializeToElement(data, data.GetType());
},
AppCacheProfile.Analytics,
[
@@ -981,7 +987,7 @@ public sealed class StatisticsController(
params string?[] filters) =>
AppCacheKeys.Statistics(
area,
currentUserDataScope.Current.Scope.ToString(),
currentUser.Scope.ToString(),
effectiveCollegeId,
filters);
@@ -0,0 +1,158 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.Student)]
[Route("api/student/profile")]
public sealed class StudentProfileController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<StudentProfileDto>> Get(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var profile = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new StudentProfileDto(
x.StudentNumber,
x.Name,
x.AdministrativeClass!.Name,
x.AdministrativeClass.Major!.Name,
x.AdministrativeClass.Major.College!.Name,
x.EnrollmentYear,
x.EnrollmentDate,
x.Status,
x.Gender,
x.DateOfBirth,
x.EnglishName,
x.IdCardNumber,
x.Nationality,
x.Ethnicity,
x.PoliticalStatus,
x.NativePlace,
x.HouseholdAddress,
x.CurrentAddress,
x.PostalCode,
x.Phone,
x.Email,
x.Qq,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
x.Biography))
.SingleOrDefaultAsync(cancellationToken);
return profile is null ? NotFound() : Ok(profile);
}
[HttpPut]
public async Task<IActionResult> Update(
StudentProfileUpdateRequest request,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.SingleOrDefaultAsync(
x => x.UserId == userId,
cancellationToken);
if (student is null) return NotFound();
student.Gender = request.Gender;
student.DateOfBirth = request.DateOfBirth;
student.EnglishName = Normalize(request.EnglishName);
student.IdCardNumber = Normalize(request.IdCardNumber);
student.Nationality = Normalize(request.Nationality);
student.Ethnicity = Normalize(request.Ethnicity);
student.PoliticalStatus = Normalize(request.PoliticalStatus);
student.NativePlace = Normalize(request.NativePlace);
student.HouseholdAddress = Normalize(request.HouseholdAddress);
student.CurrentAddress = Normalize(request.CurrentAddress);
student.PostalCode = Normalize(request.PostalCode);
student.Phone = Normalize(request.Phone);
student.Email = Normalize(request.Email);
student.Qq = Normalize(request.Qq);
student.WeChat = Normalize(request.WeChat);
student.EmergencyContactName = Normalize(request.EmergencyContactName);
student.EmergencyContactRelationship = Normalize(
request.EmergencyContactRelationship);
student.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
student.SpecialTags = Normalize(request.SpecialTags);
student.SpecialNeeds = Normalize(request.SpecialNeeds);
student.Biography = Normalize(request.Biography);
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Analytics, cancellationToken);
return NoContent();
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record StudentProfileDto(
string StudentNumber,
string Name,
string ClassName,
string MajorName,
string CollegeName,
int EnrollmentYear,
DateOnly EnrollmentDate,
StudentStatus Status,
Gender Gender,
DateOnly? DateOfBirth,
string? EnglishName,
string? IdCardNumber,
string? Nationality,
string? Ethnicity,
string? PoliticalStatus,
string? NativePlace,
string? HouseholdAddress,
string? CurrentAddress,
string? PostalCode,
string? Phone,
string? Email,
string? Qq,
string? WeChat,
string? EmergencyContactName,
string? EmergencyContactRelationship,
string? EmergencyContactPhone,
string? SpecialTags,
string? SpecialNeeds,
string? Biography);
public sealed record StudentProfileUpdateRequest(
Gender Gender,
DateOnly? DateOfBirth,
[MaxLength(100)] string? EnglishName,
[MaxLength(30)] string? IdCardNumber,
[MaxLength(50)] string? Nationality,
[MaxLength(50)] string? Ethnicity,
[MaxLength(50)] string? PoliticalStatus,
[MaxLength(100)] string? NativePlace,
[MaxLength(300)] string? HouseholdAddress,
[MaxLength(300)] string? CurrentAddress,
[MaxLength(20)] string? PostalCode,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(30)] string? Qq,
[MaxLength(60)] string? WeChat,
[MaxLength(50)] string? EmergencyContactName,
[MaxLength(30)] string? EmergencyContactRelationship,
[MaxLength(30)] string? EmergencyContactPhone,
[MaxLength(300)] string? SpecialTags,
[MaxLength(1000)] string? SpecialNeeds,
[MaxLength(1000)] string? Biography);
@@ -0,0 +1,28 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/system")]
public sealed class SystemController : ControllerBase
{
[AllowAnonymous]
[HttpGet("version")]
[ProducesResponseType<SystemVersionResponse>(StatusCodes.Status200OK)]
public SystemVersionResponse GetVersion()
{
var assembly = typeof(SystemController).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
var version = informationalVersion?.Split('+', 2)[0]
?? assembly.GetName().Version?.ToString(3)
?? "unknown";
return new SystemVersionResponse(version);
}
}
public sealed record SystemVersionResponse(string Version);
@@ -82,6 +82,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode,
x.Status,
TeacherNames = x.Teachers
@@ -127,6 +130,10 @@ public sealed class TeachingTasksController(
x.EndWeek,
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course!.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
@@ -163,6 +170,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours,
x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode,
x.Status,
x.Notes,
@@ -423,7 +433,8 @@ public sealed class TeachingTasksController(
course,
request.StartWeek,
request.EndWeek,
request.WeeklyHours);
request.WeeklyHours,
TeachingTaskSchedulingMode.Standard);
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
@@ -598,7 +609,8 @@ public sealed class TeachingTasksController(
course,
request.StartWeek,
request.EndWeek,
request.WeeklyHours);
request.WeeklyHours,
request.SchedulingMode);
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync(
@@ -367,7 +367,10 @@ public sealed class TimetablesController(
db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)))
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
db.ExamPlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == ExamPlanStatus.Published)))
.ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
@@ -394,7 +397,12 @@ public sealed class TimetablesController(
task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))))
task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
db.ExamSessions.Any(session =>
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
session.ExamPlan.Status == ExamPlanStatus.Published &&
session.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)))))
.ToListAsync(cancellationToken);
var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled)
@@ -21,32 +21,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
.OrderBy(x => x.Type).Select(x => new { x.Id, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
.ToListAsync(ct));
[HttpPut("rules")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
{
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
db.WarningRules.RemoveRange(existing);
var incomingTypes = rules.Select(x => x.Type).ToHashSet();
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
foreach (var r in rules)
{
db.WarningRules.Add(new WarningRule
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
if (entity is null)
{
entity = new WarningRule
{
AcademicTermId = academicTermId,
Type = r.Type,
Name = r.Name.Trim(),
Threshold = r.Threshold,
IsEnabled = r.IsEnabled,
NotifyStudent = r.NotifyStudent,
NotifyCounselor = r.NotifyCounselor,
Description = r.Description?.Trim(),
AutoCheckEnabled = r.AutoCheckEnabled,
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
CheckHour = r.CheckHour,
CheckMinute = r.CheckMinute
});
Name = r.Name.Trim()
};
db.WarningRules.Add(entity);
}
entity.Name = r.Name.Trim();
entity.Threshold = r.Threshold;
entity.IsEnabled = r.IsEnabled;
entity.NotifyStudent = r.NotifyStudent;
entity.NotifyCounselor = r.NotifyCounselor;
entity.Description = r.Description?.Trim();
entity.AutoCheckEnabled = r.AutoCheckEnabled;
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
entity.CheckHour = r.CheckHour;
entity.CheckMinute = r.CheckMinute;
}
await db.SaveChangesAsync(ct);
return NoContent();
@@ -120,7 +133,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
}
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
if (type.HasValue) q = q.Where(x => x.Type == type);
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
}
// ═══════════ Student ═══════════
@@ -131,7 +144,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
.Select(x => new { x.Id, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
.ToListAsync(ct));
}
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = [];
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
}
public sealed class AttendanceRecord
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
public DateTime? AppealReviewedAt { get; set; }
}
public sealed class AttendanceCheckInAttempt : EntityBase
{
public Guid AttendanceSheetId { get; set; }
public AttendanceSheet? AttendanceSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public AttendanceCheckInMethod CheckInMethod { get; set; }
public bool IsSuccessful { get; set; }
public string? FailureCode { get; set; }
public string? DeviceIdentifierHash { get; set; }
public string? DevicePlatform { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? RiskFlags { get; set; }
public decimal? Latitude { get; set; }
public decimal? Longitude { get; set; }
public double? AccuracyMeters { get; set; }
public double? DistanceMeters { get; set; }
}
public enum AttendanceSheetStatus
{
Draft = 1,
@@ -0,0 +1,14 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class CourseGradeStatisticsRefreshSetting : EntityBase
{
public const string DefaultKey = "default";
public string Key { get; set; } = DefaultKey;
public bool IsEnabled { get; set; } = true;
public int IntervalSeconds { get; set; } = 300;
public int BatchSize { get; set; } = 100;
public DateTime? LastRunAt { get; set; }
}
@@ -38,6 +38,22 @@ public sealed class CurriculumCourse : EntityBase
public string? Notes { get; set; }
}
public sealed class CourseGroup : EntityBase
{
public required string Code { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public ICollection<CourseGroupCourse> Courses { get; set; } = [];
}
public sealed class CourseGroupCourse : EntityBase
{
public Guid CourseGroupId { get; set; }
public CourseGroup? CourseGroup { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
}
public enum CurriculumPlanStatus
{
Draft = 1,
@@ -11,6 +11,42 @@ public sealed class ExamPlan : EntityBase
public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ExamSession> Sessions { get; set; } = [];
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
}
public sealed class ExamArrangementJob : EntityBase
{
public ExamArrangementKind Kind { get; set; }
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? ProjectIdsJson { get; set; }
public string? SessionIdsJson { get; set; }
public bool AssignClassrooms { get; set; }
public bool AssignInvigilators { get; set; }
public ExamArrangementJobStatus Status { get; set; } =
ExamArrangementJobStatus.Queued;
public int TotalSessions { get; set; }
public int ProcessedSessions { get; set; }
public string? CurrentStep { get; set; }
public string? ResultMessage { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamArrangementKind
{
FormalExam = 1,
MakeupExam = 2
}
public enum ExamArrangementJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public sealed class ExamSession : EntityBase
@@ -28,9 +64,12 @@ public sealed class ExamSession : EntityBase
public DateTime EndsAt { get; set; }
public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; }
public string? RequiredBuildingIds { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; }
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
}
public sealed class ExamSessionInvigilator
@@ -41,9 +80,84 @@ public sealed class ExamSessionInvigilator
public Teacher? Teacher { get; set; }
}
public sealed class ExamRoomAssignment : EntityBase
{
public Guid ExamPlanId { get; set; }
public ExamPlan? ExamPlan { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly ExamDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2;
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
}
public sealed class ExamRoomSession
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
}
public sealed class ExamSeatAssignment
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int SeatNumber { get; set; }
}
public sealed class ExamRoomInvigilator
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
}
public enum ExamPlanStatus
{
Draft = 1,
Published = 2,
Archived = 3
}
public sealed class ExamPublishJob : EntityBase
{
public ExamPublishJobKind Kind { get; set; }
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? ProjectIdsJson { 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,
ExperimentProjects = 3
}
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,85 @@
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 Guid? ScheduleEntryId { get; set; }
public ScheduleEntry? ScheduleEntry { get; set; }
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
public int? ScheduleWeek { 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,108 @@
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; } = [];
}
/// <summary>
/// A student's persisted experiment-part score for one teaching task.
/// The score is the weighted average of every published experiment project.
/// </summary>
public sealed class ExperimentCourseGrade : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public decimal? WeightedAverageScore { get; set; }
public decimal TotalWeight { get; set; }
public int PublishedProjectCount { get; set; }
public DateTime RefreshedAt { get; set; }
}
public sealed class ExperimentGradeItemScore
{
public Guid ExperimentGradeRecordId { get; set; }
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
public Guid ExperimentGradeItemId { get; set; }
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
public decimal? Score { get; set; }
public string? Comment { get; set; }
}
public enum ExperimentGradeSheetStatus
{
Draft = 1,
Submitted = 2,
Approved = 3,
Published = 4,
Returned = 5
}
public enum ExperimentGradeItemKind
{
Operation = 1,
Report = 2,
Result = 3,
Defense = 4,
Attendance = 5,
Safety = 6,
Other = 7
}
public enum ExperimentParticipationStatus
{
Pending = 1,
Completed = 2,
Absent = 3,
Excused = 4,
Makeup = 5,
Exempt = 6
}
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
public required string Name { get; set; }
public decimal Weight { get; set; }
public int SortOrder { get; set; }
public GradeItemSourceType SourceType { get; set; } =
GradeItemSourceType.Manual;
public DateTime? SourceSnapshotAt { get; set; }
public ICollection<GradeItemScore> Scores { get; set; } = [];
}
@@ -51,6 +54,99 @@ public sealed class GradeItemScore
public decimal? Score { get; set; }
}
/// <summary>
/// Persisted course-result aggregate. One course/term is materialized at each
/// organizational level so the result-analysis page never aggregates raw
/// grade records on request.
/// </summary>
public sealed class CourseGradeStatistic : EntityBase
{
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public CourseGradeStatisticScope Scope { get; set; }
public Guid? ScopeEntityId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int Below60Count { get; set; }
public int From60To69Count { get; set; }
public int From70To79Count { get; set; }
public int From80To89Count { get; set; }
public int From90To100Count { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal LowestScore { get; set; }
public decimal PassRate { get; set; }
public DateTime CalculatedAt { get; set; }
}
/// <summary>
/// Materialized analysis for one published teaching class. Course/term
/// organizational benchmarks stay in <see cref="CourseGradeStatistic"/>;
/// this table is the grain used for peer-class and historical comparisons.
/// </summary>
public sealed class TeachingTaskGradeStatistic : EntityBase
{
public Guid GradeSheetId { get; set; }
public GradeSheet? GradeSheet { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int ExcellentCount { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal MedianScore { get; set; }
public decimal LowestScore { get; set; }
public decimal StandardDeviation { get; set; }
public decimal PassRate { get; set; }
public decimal ExcellentRate { get; set; }
public DateTime CalculatedAt { get; set; }
public ICollection<TeachingTaskGradeScoreBand> ScoreBands { get; set; } = [];
}
/// <summary>
/// Flexible score-band rows are kept separately so future band definitions do
/// not require widening the teaching-class summary table.
/// </summary>
public sealed class TeachingTaskGradeScoreBand : EntityBase
{
public Guid TeachingTaskGradeStatisticId { get; set; }
public TeachingTaskGradeStatistic? TeachingTaskGradeStatistic { get; set; }
public required string Label { get; set; }
public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
public int StudentCount { get; set; }
public int SortOrder { get; set; }
}
public enum CourseGradeStatisticScope
{
AdministrativeClass = 1,
Major = 2,
College = 3,
University = 4
}
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
{
public Guid GradeSheetId { get; set; }
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
CourseGradeStatisticsRefreshJobStatus.Queued;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}
public enum CourseGradeStatisticsRefreshJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum GradeSheetStatus
{
Draft = 1,
@@ -68,3 +164,9 @@ public enum GradeExamStatus
Exempt = 4,
Makeup = 5
}
public enum GradeItemSourceType
{
Manual = 1,
ExperimentSummary = 2
}
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
public DateTime EndsAt { get; set; }
public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; }
public string? RequiredBuildingIds { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; }
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
@@ -46,9 +46,35 @@ public sealed class Classroom : CatalogEntity
public Building? Building { get; set; }
public int Capacity { get; set; }
public string RoomType { get; set; } = "普通教室";
public TeachingVenueNature TeachingVenueNature { get; set; } =
TeachingVenueNature.GeneralClassroom;
public string? Equipment { get; set; }
}
[Flags]
public enum TeachingVenueNature
{
GeneralClassroom = 1,
Laboratory = 2,
TrainingRoom = 4,
ComputerLab = 8,
LanguageLab = 16,
SportsVenue = 32,
ArtsVenue = 64
}
public static class TeachingVenueNatureRules
{
public const TeachingVenueNature ExperimentTeaching =
TeachingVenueNature.Laboratory |
TeachingVenueNature.TrainingRoom |
TeachingVenueNature.ComputerLab |
TeachingVenueNature.LanguageLab;
public static bool SupportsExperiment(TeachingVenueNature value) =>
(value & ExperimentTeaching) != 0;
}
public sealed class AcademicTerm : CatalogEntity
{
public required string AcademicYear { get; set; }
@@ -0,0 +1,44 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class OtherExamBatch : EntityBase
{
public string? ExamCode { get; set; }
public required string Name { get; set; }
public string? Organizer { get; set; }
public DateOnly ExamDate { get; set; }
public OtherExamMetricKind MetricKind { get; set; }
public decimal? MaxScore { get; set; }
public string? LevelOptions { get; set; }
public OtherExamBatchStatus Status { get; set; } = OtherExamBatchStatus.Draft;
public int PublicationCount { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<OtherExamResult> Results { get; set; } = [];
}
public sealed class OtherExamResult : EntityBase
{
public Guid OtherExamBatchId { get; set; }
public OtherExamBatch? OtherExamBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int AttemptNumber { get; set; } = 1;
public decimal? Score { get; set; }
public string? Level { get; set; }
public bool? IsPassed { get; set; }
public string? Notes { get; set; }
}
public enum OtherExamMetricKind
{
PassFail = 1,
Level = 2,
Score = 3
}
public enum OtherExamBatchStatus
{
Draft = 1,
Published = 2
}
@@ -30,8 +30,25 @@ public sealed class Student : EntityBase
public DateOnly EnrollmentDate { get; set; }
public StudentStatus Status { get; set; } = StudentStatus.Active;
public DateOnly? DateOfBirth { get; set; }
public string? EnglishName { get; set; }
public string? IdCardNumber { get; set; }
public string? Nationality { get; set; }
public string? Ethnicity { get; set; }
public string? PoliticalStatus { get; set; }
public string? NativePlace { get; set; }
public string? HouseholdAddress { get; set; }
public string? CurrentAddress { get; set; }
public string? PostalCode { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Qq { get; set; }
public string? WeChat { get; set; }
public string? EmergencyContactName { get; set; }
public string? EmergencyContactRelationship { get; set; }
public string? EmergencyContactPhone { get; set; }
public string? SpecialTags { get; set; }
public string? SpecialNeeds { get; set; }
public string? Biography { get; set; }
public string? Notes { get; set; }
public Guid? UserId { get; set; }
}
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
public SchedulePlan? SchedulePlan { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
public Guid? ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public int DayOfWeek { get; set; }
@@ -51,10 +52,16 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
public Campus? RequiredCampus { get; set; }
public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; }
public Guid? ExperimentRequiredCampusId { get; set; }
public Campus? ExperimentRequiredCampus { get; set; }
public Guid? ExperimentRequiredBuildingId { get; set; }
public Building? ExperimentRequiredBuilding { get; set; }
public string? AllowedDayOfWeeks { get; set; }
public int? EarliestPeriod { get; set; }
public int? LatestPeriod { get; set; }
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
public ICollection<TeachingTaskAllowedExperimentClassroom> AllowedExperimentClassrooms { get; set; } = [];
}
public sealed class TeachingTaskAllowedClassroom
@@ -65,6 +72,31 @@ public sealed class TeachingTaskAllowedClassroom
public Classroom? Classroom { get; set; }
}
public sealed class PublishedScheduleOccurrence : EntityBase
{
public Guid SchedulePlanId { get; set; }
public Guid AcademicTermId { get; set; }
public Guid ScheduleEntryId { get; set; }
public Guid TeachingTaskId { get; set; }
public Guid? ClassroomId { get; set; }
public int Week { get; set; }
public int DayOfWeek { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public ScheduleEntryKind Kind { get; set; }
public ScheduleEntry? ScheduleEntry { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class TeachingTaskAllowedExperimentClassroom
{
public Guid TeachingTaskScheduleConstraintId { get; set; }
public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class AutomaticScheduleJob : EntityBase
{
public Guid SchedulePlanId { get; set; }
@@ -129,3 +161,9 @@ public enum WeekPattern
Odd = 2,
Even = 3
}
public enum ScheduleEntryKind
{
Lecture = 1,
Experiment = 2
}
@@ -0,0 +1,22 @@
namespace Jiaowu.Api.Domain.Identity;
public enum AuthenticationClientType
{
Web = 0,
App = 1
}
public sealed class RefreshSession
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public ApplicationUser? User { get; set; }
public required string TokenHash { get; set; }
public AuthenticationClientType ClientType { get; set; }
public required string SecurityStamp { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastRefreshedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
public Guid? ReplacedBySessionId { 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,11 @@ public enum BackgroundJobKind
{
AutomaticSchedule = 1,
SchedulePublish = 2,
MakeupExamAuto = 3
MakeupExamAuto = 3,
ExamArrangement = 4,
ExamSignInExport = 5,
ExamPublish = 6,
CourseGradeStatisticsRefresh = 7
}
public enum BackgroundJobOutboxState
@@ -0,0 +1,14 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.System;
public static class SystemFeatureKeys
{
public const string SwaggerDocumentation = "SwaggerDocumentation";
}
public sealed class SystemFeatureSetting : EntityBase
{
public required string Key { get; set; }
public bool IsEnabled { get; set; }
}
@@ -0,0 +1,180 @@
using System.Security.Cryptography;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Infrastructure.Auth;
public interface IAuthSessionService
{
Task<AuthSessionResult> CreateAsync(
ApplicationUser user,
IEnumerable<string> roles,
AuthenticationClientType clientType,
CancellationToken cancellationToken = default);
Task<AuthSessionResult?> RefreshAsync(
string refreshToken,
CancellationToken cancellationToken = default);
Task RevokeAsync(
string refreshToken,
CancellationToken cancellationToken = default);
}
public sealed record AuthSessionResult(
string AccessToken,
DateTime AccessTokenExpiresAt,
string RefreshToken,
DateTime SessionExpiresAt,
ApplicationUser User,
IReadOnlyList<string> Roles);
public sealed class AuthSessionService(
AppDbContext db,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IOptions<JwtOptions> options) : IAuthSessionService
{
private readonly JwtOptions _options = options.Value;
public async Task<AuthSessionResult> CreateAsync(
ApplicationUser user,
IEnumerable<string> roles,
AuthenticationClientType clientType,
CancellationToken cancellationToken = default)
{
var roleList = roles.ToList();
var now = DateTime.UtcNow;
var rawRefreshToken = CreateRefreshToken();
var session = new RefreshSession
{
UserId = user.Id,
TokenHash = HashToken(rawRefreshToken),
ClientType = clientType,
SecurityStamp = user.SecurityStamp ?? string.Empty,
CreatedAt = now,
LastRefreshedAt = now,
ExpiresAt = now.Add(GetIdleTimeout(clientType))
};
await RemoveExpiredSessionsAsync(user.Id, now, cancellationToken);
db.RefreshSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
return BuildResult(user, roleList, rawRefreshToken, session.ExpiresAt);
}
public async Task<AuthSessionResult?> RefreshAsync(
string refreshToken,
CancellationToken cancellationToken = default)
{
var tokenHash = HashToken(refreshToken);
var now = DateTime.UtcNow;
var current = await db.RefreshSessions
.Include(x => x.User)
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
var user = current?.User;
if (current is null || user is null || current.RevokedAt.HasValue ||
current.ExpiresAt <= now || !user.IsEnabled ||
await userManager.IsLockedOutAsync(user) ||
!string.Equals(current.SecurityStamp, user.SecurityStamp ?? string.Empty,
StringComparison.Ordinal))
{
return null;
}
var newRawToken = CreateRefreshToken();
var replacement = new RefreshSession
{
UserId = user.Id,
TokenHash = HashToken(newRawToken),
ClientType = current.ClientType,
SecurityStamp = current.SecurityStamp,
CreatedAt = now,
LastRefreshedAt = now,
ExpiresAt = now.Add(GetIdleTimeout(current.ClientType))
};
var rotated = await db.ExecuteInRetriableTransactionAsync(
async transaction =>
{
db.ChangeTracker.Clear();
var updated = await db.RefreshSessions
.Where(x => x.Id == current.Id && x.RevokedAt == null && x.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.RevokedAt, now)
.SetProperty(x => x.ReplacedBySessionId, replacement.Id),
cancellationToken);
if (updated != 1)
{
await transaction.RollbackAsync(cancellationToken);
return false;
}
db.RefreshSessions.Add(replacement);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return true;
},
cancellationToken);
if (!rotated) return null;
var roles = await userManager.GetRolesAsync(user);
return BuildResult(user, roles.ToList(), newRawToken, replacement.ExpiresAt);
}
public async Task RevokeAsync(
string refreshToken,
CancellationToken cancellationToken = default)
{
var tokenHash = HashToken(refreshToken);
var now = DateTime.UtcNow;
await db.RefreshSessions
.Where(x => x.TokenHash == tokenHash && x.RevokedAt == null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(x => x.RevokedAt, now),
cancellationToken);
}
private AuthSessionResult BuildResult(
ApplicationUser user,
IReadOnlyList<string> roles,
string refreshToken,
DateTime sessionExpiresAt)
{
var accessToken = tokenService.Create(user, roles);
return new AuthSessionResult(
accessToken.Token,
accessToken.ExpiresAt,
refreshToken,
sessionExpiresAt,
user,
roles);
}
private TimeSpan GetIdleTimeout(AuthenticationClientType clientType) =>
TimeSpan.FromMinutes(clientType == AuthenticationClientType.App
? _options.AppIdleMinutes
: _options.WebIdleMinutes);
private async Task RemoveExpiredSessionsAsync(
Guid userId,
DateTime now,
CancellationToken cancellationToken)
{
var retentionCutoff = now.AddDays(-7);
await db.RefreshSessions
.Where(x => x.UserId == userId &&
(x.ExpiresAt < now || x.RevokedAt < retentionCutoff))
.ExecuteDeleteAsync(cancellationToken);
}
private static string CreateRefreshToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
private static string HashToken(string token) =>
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
}
@@ -6,5 +6,7 @@ public sealed class JwtOptions
public string Issuer { get; set; } = "Jiaowu.Api";
public string Audience { get; set; } = "Jiaowu.Web";
public string Key { get; set; } = string.Empty;
public int ExpireMinutes { get; set; } = 480;
public int AccessTokenMinutes { get; set; } = 10;
public int WebIdleMinutes { get; set; } = 30;
public int AppIdleMinutes { get; set; } = 3 * 24 * 60;
}
@@ -0,0 +1,24 @@
namespace Jiaowu.Api.Infrastructure.Auth;
public sealed class SsoOptions
{
public const string SectionName = "Sso";
public bool Enabled { get; set; }
public string DisplayName { get; set; } = "学校统一身份认证";
public string Authority { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string UserNameClaim { get; set; } = "preferred_username";
public bool RequireHttpsMetadata { get; set; } = true;
public bool LinkExistingUsersByUserName { get; set; } = true;
public string FrontendBaseUrl { get; set; } = string.Empty;
public string CallbackUrl { get; set; } = string.Empty;
}
public static class SsoAuthSchemes
{
public const string Keycloak = "Keycloak";
public const string ExternalCookie = "KeycloakExternal";
public const string LoginProvider = "Keycloak";
}
@@ -9,14 +9,16 @@ namespace Jiaowu.Api.Infrastructure.Auth;
public interface ITokenService
{
string Create(ApplicationUser user, IEnumerable<string> roles);
AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles);
}
public sealed record AccessTokenResult(string Token, DateTime ExpiresAt);
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
{
private readonly JwtOptions _options = options.Value;
public string Create(ApplicationUser user, IEnumerable<string> roles)
public AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles)
{
var claims = new List<Claim>
{
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
SecurityAlgorithms.HmacSha256);
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
expires: expiresAt,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
return new AccessTokenResult(
new JwtSecurityTokenHandler().WriteToken(token),
expiresAt);
}
}
@@ -13,6 +13,10 @@ public sealed class BackgroundJobOptions
public int AutomaticScheduleConcurrency { get; set; } = 1;
public int SchedulePublishConcurrency { get; set; } = 1;
public int MakeupExamAutoConcurrency { get; set; } = 1;
public int ExamArrangementConcurrency { get; set; } = 1;
public int ExamSignInExportConcurrency { get; set; } = 1;
public int ExamPublishConcurrency { get; set; } = 1;
public int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
public string Exchange { get; set; } = "jiaowu.background-jobs";
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
public bool UseQuorumQueues { get; set; } = true;
@@ -29,6 +33,11 @@ public sealed class BackgroundJobOptions
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
BackgroundJobKind.CourseGradeStatisticsRefresh =>
CourseGradeStatisticsRefreshConcurrency,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
@@ -103,6 +103,42 @@ public sealed class BackgroundJobOutboxPublisher(
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamArrangement &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamSignInExportJobStatus.Queued ||
x.Status == ExamSignInExportJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamSignInExport &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamPublish &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
.Where(x =>
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var missingKeys = automaticJobs
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
@@ -110,6 +146,14 @@ public sealed class BackgroundJobOutboxPublisher(
(BackgroundJobKind.SchedulePublish, id)))
.Concat(makeupJobs.Select(id =>
(BackgroundJobKind.MakeupExamAuto, id)))
.Concat(arrangementJobs.Select(id =>
(BackgroundJobKind.ExamArrangement, id)))
.Concat(exportJobs.Select(id =>
(BackgroundJobKind.ExamSignInExport, id)))
.Concat(publishJobs2.Select(id =>
(BackgroundJobKind.ExamPublish, id)))
.Concat(gradeStatisticsJobs.Select(id =>
(BackgroundJobKind.CourseGradeStatisticsRefresh, id)))
.ToList();
foreach (var (kind, jobId) in missingKeys)
{
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore;
@@ -85,6 +86,26 @@ public sealed class BackgroundJobRunner(
.GetRequiredService<MakeupExamAutoJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamArrangement:
await scope.ServiceProvider
.GetRequiredService<ExamArrangementJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamSignInExport:
await scope.ServiceProvider
.GetRequiredService<ExamSignInExportJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await scope.ServiceProvider
.GetRequiredService<ExamPublishJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await scope.ServiceProvider
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
default:
throw new InvalidOperationException(
$"Unsupported background job kind '{message.JobKind}'.");
@@ -259,6 +280,53 @@ public sealed class BackgroundJobRunner(
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.ExamArrangement:
await db.ExamArrangementJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamArrangementJobStatus.Succeeded &&
x.Status != ExamArrangementJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamArrangementJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await db.ExamPublishJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamPublishJobStatus.Succeeded &&
x.Status != ExamPublishJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamPublishJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await db.CourseGradeStatisticsRefreshJobs
.Where(x => x.Id == message.JobId &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.Status,
CourseGradeStatisticsRefreshJobStatus.Failed)
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(message.JobKind),
@@ -316,7 +316,11 @@ internal static class RabbitMqBackgroundJobTopology
[
BackgroundJobKind.AutomaticSchedule,
BackgroundJobKind.SchedulePublish,
BackgroundJobKind.MakeupExamAuto
BackgroundJobKind.MakeupExamAuto,
BackgroundJobKind.ExamArrangement,
BackgroundJobKind.ExamSignInExport,
BackgroundJobKind.ExamPublish,
BackgroundJobKind.CourseGradeStatisticsRefresh
];
public static async Task<IConnection> CreateConnectionAsync(
@@ -412,6 +416,10 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
BackgroundJobKind.SchedulePublish => "schedule.publish",
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
BackgroundJobKind.ExamArrangement => "exam.arrangement",
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
BackgroundJobKind.ExamPublish => "exam.publish",
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
@@ -182,12 +182,19 @@ public static class AppCacheKeys
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
}
public static string CourseGradeStatistics(Guid gradeSheetId) =>
$"grade-statistics:sheet:{gradeSheetId:N}";
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
}
public static class AppCacheTags
{
public const string BaseData = "base-data";
public const string Analytics = "analytics";
public const string CourseGradeStatistics = "grade-statistics";
public const string Timetables = "timetables";
public const string TimetableOptions = "timetable:options";
@@ -0,0 +1,126 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementJobProcessor(
AppDbContext db,
ExamArrangementService examArrangementService,
MakeupExamArrangementService makeupExamArrangementService,
IAppCache cache,
ILogger<ExamArrangementJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamArrangementJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamArrangementJobStatus.Succeeded
or ExamArrangementJobStatus.Failed)
{
return;
}
job.Status = ExamArrangementJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.ResultMessage = null;
job.CurrentStep = job.Kind == ExamArrangementKind.FormalExam
? "正在编排正式考试"
: "正在编排补考";
await db.SaveChangesAsync(stoppingToken);
var sessionIds = DeserializeSessionIds(job.SessionIdsJson);
var result = job.Kind switch
{
ExamArrangementKind.FormalExam =>
await examArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
ExamArrangementKind.MakeupExam =>
await makeupExamArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
_ => throw new InvalidOperationException(
$"不支持的考试编排类型:{job.Kind}。")
};
if (!result.Success)
{
await MarkFailedAsync(jobId, result.Message);
return;
}
job.Status = ExamArrangementJobStatus.Succeeded;
job.ProcessedSessions = job.TotalSessions;
job.CurrentStep = "编排完成";
job.ResultMessage = result.Message;
job.ActivePlanId = null;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
if (job.Kind == ExamArrangementKind.FormalExam)
{
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
stoppingToken);
}
logger.LogInformation(
"Exam arrangement job {JobId} for {Kind}/{PlanId} completed.",
job.Id,
job.Kind,
job.PlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Exam arrangement job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Exam arrangement job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private static IReadOnlyCollection<Guid>? DeserializeSessionIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
var sessionIds = JsonSerializer.Deserialize<Guid[]>(json);
return sessionIds is { Length: > 0 } ? sessionIds : null;
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamArrangementJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamArrangementJobStatus.Failed;
job.ActivePlanId = null;
job.CurrentStep = "编排失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
@@ -1,13 +1,33 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementService(AppDbContext db)
{
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
private sealed record RoomGroupKey(
Guid CourseId,
DateOnly ExamDate,
int StartPeriod,
int PeriodCount,
string RequiredBuildingIdsKey);
private sealed record RoomOccupancy(
Guid ClassroomId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record InvigilatorOccupancy(
Guid TeacherId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record CandidateSeat(
Guid ExamSessionId,
Guid StudentId,
string StudentNumber);
public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId,
@@ -29,6 +49,13 @@ public sealed class ExamArrangementService(AppDbContext db)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.Rooms)
.ThenInclude(x => x.SessionLinks)
.Include(x => x.Rooms)
.ThenInclude(x => x.Seats)
.Include(x => x.Rooms)
.ThenInclude(x => x.Invigilators)
.AsSplitQuery()
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
@@ -45,201 +72,549 @@ public sealed class ExamArrangementService(AppDbContext db)
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
var sessions = plan.Sessions
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenByDescending(x => x.RequiredInvigilatorCount)
.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var termId = plan.AcademicTermId;
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (timeSlots.Count == 0)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
foreach (var session in plan.Sessions)
ComputeTimesFromSlots(session, timeSlotLookup);
int assignedRooms = 0;
int assignedInvigilators = 0;
int unavailableRooms = 0;
int unavailableInvigilators = 0;
var explicitlySelected = plan.Sessions
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.ToList();
if (explicitlySelected.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var selectedKeys = explicitlySelected
.Select(GroupKey)
.ToHashSet();
var sessions = plan.Sessions
.Where(x => selectedKeys.Contains(GroupKey(x)))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ToList();
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
sessions.Select(x => x.TeachingTaskId),
cancellationToken);
var sessionByTaskId = sessions
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.First());
var replacedRooms = assignClassrooms
? plan.Rooms
.Where(room => room.SessionLinks.Any(link =>
sessionIds.Contains(link.ExamSessionId)))
.ToList()
: [];
var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet();
if (replacedRooms.Count > 0)
db.ExamRooms.RemoveRange(replacedRooms);
var occupiedRooms = await LoadOccupiedRoomsAsync(
plan,
sessionIds,
replacedRoomIds,
cancellationToken);
var rooms = await db.Classrooms.AsNoTracking()
.Include(x => x.Building)
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
var targetRooms = new List<ExamRoomAssignment>();
var messages = new List<string>();
var seatedStudents = 0;
var unavailableStudents = 0;
// Track occupied time slots to avoid conflicts
var occupiedRooms = sessions
.Where(x => x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
.ToList();
var occupiedInvigilators = sessions
.SelectMany(x => x.Invigilators.Select(i =>
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
.ToList();
if (assignClassrooms)
{
foreach (var session in sessions)
{
// Compute StartsAt/EndsAt from time slots
ComputeTimesFromSlots(session, timeSlotLookup);
var studentCount = await db.CourseEnrollments.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
cancellationToken);
session.ClassroomId = null;
if (session.Invigilators.Count > 0)
{
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators.Clear();
}
}
// ── Auto-assign classroom ──
if (assignClassrooms && !session.ClassroomId.HasValue)
foreach (var group in sessions
.GroupBy(GroupKey)
.OrderBy(x => x.Key.ExamDate)
.ThenBy(x => x.Key.StartPeriod)
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
{
var room = await FindBestClassroomAsync(
session, studentCount, occupiedRooms, cancellationToken);
if (room is not null)
var groupSessions = group.ToList();
var candidates = InterleaveCandidates(
groupSessions,
roster,
sessionByTaskId);
if (candidates.Count == 0)
{
session.ClassroomId = room.Id;
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
$"“{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
{
unavailableRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
targetRooms.AddRange(plan.Rooms.Where(room =>
room.SessionLinks.Any(link =>
sessionIds.Contains(link.ExamSessionId))));
var linkedSessionIds = targetRooms
.SelectMany(x => x.SessionLinks)
.Select(x => x.ExamSessionId)
.ToHashSet();
foreach (var session in sessions.Where(x =>
!linkedSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue))
{
var legacyRoom = CreateLegacyRoom(
plan,
session,
roster.Where(x =>
x.TeachingTaskId == session.TeachingTaskId));
db.ExamRooms.Add(legacyRoom);
targetRooms.Add(legacyRoom);
session.ClassroomId = null;
if (session.Invigilators.Count > 0)
{
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators.Clear();
}
}
// ── Auto-assign invigilators ──
var currentInvigilatorCount = session.Invigilators.Count;
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (assignInvigilators && needed > 0)
seatedStudents = targetRooms.Sum(x => x.Seats.Count);
}
var assignedInvigilators = 0;
var unavailableInvigilators = 0;
if (assignInvigilators)
{
var courseTeacherIds = session.TeachingTask!.Teachers
.Select(x => x.TeacherId).ToHashSet();
var newlyAssigned = await FindInvigilatorsAsync(
session, needed, courseTeacherIds,
occupiedInvigilators, cancellationToken);
foreach (var teacher in newlyAssigned)
if (targetRooms.Count == 0)
return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
var occupiedInvigilators = plan.Rooms
.Where(x => !replacedRoomIds.Contains(x.Id) &&
!targetRooms.Any(target => target.Id == x.Id))
.SelectMany(room => room.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
room.StartsAt,
room.EndsAt)))
.Concat(plan.Sessions
.Where(x => !sessionIds.Contains(x.Id))
.SelectMany(session => session.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
session.StartsAt,
session.EndsAt))))
.ToList();
foreach (var room in targetRooms
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.ClassroomId))
{
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
});
occupiedInvigilators.Add(new InvigilatorOccupancy(
teacher.Id, session.StartsAt, session.EndsAt));
teacher.Id,
room.StartsAt,
room.EndsAt));
assignedInvigilators++;
}
if (newlyAssigned.Count < needed)
{
unavailableInvigilators += needed - newlyAssigned.Count;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
if (teachers.Count < needed)
unavailableInvigilators += needed - teachers.Count;
}
}
await db.SaveChangesAsync(cancellationToken);
var expandedCount = sessions.Count - explicitlySelected.Count;
var detail = messages.Count > 0
? $" 详情:{string.Join("", messages.Take(10))}"
: "";
return new ExamArrangementResult(
true,
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
(messages.Count > 0 ? $" 详情:{string.Join("", messages.Take(10))}" : ""));
$"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
$"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
(expandedCount > 0
? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。"
: "") +
(unavailableStudents > 0
? $" {unavailableStudents}名考生尚未安排考场。"
: "") +
(unavailableInvigilators > 0
? $" 仍缺{unavailableInvigilators}名监考教师。"
: "") +
detail);
}
private async Task<List<RoomOccupancy>> LoadOccupiedRoomsAsync(
ExamPlan plan,
IReadOnlySet<Guid> targetSessionIds,
IReadOnlySet<Guid> replacedRoomIds,
CancellationToken cancellationToken)
{
var occupied = plan.Rooms
.Where(x => !replacedRoomIds.Contains(x.Id))
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToList();
occupied.AddRange(plan.Sessions
.Where(x =>
!targetSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt)));
var externalRooms = await db.ExamRooms.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived)
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalRooms);
var externalLegacyRooms = await db.ExamSessions.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ClassroomId != null)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalLegacyRooms);
return occupied;
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamRoomAssignment room,
int needed,
HashSet<Guid> excludedTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt,
x.EndsAt,
room.StartsAt,
room.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking()
.Where(x =>
x.ExamRoomId != room.Id &&
x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamRoom.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamRoom.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in databaseBusyIds)
busyTeacherIds.Add(teacherId);
var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x =>
x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamSession.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in legacyBusyIds)
busyTeacherIds.Add(teacherId);
foreach (var teacherId in excludedTeacherIds)
busyTeacherIds.Add(teacherId);
var candidates = await InvigilatorCandidateQuery
.Create(db, busyTeacherIds)
.ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
}
private static List<Classroom> SelectRooms(
IReadOnlyCollection<Classroom> availableRooms,
int candidateCount)
{
var remainingRooms = availableRooms.ToList();
var selected = new List<Classroom>();
var remainingSeats = candidateCount;
while (remainingSeats > 0 && remainingRooms.Count > 0)
{
var room = remainingRooms
.Where(x => x.Capacity / 2 >= remainingSeats)
.OrderBy(x => x.Capacity / 2)
.ThenBy(x => x.Name)
.FirstOrDefault()
?? remainingRooms
.OrderByDescending(x => x.Capacity / 2)
.ThenBy(x => x.Name)
.First();
selected.Add(room);
remainingRooms.Remove(room);
remainingSeats -= room.Capacity / 2;
}
return selected;
}
private static List<CandidateSeat> InterleaveCandidates(
IReadOnlyCollection<ExamSession> sessions,
IReadOnlyCollection<TeachingTaskRosterEntry> roster,
IReadOnlyDictionary<Guid, ExamSession> sessionByTaskId)
{
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var queues = roster
.Where(x =>
sessionByTaskId.TryGetValue(
x.TeachingTaskId,
out var session) &&
sessionIds.Contains(session.Id))
.GroupBy(x => x.TeachingTaskId)
.OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber)
.Select(group => new Queue<CandidateSeat>(
group.OrderBy(x => x.StudentNumber)
.Select(x => new CandidateSeat(
sessionByTaskId[x.TeachingTaskId].Id,
x.StudentId,
x.StudentNumber))))
.ToList();
var result = new List<CandidateSeat>();
var assignedStudentIds = new HashSet<Guid>();
while (queues.Any(x => x.Count > 0))
{
foreach (var queue in queues)
{
while (queue.Count > 0)
{
var candidate = queue.Dequeue();
if (!assignedStudentIds.Add(candidate.StudentId))
continue;
result.Add(candidate);
break;
}
}
}
return result;
}
private static ExamRoomAssignment CreateLegacyRoom(
ExamPlan plan,
ExamSession session,
IEnumerable<TeachingTaskRosterEntry> roster)
{
var students = roster
.OrderBy(x => x.StudentNumber)
.ToList();
return new ExamRoomAssignment
{
ExamPlanId = plan.Id,
CourseId = session.TeachingTask!.CourseId,
ClassroomId = session.ClassroomId!.Value,
ExamDate = session.ExamDate,
StartPeriod = session.StartPeriod,
PeriodCount = session.PeriodCount,
StartsAt = session.StartsAt,
EndsAt = session.EndsAt,
RequiredInvigilatorCount = session.RequiredInvigilatorCount,
SessionLinks =
[
new ExamRoomSession
{
ExamSessionId = session.Id
}
],
Seats = students.Select((student, index) =>
new ExamSeatAssignment
{
ExamSessionId = session.Id,
StudentId = student.StudentId,
SeatNumber = index + 1
}).ToList(),
Invigilators = session.Invigilators.Select(x =>
new ExamRoomInvigilator
{
TeacherId = x.TeacherId
}).ToList()
};
}
private static RoomGroupKey GroupKey(ExamSession session)
{
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
if (session.RequiredBuildingId.HasValue)
buildingIds.Add(session.RequiredBuildingId.Value);
var key = buildingIds.Count == 0
? string.Empty
: string.Join(",", buildingIds.OrderBy(x => x));
return new(
session.TeachingTask!.CourseId,
session.ExamDate,
session.StartPeriod,
session.PeriodCount,
key);
}
private static HashSet<Guid> ParseBuildingIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
}
catch
{
return [];
}
}
private static void ComputeTimesFromSlots(
ExamSession session,
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
{
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
var endSlot = timeSlotLookup.GetValueOrDefault(
session.StartPeriod + session.PeriodCount - 1);
if (startSlot is null || endSlot is null) return;
var examDate = session.ExamDate;
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
}
private async Task<Classroom?> FindBestClassroomAsync(
ExamSession session,
int studentCount,
List<RoomOccupancy> occupied,
CancellationToken cancellationToken)
{
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
// Exclude classrooms already occupied in-memory
var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.ClassroomId)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
// Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == session.ExamPlanId &&
x.Id != session.Id &&
x.ClassroomId != null &&
x.StartsAt < session.EndsAt &&
session.StartsAt < x.EndsAt)
.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query
.OrderBy(x => x.Capacity)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamSession session,
int needed,
HashSet<Guid> excludeTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
x.ExamSessionId != session.Id &&
x.ExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken);
session.StartsAt = session.ExamDate.ToDateTime(
startSlot.StartsAt,
DateTimeKind.Utc);
session.EndsAt = session.ExamDate.ToDateTime(
endSlot.EndsAt,
DateTimeKind.Utc);
}
}
public sealed record ExamArrangementResult(bool Success, string Message)
{
public static ExamArrangementResult Fail(string message) => new(false, message);
public static ExamArrangementResult Fail(string message) =>
new(false, message);
}
@@ -0,0 +1,328 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
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;
case ExamPublishJobKind.ExperimentProjects:
await PublishExperimentProjectsAsync(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 PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct)
{
var ids = JsonSerializer.Deserialize<List<Guid>>(job.ProjectIdsJson ?? "[]")?
.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
if (ids.Count is 0 or > 100)
throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。");
var projects = await db.ExperimentProjects
.Include(x => x.Sessions)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Where(x => ids.Contains(x.Id))
.ToListAsync(ct);
if (projects.Count != ids.Count)
throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。");
foreach (var project in projects)
{
if (project.Status != ExperimentProjectStatus.Draft)
throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。");
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
if (!hasSchedule)
throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。");
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
}
job.CurrentStep = "正在发布实验项目";
await db.SaveChangesAsync(ct);
var publishedAt = DateTime.UtcNow;
foreach (var project in projects)
{
project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = publishedAt;
}
await db.SaveChangesAsync(ct);
foreach (var project in projects)
{
var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId)
.Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct);
if (userIds.Count == 0) continue;
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约";
await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments", ct, NotificationCategory.Schedule);
}
}
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)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.AsSplitQuery()
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
@@ -99,7 +100,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++;
messages.Add(
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)");
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
}
else
{
@@ -170,10 +171,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
CancellationToken cancellationToken)
{
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount);
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
buildingIds.Add(session.RequiredBuildingId.Value);
if (buildingIds.Count > 0)
query = query.Where(x => buildingIds.Contains(x.BuildingId));
var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
@@ -225,11 +229,27 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
var candidates = await InvigilatorCandidateQuery
.Create(db, busyTeacherIds)
.ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
}
private static HashSet<Guid> ParseBuildingIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
}
catch
{
return [];
}
}
}
@@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper
string sheetName,
IReadOnlyList<string> headers,
IEnumerable<IReadOnlyList<object?>> rows,
IReadOnlyList<string>? instructions = null)
IReadOnlyList<string>? instructions = null,
Action<IXLWorksheet, int>? configureRow = null)
{
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add(sheetName);
@@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper
{
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
}
configureRow?.Invoke(sheet, rowNumber);
rowNumber++;
}
@@ -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,228 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
/// <summary>
/// Repairs missing or stale materialized grade statistics on a database-
/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
/// worker batches changes made during bulk imports.
/// </summary>
public sealed class CourseGradeStatisticsRefreshWorker(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
ILogger<CourseGradeStatisticsRefreshWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Database-configured course grade statistics scheduler started.");
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), timeProvider);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var scheduler = scope.ServiceProvider
.GetRequiredService<CourseGradeStatisticsRefreshScheduler>();
var queued = await scheduler.EnqueueDueAsync(
timeProvider.GetUtcNow().UtcDateTime,
stoppingToken);
if (queued > 0)
logger.LogInformation(
"Scheduled course grade statistics scan queued {Count} refresh jobs.",
queued);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Scheduled course grade statistics scan failed.");
}
if (!await timer.WaitForNextTickAsync(stoppingToken)) break;
}
}
}
public sealed class CourseGradeStatisticsRefreshScheduler(
AppDbContext db,
ILogger<CourseGradeStatisticsRefreshScheduler> logger)
{
public async Task<int> EnqueueDueAsync(
DateTime utcNow,
CancellationToken cancellationToken) =>
await ExecuteWithLeaseAsync(async () =>
{
var setting = await db.CourseGradeStatisticsRefreshSettings
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
if (setting is null)
{
setting = new CourseGradeStatisticsRefreshSetting();
db.CourseGradeStatisticsRefreshSettings.Add(setting);
}
var interval = TimeSpan.FromSeconds(
Math.Clamp(setting.IntervalSeconds, 10, 86400));
if (!setting.IsEnabled ||
setting.LastRunAt.HasValue && utcNow < setting.LastRunAt.Value + interval)
{
if (db.Entry(setting).State == EntityState.Added)
await db.SaveChangesAsync(cancellationToken);
return 0;
}
setting.LastRunAt = utcNow;
var queued = await EnqueueStaleCoreAsync(setting.BatchSize, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return queued;
}, cancellationToken);
public async Task<int> EnqueueStaleAsync(
int batchSize,
CancellationToken cancellationToken) =>
await ExecuteWithLeaseAsync(async () =>
{
var queued = await EnqueueStaleCoreAsync(batchSize, cancellationToken);
if (queued > 0) await db.SaveChangesAsync(cancellationToken);
return queued;
}, cancellationToken);
private async Task<int> ExecuteWithLeaseAsync(
Func<Task<int>> action,
CancellationToken cancellationToken)
{
var usesMySqlLease = db.Database.ProviderName?.Contains(
"MySql",
StringComparison.OrdinalIgnoreCase) == true;
if (usesMySqlLease && !await TryAcquireMySqlLeaseAsync(cancellationToken))
{
await db.Database.CloseConnectionAsync();
logger.LogDebug("Another instance owns the grade statistics refresh lease.");
return 0;
}
try
{
return await action();
}
finally
{
if (usesMySqlLease)
await ReleaseMySqlLeaseAsync();
}
}
private async Task<int> EnqueueStaleCoreAsync(
int batchSize,
CancellationToken cancellationToken)
{
batchSize = Math.Clamp(batchSize, 1, 5000);
var activeTargets = await (
from job in db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
join sheet in db.GradeSheets.AsNoTracking()
on job.GradeSheetId equals sheet.Id
where job.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
job.Status == CourseGradeStatisticsRefreshJobStatus.Running
select new CourseTermTarget(
sheet.TeachingTask!.CourseId,
sheet.TeachingTask.AcademicTermId))
.Distinct()
.ToListAsync(cancellationToken);
var active = activeTargets.ToHashSet();
var rows = await db.GradeSheets.AsNoTracking()
.Where(sheet =>
sheet.Status == GradeSheetStatus.Published &&
sheet.Records.Any(record => record.TotalScore != null))
.Select(sheet => new RefreshCandidate(
sheet.Id,
sheet.TeachingTask!.CourseId,
sheet.TeachingTask.AcademicTermId,
sheet.UpdatedAt,
sheet.Records
.Where(record => record.TotalScore != null)
.Max(record => record.UpdatedAt),
db.TeachingTaskGradeStatistics
.Where(statistic => statistic.GradeSheetId == sheet.Id)
.Select(statistic => (DateTime?)statistic.CalculatedAt)
.FirstOrDefault()))
.ToListAsync(cancellationToken);
var stale = rows
.Where(row =>
row.CalculatedAt is null ||
row.SheetUpdatedAt > row.CalculatedAt ||
row.RecordsUpdatedAt > row.CalculatedAt)
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
.Where(group => !active.Contains(group.Key))
.Select(group => group
.OrderByDescending(row => row.RecordsUpdatedAt)
.ThenByDescending(row => row.SheetUpdatedAt)
.First())
.Take(batchSize)
.ToArray();
foreach (var candidate in stale)
{
var job = new CourseGradeStatisticsRefreshJob
{
GradeSheetId = candidate.GradeSheetId
};
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
}
if (stale.Length == 0) return 0;
logger.LogDebug(
"Queued {Count} stale course grade statistics targets.",
stale.Length);
return stale.Length;
}
private async Task<bool> TryAcquireMySqlLeaseAsync(
CancellationToken cancellationToken)
{
await db.Database.OpenConnectionAsync(cancellationToken);
await using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = "SELECT GET_LOCK('jiaowu:grade-statistics-refresh', 0);";
var result = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt32(result) == 1;
}
private async Task ReleaseMySqlLeaseAsync()
{
try
{
await using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = "SELECT RELEASE_LOCK('jiaowu:grade-statistics-refresh');";
await command.ExecuteScalarAsync(CancellationToken.None);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to release grade statistics refresh lease.");
}
finally
{
await db.Database.CloseConnectionAsync();
}
}
private sealed record RefreshCandidate(
Guid GradeSheetId,
Guid CourseId,
Guid AcademicTermId,
DateTime SheetUpdatedAt,
DateTime RecordsUpdatedAt,
DateTime? CalculatedAt);
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -0,0 +1,250 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
/// <summary>
/// Rebuilds a course/term's denormalized result statistics. The operation is
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
/// </summary>
public sealed class CourseGradeStatisticsRefreshJobProcessor(
AppDbContext db,
IAppCache cache,
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
{
var job = await db.CourseGradeStatisticsRefreshJobs
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
return;
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
job.StartedAt = DateTime.UtcNow;
job.ErrorMessage = null;
await db.SaveChangesAsync(cancellationToken);
var sheetData = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == job.GradeSheetId)
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
.FirstOrDefaultAsync(cancellationToken);
if (sheetData is null)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return;
}
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
try
{
// Statistics shown to students are based only on formally published
// scores. This prevents an unfinished class from exposing data.
var scores = await db.GradeRecords.AsNoTracking()
.Where(x => x.TotalScore != null &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
.Select(x => new ScoreRow(
x.GradeSheetId,
x.GradeSheet!.TeachingTaskId,
x.TotalScore!.Value,
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.MajorId,
x.Student.AdministrativeClass.Major!.CollegeId))
.ToListAsync(cancellationToken);
var now = DateTime.UtcNow;
var rebuilt = new List<CourseGradeStatistic>();
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.Major,
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.College,
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
AddUniversityStatistic(scores, rebuilt, target, now);
var rebuiltTeachingTasks = scores
.GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId })
.Select(group => CreateTeachingTaskStatistic(
group.Key.GradeSheetId,
group.Key.TeachingTaskId,
group.Select(x => x.Score),
target,
now))
.ToList();
await db.CourseGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ExecuteDeleteAsync(cancellationToken);
if (rebuilt.Count > 0)
db.CourseGradeStatistics.AddRange(rebuilt);
var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (oldTeachingTaskStatisticIds.Count > 0)
{
await db.TeachingTaskGradeScoreBands
.Where(x => oldTeachingTaskStatisticIds.Contains(
x.TeachingTaskGradeStatisticId))
.ExecuteDeleteAsync(cancellationToken);
await db.TeachingTaskGradeStatistics
.Where(x => oldTeachingTaskStatisticIds.Contains(x.Id))
.ExecuteDeleteAsync(cancellationToken);
}
if (rebuiltTeachingTasks.Count > 0)
db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks);
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = now;
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
cancellationToken);
}
catch (Exception exception)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
exception.GetBaseException().Message.Length)];
await db.SaveChangesAsync(CancellationToken.None);
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
throw;
}
}
private static void AddStatistics(
CourseGradeStatisticScope scope,
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
foreach (var group in groups)
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
}
private static void AddUniversityStatistic(
IReadOnlyCollection<ScoreRow> scores,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
if (scores.Count > 0)
target.Add(Create(CourseGradeStatisticScope.University, null,
scores.Select(x => x.Score), targetInfo, calculatedAt));
}
private static CourseGradeStatistic Create(
CourseGradeStatisticScope scope,
Guid? scopeEntityId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.ToArray();
var passed = scores.Count(x => x >= 60m);
return new CourseGradeStatistic
{
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
Scope = scope,
ScopeEntityId = scopeEntityId,
StudentCount = scores.Length,
PassedCount = passed,
Below60Count = scores.Count(x => x < 60m),
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
From90To100Count = scores.Count(x => x >= 90m),
HighestScore = scores.Max(),
AverageScore = Math.Round(scores.Average(), 1),
LowestScore = scores.Min(),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
}
private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic(
Guid gradeSheetId,
Guid teachingTaskId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.OrderBy(x => x).ToArray();
var passed = scores.Count(x => x >= 60m);
var excellent = scores.Count(x => x >= 90m);
var average = scores.Average();
var middle = scores.Length / 2;
var median = scores.Length % 2 == 0
? (scores[middle - 1] + scores[middle]) / 2m
: scores[middle];
var variance = scores.Average(x =>
(double)((x - average) * (x - average)));
var statistic = new TeachingTaskGradeStatistic
{
GradeSheetId = gradeSheetId,
TeachingTaskId = teachingTaskId,
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
StudentCount = scores.Length,
PassedCount = passed,
ExcellentCount = excellent,
HighestScore = scores.Max(),
AverageScore = Math.Round(average, 1),
MedianScore = Math.Round(median, 1),
LowestScore = scores.Min(),
StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
statistic.ScoreBands =
[
CreateBand(statistic.Id, "059", 0m, 60m,
scores.Count(x => x < 60m), 0),
CreateBand(statistic.Id, "6069", 60m, 70m,
scores.Count(x => x >= 60m && x < 70m), 1),
CreateBand(statistic.Id, "7079", 70m, 80m,
scores.Count(x => x >= 70m && x < 80m), 2),
CreateBand(statistic.Id, "8089", 80m, 90m,
scores.Count(x => x >= 80m && x < 90m), 3),
CreateBand(statistic.Id, "90100", 90m, null,
scores.Count(x => x >= 90m), 4)
];
return statistic;
}
private static TeachingTaskGradeScoreBand CreateBand(
Guid statisticId,
string label,
decimal lowerBound,
decimal? upperBound,
int count,
int sortOrder) => new()
{
TeachingTaskGradeStatisticId = statisticId,
Label = label,
LowerBound = lowerBound,
UpperBound = upperBound,
StudentCount = count,
SortOrder = sortOrder
};
private sealed record ScoreRow(
Guid GradeSheetId,
Guid TeachingTaskId,
decimal Score,
Guid ClassId,
Guid MajorId,
Guid CollegeId);
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -0,0 +1,147 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService
{
public static async Task RefreshTeachingTaskAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var existing = await db.ExperimentCourseGrades
.Where(x => x.TeachingTaskId == teachingTaskId)
.ToDictionaryAsync(x => x.StudentId, cancellationToken);
var studentIds = sheets
.SelectMany(x => x.Scores.Select(score => score.StudentId))
.Distinct()
.ToArray();
var refreshedAt = DateTime.UtcNow;
var totalWeight = sheets.Sum(x => x.ContributionWeight);
foreach (var studentId in studentIds)
{
var score = CalculateStudentScore(sheets, studentId);
if (!existing.Remove(studentId, out var aggregate))
{
aggregate = new Domain.Academic.ExperimentCourseGrade
{
TeachingTaskId = teachingTaskId,
StudentId = studentId
};
db.ExperimentCourseGrades.Add(aggregate);
}
aggregate.WeightedAverageScore = score;
aggregate.TotalWeight = totalWeight;
aggregate.PublishedProjectCount = sheets.Count;
aggregate.RefreshedAt = refreshedAt;
}
db.ExperimentCourseGrades.RemoveRange(existing.Values);
await db.SaveChangesAsync(cancellationToken);
}
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db,
Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken)
{
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var requestedStudentIds = studentIds.Distinct().ToArray();
var persistedScores = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x =>
x.TeachingTaskId == teachingTaskId &&
requestedStudentIds.Contains(x.StudentId))
.ToDictionaryAsync(
x => x.StudentId,
x => x.WeightedAverageScore,
cancellationToken);
var scores = requestedStudentIds.ToDictionary(
studentId => studentId,
studentId => persistedScores.GetValueOrDefault(studentId));
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private static Task<List<PublishedExperimentSheet>> LoadPublishedSheetsAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken) =>
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);
private static decimal? CalculateStudentScore(
IReadOnlyCollection<PublishedExperimentSheet> sheets,
Guid studentId)
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
if (sheets.Count == 0) return null;
foreach (var sheet in sheets)
{
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore) return null;
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
return totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
}
private sealed record PublishedExperimentSheet(
Guid Id,
string Code,
string Name,
decimal ContributionWeight,
List<PublishedExperimentScore> Scores);
private sealed record PublishedExperimentScore(
Guid StudentId,
decimal? TotalScore);
}
public sealed record ExperimentGradeAggregateResult(
int PublishedProjectCount,
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
IReadOnlyDictionary<Guid, decimal?> Scores);
public sealed record ExperimentGradeAggregateProject(
Guid Id,
string Code,
string Name,
decimal ContributionWeight);
@@ -0,0 +1,45 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeCalculator
{
public static bool AreWeightsValid(
decimal contributionWeight,
decimal passScore,
IEnumerable<ExperimentGradeItem> items)
{
var itemList = items.ToList();
return contributionWeight is > 0 and <= 100 &&
passScore is >= 0 and <= 100 &&
itemList.Count > 0 &&
itemList.All(item => item.Weight is > 0 and <= 100) &&
itemList.Sum(item => item.Weight) == 100;
}
public static decimal? CalculateTotal(
ExperimentGradeRecord record,
IReadOnlyCollection<ExperimentGradeItem> items)
{
if (record.SafetyViolation ||
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
return 0;
if (record.ParticipationStatus is
ExperimentParticipationStatus.Pending or
ExperimentParticipationStatus.Excused or
ExperimentParticipationStatus.Exempt)
return null;
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
decimal total = 0;
foreach (var item in items)
{
if (!scores.TryGetValue(item.Id, out var score) ||
!score.Score.HasValue)
return null;
total += score.Score.Value * item.Weight / 100;
}
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
}
}
@@ -0,0 +1,514 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Jiaowu.Api.Controllers;
using SkiaSharp;
using A = DocumentFormat.OpenXml.Drawing;
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
using W = DocumentFormat.OpenXml.Wordprocessing;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class GradeAnalysisWordReportGenerator
{
private const string Blue = "2E74B5";
private const string DarkBlue = "1F4D78";
private const string Ink = "263238";
private const string Muted = "68707A";
private const string LightFill = "F2F4F7";
private const int ContentWidth = 9360;
public static byte[] Generate(
GradeAnalyticsController.TeachingClassAnalysisReport report,
DateTime generatedAt)
{
ArgumentNullException.ThrowIfNull(report.Summary);
using var stream = new MemoryStream();
using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true))
{
document.PackageProperties.Title = $"{report.CourseName}成绩分析报告";
document.PackageProperties.Subject = "教学班成绩统计与对比分析";
document.PackageProperties.Creator = "教务管理系统";
document.PackageProperties.Created = generatedAt;
var mainPart = document.AddMainDocumentPart();
mainPart.Document = new Document(new Body());
var settingsPart = mainPart.AddNewPart<DocumentSettingsPart>();
settingsPart.Settings = new Settings(new EvenAndOddHeaders());
settingsPart.Settings.Save();
AddStyles(mainPart);
var headerFooterIds = AddHeaderAndFooter(mainPart);
BuildBody(mainPart, report, generatedAt, headerFooterIds);
mainPart.Document.Save();
}
return stream.ToArray();
}
private static void BuildBody(
MainDocumentPart mainPart,
GradeAnalyticsController.TeachingClassAnalysisReport report,
DateTime generatedAt,
HeaderFooterIds headerFooterIds)
{
var body = mainPart.Document?.Body
?? throw new InvalidOperationException("The report document body has not been initialized.");
var summary = report.Summary!;
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220));
body.Append(MetadataTable([
("课程", $"{report.CourseCode} {report.CourseName}"),
("教学班", $"{report.TaskNumber} {report.TaskName}"),
("学期", report.TermName),
("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")),
("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")),
("统计对象", $"{summary.StudentCount} 份已发布有效成绩")
]));
body.Append(Heading("一、分析摘要", 1));
body.Append(Callout(BuildExecutiveSummary(report)));
body.Append(MetricsTable(summary));
body.Append(Heading("二、分数段分布", 1));
body.Append(Paragraph("图 1 当前教学班各分数段人数", 20, false, Muted, 80, 80));
body.Append(ImageParagraph(mainPart, DrawScoreBands(summary.ScoreBands), "分数段分布图", 6.3, 3.0));
body.Append(DataTable(
["分数段", "下限", "上限", "人数", "占比"],
summary.ScoreBands.Select(x => new[]
{
x.Label,
x.LowerBound.ToString("0.#"),
x.UpperBound?.ToString("0.#") ?? "无上限",
x.StudentCount.ToString(),
Percent(x.StudentCount, summary.StudentCount)
}),
[1800, 1500, 1500, 1500, 3060]));
body.Append(Heading("三、同课程教学班对比", 1));
body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80));
var peerChartHeight = Math.Clamp(1.45 + report.PeerTeachingClasses.Count * 0.32, 1.8, 3.35);
body.Append(ImageParagraph(mainPart, DrawPeerAverages(report.PeerTeachingClasses), "教学班平均分对比图", 6.3, peerChartHeight));
body.Append(DataTable(
["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"],
report.PeerTeachingClasses.Select(x => new[]
{
$"{x.TaskNumber}{(x.IsSelected ? "" : "")}\n{x.TeacherNames}",
x.StudentCount.ToString(),
Score(x.AverageScore),
Score(x.MedianScore),
x.StandardDeviation.ToString("0.00"),
Rate(x.PassRate),
Rate(x.ExcellentRate)
}),
[2600, 820, 1050, 1050, 1050, 1395, 1395]));
body.Append(Heading("四、各范围基准", 1));
body.Append(Paragraph("范围基准按当前课程、当前学期聚合;同一教学班包含多个来源行政班时,将分别列示可用基准。", 22, false, Muted, 0, 100));
body.Append(DataTable(
["范围", "对象", "人数", "最高分", "平均分", "最低分", "合格率"],
report.ScopeBenchmarks.Select(x => new[]
{
x.Scope, x.Name, x.StudentCount.ToString(), Score(x.HighestScore),
Score(x.AverageScore), Score(x.LowestScore), Rate(x.PassRate)
}),
[980, 2200, 900, 1200, 1200, 1200, 1680]));
body.Append(Heading("五、历年成绩趋势", 1));
body.Append(Paragraph("图 3 同课程全校与当前任课教师历年平均分", 20, false, Muted, 80, 80));
body.Append(ImageParagraph(mainPart, DrawHistory(report.History), "历年平均分趋势图", 6.3, 3.15));
body.Append(DataTable(
["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"],
report.History.Select(x => new[]
{
x.TermName,
x.CourseStudentCount.ToString(),
Score(x.CourseAverageScore),
Rate(x.CoursePassRate),
x.Instructor?.StudentCount.ToString() ?? "—",
x.Instructor is null ? "—" : Score(x.Instructor.AverageScore),
x.Instructor is null ? "—" : Rate(x.Instructor.PassRate)
}),
[1700, 1050, 1200, 1450, 1050, 1200, 1710]));
body.Append(Heading("六、统计口径与使用说明", 1));
body.Append(Paragraph("1. 本报告仅统计已正式发布且纳入当前统计任务的有效成绩,不包含草稿、未发布成绩或学生逐人成绩明细。", 22, false, Ink, 0, 80));
body.Append(Paragraph("2. 合格率按成绩达到 60 分计算,优秀率按成绩达到 90 分计算;平均分、中位数和标准差均基于同一批有效成绩。", 22, false, Ink, 0, 80));
body.Append(Paragraph("3. 同课程教学班对比限定为当前学期;历年对比同时展示课程全校口径和当前任课教师所带教学班的加权汇总。", 22, false, Ink, 0, 80));
body.Append(Paragraph("4. 统计结果用于教学诊断和质量改进,不应脱离样本量、课程难度、考核方式等背景作单一排名或评价。", 22, false, Ink, 0, 80));
body.Append(new SectionProperties(
new HeaderReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultHeader },
new HeaderReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenHeader },
new FooterReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultFooter },
new FooterReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenFooter },
new PageSize { Width = 12240, Height = 15840 },
new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 708, Footer = 708 }));
}
private static string BuildExecutiveSummary(GradeAnalyticsController.TeachingClassAnalysisReport report)
{
var summary = report.Summary!;
var band = summary.ScoreBands.OrderByDescending(x => x.StudentCount).FirstOrDefault();
var parts = new List<string>
{
$"本教学班共纳入 {summary.StudentCount} 份有效成绩,平均分 {Score(summary.AverageScore)},中位数 {Score(summary.MedianScore)},合格率 {Rate(summary.PassRate)},优秀率 {Rate(summary.ExcellentRate)}。"
};
if (band is not null)
parts.Add($"人数最多的分数段为 {band.Label},共 {band.StudentCount} 人,占 {Percent(band.StudentCount, summary.StudentCount)}。 ");
if (report.UniversityDelta is { } delta)
parts.Add($"与本学期全校同课程相比,平均分{Direction(delta.AverageScoreDifference, "")},合格率{Direction(delta.PassRateDifference, "")}。 ");
parts.Add($"成绩标准差为 {summary.StandardDeviation:0.00},分数范围 {Score(summary.LowestScore)}{Score(summary.HighestScore)}。建议结合分数段、同课程教学班和历年趋势综合研判。 ");
return string.Concat(parts);
}
private static string Direction(decimal value, string unit) =>
value > 0 ? $"高 {value:0.0} {unit}" : value < 0 ? $"低 {Math.Abs(value):0.0} {unit}" : "持平";
private static W.Table MetadataTable(IEnumerable<(string Label, string Value)> items)
{
var rows = items.Select(x => new[] { x.Label, x.Value });
return DataTable(["项目", "内容"], rows, [1800, 7560], false);
}
private static W.Table MetricsTable(GradeAnalyticsController.TeachingClassMetrics value)
{
return DataTable(
["指标", "结果", "指标", "结果"],
[
["最高分", Score(value.HighestScore), "最低分", Score(value.LowestScore)],
["平均分", Score(value.AverageScore), "中位数", Score(value.MedianScore)],
["合格人数", $"{value.PassedCount} 人", "合格率", Rate(value.PassRate)],
["优秀人数", $"{value.ExcellentCount} 人", "优秀率", Rate(value.ExcellentRate)],
["标准差", value.StandardDeviation.ToString("0.00"), "有效成绩", $"{value.StudentCount} 份"]
],
[1800, 2880, 1800, 2880]);
}
private static W.Table DataTable(
IReadOnlyList<string> headers,
IEnumerable<string[]> rows,
IReadOnlyList<int> widths,
bool shadeHeader = true)
{
var table = new W.Table();
table.Append(new TableProperties(
new TableWidth { Width = ContentWidth.ToString(), Type = TableWidthUnitValues.Dxa },
new TableIndentation { Width = 120, Type = TableWidthUnitValues.Dxa },
new TableLayout { Type = TableLayoutValues.Fixed },
new TableBorders(
Border<TopBorder>(), Border<LeftBorder>(), Border<BottomBorder>(),
Border<RightBorder>(), Border<InsideHorizontalBorder>(), Border<InsideVerticalBorder>()),
new TableCellMarginDefault(
new TopMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
new TableCellLeftMargin { Width = 120, Type = TableWidthValues.Dxa },
new BottomMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
new TableCellRightMargin { Width = 120, Type = TableWidthValues.Dxa })));
table.Append(new TableGrid(widths.Select(x => new GridColumn { Width = x.ToString() })));
table.Append(Row(headers, widths, shadeHeader ? LightFill : "FFFFFF", true, true));
foreach (var row in rows)
table.Append(Row(row, widths, "FFFFFF", false, false));
return table;
}
private static TableRow Row(
IReadOnlyList<string> values,
IReadOnlyList<int> widths,
string fill,
bool bold,
bool repeat)
{
var row = new TableRow();
if (repeat) row.AppendChild(new TableRowProperties(new TableHeader()));
for (var i = 0; i < widths.Count; i++)
{
var cell = new TableCell();
cell.Append(new TableCellProperties(
new TableCellWidth { Width = widths[i].ToString(), Type = TableWidthUnitValues.Dxa },
new Shading { Fill = fill, Val = ShadingPatternValues.Clear }));
var lines = (i < values.Count ? values[i] : "").Split('\n');
foreach (var line in lines)
cell.Append(Paragraph(line, 19, bold, Ink, 0, 0));
row.Append(cell);
}
return row;
}
private static T Border<T>() where T : BorderType, new() =>
new() { Val = BorderValues.Single, Color = "D6DBE1", Size = 4 };
private static W.Table Callout(string text)
{
return DataTable(["核心结论"], [[text]], [ContentWidth]);
}
private static Paragraph Heading(string text, int level)
{
var paragraph = new Paragraph(new ParagraphProperties(new ParagraphStyleId { Val = $"Heading{level}" }));
paragraph.Append(Run(text, level == 1 ? 32 : 26, true, level == 1 ? Blue : DarkBlue));
return paragraph;
}
private static Paragraph Paragraph(
string text,
int size,
bool bold,
string color,
int before,
int after)
{
var paragraph = new Paragraph(new ParagraphProperties(
new SpacingBetweenLines { Before = before.ToString(), After = after.ToString(), Line = "264", LineRule = LineSpacingRuleValues.Auto }));
paragraph.Append(Run(text, size, bold, color));
return paragraph;
}
private static Run Run(string text, int size, bool bold, string color)
{
return new Run(
new RunProperties(
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
new Bold { Val = bold },
new Color { Val = color },
new FontSize { Val = size.ToString() },
new FontSizeComplexScript { Val = size.ToString() }),
new Text(text) { Space = SpaceProcessingModeValues.Preserve });
}
private static Paragraph ImageParagraph(
MainDocumentPart mainPart,
byte[] image,
string description,
double widthInches,
double heightInches)
{
var part = mainPart.AddImagePart(ImagePartType.Png);
using (var stream = new MemoryStream(image)) part.FeedData(stream);
var relationshipId = mainPart.GetIdOfPart(part);
var width = (long)(widthInches * 914400L);
var height = (long)(heightInches * 914400L);
var drawing = new W.Drawing(
new DW.Inline(
new DW.Extent { Cx = width, Cy = height },
new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 },
new DW.DocProperties { Id = (UInt32Value)(uint)(mainPart.ImageParts.Count()), Name = description, Description = description },
new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }),
new A.Graphic(new A.GraphicData(
new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties { Id = 0, Name = description, Description = description },
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip { Embed = relationshipId, CompressionState = A.BlipCompressionValues.Print },
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0, Y = 0 },
new A.Extents { Cx = width, Cy = height }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })))
{ Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" }))
{ DistanceFromTop = 0, DistanceFromBottom = 0, DistanceFromLeft = 0, DistanceFromRight = 0 });
var paragraph = new Paragraph(new ParagraphProperties(
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { Before = "0", After = "120" }));
paragraph.Append(new Run(drawing));
return paragraph;
}
private static void AddStyles(MainDocumentPart mainPart)
{
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
var normal = new Style(
new StyleName { Val = "Normal" },
new StyleRunProperties(
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
new FontSize { Val = "22" }, new Color { Val = Ink }),
new StyleParagraphProperties(
new SpacingBetweenLines { Before = "0", After = "120", Line = "264", LineRule = LineSpacingRuleValues.Auto }))
{ Type = StyleValues.Paragraph, StyleId = "Normal", Default = true };
var h1 = new Style(
new StyleName { Val = "heading 1" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new StyleRunProperties(new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, new Bold(), new Color { Val = Blue }, new FontSize { Val = "32" }),
new StyleParagraphProperties(new KeepNext(), new SpacingBetweenLines { Before = "320", After = "160" }))
{ Type = StyleValues.Paragraph, StyleId = "Heading1" };
stylesPart.Styles = new Styles(normal, h1);
stylesPart.Styles.Save();
}
private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart)
{
var defaultHeader = mainPart.AddNewPart<HeaderPart>();
defaultHeader.Header = CreateHeader();
defaultHeader.Header.Save();
var evenHeader = mainPart.AddNewPart<HeaderPart>();
evenHeader.Header = CreateHeader();
evenHeader.Header.Save();
var defaultFooter = mainPart.AddNewPart<FooterPart>();
defaultFooter.Footer = CreateFooter();
defaultFooter.Footer.Save();
var evenFooter = mainPart.AddNewPart<FooterPart>();
evenFooter.Footer = CreateFooter();
evenFooter.Footer.Save();
return new HeaderFooterIds(
mainPart.GetIdOfPart(defaultHeader),
mainPart.GetIdOfPart(evenHeader),
mainPart.GetIdOfPart(defaultFooter),
mainPart.GetIdOfPart(evenFooter));
}
private static Header CreateHeader() =>
new(Paragraph("成绩分析报告 | 教务管理系统", 18, false, Muted, 0, 0));
private static Footer CreateFooter()
{
var footerParagraph = new Paragraph(new ParagraphProperties(new Justification { Val = JustificationValues.Right }));
footerParagraph.Append(Run("第 ", 18, false, Muted));
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
footerParagraph.Append(new Run(new FieldCode(" PAGE ")));
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
footerParagraph.Append(Run(" 页", 18, false, Muted));
return new Footer(footerParagraph);
}
private static byte[] DrawScoreBands(IReadOnlyList<GradeAnalyticsController.ScoreBand> rows)
{
return DrawChart(1200, 540, (canvas, typeface) =>
{
DrawAxes(canvas, typeface, "人数", 70, 35, 1080, 430);
var max = Math.Max(1, rows.Max(x => x.StudentCount));
var barWidth = 150f;
var gap = (1000f - rows.Count * barWidth) / Math.Max(1, rows.Count);
for (var i = 0; i < rows.Count; i++)
{
var x = 105 + gap / 2 + i * (barWidth + gap);
var height = rows[i].StudentCount / (float)max * 330;
using var paint = new SKPaint { Color = new SKColor(46, 116, 181), IsAntialias = true };
canvas.DrawRoundRect(new SKRect(x, 430 - height, x + barWidth, 430), 8, 8, paint);
DrawText(canvas, typeface, rows[i].StudentCount.ToString(), x + barWidth / 2, 415 - height, 24, Ink, SKTextAlign.Center, true);
DrawText(canvas, typeface, rows[i].Label, x + barWidth / 2, 475, 18, Muted, SKTextAlign.Center);
}
});
}
private static byte[] DrawPeerAverages(IReadOnlyList<GradeAnalyticsController.TeachingClassComparison> rows)
{
var visible = rows.Take(8).ToArray();
var height = Math.Max(250, 120 + visible.Length * 62);
return DrawChart(1200, height, (canvas, typeface) =>
{
var top = 55f;
var rowHeight = 62f;
DrawText(canvas, typeface, "0", 280, 40, 20, Muted, SKTextAlign.Center);
DrawText(canvas, typeface, "50", 700, 40, 20, Muted, SKTextAlign.Center);
DrawText(canvas, typeface, "100", 1120, 40, 20, Muted, SKTextAlign.Center);
for (var i = 0; i < visible.Length; i++)
{
var y = top + i * rowHeight;
var value = Math.Clamp((float)visible[i].AverageScore, 0, 100);
DrawText(canvas, typeface, visible[i].TaskNumber, 245, y + 28, 21, visible[i].IsSelected ? Blue : Ink, SKTextAlign.Right, visible[i].IsSelected);
using var track = new SKPaint { Color = new SKColor(235, 239, 244) };
using var fill = new SKPaint { Color = visible[i].IsSelected ? new SKColor(46, 116, 181) : new SKColor(155, 177, 202) };
canvas.DrawRoundRect(new SKRect(280, y, 1120, y + 34), 6, 6, track);
canvas.DrawRoundRect(new SKRect(280, y, 280 + value / 100 * 840, y + 34), 6, 6, fill);
DrawText(canvas, typeface, value.ToString("0.0"), 290 + value / 100 * 840, y + 27, 20, Ink, SKTextAlign.Left, true);
}
});
}
private static byte[] DrawHistory(IReadOnlyList<GradeAnalyticsController.HistoricalComparison> rows)
{
return DrawChart(1200, 570, (canvas, typeface) =>
{
DrawAxes(canvas, typeface, "平均分", 70, 35, 1080, 430);
if (rows.Count == 0) return;
var min = 0f;
var max = 100f;
var points = rows.Select((x, i) => new SKPoint(
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
430 - ((float)x.CourseAverageScore - min) / (max - min) * 350)).ToArray();
DrawLineSeries(canvas, typeface, points, rows.Select(x => x.CourseAverageScore).ToArray(), new SKColor(46, 116, 181));
var teacher = rows.Select((x, i) => x.Instructor is null ? (SKPoint?)null : new SKPoint(
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
430 - ((float)x.Instructor.AverageScore - min) / (max - min) * 350)).ToArray();
DrawOptionalLineSeries(canvas, typeface, teacher, rows.Select(x => x.Instructor?.AverageScore).ToArray(), new SKColor(211, 133, 45));
for (var i = 0; i < rows.Count; i++)
DrawText(canvas, typeface, rows[i].TermName, points[i].X, 480, 20, Muted, SKTextAlign.Center);
using var blue = new SKPaint { Color = new SKColor(46, 116, 181), StrokeWidth = 4 };
using var gold = new SKPaint { Color = new SKColor(211, 133, 45), StrokeWidth = 4 };
canvas.DrawLine(760, 520, 805, 520, blue);
canvas.DrawLine(940, 520, 985, 520, gold);
DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink);
DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink);
});
}
private static byte[] DrawChart(int width, int height, Action<SKCanvas, SKTypeface> draw)
{
using var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.White);
using var typeface = SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.Default;
draw(canvas, typeface);
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 92);
return data.ToArray();
}
private static void DrawAxes(SKCanvas canvas, SKTypeface typeface, string label, float left, float top, float right, float bottom)
{
using var axis = new SKPaint { Color = new SKColor(190, 198, 207), StrokeWidth = 2 };
canvas.DrawLine(left, bottom, right, bottom, axis);
canvas.DrawLine(left, top, left, bottom, axis);
DrawText(canvas, typeface, label, left, 25, 21, Muted);
}
private static void DrawLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint[] points, decimal[] values, SKColor color)
{
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
using var fill = new SKPaint { Color = color, IsAntialias = true };
using var builder = new SKPathBuilder();
builder.MoveTo(points[0]);
foreach (var point in points.Skip(1)) builder.LineTo(point);
using var path = builder.Detach();
canvas.DrawPath(path, paint);
for (var i = 0; i < points.Length; i++)
{
canvas.DrawCircle(points[i], 7, fill);
DrawText(canvas, typeface, values[i].ToString("0.0"), points[i].X, points[i].Y - 14, 19, Ink, SKTextAlign.Center, true);
}
}
private static void DrawOptionalLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint?[] points, decimal?[] values, SKColor color)
{
var available = points.Select((point, index) => (point, index)).Where(x => x.point.HasValue).ToArray();
if (available.Length == 0) return;
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
using var fill = new SKPaint { Color = color, IsAntialias = true };
for (var i = 1; i < available.Length; i++) canvas.DrawLine(available[i - 1].point!.Value, available[i].point!.Value, paint);
foreach (var item in available)
{
var point = item.point!.Value;
canvas.DrawCircle(point, 7, fill);
DrawText(canvas, typeface, values[item.index]!.Value.ToString("0.0"), point.X, point.Y + 28, 19, Ink, SKTextAlign.Center, true);
}
}
private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, float size, string color, SKTextAlign align = SKTextAlign.Left, bool bold = false)
{
using var font = new SKFont(typeface, size) { Embolden = bold };
using var paint = new SKPaint { Color = SKColor.Parse(color), IsAntialias = true };
canvas.DrawText(text, x, y, align, font, paint);
}
private static string Score(decimal value) => value.ToString("0.0");
private static string Rate(decimal value) => $"{value:0.0}%";
private static string Percent(int value, int total) => total == 0 ? "0.0%" : $"{(decimal)value / total * 100m:0.0}%";
private sealed record HeaderFooterIds(
string DefaultHeader,
string EvenHeader,
string DefaultFooter,
string EvenFooter);
}
@@ -0,0 +1,281 @@
using System.Data.Common;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed class DatabaseCommandTelemetryInterceptor(
ObservabilityOptions options,
ILogger<DatabaseCommandTelemetryInterceptor> logger)
: DbCommandInterceptor
{
public const string ActivitySourceName = "Jiaowu.Api.Database";
public const string MeterName = "Jiaowu.Api.Database";
private static readonly ActivitySource ActivitySource =
new(ActivitySourceName);
private static readonly Meter Meter = new(MeterName);
private static readonly Histogram<double> CommandDuration =
Meter.CreateHistogram<double>(
"jiaowu.db.command.duration",
"ms",
"EF Core database command duration");
private static readonly Counter<long> SlowCommandCount =
Meter.CreateCounter<long>(
"jiaowu.db.command.slow",
"{command}",
"EF Core commands exceeding the configured slow-query threshold");
private static readonly Counter<long> FailedCommandCount =
Meter.CreateCounter<long>(
"jiaowu.db.command.failed",
"{command}",
"Failed EF Core database commands");
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
Observe(command, eventData.Duration, "reader");
return result;
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "reader");
return ValueTask.FromResult(result);
}
public override int NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
int result)
{
Observe(command, eventData.Duration, "nonquery");
return result;
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "nonquery");
return ValueTask.FromResult(result);
}
public override object? ScalarExecuted(
DbCommand command,
CommandExecutedEventData eventData,
object? result)
{
Observe(command, eventData.Duration, "scalar");
return result;
}
public override ValueTask<object?> ScalarExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
object? result,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "scalar");
return ValueTask.FromResult(result);
}
public override void CommandFailed(
DbCommand command,
CommandErrorEventData eventData) =>
Observe(
command,
eventData.Duration,
"failed",
eventData.Exception.GetType().Name);
public override Task CommandFailedAsync(
DbCommand command,
CommandErrorEventData eventData,
CancellationToken cancellationToken = default)
{
Observe(
command,
eventData.Duration,
"failed",
eventData.Exception.GetType().Name);
return Task.CompletedTask;
}
public override void CommandCanceled(
DbCommand command,
CommandEndEventData eventData) =>
Observe(command, eventData.Duration, "canceled", "canceled");
public override Task CommandCanceledAsync(
DbCommand command,
CommandEndEventData eventData,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "canceled", "canceled");
return Task.CompletedTask;
}
private void Observe(
DbCommand command,
TimeSpan duration,
string commandKind,
string? errorType = null)
{
if (!options.Enabled) return;
var queryName = GetQueryName(command.CommandText);
var statementHash = GetStatementHash(command.CommandText);
var provider = GetProviderName(command);
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
var tags = new TagList
{
{ "db.system.name", provider },
{ "db.operation.name", commandKind },
{ "db.query.name", queryName }
};
if (errorType is not null)
tags.Add("error.type", errorType);
var durationMilliseconds = duration.TotalMilliseconds;
CommandDuration.Record(durationMilliseconds, tags);
if (errorType is not null)
FailedCommandCount.Add(1, tags);
using var activity = ActivitySource.StartActivity(
ActivityKind.Client,
Activity.Current?.Context ?? default,
startTime: DateTimeOffset.UtcNow - duration,
name: queryName);
if (activity is not null)
{
activity.SetTag("db.system.name", provider);
activity.SetTag("db.operation.name", commandKind);
activity.SetTag("db.query.name", queryName);
activity.SetTag("db.statement.hash", statementHash);
activity.SetTag(
"db.namespace",
EmptyToNull(command.Connection?.Database));
if (options.IncludeSqlText)
{
activity.SetTag(
"db.query.text",
Truncate(command.CommandText, options.MaximumSqlTextLength));
}
if (errorType is not null)
{
activity.SetTag("error.type", errorType);
activity.SetStatus(ActivityStatusCode.Error, errorType);
}
activity.SetEndTime(DateTime.UtcNow);
}
if (errorType is not null)
{
logger.LogError(
"Database command failed after {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"error {ErrorType}, trace {TraceId}).",
durationMilliseconds,
queryName,
commandKind,
provider,
statementHash,
errorType,
traceId);
return;
}
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
return;
SlowCommandCount.Add(1, tags);
if (options.IncludeSqlText)
{
logger.LogWarning(
"Slow database command took {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"trace {TraceId}). " +
"SQL template: {SqlTemplate}",
durationMilliseconds,
queryName,
commandKind,
provider,
statementHash,
traceId,
Truncate(command.CommandText, options.MaximumSqlTextLength));
}
else
{
logger.LogWarning(
"Slow database command took {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"trace {TraceId}).",
durationMilliseconds,
queryName,
commandKind,
provider,
statementHash,
traceId);
}
}
internal static string GetQueryName(string commandText)
{
using var reader = new StringReader(commandText);
while (reader.ReadLine() is { } line)
{
var trimmed = line.Trim();
if (trimmed.Length == 0) continue;
if (trimmed.StartsWith("-- ", StringComparison.Ordinal))
return Truncate(trimmed[3..].Trim(), 120);
return $"{FirstToken(trimmed)}:{GetStatementHash(commandText)}";
}
return $"unknown:{GetStatementHash(commandText)}";
}
internal static string GetStatementHash(string commandText)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandText));
return Convert.ToHexString(bytes.AsSpan(0, 6)).ToLowerInvariant();
}
private static string FirstToken(string value)
{
var end = value.IndexOfAny([' ', '\t', '\r', '\n', '(']);
var token = end < 0 ? value : value[..end];
return token.Length == 0
? "command"
: token.ToLowerInvariant();
}
private static string GetProviderName(DbCommand command)
{
var typeName = command.GetType().FullName ?? command.GetType().Name;
if (typeName.Contains("MySql", StringComparison.OrdinalIgnoreCase))
return "mysql";
if (typeName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
return "sqlite";
return "other_sql";
}
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
private static string Truncate(string value, int maximumLength) =>
value.Length <= maximumLength
? value
: value[..maximumLength];
}
@@ -0,0 +1,12 @@
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed class ObservabilityOptions
{
public const string SectionName = "Observability";
public bool Enabled { get; set; } = true;
public string ServiceName { get; set; } = "jiaowu-api";
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
public bool IncludeSqlText { get; set; }
public int MaximumSqlTextLength { get; set; } = 2000;
}
@@ -0,0 +1,556 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed class PerformanceReportService(
HttpClient httpClient,
IMemoryCache cache,
PerformanceReportingOptions options,
ObservabilityOptions observability,
ILogger<PerformanceReportService> logger)
{
public async Task<PerformanceReport> GetAsync(
string? range,
CancellationToken cancellationToken)
{
var rangeSpec = PerformanceRange.TryParse(range);
if (rangeSpec is null)
throw new ArgumentOutOfRangeException(
nameof(range),
"性能报表范围仅支持 15m、1h、24h 或 7d。");
if (!options.Enabled ||
string.IsNullOrWhiteSpace(options.PrometheusBaseUrl))
{
return PerformanceReport.NotConfigured(
rangeSpec.Key,
options.GrafanaBaseUrl);
}
var cacheKey = $"performance-report:{rangeSpec.Key}";
if (cache.TryGetValue<PerformanceReport>(cacheKey, out var cached))
return cached!;
PerformanceReport report;
try
{
report = await LoadAsync(rangeSpec, cancellationToken);
}
catch (Exception exception) when (
!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(
exception,
"Performance report source is unavailable for range {Range}.",
rangeSpec.Key);
report = PerformanceReport.Unavailable(
rangeSpec.Key,
options.GrafanaBaseUrl);
}
cache.Set(
cacheKey,
report,
TimeSpan.FromSeconds(options.CacheSeconds));
return report;
}
private async Task<PerformanceReport> LoadAsync(
PerformanceRange range,
CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var from = now - range.Duration;
var requestCountSelector = Selector(
options.RequestDurationMetric + "_count",
(options.ServiceLabel, "=", observability.ServiceName));
var requestBucketSelector = Selector(
options.RequestDurationMetric + "_bucket",
(options.ServiceLabel, "=", observability.ServiceName));
var errorCountSelector = Selector(
options.RequestDurationMetric + "_count",
(options.ServiceLabel, "=", observability.ServiceName),
("http_response_status_code", "=~", "5.."));
var databaseCountSelector = Selector(
options.DatabaseDurationMetric + "_count",
(options.ServiceLabel, "=", observability.ServiceName));
var databaseBucketSelector = Selector(
options.DatabaseDurationMetric + "_bucket",
(options.ServiceLabel, "=", observability.ServiceName));
var slowDatabaseSelector = Selector(
options.SlowDatabaseMetric,
(options.ServiceLabel, "=", observability.ServiceName));
var failedDatabaseSelector = Selector(
options.FailedDatabaseMetric,
(options.ServiceLabel, "=", observability.ServiceName));
var requestCountTask = QueryScalarAsync(
$"sum(increase({requestCountSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var serverErrorCountTask = QueryScalarAsync(
$"sum(increase({errorCountSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var requestP95Task = QueryScalarAsync(
"histogram_quantile(0.95, " +
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
"* 1000",
now,
cancellationToken);
var databaseP95Task = QueryScalarAsync(
"histogram_quantile(0.95, " +
$"sum by (le) (rate({databaseBucketSelector}[{range.RateWindow}])))",
now,
cancellationToken);
var slowCountTask = QueryScalarAsync(
$"sum(increase({slowDatabaseSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var failedCountTask = QueryScalarAsync(
$"sum(increase({failedDatabaseSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var requestTimelineTask = QueryRangeAsync(
$"sum(rate({requestCountSelector}[{range.RateWindow}]))",
from,
now,
range.StepSeconds,
cancellationToken);
var latencyTimelineTask = QueryRangeAsync(
"histogram_quantile(0.95, " +
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
"* 1000",
from,
now,
range.StepSeconds,
cancellationToken);
var routeLatencyTask = QueryVectorAsync(
"histogram_quantile(0.95, " +
$"sum by (le, http_route) (rate({requestBucketSelector}" +
$"[{range.RateWindow}]))) * 1000",
now,
cancellationToken);
var routeCountTask = QueryVectorAsync(
$"sum by (http_route) (increase({requestCountSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
var routeErrorTask = QueryVectorAsync(
$"sum by (http_route) (increase({errorCountSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
var queryLatencyTask = QueryVectorAsync(
"histogram_quantile(0.95, " +
$"sum by (le, db_query_name) (rate({databaseBucketSelector}" +
$"[{range.RateWindow}])))",
now,
cancellationToken);
var queryCountTask = QueryVectorAsync(
$"sum by (db_query_name) (increase({databaseCountSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
var querySlowTask = QueryVectorAsync(
$"sum by (db_query_name) (increase({slowDatabaseSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
await Task.WhenAll(
requestCountTask,
serverErrorCountTask,
requestP95Task,
databaseP95Task,
slowCountTask,
failedCountTask,
requestTimelineTask,
latencyTimelineTask,
routeLatencyTask,
routeCountTask,
routeErrorTask,
queryLatencyTask,
queryCountTask,
querySlowTask);
var requestCount = await requestCountTask;
var serverErrorCount = await serverErrorCountTask;
double? errorRate = requestCount is > 0 && serverErrorCount.HasValue
? serverErrorCount.Value / requestCount.Value * 100
: requestCount == 0
? 0
: null;
var timeline = MergeTimeline(
await requestTimelineTask,
await latencyTimelineTask);
var endpoints = MergeRanking(
await routeLatencyTask,
await routeCountTask,
await routeErrorTask,
"http_route");
var databaseQueries = MergeRanking(
await queryLatencyTask,
await queryCountTask,
await querySlowTask,
"db_query_name");
return new PerformanceReport(
"ready",
range.Key,
from,
now,
DateTime.UtcNow,
"prometheus",
EmptyToNull(options.GrafanaBaseUrl),
null,
new PerformanceHeadline(
Round(requestCount),
Round(await requestP95Task),
Round(errorRate),
Round(await databaseP95Task),
Round(await slowCountTask),
Round(await failedCountTask)),
timeline,
endpoints,
databaseQueries);
}
private async Task<double?> QueryScalarAsync(
string query,
DateTime time,
CancellationToken cancellationToken)
{
var vector = await QueryVectorAsync(
query,
time,
cancellationToken);
return vector.FirstOrDefault()?.Value;
}
private async Task<IReadOnlyList<PrometheusSample>> QueryVectorAsync(
string query,
DateTime time,
CancellationToken cancellationToken)
{
var uri = BuildUri(
"api/v1/query",
("query", query),
("time", ToUnixSeconds(time).ToString(
CultureInfo.InvariantCulture)));
using var document = await SendAsync(uri, cancellationToken);
var data = document.RootElement.GetProperty("data");
var result = data.GetProperty("result");
var samples = new List<PrometheusSample>();
foreach (var item in result.EnumerateArray())
{
var labels = ReadLabels(item.GetProperty("metric"));
if (!TryReadValue(item.GetProperty("value"), out var value))
continue;
samples.Add(new PrometheusSample(labels, value));
}
return samples;
}
private async Task<IReadOnlyList<PerformanceSeriesPoint>> QueryRangeAsync(
string query,
DateTime from,
DateTime to,
int stepSeconds,
CancellationToken cancellationToken)
{
var uri = BuildUri(
"api/v1/query_range",
("query", query),
("start", ToUnixSeconds(from).ToString(
CultureInfo.InvariantCulture)),
("end", ToUnixSeconds(to).ToString(
CultureInfo.InvariantCulture)),
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)));
using var document = await SendAsync(uri, cancellationToken);
var result = document.RootElement
.GetProperty("data")
.GetProperty("result");
var first = result.EnumerateArray().FirstOrDefault();
if (first.ValueKind == JsonValueKind.Undefined ||
!first.TryGetProperty("values", out var values))
return [];
var points = new List<PerformanceSeriesPoint>();
foreach (var value in values.EnumerateArray())
{
if (!TryReadValue(value, out var measurement)) continue;
var timestamp = value[0].GetDouble();
points.Add(new PerformanceSeriesPoint(
DateTimeOffset.FromUnixTimeMilliseconds(
checked((long)(timestamp * 1000))).UtcDateTime,
measurement));
}
return points;
}
private async Task<JsonDocument> SendAsync(
Uri uri,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (!string.IsNullOrWhiteSpace(options.BearerToken))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", options.BearerToken);
}
using var response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(
cancellationToken);
var document = await JsonDocument.ParseAsync(
stream,
cancellationToken: cancellationToken);
if (!document.RootElement.TryGetProperty("status", out var status) ||
status.GetString() != "success")
{
document.Dispose();
throw new InvalidOperationException(
"Prometheus 返回了非成功查询状态。");
}
return document;
}
private Uri BuildUri(
string relativePath,
params (string Key, string Value)[] parameters)
{
var baseUri = new Uri(
options.PrometheusBaseUrl.TrimEnd('/') + "/",
UriKind.Absolute);
var query = string.Join(
"&",
parameters.Select(parameter =>
$"{Uri.EscapeDataString(parameter.Key)}=" +
$"{Uri.EscapeDataString(parameter.Value)}"));
return new Uri(baseUri, $"{relativePath}?{query}");
}
private static string Selector(
string metric,
params (string Label, string Operator, string Value)[] filters)
{
var matchers = string.Join(
",",
filters.Select(filter =>
$"{filter.Label}{filter.Operator}\"" +
$"{EscapePrometheusValue(filter.Value)}\""));
return $"{metric}{{{matchers}}}";
}
private static string EscapePrometheusValue(string value) =>
value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal);
private static IReadOnlyDictionary<string, string> ReadLabels(
JsonElement metric)
{
var result = new Dictionary<string, string>(
StringComparer.Ordinal);
foreach (var property in metric.EnumerateObject())
result[property.Name] = property.Value.GetString() ?? "";
return result;
}
private static bool TryReadValue(
JsonElement value,
out double measurement)
{
measurement = 0;
if (value.ValueKind != JsonValueKind.Array ||
value.GetArrayLength() < 2)
return false;
var raw = value[1].GetString();
return double.TryParse(
raw,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out measurement) &&
double.IsFinite(measurement);
}
private static IReadOnlyList<PerformanceTimelinePoint> MergeTimeline(
IReadOnlyList<PerformanceSeriesPoint> requestRate,
IReadOnlyList<PerformanceSeriesPoint> latency)
{
var points = new SortedDictionary<DateTime, PerformanceTimelinePoint>();
foreach (var point in requestRate)
{
points[point.Timestamp] = new PerformanceTimelinePoint(
point.Timestamp,
Math.Round(point.Value, 3),
null);
}
foreach (var point in latency)
{
points.TryGetValue(point.Timestamp, out var existing);
points[point.Timestamp] = new PerformanceTimelinePoint(
point.Timestamp,
existing?.RequestsPerSecond,
Math.Round(point.Value, 2));
}
return points.Values.ToArray();
}
private static IReadOnlyList<PerformanceRankingItem> MergeRanking(
IReadOnlyList<PrometheusSample> latency,
IReadOnlyList<PrometheusSample> count,
IReadOnlyList<PrometheusSample> exceptional,
string label)
{
var names = latency
.Concat(count)
.Concat(exceptional)
.Select(item => item.Labels.GetValueOrDefault(label))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.Ordinal)
.ToArray();
var items = names.Select(name =>
{
var latencyValue = FindValue(latency, label, name);
var countValue = FindValue(count, label, name);
var exceptionalValue = FindValue(exceptional, label, name);
return new PerformanceRankingItem(
name!,
Round(latencyValue),
Round(countValue),
Round(exceptionalValue));
});
return items
.OrderByDescending(item => item.P95Milliseconds ?? -1)
.ThenByDescending(item => item.RequestCount ?? -1)
.Take(10)
.ToArray();
}
private static double? FindValue(
IReadOnlyList<PrometheusSample> samples,
string label,
string? name) =>
samples.FirstOrDefault(item =>
item.Labels.GetValueOrDefault(label) == name)?.Value;
private static double ToUnixSeconds(DateTime value) =>
new DateTimeOffset(
DateTime.SpecifyKind(value, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
/ 1000d;
private static double? Round(double? value) =>
value.HasValue && double.IsFinite(value.Value)
? Math.Round(value.Value, 2)
: null;
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
private sealed record PrometheusSample(
IReadOnlyDictionary<string, string> Labels,
double Value);
private sealed record PerformanceSeriesPoint(
DateTime Timestamp,
double Value);
}
public sealed record PerformanceHeadline(
double? RequestCount,
double? RequestP95Milliseconds,
double? ServerErrorRatePercent,
double? DatabaseP95Milliseconds,
double? SlowDatabaseCommandCount,
double? FailedDatabaseCommandCount);
public sealed record PerformanceTimelinePoint(
DateTime Timestamp,
double? RequestsPerSecond,
double? RequestP95Milliseconds);
public sealed record PerformanceRankingItem(
string Name,
double? P95Milliseconds,
double? RequestCount,
double? ExceptionalCount);
public sealed record PerformanceReport(
string Status,
string Range,
DateTime? From,
DateTime? To,
DateTime GeneratedAt,
string DataSource,
string? DashboardUrl,
string? Detail,
PerformanceHeadline? Headline,
IReadOnlyList<PerformanceTimelinePoint> Timeline,
IReadOnlyList<PerformanceRankingItem> Endpoints,
IReadOnlyList<PerformanceRankingItem> DatabaseQueries)
{
public static PerformanceReport NotConfigured(
string range,
string? dashboardUrl) =>
Empty(
"not_configured",
range,
dashboardUrl,
"尚未配置 Prometheus 数据源。请先部署指标存储并设置 " +
"PerformanceReporting__PrometheusBaseUrl。");
public static PerformanceReport Unavailable(
string range,
string? dashboardUrl) =>
Empty(
"unavailable",
range,
dashboardUrl,
"性能数据源暂时不可用。系统业务不受影响,请检查 Prometheus 与网络配置。");
private static PerformanceReport Empty(
string status,
string range,
string? dashboardUrl,
string detail) =>
new(
status,
range,
null,
null,
DateTime.UtcNow,
"prometheus",
string.IsNullOrWhiteSpace(dashboardUrl) ? null : dashboardUrl,
detail,
null,
[],
[],
[]);
}
internal sealed record PerformanceRange(
string Key,
TimeSpan Duration,
string PrometheusRange,
string RateWindow,
int StepSeconds)
{
public static PerformanceRange? TryParse(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
"15m" => new("15m", TimeSpan.FromMinutes(15), "15m", "1m", 30),
"1h" => new("1h", TimeSpan.FromHours(1), "1h", "5m", 60),
"24h" => new("24h", TimeSpan.FromHours(24), "24h", "15m", 900),
"7d" => new("7d", TimeSpan.FromDays(7), "7d", "1h", 3600),
_ => null
};
}
@@ -0,0 +1,31 @@
using System.Text.RegularExpressions;
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed partial class PerformanceReportingOptions
{
public const string SectionName = "PerformanceReporting";
public bool Enabled { get; set; }
public string PrometheusBaseUrl { get; set; } = "";
public string BearerToken { get; set; } = "";
public string GrafanaBaseUrl { get; set; } = "";
public int CacheSeconds { get; set; } = 30;
public int TimeoutSeconds { get; set; } = 10;
public string ServiceLabel { get; set; } = "service_name";
public string RequestDurationMetric { get; set; } =
"http_server_request_duration_seconds";
public string DatabaseDurationMetric { get; set; } =
"jiaowu_db_command_duration_milliseconds";
public string SlowDatabaseMetric { get; set; } =
"jiaowu_db_command_slow_total";
public string FailedDatabaseMetric { get; set; } =
"jiaowu_db_command_failed_total";
public static bool IsMetricOrLabelName(string value) =>
!string.IsNullOrWhiteSpace(value) &&
PrometheusNamePattern().IsMatch(value);
[GeneratedRegex("^[a-zA-Z_:][a-zA-Z0-9_:]*$")]
private static partial Regex PrometheusNamePattern();
}
@@ -26,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
public DbSet<CourseGroup> CourseGroups => Set<CourseGroup>();
public DbSet<CourseGroupCourse> CourseGroupCourses => Set<CourseGroupCourse>();
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
@@ -33,17 +35,33 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeacherCourseApplication>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<PublishedScheduleOccurrence> PublishedScheduleOccurrences => Set<PublishedScheduleOccurrence>();
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
Set<TeachingTaskScheduleConstraint>();
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
Set<TeachingTaskAllowedClassroom>();
public DbSet<TeachingTaskAllowedExperimentClassroom> TeachingTaskAllowedExperimentClassrooms =>
Set<TeachingTaskAllowedExperimentClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
Set<SchedulePublishJob>();
public DbSet<ClassroomReservation> ClassroomReservations =>
Set<ClassroomReservation>();
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
Set<ExperimentGradeSheet>();
public DbSet<ExperimentGradeItem> ExperimentGradeItems =>
Set<ExperimentGradeItem>();
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
Set<ExperimentGradeRecord>();
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
Set<ExperimentCourseGrade>();
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
@@ -55,12 +73,35 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
Set<CourseGradeStatistic>();
public DbSet<TeachingTaskGradeStatistic> TeachingTaskGradeStatistics =>
Set<TeachingTaskGradeStatistic>();
public DbSet<TeachingTaskGradeScoreBand> TeachingTaskGradeScoreBands =>
Set<TeachingTaskGradeScoreBand>();
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
Set<CourseGradeStatisticsRefreshJob>();
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
Set<AttendanceCheckInAttempt>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
Set<ExamArrangementJob>();
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
Set<ExamSignInExportJob>();
public DbSet<ExamPublishJob> ExamPublishJobs =>
Set<ExamPublishJob>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>();
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
Set<ExamRoomInvigilator>();
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
@@ -101,6 +142,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
Set<BackgroundJobOutboxMessage>();
public DbSet<AppUpdateRelease> AppUpdateReleases =>
Set<AppUpdateRelease>();
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
Set<SystemFeatureSetting>();
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
Set<CourseGradeStatisticsRefreshSetting>();
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
@@ -137,6 +185,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Description).HasMaxLength(100);
});
builder.Entity<RefreshSession>(entity =>
{
entity.Property(x => x.TokenHash).HasMaxLength(64);
entity.Property(x => x.SecurityStamp).HasMaxLength(100);
entity.Property(x => x.ClientType)
.HasConversion<string>()
.HasMaxLength(20);
entity.HasIndex(x => x.TokenHash).IsUnique();
entity.HasIndex(x => new { x.UserId, x.ExpiresAt });
entity.HasOne(x => x.User)
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
ConfigureCatalog<Campus>(builder);
ConfigureCatalog<College>(builder);
ConfigureCatalog<Major>(builder);
@@ -215,8 +278,25 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.StudentNumber).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(50);
entity.Property(x => x.EnglishName).HasMaxLength(100);
entity.Property(x => x.IdCardNumber).HasMaxLength(30);
entity.Property(x => x.Nationality).HasMaxLength(50);
entity.Property(x => x.Ethnicity).HasMaxLength(50);
entity.Property(x => x.PoliticalStatus).HasMaxLength(50);
entity.Property(x => x.NativePlace).HasMaxLength(100);
entity.Property(x => x.HouseholdAddress).HasMaxLength(300);
entity.Property(x => x.CurrentAddress).HasMaxLength(300);
entity.Property(x => x.PostalCode).HasMaxLength(20);
entity.Property(x => x.Phone).HasMaxLength(30);
entity.Property(x => x.Email).HasMaxLength(100);
entity.Property(x => x.Qq).HasMaxLength(30);
entity.Property(x => x.WeChat).HasMaxLength(60);
entity.Property(x => x.EmergencyContactName).HasMaxLength(50);
entity.Property(x => x.EmergencyContactRelationship).HasMaxLength(30);
entity.Property(x => x.EmergencyContactPhone).HasMaxLength(30);
entity.Property(x => x.SpecialTags).HasMaxLength(300);
entity.Property(x => x.SpecialNeeds).HasMaxLength(1000);
entity.Property(x => x.Biography).HasMaxLength(1000);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => x.StudentNumber).IsUnique();
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
@@ -390,6 +470,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ScheduleEntry>(entity =>
{
entity.Property(x => x.Kind)
.HasDefaultValue(ScheduleEntryKind.Lecture)
.HasSentinel((ScheduleEntryKind)0);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
@@ -437,6 +520,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.WithMany()
.HasForeignKey(x => x.RequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredCampus)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredCampusId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredBuilding)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskAllowedClassroom>(entity =>
@@ -456,6 +547,57 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseGroup>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(100);
entity.Property(x => x.Description).HasMaxLength(500);
entity.HasIndex(x => x.Code).IsUnique();
});
builder.Entity<CourseGroupCourse>(entity =>
{
entity.HasIndex(x => new { x.CourseGroupId, x.CourseId }).IsUnique();
entity.HasOne(x => x.CourseGroup)
.WithMany(x => x.Courses)
.HasForeignKey(x => x.CourseGroupId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course)
.WithMany()
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<PublishedScheduleOccurrence>(entity =>
{
entity.HasIndex(x => new { x.AcademicTermId, x.TeachingTaskId, x.Week });
entity.HasIndex(x => new
{
x.AcademicTermId,
x.Week,
x.DayOfWeek,
x.StartPeriod,
x.ClassroomId
});
entity.HasIndex(x => new { x.SchedulePlanId, x.ClassroomId, x.Week, x.DayOfWeek, x.StartPeriod });
entity.HasIndex(x => new { x.ScheduleEntryId, x.Week }).IsUnique();
entity.HasOne(x => x.ScheduleEntry).WithMany().HasForeignKey(x => x.ScheduleEntryId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany().HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany().HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<TeachingTaskAllowedExperimentClassroom>(entity =>
{
entity.HasKey(x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
entity.HasOne(x => x.TeachingTaskScheduleConstraint)
.WithMany(x => x.AllowedExperimentClassrooms)
.HasForeignKey(x => x.TeachingTaskScheduleConstraintId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AutomaticScheduleJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
@@ -534,6 +676,161 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<ExperimentProject>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(40);
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.Property(x => x.Requirements).HasMaxLength(1000);
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
entity.HasIndex(x => new
{
x.TeachingTaskId,
x.Code,
x.ScheduleEntryId,
x.ScheduleWeek
})
.IsUnique();
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasIndex(x => x.ScheduleEntryId);
entity.HasOne(x => x.ScheduleEntry).WithMany()
.HasForeignKey(x => x.ScheduleEntryId)
.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<ExperimentCourseGrade>(entity =>
{
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
entity.Property(x => x.TotalWeight).HasPrecision(8, 1);
entity.HasIndex(x => new { x.TeachingTaskId, x.StudentId })
.IsUnique();
entity.HasIndex(x => x.StudentId);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity =>
{
entity.HasKey(x => new
{
x.ExperimentGradeRecordId,
x.ExperimentGradeItemId
});
entity.Property(x => x.Score).HasPrecision(5, 1);
entity.Property(x => x.Comment).HasMaxLength(300);
entity.HasOne(x => x.ExperimentGradeRecord)
.WithMany(x => x.ItemScores)
.HasForeignKey(x => x.ExperimentGradeRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentGradeItem)
.WithMany(x => x.Scores)
.HasForeignKey(x => x.ExperimentGradeItemId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseSelectionRound>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -613,6 +910,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.Property(x => x.SourceType)
.HasDefaultValue(GradeItemSourceType.Manual)
.HasSentinel((GradeItemSourceType)0);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items)
@@ -653,6 +953,72 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.HasIndex(x => new
{
x.CourseId, x.AcademicTermId, x.Scope, x.ScopeEntityId
}).IsUnique().HasDatabaseName("UX_CourseGradeStatistics_Scope");
entity.HasIndex(x => new { x.AcademicTermId, x.Scope, x.ScopeEntityId });
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.MedianScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.StandardDeviation).HasPrecision(6, 2);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.Property(x => x.ExcellentRate).HasPrecision(5, 2);
entity.HasIndex(x => x.GradeSheetId).IsUnique();
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
entity.HasIndex(x => new { x.CourseId, x.AcademicTermId });
entity.HasOne(x => x.GradeSheet).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeScoreBand>(entity =>
{
entity.Property(x => x.Label).HasMaxLength(30);
entity.Property(x => x.LowerBound).HasPrecision(5, 1);
entity.Property(x => x.UpperBound).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.TeachingTaskGradeStatisticId,
x.SortOrder
}).IsUnique();
entity.HasOne(x => x.TeachingTaskGradeStatistic)
.WithMany(x => x.ScoreBands)
.HasForeignKey(x => x.TeachingTaskGradeStatisticId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.GradeSheetId);
entity.HasOne<GradeSheet>().WithMany().HasForeignKey(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AttendanceSheet>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -687,6 +1053,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AttendanceCheckInAttempt>(entity =>
{
entity.Property(x => x.FailureCode).HasMaxLength(64);
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.Property(x => x.UserAgent).HasMaxLength(500);
entity.Property(x => x.RiskFlags).HasMaxLength(300);
entity.Property(x => x.Latitude).HasPrecision(10, 7);
entity.Property(x => x.Longitude).HasPrecision(10, 7);
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.CheckInAttempts)
.HasForeignKey(x => x.AttendanceSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -702,6 +1091,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId);
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany()
@@ -719,6 +1109,87 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomAssignment>(entity =>
{
entity.ToTable("ExamRooms");
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
.HasDatabaseName("IX_ExamRooms_Plan_Time");
entity.HasIndex(x => new
{
x.ExamPlanId,
x.ClassroomId,
x.StartsAt
})
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
entity.HasIndex(x => x.CourseId)
.HasDatabaseName("IX_ExamRooms_CourseId");
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course).WithMany()
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomSession>(entity =>
{
entity.ToTable("ExamRoomSessions");
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
entity.HasIndex(x => x.ExamSessionId)
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSeatAssignment>(entity =>
{
entity.ToTable("ExamSeats");
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
.IsUnique()
.HasDatabaseName("UX_ExamSeats_Session_Student");
entity.HasIndex(x => x.StudentId)
.HasDatabaseName("IX_ExamSeats_StudentId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomInvigilator>(entity =>
{
entity.ToTable("ExamRoomInvigilators");
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
entity.HasIndex(x => x.TeacherId)
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamArrangementJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
.IsUnique()
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
});
builder.Entity<ExamSignInExportJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.FileName).HasMaxLength(200);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
});
builder.Entity<MakeupExamPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -734,6 +1205,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId);
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany()
@@ -890,6 +1362,27 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.GradeRecord).WithMany()
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<OtherExamBatch>(entity =>
{
entity.Property(x => x.ExamCode).HasMaxLength(60);
entity.Property(x => x.Name).HasMaxLength(150);
entity.Property(x => x.Organizer).HasMaxLength(150);
entity.Property(x => x.LevelOptions).HasMaxLength(500);
entity.Property(x => x.MaxScore).HasPrecision(8, 2);
entity.HasIndex(x => new { x.Status, x.ExamDate });
});
builder.Entity<OtherExamResult>(entity =>
{
entity.Property(x => x.Score).HasPrecision(8, 2);
entity.Property(x => x.Level).HasMaxLength(50);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.OtherExamBatchId, x.StudentId, x.AttemptNumber }).IsUnique();
entity.HasIndex(x => new { x.StudentId, x.OtherExamBatchId });
entity.HasOne(x => x.OtherExamBatch).WithMany(x => x.Results)
.HasForeignKey(x => x.OtherExamBatchId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<WarningRule>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(100);
@@ -1020,6 +1513,54 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.LeaseExpiresAt);
});
builder.Entity<AppUpdateRelease>(entity =>
{
entity.Property(x => x.Platform)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Channel)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Version).HasMaxLength(40);
entity.Property(x => x.NativeVersion).HasMaxLength(40);
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
entity.Property(x => x.FileName).HasMaxLength(180);
entity.Property(x => x.Sha256).HasMaxLength(64);
entity.Property(x => x.BundleContent).HasColumnType("longblob");
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Version
}).IsUnique();
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Status
});
entity.HasIndex(x => x.CreatedAt);
});
builder.Entity<SystemFeatureSetting>(entity =>
{
entity.Property(x => x.Key).HasMaxLength(100);
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<CourseGradeStatisticsRefreshSetting>(entity =>
{
entity.Property(x => x.Key).HasMaxLength(50);
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<OfficialDocument>(entity =>
{
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
File diff suppressed because it is too large Load Diff
@@ -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");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class IntegratedExperimentScheduling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Kind",
table: "ScheduleEntries",
type: "int",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "ScheduleEntries");
}
}
}

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