61 Commits
  • 滑动续期与自动刷新:
    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)
    未分配考场或监考教师时,也能导出并显示“待分配”。
  • “教务总览”改成可实际工作的分级工作台:
    校级管理员查看全校数据,学院管理员仅查看本学院数据。
    待办按当前审批阶段统计,涵盖授课资格、成绩、调停课、学籍异动、教室借用等。
    增加学期进度、教学任务发布、课表覆盖、成绩发布状态。
    快捷入口根据管理员角色自动调整。
    完善未配置学期、无待办、加载失败等状态。
    适配桌面和 390px 移动端,无横向溢出。
  • “运维与审计控制台”。
    主要能力:
    SuperAdmin 专用入口:组织与权限 → 运维与审计。
    操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。
    汇总自动排课、课表发布、补考安排三类失败后台任务。
    实时检查数据库、缓存、任务通道及积压状态。
    聚合 5xx、失败/重试任务、健康探针和备份时效告警。
    SQLite 在线备份;MySQL 调用原生客户端备份。
    SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。
    MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
  • 发信改为事务内每 300 人分批写入,避免全校群发一次插入过大;数据库异常现在返回明确的 503,不再只显示模糊 500。
    校级、学院级管理员支持按学院、身份、行政班、教学班、姓名/学号/工号筛选,也可指定最多 500 名收件人。所有结果均由服务端重新校验权限。
    接入 CKEditor 5,支持标题、列表、引用和链接;正文扩展为 MySQL longtext。
    富文本在收件箱、详情弹窗和已发送记录中统一经 DOMPurify 净化后展示。
    页面改用系统现有的冷白、靛蓝、青绿色令牌,新增“选择收件人 → 编辑内容”工作台和移动端适配。
  • 点名、成绩册统一读取“行政班在籍学生 ∪ 已选课学生”,不再错误显示 0 人。
    教师选课名单改为展示全部本人教学班及完整学生名单,支持 Excel 导出。
    调停课改用教师专用教学班接口,并在服务端限制只能操作本人教学班。
  • 第二阶段优化已完成,重点是提高后台任务吞吐并降低 Outbox 数据库开销。
    RabbitMQ 默认从单一回调通道提升为三类任务独立并行,跨类型并发能力由 1 提升到 3;每类还能独立配置 1–16 个消费者。[RabbitMqBackgroundJobs.cs (line 143)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs:143)
    InMemory 开发模式同步改为按任务类型隔离队列,避免某类长任务堵塞其他任务。
    租约恢复检查从“每发布一条执行一次”改为默认每 60 秒维护一次。
    启动恢复改为数据库 NOT EXISTS 查询,不再把全部历史 Outbox 加载进内存。[BackgroundJobOutboxPublisher.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs:9)
    完成消息默认保留 14 天,之后按每批 500 条清理,并增加对应组合索引。
    /health/messaging 现在返回各状态积压量、过期租约和最老任务等待时间。[BackgroundJobMonitoringService.cs (line 16)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobMonitoringService.cs:16)
    新增 Jiaowu.BackgroundJobs 运行时指标,覆盖发布量、处理量、发布耗时、处理耗时和清理量。[BackgroundJobTelemetry.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobTelemetry.cs:9)
    配置、Compose 和调优建议已更新。[README.md (line 257)](E:/jiaowu/README.md:257)
  • 排课计算、课表发布、补考自动编排三类任务统一接入消息系统。
    业务任务与 Outbox 消息同一事务落库,避免“数据库成功但消息没发出去”。
    RabbitMQ 使用持久化消息、发布确认、手动 ACK、Quorum Queue、死信队列。
    增加处理租约、心跳、异常重试、最大重试次数和幂等状态控制。
    保留 InMemory 模式,开发环境无需安装 RabbitMQ。
    支持多实例竞争消费,后续可以横向扩容。
    新增 /health/messaging 消息系统健康检查。
  • 成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
    手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
    选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
    关闭选课界面会明确提示发送通知,并显示实际通知人数。
  • 学生可在“学籍管理 → 电子成绩单与证明”申请成绩单或学籍证明。
    服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。
    成绩单仅包含正式发布成绩;无已发布成绩时明确提示。
    同类型、同用途 5 分钟内重复申请复用已有有效凭证。
    申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。
    管理员的下载记录、失效、重签能力保持不变。
  • 已完善个人教学日历订阅。
    主要能力:
    教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。
    自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。
    采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。
    支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。
    订阅地址按实际前端 API 地址生成,兼容独立部署域名。
    已增加窄屏弹窗最大宽度和纵向操作布局。
  • 将索引缩短为 IX_CE_Offering_Status_WaitlistedAt。
    将候补迁移改为可重入:先检查列和索引是否存在,再执行 DDL。
    附件中已经成功添加 WaitlistedAt、但索引创建失败的数据库可直接重试,不会报重复列。
    运行时和 dotnet ef 连接统一开启 AllowUserVariables;Connector/NET 默认关闭该选项。Connector/NET 官方说明
    增加所有 MySQL 索引名不得超过 64 字符的回归测试。
  • 已把选课的“年级划分”和限制规则补成完整闭环:
    选课批次可指定多个适用年级;留空兼容原有“全部年级”。
    新增可选的“最多课程门数”,与原有学分上限并行。
    年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。
    普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。
    管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。
    新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
  • 已完成“选课候补与自动递补”完整流程。
    学生端:满员后可加入/取消候补,展示候补顺位和当前候补人数。
    自动递补:退课释放名额后,按候补时间顺序重新校验学籍、范围、学分及时间冲突;不合格者自动失效并继续下一位。
    管理端:教学班显示候补人数,名单抽屉提供候补队列及移出操作。
    状态通知:递补成功、资格失效、批次关闭、管理员移出均会通知学生。
    并发安全:退课和递补放在 Serializable 事务中执行。
    数据库:已补充 SQLite 开发迁移和 MySQL 正式迁移。
  • 仪表盘和 6 类统计摘要接入 HybridCache(内存 + Redis)。
    默认 Redis 缓存 3 分钟、本地缓存 30 秒,可通过环境变量调整。
    缓存键包含数据权限范围、有效学院和全部筛选条件,避免跨学院串数据。
    Excel 导出保持实时查询,不使用摘要缓存。
    无需数据库迁移。
  • 主要变更:
    新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12)
    接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153)
    缓存学生激活选项、基础数据、公开课表和课表选项。
    个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。
    基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。
    新增 /health/cache,Redis 故障不影响 /health/ready。
    Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。
    更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
288 changed files with 166248 additions and 2299 deletions
+7
View File
@@ -8,9 +8,16 @@ MYSQL_USER=jiaowu
MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD=
RABBITMQ_USER=jiaowu
RABBITMQ_PASSWORD=
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY=1
BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY=1
JWT_KEY=
ALLOWED_HOSTS=localhost
CORS_ORIGIN=http://localhost:8080
OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL=http://localhost:8080
# 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。
SEED_ADMIN_USERNAME=demo-admin
+88 -1
View File
@@ -6,14 +6,101 @@ Database__Provider=MySql
Database__ApplyMigrationsOnStartup=false
Database__CommandTimeoutSeconds=30
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
# 仅供备份与隔离恢复演练使用。该账号需要读取业务库,并仅能创建/删除
# jiaowu_restore_drill_* 临时库;不要在此复用日常业务账号。
# ConnectionStrings__OperationsMySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=OPS_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
# Redis 是可选加速器;留空时应用仅使用进程内缓存。
# ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false"
# 单机可继续使用 InMemory;多实例生产部署建议启用 RabbitMQ。
BackgroundJobs__Transport=InMemory
BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
# RabbitMq__HostName=rabbitmq.example.edu.cn
# RabbitMq__Port=5671
# RabbitMq__UserName=jiaowu
# RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD
# RabbitMq__VirtualHost=/jiaowu
# RabbitMq__UseTls=true
# RabbitMq__TlsServerName=rabbitmq.example.edu.cn
Cache__Enabled=true
Cache__KeyPrefix=jiaowu:v1
Cache__ReferenceExpirationMinutes=30
Cache__ReferenceLocalExpirationSeconds=120
Cache__TimetableExpirationMinutes=10
Cache__TimetableLocalExpirationSeconds=30
Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048
# 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
Operations__ToolTimeoutMinutes=30
Operations__MySqlDumpPath=mysqldump
Operations__MySqlClientPath=mysql
# 按实际客户端补充 TLS 参数;Oracle MySQL 客户端示例:
# Operations__MySqlAdditionalArguments__0=--ssl-mode=VERIFY_IDENTITY
# Operations__MySqlAdditionalArguments__1=--ssl-ca=/etc/jiaowu/mysql-ca.pem
Jwt__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web
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=明序大学
OfficialDocuments__IssuingOffice=教务处
OfficialDocuments__DocumentNumberPrefix=MXU
OfficialDocuments__PublicBaseUrl=https://jiaowu.example.edu.cn
# 默认使用系统中文字体;仅在自定义字体部署时填写绝对路径。
# OfficialDocuments__FontPath=/usr/share/fonts/noto/NotoSansCJK-Regular.ttc
# 仅首次创建管理员时临时取消注释,创建成功后立即删除。
# SeedAdmin__UserName=admin
+9 -1
View File
@@ -30,10 +30,14 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_EnableDiagnostics=0
DOTNET_EnableDiagnostics=0 \
Operations__BackupDirectory=/var/lib/jiaowu/backups \
Operations__MySqlDumpPath=mariadb-dump \
Operations__MySqlClientPath=mariadb
EXPOSE 8080
COPY --from=build /app/publish/ ./
ARG UID=10001
RUN apk add --no-cache font-noto-cjk mariadb-client
RUN adduser \
--disabled-password \
--gecos "" \
@@ -42,5 +46,9 @@ RUN adduser \
--no-create-home \
--uid "${UID}" \
appuser
RUN mkdir -p /var/lib/jiaowu/backups \
&& chown appuser:appuser /var/lib/jiaowu/backups \
&& chmod 700 /var/lib/jiaowu/backups
VOLUME ["/var/lib/jiaowu/backups"]
USER appuser
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
+260 -1
View File
@@ -2,7 +2,9 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定
官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
@@ -45,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。构建发布包与数据库配置相互独立:
@@ -80,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),适用于
@@ -99,6 +207,12 @@ sudo chown -R root:jiaowu /opt/jiaowu
sudo chmod 0750 /opt/jiaowu
sudo chmod 0750 /opt/jiaowu/Jiaowu.Api
sudo chmod 0640 /opt/jiaowu/.env
sudo install -d \
--owner=jiaowu \
--group=jiaowu \
--mode=0700 \
/var/lib/jiaowu/backups
sudo apt-get install default-mysql-client
```
如果账号已存在,`useradd` 会报错,可以跳过该命令。RHEL 系发行版的 `nologin` 通常
@@ -219,6 +333,151 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
- `/health/live`:只检查进程存活。
- `/health``/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
- `/health/cache`:检查可选 Redis;未配置 Redis 时返回 `disabled`Redis
故障不会影响数据库就绪探针。
- `/health/messaging`:检查后台任务传输;单机内存队列返回 `memory`,启用
RabbitMQ 时实际检查代理连接,同时返回各 Outbox 状态的积压数、过期租约数和
最老未完成任务的等待时间。
### 查询缓存与 Redis
应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为
学生激活/基础数据选项、匿名可访问的已发布课表、仪表盘以及统计分析摘要。统计缓存键
包含有效数据范围、学院和规范化筛选条件,避免跨学院复用;统计 Excel 导出仍实时查询。
选课容量、成绩写入、考勤、审批、通知未读数、权限和后台任务状态仍直接以 MySQL 为准。
不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装
Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如:
```text
ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false
```
仪表盘和统计摘要默认在 Redis 中缓存 3 分钟、进程内缓存 30 秒,可分别通过
`Cache__AnalyticsExpirationMinutes``Cache__AnalyticsLocalExpirationSeconds`
调整。该类汇总采用短 TTL 控制数据新鲜度,不要求每个业务写入点同步清理缓存。
Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和
`/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境,
同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用
独立实例、私有网络、ACL 和 TLS。
`compose.example.yml` 包含不暴露宿主机端口的 Redis 服务,限制为 256 MB 并使用
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
### 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 保存任务消息。创建业务任务与
Outbox 消息在同一次 MySQL 提交中完成,后台发布器再将消息投递给任务 Worker;重复
投递通过 Outbox 处理租约和唯一任务键抑制。任务状态表仍是前端查询进度与错误信息的
唯一来源。
开发和单机部署默认使用有界进程内队列,不需要 RabbitMQ:
```text
BackgroundJobs__Transport=InMemory
```
多实例生产部署应切换为 RabbitMQ,并配置独立账号、虚拟主机和 TLS:
```text
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
RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD
RabbitMq__VirtualHost=/jiaowu
RabbitMq__UseTls=true
RabbitMq__TlsServerName=rabbitmq.example.edu.cn
```
RabbitMQ 传输使用持久消息、发布确认、手动消费确认、每种任务独立队列和死信队列。
默认创建 Quorum Queue,重任务的消费者预取数为 1。三类任务各自至少有一个消费者,
因此不同类型的任务不会再互相阻塞;单类任务的并发度可独立设置为 1-16。自动排课
通常最消耗 CPU,建议先保持为 1,再根据 CPU、数据库连接池和任务等待时间逐级调到
2 或 3;不要只提高预取数。
Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消息都额外扫描数据库;已完成
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`
`MaintenanceIntervalSeconds``CleanupBatchSize` 调整。应用暴露
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
可接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
### 运维与审计控制台
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
前端菜单隐藏。
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump`
`mysql`(容器镜像已包含对应的 `mariadb-dump``mariadb` 客户端),并配置独立的
`ConnectionStrings__OperationsMySql`。该账号不得复用日常业务账号:它需要读取业务库,
并只应被授权创建和删除名称为 `jiaowu_restore_drill_*` 的临时演练库。恢复演练不会覆盖
当前业务库,流程是“校验 SHA-256 → 恢复到随机临时库 → 检查表结构 → 删除临时库”。
备份目录必须是仅服务账号可写的持久化目录。示例配置使用
`/var/lib/jiaowu/backups`;Compose 已挂载独立命名卷。启用 MySQL TLS 时,还要通过
`Operations__MySqlAdditionalArguments__N` 传入与所选命令行客户端匹配的 CA 与主机名
校验参数。例如 Oracle MySQL 客户端使用 `--ssl-mode=VERIFY_IDENTITY`
`--ssl-ca=/etc/jiaowu/mysql-ca.pem`,容器内 MariaDB 客户端使用 `--ssl`
`--ssl-ca=...``--ssl-verify-server-cert`
## 跨平台发布与 Docker
+6 -2
View File
@@ -17,11 +17,15 @@ services:
options:
max-size: "10m"
max-file: "3"
volumes:
- jiaowu-backups:/var/lib/jiaowu/backups
# 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到
# ./certs/mysql-ca.pem,并取消下面三行注释
# volumes:
# ./certs/mysql-ca.pem,并在上面的 volumes 中追加以下四行
# - type: bind
# source: ./certs/mysql-ca.pem
# target: /etc/jiaowu/mysql-ca.pem
# read_only: true
volumes:
jiaowu-backups:
+69 -1
View File
@@ -13,12 +13,29 @@ x-jiaowu-environment: &jiaowu-environment
Database__ApplyMigrationsOnStartup: "false"
Database__CommandTimeoutSeconds: "30"
ConnectionStrings__MySql: "Server=mysql;Port=3306;Database=${MYSQL_DATABASE:-jiaowu_demo};User=${MYSQL_USER:-jiaowu};Password=${MYSQL_PASSWORD:?请在 .env.docker 中设置 MYSQL_PASSWORD};SslMode=Disabled;"
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
Cache__Enabled: "true"
Cache__KeyPrefix: "jiaowu:v1"
BackgroundJobs__Transport: RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672"
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
RabbitMq__Password: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}"
RabbitMq__VirtualHost: "/"
RabbitMq__UseTls: "false"
Jwt__Issuer: Jiaowu.Api
Jwt__Audience: Jiaowu.Web
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
Jwt__ExpireMinutes: "60"
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}"
SeedAdmin__UserName: "${SEED_ADMIN_USERNAME:-}"
SeedAdmin__Password: "${SEED_ADMIN_PASSWORD:-}"
SeedAdmin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-}"
@@ -30,6 +47,49 @@ x-json-logging: &json-logging
max-file: "3"
services:
rabbitmq:
image: rabbitmq:4.2-management-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER:-jiaowu}"
RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}"
healthcheck:
test:
- CMD
- rabbitmq-diagnostics
- -q
- ping
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
- rabbitmq-data:/var/lib/rabbitmq
logging: *json-logging
redis:
image: redis:8.8-alpine
restart: unless-stopped
command:
- redis-server
- --save
- ""
- --appendonly
- "no"
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lfu
healthcheck:
test:
- CMD
- redis-cli
- ping
interval: 5s
timeout: 3s
retries: 10
logging: *json-logging
mysql:
image: mysql:8.4
restart: unless-stopped
@@ -69,6 +129,10 @@ services:
<<: *jiaowu-image
environment: *jiaowu-environment
depends_on:
rabbitmq:
condition: service_healthy
redis:
condition: service_started
mysql:
condition: service_healthy
migrate:
@@ -77,6 +141,8 @@ services:
- "${JIAOWU_PORT:-8080}:8080"
restart: unless-stopped
init: true
volumes:
- backup-data:/var/lib/jiaowu/backups
logging: *json-logging
# 工具型一次性服务:普通 docker compose up 不会执行它。
@@ -99,3 +165,5 @@ services:
volumes:
mysql-data:
rabbitmq-data:
backup-data:
@@ -0,0 +1,621 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.Student)]
[Route("api/student/academic-planning")]
public sealed class AcademicPlanningController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet]
public async Task<ActionResult> Get(CancellationToken cancellationToken)
{
var loaded = await LoadAsync(cancellationToken);
if (loaded.Error is not null) return loaded.Error;
var context = loaded.Context!;
var completion = CurriculumCompletionRules.Evaluate(
context.Plan.Modules,
context.PassedCourseIds);
var planCompletedCredits = context.Courses
.Where(x => context.PassedCourseIds.Contains(x.CourseId))
.Sum(x => x.Credits);
var suggestionIds = AcademicPlanningRules.SuggestNextSemester(
context.CourseSnapshots,
context.PassedCourseIds,
context.InProgressCourseIds,
context.NextSemester);
var suggestionIdSet = suggestionIds.ToHashSet();
var latestAudit = await db.GraduationAuditResults.AsNoTracking()
.Where(x =>
x.StudentId == context.Student.Id &&
x.GraduationAuditBatch!.Status ==
GraduationAuditBatchStatus.Published)
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new
{
BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear,
x.RequiredCredits,
x.EarnedCredits,
x.RequiredCourseCount,
x.PassedRequiredCourseCount,
x.FailedCourseCount,
x.MissingCourseNames,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.GraduationAuditBatch.PublishedAt
})
.FirstOrDefaultAsync(cancellationToken);
var endSemester = Math.Max(
context.Plan.Major!.SchoolingYears * 2,
context.NextSemester + 3);
return Ok(new
{
Student = new
{
context.Student.Id,
context.Student.StudentNumber,
context.Student.Name,
context.Student.EnrollmentYear,
context.Student.Status,
ClassName = context.Student.AdministrativeClass!.Name,
MajorName = context.Student.AdministrativeClass.Major!.Name,
CollegeName =
context.Student.AdministrativeClass.Major.College!.Name
},
Plan = new
{
context.Plan.Id,
context.Plan.Name,
context.Plan.Version,
context.Plan.TotalCredits,
SchoolingYears = context.Plan.Major.SchoolingYears,
CurrentSemester = context.CurrentSemester,
NextSemester = context.NextSemester,
StandardGraduationSemester =
context.Plan.Major.SchoolingYears * 2
},
Baseline = new
{
EarnedCredits = context.EarnedCredits,
PlanCompletedCredits = planCompletedCredits,
CreditGap = Math.Max(
context.Plan.TotalCredits - context.EarnedCredits,
0),
CompletionRate = context.Plan.TotalCredits <= 0
? 0
: Math.Min(100, Math.Round(
planCompletedCredits / context.Plan.TotalCredits * 100,
1)),
completion.RequirementCount,
completion.PassedRequirementCount,
completion.MissingRequirements,
InProgressCredits = context.Courses
.Where(x => context.InProgressCourseIds.Contains(x.CourseId))
.Sum(x => x.Credits)
},
Terms = Enumerable.Range(
context.NextSemester,
endSemester - context.NextSemester + 1)
.Select(semester => new
{
Semester = semester,
Label = FormatSemester(
context.Student.EnrollmentYear,
semester),
IsBeyondStandard =
semester > context.Plan.Major.SchoolingYears * 2
}),
Courses = context.Courses
.OrderBy(x => x.RecommendedSemester)
.ThenBy(x => x.CourseCode)
.Select(course => new
{
course.CourseId,
course.CourseCode,
course.CourseName,
course.Credits,
course.RecommendedSemester,
course.Type,
course.ModuleCode,
course.ModuleName,
Status = context.StatusByCourse[course.CourseId],
IsSuggested = suggestionIdSet.Contains(course.CourseId),
Prerequisites = course.Prerequisites.Select(item => new
{
item.CourseId,
item.CourseCode,
item.CourseName,
IsCompleted =
context.PassedCourseIds.Contains(item.CourseId),
IsInProgress =
context.InProgressCourseIds.Contains(item.CourseId)
})
}),
NextSemesterSuggestion = suggestionIds.Select(courseId =>
{
var course = context.CourseById[courseId];
return new
{
course.CourseId,
course.CourseCode,
course.CourseName,
course.Credits,
course.Type,
Reason = course.Type == CurriculumCourseType.Required &&
course.RecommendedSemester <= context.NextSemester
? "计划学期已到,且先修条件已满足"
: course.Type == CurriculumCourseType.Required
? "必修课程,按培养方案顺序推进"
: "用于补足培养模块与总学分"
};
}),
LatestGraduationAudit = latestAudit,
Assumptions = new[]
{
"模拟课程按顺利通过计算,不会写入成绩或正式选课。",
$"预计毕业学期按每学期最多 {AcademicPlanningRules.RecommendedSemesterCreditLimit:0} 学分估算。",
"当前在读课程按本学期顺利完成计入预测。"
}
});
}
[HttpPost("simulate")]
public async Task<ActionResult> Simulate(
AcademicPlanningRequest request,
CancellationToken cancellationToken)
{
var loaded = await LoadAsync(cancellationToken);
if (loaded.Error is not null) return loaded.Error;
var context = loaded.Context!;
var selections = request.Terms
.SelectMany(term => term.CourseIds.Select(courseId => new
{
term.Semester,
CourseId = courseId
}))
.ToList();
var duplicate = selections
.GroupBy(x => x.CourseId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null)
return ValidationProblem("同一门课程不能安排在多个学期。");
if (request.Terms.Any(x =>
x.Semester < context.NextSemester ||
x.Semester > context.NextSemester + 12))
return ValidationProblem("模拟学期超出了可规划范围。");
var invalidCourse = selections.FirstOrDefault(x =>
!context.CourseById.ContainsKey(x.CourseId));
if (invalidCourse is not null)
return ValidationProblem("模拟计划包含不属于当前培养方案的课程。");
var alreadyHandled = selections.FirstOrDefault(x =>
context.PassedCourseIds.Contains(x.CourseId) ||
context.InProgressCourseIds.Contains(x.CourseId));
if (alreadyHandled is not null)
return ValidationProblem("已完成或当前在读课程无需重复安排。");
var plannedSemesters = selections.ToDictionary(
x => x.CourseId,
x => x.Semester);
var projectedCourseIds = context.PassedCourseIds
.Concat(context.InProgressCourseIds)
.Concat(plannedSemesters.Keys)
.ToHashSet();
var completion = CurriculumCompletionRules.Evaluate(
context.Plan.Modules,
projectedCourseIds);
var addedCredits = context.Courses
.Where(x =>
!context.PassedCourseIds.Contains(x.CourseId) &&
(context.InProgressCourseIds.Contains(x.CourseId) ||
plannedSemesters.ContainsKey(x.CourseId)))
.Sum(x => x.Credits);
var projectedEarnedCredits = context.EarnedCredits + addedCredits;
var creditGap = Math.Max(
context.Plan.TotalCredits - projectedEarnedCredits,
0);
var prerequisiteIssues = AcademicPlanningRules.FindPrerequisiteIssues(
context.CourseSnapshots,
context.PassedCourseIds,
context.InProgressCourseIds,
plannedSemesters);
var conflicts = prerequisiteIssues.Select(issue =>
{
var course = context.CourseById[issue.CourseId];
var prerequisite = context.AllCourseLabels.GetValueOrDefault(
issue.PrerequisiteCourseId,
new CourseLabel(
issue.PrerequisiteCourseId,
"未知课程",
"未找到的先修课程"));
return new
{
Type = "Prerequisite",
issue.CourseId,
course.CourseName,
PrerequisiteCourseId = prerequisite.CourseId,
PrerequisiteCourseName = prerequisite.CourseName,
issue.PlannedSemester,
issue.PrerequisitePlannedSemester,
Message = issue.PrerequisitePlannedSemester.HasValue
? $"《{prerequisite.CourseName}》必须安排在《{course.CourseName}》之前。"
: $"《{course.CourseName}》的先修课程《{prerequisite.CourseName}》尚未完成或安排。"
};
}).ToList();
var workloadWarnings = request.Terms
.Select(term => new
{
term.Semester,
Credits = term.CourseIds.Sum(courseId =>
context.CourseById.GetValueOrDefault(courseId)?.Credits ?? 0)
})
.Where(x =>
x.Credits > AcademicPlanningRules.HeavySemesterCreditLimit)
.Select(x => new
{
Type = "Workload",
x.Semester,
x.Credits,
Message =
$"第 {x.Semester} 学期安排了 {x.Credits:0.#} 学分,超过建议上限 {AcademicPlanningRules.HeavySemesterCreditLimit:0.#} 学分。"
})
.ToList();
var timingWarnings = selections
.Select(item => new
{
item.Semester,
Course = context.CourseById[item.CourseId]
})
.Where(x => x.Semester < x.Course.RecommendedSemester)
.Select(x => new
{
Type = "EarlyCourse",
x.Course.CourseId,
x.Course.CourseName,
x.Semester,
x.Course.RecommendedSemester,
Message =
$"《{x.Course.CourseName}》早于培养方案建议学期修读,请确认课程开设条件。"
})
.ToList();
var unresolvedFailedCourseCount = context.FailedCourseIds.Count(
courseId => !projectedCourseIds.Contains(courseId));
var conclusion = GraduationAuditRules.Evaluate(
true,
context.Student.Status,
context.Plan.TotalCredits,
projectedEarnedCredits,
completion.RequirementCount,
completion.PassedRequirementCount,
unresolvedFailedCourseCount);
var latestPlannedSemester = plannedSemesters.Count == 0
? context.NextSemester - 1
: plannedSemesters.Values.Max();
var estimatedSemester =
AcademicPlanningRules.EstimateCompletionSemester(
context.NextSemester,
latestPlannedSemester,
creditGap,
completion.RequirementCount -
completion.PassedRequirementCount);
var remainingRequiredSemesterFloor = context.Courses
.Where(course =>
course.Type == CurriculumCourseType.Required &&
!projectedCourseIds.Contains(course.CourseId))
.Select(course => course.RecommendedSemester)
.DefaultIfEmpty(estimatedSemester)
.Max();
estimatedSemester = Math.Max(
estimatedSemester,
remainingRequiredSemesterFloor);
var planCompletedCredits = context.Courses
.Where(x => projectedCourseIds.Contains(x.CourseId))
.Sum(x => x.Credits);
return Ok(new
{
Projected = new
{
EarnedCredits = projectedEarnedCredits,
PlanCompletedCredits = planCompletedCredits,
CreditGap = creditGap,
CompletionRate = context.Plan.TotalCredits <= 0
? 0
: Math.Min(100, Math.Round(
planCompletedCredits / context.Plan.TotalCredits * 100,
1)),
completion.RequirementCount,
completion.PassedRequirementCount,
completion.MissingRequirements,
UnresolvedFailedCourseCount = unresolvedFailedCourseCount,
GraduationConclusion = conclusion,
EstimatedGraduationSemester = estimatedSemester,
EstimatedGraduationTerm = FormatSemester(
context.Student.EnrollmentYear,
estimatedSemester),
IsBeyondStandard =
estimatedSemester >
context.Plan.Major!.SchoolingYears * 2
},
Conflicts = conflicts,
Warnings = workloadWarnings.Cast<object>()
.Concat(timingWarnings)
.ToList(),
TermSummaries = request.Terms
.OrderBy(x => x.Semester)
.Select(term => new
{
term.Semester,
Label = FormatSemester(
context.Student.EnrollmentYear,
term.Semester),
CourseCount = term.CourseIds.Count,
Credits = term.CourseIds.Sum(courseId =>
context.CourseById.GetValueOrDefault(courseId)?.Credits ??
0)
})
});
}
private async Task<LoadResult> LoadAsync(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
if (student is null)
return new LoadResult(
null,
ConflictProblem("当前账号尚未关联学生档案。"));
var plan = await db.CurriculumPlans.AsNoTracking()
.AsSplitQuery()
.Include(x => x.Major)
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.ThenInclude(x => x!.Prerequisites)
.ThenInclude(x => x.PrerequisiteCourse)
.Where(x =>
x.MajorId == student.AdministrativeClass!.MajorId &&
x.EffectiveGrade == student.EnrollmentYear &&
x.Status == CurriculumPlanStatus.Published)
.OrderByDescending(x => x.PublishedAt)
.FirstOrDefaultAsync(cancellationToken);
if (plan is null)
return new LoadResult(
null,
ConflictProblem(
$"{student.EnrollmentYear} 级{student.AdministrativeClass!.Major!.Name}尚未发布培养方案。"));
var courses = plan.Modules
.SelectMany(module => module.Courses.Select(item =>
new PlanningCourse(
item.CourseId,
item.Course!.Code,
item.Course.Name,
item.Course.Credits,
item.RecommendedSemester,
item.Type,
module.Code,
module.Name,
item.Course.Prerequisites
.Select(prerequisite => new CourseLabel(
prerequisite.PrerequisiteCourseId,
prerequisite.PrerequisiteCourse!.Code,
prerequisite.PrerequisiteCourse.Name))
.OrderBy(x => x.CourseCode)
.ToList())))
.GroupBy(x => x.CourseId)
.Select(group => group.First())
.ToList();
var planCourseIds = courses.Select(x => x.CourseId).ToArray();
var gradeAttempts = await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new GradeAttempt(
x.GradeSheet!.TeachingTask!.CourseId,
x.GradeSheet.TeachingTask.Course!.Credits,
x.TotalScore,
x.ExamStatus))
.ToListAsync(cancellationToken);
var passedCourseIds = gradeAttempts
.Where(x => StudentCourseProgressRules.IsPassed(
new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus)))
.Select(x => x.CourseId)
.Distinct()
.ToHashSet();
var failedCourseIds = gradeAttempts
.GroupBy(x => x.CourseId)
.Where(group => !group.Any(x =>
StudentCourseProgressRules.IsPassed(
new StudentCourseAttemptSnapshot(
x.TotalScore,
x.ExamStatus))))
.Select(group => group.Key)
.ToHashSet();
var earnedCredits = gradeAttempts
.Where(x => passedCourseIds.Contains(x.CourseId))
.GroupBy(x => x.CourseId)
.Sum(group => group.Max(x => x.Credits));
var inProgressCourseIds = await db.TeachingTasks.AsNoTracking()
.Where(task =>
task.Status == TeachingTaskStatus.Published &&
task.AcademicTerm!.IsCurrent &&
(task.Classes.Any(item =>
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
task.Id)))
.Select(x => x.CourseId)
.Distinct()
.ToListAsync(cancellationToken);
var inProgressSet = inProgressCourseIds
.Where(planCourseIds.Contains)
.Where(courseId => !passedCourseIds.Contains(courseId))
.ToHashSet();
var attemptsByCourse = gradeAttempts
.Where(x => planCourseIds.Contains(x.CourseId))
.GroupBy(x => x.CourseId)
.ToDictionary(x => x.Key, x => x.ToList());
var statuses = courses.ToDictionary(
x => x.CourseId,
x => StudentCourseProgressRules.Evaluate(
attemptsByCourse.GetValueOrDefault(x.CourseId, [])
.Select(attempt => new StudentCourseAttemptSnapshot(
attempt.TotalScore,
attempt.ExamStatus)),
inProgressSet.Contains(x.CourseId)).Status);
var currentTerm = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsCurrent)
.OrderByDescending(x => x.StartDate)
.FirstOrDefaultAsync(cancellationToken);
var currentSemester = CalculateCurrentSemester(
student.EnrollmentYear,
currentTerm);
var snapshots = courses.Select(x =>
new AcademicPlanningCourseSnapshot(
x.CourseId,
x.CourseName,
x.Credits,
x.RecommendedSemester,
x.Type,
x.Prerequisites.Select(p => p.CourseId).ToArray()))
.ToList();
var allLabels = courses
.Select(x => new CourseLabel(
x.CourseId,
x.CourseCode,
x.CourseName))
.Concat(courses.SelectMany(x => x.Prerequisites))
.GroupBy(x => x.CourseId)
.ToDictionary(x => x.Key, x => x.First());
return new LoadResult(
new PlanningContext(
student,
plan,
courses,
courses.ToDictionary(x => x.CourseId),
snapshots,
allLabels,
passedCourseIds,
inProgressSet,
failedCourseIds,
statuses,
earnedCredits,
currentSemester,
currentSemester + 1),
null);
}
private static int CalculateCurrentSemester(
int enrollmentYear,
AcademicTerm? currentTerm)
{
if (currentTerm is not null)
{
return Math.Max(
1,
currentTerm.Season == TermSeason.Autumn
? (currentTerm.StartDate.Year - enrollmentYear) * 2 + 1
: (currentTerm.StartDate.Year - enrollmentYear - 1) * 2 + 2);
}
var today = DateOnly.FromDateTime(DateTime.Today);
return Math.Max(
1,
today.Month >= 8
? (today.Year - enrollmentYear) * 2 + 1
: (today.Year - enrollmentYear - 1) * 2 + 2);
}
private static string FormatSemester(int enrollmentYear, int semester)
{
var startYear = enrollmentYear + (semester - 1) / 2;
var season = semester % 2 == 1 ? "秋季学期" : "春季学期";
return $"{startYear}—{startYear + 1} 学年{season}";
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法进行学业规划",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private sealed record GradeAttempt(
Guid CourseId,
decimal Credits,
decimal? TotalScore,
GradeExamStatus ExamStatus);
private sealed record CourseLabel(
Guid CourseId,
string CourseCode,
string CourseName);
private sealed record PlanningCourse(
Guid CourseId,
string CourseCode,
string CourseName,
decimal Credits,
int RecommendedSemester,
CurriculumCourseType Type,
string ModuleCode,
string ModuleName,
IReadOnlyList<CourseLabel> Prerequisites);
private sealed record PlanningContext(
Student Student,
CurriculumPlan Plan,
IReadOnlyList<PlanningCourse> Courses,
IReadOnlyDictionary<Guid, PlanningCourse> CourseById,
IReadOnlyList<AcademicPlanningCourseSnapshot> CourseSnapshots,
IReadOnlyDictionary<Guid, CourseLabel> AllCourseLabels,
IReadOnlySet<Guid> PassedCourseIds,
IReadOnlySet<Guid> InProgressCourseIds,
IReadOnlySet<Guid> FailedCourseIds,
IReadOnlyDictionary<Guid, StudentCourseProgressStatus> StatusByCourse,
decimal EarnedCredits,
int CurrentSemester,
int NextSemester);
private sealed record LoadResult(
PlanningContext? Context,
ActionResult? Error);
}
public sealed record AcademicPlanningRequest(
IReadOnlyCollection<AcademicPlanningTermRequest> Terms);
public sealed record AcademicPlanningTermRequest(
[Range(1, 30)] int Semester,
IReadOnlyCollection<Guid> CourseIds);
@@ -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);
@@ -206,7 +206,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
.FirstAsync(ct);
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct);
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}",
cancellationToken: ct,
category: NotificationCategory.Grade);
return Created("", new { gm.Id });
}
@@ -219,7 +221,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.AcademicAdmin,
"成绩修改待校级审核",
$"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》",
linkUrl: "/approvals",
cancellationToken: ct,
category: NotificationCategory.Grade);
return NoContent();
}
@@ -227,7 +236,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
{
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
var gm = await db.GradeModifications
.Include(x => x.GradeRecord)
.ThenInclude(x => x!.Student)
.Include(x => x.GradeRecord)
.ThenInclude(x => x!.GradeSheet)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (gm is null) return NotFound();
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
@@ -235,7 +251,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
gm.GradeRecord!.TotalScore = gm.RequestedScore;
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
await NotificationService.SendAsync(
db,
gm.ApplicantUserId,
"成绩修改已通过",
"您的成绩修改申请已通过三级审批并生效。",
"/approvals",
ct,
NotificationCategory.Grade);
var studentUserId = gm.GradeRecord.Student?.UserId;
if (studentUserId.HasValue &&
studentUserId.Value != gm.ApplicantUserId)
{
await NotificationService.SendAsync(
db,
studentUserId.Value,
"成绩已更新",
$"《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》成绩已由 {gm.CurrentScore} 调整为 {gm.RequestedScore}。",
"/grades",
ct,
NotificationCategory.Grade);
}
return NoContent();
}
@@ -249,7 +285,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", cancellationToken: ct);
await NotificationService.SendAsync(
db,
gm.ApplicantUserId,
"成绩修改已驳回",
gm.ReviewComment ?? "审核未通过。",
"/approvals",
ct,
NotificationCategory.Grade);
return NoContent();
}
@@ -513,10 +556,10 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
ct);
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, cancellationToken: ct); }
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct); }
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, "/approvals", ct, NotificationCategory.Approval); }
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct, category: NotificationCategory.Grade); }
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
@@ -1,12 +1,14 @@
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;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -48,9 +50,15 @@ public sealed class AttendanceController(
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course!.Name,
StudentCount = db.CourseEnrollments.Count(enrollment =>
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id),
StudentCount = db.Students.Count(student =>
(student.Status == StudentStatus.Active &&
x.Classes.Any(assignment =>
assignment.AdministrativeClassId ==
student.AdministrativeClassId)) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id)),
SheetCount = db.AttendanceSheets.Count(sheet =>
sheet.TeachingTaskId == x.Id)
})
@@ -104,15 +112,13 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound();
var studentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId)
.Select(x => x.StudentId)
.Distinct()
var studentIds = await TeachingTaskRosterQuery
.ForTask(db, request.TeachingTaskId)
.AsNoTracking()
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (studentIds.Count == 0)
return ConflictProblem("该教学班没有有效选课学生。");
return ConflictProblem("该教学班没有有效学生。");
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
if (!Enum.IsDefined(checkInMethod))
@@ -175,7 +181,6 @@ public sealed class AttendanceController(
{
sheet.Id,
sheet.CheckInMethod,
sheet.CheckInToken,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt
});
@@ -195,7 +200,6 @@ public sealed class AttendanceController(
x.AttendanceDate,
x.Status,
x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt,
x.CheckInEndsAt,
x.TargetLatitude,
@@ -242,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
@@ -252,7 +353,6 @@ public sealed class AttendanceController(
sheet.AttendanceDate,
sheet.Status,
sheet.CheckInMethod,
CheckInToken = canManage ? sheet.CheckInToken : null,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
sheet.TargetLatitude,
@@ -271,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
});
@@ -498,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")]
@@ -511,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,
@@ -525,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,
@@ -534,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,
@@ -605,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)
{
@@ -622,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,
@@ -631,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,
@@ -670,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);
}
}
@@ -688,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
@@ -793,7 +994,8 @@ public sealed class AttendanceController(
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"考勤申诉待处理",
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
"/teacher-attendance", cancellationToken);
"/teacher-attendance", cancellationToken,
NotificationCategory.Attendance);
}
return NoContent();
@@ -905,7 +1107,7 @@ public sealed class AttendanceController(
request.Comment is not null
? $"您的考勤申诉{result}。意见:{request.Comment}"
: $"您的考勤申诉{result}。",
null, cancellationToken);
null, cancellationToken, NotificationCategory.Attendance);
}
return NoContent();
@@ -980,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,
@@ -1031,16 +1330,14 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken);
if (course is null) return null;
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)
var enrolledStudents = await TeachingTaskRosterQuery
.ForTask(db, teachingTaskId)
.AsNoTracking()
.Select(x => new AttendanceStatisticsStudentIdentity(
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
x.Student.AdministrativeClass!.Name))
.Distinct()
x.Id,
x.StudentNumber,
x.Name,
x.AdministrativeClass!.Name))
.ToListAsync(cancellationToken);
var submittedSheets = await db.AttendanceSheets.AsNoTracking()
@@ -1376,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,
+132 -36
View File
@@ -3,6 +3,7 @@ using System.Security.Claims;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -17,35 +18,51 @@ namespace Jiaowu.Api.Controllers;
public sealed class AuthController(
AppDbContext db,
UserManager<ApplicationUser> userManager,
ITokenService tokenService) : ControllerBase
IAuthSessionService authSessionService,
IAppCache cache) : ControllerBase
{
[AllowAnonymous]
[HttpGet("activation-options")]
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
{
var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name })
.ToListAsync(cancellationToken);
var majors = await db.Majors.AsNoTracking()
.Where(x => x.IsEnabled && x.College!.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
.ToListAsync(cancellationToken);
var classes = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
.ToListAsync(cancellationToken);
return Ok(new
{
Colleges = colleges,
Majors = majors,
Classes = classes,
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
});
var result = await cache.GetOrCreateAsync(
AppCacheKeys.ActivationOptions,
async token =>
{
var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
.ToListAsync(token);
var majors = await db.Majors.AsNoTracking()
.Where(x => x.IsEnabled && x.College!.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new ActivationMajorOption(
x.Id, x.Code, x.Name, x.CollegeId))
.ToListAsync(token);
var classes = await db.AdministrativeClasses.AsNoTracking()
.Where(x =>
x.IsEnabled &&
x.Major!.IsEnabled &&
x.Major.College!.IsEnabled)
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new ActivationClassOption(
x.Id, x.Code, x.Name, x.Grade, x.MajorId))
.ToListAsync(token);
return new ActivationOptionsResponse(
colleges,
majors,
classes,
classes.Select(x => x.Grade)
.Distinct()
.OrderByDescending(x => x)
.ToList());
},
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
}
[AllowAnonymous]
@@ -118,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)
@@ -149,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,
roles,
user.CollegeId,
EffectiveDataScopeResolver.Resolve(roles).ToString()));
var session = await authSessionService.CreateAsync(
user,
roles,
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]
@@ -195,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,
@@ -210,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,
@@ -219,3 +294,24 @@ public sealed record CurrentUserResponse(
IEnumerable<string> Roles,
Guid? CollegeId,
string EffectiveDataScope);
public sealed record ActivationOptionsResponse(
IReadOnlyList<ActivationCollegeOption> Colleges,
IReadOnlyList<ActivationMajorOption> Majors,
IReadOnlyList<ActivationClassOption> Classes,
IReadOnlyList<int> Grades);
public sealed record ActivationCollegeOption(Guid Id, string Code, string Name);
public sealed record ActivationMajorOption(
Guid Id,
string Code,
string Name,
Guid CollegeId);
public sealed record ActivationClassOption(
Guid Id,
string Code,
string Name,
int Grade,
Guid MajorId);
+217 -79
View File
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/base-data")]
public sealed class BaseDataController(AppDbContext db) : ControllerBase
public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase
{
private const string Administrators =
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
@@ -20,9 +21,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
[HttpGet("campuses")]
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
CancellationToken cancellationToken) =>
await db.Campuses.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(cancellationToken);
await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("campuses"),
token => db.Campuses.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
[HttpPost("campuses")]
[Authorize(Roles = Administrators)]
@@ -52,21 +58,32 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
entity.Address = request.Description?.Trim();
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpGet("colleges")]
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) =>
Ok(await db.Colleges.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
CampusName = x.Campus != null ? x.Campus.Name : null,
x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken)
{
var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("colleges"),
token => db.Colleges.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new CollegeListItem(
x.Id,
x.Code,
x.Name,
x.ShortName,
x.CampusId,
x.Campus != null ? x.Campus.Name : null,
x.IsEnabled,
x.SortOrder))
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
}
[HttpPost("colleges")]
[Authorize(Roles = Administrators)]
@@ -104,29 +121,46 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
ApplyCatalog(entity, request);
entity.ShortName = request.ShortName?.Trim();
entity.CampusId = request.CampusId;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpGet("majors")]
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) =>
Ok(await db.Majors.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.CollegeId,
CollegeName = x.College!.Name,
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken)
{
var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("majors"),
token => db.Majors.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new MajorListItem(
x.Id,
x.Code,
x.Name,
x.CollegeId,
x.College!.Name,
x.DegreeType,
x.SchoolingYears,
x.IsEnabled,
x.SortOrder))
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
}
[HttpGet("course-categories")]
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
CancellationToken cancellationToken) =>
await db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.ToListAsync(cancellationToken);
await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("course-categories"),
token => db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
[HttpPost("course-categories")]
[Authorize(Roles = Administrators)]
@@ -154,7 +188,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
@@ -193,23 +227,35 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.CollegeId = request.CollegeId;
entity.DegreeType = request.DegreeType.Trim();
entity.SchoolingYears = request.SchoolingYears;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpGet("classes")]
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) =>
Ok(await db.AdministrativeClasses.AsNoTracking()
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.MajorId,
MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name,
x.Grade, x.CounselorUserId, x.CounselorName,
x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken)
{
var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("classes"),
token => db.AdministrativeClasses.AsNoTracking()
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
.Select(x => new AdministrativeClassListItem(
x.Id,
x.Code,
x.Name,
x.MajorId,
x.Major!.Name,
x.Major.College!.Name,
x.Grade,
x.CounselorUserId,
x.CounselorName,
x.IsEnabled,
x.SortOrder))
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
}
[HttpGet("counselors")]
[Authorize(Roles = Administrators)]
@@ -279,18 +325,23 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.Grade = request.Grade;
entity.CounselorUserId = request.CounselorUserId;
entity.CounselorName = counselorName;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpGet("terms")]
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
CancellationToken cancellationToken) =>
await db.AcademicTerms.AsNoTracking()
.OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate)
.ToListAsync(cancellationToken);
await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("terms"),
token => db.AcademicTerms.AsNoTracking()
.OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate)
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
[HttpPost("terms")]
[Authorize(Roles = Administrators)]
@@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.StartDate = request.StartDate;
entity.EndDate = request.EndDate;
entity.IsCurrent = request.IsCurrent;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
@@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false;
entity.IsCurrent = true;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
@@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.IsArchived = true;
entity.ArchivedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
@@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.IsArchived = false;
entity.ArchivedAt = null;
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
[HttpGet("classrooms")]
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
Ok(await db.Classrooms.AsNoTracking()
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.BuildingId,
CampusId = x.Building!.CampusId,
BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name,
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
{
var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("classrooms"),
token => db.Classrooms.AsNoTracking()
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Code)
.Select(x => new ClassroomListItem(
x.Id,
x.Code,
x.Name,
x.BuildingId,
x.Building!.CampusId,
x.Building.Name,
x.Building.Campus!.Name,
x.Capacity,
x.RoomType,
x.Equipment,
x.IsEnabled,
x.SortOrder))
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
}
[HttpGet("buildings")]
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
Ok(await db.Buildings.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new
{
x.Id, x.Code, x.Name, x.CampusId,
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken)
{
var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("buildings"),
token => db.Buildings.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new BuildingListItem(
x.Id,
x.Code,
x.Name,
x.CampusId,
x.Campus!.Name,
x.IsEnabled,
x.SortOrder))
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
}
[HttpPost("buildings")]
[Authorize(Roles = Administrators)]
@@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.Capacity = request.Capacity;
entity.RoomType = request.RoomType.Trim();
entity.Equipment = request.Equipment?.Trim();
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return entity;
}
@@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
db.Remove(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
@@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
db.Add(entity);
try
{
await db.SaveChangesAsync(cancellationToken);
await SaveAndInvalidateAsync(cancellationToken);
}
catch (DbUpdateException)
{
@@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
return CreatedAtAction(action, new { id = entity.Id }, entity);
}
private async Task SaveAndInvalidateAsync(CancellationToken cancellationToken)
{
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken);
}
private async Task<string?> GetCounselorNameAsync(
Guid? userId,
CancellationToken cancellationToken)
@@ -649,3 +730,60 @@ public sealed record ClassroomRequest(
[Required, MaxLength(40)] string RoomType,
[MaxLength(300)] string? Equipment)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record CollegeListItem(
Guid Id,
string Code,
string Name,
string? ShortName,
Guid? CampusId,
string? CampusName,
bool IsEnabled,
int SortOrder);
public sealed record MajorListItem(
Guid Id,
string Code,
string Name,
Guid CollegeId,
string CollegeName,
string DegreeType,
int SchoolingYears,
bool IsEnabled,
int SortOrder);
public sealed record AdministrativeClassListItem(
Guid Id,
string Code,
string Name,
Guid MajorId,
string MajorName,
string CollegeName,
int Grade,
Guid? CounselorUserId,
string? CounselorName,
bool IsEnabled,
int SortOrder);
public sealed record BuildingListItem(
Guid Id,
string Code,
string Name,
Guid CampusId,
string CampusName,
bool IsEnabled,
int SortOrder);
public sealed record ClassroomListItem(
Guid Id,
string Code,
string Name,
Guid BuildingId,
Guid CampusId,
string BuildingName,
string CampusName,
int Capacity,
string RoomType,
string? Equipment,
bool IsEnabled,
int SortOrder);
@@ -1,6 +1,7 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = Administrators)]
[Route("api/base-data")]
public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
{
private const string Administrators =
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
@@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.BaseData,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
@@ -0,0 +1,708 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/classroom-reservations")]
public sealed class ClassroomReservationsController(
AppDbContext db,
ICurrentUserDataScope scope,
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
{
[HttpGet("options")]
public async Task<ActionResult> GetOptions(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var applicant = await ResolveApplicantAsync(cancellationToken);
var terms = await db.AcademicTerms.AsNoTracking()
.Where(term => term.IsEnabled && !term.IsArchived)
.OrderByDescending(term => term.IsCurrent)
.ThenByDescending(term => term.StartDate)
.Select(term => new
{
term.Id,
term.Name,
term.StartDate,
term.EndDate,
term.IsCurrent,
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == term.Id &&
plan.Status == SchedulePlanStatus.Published)
})
.ToListAsync(cancellationToken);
var selectedTermId = academicTermId
?? terms.FirstOrDefault(term =>
term.IsCurrent && term.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault(term => term.HasPublishedTimetable)?.Id
?? terms.FirstOrDefault()?.Id;
var campuses = await db.Campuses.AsNoTracking()
.Where(campus => campus.IsEnabled)
.OrderBy(campus => campus.SortOrder)
.ThenBy(campus => campus.Code)
.Select(campus => new { campus.Id, campus.Code, campus.Name })
.ToListAsync(cancellationToken);
var buildings = await db.Buildings.AsNoTracking()
.Where(building =>
building.IsEnabled &&
building.Campus!.IsEnabled)
.OrderBy(building => building.Campus!.SortOrder)
.ThenBy(building => building.SortOrder)
.ThenBy(building => building.Code)
.Select(building => new
{
building.Id,
building.Code,
building.Name,
building.CampusId
})
.ToListAsync(cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking()
.Where(classroom =>
classroom.IsEnabled &&
classroom.Building!.IsEnabled &&
classroom.Building.Campus!.IsEnabled)
.OrderBy(classroom => classroom.Building!.Campus!.SortOrder)
.ThenBy(classroom => classroom.Building!.SortOrder)
.ThenBy(classroom => classroom.Code)
.Select(classroom => new
{
classroom.Id,
classroom.Code,
classroom.Name,
classroom.BuildingId,
classroom.Capacity,
classroom.RoomType
})
.ToListAsync(cancellationToken);
var timeSlots = await LoadTimeSlotsAsync(
selectedTermId,
cancellationToken);
return Ok(new
{
Applicant = applicant.Context is null
? null
: new
{
applicant.Context.User.Id,
applicant.Context.User.DisplayName,
CollegeId = applicant.Context.CollegeId,
CollegeName = applicant.Context.CollegeName
},
ApplicantProblem = applicant.Error,
Terms = terms,
SelectedTermId = selectedTermId,
Campuses = campuses,
Buildings = buildings,
Classrooms = classrooms,
TimeSlots = timeSlots
});
}
[HttpGet("availability")]
public async Task<ActionResult> GetAvailability(
Guid academicTermId,
DateOnly reservationDate,
[Range(1, 30)] int startPeriod,
[Range(1, 6)] int periodCount,
Guid? campusId,
Guid? buildingId,
[Range(1, 10000)] int? attendeeCount,
CancellationToken cancellationToken)
{
var validation = await ValidateSlotAsync(
academicTermId,
reservationDate,
startPeriod,
periodCount,
cancellationToken);
if (validation.Error is not null) return validation.Error;
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
validation.Term!,
reservationDate,
startPeriod,
periodCount,
null,
cancellationToken);
var rooms = db.Classrooms.AsNoTracking()
.Where(classroom =>
classroom.IsEnabled &&
classroom.Building!.IsEnabled &&
classroom.Building.Campus!.IsEnabled)
.WhereNotIn(occupiedIds, classroom => classroom.Id);
if (campusId.HasValue)
rooms = rooms.Where(room =>
room.Building!.CampusId == campusId.Value);
if (buildingId.HasValue)
rooms = rooms.Where(room => room.BuildingId == buildingId.Value);
if (attendeeCount.HasValue)
rooms = rooms.Where(room => room.Capacity >= attendeeCount.Value);
var items = await rooms
.OrderBy(room => room.Building!.Campus!.SortOrder)
.ThenBy(room => room.Building!.SortOrder)
.ThenBy(room => room.Code)
.Select(room => new
{
room.Id,
room.Code,
room.Name,
room.Capacity,
room.RoomType,
room.BuildingId,
BuildingName = room.Building!.Name,
CampusId = room.Building.CampusId,
CampusName = room.Building.Campus!.Name
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items });
}
[HttpGet("mine")]
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetMine(
ClassroomReservationStatus? status,
[Range(1, int.MaxValue)] int page = 1,
[Range(1, 100)] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = db.ClassroomReservations.AsNoTracking()
.Where(reservation =>
reservation.ApplicantUserId == scope.Current.UserId);
if (status.HasValue)
query = query.Where(reservation => reservation.Status == status.Value);
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
}
[HttpGet("review")]
[Authorize(Roles = SystemRoles.CollegeAdmin)]
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetReviewQueue(
ClassroomReservationStatus? status,
[Range(1, int.MaxValue)] int page = 1,
[Range(1, 100)] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var collegeError = ReviewCollegeError();
if (collegeError is not null) return collegeError;
var collegeId = scope.Current.CollegeId!.Value;
var query = db.ClassroomReservations.AsNoTracking()
.Where(reservation => reservation.ApplicantCollegeId == collegeId);
if (status.HasValue)
query = query.Where(reservation => reservation.Status == status.Value);
else
query = query.Where(reservation =>
reservation.Status == ClassroomReservationStatus.Submitted);
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
}
[HttpPost]
public async Task<ActionResult> Create(
CreateClassroomReservationRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Purpose))
return ValidationProblem("请填写借用用途。");
if (string.IsNullOrWhiteSpace(request.ContactPhone))
return ValidationProblem("请填写联系电话。");
var applicant = await ResolveApplicantAsync(cancellationToken);
if (applicant.Context is null)
return ConflictProblem(
applicant.Error ?? "当前账号未配置申请人学院,无法提交申请。");
var validation = await ValidateSlotAsync(
request.AcademicTermId,
request.ReservationDate,
request.StartPeriod,
request.PeriodCount,
cancellationToken);
if (validation.Error is not null) return validation.Error;
var classroom = await db.Classrooms.AsNoTracking()
.Where(room =>
room.Id == request.ClassroomId &&
room.IsEnabled &&
room.Building!.IsEnabled &&
room.Building.Campus!.IsEnabled)
.Select(room => new
{
room.Id,
room.Name,
room.Code,
room.Capacity,
BuildingName = room.Building!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (classroom is null) return NotFound();
if (request.AttendeeCount > classroom.Capacity)
return ValidationProblem(
$"申请人数超过教室容量({classroom.Capacity} 人)。");
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
validation.Term!,
request.ReservationDate,
request.StartPeriod,
request.PeriodCount,
null,
cancellationToken);
if (occupiedIds.Contains(classroom.Id))
return ConflictProblem("所选教室在该时段已有课程、考试或已批准预约。");
var duplicate = await db.ClassroomReservations.AsNoTracking()
.AnyAsync(reservation =>
reservation.ApplicantUserId == applicant.Context.User.Id &&
reservation.ClassroomId == classroom.Id &&
reservation.ReservationDate == request.ReservationDate &&
reservation.Status == ClassroomReservationStatus.Submitted &&
reservation.StartPeriod <
request.StartPeriod + request.PeriodCount &&
request.StartPeriod <
reservation.StartPeriod + reservation.PeriodCount,
cancellationToken);
if (duplicate)
return ConflictProblem("您已提交过同一教室、重叠时段的申请。");
var reservation = new ClassroomReservation
{
ApplicantUserId = applicant.Context.User.Id,
ApplicantName = applicant.Context.User.DisplayName,
ApplicantCollegeId = applicant.Context.CollegeId,
AcademicTermId = request.AcademicTermId,
ClassroomId = request.ClassroomId,
ReservationDate = request.ReservationDate,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
AttendeeCount = request.AttendeeCount,
Purpose = request.Purpose.Trim(),
ContactPhone = request.ContactPhone.Trim(),
Notes = Normalize(request.Notes)
};
db.ClassroomReservations.Add(reservation);
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"教室借用申请待审核",
$"{reservation.ApplicantName} 申请于 {reservation.ReservationDate:yyyy-MM-dd} " +
$"借用 {classroom.BuildingName} {classroom.Name}。",
reservation.ApplicantCollegeId,
"/classroom-reservations",
cancellationToken,
NotificationCategory.Approval);
return Created("", new { reservation.Id });
}
[HttpPost("{id:guid}/cancel")]
public async Task<ActionResult> Cancel(
Guid id,
CancellationToken cancellationToken)
{
var reservation = await db.ClassroomReservations
.FirstOrDefaultAsync(item =>
item.Id == id &&
item.ApplicantUserId == scope.Current.UserId,
cancellationToken);
if (reservation is null) return NotFound();
if (reservation.Status is not (
ClassroomReservationStatus.Submitted or
ClassroomReservationStatus.Approved))
return ConflictProblem("当前状态不能取消。");
if (reservation.ReservationDate < TodayInChina())
return ConflictProblem("已过期的预约不能取消。");
var wasApproved =
reservation.Status == ClassroomReservationStatus.Approved;
reservation.Status = ClassroomReservationStatus.Cancelled;
reservation.CancelledAt = DateTime.UtcNow;
reservation.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
if (wasApproved)
{
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"已批准教室预约被申请人取消",
$"{reservation.ApplicantName} 已取消 " +
$"{reservation.ReservationDate:yyyy-MM-dd} 的教室预约。",
reservation.ApplicantCollegeId,
"/classroom-reservations",
cancellationToken,
NotificationCategory.Approval);
}
return NoContent();
}
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = SystemRoles.CollegeAdmin)]
public Task<ActionResult> Approve(
Guid id,
ReviewClassroomReservationRequest request,
CancellationToken cancellationToken) =>
ReviewAsync(id, true, request.Comment, cancellationToken);
[HttpPost("{id:guid}/reject")]
[Authorize(Roles = SystemRoles.CollegeAdmin)]
public Task<ActionResult> Reject(
Guid id,
ReviewClassroomReservationRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Comment))
return Task.FromResult<ActionResult>(
ValidationProblem("驳回时必须填写审核意见。"));
return ReviewAsync(id, false, request.Comment, cancellationToken);
}
private async Task<ActionResult> ReviewAsync(
Guid id,
bool approve,
string? comment,
CancellationToken cancellationToken)
{
var collegeError = ReviewCollegeError();
if (collegeError is not null) return collegeError;
var reviewerCollegeId = scope.Current.CollegeId!.Value;
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var reservation = await db.ClassroomReservations
.Include(item => item.AcademicTerm)
.Include(item => item.Classroom)
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
if (reservation is null) return NotFound();
if (reservation.ApplicantCollegeId != reviewerCollegeId)
return Forbid();
if (reservation.Status != ClassroomReservationStatus.Submitted)
return ConflictProblem("该申请已处理,不能重复审核。");
if (approve)
{
if (reservation.ReservationDate < TodayInChina())
return ConflictProblem("预约日期已过,不能批准。");
var validation = await ValidateSlotAsync(
reservation.AcademicTermId,
reservation.ReservationDate,
reservation.StartPeriod,
reservation.PeriodCount,
cancellationToken);
if (validation.Error is not null) return validation.Error;
if (reservation.AttendeeCount > reservation.Classroom!.Capacity)
return ConflictProblem("申请人数已超过教室当前容量,不能批准。");
var occupiedIds =
await availabilityService.GetOccupiedClassroomIdsAsync(
validation.Term!,
reservation.ReservationDate,
reservation.StartPeriod,
reservation.PeriodCount,
reservation.Id,
cancellationToken);
if (occupiedIds.Contains(reservation.ClassroomId))
return ConflictProblem(
"教室在该时段已被课程、考试或其他已批准预约占用。");
reservation.Status = ClassroomReservationStatus.Approved;
}
else
{
reservation.Status = ClassroomReservationStatus.Rejected;
}
reservation.ReviewedByUserId = scope.Current.UserId;
reservation.ReviewedAt = DateTime.UtcNow;
reservation.ReviewComment = Normalize(comment);
reservation.UpdatedAt = DateTime.UtcNow;
await NotificationService.SendAsync(
db,
reservation.ApplicantUserId,
approve ? "教室借用申请已通过" : "教室借用申请已驳回",
approve
? $"{reservation.ReservationDate:yyyy-MM-dd} 的教室借用申请已通过。"
: reservation.ReviewComment ?? "审核未通过。",
"/classroom-reservations",
cancellationToken,
NotificationCategory.Approval);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken,
IsolationLevel.Serializable);
}
private async Task<PagedResult<ClassroomReservationDto>> BuildPageAsync(
IQueryable<ClassroomReservation> query,
int page,
int pageSize,
CancellationToken cancellationToken)
{
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(reservation =>
reservation.Status == ClassroomReservationStatus.Submitted)
.ThenByDescending(reservation => reservation.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(reservation => new ClassroomReservationDto(
reservation.Id,
reservation.ApplicantUserId,
reservation.ApplicantName,
reservation.ApplicantCollegeId,
reservation.ApplicantCollege!.Name,
reservation.AcademicTermId,
reservation.AcademicTerm!.Name,
reservation.ClassroomId,
reservation.Classroom!.Code,
reservation.Classroom.Name,
reservation.Classroom.Building!.Name,
reservation.Classroom.Building.Campus!.Name,
reservation.ReservationDate,
reservation.StartPeriod,
reservation.PeriodCount,
reservation.AttendeeCount,
reservation.Purpose,
reservation.ContactPhone,
reservation.Notes,
reservation.Status,
reservation.ReviewComment,
reservation.ReviewedAt,
reservation.CancelledAt,
reservation.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<ClassroomReservationDto>(
items,
total,
page,
pageSize);
}
private async Task<(
AcademicTerm? Term,
ActionResult? Error)> ValidateSlotAsync(
Guid academicTermId,
DateOnly reservationDate,
int startPeriod,
int periodCount,
CancellationToken cancellationToken)
{
var term = await db.AcademicTerms.AsNoTracking()
.FirstOrDefaultAsync(item =>
item.Id == academicTermId &&
item.IsEnabled &&
!item.IsArchived,
cancellationToken);
if (term is null) return (null, NotFound());
if (reservationDate < TodayInChina())
return (null, ValidationProblem("预约日期不能早于今天。"));
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
return (null, ValidationProblem("预约日期不在所选学期范围内。"));
var hasPublishedTimetable = await db.SchedulePlans.AsNoTracking()
.AnyAsync(plan =>
plan.AcademicTermId == term.Id &&
plan.Status == SchedulePlanStatus.Published,
cancellationToken);
if (!hasPublishedTimetable)
return (null, ConflictProblem(
"所选学期的正式课表尚未发布,暂不能提交教室预约。"));
var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray();
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
.CountAsync(slot =>
slot.AcademicTermId == academicTermId,
cancellationToken);
if (configuredPeriodCount == 0)
{
if (requestedPeriods.Any(period => period > 12))
return (null, ValidationProblem("预约范围包含不存在的节次。"));
}
else
{
var enabledPeriods = await db.ScheduleTimeSlots.AsNoTracking()
.Where(slot =>
slot.AcademicTermId == academicTermId &&
slot.IsEnabled)
.WhereIn(requestedPeriods, slot => slot.PeriodNumber)
.CountAsync(cancellationToken);
if (enabledPeriods != requestedPeriods.Length)
return (null, ValidationProblem("预约范围包含不存在或未启用的节次。"));
}
return (term, null);
}
private async Task<List<ClassroomReservationTimeSlotDto>> LoadTimeSlotsAsync(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = academicTermId.HasValue
? await db.ScheduleTimeSlots.AsNoTracking()
.Where(slot =>
slot.AcademicTermId == academicTermId.Value &&
slot.IsEnabled)
.OrderBy(slot => slot.PeriodNumber)
.Select(slot => new ClassroomReservationTimeSlotDto(
slot.PeriodNumber,
slot.Name,
slot.StartsAt.ToString("HH:mm"),
slot.EndsAt.ToString("HH:mm")))
.ToListAsync(cancellationToken)
: [];
return result.Count > 0
? result
: Enumerable.Range(1, 12)
.Select(period => new ClassroomReservationTimeSlotDto(
period,
$"第 {period} 节",
"",
""))
.ToList();
}
private async Task<ApplicantResolution> ResolveApplicantAsync(
CancellationToken cancellationToken)
{
if (scope.Current.UserId == Guid.Empty)
return new(null, "无法识别当前登录账号。");
var user = await db.Users.AsNoTracking()
.FirstOrDefaultAsync(item =>
item.Id == scope.Current.UserId &&
item.IsEnabled,
cancellationToken);
if (user is null) return new(null, "当前账号不存在或已停用。");
var linkedCollegeIds = new List<Guid>();
if (user.CollegeId.HasValue) linkedCollegeIds.Add(user.CollegeId.Value);
var teacherCollegeId = await db.Teachers.AsNoTracking()
.Where(teacher => teacher.UserId == user.Id)
.Select(teacher => (Guid?)teacher.CollegeId)
.FirstOrDefaultAsync(cancellationToken);
if (teacherCollegeId.HasValue)
linkedCollegeIds.Add(teacherCollegeId.Value);
var studentCollegeId = await db.Students.AsNoTracking()
.Where(student => student.UserId == user.Id)
.Select(student =>
(Guid?)student.AdministrativeClass!.Major!.CollegeId)
.FirstOrDefaultAsync(cancellationToken);
if (studentCollegeId.HasValue)
linkedCollegeIds.Add(studentCollegeId.Value);
var distinctIds = linkedCollegeIds.Distinct().ToArray();
if (distinctIds.Length == 0)
return new(null, "当前账号未配置所在学院,请联系账号管理员补充学院信息。");
if (distinctIds.Length > 1)
return new(null, "账号与人员档案的学院信息不一致,请联系管理员修正后再申请。");
var college = await db.Colleges.AsNoTracking()
.Where(item => item.Id == distinctIds[0] && item.IsEnabled)
.Select(item => new { item.Id, item.Name })
.FirstOrDefaultAsync(cancellationToken);
return college is null
? new(null, "当前账号所属学院不存在或已停用。")
: new(
new ApplicantContext(user, college.Id, college.Name),
null);
}
private ActionResult? ReviewCollegeError()
{
if (!scope.Current.IsInRole(SystemRoles.CollegeAdmin))
return Forbid();
return scope.Current.CollegeId.HasValue
? null
: ConflictProblem("学院管理员账号未配置所属学院,无法审核。");
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成教室预约",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static DateOnly TodayInChina()
{
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
{
try
{
var zone = TimeZoneInfo.FindSystemTimeZoneById(id);
return DateOnly.FromDateTime(
TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone).DateTime);
}
catch (TimeZoneNotFoundException)
{
}
catch (InvalidTimeZoneException)
{
}
}
return DateOnly.FromDateTime(DateTime.Today);
}
private sealed record ApplicantContext(
ApplicationUser User,
Guid CollegeId,
string CollegeName);
private sealed record ApplicantResolution(
ApplicantContext? Context,
string? Error);
}
public sealed record CreateClassroomReservationRequest(
Guid AcademicTermId,
Guid ClassroomId,
DateOnly ReservationDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
[Range(1, 10000)] int AttendeeCount,
[Required, StringLength(200)] string Purpose,
[Required, StringLength(30)] string ContactPhone,
[StringLength(500)] string? Notes);
public sealed record ReviewClassroomReservationRequest(
[StringLength(500)] string? Comment);
public sealed record ClassroomReservationTimeSlotDto(
int PeriodNumber,
string Name,
string StartTime,
string EndTime);
public sealed record ClassroomReservationDto(
Guid Id,
Guid ApplicantUserId,
string ApplicantName,
Guid ApplicantCollegeId,
string ApplicantCollegeName,
Guid AcademicTermId,
string AcademicTermName,
Guid ClassroomId,
string ClassroomCode,
string ClassroomName,
string BuildingName,
string CampusName,
DateOnly ReservationDate,
int StartPeriod,
int PeriodCount,
int AttendeeCount,
string Purpose,
string ContactPhone,
string? Notes,
ClassroomReservationStatus Status,
string? ReviewComment,
DateTime? ReviewedAt,
DateTime? CancelledAt,
DateTime CreatedAt);
@@ -3,6 +3,8 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -27,6 +29,145 @@ public sealed class CourseAdjustmentsController(
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet("task-options")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetTaskOptions(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = AccessibleTeachingTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
ClassNames = x.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name)
})
.ToListAsync(cancellationToken));
}
[HttpGet("tasks/{teachingTaskId:guid}/session-options")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetSessionOptions(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var task = await AccessibleTeachingTasks().AsNoTracking()
.Where(x =>
x.Id == teachingTaskId &&
x.Status == TeachingTaskStatus.Published)
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name,
x.AcademicTermId,
TermStartDate = x.AcademicTerm!.StartDate,
TermEndDate = x.AcademicTerm.EndDate
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null) return NotFound();
var entries = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.TeachingTaskId == teachingTaskId &&
x.SchedulePlan!.Status == SchedulePlanStatus.Published)
.OrderBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
x.DayOfWeek,
x.StartPeriod,
x.PeriodCount,
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.ClassroomId,
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null
})
.ToListAsync(cancellationToken);
var occupied = await db.CourseAdjustments.AsNoTracking()
.Where(x =>
x.TeachingTaskId == teachingTaskId &&
x.SourceScheduleEntryId != null &&
x.SourceWeek != null &&
(x.Status == CourseAdjustmentStatus.Submitted ||
x.Status == CourseAdjustmentStatus.Approved))
.Select(x => new { x.SourceScheduleEntryId, x.SourceWeek })
.ToListAsync(cancellationToken);
var occupiedOccurrences = occupied
.Select(x => (x.SourceScheduleEntryId!.Value, x.SourceWeek!.Value))
.ToHashSet();
var sessions = entries
.SelectMany(entry => Enumerable.Range(
entry.StartWeek,
entry.EndWeek - entry.StartWeek + 1)
.Where(week => IncludesWeek(entry.WeekPattern, week))
.Select(week => new
{
ScheduleEntryId = entry.Id,
Week = week,
Date = ResolveDate(task.TermStartDate, week, entry.DayOfWeek),
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.ClassroomId,
entry.ClassroomName,
entry.BuildingName,
HasExistingAdjustment = occupiedOccurrences.Contains((entry.Id, week))
}))
.Where(x => x.Date >= task.TermStartDate && x.Date <= task.TermEndDate)
.OrderBy(x => x.Date)
.ThenBy(x => x.StartPeriod)
.ToList();
var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Building!.SortOrder)
.ThenBy(x => x.SortOrder)
.Select(x => new
{
x.Id,
x.Name,
BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name,
x.Capacity
})
.ToListAsync(cancellationToken);
var periods = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == task.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.Select(x => new
{
x.PeriodNumber,
x.Name,
StartsAt = x.StartsAt.ToString("HH:mm"),
EndsAt = x.EndsAt.ToString("HH:mm")
})
.ToListAsync(cancellationToken);
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
}
// ═══════════════ My adjustments ═══════════════
[HttpGet("mine")]
@@ -100,20 +241,41 @@ public sealed class CourseAdjustmentsController(
var validation = await ValidateRequestAsync(request, cancellationToken);
if (validation is not null) return validation;
var source = RequiresSourceOccurrence(request.Type)
? await LoadSourceOccurrenceAsync(
request.TeachingTaskId,
request.SourceScheduleEntryId,
request.SourceWeek,
cancellationToken)
: null;
var targetDayOfWeek = request.TargetDate.HasValue
? ToIsoDayOfWeek(request.TargetDate.Value.DayOfWeek)
: request.DayOfWeek;
var adj = new CourseAdjustment
{
TeachingTaskId = request.TeachingTaskId,
Type = request.Type,
ApplicantUserId = userId,
Reason = request.Reason.Trim(),
SourceScheduleEntryId = source?.Entry.Id,
SourceWeek = source?.Week,
SourceDate = source?.Date,
SourceStartPeriod = source?.Entry.StartPeriod,
SourcePeriodCount = source?.Entry.PeriodCount,
SourceClassroomId = source?.Entry.ClassroomId,
TargetDate = request.TargetDate,
DayOfWeek = request.DayOfWeek,
DayOfWeek = targetDayOfWeek,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount,
ClassroomId = request.ClassroomId,
SubstituteTeacherId = request.SubstituteTeacherId,
CancelWeek = request.CancelWeek,
CancelDate = request.CancelDate
CancelWeek = request.Type == CourseAdjustmentType.Cancel
? source?.Week
: request.CancelWeek,
CancelDate = request.Type == CourseAdjustmentType.Cancel
? source?.Date
: request.CancelDate
};
if (request.Submit)
@@ -138,7 +300,8 @@ public sealed class CourseAdjustmentsController(
SystemRoles.CollegeAdmin,
$"新的{tl}申请",
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
taskInfo.CollegeId, "/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
}
}
@@ -161,6 +324,25 @@ public sealed class CourseAdjustmentsController(
if (adj.Status != CourseAdjustmentStatus.Draft)
return ConflictProblem("只有草稿可以提交。");
var validation = await ValidateRequestAsync(
new CourseAdjustmentRequest(
adj.TeachingTaskId,
adj.Type,
adj.Reason,
true,
adj.TargetDate,
adj.DayOfWeek,
adj.StartPeriod,
adj.PeriodCount,
adj.ClassroomId,
adj.SubstituteTeacherId,
adj.CancelWeek,
adj.CancelDate,
adj.SourceScheduleEntryId,
adj.SourceWeek),
cancellationToken);
if (validation is not null) return validation;
adj.Status = CourseAdjustmentStatus.Submitted;
adj.SubmittedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
@@ -171,12 +353,14 @@ public sealed class CourseAdjustmentsController(
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
adj.TeachingTask.Course.CollegeId,
"/course-adjustments", cancellationToken);
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
await NotificationService.SendToRoleAsync(db,
SystemRoles.AcademicAdmin,
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
null, "/course-adjustments", cancellationToken);
null, "/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
return NoContent();
}
@@ -190,6 +374,12 @@ public sealed class CourseAdjustmentsController(
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (adj is null) return NotFound();
@@ -200,6 +390,12 @@ public sealed class CourseAdjustmentsController(
if (adj.Status != CourseAdjustmentStatus.Submitted)
return ConflictProblem("只有待审核的申请可以审批。");
var scheduleProblem = await ValidateApprovalScheduleAsync(
adj,
cancellationToken);
if (scheduleProblem is not null)
return ConflictProblem(scheduleProblem);
adj.Status = CourseAdjustmentStatus.Approved;
adj.ReviewedAt = DateTime.UtcNow;
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
@@ -213,14 +409,13 @@ public sealed class CourseAdjustmentsController(
await NotificationService.SendAsync(db, adj.ApplicantUserId,
"调停课申请已通过",
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
"/course-adjustments", cancellationToken);
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
// Notify affected students
var studentUserIds = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => x.Student!.UserId)
var studentUserIds = await TeachingTaskRosterQuery
.ForTask(db, adj.TeachingTaskId)
.Select(x => x.UserId)
.Where(uid => uid != null)
.Select(uid => uid!.Value)
.Distinct()
@@ -230,7 +425,8 @@ public sealed class CourseAdjustmentsController(
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"课程变动通知",
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
"/my-timetable", cancellationToken);
"/my-timetable", cancellationToken,
NotificationCategory.Schedule);
}
return NoContent();
@@ -242,81 +438,19 @@ public sealed class CourseAdjustmentsController(
switch (adj.Type)
{
case CourseAdjustmentType.Reschedule:
// Update existing schedule entries for this teaching task
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
{
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
.ToListAsync(ct);
foreach (var entry in entries)
{
entry.DayOfWeek = adj.DayOfWeek.Value;
entry.StartPeriod = adj.StartPeriod.Value;
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
}
if (adj.ClassroomId.HasValue)
{
foreach (var entry in entries)
entry.ClassroomId = adj.ClassroomId.Value;
}
}
var rescheduleSource = await LoadSourceEntryAsync(adj, ct);
ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value);
db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId));
break;
case CourseAdjustmentType.Cancel:
// Cancel: remove schedule entries for the specified week
if (adj.CancelWeek.HasValue)
{
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
x.StartWeek <= adj.CancelWeek.Value &&
x.EndWeek >= adj.CancelWeek.Value)
.ToListAsync(ct);
foreach (var entry in entries)
{
// Split the entry to exclude the cancelled week
if (entry.StartWeek == adj.CancelWeek.Value &&
entry.EndWeek == adj.CancelWeek.Value)
{
db.ScheduleEntries.Remove(entry);
}
else if (entry.StartWeek == adj.CancelWeek.Value)
{
entry.StartWeek = adj.CancelWeek.Value + 1;
}
else if (entry.EndWeek == adj.CancelWeek.Value)
{
entry.EndWeek = adj.CancelWeek.Value - 1;
}
}
}
var cancelSource = await LoadSourceEntryAsync(adj, ct);
ExcludeWeek(cancelSource, adj.SourceWeek!.Value);
break;
case CourseAdjustmentType.Makeup:
// Makeup: add a temp schedule entry for the makeup date
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
adj.StartPeriod.HasValue)
{
var publishedPlan = await db.SchedulePlans
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.FirstOrDefaultAsync(ct);
if (publishedPlan is not null)
{
db.ScheduleEntries.Add(new ScheduleEntry
{
SchedulePlanId = publishedPlan.Id,
TeachingTaskId = adj.TeachingTaskId,
ClassroomId = adj.ClassroomId,
DayOfWeek = adj.DayOfWeek.Value,
StartPeriod = adj.StartPeriod.Value,
PeriodCount = adj.PeriodCount ?? 2,
StartWeek = 1,
EndWeek = 1,
WeekPattern = WeekPattern.All,
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd}"
});
}
}
var makeupSource = await LoadSourceEntryAsync(adj, ct);
db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId));
break;
case CourseAdjustmentType.Substitute:
@@ -383,7 +517,8 @@ public sealed class CourseAdjustmentsController(
: $"您的{TypeLabel(adj.Type)}申请已退回。";
await NotificationService.SendAsync(db, adj.ApplicantUserId,
$"{TypeLabel(adj.Type)}申请已退回", msg,
"/course-adjustments", cancellationToken);
"/course-adjustments", cancellationToken,
NotificationCategory.Schedule);
return NoContent();
}
@@ -393,7 +528,7 @@ public sealed class CourseAdjustmentsController(
CourseAdjustmentRequest request,
CancellationToken cancellationToken)
{
var task = await db.TeachingTasks.AsNoTracking()
var task = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.Course)
.FirstOrDefaultAsync(x =>
x.Id == request.TeachingTaskId &&
@@ -405,15 +540,13 @@ public sealed class CourseAdjustmentsController(
{
case CourseAdjustmentType.Reschedule:
case CourseAdjustmentType.Makeup:
if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7)
return ValidationProblem("请选择有效的上课日。");
if (!request.TargetDate.HasValue)
return ValidationProblem(
request.Type == CourseAdjustmentType.Reschedule
? "请选择调课后的日期。"
: "请选择补课日期。");
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
return ValidationProblem("请选择起始节次。");
if (!request.PeriodCount.HasValue || request.PeriodCount < 1)
return ValidationProblem("请选择持续节数。");
if (request.Type == CourseAdjustmentType.Makeup &&
!request.TargetDate.HasValue)
return ValidationProblem("补课必须指定日期。");
break;
case CourseAdjustmentType.Substitute:
if (!request.SubstituteTeacherId.HasValue)
@@ -424,15 +557,241 @@ public sealed class CourseAdjustmentsController(
return ValidationProblem("代课教师不存在或已离职。");
break;
case CourseAdjustmentType.Cancel:
if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue)
return ValidationProblem("停课需指定周次或日期。");
break;
}
if (RequiresSourceOccurrence(request.Type))
{
var source = await LoadSourceOccurrenceAsync(
request.TeachingTaskId,
request.SourceScheduleEntryId,
request.SourceWeek,
cancellationToken);
if (source is null)
return ValidationProblem("请选择当前已发布课表中的具体原课次。");
var duplicate = await db.CourseAdjustments.AsNoTracking().AnyAsync(x =>
x.SourceScheduleEntryId == source.Entry.Id &&
x.SourceWeek == source.Week &&
(x.Status == CourseAdjustmentStatus.Submitted ||
x.Status == CourseAdjustmentStatus.Approved),
cancellationToken);
if (duplicate)
return ConflictProblem("该课次已有待审核或已通过的调停课记录。");
if (request.TargetDate.HasValue &&
(request.TargetDate < source.Term.StartDate ||
request.TargetDate > source.Term.EndDate))
return ValidationProblem("目标日期必须在本学期起止日期内。");
}
if (request.ClassroomId.HasValue &&
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken))
return ValidationProblem("目标教室不存在或已停用。");
return null;
}
private async Task<string?> ValidateApprovalScheduleAsync(
CourseAdjustment adjustment,
CancellationToken cancellationToken)
{
if (!RequiresSourceOccurrence(adjustment.Type)) return null;
var source = await LoadSourceOccurrenceAsync(
adjustment.TeachingTaskId,
adjustment.SourceScheduleEntryId,
adjustment.SourceWeek,
cancellationToken);
if (source is null)
return "原课次已不在当前发布课表中,请退回后重新选择。";
if (adjustment.Type == CourseAdjustmentType.Cancel) return null;
if (!adjustment.TargetDate.HasValue || !adjustment.StartPeriod.HasValue)
return "目标上课日期或节次不完整。";
var targetWeek = ResolveWeek(source.Term.StartDate, adjustment.TargetDate.Value);
var targetDay = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek);
var target = new ScheduleEntry
{
TeachingTaskId = adjustment.TeachingTaskId,
TeachingTask = adjustment.TeachingTask,
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
DayOfWeek = targetDay,
StartPeriod = adjustment.StartPeriod.Value,
PeriodCount = adjustment.SourcePeriodCount ?? source.Entry.PeriodCount,
StartWeek = targetWeek,
EndWeek = targetWeek,
WeekPattern = WeekPattern.All
};
var possibleConflicts = await db.ScheduleEntries.AsNoTracking()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.Where(x =>
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.Id != adjustment.SourceScheduleEntryId &&
x.DayOfWeek == targetDay &&
x.StartWeek <= targetWeek &&
x.EndWeek >= targetWeek &&
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
target.StartPeriod < x.StartPeriod + x.PeriodCount)
.ToListAsync(cancellationToken);
foreach (var existing in possibleConflicts.Where(x =>
IncludesWeek(x.WeekPattern, targetWeek)))
{
var reason = ScheduleConflictDetector.ConflictReason(target, existing);
if (reason is not null)
return $"目标时间存在{reason}冲突:{existing.TeachingTask!.Name}。";
}
return null;
}
private static System.Linq.Expressions.Expression<
private async Task<SourceOccurrence?> LoadSourceOccurrenceAsync(
Guid teachingTaskId,
Guid? scheduleEntryId,
int? week,
CancellationToken cancellationToken)
{
if (!scheduleEntryId.HasValue || !week.HasValue) return null;
var entry = await db.ScheduleEntries.AsNoTracking()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.FirstOrDefaultAsync(x =>
x.Id == scheduleEntryId.Value &&
x.TeachingTaskId == teachingTaskId &&
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
cancellationToken);
if (entry?.TeachingTask?.AcademicTerm is null ||
week < entry.StartWeek || week > entry.EndWeek ||
!IncludesWeek(entry.WeekPattern, week.Value))
return null;
var date = ResolveDate(
entry.TeachingTask.AcademicTerm.StartDate,
week.Value,
entry.DayOfWeek);
if (date < entry.TeachingTask.AcademicTerm.StartDate ||
date > entry.TeachingTask.AcademicTerm.EndDate)
return null;
return new SourceOccurrence(
entry,
entry.TeachingTask.AcademicTerm,
week.Value,
date);
}
private Task<ScheduleEntry> LoadSourceEntryAsync(
CourseAdjustment adjustment,
CancellationToken cancellationToken) =>
db.ScheduleEntries.SingleAsync(x =>
x.Id == adjustment.SourceScheduleEntryId &&
x.TeachingTaskId == adjustment.TeachingTaskId &&
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
cancellationToken);
private void ExcludeWeek(ScheduleEntry entry, int week)
{
db.ScheduleEntries.Remove(entry);
if (entry.StartWeek <= week - 1 &&
HasIncludedWeek(entry.WeekPattern, entry.StartWeek, week - 1))
db.ScheduleEntries.Add(CloneEntry(entry, entry.StartWeek, week - 1));
if (week + 1 <= entry.EndWeek &&
HasIncludedWeek(entry.WeekPattern, week + 1, entry.EndWeek))
db.ScheduleEntries.Add(CloneEntry(entry, week + 1, entry.EndWeek));
}
private ScheduleEntry CreateTargetEntry(
CourseAdjustment adjustment,
Guid schedulePlanId)
{
var targetWeek = ResolveWeek(
adjustment.TeachingTask!.AcademicTerm!.StartDate,
adjustment.TargetDate!.Value);
return new ScheduleEntry
{
SchedulePlanId = schedulePlanId,
TeachingTaskId = adjustment.TeachingTaskId,
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
DayOfWeek = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek),
StartPeriod = adjustment.StartPeriod!.Value,
PeriodCount = adjustment.SourcePeriodCount!.Value,
StartWeek = targetWeek,
EndWeek = targetWeek,
WeekPattern = WeekPattern.All,
Notes = adjustment.Type == CourseAdjustmentType.Reschedule
? $"调课(原第 {adjustment.SourceWeek} 周)"
: $"补课(对应原第 {adjustment.SourceWeek} 周课次)"
};
}
private static ScheduleEntry CloneEntry(
ScheduleEntry source,
int startWeek,
int endWeek) => new()
{
SchedulePlanId = source.SchedulePlanId,
TeachingTaskId = source.TeachingTaskId,
ClassroomId = source.ClassroomId,
DayOfWeek = source.DayOfWeek,
StartPeriod = source.StartPeriod,
PeriodCount = source.PeriodCount,
StartWeek = startWeek,
EndWeek = endWeek,
WeekPattern = source.WeekPattern,
Notes = source.Notes
};
private static bool RequiresSourceOccurrence(CourseAdjustmentType type) =>
type is CourseAdjustmentType.Reschedule or
CourseAdjustmentType.Cancel or
CourseAdjustmentType.Makeup;
private static bool IncludesWeek(WeekPattern pattern, int week) =>
pattern == WeekPattern.All ||
pattern == WeekPattern.Odd && week % 2 == 1 ||
pattern == WeekPattern.Even && week % 2 == 0;
private static bool HasIncludedWeek(WeekPattern pattern, int startWeek, int endWeek) =>
Enumerable.Range(startWeek, endWeek - startWeek + 1)
.Any(week => IncludesWeek(pattern, week));
private static DateOnly ResolveDate(DateOnly termStartDate, int week, int dayOfWeek)
{
var startDay = (int)termStartDate.DayOfWeek;
var daysSinceMonday = (startDay + 6) % 7;
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
}
private static int ResolveWeek(DateOnly termStartDate, DateOnly date)
{
var startDay = (int)termStartDate.DayOfWeek;
var daysSinceMonday = (startDay + 6) % 7;
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
return (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
}
private static int ToIsoDayOfWeek(DayOfWeek dayOfWeek) =>
dayOfWeek == System.DayOfWeek.Sunday ? 7 : (int)dayOfWeek;
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private System.Linq.Expressions.Expression<
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
{
x.Id,
@@ -449,6 +808,17 @@ public sealed class CourseAdjustmentsController(
x.Status,
x.Reason,
x.ReviewComment,
x.SourceScheduleEntryId,
x.SourceWeek,
x.SourceDate,
x.SourceStartPeriod,
x.SourcePeriodCount,
SourceClassroomName = x.SourceClassroomId == null
? null
: db.Classrooms
.Where(room => room.Id == x.SourceClassroomId)
.Select(room => room.Building!.Name + " " + room.Name)
.FirstOrDefault(),
x.TargetDate,
x.DayOfWeek,
x.StartPeriod,
@@ -464,6 +834,12 @@ public sealed class CourseAdjustmentsController(
x.CreatedAt
};
private sealed record SourceOccurrence(
ScheduleEntry Entry,
AcademicTerm Term,
int Week,
DateOnly Date);
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
@@ -487,7 +863,9 @@ public sealed record CourseAdjustmentRequest(
Guid? ClassroomId,
Guid? SubstituteTeacherId,
int? CancelWeek,
DateOnly? CancelDate);
DateOnly? CancelDate,
Guid? SourceScheduleEntryId = null,
int? SourceWeek = null);
public sealed record RejectionRequest(
[MaxLength(500)] string? Comment);
File diff suppressed because it is too large Load Diff
+100 -7
View File
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/courses")]
public sealed class CoursesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
@@ -82,6 +84,17 @@ public sealed class CoursesController(
x.Nature,
x.AssessmentMethod,
x.Description,
PrerequisiteCourseIds = x.Prerequisites
.OrderBy(item => item.PrerequisiteCourse!.Code)
.Select(item => item.PrerequisiteCourseId),
Prerequisites = x.Prerequisites
.OrderBy(item => item.PrerequisiteCourse!.Code)
.Select(item => new
{
item.PrerequisiteCourseId,
item.PrerequisiteCourse!.Code,
item.PrerequisiteCourse.Name
}),
x.IsEnabled,
x.SortOrder,
x.CreatedAt,
@@ -132,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
})
@@ -144,9 +159,6 @@ public sealed class CoursesController(
CourseRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
var entity = new Course
{
Code = request.Code.Trim(),
@@ -164,6 +176,16 @@ public sealed class CoursesController(
IsEnabled = request.IsEnabled,
SortOrder = request.SortOrder
};
var validation = await ValidateAsync(entity.Id, request, cancellationToken);
if (validation is not null) return validation;
entity.Prerequisites = NormalizePrerequisiteIds(request)
.Select(prerequisiteId => new CoursePrerequisite
{
CourseId = entity.Id,
PrerequisiteCourseId = prerequisiteId
})
.ToList();
db.Courses.Add(entity);
return await SaveAsync(entity.Id, true, cancellationToken);
}
@@ -175,10 +197,12 @@ public sealed class CoursesController(
CourseRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Courses.FindAsync([id], cancellationToken);
var entity = await db.Courses
.Include(x => x.Prerequisites)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) return NotFound();
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
var validation = await ValidateAsync(request, cancellationToken);
var validation = await ValidateAsync(id, request, cancellationToken);
if (validation is not null) return validation;
entity.Code = request.Code.Trim();
@@ -195,6 +219,19 @@ public sealed class CoursesController(
entity.Description = Normalize(request.Description);
entity.IsEnabled = request.IsEnabled;
entity.SortOrder = request.SortOrder;
var prerequisiteIds = NormalizePrerequisiteIds(request).ToHashSet();
db.CoursePrerequisites.RemoveRange(
entity.Prerequisites.Where(x =>
!prerequisiteIds.Contains(x.PrerequisiteCourseId)));
foreach (var prerequisiteId in prerequisiteIds.Except(
entity.Prerequisites.Select(x => x.PrerequisiteCourseId)))
{
entity.Prerequisites.Add(new CoursePrerequisite
{
CourseId = entity.Id,
PrerequisiteCourseId = prerequisiteId
});
}
return await SaveAsync(entity.Id, false, cancellationToken);
}
@@ -210,6 +247,7 @@ public sealed class CoursesController(
}
private async Task<ActionResult?> ValidateAsync(
Guid courseId,
CourseRequest request,
CancellationToken cancellationToken)
{
@@ -222,9 +260,62 @@ public sealed class CoursesController(
return ValidationProblem("所选课程分类不存在或已停用。");
if (request.LectureHours + request.PracticeHours > request.TotalHours)
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
var prerequisiteIds = NormalizePrerequisiteIds(request).ToArray();
if (prerequisiteIds.Contains(courseId))
return ValidationProblem("课程不能把自身设置为先修课程。");
var accessiblePrerequisiteCount = await ScopedCourses()
.CountAsync(x =>
prerequisiteIds.Contains(x.Id) &&
x.IsEnabled,
cancellationToken);
if (accessiblePrerequisiteCount != prerequisiteIds.Length)
return ValidationProblem("包含不存在、已停用或不在当前数据范围内的先修课程。");
if (await CreatesPrerequisiteCycleAsync(
courseId,
prerequisiteIds,
cancellationToken))
return ValidationProblem("先修关系不能形成循环依赖。");
return null;
}
private async Task<bool> CreatesPrerequisiteCycleAsync(
Guid courseId,
IReadOnlyCollection<Guid> prerequisiteIds,
CancellationToken cancellationToken)
{
if (prerequisiteIds.Count == 0) return false;
var edges = await db.CoursePrerequisites.AsNoTracking()
.Where(x => x.CourseId != courseId)
.Select(x => new { x.CourseId, x.PrerequisiteCourseId })
.ToListAsync(cancellationToken);
var prerequisitesByCourse = edges
.GroupBy(x => x.CourseId)
.ToDictionary(
group => group.Key,
group => group.Select(x => x.PrerequisiteCourseId).ToArray());
foreach (var prerequisiteId in prerequisiteIds)
{
var pending = new Stack<Guid>();
var visited = new HashSet<Guid>();
pending.Push(prerequisiteId);
while (pending.TryPop(out var candidate))
{
if (candidate == courseId) return true;
if (!visited.Add(candidate) ||
!prerequisitesByCourse.TryGetValue(candidate, out var next))
continue;
foreach (var item in next) pending.Push(item);
}
}
return false;
}
private static IEnumerable<Guid> NormalizePrerequisiteIds(CourseRequest request) =>
(request.PrerequisiteCourseIds ?? []).Distinct();
private IQueryable<Course> ScopedCourses()
{
var scope = currentUserDataScope.Current;
@@ -273,6 +364,7 @@ public sealed class CoursesController(
try
{
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
@@ -304,4 +396,5 @@ public sealed record CourseRequest(
AssessmentMethod AssessmentMethod,
[MaxLength(1000)] string? Description,
bool IsEnabled,
int SortOrder);
int SortOrder,
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
@@ -2,6 +2,7 @@ using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/courses")]
public sealed class CoursesExcelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
@@ -141,6 +143,9 @@ public sealed class CoursesExcelController(
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
+292 -62
View File
@@ -1,4 +1,6 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -9,75 +11,303 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/dashboard")]
public sealed class DashboardController(AppDbContext db) : ControllerBase
public sealed class DashboardController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
public async Task<ActionResult<DashboardResponse>> Get(
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
Guid? restrictedCollegeId = scope.Scope == DataScope.All
? null
: scope.CollegeId ?? Guid.Empty;
var collegeName = restrictedCollegeId.HasValue &&
restrictedCollegeId.Value != Guid.Empty
? await db.Colleges.AsNoTracking()
.Where(x => x.Id == restrictedCollegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var currentTerm = await db.AcademicTerms
.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
.Select(x => new DashboardTerm(
x.Id,
x.Name,
x.StartDate,
x.EndDate))
.FirstOrDefaultAsync(cancellationToken);
var currentTermId = currentTerm?.Id;
return new
{
CurrentTerm = currentTerm,
Counts = new
{
Campuses = await db.Campuses.CountAsync(cancellationToken),
Colleges = await db.Colleges.CountAsync(cancellationToken),
Majors = await db.Majors.CountAsync(cancellationToken),
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
Teachers = await db.Teachers.CountAsync(cancellationToken),
Students = await db.Students.CountAsync(cancellationToken),
Courses = await db.Courses.CountAsync(cancellationToken),
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
CourseSelectionRounds = await db.CourseSelectionRounds
.CountAsync(cancellationToken),
CourseSelectionOfferings = await db.CourseSelectionOfferings
.CountAsync(cancellationToken),
CourseEnrollments = await db.CourseEnrollments
.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken),
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
PublishedGradeSheets = await db.GradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Published,
cancellationToken),
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
StudentStatusChanges = await db.StudentStatusChanges
.CountAsync(cancellationToken),
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
x => x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved,
cancellationToken),
GraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(cancellationToken),
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(
x => x.Status == GraduationAuditBatchStatus.Published,
cancellationToken),
DegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(cancellationToken),
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(
x => x.Status == DegreeAwardBatchStatus.Published,
cancellationToken),
GraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(cancellationToken),
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(
x => x.Status == GraduationClearanceBatchStatus.Open,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
var students = db.Students.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value);
var teachers = db.Teachers.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.CollegeId == restrictedCollegeId.Value);
var courses = db.Courses.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.CollegeId == restrictedCollegeId.Value);
var teachingTasks = db.TeachingTasks.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.Course!.CollegeId == restrictedCollegeId.Value));
var gradeSheets = db.GradeSheets.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.TeachingTask!.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask.Course!.CollegeId ==
restrictedCollegeId.Value));
var enrollments = db.CourseEnrollments.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!
.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.CourseSelectionOffering.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var taskCount = await teachingTasks.CountAsync(cancellationToken);
var publishedTaskCount = await teachingTasks.CountAsync(
x => x.Status == TeachingTaskStatus.Published,
cancellationToken);
var scheduledTaskCount = currentTermId.HasValue
? await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlan!.AcademicTermId == currentTermId.Value &&
x.SchedulePlan.Status == SchedulePlanStatus.Published &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value))
.Select(x => x.TeachingTaskId)
.Distinct()
.CountAsync(cancellationToken)
: 0;
var gradeSheetCount = await gradeSheets.CountAsync(cancellationToken);
var publishedGradeSheetCount = await gradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Published,
cancellationToken);
var counts = new DashboardCounts(
await students.CountAsync(
x => x.Status == StudentStatus.Active,
cancellationToken),
await teachers.CountAsync(
x => x.Status == TeacherStatus.Active,
cancellationToken),
await courses.CountAsync(
x => x.IsEnabled,
cancellationToken),
taskCount,
publishedTaskCount,
scheduledTaskCount,
await enrollments.CountAsync(cancellationToken),
gradeSheetCount,
publishedGradeSheetCount,
await gradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Submitted,
cancellationToken),
currentTermId.HasValue
? await db.CourseSelectionRounds.AsNoTracking().CountAsync(
x => x.AcademicTermId == currentTermId.Value &&
x.Status == CourseSelectionRoundStatus.Open,
cancellationToken)
: 0);
var pending = await LoadPendingAsync(
scope,
restrictedCollegeId,
cancellationToken);
return Ok(new DashboardResponse(
BuildAudience(scope, collegeName),
currentTerm,
counts,
pending,
DateTime.UtcNow));
}
private async Task<DashboardPending> LoadPendingAsync(
CurrentUserScope scope,
Guid? restrictedCollegeId,
CancellationToken cancellationToken)
{
var isSchoolManager =
scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin);
var isCollegeManager =
!isSchoolManager && scope.IsInRole(SystemRoles.CollegeAdmin);
if (!isSchoolManager && !isCollegeManager)
return new DashboardPending(0, 0, 0, 0, 0, 0, 0);
var teacherApplications = db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.Status == TeacherCourseApplicationStatus.Pending &&
(!restrictedCollegeId.HasValue ||
x.Teacher!.CollegeId == restrictedCollegeId.Value));
var gradeSheets = db.GradeSheets.AsNoTracking()
.Where(x =>
x.Status == GradeSheetStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var courseAdjustments = db.CourseAdjustments.AsNoTracking()
.Where(x =>
x.Status == CourseAdjustmentStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var studentStatusChanges = db.StudentStatusChanges.AsNoTracking()
.Where(x =>
x.State == (isCollegeManager
? StudentStatusChangeState.CounselorApproved
: StudentStatusChangeState.CollegeApproved) &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value));
var gradeModifications = db.GradeModifications.AsNoTracking()
.Where(x =>
x.Status == (isCollegeManager
? GradeModificationStatus.TeacherSubmitted
: GradeModificationStatus.CollegeApproved) &&
(!restrictedCollegeId.HasValue ||
x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var generalApprovals =
await db.CourseExemptions.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.DeferredExams.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.CourseSubstitutions.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.AttendanceRecords.AsNoTracking().CountAsync(
x => x.AppealStatus == AttendanceAppealStatus.Pending &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken);
var classroomReservations = isCollegeManager &&
restrictedCollegeId.HasValue
? await db.ClassroomReservations.AsNoTracking().CountAsync(
x => x.Status == ClassroomReservationStatus.Submitted &&
x.ApplicantCollegeId == restrictedCollegeId.Value,
cancellationToken)
: 0;
return new DashboardPending(
await teacherApplications.CountAsync(cancellationToken),
await gradeSheets.CountAsync(cancellationToken),
await courseAdjustments.CountAsync(cancellationToken),
await studentStatusChanges.CountAsync(cancellationToken),
await gradeModifications.CountAsync(cancellationToken),
classroomReservations,
generalApprovals);
}
private static DashboardAudience BuildAudience(
CurrentUserScope scope,
string? collegeName)
{
if (scope.IsInRole(SystemRoles.SuperAdmin))
return new DashboardAudience(
"System",
"全域教务工作台",
"全校",
"统筹基础数据、教学运行与系统治理");
if (scope.IsInRole(SystemRoles.AcademicAdmin))
return new DashboardAudience(
"School",
"校级教务工作台",
"全校",
"聚焦跨学院教学运行与校级审核");
if (scope.IsInRole(SystemRoles.CollegeAdmin))
return new DashboardAudience(
"College",
"学院教务工作台",
collegeName ?? "本学院",
"聚焦本学院教学准备、过程审核与成绩归档");
if (scope.IsInRole(SystemRoles.Leader))
return new DashboardAudience(
"Leadership",
"教学运行观察台",
"全校",
"查看全校教学运行与质量数据");
if (scope.IsInRole(SystemRoles.Counselor))
return new DashboardAudience(
"Counselor",
"班级工作台",
collegeName ?? "所辖班级",
"处理学生过程管理与学业支持");
return new DashboardAudience(
"Teaching",
"教学工作台",
collegeName ?? "个人教学",
"查看课程运行并进入日常教学工作");
}
}
public sealed record DashboardResponse(
DashboardAudience Audience,
DashboardTerm? CurrentTerm,
DashboardCounts Counts,
DashboardPending Pending,
DateTime GeneratedAt);
public sealed record DashboardAudience(
string Level,
string Title,
string ScopeName,
string Description);
public sealed record DashboardTerm(
Guid Id,
string Name,
DateOnly StartDate,
DateOnly EndDate);
public sealed record DashboardCounts(
int Students,
int Teachers,
int Courses,
int TeachingTasks,
int PublishedTeachingTasks,
int ScheduledTeachingTasks,
int CourseEnrollments,
int GradeSheets,
int PublishedGradeSheets,
int SubmittedGradeSheets,
int OpenCourseSelectionRounds);
public sealed record DashboardPending(
int TeacherApplications,
int GradeSheets,
int CourseAdjustments,
int StudentStatusChanges,
int GradeModifications,
int ClassroomReservations,
int GeneralApprovals);
@@ -60,8 +60,16 @@ public sealed class DegreeAwardsController(
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
public async Task<ActionResult> GetBatch(
Guid id,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? keyword = null,
[FromQuery] DegreeAwardConclusion? conclusion = null,
CancellationToken token = default)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 10, 100);
var batch = await db.DegreeAwardBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
@@ -71,6 +79,52 @@ public sealed class DegreeAwardsController(
if (collegeId.HasValue)
source = source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
var summary = await source
.GroupBy(_ => 1)
.Select(group => new
{
ResultCount = group.Count(),
GrantedCount = group.Count(x =>
x.Conclusion == DegreeAwardConclusion.Granted),
NotGrantedCount = group.Count(x =>
x.Conclusion == DegreeAwardConclusion.NotGranted),
OverrideCount = group.Count(x => x.IsOverridden)
})
.FirstOrDefaultAsync(token);
var normalizedKeyword = keyword?.Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
source = source.Where(x =>
x.Student!.StudentNumber.Contains(normalizedKeyword) ||
x.Student.Name.Contains(normalizedKeyword) ||
x.Student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
x.Student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
if (conclusion.HasValue)
source = source.Where(x => x.Conclusion == conclusion.Value);
var total = await source.CountAsync(token);
var items = await source.OrderBy(x => x.Student!.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.AverageGradePoint,
x.CalculatedConclusion,
x.Conclusion,
x.ExceptionReason,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token);
return Ok(new
{
batch.Id,
@@ -82,24 +136,17 @@ public sealed class DegreeAwardsController(
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
Results = await source.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.AverageGradePoint,
x.CalculatedConclusion,
x.Conclusion,
x.ExceptionReason,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token)
ResultCount = summary?.ResultCount ?? 0,
GrantedCount = summary?.GrantedCount ?? 0,
NotGrantedCount = summary?.NotGrantedCount ?? 0,
OverrideCount = summary?.OverrideCount ?? 0,
Results = new
{
Items = items,
Total = total,
Page = page,
PageSize = pageSize
}
});
}
@@ -133,25 +180,30 @@ public sealed class DegreeAwardsController(
return ConflictProblem("已发布批次不能重新计算。");
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
.Include(x => x.GraduationAuditBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new
{
x.Id,
x.StudentId,
StudentStatus = x.Student!.Status
})
.ToListAsync(token);
var audits = auditCandidates
.GroupBy(x => x.StudentId)
.Select(x => x.First())
.ToList();
var studentIds = audits.Select(x => x.StudentId).ToArray();
var gradePoints = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue)
.WhereIn(studentIds, x => x.StudentId)
.Where(x => db.GraduationAuditResults.Any(audit =>
audit.StudentId == x.StudentId &&
audit.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
audit.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
audit.Conclusion == GraduationAuditConclusion.Eligible))
.Select(x => new
{
x.StudentId,
@@ -159,14 +211,17 @@ public sealed class DegreeAwardsController(
x.GradeSheet!.TeachingTask!.Course!.Credits
}).ToListAsync(token);
var oldResults = await db.DegreeAwardResults
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
db.DegreeAwardResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
var gradePointsByStudent = gradePoints
.GroupBy(x => x.StudentId)
.ToDictionary(group => group.Key, group => group.ToList());
await db.DegreeAwardResults
.Where(x => x.DegreeAwardBatchId == id)
.ExecuteDeleteAsync(token);
foreach (var audit in audits)
{
var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList();
var grades = gradePointsByStudent.GetValueOrDefault(audit.StudentId) ?? [];
var credits = grades.Sum(x => x.Credits);
var average = credits == 0
? 0
@@ -175,8 +230,8 @@ public sealed class DegreeAwardsController(
2,
MidpointRounding.AwayFromZero);
var conclusion = DegreeAwardRules.Evaluate(
true, audit.Student!.Status, average, batch.MinimumGradePoint);
var reason = audit.Student.Status != StudentStatus.Graduated
true, audit.StudentStatus, average, batch.MinimumGradePoint);
var reason = audit.StudentStatus != StudentStatus.Graduated
? "学籍状态尚未转为毕业"
: average < batch.MinimumGradePoint
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,945 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/experiment-grades")]
public sealed class ExperimentGradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
private const string Reviewers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string Publishers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet("management")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetManagement(
Guid? academicTermId,
ExperimentGradeSheetStatus? status,
CancellationToken cancellationToken)
{
var source = ScopedProjects().AsNoTracking()
.Where(x =>
x.Status == ExperimentProjectStatus.Published ||
x.Status == ExperimentProjectStatus.Closed);
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x =>
x.GradeSheet != null &&
x.GradeSheet.Status == status.Value);
return Ok(await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.ArrangementMode,
ProjectStatus = x.Status,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
AcademicTermId = x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
Sheet = x.GradeSheet == null
? null
: new
{
x.GradeSheet.Id,
x.GradeSheet.Status,
x.GradeSheet.ContributionWeight,
x.GradeSheet.PassScore,
ItemCount = x.GradeSheet.Items.Count,
StudentCount = x.GradeSheet.Records.Count,
CompletedCount = x.GradeSheet.Records.Count(record =>
record.ParticipationStatus !=
ExperimentParticipationStatus.Pending),
ScoredCount = x.GradeSheet.Records.Count(record =>
record.TotalScore != null),
PassedCount = x.GradeSheet.Records.Count(record =>
record.IsPassed == true),
x.GradeSheet.SubmittedAt,
x.GradeSheet.ReviewedAt,
x.GradeSheet.PublishedAt
}
})
.ToListAsync(cancellationToken));
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.ExperimentGradeSheet!.Status ==
ExperimentGradeSheetStatus.Published)
.OrderByDescending(x =>
x.ExperimentGradeSheet!.PublishedAt)
.ThenBy(x =>
x.ExperimentGradeSheet!.ExperimentProject!.Code)
.Select(x => new
{
x.Id,
x.ExperimentGradeSheetId,
ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
ProjectCode =
x.ExperimentGradeSheet.ExperimentProject!.Code,
ProjectName =
x.ExperimentGradeSheet.ExperimentProject.Name,
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.PassScore,
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
Items = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.ExperimentGradeItem.Kind,
score.ExperimentGradeItem.Weight,
score.Score,
score.Comment
}),
x.ExperimentGradeSheet.PublishedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("sheets")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSheet(
ExperimentGradeSheetRequest request,
CancellationToken cancellationToken)
{
var items = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
items))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x =>
x.Id == request.ExperimentProjectId,
cancellationToken);
if (project is null) return NotFound();
if (project.Status is not (
ExperimentProjectStatus.Published or
ExperimentProjectStatus.Closed))
return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
if (!CanInitialize(project.TeachingTask!)) return Forbid();
if (await db.ExperimentGradeSheets.AnyAsync(x =>
x.ExperimentProjectId == project.Id,
cancellationToken))
return ConflictProblem("该实验项目已经建立成绩单。");
var participants = await LoadParticipantsAsync(
project,
cancellationToken);
if (participants.Count == 0)
return ConflictProblem("实验项目当前没有可评分学生。");
var sheet = new ExperimentGradeSheet
{
ExperimentProjectId = project.Id,
ContributionWeight = request.ContributionWeight,
PassScore = request.PassScore,
Items = items,
Records = participants.Select(participant =>
new ExperimentGradeRecord
{
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId,
ItemScores = items.Select(item =>
new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
}).ToList()
}).ToList()
};
db.ExperimentGradeSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
}
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetSheet(
Guid id,
int recordPage = 1,
int recordPageSize = 50,
string? studentKeyword = null,
CancellationToken cancellationToken = default)
{
recordPage = Math.Max(1, recordPage);
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
? null
: studentKeyword.Trim();
var sheet = await ScopedSheets().AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.ExperimentProjectId,
ProjectCode = x.ExperimentProject!.Code,
ProjectName = x.ExperimentProject.Name,
x.ExperimentProject.ArrangementMode,
x.ExperimentProject.TeachingTaskId,
x.ExperimentProject.TeachingTask!.TaskNumber,
TaskName = x.ExperimentProject.TeachingTask.Name,
AcademicTermId =
x.ExperimentProject.TeachingTask.AcademicTermId,
TermName = x.ExperimentProject.TeachingTask
.AcademicTerm!.Name,
CourseCollegeId = x.ExperimentProject.TeachingTask
.Course!.CollegeId,
CourseCollegeName = x.ExperimentProject.TeachingTask
.Course.College!.Name,
CourseCode =
x.ExperimentProject.TeachingTask.Course.Code,
CourseName =
x.ExperimentProject.TeachingTask.Course.Name,
TeacherNames = x.ExperimentProject.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.ContributionWeight,
x.PassScore,
Items = x.Items.OrderBy(item => item.SortOrder)
.Select(item => new
{
item.Id,
item.Name,
item.Kind,
item.Weight
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.PublishedAt,
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
.Where(x => x.ExperimentGradeSheetId == id);
if (studentKeyword is not null)
recordsSource = recordsSource.Where(x =>
x.Student!.StudentNumber.Contains(studentKeyword) ||
x.Student.Name.Contains(studentKeyword) ||
x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
var recordTotal = await recordsSource.CountAsync(cancellationToken);
var records = await recordsSource
.OrderBy(x => x.Student!.StudentNumber)
.Skip((recordPage - 1) * recordPageSize)
.Take(recordPageSize)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
x.ExperimentSessionId,
Session = x.ExperimentSession == null
? null
: new
{
x.ExperimentSession.SessionDate,
x.ExperimentSession.StartPeriod,
x.ExperimentSession.PeriodCount,
ClassroomName =
x.ExperimentSession.Classroom!.Name
},
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
ItemScores = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.Score,
score.Comment
}),
x.UpdatedAt
})
.ToListAsync(cancellationToken);
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.SingleAsync(
x => x.Id == sheet.TeachingTaskId,
cancellationToken);
var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
sheet.Status ==
ExperimentGradeSheetStatus.Submitted;
return Ok(new
{
Sheet = new
{
sheet.Id,
sheet.ExperimentProjectId,
sheet.ProjectCode,
sheet.ProjectName,
sheet.ArrangementMode,
sheet.TeachingTaskId,
sheet.TaskNumber,
sheet.TaskName,
sheet.AcademicTermId,
sheet.TermName,
sheet.CourseCollegeName,
sheet.CourseCode,
sheet.CourseName,
sheet.TeacherNames,
sheet.ContributionWeight,
sheet.PassScore,
sheet.Items,
sheet.Status,
sheet.ReviewComment,
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
RecordTotal = recordTotal,
RecordPage = recordPage,
RecordPageSize = recordPageSize,
Records = records,
sheet.CreatedAt,
sheet.UpdatedAt
},
CanEdit = CanEdit(task) &&
sheet.Status is
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned,
CanReview = canReview,
CanPublish = IsPublisher() &&
sheet.Status == ExperimentGradeSheetStatus.Approved
});
}
[HttpPut("sheets/{id:guid}/settings")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateSettings(
Guid id,
ExperimentGradeSettingsRequest request,
CancellationToken cancellationToken)
{
var replacementItems = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
replacementItems))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("当前状态不能修改实验评分方案。");
sheet.ContributionWeight = request.ContributionWeight;
sheet.PassScore = request.PassScore;
db.ExperimentGradeItems.RemoveRange(sheet.Items);
foreach (var record in sheet.Records)
{
record.ItemScores.Clear();
record.TotalScore = null;
record.IsPassed = null;
}
sheet.Items = replacementItems;
foreach (var record in sheet.Records)
foreach (var item in replacementItems)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPut("sheets/{id:guid}/records")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateRecords(
Guid id,
ExperimentGradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能继续修改。");
var records = sheet.Records.ToDictionary(x => x.Id);
if (request.Records.Select(x => x.Id).Distinct().Count() !=
request.Records.Count ||
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的实验成绩记录。");
var itemIds = sheet.Items.Select(x => x.Id).ToHashSet();
foreach (var item in request.Records)
{
if (item.AttemptNumber is < 1 or > 20)
return ValidationProblem("实验尝试次数应在 1—20 次之间。");
if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
.Distinct().Count() != item.ItemScores.Count ||
item.ItemScores.Any(x =>
!itemIds.Contains(x.ExperimentGradeItemId) ||
!ValidScore(x.Score)))
return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");
var record = records[item.Id];
record.ParticipationStatus = item.ParticipationStatus;
record.SafetyViolation = item.SafetyViolation;
record.AttemptNumber = item.AttemptNumber;
record.SubmissionReference =
Normalize(item.SubmissionReference);
record.SubmittedAt = item.SubmittedAt;
record.IsLate = item.IsLate;
record.TeacherComment = Normalize(item.TeacherComment);
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
foreach (var score in item.ItemScores)
{
var target = scores[score.ExperimentGradeItemId];
target.Score = score.Score;
target.Comment = Normalize(score.Comment);
}
Recalculate(sheet, record);
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/sync-participants")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SyncParticipants(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能同步参与学生。");
var expected = await LoadParticipantsAsync(
sheet.ExperimentProject,
cancellationToken);
var expectedByStudent = expected.ToDictionary(x => x.StudentId);
var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
var added = 0;
var removed = 0;
var retained = 0;
var recordsToRemove = new List<ExperimentGradeRecord>();
var recordsToAdd = new List<ExperimentGradeRecord>();
foreach (var participant in expected)
{
if (existingByStudent.TryGetValue(
participant.StudentId,
out var record))
{
if (record.ParticipationStatus ==
ExperimentParticipationStatus.Pending &&
record.ItemScores.All(x => !x.Score.HasValue))
record.ExperimentSessionId = participant.SessionId;
retained++;
continue;
}
var newRecord = new ExperimentGradeRecord
{
ExperimentGradeSheetId = sheet.Id,
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId
};
db.ExperimentGradeRecords.Add(newRecord);
recordsToAdd.Add(newRecord);
added++;
}
foreach (var record in sheet.Records
.Where(x => !expectedByStudent.ContainsKey(x.StudentId))
.ToList())
{
if (record.ParticipationStatus !=
ExperimentParticipationStatus.Pending ||
record.ItemScores.Any(x => x.Score.HasValue))
{
retained++;
continue;
}
db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
recordsToRemove.Add(record);
removed++;
}
if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
{
await db.SaveChangesAsync(cancellationToken);
foreach (var record in recordsToRemove)
sheet.Records.Remove(record);
db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
}
foreach (var record in recordsToAdd)
foreach (var item in sheet.Items)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
Added = added,
Removed = removed,
Retained = retained,
Total = await db.ExperimentGradeRecords.CountAsync(
x => x.ExperimentGradeSheetId == sheet.Id,
cancellationToken)
});
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Submit(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("只有草稿或已退回实验成绩单可以提交。");
if (sheet.Records.Count == 0)
return ConflictProblem("实验成绩单没有学生记录。");
foreach (var record in sheet.Records) Recalculate(sheet, record);
if (sheet.Records.Any(x =>
x.ParticipationStatus ==
ExperimentParticipationStatus.Pending))
return ConflictProblem("仍有学生未登记实验参与状态。");
if (sheet.Records.Any(x =>
x.ParticipationStatus is
ExperimentParticipationStatus.Completed or
ExperimentParticipationStatus.Makeup &&
!x.TotalScore.HasValue))
return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");
sheet.Status = ExperimentGradeSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"实验成绩待审核",
$"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。",
sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
[HttpPost("sheets/{id:guid}/approve")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以通过审核。");
sheet.Status = ExperimentGradeSheetStatus.Approved;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩审核通过",
$"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/return")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Return(
Guid id,
ExperimentGradeReviewRequest request,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以退回。");
if (string.IsNullOrWhiteSpace(request.Comment))
return ValidationProblem("退回时必须填写修改意见。");
sheet.Status = ExperimentGradeSheetStatus.Returned;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = request.Comment.Trim();
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩被退回",
$"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/publish")]
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!IsPublisher()) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Approved)
return ConflictProblem("只有审核通过的实验成绩单可以发布。");
sheet.Status = ExperimentGradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var userIds = await db.ExperimentGradeRecords
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验成绩已发布",
$"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
Guid id,
CancellationToken cancellationToken)
{
if (!await ScopedSheets().AnyAsync(
x => x.Id == id,
cancellationToken))
return null;
return await db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
if (project.ArrangementMode ==
ExperimentArrangementMode.Centralized)
{
return await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.AsNoTracking()
.OrderBy(x => x.StudentNumber)
.Select(x => new ExperimentParticipantSeed(x.Id, null))
.ToListAsync(cancellationToken);
}
return await db.ExperimentBookings.AsNoTracking()
.Where(x =>
x.ExperimentProjectId == project.Id &&
x.Status == ExperimentBookingStatus.Booked &&
x.Student!.Status == StudentStatus.Active)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new ExperimentParticipantSeed(
x.StudentId,
x.ExperimentSessionId))
.ToListAsync(cancellationToken);
}
private static List<ExperimentGradeItem> BuildItems(
IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
requests?.Select((item, index) => new ExperimentGradeItem
{
Name = item.Name.Trim(),
Kind = item.Kind,
Weight = item.Weight,
SortOrder = index
}).ToList() ?? [];
private static void Recalculate(
ExperimentGradeSheet sheet,
ExperimentGradeRecord record)
{
record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
record,
sheet.Items.ToList());
record.IsPassed = record.TotalScore.HasValue
? record.TotalScore.Value >= sheet.PassScore &&
!record.SafetyViolation
: null;
}
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x => x.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private IQueryable<ExperimentProject> ScopedProjects()
{
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
return db.ExperimentProjects.Where(x =>
taskIds.Contains(x.TeachingTaskId));
}
private IQueryable<ExperimentGradeSheet> ScopedSheets()
{
var projectIds = ScopedProjects().Select(x => x.Id);
return db.ExperimentGradeSheets.Where(x =>
projectIds.Contains(x.ExperimentProjectId));
}
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
IsAssignedTeacher(task);
private bool CanEdit(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
IsAssignedTeacher(task);
private bool IsAssignedTeacher(TeachingTask task) =>
task.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
private bool CanReviewCollege(Guid collegeId)
{
var scope = currentUserDataScope.Current;
if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
!scope.IsInRole(SystemRoles.AcademicAdmin) &&
!scope.IsInRole(SystemRoles.CollegeAdmin))
return false;
return scope.Scope == DataScope.All ||
scope.RestrictedCollegeId == collegeId;
}
private bool IsPublisher() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x =>
x.UserId == currentUserDataScope.Current.UserId &&
x.Status == StudentStatus.Active,
cancellationToken);
private async Task NotifyTeachersAsync(
Guid teachingTaskId,
string title,
string content,
CancellationToken cancellationToken)
{
var userIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == teachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
title,
content,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
}
private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100;
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成实验成绩操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private sealed record ExperimentParticipantSeed(
Guid StudentId,
Guid? SessionId);
}
public sealed record ExperimentGradeSheetRequest(
Guid ExperimentProjectId,
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeSettingsRequest(
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeItemRequest(
[Required, MaxLength(60)] string Name,
ExperimentGradeItemKind Kind,
[Range(typeof(decimal), "0.1", "100")] decimal Weight);
public sealed record ExperimentGradeRecordsRequest(
IReadOnlyCollection<ExperimentGradeRecordRequest> Records);
public sealed record ExperimentGradeRecordRequest(
Guid Id,
ExperimentParticipationStatus ParticipationStatus,
bool SafetyViolation,
[Range(1, 20)] int AttemptNumber,
[MaxLength(500)] string? SubmissionReference,
DateTime? SubmittedAt,
bool IsLate,
[MaxLength(500)] string? TeacherComment,
IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);
public sealed record ExperimentGradeItemScoreRequest(
Guid ExperimentGradeItemId,
decimal? Score,
[MaxLength(300)] string? Comment);
public sealed record ExperimentGradeReviewRequest(
[MaxLength(500)] string? Comment);
File diff suppressed because it is too large Load Diff
+203 -56
View File
@@ -1,11 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -40,18 +42,38 @@ public sealed class GradesController(
public async Task<ActionResult> GetSheets(
Guid? academicTermId,
GradeSheetStatus? status,
CancellationToken cancellationToken)
string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 50);
var source = AccessibleTasks().AsNoTracking()
.Where(x =>
x.Status == TeachingTaskStatus.Published ||
x.Status == TeachingTaskStatus.Closed);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x => db.GradeSheets.Any(sheet =>
sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.TaskNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.Course!.Code.Contains(keyword) ||
x.Course.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
@@ -79,7 +101,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 =>
@@ -95,10 +119,7 @@ public sealed class GradesController(
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
if (status.HasValue)
items = items.Where(x => x.Sheet?.Status == status.Value).ToList();
return Ok(items);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost("sheets")]
@@ -134,15 +155,13 @@ public sealed class GradesController(
cancellationToken))
return ConflictProblem("该教学班已经建立成绩单。");
var studentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
.Select(x => x.StudentId)
.Distinct()
var studentIds = await TeachingTaskRosterQuery
.ForTask(db, task.Id)
.AsNoTracking()
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (studentIds.Count == 0)
return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。");
return ConflictProblem("教学班当前没有有效学生,无法建立成绩单。");
var sheet = new GradeSheet
{
@@ -165,8 +184,18 @@ public sealed class GradesController(
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
public async Task<ActionResult> GetSheet(
Guid id,
int recordPage = 1,
int recordPageSize = 50,
string? studentKeyword = null,
CancellationToken cancellationToken = default)
{
recordPage = Math.Max(1, recordPage);
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
? null
: studentKeyword.Trim();
var sheet = await AccessibleSheets().AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
@@ -193,44 +222,58 @@ public sealed class GradesController(
{
item.Id,
item.Name,
item.Weight
item.Weight,
item.SourceType,
item.SourceSnapshotAt
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.PublishedAt,
Records = x.Records
.OrderBy(record => record.Student!.StudentNumber)
.Select(record => new
{
record.Id,
record.StudentId,
record.Student!.StudentNumber,
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore,
record.GradePoint,
record.ExamStatus,
record.Notes,
record.UpdatedAt
}),
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var recordsSource = db.GradeRecords.AsNoTracking()
.Where(record => record.GradeSheetId == id);
if (studentKeyword is not null)
recordsSource = recordsSource.Where(record =>
record.Student!.StudentNumber.Contains(studentKeyword) ||
record.Student.Name.Contains(studentKeyword) ||
record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
var recordTotal = await recordsSource.CountAsync(cancellationToken);
var records = await recordsSource
.OrderBy(record => record.Student!.StudentNumber)
.Skip((recordPage - 1) * recordPageSize)
.Take(recordPageSize)
.Select(record => new
{
record.Id,
record.StudentId,
record.Student!.StudentNumber,
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore,
record.GradePoint,
record.ExamStatus,
record.Notes,
record.UpdatedAt
})
.ToListAsync(cancellationToken);
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
@@ -244,7 +287,36 @@ public sealed class GradesController(
return Ok(new
{
Sheet = sheet,
Sheet = new
{
sheet.Id,
sheet.TeachingTaskId,
sheet.TaskNumber,
sheet.TaskName,
sheet.AcademicTermId,
sheet.TermName,
sheet.CourseCode,
sheet.CourseName,
sheet.CourseCollegeId,
sheet.CourseCollegeName,
sheet.Credits,
sheet.TeacherNames,
sheet.ClassNames,
sheet.RegularWeight,
sheet.FinalWeight,
sheet.Items,
sheet.Status,
sheet.ReviewComment,
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
RecordTotal = recordTotal,
RecordPage = recordPage,
RecordPageSize = recordPageSize,
Records = records,
sheet.CreatedAt,
sheet.UpdatedAt
},
CanEdit = CanEditScores(task) &&
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
@@ -340,7 +412,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) ||
@@ -360,8 +432,19 @@ public sealed class GradesController(
{
if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
existing.Score = scoreEntry.Score;
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;
}
}
@@ -370,6 +453,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)
@@ -411,7 +554,8 @@ public sealed class GradesController(
$"《{courseName}》成绩已提交,请及时审核。",
collegeId,
"/grades",
cancellationToken);
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
@@ -452,7 +596,7 @@ public sealed class GradesController(
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩审核通过",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
"/grades", cancellationToken);
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
@@ -497,7 +641,7 @@ public sealed class GradesController(
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩被退回",
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
"/grades", cancellationToken);
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
@@ -505,7 +649,10 @@ public sealed class GradesController(
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
var sheet = await AccessibleSheets()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(
x => x.Id == id,
cancellationToken);
if (sheet is null) return NotFound();
@@ -514,13 +661,11 @@ public sealed class GradesController(
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
// Notify enrolled students
var studentUserIds = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
x.Status == CourseEnrollmentStatus.Enrolled)
// The grade sheet roster is authoritative at publication time. This also
// covers students added through approved roster corrections.
var studentUserIds = await db.GradeRecords
.Where(x => x.GradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
@@ -528,8 +673,8 @@ public sealed class GradesController(
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"成绩已发布",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
"/grades", cancellationToken);
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
@@ -928,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
Guid GradeItemId,
decimal? Score);
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);
@@ -61,8 +61,16 @@ public sealed class GraduationAuditsController(
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
public async Task<ActionResult> GetBatch(
Guid id,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? keyword = null,
[FromQuery] GraduationAuditConclusion? conclusion = null,
CancellationToken token = default)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 10, 100);
var batch = await db.GraduationAuditBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
@@ -73,6 +81,59 @@ public sealed class GraduationAuditsController(
if (collegeId.HasValue)
results = results.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
var summary = await results
.GroupBy(_ => 1)
.Select(group => new
{
ResultCount = group.Count(),
EligibleCount = group.Count(x =>
x.Conclusion == GraduationAuditConclusion.Eligible),
IneligibleCount = group.Count(x =>
x.Conclusion == GraduationAuditConclusion.Ineligible),
OverrideCount = group.Count(x => x.IsOverridden)
})
.FirstOrDefaultAsync(token);
var normalizedKeyword = keyword?.Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
results = results.Where(x =>
x.Student!.StudentNumber.Contains(normalizedKeyword) ||
x.Student.Name.Contains(normalizedKeyword) ||
x.Student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
x.Student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
if (conclusion.HasValue)
results = results.Where(x => x.Conclusion == conclusion.Value);
var total = await results.CountAsync(token);
var items = await results
.OrderBy(x => x.Student!.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.StudentStatusSnapshot,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits,
x.EarnedCredits,
x.RequiredCourseCount,
x.PassedRequiredCourseCount,
x.FailedCourseCount,
x.MissingCourseNames,
x.CalculatedConclusion,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token);
return Ok(new
{
batch.Id,
@@ -83,31 +144,17 @@ public sealed class GraduationAuditsController(
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
Results = await results
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.StudentStatusSnapshot,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits,
x.EarnedCredits,
x.RequiredCourseCount,
x.PassedRequiredCourseCount,
x.FailedCourseCount,
x.MissingCourseNames,
x.CalculatedConclusion,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token)
ResultCount = summary?.ResultCount ?? 0,
EligibleCount = summary?.EligibleCount ?? 0,
IneligibleCount = summary?.IneligibleCount ?? 0,
OverrideCount = summary?.OverrideCount ?? 0,
Results = new
{
Items = items,
Total = total,
Page = page,
PageSize = pageSize
}
});
}
@@ -156,10 +203,11 @@ public sealed class GraduationAuditsController(
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
x.Status == CurriculumPlanStatus.Published)
.ToListAsync(token);
var studentIds = students.Select(x => x.Id).ToArray();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
.WhereIn(studentIds, x => x.StudentId)
.Where(x => x.Student!.EnrollmentYear == batch.EnrollmentYear &&
(x.Student.Status == StudentStatus.Active ||
x.Student.Status == StudentStatus.Suspended))
.Select(x => new GradeSnapshot(
x.StudentId,
x.GradeSheet!.TeachingTask!.CourseId,
@@ -169,16 +217,23 @@ public sealed class GraduationAuditsController(
x.ExamStatus))
.ToListAsync(token);
var oldResults = await db.GraduationAuditResults
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
db.GraduationAuditResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
var plansByMajor = plans
.GroupBy(x => x.MajorId)
.ToDictionary(group => group.Key, group => group.First());
var gradesByStudent = grades
.GroupBy(x => x.StudentId)
.ToDictionary(group => group.Key, group => group.ToList());
await db.GraduationAuditResults
.Where(x => x.GraduationAuditBatchId == id)
.ExecuteDeleteAsync(token);
foreach (var student in students)
{
var plan = plans.FirstOrDefault(x =>
x.MajorId == student.AdministrativeClass!.MajorId);
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
plansByMajor.TryGetValue(
student.AdministrativeClass!.MajorId,
out var plan);
var studentGrades = gradesByStudent.GetValueOrDefault(student.Id) ?? [];
var passedCourseIds = studentGrades
.Where(IsPassed)
.Select(x => x.CourseId)
@@ -34,31 +34,46 @@ public sealed class GraduationClearanceController(
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
}).ToListAsync(token);
var records = await ScopedRecords().AsNoTracking()
.Select(x => new
var statistics = await ScopedRecords().AsNoTracking()
.GroupBy(x => x.GraduationClearanceItem!.GraduationClearanceBatchId)
.Select(group => new
{
BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId,
x.StudentId, x.Status
}).ToListAsync(token);
BatchId = group.Key,
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
RecordCount = group.Count(),
CompletedCount = group.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
}).ToDictionaryAsync(x => x.BatchId, token);
return Ok(batches.Select(batch =>
{
var scoped = records.Where(x => x.BatchId == batch.Id).ToList();
statistics.TryGetValue(batch.Id, out var summary);
return new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(),
RecordCount = scoped.Count,
CompletedCount = scoped.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
StudentCount = summary?.StudentCount ?? 0,
RecordCount = summary?.RecordCount ?? 0,
CompletedCount = summary?.CompletedCount ?? 0
};
}));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
public async Task<ActionResult> GetBatch(
Guid id,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? keyword = null,
[FromQuery] string? progress = null,
CancellationToken token = default)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 10, 100);
var normalizedProgress = progress?.Trim();
if (normalizedProgress is not null and not "Pending" and not "Completed")
return ValidationProblem("离校办理进度筛选值无效。");
var batch = await db.GraduationClearanceBatches.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
@@ -72,8 +87,56 @@ public sealed class GraduationClearanceController(
})
}).FirstOrDefaultAsync(token);
if (batch is null) return NotFound();
var records = await ScopedRecords().AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
var records = ScopedRecords().AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id);
var summary = await records
.GroupBy(_ => 1)
.Select(group => new
{
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
RecordCount = group.Count(),
CompletedCount = group.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
})
.FirstOrDefaultAsync(token);
var students = db.Students.AsNoTracking()
.Where(student => records.Any(record => record.StudentId == student.Id));
var normalizedKeyword = keyword?.Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
students = students.Where(student =>
student.StudentNumber.Contains(normalizedKeyword) ||
student.Name.Contains(normalizedKeyword) ||
student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
if (normalizedProgress == "Pending")
students = students.Where(student => records.Any(record =>
record.StudentId == student.Id &&
record.GraduationClearanceItem!.IsRequired &&
record.Status == GraduationClearanceRecordStatus.Pending));
else if (normalizedProgress == "Completed")
students = students.Where(student => !records.Any(record =>
record.StudentId == student.Id &&
record.GraduationClearanceItem!.IsRequired &&
record.Status == GraduationClearanceRecordStatus.Pending));
var total = await students.CountAsync(token);
var studentPage = await students
.OrderBy(student => student.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(student => new
{
student.Id,
student.StudentNumber,
student.Name,
ClassName = student.AdministrativeClass!.Name,
MajorName = student.AdministrativeClass.Major!.Name
}).ToListAsync(token);
var studentIds = studentPage.Select(student => student.Id).ToArray();
var pageRecords = await records
.WhereIn(studentIds, x => x.StudentId)
.OrderBy(x => x.Student!.StudentNumber)
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
.Select(x => new
@@ -89,11 +152,32 @@ public sealed class GraduationClearanceController(
x.GraduationClearanceItem.IsRequired,
x.Status, x.Notes, x.CompletedAt
}).ToListAsync(token);
var recordsByStudent = pageRecords
.GroupBy(record => record.StudentId)
.ToDictionary(group => group.Key, group => group.ToList());
var studentItems = studentPage.Select(student => new
{
StudentId = student.Id,
student.StudentNumber,
student.Name,
student.ClassName,
student.MajorName,
Records = recordsByStudent.GetValueOrDefault(student.Id) ?? []
}).ToList();
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
Records = records
StudentCount = summary?.StudentCount ?? 0,
RecordCount = summary?.RecordCount ?? 0,
CompletedCount = summary?.CompletedCount ?? 0,
Students = new
{
Items = studentItems,
Total = total,
Page = page,
PageSize = pageSize
}
});
}
@@ -150,16 +234,15 @@ public sealed class GraduationClearanceController(
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("已关闭批次不能重新生成办理记录。");
var eligibleAudits = await db.GraduationAuditResults.AsNoTracking()
var studentIds = await db.GraduationAuditResults.AsNoTracking()
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible &&
x.Student!.Status == StudentStatus.Graduated)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new { x.StudentId })
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(token);
var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray();
var existing = await db.GraduationClearanceRecords.AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
@@ -180,7 +263,7 @@ public sealed class GraduationClearanceController(
created++;
}
await db.SaveChangesAsync(token);
return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created });
return Ok(new { StudentCount = studentIds.Count, CreatedRecordCount = created });
}
[HttpPut("records/{id:guid}")]
@@ -221,15 +304,17 @@ public sealed class GraduationClearanceController(
public async Task<ActionResult> Close(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches
.Include(x => x.Items)
.ThenInclude(x => x.Records)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("该批次已经关闭。");
var records = batch.Items.SelectMany(item => item.Records.Select(record =>
(item.IsRequired, record.Status)));
if (!GraduationClearanceRules.CanClose(records))
var hasIncompleteRequiredItem = await db.GraduationClearanceRecords
.AnyAsync(record =>
record.GraduationClearanceItem!.GraduationClearanceBatchId == id &&
record.GraduationClearanceItem.IsRequired &&
record.Status == GraduationClearanceRecordStatus.Pending,
token);
if (hasIncompleteRequiredItem)
return ConflictProblem("仍有必办离校事项尚未完成。");
batch.Status = GraduationClearanceBatchStatus.Closed;
batch.ClosedAt = DateTime.UtcNow;
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Grades;
@@ -17,9 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class MakeupExamsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService,
MakeupExamArrangementService arrangementService,
MakeupExamAutoJobQueue autoJobQueue) : 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 =>
@@ -224,6 +279,81 @@ public sealed class MakeupExamsController(
return await SaveAsync(session.Id, true, cancellationToken);
}
[HttpPost("plans/{planId:guid}/sessions/batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSessionsBatch(
Guid planId,
CreateMakeupExamSessionsBatchRequest request,
CancellationToken cancellationToken)
{
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)
return ConflictProblem("已发布的补考计划不能调整场次。");
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
if (taskIds.Length == 0)
return ValidationProblem("请至少选择一个教学班。");
if (taskIds.Length > 100)
return ValidationProblem("一次最多选择100个教学班。");
var timeResult = ResolveExamTime(plan.AcademicTermId,
request.ExamDate, request.StartPeriod, request.PeriodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var validTaskIds = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (validTaskIds.Count != taskIds.Length)
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId)
.WhereIn(taskIds, x => x.TeachingTaskId)
.Select(x => x.TeachingTaskId)
.ToListAsync(cancellationToken);
if (duplicateTaskIds.Count > 0)
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。");
foreach (var taskId in taskIds)
{
var validation = await ValidateSessionAsync(plan, null,
taskId, null, null, startsAt, endsAt, cancellationToken);
if (validation is not null) return validation;
}
db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession
{
MakeupExamPlanId = planId,
TeachingTaskId = taskId,
ExamDate = request.ExamDate,
StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes)
}));
try
{
await db.SaveChangesAsync(cancellationToken);
return Ok(new { createdCount = taskIds.Length });
}
catch (DbUpdateException)
{
return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。");
}
}
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateSession(
@@ -232,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)
@@ -258,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);
@@ -275,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();
@@ -292,12 +427,121 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)]
public async Task<ActionResult> AutoArrange(
Guid planId,
ExamAutoArrangeRequest? request,
CancellationToken cancellationToken)
{
var result = await arrangementService.ArrangeAsync(planId, cancellationToken);
if (!result.Success)
return ConflictProblem(result.Message);
return Ok(new { message = result.Message });
request ??= new ExamAutoArrangeRequest();
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 (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));
}
// ═══════════════════════════════════════════
@@ -315,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()
@@ -336,10 +582,12 @@ public sealed class MakeupExamsController(
MakeupExamPlanId = planId
};
db.MakeupExamAutoJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.MakeupExamAuto,
job.Id));
await db.SaveChangesAsync(cancellationToken);
autoJobQueue.Enqueue(job.Id);
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
new { jobId = job.Id, status = job.Status.ToString() });
}
@@ -553,19 +801,43 @@ public sealed class MakeupExamsController(
x.TeachingTask!.TaskNumber,
CourseName = x.TeachingTask.Course!.Name,
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
x.StartsAt,
Students = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.MakeupScore
}).ToList()
x.StartsAt
}).FirstOrDefaultAsync(cancellationToken);
return session is null ? NotFound() : Ok(session);
if (session is null) return NotFound();
var students = await db.MakeupExamEnrollments.AsNoTracking()
.Where(e => e.MakeupExamSessionId == id)
.OrderBy(e => e.Student!.StudentNumber)
.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.MakeupScore
})
.ToListAsync(cancellationToken);
return Ok(new
{
session.Id,
session.TeachingTaskId,
session.TaskNumber,
session.CourseName,
session.ClassroomName,
session.StartsAt,
Students = students.Select((student, index) => new
{
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName,
student.Reason,
student.MakeupScore,
SeatNumber = (index + 1).ToString("D3")
})
});
}
// ═══════════════════════════════════════════
@@ -697,7 +969,7 @@ public sealed class MakeupExamsController(
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
var schedule = await db.MakeupExamEnrollments.AsNoTracking()
.Where(x => x.StudentId == studentId &&
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
.OrderBy(x => x.MakeupExamSession!.ExamDate)
@@ -722,7 +994,49 @@ public sealed class MakeupExamsController(
x.MakeupScore,
IsMakeup = true
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
if (schedule.Count == 0) return Ok(schedule);
var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray();
var seatRows = await db.MakeupExamEnrollments.AsNoTracking()
.WhereIn(sessionIds, x => x.MakeupExamSessionId)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.MakeupExamSessionId,
x.StudentId,
x.Student!.StudentNumber
})
.ToListAsync(cancellationToken);
var seatNumbers = seatRows
.GroupBy(x => x.MakeupExamSessionId)
.SelectMany(group => group.Select((row, index) => new
{
row.MakeupExamSessionId,
row.StudentId,
SeatNumber = (index + 1).ToString("D3")
}))
.ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber);
return Ok(schedule.Select(x => new
{
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.Reason,
x.MakeupScore,
SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)),
x.IsMakeup
}));
}
if (scope.IsInRole(SystemRoles.Teacher))
{
@@ -895,6 +1209,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)
{
@@ -917,6 +1312,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;
}
// ═══════════════════════════════════════════
@@ -935,10 +1334,21 @@ 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);
public sealed record CreateMakeupExamSessionsBatchRequest(
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
DateOnly ExamDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount,
[MaxLength(500)] string? Notes);
public sealed record EnrollStudentsRequest(
[Required] IReadOnlyCollection<Guid> StudentIds);
@@ -1,4 +1,7 @@
using System.Security.Claims;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Net;
using System.Text.RegularExpressions;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -14,20 +17,43 @@ namespace Jiaowu.Api.Controllers;
[Route("api/notifications")]
public sealed class NotificationsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
ILogger<NotificationsController>? logger = null) : ControllerBase
{
private const int MaximumSelectedRecipients = 500;
private const int NotificationBatchSize = 300;
private const string Senders =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Counselor + "," +
SystemRoles.Teacher;
[HttpGet]
public async Task<ActionResult> GetNotifications(
bool? unreadOnly,
NotificationCategory? category,
string? keyword,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var userId = currentUserDataScope.Current.UserId;
var source = db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId);
if (unreadOnly == true)
source = source.Where(x => !x.IsRead);
if (category.HasValue)
source = source.Where(x => x.Category == category);
if (!string.IsNullOrWhiteSpace(keyword))
{
var value = keyword.Trim();
source = source.Where(x =>
x.Title.Contains(value) ||
x.Content.Contains(value));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
@@ -36,14 +62,38 @@ public sealed class NotificationsController(
.Take(pageSize)
.Select(x => new
{
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
x.Id,
x.Title,
x.Content,
x.Category,
x.IsRead,
x.LinkUrl,
SenderName = x.MessageDispatch == null
? "教务系统"
: x.MessageDispatch.SenderName,
AudienceName = x.MessageDispatch == null
? null
: x.MessageDispatch.AudienceName,
IsManual = x.MessageDispatchId != null,
x.CreatedAt
})
.ToListAsync(cancellationToken);
var unreadCount = await db.Notifications
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
var unreadByCategory = await db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId && !x.IsRead)
.GroupBy(x => x.Category)
.Select(x => new { Category = x.Key, Count = x.Count() })
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, UnreadCount = unreadCount });
return Ok(new
{
Items = items,
Total = total,
UnreadCount = unreadCount,
UnreadByCategory = unreadByCategory
});
}
[HttpGet("unread-count")]
@@ -55,30 +105,802 @@ public sealed class NotificationsController(
return Ok(new { Count = count });
}
[HttpGet("composer")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetComposer(CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
if (IsSchoolAdministrator(scope))
{
return Ok(await BuildAdministratorComposerAsync(
scope,
null,
MessageAudienceType.School,
"全校已启用账号",
cancellationToken));
}
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor))
{
var collegeId = await ResolveSenderCollegeIdAsync(
scope,
cancellationToken);
if (!collegeId.HasValue)
{
return ConflictProblem(
"当前账号未关联学院,暂时无法确定发信范围。");
}
var college = await db.Colleges.AsNoTracking()
.Where(x => x.Id == collegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken);
if (college is null)
return ConflictProblem("当前账号关联的学院不存在。");
return Ok(await BuildAdministratorComposerAsync(
scope,
collegeId.Value,
MessageAudienceType.College,
$"{college}全院成员",
cancellationToken));
}
if (scope.IsInRole(SystemRoles.Teacher))
{
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(task =>
task.Status != TeachingTaskStatus.Draft &&
task.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId))
.OrderByDescending(task => task.AcademicTerm!.IsCurrent)
.ThenByDescending(task => task.AcademicTerm!.StartDate)
.ThenBy(task => task.TaskNumber)
.Select(task => new
{
task.Id,
task.TaskNumber,
CourseName = task.Course!.Name,
TermName = task.AcademicTerm!.Name,
RecipientCount = db.Students.Count(student =>
student.Status == StudentStatus.Active &&
student.UserId != null &&
(task.Classes.Any(item =>
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status ==
CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!
.TeachingTaskId == task.Id)))
})
.ToListAsync(cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.TeachingTask,
AudienceName = "所带教学班",
RecipientCount = 0,
TeachingTasks = tasks
});
}
return Forbid();
}
[HttpGet("recipients")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetRecipients(
Guid? collegeId,
string? role,
Guid? administrativeClassId,
Guid? teachingTaskId,
string? keyword,
int page = 1,
int pageSize = 30,
CancellationToken cancellationToken = default)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.Teacher) &&
!IsSchoolAdministrator(scope) &&
!scope.IsInRole(SystemRoles.CollegeAdmin) &&
!scope.IsInRole(SystemRoles.Counselor))
{
return Forbid();
}
var resolvedCollegeId = IsSchoolAdministrator(scope)
? null
: await ResolveSenderCollegeIdAsync(scope, cancellationToken);
if (!IsSchoolAdministrator(scope) && !resolvedCollegeId.HasValue)
return ConflictProblem("当前账号未关联学院,暂时无法确定收件人范围。");
var filter = new MessageRecipientFilter(
collegeId,
Normalize(role),
administrativeClassId,
teachingTaskId,
Normalize(keyword));
if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role))
return ValidationProblem("所选身份类型无效。");
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = ApplyRecipientFilter(
ScopedRecipientQuery(scope, resolvedCollegeId),
filter);
var total = await source.CountAsync(cancellationToken);
var users = await source
.OrderBy(x => x.DisplayName)
.ThenBy(x => x.UserName)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(user => new
{
user.Id,
user.DisplayName,
user.UserName,
user.StaffNumber,
CollegeName = db.Colleges
.Where(college => college.Id == user.CollegeId)
.Select(college => college.Name)
.FirstOrDefault(),
StudentNumber = db.Students
.Where(student => student.UserId == user.Id)
.Select(student => student.StudentNumber)
.FirstOrDefault(),
ClassName = db.Students
.Where(student => student.UserId == user.Id)
.Select(student => student.AdministrativeClass!.Name)
.FirstOrDefault(),
TeacherNumber = db.Teachers
.Where(teacher => teacher.UserId == user.Id)
.Select(teacher => teacher.TeacherNumber)
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
var userIds = users.Select(x => x.Id).ToArray();
var roleRows = await db.UserRoles.AsNoTracking()
.Where(x => userIds.Contains(x.UserId))
.Join(
db.Roles.AsNoTracking(),
userRole => userRole.RoleId,
roleEntity => roleEntity.Id,
(userRole, roleEntity) => new
{
userRole.UserId,
RoleName = roleEntity.Name!
})
.ToListAsync(cancellationToken);
var rolesByUser = roleRows
.GroupBy(x => x.UserId)
.ToDictionary(
group => group.Key,
group => group.Select(x => x.RoleName).Distinct().ToArray());
return Ok(new
{
Items = users.Select(user => new
{
user.Id,
user.DisplayName,
user.UserName,
Number = user.StudentNumber ?? user.TeacherNumber ?? user.StaffNumber,
user.CollegeName,
user.ClassName,
Roles = rolesByUser.GetValueOrDefault(user.Id, [])
}),
Total = total,
Page = page,
PageSize = pageSize
});
}
[HttpPost("send")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> Send(
SendMessageRequest request,
CancellationToken cancellationToken)
{
var title = request.Title.Trim();
var content = request.Content.Trim();
if (title.Length == 0)
return ValidationProblem("请填写消息标题。");
if (PlainText(content).Length == 0)
return ValidationProblem("请填写消息正文。");
if (content.Length > 20000)
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
var scope = currentUserDataScope.Current;
MessageAudienceType audienceType;
Guid? audienceId = null;
string audienceName;
List<Guid> recipientIds;
if (IsSchoolAdministrator(scope))
{
var result = await ResolveAdministratorRecipientsAsync(
scope,
null,
request,
"全校已启用账号",
cancellationToken);
if (result.Error is not null) return result.Error;
audienceType = result.AudienceType;
audienceName = result.AudienceName!;
recipientIds = result.RecipientIds!;
}
else if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor))
{
var collegeId = await ResolveSenderCollegeIdAsync(
scope,
cancellationToken);
if (!collegeId.HasValue)
{
return ConflictProblem(
"当前账号未关联学院,暂时无法确定发信范围。");
}
var collegeName = await db.Colleges.AsNoTracking()
.Where(x => x.Id == collegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken);
if (collegeName is null)
return ConflictProblem("当前账号关联的学院不存在。");
var result = await ResolveAdministratorRecipientsAsync(
scope,
collegeId.Value,
request,
$"{collegeName}全院成员",
cancellationToken);
if (result.Error is not null) return result.Error;
audienceType = result.AudienceType;
audienceId = audienceType == MessageAudienceType.College
? collegeId.Value
: null;
audienceName = result.AudienceName!;
recipientIds = result.RecipientIds!;
}
else if (scope.IsInRole(SystemRoles.Teacher))
{
if (!request.TeachingTaskId.HasValue)
return ValidationProblem("请选择接收消息的教学班。");
var task = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.Id == request.TeachingTaskId.Value &&
x.Status != TeachingTaskStatus.Draft &&
x.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId))
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null)
{
return ConflictProblem(
"只能向自己所带且已发布的教学班发信。");
}
audienceType = MessageAudienceType.TeachingTask;
audienceId = task.Id;
audienceName = $"{task.TaskNumber} · {task.CourseName}";
recipientIds = await GetTeachingTaskRecipientIdsAsync(
task.Id,
cancellationToken);
}
else
{
return Forbid();
}
recipientIds = recipientIds.Distinct().ToList();
if (recipientIds.Count == 0)
return ConflictProblem("当前发信范围内没有可接收消息的已启用账号。");
var dispatch = new MessageDispatch
{
SenderUserId = scope.UserId,
SenderName = scope.DisplayName ?? "教务用户",
Title = title,
Content = content,
Category = NotificationCategory.General,
AudienceType = audienceType,
AudienceId = audienceId,
AudienceName = audienceName,
RecipientCount = recipientIds.Count,
LinkUrl = null
};
try
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.MessageDispatches.Add(dispatch);
await db.SaveChangesAsync(cancellationToken);
db.ChangeTracker.Clear();
foreach (var batch in recipientIds.Chunk(NotificationBatchSize))
{
db.Notifications.AddRange(batch.Select(userId => new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = NotificationCategory.General,
LinkUrl = null,
MessageDispatchId = dispatch.Id
}));
await db.SaveChangesAsync(cancellationToken);
db.ChangeTracker.Clear();
}
await transaction.CommitAsync(cancellationToken);
return Ok(new
{
dispatch.Id,
dispatch.AudienceName,
dispatch.RecipientCount,
dispatch.CreatedAt
});
},
cancellationToken,
IsolationLevel.ReadCommitted);
}
catch (DbUpdateException exception)
{
logger?.LogError(
exception,
"Failed to send notification dispatch {DispatchId} to {RecipientCount} recipients.",
dispatch.Id,
recipientIds.Count);
return Problem(
title: "消息未能发送",
detail: "消息数据保存失败。请确认数据库已完成最新升级后重试;本次消息没有发送。",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
[HttpGet("sent")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetSent(
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var userId = currentUserDataScope.Current.UserId;
var source = db.MessageDispatches.AsNoTracking()
.Where(x => x.SenderUserId == userId);
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.Title,
x.Content,
x.Category,
x.AudienceType,
x.AudienceName,
x.RecipientCount,
x.CreatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total });
}
[HttpPost("{id:guid}/read")]
public async Task<ActionResult> MarkRead(Guid id, CancellationToken cancellationToken)
public async Task<ActionResult> MarkRead(
Guid id,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var n = await db.Notifications
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
if (n is null) return NotFound();
n.IsRead = true;
var notification = await db.Notifications
.FirstOrDefaultAsync(
x => x.Id == id && x.UserId == userId,
cancellationToken);
if (notification is null) return NotFound();
notification.IsRead = true;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("read-all")]
public async Task<ActionResult> MarkAllRead(CancellationToken cancellationToken)
public async Task<ActionResult> MarkAllRead(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
await db.Notifications
.Where(x => x.UserId == userId && !x.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true),
.ExecuteUpdateAsync(
updates => updates.SetProperty(x => x.IsRead, true),
cancellationToken);
return NoContent();
}
private async Task<List<Guid>> GetTeachingTaskRecipientIdsAsync(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
return await db.Students.AsNoTracking()
.Where(student =>
student.Status == StudentStatus.Active &&
student.UserId != null &&
db.Users.Any(user =>
user.Id == student.UserId &&
user.IsEnabled) &&
(db.TeachingTaskClasses.Any(item =>
item.TeachingTaskId == teachingTaskId &&
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
teachingTaskId)))
.Select(x => x.UserId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
}
private async Task<Guid?> ResolveSenderCollegeIdAsync(
CurrentUserScope scope,
CancellationToken cancellationToken)
{
if (scope.CollegeId.HasValue)
return scope.CollegeId;
if (!scope.IsInRole(SystemRoles.Counselor))
return null;
var collegeIds = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.CounselorUserId == scope.UserId)
.Select(x => x.Major!.CollegeId)
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
return collegeIds.Count == 1 ? collegeIds[0] : null;
}
private static bool IsSchoolAdministrator(CurrentUserScope scope) =>
scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin);
private async Task<object> BuildAdministratorComposerAsync(
CurrentUserScope scope,
Guid? fixedCollegeId,
MessageAudienceType audienceType,
string audienceName,
CancellationToken cancellationToken)
{
var colleges = await db.Colleges.AsNoTracking()
.Where(x => !fixedCollegeId.HasValue || x.Id == fixedCollegeId.Value)
.OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name })
.ToListAsync(cancellationToken);
var administrativeClasses = await db.AdministrativeClasses.AsNoTracking()
.Where(x =>
x.IsEnabled &&
(!fixedCollegeId.HasValue ||
x.Major!.CollegeId == fixedCollegeId.Value))
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Grade,
CollegeId = x.Major!.CollegeId
})
.ToListAsync(cancellationToken);
var teachingTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.Status != TeachingTaskStatus.Draft &&
(!fixedCollegeId.HasValue ||
x.Course!.CollegeId == fixedCollegeId.Value))
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
.ThenByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name,
TermName = x.AcademicTerm!.Name,
CollegeId = x.Course.CollegeId
})
.ToListAsync(cancellationToken);
var recipientCount = await ScopedRecipientQuery(scope, fixedCollegeId)
.CountAsync(cancellationToken);
return new
{
AudienceType = audienceType,
AudienceName = audienceName,
RecipientCount = recipientCount,
CanFilterRecipients = true,
MaximumSelectedRecipients,
Colleges = colleges,
Roles = RoleOptions,
AdministrativeClasses = administrativeClasses,
TeachingTasks = teachingTasks
};
}
private IQueryable<ApplicationUser> ScopedRecipientQuery(
CurrentUserScope scope,
Guid? fixedCollegeId)
{
var source = db.Users.AsNoTracking()
.Where(x => x.IsEnabled && x.Id != scope.UserId);
if (IsSchoolAdministrator(scope)) return source;
if (!fixedCollegeId.HasValue) return source.Where(_ => false);
var collegeId = fixedCollegeId.Value;
return source.Where(user =>
user.CollegeId == collegeId ||
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
teacher.CollegeId == collegeId));
}
private IQueryable<ApplicationUser> ApplyRecipientFilter(
IQueryable<ApplicationUser> source,
MessageRecipientFilter? filter)
{
if (filter is null) return source;
if (filter.CollegeId.HasValue)
{
var collegeId = filter.CollegeId.Value;
source = source.Where(user =>
user.CollegeId == collegeId ||
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
teacher.CollegeId == collegeId));
}
if (!string.IsNullOrWhiteSpace(filter.Role))
{
var role = filter.Role;
source = source.Where(user =>
db.UserRoles.Any(userRole =>
userRole.UserId == user.Id &&
db.Roles.Any(roleEntity =>
roleEntity.Id == userRole.RoleId &&
roleEntity.Name == role)));
}
if (filter.AdministrativeClassId.HasValue)
{
var classId = filter.AdministrativeClassId.Value;
source = source.Where(user =>
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClassId == classId));
}
if (filter.TeachingTaskId.HasValue)
{
var taskId = filter.TeachingTaskId.Value;
source = source.Where(user =>
db.Students.Any(student =>
student.UserId == user.Id &&
(db.TeachingTaskClasses.Any(item =>
item.TeachingTaskId == taskId &&
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
taskId))));
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword;
source = source.Where(user =>
user.DisplayName.Contains(keyword) ||
(user.UserName != null && user.UserName.Contains(keyword)) ||
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
db.Students.Any(student =>
student.UserId == user.Id &&
(student.Name.Contains(keyword) ||
student.StudentNumber.Contains(keyword))) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
(teacher.Name.Contains(keyword) ||
teacher.TeacherNumber.Contains(keyword))));
}
return source;
}
private async Task<RecipientResolution> ResolveAdministratorRecipientsAsync(
CurrentUserScope scope,
Guid? fixedCollegeId,
SendMessageRequest request,
string defaultAudienceName,
CancellationToken cancellationToken)
{
var source = ScopedRecipientQuery(scope, fixedCollegeId);
if (request.RecipientMode == MessageRecipientMode.Scope)
{
return new RecipientResolution(
fixedCollegeId.HasValue
? MessageAudienceType.College
: MessageAudienceType.School,
defaultAudienceName,
await source.Select(x => x.Id).ToListAsync(cancellationToken),
null);
}
if (request.RecipientMode == MessageRecipientMode.Filtered)
{
if (request.RecipientFilter?.Role is not null &&
!SystemRoles.All.Contains(request.RecipientFilter.Role))
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("所选身份类型无效。"));
}
var recipientIds = await ApplyRecipientFilter(
source,
request.RecipientFilter)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
return new RecipientResolution(
MessageAudienceType.Custom,
BuildFilteredAudienceName(request.RecipientFilter),
recipientIds,
null);
}
if (request.RecipientMode == MessageRecipientMode.Selected)
{
var requestedIds = request.RecipientUserIds?
.Distinct()
.ToArray() ?? [];
if (requestedIds.Length == 0)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("请至少选择一名收件人。"));
}
if (requestedIds.Length > MaximumSelectedRecipients)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem(
$"单次最多指定 {MaximumSelectedRecipients} 名收件人。"));
}
var authorizedIds = await source
.Where(x => requestedIds.Contains(x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (authorizedIds.Count != requestedIds.Length)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("所选收件人包含无权限访问或已停用的账号。"));
}
return new RecipientResolution(
MessageAudienceType.Custom,
$"指定收件人({authorizedIds.Count} 人)",
authorizedIds,
null);
}
return new RecipientResolution(
default,
null,
null,
ValidationProblem("请选择有效的收件方式。"));
}
private static string BuildFilteredAudienceName(MessageRecipientFilter? filter)
{
if (filter is null) return "当前权限范围内全部账号";
var parts = new List<string>();
if (filter.CollegeId.HasValue) parts.Add("指定学院");
if (filter.Role is not null)
parts.Add(RoleOptions.FirstOrDefault(x => x.Value == filter.Role)?.Label ??
filter.Role);
if (filter.AdministrativeClassId.HasValue) parts.Add("指定行政班");
if (filter.TeachingTaskId.HasValue) parts.Add("指定教学班");
if (filter.Keyword is not null) parts.Add($"关键词“{filter.Keyword}”");
return parts.Count == 0
? "当前权限范围内全部账号"
: string.Join(" · ", parts);
}
private static string PlainText(string html)
{
var withoutTags = Regex.Replace(html, "<[^>]+>", " ");
return WebUtility.HtmlDecode(withoutTags).Trim();
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static readonly RoleOption[] RoleOptions =
[
new(SystemRoles.Student, "学生"),
new(SystemRoles.Teacher, "任课教师"),
new(SystemRoles.Counselor, "辅导员"),
new(SystemRoles.CollegeAdmin, "学院管理员"),
new(SystemRoles.AcademicAdmin, "校级教务管理员"),
new(SystemRoles.Leader, "校领导"),
new(SystemRoles.SuperAdmin, "超级管理员")
];
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法发送消息",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private ActionResult ValidationProblem(string detail) =>
BadRequest(new ProblemDetails
{
Title = "消息内容不完整",
Detail = detail,
Status = StatusCodes.Status400BadRequest
});
}
public sealed record SendMessageRequest(
[Required, StringLength(200)] string Title,
[Required, StringLength(20000)] string Content,
Guid? TeachingTaskId = null,
MessageRecipientMode RecipientMode = MessageRecipientMode.Scope,
MessageRecipientFilter? RecipientFilter = null,
IReadOnlyCollection<Guid>? RecipientUserIds = null);
public sealed record MessageRecipientFilter(
Guid? CollegeId = null,
string? Role = null,
Guid? AdministrativeClassId = null,
Guid? TeachingTaskId = null,
string? Keyword = null);
public enum MessageRecipientMode
{
Scope = 1,
Filtered = 2,
Selected = 3
}
public sealed record RoleOption(string Value, string Label);
internal sealed record RecipientResolution(
MessageAudienceType AudienceType,
string? AudienceName,
List<Guid>? RecipientIds,
ActionResult? Error);
/// <summary>
/// Centralized helper to send notifications across the app.
/// </summary>
@@ -90,13 +912,15 @@ public static class NotificationService
string title,
string content,
string? linkUrl = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
await db.SaveChangesAsync(cancellationToken);
@@ -109,22 +933,46 @@ public static class NotificationService
string content,
Guid? collegeId = null,
string? linkUrl = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
var query = db.Users
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! });
.Join(
db.UserRoles,
user => user.Id,
userRole => userRole.UserId,
(user, userRole) => new
{
user.Id,
user.IsEnabled,
user.CollegeId,
userRole.RoleId
})
.Join(
db.Roles,
item => item.RoleId,
role => role.Id,
(item, role) => new
{
item.Id,
item.IsEnabled,
item.CollegeId,
RoleName = role.Name!
});
var userIds = query.Where(x => x.RoleName == roleName);
var userIds = query.Where(x =>
x.RoleName == roleName &&
x.IsEnabled);
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
if (collegeId.HasValue)
{
userIds = userIds.Where(x =>
db.Teachers.Any(t =>
t.UserId == x.Id && t.CollegeId == collegeId.Value));
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
}
var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken);
var ids = await userIds
.Select(x => x.Id)
.Distinct()
.ToListAsync(cancellationToken);
foreach (var id in ids)
{
db.Notifications.Add(new Notification
@@ -132,6 +980,7 @@ public static class NotificationService
UserId = id,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
}
@@ -144,7 +993,8 @@ public static class NotificationService
string title,
string content,
string? linkUrl = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
foreach (var userId in userIds.Distinct())
{
@@ -153,6 +1003,7 @@ public static class NotificationService
UserId = userId,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
}
@@ -0,0 +1,413 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/official-documents")]
public sealed class OfficialDocumentsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
OfficialDocumentService service,
IOptions<OfficialDocumentOptions> options) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string Readers = Managers + "," + SystemRoles.Student;
[HttpGet]
[Authorize(Roles = Readers)]
public async Task<ActionResult> List(
OfficialDocumentType? type,
OfficialDocumentStatus? status,
Guid? studentId,
CancellationToken cancellationToken)
{
var source = AccessibleDocuments().AsNoTracking();
if (type.HasValue) source = source.Where(x => x.Type == type);
if (status.HasValue) source = source.Where(x => x.Status == status);
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
return Ok(await source
.OrderByDescending(x => x.IssuedAt)
.Select(x => new
{
x.Id,
x.DocumentNumber,
x.Type,
x.Status,
x.StudentId,
x.Student!.StudentNumber,
StudentName = x.Student.Name,
CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name,
x.Purpose,
x.IssuedAt,
IssuedByName = x.Student.UserId == x.IssuedByUserId
? "学生自助服务"
: x.IssuedByUser!.DisplayName,
x.InvalidatedAt,
x.InvalidationReason,
x.ReissuedFromDocumentId,
DownloadCount = x.Downloads.Count,
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
})
.ToListAsync(cancellationToken));
}
[HttpGet("students/options")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> StudentOptions(
string? keyword,
CancellationToken cancellationToken)
{
var source = AccessibleStudents().AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword))
{
var value = keyword.Trim();
source = source.Where(x =>
x.StudentNumber.Contains(value) || x.Name.Contains(value));
}
return Ok(await source.OrderBy(x => x.StudentNumber)
.Select(x => new
{
x.Id,
x.StudentNumber,
x.Name,
x.Status,
ClassName = x.AdministrativeClass!.Name,
MajorName = x.AdministrativeClass.Major!.Name,
CollegeName = x.AdministrativeClass.Major.College!.Name
})
.ToListAsync(cancellationToken));
}
[HttpPost]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Issue(
IssueOfficialDocumentRequest request,
CancellationToken cancellationToken)
{
if (!await AccessibleStudents().AnyAsync(x => x.Id == request.StudentId, cancellationToken))
return NotFound();
try
{
var document = await service.CreateAsync(
request.StudentId,
request.Type,
request.Purpose,
currentUserDataScope.Current.UserId,
currentUserDataScope.Current.DisplayName ?? "教务管理员",
VerificationBaseUrl(),
null,
cancellationToken);
db.OfficialDocuments.Add(document);
await db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(Download), new { id = document.Id },
new { document.Id, document.DocumentNumber });
}
catch (OfficialDocumentValidationException exception)
{
return ConflictProblem(exception.Message);
}
}
[HttpPost("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> IssueMine(
SelfIssueOfficialDocumentRequest request,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var studentId = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => (Guid?)x.Id)
.SingleOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联有效学生档案,不能申请电子凭证。");
var purpose = string.IsNullOrWhiteSpace(request.Purpose)
? null
: request.Purpose.Trim();
var duplicateThreshold = DateTime.UtcNow.AddMinutes(-5);
var recentDocument = await db.OfficialDocuments.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.Type == request.Type &&
x.Status == OfficialDocumentStatus.Valid &&
x.Purpose == purpose &&
x.IssuedAt >= duplicateThreshold)
.OrderByDescending(x => x.IssuedAt)
.Select(x => new { x.Id, x.DocumentNumber })
.FirstOrDefaultAsync(cancellationToken);
if (recentDocument is not null)
return Ok(new
{
recentDocument.Id,
recentDocument.DocumentNumber,
Reused = true
});
try
{
var document = await service.CreateAsync(
studentId.Value,
request.Type,
purpose,
userId,
$"{options.Value.IssuingOffice}自助签发",
VerificationBaseUrl(),
null,
cancellationToken);
db.OfficialDocuments.Add(document);
await db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(Download), new { id = document.Id },
new { document.Id, document.DocumentNumber, Reused = false });
}
catch (OfficialDocumentValidationException exception)
{
return ConflictProblem(exception.Message);
}
}
[HttpGet("{id:guid}/download")]
[Authorize(Roles = Readers)]
public async Task<IActionResult> Download(Guid id, CancellationToken cancellationToken)
{
var document = await AccessibleDocuments()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (document is null) return NotFound();
if (document.Status != OfficialDocumentStatus.Valid)
return ConflictProblem("该凭证已经失效或被新凭证替代,不能继续下载。");
db.OfficialDocumentDownloads.Add(new OfficialDocumentDownload
{
OfficialDocumentId = document.Id,
DownloadedByUserId = currentUserDataScope.Current.UserId,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = Request.Headers.UserAgent.ToString()
});
await db.SaveChangesAsync(cancellationToken);
return File(document.PdfContent, "application/pdf", $"{document.DocumentNumber}.pdf");
}
[HttpGet("{id:guid}/downloads")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DownloadHistory(
Guid id,
CancellationToken cancellationToken)
{
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
return NotFound();
return Ok(await db.OfficialDocumentDownloads.AsNoTracking()
.Where(x => x.OfficialDocumentId == id)
.OrderByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id,
x.CreatedAt,
x.DownloadedByUserId,
DownloadedByName = x.DownloadedByUser!.DisplayName,
x.IpAddress,
x.UserAgent
})
.ToListAsync(cancellationToken));
}
[HttpPost("{id:guid}/invalidate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Invalidate(
Guid id,
InvalidateOfficialDocumentRequest request,
CancellationToken cancellationToken)
{
var document = await AccessibleDocuments()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (document is null) return NotFound();
if (document.Status != OfficialDocumentStatus.Valid)
return ConflictProblem("该凭证当前已不是有效状态。");
document.Status = OfficialDocumentStatus.Invalidated;
document.InvalidatedAt = DateTime.UtcNow;
document.InvalidatedByUserId = currentUserDataScope.Current.UserId;
document.InvalidationReason = request.Reason.Trim();
await db.SaveChangesAsync(cancellationToken);
return Ok(new { document.Id, document.Status, document.InvalidatedAt });
}
[HttpPost("{id:guid}/reissue")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Reissue(
Guid id,
ReissueOfficialDocumentRequest request,
CancellationToken cancellationToken)
{
try
{
var newDocument = await db.ExecuteInRetriableTransactionAsync(async transaction =>
{
var original = await AccessibleDocuments()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
?? throw new OfficialDocumentNotFoundException();
if (await db.OfficialDocuments.AnyAsync(
x => x.ReissuedFromDocumentId == original.Id,
cancellationToken))
throw new OfficialDocumentValidationException("该凭证已经重签过,不能重复重签。");
var replacement = await service.CreateAsync(
original.StudentId,
original.Type,
request.Purpose ?? original.Purpose,
currentUserDataScope.Current.UserId,
currentUserDataScope.Current.DisplayName ?? "教务管理员",
VerificationBaseUrl(),
original.Id,
cancellationToken);
original.Status = OfficialDocumentStatus.Superseded;
original.InvalidatedAt = DateTime.UtcNow;
original.InvalidatedByUserId = currentUserDataScope.Current.UserId;
original.InvalidationReason = request.Reason.Trim();
db.OfficialDocuments.Add(replacement);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return replacement;
}, cancellationToken);
return Ok(new { newDocument.Id, newDocument.DocumentNumber });
}
catch (OfficialDocumentNotFoundException)
{
return NotFound();
}
catch (OfficialDocumentValidationException exception)
{
return ConflictProblem(exception.Message);
}
}
[HttpGet("verify/{verificationCode}")]
[AllowAnonymous]
[EnableRateLimiting("official-verification")]
public async Task<ActionResult> Verify(
string verificationCode,
CancellationToken cancellationToken)
{
var normalized = verificationCode.Trim();
if (normalized.Length is < 12 or > 128)
return Ok(new { Found = false, Valid = false });
var hash = OfficialDocumentService.HashVerificationCode(normalized);
var documentNumber = normalized.ToUpperInvariant();
var document = await db.OfficialDocuments.AsNoTracking()
.FirstOrDefaultAsync(x =>
x.VerificationCodeHash == hash ||
x.DocumentNumber == documentNumber,
cancellationToken);
if (document is null) return Ok(new { Found = false, Valid = false });
var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson);
var replacementNumber = await db.OfficialDocuments.AsNoTracking()
.Where(x => x.ReissuedFromDocumentId == document.Id)
.Select(x => x.DocumentNumber)
.FirstOrDefaultAsync(cancellationToken);
return Ok(new
{
Found = true,
Valid = document.Status == OfficialDocumentStatus.Valid,
document.Status,
document.DocumentNumber,
document.Type,
snapshot.InstitutionName,
StudentName = MaskName(snapshot.Student.Name),
StudentNumber = MaskStudentNumber(snapshot.Student.StudentNumber),
snapshot.Student.CollegeName,
snapshot.Student.MajorName,
document.IssuedAt,
document.InvalidatedAt,
ReplacementDocumentNumber = replacementNumber,
PdfSha256 = document.PdfSha256
});
}
private IQueryable<OfficialDocument> AccessibleDocuments()
{
var scope = currentUserDataScope.Current;
var source = db.OfficialDocuments.AsQueryable();
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin);
if (scope.IsInRole(SystemRoles.Student) && !isManager)
return source.Where(x => x.Student!.UserId == scope.UserId);
if (scope.RestrictedCollegeId.HasValue)
return source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId ==
scope.RestrictedCollegeId.Value);
return source;
}
private IQueryable<Student> AccessibleStudents()
{
var source = db.Students.AsQueryable();
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
return collegeId.HasValue
? source.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId.Value)
: source;
}
private string VerificationBaseUrl()
{
var configured = options.Value.PublicBaseUrl;
if (!string.IsNullOrWhiteSpace(configured)) return configured.TrimEnd('/');
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
}
private static string MaskName(string name) => name.Length switch
{
0 => "*",
1 => name,
2 => name[0] + "*",
_ => name[0] + new string('*', name.Length - 2) + name[^1]
};
private static string MaskStudentNumber(string number) => number.Length <= 4
? new string('*', number.Length)
: number[..2] + new string('*', number.Length - 4) + number[^2..];
private ConflictObjectResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "当前状态不允许此操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record IssueOfficialDocumentRequest(
Guid StudentId,
OfficialDocumentType Type,
[param: StringLength(200)] string? Purpose);
public sealed record SelfIssueOfficialDocumentRequest(
OfficialDocumentType Type,
[param: StringLength(200)] string? Purpose);
public sealed record InvalidateOfficialDocumentRequest(
[param: Required, StringLength(500, MinimumLength = 2)] string Reason);
public sealed record ReissueOfficialDocumentRequest(
[param: Required, StringLength(500, MinimumLength = 2)] string Reason,
[param: StringLength(200)] string? Purpose);
file sealed class OfficialDocumentNotFoundException : Exception;
@@ -0,0 +1,606 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Observability;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin)]
[Route("api/operations")]
public sealed class OperationsController(
AppDbContext db,
OperationalHealthService healthService,
DatabaseBackupService backupService,
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)
{
var since = DateTime.UtcNow.AddHours(-24);
var health = await healthService.CheckAsync(cancellationToken);
var backups = await backupService.ListAsync(cancellationToken);
var auditCount = await db.AuditLogs.AsNoTracking()
.CountAsync(x => x.CreatedAt >= since, cancellationToken);
var serverErrorCount = await db.AuditLogs.AsNoTracking()
.CountAsync(
x => x.CreatedAt >= since && x.StatusCode >= 500,
cancellationToken);
var failedJobCount = await CountFailedJobsAsync(
DateTime.UtcNow.AddDays(-7),
cancellationToken);
var alerts = await BuildAlertsAsync(
health,
backups,
serverErrorCount,
failedJobCount,
cancellationToken);
return Ok(new OperationsSummary(
DateTime.UtcNow,
health,
new OperationsCounters(
auditCount,
serverErrorCount,
failedJobCount,
alerts.Count(x => x.Severity == "critical")),
alerts,
backups.FirstOrDefault()));
}
[HttpGet("health")]
public async Task<ActionResult<OperationalHealthSnapshot>> GetHealth(
CancellationToken cancellationToken) =>
Ok(await healthService.CheckAsync(cancellationToken));
[HttpGet("audit-logs")]
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? method = null,
[FromQuery] int? statusCode = null,
[FromQuery] string? userName = null,
[FromQuery] string? path = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
CancellationToken cancellationToken = default)
{
var pagingError = ValidatePaging(page, pageSize);
if (pagingError is not null) return pagingError;
var rangeError = ValidateRange(from, to);
if (rangeError is not null) return rangeError;
var query = db.AuditLogs.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(method))
{
var normalizedMethod = method.Trim().ToUpperInvariant();
query = query.Where(x => x.Method == normalizedMethod);
}
if (statusCode.HasValue)
query = query.Where(x => x.StatusCode == statusCode.Value);
if (!string.IsNullOrWhiteSpace(userName))
{
var normalizedUser = userName.Trim();
query = query.Where(x =>
x.UserName != null && x.UserName.Contains(normalizedUser));
}
if (!string.IsNullOrWhiteSpace(path))
{
var normalizedPath = path.Trim();
query = query.Where(x => x.Path.Contains(normalizedPath));
}
query = query.Where(x =>
x.CreatedAt >= (from ?? DateTime.UtcNow.AddDays(-1)));
if (to.HasValue)
query = query.Where(x => x.CreatedAt <= to.Value);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new AuditLogItem(
x.Id,
x.UserName,
x.Method,
x.Path,
x.StatusCode,
x.IpAddress,
x.CreatedAt))
.ToListAsync(cancellationToken);
return Ok(new PagedResult<AuditLogItem>(items, total, page, pageSize));
}
[HttpGet("failed-jobs")]
public async Task<ActionResult<PagedResult<FailedBackgroundJobItem>>>
GetFailedJobs(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? kind = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
CancellationToken cancellationToken = default)
{
var pagingError = ValidatePaging(page, pageSize);
if (pagingError is not null) return pagingError;
var rangeError = ValidateRange(from, to);
if (rangeError is not null) return rangeError;
var normalizedKind = NormalizeJobKind(kind);
if (kind is not null && normalizedKind is null)
return ValidationProblem("后台任务类型无效。");
var effectiveFrom = from ?? DateTime.UtcNow.AddDays(-30);
var take = checked(page * pageSize);
var rows = new List<FailedBackgroundJobItem>();
var total = 0;
if (normalizedKind is null or "AutomaticSchedule")
{
var query = db.AutomaticScheduleJobs.AsNoTracking()
.Where(x =>
x.Status == AutomaticScheduleJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"AutomaticSchedule",
"自动排课",
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
if (normalizedKind is null or "SchedulePublish")
{
var query = db.SchedulePublishJobs.AsNoTracking()
.Where(x =>
x.Status == SchedulePublishJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"SchedulePublish",
"课表发布",
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
if (normalizedKind is null or "MakeupExamAuto")
{
var query = db.MakeupExamAutoJobs.AsNoTracking()
.Where(x =>
x.Status == MakeupExamAutoJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"MakeupExamAuto",
"补考自动安排",
x.MakeupExamPlan == null ? "补考计划" : x.MakeupExamPlan.Name,
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
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)
.Take(pageSize)
.ToArray();
if (pageItems.Length > 0)
{
var ids = pageItems.Select(x => x.Id).ToArray();
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => ids.Contains(x.JobId))
.Select(x => new { x.JobId, x.ProcessingAttempts })
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
cancellationToken);
pageItems = pageItems
.Select(x => x with
{
ProcessingAttempts = attempts.GetValueOrDefault(x.Id)
})
.ToArray();
}
return Ok(new PagedResult<FailedBackgroundJobItem>(
pageItems,
total,
page,
pageSize));
}
[HttpGet("backups")]
public async Task<ActionResult<IReadOnlyCollection<BackupArtifact>>> GetBackups(
CancellationToken cancellationToken) =>
Ok(await backupService.ListAsync(cancellationToken));
[HttpPost("backups")]
public async Task<ActionResult<BackupArtifact>> CreateBackup(
CreateBackupRequest request,
CancellationToken cancellationToken)
{
try
{
var artifact = await backupService.CreateAsync(
request.Note,
cancellationToken);
return CreatedAtAction(
nameof(GetBackups),
new { id = artifact.Id },
artifact);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return Problem(
title: "数据库备份失败",
detail: SafeMessage(exception),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
[HttpPost("backups/{backupId}/restore-drill")]
public async Task<ActionResult<RestoreDrillResult>> RunRestoreDrill(
string backupId,
RestoreDrillRequest request,
CancellationToken cancellationToken)
{
if (!request.Confirmation.Equals(
"RESTORE_DRILL",
StringComparison.Ordinal))
{
return ValidationProblem(
"恢复演练必须明确确认,且不会覆盖当前业务数据库。");
}
try
{
return Ok(await backupService.RunRestoreDrillAsync(
backupId,
cancellationToken));
}
catch (FileNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "备份不存在",
Detail = "指定备份不存在或其文件已被移除。",
Status = StatusCodes.Status404NotFound
});
}
}
private async Task<IReadOnlyCollection<OperationalAlert>> BuildAlertsAsync(
OperationalHealthSnapshot health,
IReadOnlyCollection<BackupArtifact> backups,
int serverErrorCount,
int failedJobCount,
CancellationToken cancellationToken)
{
var alerts = health.Components
.Where(x => x.Status != "healthy")
.Select(x => new OperationalAlert(
$"health-{x.Key}",
x.Status == "unhealthy" ? "critical" : "warning",
"health",
$"{x.Label}状态异常",
x.Detail,
health.CheckedAt))
.ToList();
if (serverErrorCount > 0)
{
var latest = await db.AuditLogs.AsNoTracking()
.Where(x =>
x.CreatedAt >= DateTime.UtcNow.AddHours(-24) &&
x.StatusCode >= 500)
.OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Path, x.StatusCode, x.CreatedAt })
.FirstOrDefaultAsync(cancellationToken);
alerts.Add(new OperationalAlert(
"http-5xx",
"critical",
"audit",
$"过去 24 小时发生 {serverErrorCount} 次服务端错误",
latest is null
? "请检查服务日志定位异常。"
: $"最近一次为 {latest.StatusCode} {latest.Path}。",
latest?.CreatedAt ?? DateTime.UtcNow));
}
if (failedJobCount > 0)
{
alerts.Add(new OperationalAlert(
"failed-jobs",
"critical",
"jobs",
$"最近 7 天有 {failedJobCount} 个后台任务失败",
"任务已停止或达到重试上限,请在失败任务中查看错误详情。",
DateTime.UtcNow));
}
var retryingFailures = await db.BackgroundJobOutboxMessages.AsNoTracking()
.CountAsync(
x => x.State != BackgroundJobOutboxState.Completed &&
x.LastError != null,
cancellationToken);
if (retryingFailures > 0)
{
alerts.Add(new OperationalAlert(
"retrying-jobs",
"warning",
"jobs",
$"{retryingFailures} 个后台任务正在错误重试",
"任务队列仍会自动重试;若持续出现,请检查依赖服务与任务参数。",
DateTime.UtcNow));
}
var latestBackup = backups.FirstOrDefault();
if (latestBackup is null)
{
alerts.Add(new OperationalAlert(
"backup-missing",
"critical",
"backup",
"尚无可验证的数据库备份",
"立即创建首个备份,并在创建后执行一次隔离恢复演练。",
DateTime.UtcNow));
}
else
{
var ageHours = (DateTime.UtcNow - latestBackup.CreatedAt).TotalHours;
if (ageHours > options.BackupWarningHours)
{
alerts.Add(new OperationalAlert(
"backup-stale",
"warning",
"backup",
$"最近备份已超过 {options.BackupWarningHours} 小时",
$"最近备份创建于 {latestBackup.CreatedAt:u}。",
latestBackup.CreatedAt));
}
if (latestBackup.LastDrillSucceeded == false)
{
alerts.Add(new OperationalAlert(
"restore-drill-failed",
"critical",
"backup",
"最近一次恢复演练失败",
latestBackup.LastDrillDetail ?? "请重新运行演练并检查数据库工具日志。",
latestBackup.LastDrillAt ?? latestBackup.CreatedAt));
}
else if (!latestBackup.LastDrillAt.HasValue)
{
alerts.Add(new OperationalAlert(
"restore-drill-missing",
"warning",
"backup",
"最近备份尚未完成恢复演练",
"恢复演练只写入隔离数据库,不会覆盖当前业务数据。",
latestBackup.CreatedAt));
}
}
return alerts
.OrderBy(x => x.Severity == "critical" ? 0 : 1)
.ThenByDescending(x => x.OccurredAt)
.ToArray();
}
private async Task<int> CountFailedJobsAsync(
DateTime from,
CancellationToken cancellationToken) =>
await db.AutomaticScheduleJobs.AsNoTracking()
.CountAsync(
x => x.Status == AutomaticScheduleJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
await db.SchedulePublishJobs.AsNoTracking()
.CountAsync(
x => x.Status == SchedulePublishJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
await db.MakeupExamAutoJobs.AsNoTracking()
.CountAsync(
x => x.Status == MakeupExamAutoJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
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 ActionResult? ValidatePaging(int page, int pageSize)
{
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
{
return ValidationProblem(
"页码必须在 1 到 100000 之间,每页数量必须在 1 到 100 之间。");
}
return null;
}
private ActionResult? ValidateRange(DateTime? from, DateTime? to)
{
if (from.HasValue && to.HasValue && from.Value > to.Value)
return ValidationProblem("开始时间不能晚于结束时间。");
return null;
}
private static string? NormalizeJobKind(string? kind)
{
if (string.IsNullOrWhiteSpace(kind)) return null;
return kind.Trim() switch
{
"AutomaticSchedule" => "AutomaticSchedule",
"SchedulePublish" => "SchedulePublish",
"MakeupExamAuto" => "MakeupExamAuto",
"ExamArrangement" => "ExamArrangement",
_ => null
};
}
private static string SafeMessage(Exception exception)
{
var message = exception.GetBaseException().Message;
return message.Length <= 500 ? message : message[..500];
}
}
public sealed record AuditLogItem(
Guid Id,
string? UserName,
string Method,
string Path,
int StatusCode,
string? IpAddress,
DateTime CreatedAt);
public sealed record FailedBackgroundJobItem(
Guid Id,
string Kind,
string KindLabel,
string Context,
string ErrorMessage,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? CompletedAt,
int ProcessingAttempts);
public sealed record OperationalAlert(
string Id,
string Severity,
string Source,
string Title,
string Detail,
DateTime OccurredAt);
public sealed record OperationsCounters(
int AuditEvents24Hours,
int ServerErrors24Hours,
int FailedJobs7Days,
int CriticalAlerts);
public sealed record OperationsSummary(
DateTime GeneratedAt,
OperationalHealthSnapshot Health,
OperationsCounters Counters,
IReadOnlyCollection<OperationalAlert> Alerts,
BackupArtifact? LatestBackup);
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
public sealed record RestoreDrillRequest([Required] string Confirmation);
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
public sealed class PersonnelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
UserManager<ApplicationUser> userManager) : ControllerBase
UserManager<ApplicationUser> userManager,
IAppCache cache) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
@@ -209,6 +211,9 @@ public sealed class PersonnelController(
teacher.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(new { user.Id, UserName = userName });
},
cancellationToken);
@@ -219,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());
@@ -263,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
})
@@ -299,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);
@@ -338,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);
}
@@ -429,6 +493,12 @@ public sealed class PersonnelController(
try
{
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Analytics,
cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
@@ -442,6 +512,12 @@ public sealed class PersonnelController(
try
{
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Analytics,
cancellationToken);
return NoContent();
}
catch (DbUpdateException)
@@ -505,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(
@@ -1,6 +1,7 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@@ -14,7 +15,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/personnel")]
public sealed class PersonnelExcelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
@@ -29,6 +31,9 @@ public sealed class PersonnelExcelController(
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ExportRoles =
WriteRoles + "," + SystemRoles.Counselor;
private static readonly string[] TeacherHeaders =
[
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
@@ -38,7 +43,10 @@ public sealed class PersonnelExcelController(
private static readonly string[] StudentHeaders =
[
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
"学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
];
[HttpGet("{kind}/template")]
@@ -64,6 +72,7 @@ public sealed class PersonnelExcelController(
}
[HttpGet("{kind}/export")]
[Authorize(Roles = ExportRoles)]
public async Task<IActionResult> Export(
string kind,
[FromQuery] PersonnelQuery query,
@@ -94,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();
}
@@ -142,6 +156,12 @@ public sealed class PersonnelExcelController(
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Analytics,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
@@ -288,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);
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -11,7 +12,7 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
[Route("api/schedules")]
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase
{
[HttpGet("time-slots")]
public async Task<ActionResult> GetTimeSlots(
@@ -65,6 +66,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
IsEnabled = request.IsEnabled
}));
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return NoContent();
}
@@ -94,6 +96,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
x.SchedulingMode
})
.ToListAsync(cancellationToken);
@@ -119,6 +123,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
task.StartWeek,
task.EndWeek,
task.WeeklyHours,
task.CourseTotalHours,
task.CoursePracticeHours,
task.SchedulingMode,
HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
@@ -3,8 +3,10 @@ using System.Security.Claims;
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -15,9 +17,7 @@ namespace Jiaowu.Api.Controllers;
[Authorize(Roles = ManagementRoles)]
[Route("api/schedules")]
public sealed class SchedulesController(
AppDbContext db,
AutomaticScheduleJobQueue automaticScheduleJobQueue,
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
AppDbContext db) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
@@ -71,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,
@@ -167,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,
@@ -241,6 +243,10 @@ public sealed class SchedulesController(
CurrentStep = "等待后台检查"
};
db.SchedulePublishJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.SchedulePublish,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
@@ -263,7 +269,6 @@ public sealed class SchedulesController(
ToResponse(existing));
}
schedulePublishJobQueue.Enqueue(job.Id);
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = job.Id },
@@ -321,6 +326,10 @@ public sealed class SchedulesController(
RequestedByUserId = requestedByUserId
};
db.AutomaticScheduleJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.AutomaticSchedule,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
@@ -339,7 +348,6 @@ public sealed class SchedulesController(
ToResponse(existing));
}
automaticScheduleJobQueue.Enqueue(job.Id);
return AcceptedAtAction(
nameof(GetAutomaticScheduleJob),
new { jobId = job.Id },
@@ -413,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;
@@ -492,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 ||
@@ -501,13 +511,52 @@ 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)
.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)
@@ -531,6 +580,10 @@ public sealed class SchedulesController(
x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (request.Kind == ScheduleEntryKind.Experiment &&
!IsExperimentRoom(classroom.RoomType))
return ValidationProblem(
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。");
@@ -584,6 +637,7 @@ public sealed class SchedulesController(
{
SchedulePlanId = planId,
TeachingTaskId = request.TeachingTaskId,
Kind = request.Kind,
ClassroomId = request.ClassroomId,
DayOfWeek = request.DayOfWeek,
StartPeriod = request.StartPeriod,
@@ -601,6 +655,12 @@ public sealed class SchedulesController(
.Select(int.Parse)
.ToHashSet();
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
@@ -698,7 +758,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);
@@ -1,6 +1,8 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/statistics")]
public sealed class StatisticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache appCache) : ControllerBase
{
private const string ViewerRoles =
SystemRoles.SuperAdmin + "," +
@@ -22,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)
{
@@ -34,14 +42,49 @@ public sealed class StatisticsController(
// ── 1. Student Statistics ────────────────────────────────────────
[HttpGet("students/summary")]
public async Task<ActionResult<object>> GetStudentSummary(
public Task<ActionResult<object>> GetStudentSummary(
Guid? collegeId, Guid? majorId, Guid? classId,
int? grade, int? enrollmentYear,
CancellationToken cancellationToken) =>
GetStudentSummaryCore(
collegeId,
majorId,
classId,
grade,
enrollmentYear,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetStudentSummaryCore(
Guid? collegeId, Guid? majorId, Guid? classId,
int? grade, int? enrollmentYear,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"students",
effectiveCollegeId,
KeyPart(majorId),
KeyPart(classId),
KeyPart(grade),
KeyPart(enrollmentYear)),
token => GetStudentSummaryCore(
collegeId,
majorId,
classId,
grade,
enrollmentYear,
useCache: false,
token),
cancellationToken);
}
var baseQuery = db.Students.AsNoTracking()
.Where(s => effectiveCollegeId == null ||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
@@ -112,8 +155,16 @@ public sealed class StatisticsController(
int? grade, int? enrollmentYear,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetStudentSummaryCore(
collegeId,
majorId,
classId,
grade,
enrollmentYear,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("学生统计", new()
{
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
@@ -128,13 +179,41 @@ public sealed class StatisticsController(
// ── 2. Course Statistics ─────────────────────────────────────────
[HttpGet("courses/summary")]
public async Task<ActionResult<object>> GetCourseSummary(
public Task<ActionResult<object>> GetCourseSummary(
Guid? collegeId, Guid? categoryId, CourseNature? nature,
CancellationToken cancellationToken) =>
GetCourseSummaryCore(
collegeId,
categoryId,
nature,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetCourseSummaryCore(
Guid? collegeId, Guid? categoryId, CourseNature? nature,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"courses",
effectiveCollegeId,
KeyPart(categoryId),
KeyPart(nature)),
token => GetCourseSummaryCore(
collegeId,
categoryId,
nature,
useCache: false,
token),
cancellationToken);
}
var baseQuery = db.Courses.AsNoTracking()
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
@@ -184,8 +263,14 @@ public sealed class StatisticsController(
Guid? collegeId, Guid? categoryId, CourseNature? nature,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetCourseSummaryCore(
collegeId,
categoryId,
nature,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("课程统计", new()
{
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
@@ -199,13 +284,46 @@ public sealed class StatisticsController(
// ── 3. Grade Statistics ──────────────────────────────────────────
[HttpGet("grades/summary")]
public async Task<ActionResult<object>> GetGradeSummary(
public Task<ActionResult<object>> GetGradeSummary(
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
Guid? courseId, CancellationToken cancellationToken)
Guid? courseId, CancellationToken cancellationToken) =>
GetGradeSummaryCore(
academicTermId,
collegeId,
majorId,
classId,
courseId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetGradeSummaryCore(
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
Guid? courseId, bool useCache, CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"grades",
effectiveCollegeId,
KeyPart(academicTermId),
KeyPart(majorId),
KeyPart(classId),
KeyPart(courseId)),
token => GetGradeSummaryCore(
academicTermId,
collegeId,
majorId,
classId,
courseId,
useCache: false,
token),
cancellationToken);
}
var recordsQuery = db.GradeRecords.AsNoTracking()
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
@@ -302,8 +420,16 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
Guid? courseId, CancellationToken cancellationToken)
{
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetGradeSummaryCore(
academicTermId,
collegeId,
majorId,
classId,
courseId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("成绩统计", new()
{
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
@@ -317,13 +443,41 @@ public sealed class StatisticsController(
// ── 4. Pass Rate Statistics ──────────────────────────────────────
[HttpGet("pass-rates/summary")]
public async Task<ActionResult<object>> GetPassRateSummary(
public Task<ActionResult<object>> GetPassRateSummary(
Guid? academicTermId, Guid? collegeId, Guid? courseId,
CancellationToken cancellationToken) =>
GetPassRateSummaryCore(
academicTermId,
collegeId,
courseId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetPassRateSummaryCore(
Guid? academicTermId, Guid? collegeId, Guid? courseId,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"pass-rates",
effectiveCollegeId,
KeyPart(academicTermId),
KeyPart(courseId)),
token => GetPassRateSummaryCore(
academicTermId,
collegeId,
courseId,
useCache: false,
token),
cancellationToken);
}
var recordsQuery = db.GradeRecords.AsNoTracking()
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
@@ -425,8 +579,14 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? collegeId, Guid? courseId,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetPassRateSummaryCore(
academicTermId,
collegeId,
courseId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("通过率统计", new()
{
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
@@ -439,13 +599,38 @@ public sealed class StatisticsController(
// ── 5. Teacher Workload Statistics ───────────────────────────────
[HttpGet("teacher-workload/summary")]
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
Guid? academicTermId, Guid? collegeId,
CancellationToken cancellationToken) =>
GetTeacherWorkloadSummaryCore(
academicTermId,
collegeId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetTeacherWorkloadSummaryCore(
Guid? academicTermId, Guid? collegeId,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"teacher-workload",
effectiveCollegeId,
KeyPart(academicTermId)),
token => GetTeacherWorkloadSummaryCore(
academicTermId,
collegeId,
useCache: false,
token),
cancellationToken);
}
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
@@ -524,8 +709,13 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? collegeId,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetTeacherWorkloadSummaryCore(
academicTermId,
collegeId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("教师工作量统计", new()
{
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
@@ -537,10 +727,39 @@ public sealed class StatisticsController(
// ── 6. Classroom Utilization Statistics ──────────────────────────
[HttpGet("classroom-utilization/summary")]
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
Guid? academicTermId, Guid? buildingId, Guid? campusId,
CancellationToken cancellationToken) =>
GetClassroomUtilizationSummaryCore(
academicTermId,
buildingId,
campusId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetClassroomUtilizationSummaryCore(
Guid? academicTermId, Guid? buildingId, Guid? campusId,
bool useCache,
CancellationToken cancellationToken)
{
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"classroom-utilization",
RestrictedCollegeId,
KeyPart(academicTermId),
KeyPart(buildingId),
KeyPart(campusId)),
token => GetClassroomUtilizationSummaryCore(
academicTermId,
buildingId,
campusId,
useCache: false,
token),
cancellationToken);
}
// Find published schedule plan
var planQuery = db.SchedulePlans.AsNoTracking()
.Where(p => p.Status == SchedulePlanStatus.Published);
@@ -713,8 +932,14 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? buildingId, Guid? campusId,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetClassroomUtilizationSummaryCore(
academicTermId,
buildingId,
campusId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("教室利用率统计", new()
{
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
@@ -727,6 +952,55 @@ public sealed class StatisticsController(
// ── Helpers ──────────────────────────────────────────────────────
private async Task<ActionResult<object>> GetCachedSummaryAsync(
string key,
Func<CancellationToken, Task<ActionResult<object>>> factory,
CancellationToken cancellationToken)
{
var value = await appCache.GetOrCreateAsync(
key,
async token =>
{
var source = await factory(token);
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(data, data.GetType());
},
AppCacheProfile.Analytics,
[
AppCacheTags.Analytics,
AppCacheTags.BaseData,
AppCacheTags.Timetables
],
cancellationToken);
return value;
}
private string StatisticsKey(
string area,
Guid? effectiveCollegeId,
params string?[] filters) =>
AppCacheKeys.Statistics(
area,
currentUser.Scope.ToString(),
effectiveCollegeId,
filters);
private static string KeyPart(Guid? value) =>
value?.ToString("N") ?? "-";
private static string KeyPart(int? value) =>
value?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-";
private static string KeyPart<TEnum>(TEnum? value)
where TEnum : struct, Enum =>
value?.ToString() ?? "-";
private FileContentResult ExportSummary(
string title,
Dictionary<string, IEnumerable<object?[]>> sheets)
@@ -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);
@@ -4,6 +4,7 @@ using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/teaching-tasks")]
public sealed class TeachingTasksController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
@@ -80,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
@@ -125,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),
@@ -161,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,
@@ -389,6 +401,7 @@ public sealed class TeachingTasksController(
try
{
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return Ok(new { AffectedCount = tasks.Count });
}
catch (DbUpdateException)
@@ -420,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("批量合班生成仅用于公共必修课或公共选修课。");
@@ -544,6 +558,9 @@ public sealed class TeachingTasksController(
db.TeachingTasks.AddRange(created);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(new
{
BatchCode = batchCode,
@@ -592,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(
@@ -737,6 +755,7 @@ public sealed class TeachingTasksController(
try
{
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
@@ -209,7 +209,9 @@ public sealed class TimetableManagementController(
[ApiController]
[Route("api/timetables")]
public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
public sealed class FreeClassroomsController(
AppDbContext db,
ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase
{
[HttpGet("free-classrooms/options")]
[Authorize(Roles = SystemRoles.Student)]
@@ -297,9 +299,9 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
CancellationToken cancellationToken = default)
{
var term = await db.AcademicTerms.AsNoTracking()
.Where(x => x.Id == academicTermId && x.IsEnabled)
.Select(x => new { x.Id, x.Name })
.FirstOrDefaultAsync(cancellationToken);
.FirstOrDefaultAsync(
x => x.Id == academicTermId && x.IsEnabled,
cancellationToken);
if (term is null) return NotFound();
var plan = await db.SchedulePlans.AsNoTracking()
.Where(x =>
@@ -329,33 +331,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
if (activePeriodCount != requestedPeriods.Length)
return ValidationProblem("查询范围包含不存在或未启用的节次。");
var candidates = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
x.ClassroomId.HasValue &&
x.DayOfWeek == dayOfWeek &&
x.StartWeek <= week &&
x.EndWeek >= week &&
x.StartPeriod < startPeriod + periodCount &&
startPeriod < x.StartPeriod + x.PeriodCount)
.Select(x => new
{
x.ClassroomId,
x.WeekPattern,
x.StartPeriod,
x.PeriodCount
})
.ToListAsync(cancellationToken);
var occupiedIds = candidates
.Where(x =>
FreeClassroomRules.MatchesWeek(x.WeekPattern, week) &&
FreeClassroomRules.PeriodsOverlap(
startPeriod,
periodCount,
x.StartPeriod,
x.PeriodCount))
.Select(x => x.ClassroomId.GetValueOrDefault())
.ToHashSet();
var reservationDate = ResolveReservationDate(term, week, dayOfWeek);
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
return ValidationProblem("所选周次和星期不在学期日期范围内。");
var occupiedIds =
await reservationAvailability.GetOccupiedClassroomIdsAsync(
term,
reservationDate,
startPeriod,
periodCount,
null,
cancellationToken);
var rooms = db.Classrooms.AsNoTracking()
.Where(x =>
@@ -397,6 +383,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
});
}
private static DateOnly ResolveReservationDate(
AcademicTerm term,
int week,
int dayOfWeek)
{
var startDay = (int)term.StartDate.DayOfWeek;
var daysSinceMonday = (startDay + 6) % 7;
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
}
}
public sealed record FreeClassroomTimeSlotDto(
@@ -1,6 +1,8 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
@@ -14,74 +16,25 @@ namespace Jiaowu.Api.Controllers;
[Route("api/timetables")]
public sealed class TimetablesController(
AppDbContext db,
TimetableDataService timetableDataService) : ControllerBase
TimetableDataService timetableDataService,
PersonalCalendarService personalCalendarService,
IAppCache cache) : ControllerBase
{
[HttpGet("options")]
[AllowAnonymous]
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
{
var terms = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderByDescending(x => x.StartDate)
.Select(x => new
{
x.Id,
x.Name,
x.AcademicYear,
x.Season,
x.StartDate,
x.EndDate,
x.IsCurrent,
x.IsArchived,
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
})
.ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
var classes = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Grade,
x.MajorId,
MajorName = x.Major!.Name,
CollegeId = x.Major.CollegeId,
CollegeName = x.Major.College!.Name,
HasPublishedTimetable = defaultTermId.HasValue &&
(db.ScheduleEntries.Any(entry =>
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))
})
.ToListAsync(cancellationToken);
var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name })
.ToListAsync(cancellationToken);
var majors = await db.Majors.AsNoTracking()
.Where(x => x.IsEnabled && x.College!.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
.ToListAsync(cancellationToken);
return Ok(new { Terms = terms, Colleges = colleges, Majors = majors, Classes = classes });
var result = await cache.GetOrCreateAsync(
AppCacheKeys.TimetableOptions,
LoadOptionsAsync,
AppCacheProfile.ReferenceData,
[
AppCacheTags.BaseData,
AppCacheTags.Timetables,
AppCacheTags.TimetableOptions
],
cancellationToken);
return Ok(result);
}
[HttpGet("classes/{classId:guid}")]
@@ -99,14 +52,10 @@ public sealed class TimetablesController(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await timetableDataService.BuildAsync(
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Teacher,
teacherId,
academicTermId,
null,
false,
null,
null,
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
@@ -118,14 +67,10 @@ public sealed class TimetablesController(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await timetableDataService.BuildAsync(
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Teacher,
teacherId,
academicTermId,
null,
false,
null,
null,
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
@@ -138,14 +83,10 @@ public sealed class TimetablesController(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await timetableDataService.BuildAsync(
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Class,
classId,
academicTermId,
null,
false,
null,
null,
cancellationToken);
if (result is null) return NotFound();
return ExcelFile(result);
@@ -262,6 +203,101 @@ public sealed class TimetablesController(
return NotFound();
}
[HttpGet("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> GetMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
return Ok(BuildCalendarSubscriptionResponse(user));
}
[HttpPost("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> EnableMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
{
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
return Ok(BuildCalendarSubscriptionResponse(user));
}
[HttpPost("mine/calendar-subscription/rotate")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> RotateMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(BuildCalendarSubscriptionResponse(user));
}
[HttpDelete("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> DisableMyCalendarSubscription(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
user.CalendarSubscriptionStamp = null;
user.CalendarSubscriptionCreatedAt = null;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("mine/calendar.ics")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> DownloadMyCalendar(
CancellationToken cancellationToken)
{
var user = await CurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
if (result is null) return CalendarProfileNotFound();
return File(
result.Content,
"text/calendar; charset=utf-8",
$"{SafeFileName(user.DisplayName)}-个人教学日历.ics");
}
[HttpGet("calendar/{userId:guid}/{accessToken}.ics")]
[AllowAnonymous]
public async Task<ActionResult> GetCalendarFeed(
Guid userId,
string accessToken,
CancellationToken cancellationToken)
{
var user = await db.Users.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user is null ||
!personalCalendarService.IsAccessTokenValid(user, accessToken))
return NotFound();
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
if (result is null) return NotFound();
var etag = $"\"{Convert.ToHexString(SHA256.HashData(result.Content))}\"";
if (Request.Headers.IfNoneMatch.ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Contains(etag, StringComparer.Ordinal))
{
return StatusCode(StatusCodes.Status304NotModified);
}
Response.Headers.CacheControl = "private, max-age=300";
Response.Headers.ETag = etag;
Response.Headers.Append("X-Content-Type-Options", "nosniff");
return File(result.Content, "text/calendar; charset=utf-8");
}
private async Task<ActionResult> BuildTimetableAsync(
Guid classId,
Guid? academicTermId,
@@ -269,18 +305,119 @@ public sealed class TimetablesController(
CancellationToken cancellationToken,
TimetableStudentDto? student = null)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Class,
classId,
academicTermId,
null,
false,
studentId,
student,
cancellationToken);
var result = studentId.HasValue || student is not null
? await timetableDataService.BuildAsync(
TimetableResourceType.Class,
classId,
academicTermId,
null,
false,
studentId,
student,
cancellationToken)
: await GetPublishedTimetableAsync(
TimetableResourceType.Class,
classId,
academicTermId,
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
private Task<TimetableData?> GetPublishedTimetableAsync(
TimetableResourceType resourceType,
Guid resourceId,
Guid? academicTermId,
CancellationToken cancellationToken) =>
cache.GetOrCreateAsync(
AppCacheKeys.PublishedTimetable(
resourceType.ToString().ToLowerInvariant(),
resourceId,
academicTermId),
token => timetableDataService.BuildAsync(
resourceType,
resourceId,
academicTermId,
null,
false,
null,
null,
token),
AppCacheProfile.PublishedTimetable,
[AppCacheTags.BaseData, AppCacheTags.Timetables],
cancellationToken);
private async Task<TimetableOptionsResponse> LoadOptionsAsync(
CancellationToken cancellationToken)
{
var terms = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderByDescending(x => x.StartDate)
.Select(x => new TimetableTermOption(
x.Id,
x.Name,
x.AcademicYear,
x.Season,
x.StartDate,
x.EndDate,
x.IsCurrent,
x.IsArchived,
db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
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;
var classes = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new TimetableClassOption(
x.Id,
x.Code,
x.Name,
x.Grade,
x.MajorId,
x.Major!.Name,
x.Major.CollegeId,
x.Major.College!.Name,
defaultTermId.HasValue &&
(db.ScheduleEntries.Any(entry =>
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)) ||
db.TeachingTasks.Any(task =>
task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
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)
.OrderBy(x => x.Code)
.Select(x => new TimetableCollegeOption(x.Id, x.Code, x.Name))
.ToListAsync(cancellationToken);
var majors = await db.Majors.AsNoTracking()
.Where(x => x.IsEnabled && x.College!.IsEnabled)
.OrderBy(x => x.Code)
.Select(x => new TimetableMajorOption(
x.Id, x.Code, x.Name, x.CollegeId))
.ToListAsync(cancellationToken);
return new TimetableOptionsResponse(terms, colleges, majors, classes);
}
private ActionResult ExcelFile(TimetableData result)
{
var bytes = TimetableExcelExporter.Create(result);
@@ -295,4 +432,89 @@ public sealed class TimetablesController(
value = value.Replace(character, '-');
return value.Trim();
}
private async Task<ApplicationUser?> CurrentUserAsync(
CancellationToken cancellationToken)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return null;
return await db.Users.FirstOrDefaultAsync(
x => x.Id == userId && x.IsEnabled,
cancellationToken);
}
private PersonalCalendarSubscriptionResponse BuildCalendarSubscriptionResponse(
ApplicationUser user)
{
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
return new(false, null, null, null, null);
var accessToken = personalCalendarService.CreateAccessToken(user);
var feedPath =
$"timetables/calendar/{user.Id:N}/{accessToken}.ics";
var feedUrl =
$"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
$"/api/{feedPath}";
var webcalUrl = feedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
? $"webcal://{feedUrl[8..]}"
: feedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
? $"webcal://{feedUrl[7..]}"
: feedUrl;
return new(
true,
feedUrl,
feedPath,
webcalUrl,
user.CalendarSubscriptionCreatedAt);
}
private ActionResult CalendarProfileNotFound() => Conflict(new ProblemDetails
{
Title = "档案未关联",
Detail = "当前登录账号没有关联有效的教师或学生档案,请联系教务管理员。",
Status = StatusCodes.Status409Conflict
});
}
public sealed record PersonalCalendarSubscriptionResponse(
bool IsEnabled,
string? FeedUrl,
string? FeedPath,
string? WebcalUrl,
DateTime? CreatedAt);
public sealed record TimetableOptionsResponse(
IReadOnlyList<TimetableTermOption> Terms,
IReadOnlyList<TimetableCollegeOption> Colleges,
IReadOnlyList<TimetableMajorOption> Majors,
IReadOnlyList<TimetableClassOption> Classes);
public sealed record TimetableTermOption(
Guid Id,
string Name,
string AcademicYear,
TermSeason Season,
DateOnly StartDate,
DateOnly EndDate,
bool IsCurrent,
bool IsArchived,
bool HasPublishedTimetable);
public sealed record TimetableCollegeOption(Guid Id, string Code, string Name);
public sealed record TimetableMajorOption(
Guid Id,
string Code,
string Name,
Guid CollegeId);
public sealed record TimetableClassOption(
Guid Id,
string Code,
string Name,
int Grade,
Guid MajorId,
string MajorName,
Guid CollegeId,
string CollegeName,
bool HasPublishedTimetable);
@@ -92,12 +92,12 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
var si = studentInfos.First(s => s.Id == w.StudentId);
var r = rules.First(r => r.Type == w.Type);
if (r.NotifyStudent && si.UserId.HasValue)
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
if (r.NotifyCounselor)
{
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
if (counselorId != default)
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", cancellationToken: ct);
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
}
}
}
@@ -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,39 @@
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ClassroomReservation : EntityBase
{
public Guid ApplicantUserId { get; set; }
public ApplicationUser? ApplicantUser { get; set; }
public required string ApplicantName { get; set; }
public Guid ApplicantCollegeId { get; set; }
public College? ApplicantCollege { get; set; }
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly ReservationDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public int AttendeeCount { get; set; }
public required string Purpose { get; set; }
public required string ContactPhone { get; set; }
public string? Notes { get; set; }
public ClassroomReservationStatus Status { get; set; } =
ClassroomReservationStatus.Submitted;
public Guid? ReviewedByUserId { get; set; }
public ApplicationUser? ReviewedByUser { get; set; }
public DateTime? ReviewedAt { get; set; }
public string? ReviewComment { get; set; }
public DateTime? CancelledAt { get; set; }
}
public enum ClassroomReservationStatus
{
Submitted = 1,
Approved = 2,
Rejected = 3,
Cancelled = 4
}
@@ -10,7 +10,15 @@ public sealed class CourseAdjustment : EntityBase
public CourseAdjustmentStatus Status { get; set; } = CourseAdjustmentStatus.Draft;
public Guid ApplicantUserId { get; set; }
// For Reschedule / Makeup
// The concrete published timetable occurrence being adjusted.
public Guid? SourceScheduleEntryId { get; set; }
public int? SourceWeek { get; set; }
public DateOnly? SourceDate { get; set; }
public int? SourceStartPeriod { get; set; }
public int? SourcePeriodCount { get; set; }
public Guid? SourceClassroomId { get; set; }
// Target occurrence for Reschedule / Makeup
public DateOnly? TargetDate { get; set; }
public int? DayOfWeek { get; set; }
public int? StartPeriod { get; set; }
@@ -11,12 +11,21 @@ public sealed class CourseSelectionRound : EntityBase
public DateTime EndsAt { get; set; }
public DateTime WithdrawalEndsAt { get; set; }
public decimal MaxCredits { get; set; } = 30;
public int? MaxCourseCount { get; set; }
public CourseSelectionRoundStatus Status { get; set; } =
CourseSelectionRoundStatus.Draft;
public string? Notes { get; set; }
public ICollection<CourseSelectionRoundGrade> EligibleGrades { get; set; } = [];
public ICollection<CourseSelectionOffering> Offerings { get; set; } = [];
}
public sealed class CourseSelectionRoundGrade : EntityBase
{
public Guid CourseSelectionRoundId { get; set; }
public CourseSelectionRound? CourseSelectionRound { get; set; }
public int Grade { get; set; }
}
public sealed class CourseSelectionOffering : EntityBase
{
public Guid CourseSelectionRoundId { get; set; }
@@ -38,6 +47,7 @@ public sealed class CourseEnrollment : EntityBase
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal;
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
public DateTime? WaitlistedAt { get; set; }
public DateTime? WithdrawnAt { get; set; }
}
@@ -51,7 +61,10 @@ public enum CourseSelectionRoundStatus
public enum CourseEnrollmentStatus
{
Enrolled = 1,
Withdrawn = 2
Withdrawn = 2,
Waitlisted = 3,
Cancelled = 4,
Expired = 5
}
public enum EnrollmentType
@@ -11,6 +11,41 @@ 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? 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 +63,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 +79,82 @@ 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 ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamPublishJobKind
{
FormalExam = 1,
MakeupExam = 2
}
public enum ExamPublishJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
@@ -0,0 +1,26 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExamSignInExportJob : EntityBase
{
public Guid PlanId { get; set; }
public ExamSignInExportJobStatus Status { get; set; } =
ExamSignInExportJobStatus.Queued;
public Guid? RequestedByUserId { get; set; }
public string? FileName { get; set; }
public byte[]? FileBytes { get; set; }
public int FileSize { get; set; }
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamSignInExportJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
@@ -0,0 +1,80 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExperimentProject : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
public ExperimentArrangementMode ArrangementMode { get; set; }
public string? Description { get; set; }
public string? Requirements { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public ExperimentProjectStatus Status { get; set; } =
ExperimentProjectStatus.Draft;
public DateTime? PublishedAt { get; set; }
public DateTime? ClosedAt { get; set; }
public ICollection<ExperimentSession> Sessions { get; set; } = [];
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
public ExperimentGradeSheet? GradeSheet { get; set; }
}
public sealed class ExperimentSession : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly SessionDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public int Capacity { get; set; }
public int ReservedCount { get; set; }
public string? Notes { get; set; }
public ExperimentSessionStatus Status { get; set; } =
ExperimentSessionStatus.Scheduled;
public DateTime? CancelledAt { get; set; }
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
}
public sealed class ExperimentBooking : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public Guid ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public ExperimentBookingStatus Status { get; set; } =
ExperimentBookingStatus.Booked;
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
public DateTime? CancelledAt { get; set; }
}
public enum ExperimentArrangementMode
{
Centralized = 1,
SelfScheduled = 2
}
public enum ExperimentProjectStatus
{
Draft = 1,
Published = 2,
Closed = 3
}
public enum ExperimentSessionStatus
{
Scheduled = 1,
Cancelled = 2
}
public enum ExperimentBookingStatus
{
Booked = 1,
Cancelled = 2
}
@@ -0,0 +1,92 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExperimentGradeSheet : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public decimal ContributionWeight { get; set; } = 1;
public decimal PassScore { get; set; } = 60;
public ExperimentGradeSheetStatus Status { get; set; } =
ExperimentGradeSheetStatus.Draft;
public string? ReviewComment { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
}
public sealed class ExperimentGradeItem : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public required string Name { get; set; }
public ExperimentGradeItemKind Kind { get; set; } =
ExperimentGradeItemKind.Other;
public decimal Weight { get; set; }
public int SortOrder { get; set; }
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
}
public sealed class ExperimentGradeRecord : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid? ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
ExperimentParticipationStatus.Pending;
public decimal? TotalScore { get; set; }
public bool? IsPassed { get; set; }
public bool SafetyViolation { get; set; }
public int AttemptNumber { get; set; } = 1;
public string? SubmissionReference { get; set; }
public DateTime? SubmittedAt { get; set; }
public bool IsLate { get; set; }
public string? TeacherComment { get; set; }
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
}
public sealed class ExperimentGradeItemScore
{
public Guid ExperimentGradeRecordId { get; set; }
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
public Guid ExperimentGradeItemId { get; set; }
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
public decimal? Score { get; set; }
public string? Comment { get; set; }
}
public enum ExperimentGradeSheetStatus
{
Draft = 1,
Submitted = 2,
Approved = 3,
Published = 4,
Returned = 5
}
public enum ExperimentGradeItemKind
{
Operation = 1,
Report = 2,
Result = 3,
Defense = 4,
Attendance = 5,
Safety = 6,
Other = 7
}
public enum ExperimentParticipationStatus
{
Pending = 1,
Completed = 2,
Absent = 3,
Excused = 4,
Makeup = 5,
Exempt = 6
}
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
public required string Name { get; set; }
public 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; } = [];
}
@@ -68,3 +71,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; } = [];
@@ -7,6 +7,43 @@ public sealed class Notification : EntityBase
public Guid UserId { get; set; }
public required string Title { get; set; }
public required string Content { get; set; }
public NotificationCategory Category { get; set; } = NotificationCategory.General;
public bool IsRead { get; set; }
public string? LinkUrl { get; set; }
public Guid? MessageDispatchId { get; set; }
public MessageDispatch? MessageDispatch { get; set; }
}
public sealed class MessageDispatch : EntityBase
{
public Guid SenderUserId { get; set; }
public required string SenderName { get; set; }
public required string Title { get; set; }
public required string Content { get; set; }
public NotificationCategory Category { get; set; } = NotificationCategory.General;
public MessageAudienceType AudienceType { get; set; }
public Guid? AudienceId { get; set; }
public required string AudienceName { get; set; }
public int RecipientCount { get; set; }
public string? LinkUrl { get; set; }
public ICollection<Notification> Notifications { get; set; } = [];
}
public enum NotificationCategory
{
General = 1,
Approval = 2,
Schedule = 3,
Grade = 4,
Attendance = 5,
CourseSelection = 6,
Warning = 7
}
public enum MessageAudienceType
{
School = 1,
College = 2,
TeachingTask = 3,
Custom = 4
}
@@ -0,0 +1,51 @@
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
namespace Jiaowu.Api.Domain.Academic;
public sealed class OfficialDocument : EntityBase
{
public required string DocumentNumber { get; set; }
public required string VerificationCodeHash { get; set; }
public OfficialDocumentType Type { get; set; }
public OfficialDocumentStatus Status { get; set; } = OfficialDocumentStatus.Valid;
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid IssuedByUserId { get; set; }
public ApplicationUser? IssuedByUser { get; set; }
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
public string? Purpose { get; set; }
public required string SnapshotJson { get; set; }
public required byte[] PdfContent { get; set; }
public required string PdfSha256 { get; set; }
public DateTime? InvalidatedAt { get; set; }
public Guid? InvalidatedByUserId { get; set; }
public ApplicationUser? InvalidatedByUser { get; set; }
public string? InvalidationReason { get; set; }
public Guid? ReissuedFromDocumentId { get; set; }
public OfficialDocument? ReissuedFromDocument { get; set; }
public ICollection<OfficialDocumentDownload> Downloads { get; set; } = [];
}
public sealed class OfficialDocumentDownload : EntityBase
{
public Guid OfficialDocumentId { get; set; }
public OfficialDocument? OfficialDocument { get; set; }
public Guid DownloadedByUserId { get; set; }
public ApplicationUser? DownloadedByUser { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
}
public enum OfficialDocumentType
{
Transcript = 1,
StudentStatusCertificate = 2
}
public enum OfficialDocumentStatus
{
Valid = 1,
Invalidated = 2,
Superseded = 3
}
@@ -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; }
}
@@ -50,6 +67,16 @@ public sealed class Course : CatalogEntity
public CourseNature Nature { get; set; }
public AssessmentMethod AssessmentMethod { get; set; }
public string? Description { get; set; }
public ICollection<CoursePrerequisite> Prerequisites { get; set; } = [];
public ICollection<CoursePrerequisite> RequiredByCourses { get; set; } = [];
}
public sealed class CoursePrerequisite : EntityBase
{
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public Guid PrerequisiteCourseId { get; set; }
public Course? PrerequisiteCourse { get; set; }
}
public sealed class CourseCategory : CatalogEntity
@@ -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; }
@@ -129,3 +130,9 @@ public enum WeekPattern
Odd = 2,
Even = 3
}
public enum ScheduleEntryKind
{
Lecture = 1,
Experiment = 2
}
@@ -10,6 +10,8 @@ public sealed class ApplicationUser : IdentityUser<Guid>
public bool IsEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public string? CalendarSubscriptionStamp { get; set; }
public DateTime? CalendarSubscriptionCreatedAt { get; set; }
}
public sealed class ApplicationRole : IdentityRole<Guid>
@@ -0,0 +1,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; }
}
@@ -0,0 +1,46 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.System;
public sealed class BackgroundJobOutboxMessage : EntityBase
{
public BackgroundJobKind JobKind { get; set; }
public Guid JobId { get; set; }
public BackgroundJobOutboxState State { get; set; } =
BackgroundJobOutboxState.Pending;
public int PublishAttempts { get; set; }
public int ProcessingAttempts { get; set; }
public DateTime? PublishedAt { get; set; }
public Guid? ProcessingToken { get; set; }
public DateTime? LeaseExpiresAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? LastError { get; set; }
public static BackgroundJobOutboxMessage Create(
BackgroundJobKind jobKind,
Guid jobId) =>
new()
{
JobKind = jobKind,
JobId = jobId
};
}
public enum BackgroundJobKind
{
AutomaticSchedule = 1,
SchedulePublish = 2,
MakeupExamAuto = 3,
ExamArrangement = 4,
ExamSignInExport = 5,
ExamPublish = 6
}
public enum BackgroundJobOutboxState
{
Pending = 1,
Publishing = 2,
Published = 3,
Processing = 4,
Completed = 5
}
@@ -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);
}
}
@@ -0,0 +1,56 @@
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed record BackgroundJobBacklogSnapshot(
int Pending,
int Publishing,
int Published,
int Processing,
int ExpiredLeases,
DateTime? OldestUnfinishedAt,
double? OldestUnfinishedAgeSeconds);
public sealed class BackgroundJobMonitoringService(AppDbContext db)
{
public async Task<BackgroundJobBacklogSnapshot> GetSnapshotAsync(
CancellationToken cancellationToken)
{
var counts = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => x.State != BackgroundJobOutboxState.Completed)
.GroupBy(x => x.State)
.Select(group => new { State = group.Key, Count = group.Count() })
.ToListAsync(cancellationToken);
var countByState = counts.ToDictionary(x => x.State, x => x.Count);
var now = DateTime.UtcNow;
var expiredLeases = await db.BackgroundJobOutboxMessages.AsNoTracking()
.CountAsync(
x => (x.State == BackgroundJobOutboxState.Publishing ||
x.State == BackgroundJobOutboxState.Processing) &&
x.LeaseExpiresAt != null &&
x.LeaseExpiresAt < now,
cancellationToken);
var oldestUnfinishedAt = await db.BackgroundJobOutboxMessages
.AsNoTracking()
.Where(x => x.State != BackgroundJobOutboxState.Completed)
.Select(x => (DateTime?)x.CreatedAt)
.MinAsync(cancellationToken);
double? ageSeconds = oldestUnfinishedAt.HasValue
? Math.Max(0, (now - oldestUnfinishedAt.Value).TotalSeconds)
: null;
return new BackgroundJobBacklogSnapshot(
GetCount(BackgroundJobOutboxState.Pending),
GetCount(BackgroundJobOutboxState.Publishing),
GetCount(BackgroundJobOutboxState.Published),
GetCount(BackgroundJobOutboxState.Processing),
expiredLeases,
oldestUnfinishedAt,
ageSeconds);
int GetCount(BackgroundJobOutboxState state) =>
countByState.GetValueOrDefault(state);
}
}
@@ -0,0 +1,53 @@
using Jiaowu.Api.Domain.System;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed class BackgroundJobOptions
{
public const string SectionName = "BackgroundJobs";
public string Transport { get; set; } = "InMemory";
public int PollIntervalMilliseconds { get; set; } = 500;
public int LeaseSeconds { get; set; } = 120;
public ushort PrefetchCount { get; set; } = 1;
public int AutomaticScheduleConcurrency { get; set; } = 1;
public int SchedulePublishConcurrency { get; set; } = 1;
public int MakeupExamAutoConcurrency { get; set; } = 1;
public int ExamArrangementConcurrency { get; set; } = 1;
public int ExamSignInExportConcurrency { get; set; } = 1;
public int ExamPublishConcurrency { get; set; } = 1;
public string Exchange { get; set; } = "jiaowu.background-jobs";
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
public bool UseQuorumQueues { get; set; } = true;
public int ProcessingAttemptLimit { get; set; } = 5;
public int MaintenanceIntervalSeconds { get; set; } = 60;
public int CompletedRetentionDays { get; set; } = 14;
public int CleanupBatchSize { get; set; } = 500;
public bool UsesRabbitMq =>
Transport.Equals("RabbitMq", StringComparison.OrdinalIgnoreCase);
public int ConsumerConcurrency(BackgroundJobKind kind) => kind switch
{
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
public sealed class RabbitMqOptions
{
public const string SectionName = "RabbitMq";
public string HostName { get; set; } = "localhost";
public int Port { get; set; } = 5672;
public string UserName { get; set; } = "guest";
public string Password { get; set; } = "guest";
public string VirtualHost { get; set; } = "/";
public bool UseTls { get; set; }
public string? TlsServerName { get; set; }
}
@@ -0,0 +1,329 @@
using System.Diagnostics;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed class BackgroundJobOutboxPublisher(
IServiceScopeFactory scopeFactory,
IBackgroundJobTransport transport,
BackgroundJobOptions options,
BackgroundJobTelemetry telemetry,
ILogger<BackgroundJobOutboxPublisher> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await PrepareForStartupAsync(stoppingToken);
var nextMaintenanceAt = DateTime.MinValue;
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (DateTime.UtcNow >= nextMaintenanceAt)
{
await RecoverExpiredProcessingAsync(stoppingToken);
await CleanupCompletedAsync(stoppingToken);
nextMaintenanceAt = DateTime.UtcNow.AddSeconds(
options.MaintenanceIntervalSeconds);
}
var claimed = await ClaimNextAsync(stoppingToken);
if (claimed is null)
{
await Task.Delay(options.PollIntervalMilliseconds, stoppingToken);
continue;
}
await PublishClaimedAsync(claimed.Value.Message, claimed.Value.Token,
stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Background job outbox publishing failed.");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
private async Task PrepareForStartupAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!transport.IsDurable)
{
await db.BackgroundJobOutboxMessages
.Where(x => x.State != BackgroundJobOutboxState.Completed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, BackgroundJobOutboxState.Pending)
.SetProperty(x => x.ProcessingToken, (Guid?)null)
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
cancellationToken);
}
await AddMissingOutboxMessagesAsync(db, cancellationToken);
}
private async Task AddMissingOutboxMessagesAsync(
AppDbContext db,
CancellationToken cancellationToken)
{
var automaticJobs = await db.AutomaticScheduleJobs.AsNoTracking()
.Where(x =>
(x.Status == AutomaticScheduleJobStatus.Queued ||
x.Status == AutomaticScheduleJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.AutomaticSchedule &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var publishJobs = await db.SchedulePublishJobs.AsNoTracking()
.Where(x =>
(x.Status == SchedulePublishJobStatus.Queued ||
x.Status == SchedulePublishJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.SchedulePublish &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var makeupJobs = await db.MakeupExamAutoJobs.AsNoTracking()
.Where(x =>
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.MakeupExamAuto &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamArrangement &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamSignInExportJobStatus.Queued ||
x.Status == ExamSignInExportJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamSignInExport &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamPublish &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var missingKeys = automaticJobs
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
.Concat(publishJobs.Select(id =>
(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)))
.ToList();
foreach (var (kind, jobId) in missingKeys)
{
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(kind, jobId));
}
if (!db.ChangeTracker.HasChanges())
return;
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
foreach (var (kind, jobId) in missingKeys)
{
var recovered = await db.BackgroundJobOutboxMessages
.AsNoTracking()
.AnyAsync(
message => message.JobKind == kind &&
message.JobId == jobId,
cancellationToken);
if (!recovered)
throw;
}
logger.LogInformation(
"Another application instance recovered {Count} missing outbox messages first.",
missingKeys.Count);
}
}
private async Task CleanupCompletedAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow.AddDays(-options.CompletedRetentionDays);
var completedIds = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x =>
x.State == BackgroundJobOutboxState.Completed &&
x.CompletedAt != null &&
x.CompletedAt < cutoff)
.OrderBy(x => x.CompletedAt)
.Select(x => x.Id)
.Take(options.CleanupBatchSize)
.ToListAsync(cancellationToken);
if (completedIds.Count == 0)
return;
foreach (var id in completedIds)
{
db.BackgroundJobOutboxMessages.Remove(
new BackgroundJobOutboxMessage { Id = id });
}
await db.SaveChangesAsync(cancellationToken);
telemetry.RecordCleaned(completedIds.Count);
logger.LogInformation(
"Removed {Count} completed background job outbox messages older than {Cutoff}.",
completedIds.Count,
cutoff);
}
private async Task RecoverExpiredProcessingAsync(
CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var now = DateTime.UtcNow;
var recoveredState = transport.IsDurable
? BackgroundJobOutboxState.Published
: BackgroundJobOutboxState.Pending;
await db.BackgroundJobOutboxMessages
.Where(x =>
(x.State == BackgroundJobOutboxState.Publishing ||
x.State == BackgroundJobOutboxState.Processing) &&
x.LeaseExpiresAt != null &&
x.LeaseExpiresAt < now)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, recoveredState)
.SetProperty(x => x.ProcessingToken, (Guid?)null)
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
cancellationToken);
}
private async Task<(BackgroundJobEnvelope Message, Guid Token)?> ClaimNextAsync(
CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var candidateId = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => x.State == BackgroundJobOutboxState.Pending)
.OrderBy(x => x.CreatedAt)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!candidateId.HasValue)
return null;
var token = Guid.NewGuid();
var leaseExpiresAt = DateTime.UtcNow.AddSeconds(options.LeaseSeconds);
var claimed = await db.BackgroundJobOutboxMessages
.Where(x =>
x.Id == candidateId.Value &&
x.State == BackgroundJobOutboxState.Pending)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, BackgroundJobOutboxState.Publishing)
.SetProperty(x => x.ProcessingToken, token)
.SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt)
.SetProperty(x => x.PublishAttempts, x => x.PublishAttempts + 1)
.SetProperty(x => x.LastError, (string?)null),
cancellationToken);
if (claimed == 0)
return null;
var message = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => x.Id == candidateId.Value)
.Select(x => new BackgroundJobEnvelope(x.Id, x.JobKind, x.JobId))
.SingleAsync(cancellationToken);
return (message, token);
}
private async Task PublishClaimedAsync(
BackgroundJobEnvelope message,
Guid token,
CancellationToken cancellationToken)
{
var startedAt = Stopwatch.GetTimestamp();
try
{
await transport.PublishAsync(message, cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.BackgroundJobOutboxMessages
.Where(x =>
x.Id == message.OutboxMessageId &&
x.State == BackgroundJobOutboxState.Publishing &&
x.ProcessingToken == token)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, BackgroundJobOutboxState.Published)
.SetProperty(x => x.PublishedAt, DateTime.UtcNow)
.SetProperty(x => x.ProcessingToken, (Guid?)null)
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
cancellationToken);
telemetry.RecordPublish(
message.JobKind,
transport.IsDurable,
"published",
Stopwatch.GetElapsedTime(startedAt));
}
catch (Exception exception)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var messageText = exception.GetBaseException().Message;
if (messageText.Length > 2000)
messageText = messageText[..2000];
await db.BackgroundJobOutboxMessages
.Where(x =>
x.Id == message.OutboxMessageId &&
x.State == BackgroundJobOutboxState.Publishing &&
x.ProcessingToken == token)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, BackgroundJobOutboxState.Pending)
.SetProperty(x => x.ProcessingToken, (Guid?)null)
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null)
.SetProperty(x => x.LastError, messageText),
cancellationToken);
telemetry.RecordPublish(
message.JobKind,
transport.IsDurable,
"failed",
Stopwatch.GetElapsedTime(startedAt));
throw;
}
}
}
@@ -0,0 +1,397 @@
using System.Diagnostics;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed record BackgroundJobRunResult(
BackgroundJobRunOutcome Outcome,
TimeSpan RetryAfter)
{
public static BackgroundJobRunResult Completed { get; } =
new(BackgroundJobRunOutcome.Completed, TimeSpan.Zero);
public static BackgroundJobRunResult Retry(TimeSpan retryAfter) =>
new(BackgroundJobRunOutcome.Retry, retryAfter);
}
public enum BackgroundJobRunOutcome
{
Completed,
Retry
}
public sealed class BackgroundJobRunner(
IServiceScopeFactory scopeFactory,
IBackgroundJobTransport transport,
BackgroundJobOptions options,
BackgroundJobTelemetry telemetry,
ILogger<BackgroundJobRunner> logger)
{
public async Task<BackgroundJobRunResult> RunAsync(
BackgroundJobEnvelope message,
CancellationToken cancellationToken)
{
var startedAt = Stopwatch.GetTimestamp();
var token = Guid.NewGuid();
var claim = await TryClaimAsync(message, token, cancellationToken);
if (!claim.Claimed)
{
return claim.Completed
? BackgroundJobRunResult.Completed
: BackgroundJobRunResult.Retry(claim.RetryAfter);
}
if (claim.AttemptsExceeded)
{
await MarkJobRetryLimitExceededAsync(message, cancellationToken);
await MarkCompletedAsync(message.OutboxMessageId, token,
cancellationToken);
telemetry.RecordProcessing(
message.JobKind,
"retry-limit",
Stopwatch.GetElapsedTime(startedAt));
return BackgroundJobRunResult.Completed;
}
using var heartbeatCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var heartbeat = RunHeartbeatAsync(
message.OutboxMessageId,
token,
heartbeatCancellation.Token);
try
{
await using var scope = scopeFactory.CreateAsyncScope();
switch (message.JobKind)
{
case BackgroundJobKind.AutomaticSchedule:
await scope.ServiceProvider
.GetRequiredService<AutomaticScheduleJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.SchedulePublish:
await scope.ServiceProvider
.GetRequiredService<SchedulePublishJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.MakeupExamAuto:
await scope.ServiceProvider
.GetRequiredService<MakeupExamAutoJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamArrangement:
await scope.ServiceProvider
.GetRequiredService<ExamArrangementJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamSignInExport:
await scope.ServiceProvider
.GetRequiredService<ExamSignInExportJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await scope.ServiceProvider
.GetRequiredService<ExamPublishJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
default:
throw new InvalidOperationException(
$"Unsupported background job kind '{message.JobKind}'.");
}
await MarkCompletedAsync(message.OutboxMessageId, token,
cancellationToken);
telemetry.RecordProcessing(
message.JobKind,
"completed",
Stopwatch.GetElapsedTime(startedAt));
return BackgroundJobRunResult.Completed;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Background job {JobKind}/{JobId} will be retried.",
message.JobKind,
message.JobId);
await ReleaseForRetryAsync(
message.OutboxMessageId,
token,
exception,
CancellationToken.None);
telemetry.RecordProcessing(
message.JobKind,
"retry",
Stopwatch.GetElapsedTime(startedAt));
return BackgroundJobRunResult.Retry(TimeSpan.FromSeconds(2));
}
finally
{
await heartbeatCancellation.CancelAsync();
try
{
await heartbeat;
}
catch (OperationCanceledException)
{
// Expected when processing completes or the application stops.
}
}
}
private async Task<(
bool Claimed,
bool Completed,
bool AttemptsExceeded,
TimeSpan RetryAfter)>
TryClaimAsync(
BackgroundJobEnvelope message,
Guid token,
CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var now = DateTime.UtcNow;
var leaseExpiresAt = now.AddSeconds(options.LeaseSeconds);
var claimed = await db.BackgroundJobOutboxMessages
.Where(x =>
x.Id == message.OutboxMessageId &&
x.JobKind == message.JobKind &&
x.JobId == message.JobId &&
(x.State == BackgroundJobOutboxState.Pending ||
x.State == BackgroundJobOutboxState.Publishing ||
x.State == BackgroundJobOutboxState.Published ||
(x.State == BackgroundJobOutboxState.Processing &&
x.LeaseExpiresAt != null &&
x.LeaseExpiresAt < now)))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, BackgroundJobOutboxState.Processing)
.SetProperty(x => x.ProcessingToken, token)
.SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt)
.SetProperty(
x => x.ProcessingAttempts,
x => x.ProcessingAttempts + 1)
.SetProperty(x => x.LastError, (string?)null),
cancellationToken);
if (claimed == 1)
{
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => x.Id == message.OutboxMessageId)
.Select(x => x.ProcessingAttempts)
.SingleAsync(cancellationToken);
return (
true,
false,
attempts > options.ProcessingAttemptLimit,
TimeSpan.Zero);
}
var current = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => x.Id == message.OutboxMessageId)
.Select(x => new { x.State, x.LeaseExpiresAt })
.FirstOrDefaultAsync(cancellationToken);
if (current is null || current.State == BackgroundJobOutboxState.Completed)
return (false, true, false, TimeSpan.Zero);
var retryAfter = current.LeaseExpiresAt.HasValue
? current.LeaseExpiresAt.Value - now
: TimeSpan.FromSeconds(2);
retryAfter = TimeSpan.FromSeconds(Math.Clamp(
retryAfter.TotalSeconds,
1,
options.LeaseSeconds));
return (false, false, false, retryAfter);
}
private async Task MarkJobRetryLimitExceededAsync(
BackgroundJobEnvelope message,
CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var completedAt = DateTime.UtcNow;
var error = $"后台任务连续处理失败超过 {options.ProcessingAttemptLimit} 次," +
"已停止自动重试,请检查服务日志后重新创建任务。";
switch (message.JobKind)
{
case BackgroundJobKind.AutomaticSchedule:
await db.AutomaticScheduleJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != AutomaticScheduleJobStatus.Succeeded &&
x.Status != AutomaticScheduleJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
AutomaticScheduleJobStatus.Failed)
.SetProperty(x => x.ActiveSchedulePlanId, (Guid?)null)
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.SchedulePublish:
await db.SchedulePublishJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != SchedulePublishJobStatus.Succeeded &&
x.Status != SchedulePublishJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
SchedulePublishJobStatus.Failed)
.SetProperty(x => x.ActiveAcademicTermId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.MakeupExamAuto:
await db.MakeupExamAutoJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != MakeupExamAutoJobStatus.Succeeded &&
x.Status != MakeupExamAutoJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
MakeupExamAutoJobStatus.Failed)
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.ExamArrangement:
await db.ExamArrangementJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamArrangementJobStatus.Succeeded &&
x.Status != ExamArrangementJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamArrangementJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await db.ExamPublishJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamPublishJobStatus.Succeeded &&
x.Status != ExamPublishJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamPublishJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(message.JobKind),
message.JobKind,
null);
}
logger.LogError(
"Background job {JobKind}/{JobId} exceeded {AttemptLimit} processing attempts.",
message.JobKind,
message.JobId,
options.ProcessingAttemptLimit);
}
private async Task RunHeartbeatAsync(
Guid outboxMessageId,
Guid token,
CancellationToken cancellationToken)
{
var intervalSeconds = Math.Max(5, options.LeaseSeconds / 3);
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.BackgroundJobOutboxMessages
.Where(x =>
x.Id == outboxMessageId &&
x.State == BackgroundJobOutboxState.Processing &&
x.ProcessingToken == token)
.ExecuteUpdateAsync(
setters => setters.SetProperty(
x => x.LeaseExpiresAt,
DateTime.UtcNow.AddSeconds(options.LeaseSeconds)),
cancellationToken);
}
}
private async Task MarkCompletedAsync(
Guid outboxMessageId,
Guid token,
CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.BackgroundJobOutboxMessages
.Where(x =>
x.Id == outboxMessageId &&
x.State == BackgroundJobOutboxState.Processing &&
x.ProcessingToken == token)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, BackgroundJobOutboxState.Completed)
.SetProperty(x => x.CompletedAt, DateTime.UtcNow)
.SetProperty(x => x.ProcessingToken, (Guid?)null)
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
cancellationToken);
}
private async Task ReleaseForRetryAsync(
Guid outboxMessageId,
Guid token,
Exception exception,
CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var message = exception.GetBaseException().Message;
if (message.Length > 2000)
message = message[..2000];
var state = transport.IsDurable
? BackgroundJobOutboxState.Published
: BackgroundJobOutboxState.Pending;
await db.BackgroundJobOutboxMessages
.Where(x =>
x.Id == outboxMessageId &&
x.State == BackgroundJobOutboxState.Processing &&
x.ProcessingToken == token)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.State, state)
.SetProperty(x => x.ProcessingToken, (Guid?)null)
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null)
.SetProperty(x => x.LastError, message),
cancellationToken);
}
}
@@ -0,0 +1,74 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Jiaowu.Api.Domain.System;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed class BackgroundJobTelemetry : IDisposable
{
public const string MeterName = "Jiaowu.BackgroundJobs";
private readonly Meter _meter = new(MeterName, "1.0.0");
private readonly Counter<long> _published;
private readonly Counter<long> _processed;
private readonly Histogram<double> _publishDuration;
private readonly Histogram<double> _processingDuration;
private readonly Counter<long> _cleaned;
public BackgroundJobTelemetry()
{
_published = _meter.CreateCounter<long>(
"jiaowu.background_jobs.published",
unit: "{message}");
_processed = _meter.CreateCounter<long>(
"jiaowu.background_jobs.processed",
unit: "{job}");
_publishDuration = _meter.CreateHistogram<double>(
"jiaowu.background_jobs.publish.duration",
unit: "ms");
_processingDuration = _meter.CreateHistogram<double>(
"jiaowu.background_jobs.processing.duration",
unit: "ms");
_cleaned = _meter.CreateCounter<long>(
"jiaowu.background_jobs.outbox.cleaned",
unit: "{message}");
}
public void RecordPublish(
BackgroundJobKind kind,
bool durable,
string outcome,
TimeSpan duration)
{
var tags = new TagList
{
{ "job.kind", kind.ToString() },
{ "messaging.backend", durable ? "rabbitmq" : "memory" },
{ "job.outcome", outcome }
};
_published.Add(1, tags);
_publishDuration.Record(duration.TotalMilliseconds, tags);
}
public void RecordProcessing(
BackgroundJobKind kind,
string outcome,
TimeSpan duration)
{
var tags = new TagList
{
{ "job.kind", kind.ToString() },
{ "job.outcome", outcome }
};
_processed.Add(1, tags);
_processingDuration.Record(duration.TotalMilliseconds, tags);
}
public void RecordCleaned(int count)
{
if (count > 0)
_cleaned.Add(count);
}
public void Dispose() => _meter.Dispose();
}
@@ -0,0 +1,51 @@
using System.Threading.Channels;
using Jiaowu.Api.Domain.System;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed record BackgroundJobEnvelope(
Guid OutboxMessageId,
BackgroundJobKind JobKind,
Guid JobId);
public interface IBackgroundJobTransport
{
bool IsDurable { get; }
ValueTask PublishAsync(
BackgroundJobEnvelope message,
CancellationToken cancellationToken);
Task<bool> CheckHealthAsync(CancellationToken cancellationToken);
}
public sealed class InMemoryBackgroundJobTransport : IBackgroundJobTransport
{
private readonly IReadOnlyDictionary<
BackgroundJobKind,
Channel<BackgroundJobEnvelope>> _channels =
Enum.GetValues<BackgroundJobKind>().ToDictionary(
kind => kind,
_ => Channel.CreateBounded<BackgroundJobEnvelope>(
new BoundedChannelOptions(256)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false
}));
public bool IsDurable => false;
public ValueTask PublishAsync(
BackgroundJobEnvelope message,
CancellationToken cancellationToken) =>
_channels[message.JobKind].Writer.WriteAsync(message, cancellationToken);
public Task<bool> CheckHealthAsync(CancellationToken cancellationToken) =>
Task.FromResult(true);
public IAsyncEnumerable<BackgroundJobEnvelope> ReadAllAsync(
BackgroundJobKind kind,
CancellationToken cancellationToken) =>
_channels[kind].Reader.ReadAllAsync(cancellationToken);
}
@@ -0,0 +1,46 @@
using Jiaowu.Api.Domain.System;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed class InMemoryBackgroundJobWorker(
InMemoryBackgroundJobTransport transport,
BackgroundJobRunner runner,
BackgroundJobOptions options,
ILogger<InMemoryBackgroundJobWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var workers = Enum.GetValues<BackgroundJobKind>()
.SelectMany(kind => Enumerable.Range(
0,
options.ConsumerConcurrency(kind))
.Select(_ => ConsumeAsync(kind, stoppingToken)))
.ToArray();
try
{
await Task.WhenAll(workers);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation("In-memory background job worker is stopping.");
}
}
private async Task ConsumeAsync(
BackgroundJobKind kind,
CancellationToken cancellationToken)
{
await foreach (var message in transport.ReadAllAsync(
kind,
cancellationToken))
{
var result = await runner.RunAsync(message, cancellationToken);
if (result.Outcome != BackgroundJobRunOutcome.Retry)
continue;
await Task.Delay(result.RetryAfter, cancellationToken);
await transport.PublishAsync(message, cancellationToken);
}
}
}
@@ -0,0 +1,437 @@
using System.Text.Json;
using Jiaowu.Api.Domain.System;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
public sealed class RabbitMqBackgroundJobTransport(
BackgroundJobOptions jobOptions,
RabbitMqOptions rabbitOptions,
ILogger<RabbitMqBackgroundJobTransport> logger)
: IBackgroundJobTransport, IAsyncDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
private IConnection? _connection;
private IChannel? _channel;
public bool IsDurable => true;
public async ValueTask PublishAsync(
BackgroundJobEnvelope message,
CancellationToken cancellationToken)
{
var body = JsonSerializer.SerializeToUtf8Bytes(message);
await _gate.WaitAsync(cancellationToken);
try
{
var channel = await GetChannelAsync(cancellationToken);
var properties = new BasicProperties
{
Persistent = true,
ContentType = "application/json",
MessageId = message.OutboxMessageId.ToString("D"),
Type = message.JobKind.ToString(),
AppId = "jiaowu-api"
};
await channel.BasicPublishAsync(
jobOptions.Exchange,
RabbitMqBackgroundJobTopology.RoutingKey(message.JobKind),
mandatory: true,
properties,
body,
cancellationToken);
}
catch
{
await ResetConnectionAsync();
throw;
}
finally
{
_gate.Release();
}
}
public async Task<bool> CheckHealthAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
var channel = await GetChannelAsync(cancellationToken);
return channel.IsOpen;
}
catch (Exception exception)
{
logger.LogWarning(exception, "RabbitMQ health check failed.");
await ResetConnectionAsync();
return false;
}
finally
{
_gate.Release();
}
}
private async Task<IChannel> GetChannelAsync(CancellationToken cancellationToken)
{
if (_channel is { IsOpen: true })
return _channel;
await ResetConnectionAsync();
_connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
rabbitOptions,
"jiaowu-background-job-publisher",
consumerDispatchConcurrency: 1,
cancellationToken);
_channel = await _connection.CreateChannelAsync(
new CreateChannelOptions(
publisherConfirmationsEnabled: true,
publisherConfirmationTrackingEnabled: true),
cancellationToken);
await RabbitMqBackgroundJobTopology.DeclareAsync(
_channel,
jobOptions,
cancellationToken);
return _channel;
}
private async Task ResetConnectionAsync()
{
if (_channel is not null)
{
try
{
await _channel.DisposeAsync();
}
catch
{
// The broker may already have closed the channel.
}
_channel = null;
}
if (_connection is not null)
{
try
{
await _connection.DisposeAsync();
}
catch
{
// The broker may already have closed the connection.
}
_connection = null;
}
}
public async ValueTask DisposeAsync()
{
await _gate.WaitAsync();
try
{
await ResetConnectionAsync();
}
finally
{
_gate.Release();
_gate.Dispose();
}
}
}
public sealed class RabbitMqBackgroundJobWorker(
BackgroundJobOptions jobOptions,
RabbitMqOptions rabbitOptions,
BackgroundJobRunner runner,
ILogger<RabbitMqBackgroundJobWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
IConnection? connection = null;
var channels = new List<IChannel>();
try
{
var consumerCount = RabbitMqBackgroundJobTopology.JobKinds.Sum(
jobOptions.ConsumerConcurrency);
connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
rabbitOptions,
"jiaowu-background-job-worker",
(ushort)consumerCount,
stoppingToken);
foreach (var kind in RabbitMqBackgroundJobTopology.JobKinds)
{
for (var workerIndex = 0;
workerIndex < jobOptions.ConsumerConcurrency(kind);
workerIndex++)
{
var channel = await connection.CreateChannelAsync(
new CreateChannelOptions(
publisherConfirmationsEnabled: false,
publisherConfirmationTrackingEnabled: false),
stoppingToken);
channels.Add(channel);
await RabbitMqBackgroundJobTopology.DeclareAsync(
channel,
jobOptions,
stoppingToken);
await channel.BasicQosAsync(
0,
jobOptions.PrefetchCount,
global: false,
stoppingToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, eventArgs) =>
{
using var deliveryCancellation =
CancellationTokenSource.CreateLinkedTokenSource(
eventArgs.CancellationToken,
stoppingToken);
var deliveryToken = deliveryCancellation.Token;
try
{
var message = JsonSerializer
.Deserialize<BackgroundJobEnvelope>(
eventArgs.Body.Span);
if (message is null || message.JobKind != kind)
{
await channel.BasicNackAsync(
eventArgs.DeliveryTag,
multiple: false,
requeue: false,
deliveryToken);
return;
}
var result = await runner.RunAsync(
message,
deliveryToken);
if (result.Outcome ==
BackgroundJobRunOutcome.Completed)
{
await channel.BasicAckAsync(
eventArgs.DeliveryTag,
multiple: false,
deliveryToken);
return;
}
await Task.Delay(
result.RetryAfter,
deliveryToken);
await channel.BasicNackAsync(
eventArgs.DeliveryTag,
multiple: false,
requeue: true,
deliveryToken);
}
catch (OperationCanceledException)
{
// Closing the channel requeues unacknowledged deliveries.
}
catch (Exception exception)
{
logger.LogError(
exception,
"RabbitMQ delivery for {JobKind} failed and will be requeued.",
kind);
if (!channel.IsOpen)
return;
await channel.BasicNackAsync(
eventArgs.DeliveryTag,
multiple: false,
requeue: true,
CancellationToken.None);
}
};
await channel.BasicConsumeAsync(
RabbitMqBackgroundJobTopology.QueueName(
jobOptions,
kind),
autoAck: false,
consumer,
stoppingToken);
}
}
logger.LogInformation(
"{ConsumerCount} RabbitMQ background job consumers are connected to {HostName}:{Port}.",
consumerCount,
rabbitOptions.HostName,
rabbitOptions.Port);
while (connection.IsOpen && !stoppingToken.IsCancellationRequested)
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(
exception,
"RabbitMQ background job consumer connection failed; retrying.");
}
finally
{
foreach (var channel in channels)
{
try
{
await channel.DisposeAsync();
}
catch
{
// The connection may already have disposed its channels.
}
}
if (connection is not null)
{
try
{
await connection.DisposeAsync();
}
catch
{
// The broker may already have closed the connection.
}
}
}
if (!stoppingToken.IsCancellationRequested)
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
internal static class RabbitMqBackgroundJobTopology
{
public static readonly BackgroundJobKind[] JobKinds =
[
BackgroundJobKind.AutomaticSchedule,
BackgroundJobKind.SchedulePublish,
BackgroundJobKind.MakeupExamAuto,
BackgroundJobKind.ExamArrangement,
BackgroundJobKind.ExamSignInExport,
BackgroundJobKind.ExamPublish
];
public static async Task<IConnection> CreateConnectionAsync(
RabbitMqOptions options,
string clientName,
ushort consumerDispatchConcurrency,
CancellationToken cancellationToken)
{
var factory = new ConnectionFactory
{
HostName = options.HostName,
Port = options.Port,
UserName = options.UserName,
Password = options.Password,
VirtualHost = options.VirtualHost,
ClientProvidedName = clientName,
AutomaticRecoveryEnabled = false,
TopologyRecoveryEnabled = false,
RequestedHeartbeat = TimeSpan.FromSeconds(30),
ConsumerDispatchConcurrency = consumerDispatchConcurrency
};
if (options.UseTls)
{
factory.Ssl = new SslOption
{
Enabled = true,
ServerName = string.IsNullOrWhiteSpace(options.TlsServerName)
? options.HostName
: options.TlsServerName
};
}
return await factory.CreateConnectionAsync(cancellationToken);
}
public static async Task DeclareAsync(
IChannel channel,
BackgroundJobOptions options,
CancellationToken cancellationToken)
{
var deadExchange = options.Exchange + ".dead";
await channel.ExchangeDeclareAsync(
options.Exchange,
ExchangeType.Direct,
durable: true,
autoDelete: false,
cancellationToken: cancellationToken);
await channel.ExchangeDeclareAsync(
deadExchange,
ExchangeType.Direct,
durable: true,
autoDelete: false,
cancellationToken: cancellationToken);
foreach (var kind in JobKinds)
{
var routingKey = RoutingKey(kind);
var queueName = QueueName(options, kind);
var deadQueueName = queueName + ".dead";
var queueArguments = QueueArguments(options);
queueArguments["x-dead-letter-exchange"] = deadExchange;
queueArguments["x-dead-letter-routing-key"] = routingKey;
await channel.QueueDeclareAsync(
queueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: queueArguments,
cancellationToken: cancellationToken);
await channel.QueueBindAsync(
queueName,
options.Exchange,
routingKey,
cancellationToken: cancellationToken);
await channel.QueueDeclareAsync(
deadQueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: QueueArguments(options),
cancellationToken: cancellationToken);
await channel.QueueBindAsync(
deadQueueName,
deadExchange,
routingKey,
cancellationToken: cancellationToken);
}
}
public static string RoutingKey(BackgroundJobKind kind) => kind switch
{
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
BackgroundJobKind.SchedulePublish => "schedule.publish",
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
BackgroundJobKind.ExamArrangement => "exam.arrangement",
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
BackgroundJobKind.ExamPublish => "exam.publish",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
public static string QueueName(
BackgroundJobOptions options,
BackgroundJobKind kind) =>
$"{options.QueuePrefix}.{RoutingKey(kind)}";
private static Dictionary<string, object?> QueueArguments(
BackgroundJobOptions options)
{
var arguments = new Dictionary<string, object?>();
if (options.UseQuorumQueues)
arguments["x-queue-type"] = "quorum";
return arguments;
}
}
@@ -0,0 +1,196 @@
using Microsoft.Extensions.Caching.Hybrid;
using StackExchange.Redis;
namespace Jiaowu.Api.Infrastructure.Caching;
public enum AppCacheProfile
{
ReferenceData,
PublishedTimetable,
Analytics
}
public interface IAppCache
{
Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken);
ValueTask RemoveByTagAsync(
string tag,
CancellationToken cancellationToken = default);
}
public sealed class HybridAppCache(
HybridCache cache,
AppCacheOptions options,
IHostEnvironment environment,
ILogger<HybridAppCache> logger) : IAppCache
{
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
public async Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken)
{
if (!options.Enabled)
return await factory(cancellationToken);
var sourceCompleted = false;
T? sourceValue = default;
try
{
return await cache.GetOrCreateAsync(
$"{prefix}:{key}",
async token =>
{
sourceValue = await factory(token);
sourceCompleted = true;
return sourceValue;
},
GetEntryOptions(profile),
tags.Select(tag => $"{prefix}:tag:{tag}"),
cancellationToken);
}
catch (RedisException exception)
{
logger.LogWarning(
exception,
"Redis cache operation failed for {CacheKey}; using the source directly.",
key);
if (sourceCompleted)
return sourceValue!;
return await factory(cancellationToken);
}
}
public async ValueTask RemoveByTagAsync(
string tag,
CancellationToken cancellationToken = default)
{
if (!options.Enabled)
return;
try
{
await cache.RemoveByTagAsync(
$"{prefix}:tag:{tag}",
cancellationToken);
}
catch (RedisException exception)
{
logger.LogWarning(
exception,
"Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.",
tag);
}
}
private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) =>
profile switch
{
AppCacheProfile.ReferenceData => new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes),
LocalCacheExpiration =
TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds)
},
AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes),
LocalCacheExpiration =
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
},
AppCacheProfile.Analytics => new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
LocalCacheExpiration =
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
},
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
};
private static string BuildPrefix(string configuredPrefix, string environmentName)
{
var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix)
? "jiaowu:v1"
: configuredPrefix.Trim().Trim(':');
var environmentValue = string.Concat(
environmentName.Trim().ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-'));
return $"{prefixValue}:{environmentValue}";
}
}
public sealed class NoOpAppCache : IAppCache
{
public static NoOpAppCache Instance { get; } = new();
private NoOpAppCache()
{
}
public Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken) =>
factory(cancellationToken);
public ValueTask RemoveByTagAsync(
string tag,
CancellationToken cancellationToken = default) =>
ValueTask.CompletedTask;
}
public static class AppCacheKeys
{
public const string ActivationOptions = "auth:activation-options";
public const string Dashboard = "dashboard:summary";
public const string TimetableOptions = "timetable:options";
public static string BaseData(string kind) => $"base-data:{kind}";
public static string PublishedTimetable(
string resourceType,
Guid resourceId,
Guid? academicTermId) =>
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
$"{resourceType}:{resourceId:N}";
public static string Statistics(
string area,
string dataScope,
Guid? effectiveCollegeId,
params string?[] filters)
{
static string Normalize(string? value) =>
string.IsNullOrWhiteSpace(value)
? "-"
: value.Trim().ToLowerInvariant();
var filterPart = filters.Length == 0
? "all"
: string.Join(':', filters.Select(Normalize));
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
}
}
public static class AppCacheTags
{
public const string BaseData = "base-data";
public const string Analytics = "analytics";
public const string Timetables = "timetables";
public const string TimetableOptions = "timetable:options";
public static string Timetable(Guid academicTermId) =>
$"timetable:term:{academicTermId:N}";
}
@@ -0,0 +1,16 @@
namespace Jiaowu.Api.Infrastructure.Caching;
public sealed class AppCacheOptions
{
public const string SectionName = "Cache";
public bool Enabled { get; set; } = true;
public string KeyPrefix { get; set; } = "jiaowu:v1";
public int ReferenceExpirationMinutes { get; set; } = 30;
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
public int TimetableExpirationMinutes { get; set; } = 10;
public int TimetableLocalExpirationSeconds { get; set; } = 30;
public int AnalyticsExpirationMinutes { get; set; } = 3;
public int AnalyticsLocalExpirationSeconds { get; set; } = 30;
public int MaximumPayloadKilobytes { get; set; } = 2048;
}
@@ -14,9 +14,23 @@ public static class CourseSelectionRules
round.Status == CourseSelectionRoundStatus.Open &&
nowUtc <= round.WithdrawalEndsAt;
public static bool CanPromoteWaitlist(CourseSelectionRound round, DateTime nowUtc) =>
round.Status == CourseSelectionRoundStatus.Open &&
nowUtc <= round.WithdrawalEndsAt;
public static bool SupportsProxyEnrollment(CourseNature nature) =>
nature == CourseNature.GeneralRequired;
public static bool IsGradeEligible(
IEnumerable<int> eligibleGrades,
int studentGrade) =>
!eligibleGrades.Any() || eligibleGrades.Contains(studentGrade);
public static bool HasReachedCourseLimit(
int? maxCourseCount,
int selectedCourseCount) =>
maxCourseCount.HasValue && selectedCourseCount >= maxCourseCount.Value;
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
schedulingMode == TeachingTaskSchedulingMode.Standard;
@@ -68,4 +82,7 @@ public static class CourseSelectionRules
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
public static int RetakeCapacity(int originalCapacity) =>
(int)Math.Ceiling(originalCapacity * 1.15);
public static int EffectiveCapacity(int originalCapacity, bool isRetake) =>
isRetake ? RetakeCapacity(originalCapacity) : originalCapacity;
}
@@ -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,18 +1,44 @@
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,
IReadOnlyCollection<Guid>? requestedSessionIds,
bool assignClassrooms,
bool assignInvigilators,
CancellationToken cancellationToken)
{
if (!assignClassrooms && !assignInvigilators)
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
var plan = await db.ExamPlans
.Include(x => x.AcademicTerm)
.Include(x => x.Sessions)
@@ -20,6 +46,16 @@ public sealed class ExamArrangementService(AppDbContext db)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.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)
@@ -27,201 +63,558 @@ public sealed class ExamArrangementService(AppDbContext db)
if (plan.Status != ExamPlanStatus.Draft)
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
var sessions = plan.Sessions.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("考试计划中没有场次。");
var requestedIds = (requestedSessionIds ?? [])
.Distinct()
.ToHashSet();
if (requestedIds.Count > 100)
return ExamArrangementResult.Fail("一次最多处理100个考试场次。");
if (requestedIds.Count > 0 &&
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
var 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);
int assignedRooms = 0;
int assignedInvigilators = 0;
int skippedRooms = 0;
int skippedInvigilators = 0;
var messages = new List<string>();
// 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();
foreach (var session in sessions)
{
// Compute StartsAt/EndsAt from time slots
foreach (var session in plan.Sessions)
ComputeTimesFromSlots(session, timeSlotLookup);
var studentCount = await db.CourseEnrollments.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
cancellationToken);
// ── Auto-assign classroom ──
if (!session.ClassroomId.HasValue)
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;
if (assignClassrooms)
{
foreach (var session in sessions)
{
var room = await FindBestClassroomAsync(
session, studentCount, occupiedRooms, cancellationToken);
if (room is not null)
session.ClassroomId = null;
if (session.Invigilators.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}座)");
}
else
{
skippedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators.Clear();
}
}
else
{
skippedRooms++;
}
// ── Auto-assign invigilators ──
var currentInvigilatorCount = session.Invigilators.Count;
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (needed > 0)
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 courseTeacherIds = session.TeachingTask!.Teachers
.Select(x => x.TeacherId).ToHashSet();
var newlyAssigned = await FindInvigilatorsAsync(
session, needed, courseTeacherIds,
occupiedInvigilators, cancellationToken);
foreach (var teacher in newlyAssigned)
var groupSessions = group.ToList();
var candidates = InterleaveCandidates(
groupSessions,
roster,
sessionByTaskId);
if (candidates.Count == 0)
{
session.Invigilators.Add(new ExamSessionInvigilator
messages.Add(
$"“{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
{
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();
}
}
seatedStudents = targetRooms.Sum(x => x.Seats.Count);
}
var assignedInvigilators = 0;
var unavailableInvigilators = 0;
if (assignInvigilators)
{
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))
{
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)
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
else
{
skippedInvigilators++;
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,
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
(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,270 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamPublishJobProcessor(
AppDbContext db,
IAppCache cache,
ILogger<ExamPublishJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamPublishJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamPublishJobStatus.Succeeded
or ExamPublishJobStatus.Failed)
{
return;
}
job.Status = ExamPublishJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.CurrentStep = "正在校验考试计划";
await db.SaveChangesAsync(stoppingToken);
switch (job.Kind)
{
case ExamPublishJobKind.FormalExam:
await PublishFormalExamAsync(job, stoppingToken);
break;
case ExamPublishJobKind.MakeupExam:
await PublishMakeupExamAsync(job, stoppingToken);
break;
default:
throw new InvalidOperationException(
$"不支持的考试发布类型:{job.Kind}。");
}
job.Status = ExamPublishJobStatus.Succeeded;
job.ActivePlanId = null;
job.CurrentStep = "发布完成";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
stoppingToken);
logger.LogInformation(
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
job.Id,
job.Kind,
job.PlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Exam publish job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (ExamPublishValidationException validationException)
{
logger.LogWarning(
validationException,
"Exam publish job {JobId} validation failed.",
jobId);
await MarkFailedAsync(jobId, validationException.Message);
}
catch (Exception exception)
{
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private async Task PublishFormalExamAsync(
ExamPublishJob job,
CancellationToken ct)
{
var plan = await db.ExamPlans
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
if (plan is null)
throw new ExamPublishValidationException("考试计划不存在。");
if (plan.Status != ExamPlanStatus.Draft)
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
// Step 1: check sessions exist — simple count, no JOIN
var sessionCount = await db.ExamSessions
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
if (sessionCount == 0)
throw new ExamPublishValidationException(
"至少安排一个考试场次后才能发布。");
// Step 2: load session summaries — only necessary columns, no Include chains
var sessions = await db.ExamSessions
.AsNoTracking()
.Where(x => x.ExamPlanId == job.PlanId)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.ClassroomId,
InvigilatorCount = x.Invigilators.Count,
RoomLinkCount = x.RoomLinks.Count
})
.ToListAsync(ct);
// Step 3: load roster counts — independent query
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
var rosterCounts = (await TeachingTaskRosterQuery
.LoadForTasksAsync(db, taskIds, ct))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
// Step 4: validate each session's assignment completeness
var unassignedSessionIds = new List<Guid>();
foreach (var session in sessions)
{
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
if (session.RoomLinkCount > 0)
{
// Has room links — check seat assignment via separate query
var assignedSeatCount = await db.ExamSeats
.CountAsync(seat =>
seat.ExamSessionId == session.Id,
ct);
if (assignedSeatCount != rosterCount)
unassignedSessionIds.Add(session.Id);
}
else if (!session.ClassroomId.HasValue ||
session.InvigilatorCount == 0)
{
unassignedSessionIds.Add(session.Id);
}
}
if (unassignedSessionIds.Count > 0)
throw new ExamPublishValidationException(
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
"请先完成自动编排。");
// Step 5: check room invigilator sufficiency — separate query
var insufficientInvigilatorCount = await db.ExamRoomSessions
.Where(link =>
link.ExamRoom!.ExamPlanId == job.PlanId &&
link.ExamRoom.Invigilators.Count <
link.ExamRoom.RequiredInvigilatorCount)
.Select(link => link.ExamSessionId)
.Distinct()
.CountAsync(ct);
if (insufficientInvigilatorCount > 0)
throw new ExamPublishValidationException(
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
"请先完成自动编排。");
// Step 6: validate mixed rooms — split into two independent queries
// to avoid Seats × SessionLinks Cartesian product
// 6a: capacity overflow
var overCapacityCount = await db.ExamRooms
.Where(r => r.ExamPlanId == job.PlanId)
.Select(r => new
{
r.Id,
SeatCount = r.Seats.Count,
Capacity = r.Classroom!.Capacity
})
.CountAsync(x => x.SeatCount > x.Capacity, ct);
if (overCapacityCount > 0)
throw new ExamPublishValidationException(
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
// 6b: course mismatch
var courseMismatchCount = await db.ExamRoomSessions
.Where(link =>
link.ExamRoom!.ExamPlanId == job.PlanId &&
link.ExamSession!.TeachingTask!.CourseId !=
link.ExamRoom.CourseId)
.Select(link => link.ExamRoomId)
.Distinct()
.CountAsync(ct);
if (courseMismatchCount > 0)
throw new ExamPublishValidationException(
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
// Step 7: publish
plan.Status = ExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
private async Task PublishMakeupExamAsync(
ExamPublishJob job,
CancellationToken ct)
{
var plan = await db.MakeupExamPlans
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
if (plan is null)
throw new ExamPublishValidationException("补考计划不存在。");
if (plan.Status != MakeupExamPlanStatus.Draft)
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
// Step 1: check sessions exist — simple count
var sessionCount = await db.MakeupExamSessions
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
if (sessionCount == 0)
throw new ExamPublishValidationException(
"至少安排一个考试场次后才能发布。");
// Step 2: load session summaries — only necessary columns
var sessions = await db.MakeupExamSessions
.AsNoTracking()
.Where(x => x.MakeupExamPlanId == job.PlanId)
.Select(x => new
{
x.Id,
x.ClassroomId,
InvigilatorCount = x.Invigilators.Count,
EnrollmentCount = x.Enrollments.Count
})
.ToListAsync(ct);
// Step 3: validate completeness — no Include chain needed
var unassigned = sessions.Count(x =>
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
if (unassigned > 0)
throw new ExamPublishValidationException(
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
// Step 4: validate enrollments
var empty = sessions.Count(x => x.EnrollmentCount == 0);
if (empty > 0)
throw new ExamPublishValidationException(
$"还有 {empty} 个场次没有登记补考学生。");
// Step 5: publish
plan.Status = MakeupExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamPublishJobStatus.Failed;
job.ActivePlanId = null;
job.CurrentStep = "发布失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
public sealed class ExamPublishValidationException(string message)
: InvalidOperationException(message);
@@ -0,0 +1,212 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamSignInExportJobProcessor(
AppDbContext db,
ILogger<ExamSignInExportJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamSignInExportJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamSignInExportJobStatus.Succeeded
or ExamSignInExportJobStatus.Failed)
{
return;
}
job.Status = ExamSignInExportJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.CurrentStep = "正在生成考场签名单";
await db.SaveChangesAsync(stoppingToken);
var plan = await db.ExamPlans.AsNoTracking()
.Where(x => x.Id == job.PlanId)
.Select(x => new
{
x.Name,
TermName = x.AcademicTerm!.Name
})
.FirstOrDefaultAsync(stoppingToken);
if (plan is null)
{
await MarkFailedAsync(jobId, "考试计划不存在。");
return;
}
var rooms = await db.ExamRooms.AsNoTracking()
.Include(x => x.Course)
.Include(x => x.Classroom)
.ThenInclude(x => x!.Building)
.Include(x => x.Invigilators)
.ThenInclude(x => x.Teacher)
.Include(x => x.SessionLinks)
.ThenInclude(x => x.ExamSession)
.ThenInclude(x => x!.TeachingTask)
.Include(x => x.Seats)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Where(x => x.ExamPlanId == job.PlanId)
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.Classroom!.Building!.Name)
.ThenBy(x => x.Classroom!.Name)
.AsSplitQuery()
.ToListAsync(stoppingToken);
var legacySessions = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == job.PlanId)
.Where(x => !x.RoomLinks.Any())
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.Select(session => new
{
session.Id,
session.TeachingTaskId,
session.ExamDate,
session.StartsAt,
session.EndsAt,
CourseCode = session.TeachingTask!.Course!.Code,
CourseName = session.TeachingTask.Course.Name,
session.TeachingTask.TaskNumber,
BuildingName = session.Classroom != null
? session.Classroom.Building!.Name
: null,
ClassroomName = session.Classroom != null
? session.Classroom.Name
: null,
InvigilatorNames = session.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
})
.ToListAsync(stoppingToken);
if (rooms.Count == 0 && legacySessions.Count == 0)
{
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
return;
}
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
legacySessions.Select(x => x.TeachingTaskId),
stoppingToken);
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
var sheets = rooms.Select(room => new ExamSignInSessionData(
room.Id,
room.ExamDate,
room.StartsAt,
room.EndsAt,
room.Course!.Code,
room.Course.Name,
string.Join(
"、",
room.SessionLinks
.Select(link =>
link.ExamSession!.TeachingTask!.TaskNumber)
.Distinct()
.OrderBy(x => x)),
room.Classroom!.Building!.Name,
room.Classroom.Name,
room.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
.ToList(),
room.Seats
.OrderBy(seat => seat.SeatNumber)
.Select(seat => new ExamSignInStudentData(
seat.StudentId,
seat.Student!.StudentNumber,
seat.Student.Name,
seat.Student.AdministrativeClass!.Name,
seat.SeatNumber))
.ToList()))
.Concat(legacySessions.Select(session =>
new ExamSignInSessionData(
session.Id,
session.ExamDate,
session.StartsAt,
session.EndsAt,
session.CourseCode,
session.CourseName,
session.TaskNumber,
session.BuildingName,
session.ClassroomName,
session.InvigilatorNames.ToList(),
studentsByTask[session.TeachingTaskId]
.Select((student, index) =>
new ExamSignInStudentData(
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName,
index + 1))
.ToList())))
.ToList();
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
var bytes = ExamSignInWorkbookExporter.Create(data);
job.Status = ExamSignInExportJobStatus.Succeeded;
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
job.FileBytes = bytes;
job.FileSize = bytes.Length;
job.CurrentStep = "生成完成";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
logger.LogInformation(
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
job.Id,
job.PlanId,
bytes.Length);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Sign-in export job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamSignInExportJobStatus.Failed;
job.CurrentStep = "生成失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
private static string SanitizeFileName(string value)
{
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
var normalized = new string(value
.Select(c => invalid.Contains(c) ? '_' : c)
.ToArray()).Trim();
return normalized.Length > 100 ? normalized[..100] : normalized;
}
}
@@ -0,0 +1,305 @@
using ClosedXML.Excel;
namespace Jiaowu.Api.Infrastructure.Exams;
public static class ExamSignInWorkbookExporter
{
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
public static byte[] Create(ExamSignInWorkbookData data)
{
using var workbook = new XLWorkbook();
AddSummarySheet(workbook, data);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"考场汇总"
};
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName)
.ThenBy(x => x.CourseCode))
{
AddSignInSheet(
workbook,
data,
session,
UniqueSheetName(session, usedNames));
}
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
private static void AddSummarySheet(
XLWorkbook workbook,
ExamSignInWorkbookData data)
{
var sheet = workbook.Worksheets.Add("考场汇总");
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Row(1).Height = 34;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value =
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Row(2).Height = 24;
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(4, column).Value = headers[column - 1];
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
var row = 5;
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName))
{
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Cell(row, 3).Value = Location(session);
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
sheet.Cell(row, 5).Value = session.TaskNumber;
sheet.Cell(row, 6).Value = session.Students.Count;
sheet.Cell(row, 7).Value = Invigilators(session);
row++;
}
if (row > 5)
{
var table = sheet.Range(4, 1, row - 1, headers.Length);
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
table.Style.Border.InsideBorderColor = Rule;
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
table.Style.Border.OutsideBorderColor = Rule;
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Rows(5, row - 1).Height = 25;
}
sheet.Column(1).Width = 13;
sheet.Column(2).Width = 15;
sheet.Column(3).Width = 24;
sheet.Column(4).Width = 30;
sheet.Column(5).Width = 22;
sheet.Column(6).Width = 11;
sheet.Column(7).Width = 24;
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
sheet.SheetView.FreezeRows(4);
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
}
private static void AddSignInSheet(
XLWorkbook workbook,
ExamSignInWorkbookData data,
ExamSignInSessionData session,
string sheetName)
{
var sheet = workbook.Worksheets.Add(sheetName);
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = "考试考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Cell("A1").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(1).Height = 36;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Cell("A2").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(2).Height = 23;
sheet.Range("A3:D3").Merge();
sheet.Cell("A3").Value =
$"课程:{session.CourseCode} {session.CourseName}{session.TaskNumber}";
sheet.Range("E3:G3").Merge();
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
sheet.Range("A4:D4").Merge();
sheet.Cell("A4").Value =
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Range("E4:G4").Merge();
sheet.Cell("E4").Value = $"考场:{Location(session)}";
sheet.Range("A5:D5").Merge();
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
sheet.Range("E5:G5").Merge();
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
var metadata = sheet.Range("A3:G5");
metadata.Style.Fill.BackgroundColor = XLColor.White;
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
metadata.Style.Alignment.WrapText = true;
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
metadata.Style.Border.BottomBorderColor = Rule;
sheet.Rows(3, 5).Height = 24;
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(7, column).Value = headers[column - 1];
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
sheet.Row(7).Height = 27;
var row = 8;
foreach (var student in session.Students
.OrderBy(x => x.SeatNumber)
.ThenBy(x => x.StudentNumber))
{
var index = row - 7;
sheet.Cell(row, 1).Value = index;
sheet.Cell(row, 2).Value =
(student.SeatNumber > 0 ? student.SeatNumber : index)
.ToString("D3");
sheet.Cell(row, 3).Value = student.StudentNumber;
sheet.Cell(row, 4).Value = student.Name;
sheet.Cell(row, 5).Value = student.ClassName;
sheet.Row(row).Height = 28;
row++;
}
if (session.Students.Count == 0)
{
sheet.Range("A8:G8").Merge();
sheet.Cell("A8").Value = "本场次暂无考生";
sheet.Cell("A8").Style
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
sheet.Row(8).Height = 28;
row = 9;
}
var roster = sheet.Range(7, 1, row - 1, headers.Length);
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
roster.Style.Border.InsideBorderColor = Rule;
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
sheet.Column(1).Width = 7;
sheet.Column(2).Width = 10;
sheet.Column(3).Width = 16;
sheet.Column(4).Width = 12;
sheet.Column(5).Width = 20;
sheet.Column(6).Width = 22;
sheet.Column(7).Width = 17;
sheet.SheetView.FreezeRows(7);
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
}
private static void StyleTitle(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontSize(18)
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Navy)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleSubTitle(IXLRange range) =>
range.Style
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(Pale)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleHeader(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Teal)
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static string Location(ExamSignInSessionData session) =>
string.Join(" · ", new[]
{
session.BuildingName,
session.ClassroomName
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
? value
: "待分配考场";
private static string Invigilators(ExamSignInSessionData session) =>
session.InvigilatorNames.Count > 0
? string.Join('、', session.InvigilatorNames)
: "待分配";
private static string UniqueSheetName(
ExamSignInSessionData session,
ISet<string> usedNames)
{
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
? "待分配"
: session.ClassroomName;
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
var invalidCharacters = new HashSet<char>(":\\/?*[]");
var sanitized = new string(raw
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
.ToArray()).Trim(' ', '\'');
if (sanitized.Length == 0) sanitized = "考场";
if (sanitized.Length > 31) sanitized = sanitized[..31];
var candidate = sanitized;
var suffix = 2;
while (!usedNames.Add(candidate))
{
var marker = $"-{suffix++}";
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
candidate = sanitized[..prefixLength] + marker;
}
return candidate;
}
}
public sealed record ExamSignInWorkbookData(
string PlanName,
string TermName,
IReadOnlyList<ExamSignInSessionData> Sessions);
public sealed record ExamSignInSessionData(
Guid SessionId,
DateOnly ExamDate,
DateTime StartsAt,
DateTime EndsAt,
string CourseCode,
string CourseName,
string TaskNumber,
string? BuildingName,
string? ClassroomName,
IReadOnlyList<string> InvigilatorNames,
IReadOnlyList<ExamSignInStudentData> Students);
public sealed record ExamSignInStudentData(
Guid StudentId,
string StudentNumber,
string Name,
string ClassName,
int SeatNumber = 0);
@@ -0,0 +1,15 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
internal static class InvigilatorCandidateQuery
{
public static IQueryable<Teacher> Create(
AppDbContext db,
IEnumerable<Guid> excludedTeacherIds) =>
db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(excludedTeacherIds, x => x.Id);
}
@@ -11,8 +11,14 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId,
IReadOnlyCollection<Guid>? requestedSessionIds,
bool assignClassrooms,
bool assignInvigilators,
CancellationToken cancellationToken)
{
if (!assignClassrooms && !assignInvigilators)
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
var plan = await db.MakeupExamPlans
.Include(x => x.AcademicTerm)
.Include(x => x.Sessions)
@@ -20,6 +26,10 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.AsSplitQuery()
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
@@ -27,9 +37,23 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
if (plan.Status != MakeupExamPlanStatus.Draft)
return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。");
var sessions = plan.Sessions.ToList();
var requestedIds = (requestedSessionIds ?? [])
.Distinct()
.ToHashSet();
if (requestedIds.Count > 100)
return ExamArrangementResult.Fail("一次最多处理100个补考场次。");
if (requestedIds.Count > 0 &&
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
return ExamArrangementResult.Fail("所选场次不存在或不属于当前补考计划。");
var sessions = plan.Sessions
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenByDescending(x => x.RequiredInvigilatorCount)
.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("补考计划中没有场次。");
return ExamArrangementResult.Fail("没有可处理的补考场次。");
var termId = plan.AcademicTermId;
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
@@ -44,8 +68,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
int assignedRooms = 0;
int assignedInvigilators = 0;
int skippedRooms = 0;
int skippedInvigilators = 0;
int unavailableRooms = 0;
int unavailableInvigilators = 0;
var messages = new List<string>();
var occupiedRooms = sessions
@@ -66,7 +90,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
// Auto-assign classroom
if (!session.ClassroomId.HasValue)
if (assignClassrooms && !session.ClassroomId.HasValue)
{
var room = await FindBestClassroomAsync(
session, enrolledCount, occupiedRooms, cancellationToken);
@@ -76,24 +100,19 @@ 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
{
skippedRooms++;
unavailableRooms++;
messages.Add(
$"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)");
}
}
else
{
skippedRooms++;
}
// Auto-assign invigilators
var currentInvigilatorCount = session.Invigilators.Count;
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (needed > 0)
if (assignInvigilators && needed > 0)
{
var courseTeacherIds = session.TeachingTask!.Teachers
.Select(x => x.TeacherId).ToHashSet();
@@ -113,12 +132,11 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
}
if (newlyAssigned.Count < needed)
{
unavailableInvigilators += needed - newlyAssigned.Count;
messages.Add(
$"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
else
{
skippedInvigilators++;
}
}
}
@@ -126,8 +144,9 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
return new ExamArrangementResult(
true,
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
(unavailableRooms > 0 ? $" {unavailableRooms}个场次无可用考场。" : "") +
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
(messages.Count > 0 ? $" 详情:{string.Join("", messages.Take(10))}" : ""));
}
@@ -152,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(
@@ -207,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 [];
}
}
}
@@ -1,111 +1,10 @@
using System.Text.Json;
using System.Threading.Channels;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class MakeupExamAutoJobQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
public void Enqueue(Guid jobId)
{
if (!_channel.Writer.TryWrite(jobId))
throw new InvalidOperationException("补考自动生成任务队列当前不可用。");
}
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
public sealed class MakeupExamAutoJobWorker(
IServiceScopeFactory scopeFactory,
MakeupExamAutoJobQueue queue,
ILogger<MakeupExamAutoJobWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await RecoverInterruptedJobsAsync(stoppingToken);
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not recover makeup exam auto jobs (table may not exist yet).");
}
try
{
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<MakeupExamAutoJobProcessor>();
await processor.ProcessAsync(jobId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Unexpected failure while dispatching makeup exam auto job {JobId}.",
jobId);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Makeup exam auto job worker is stopping.");
}
}
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var jobs = await db.MakeupExamAutoJobs
.Where(x =>
x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
foreach (var job in jobs)
{
job.Status = MakeupExamAutoJobStatus.Queued;
job.StartedAt = null;
job.CompletedAt = null;
job.ErrorMessage = null;
}
if (jobs.Count > 0)
await db.SaveChangesAsync(cancellationToken);
foreach (var job in jobs)
queue.Enqueue(job.Id);
if (jobs.Count > 0)
{
logger.LogInformation(
"Recovered {JobCount} queued or interrupted makeup exam auto jobs.",
jobs.Count);
}
}
}
public sealed class MakeupExamAutoJobProcessor(
AppDbContext db,
MakeupExamEligibilityService eligibilityService,
@@ -0,0 +1,59 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Experiments;
internal static class CentralizedExperimentConflictQuery
{
public static IQueryable<Guid> TaskIdsForTeachers(
AppDbContext db,
IReadOnlyCollection<Guid> teacherIds) =>
db.TeachingTaskTeachers.AsNoTracking()
.WhereIn(teacherIds, x => x.TeacherId)
.Select(x => x.TeachingTaskId);
public static IQueryable<Guid> TaskIdsForClasses(
AppDbContext db,
IReadOnlyCollection<Guid> classIds) =>
db.TeachingTaskClasses.AsNoTracking()
.WhereIn(classIds, x => x.AdministrativeClassId)
.Select(x => x.TeachingTaskId);
public static IQueryable<ScheduleEntry> ScheduleEntries(
AppDbContext db,
IReadOnlyCollection<Guid> relatedTaskIds,
Guid academicTermId,
int dayOfWeek,
int week,
int startPeriod,
int periodCount) =>
db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.SchedulePlan.AcademicTermId == academicTermId &&
x.DayOfWeek == dayOfWeek &&
x.StartWeek <= week &&
x.EndWeek >= week &&
x.StartPeriod < startPeriod + periodCount &&
startPeriod < x.StartPeriod + x.PeriodCount)
.WhereIn(relatedTaskIds, x => x.TeachingTaskId);
public static IQueryable<ExperimentSession> ExperimentSessions(
AppDbContext db,
IReadOnlyCollection<Guid> relatedTaskIds,
DateOnly date,
int startPeriod,
int periodCount) =>
db.ExperimentSessions.AsNoTracking()
.Where(x =>
x.Status == ExperimentSessionStatus.Scheduled &&
x.SessionDate == date &&
x.StartPeriod < startPeriod + periodCount &&
startPeriod < x.StartPeriod + x.PeriodCount &&
x.ExperimentProject!.ArrangementMode ==
ExperimentArrangementMode.Centralized)
.WhereIn(
relatedTaskIds,
x => x.ExperimentProject!.TeachingTaskId);
}
@@ -0,0 +1,88 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService
{
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db,
Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken)
{
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
.Where(x =>
x.Status ==
Domain.Academic.ExperimentGradeSheetStatus.Published &&
x.ExperimentProject!.TeachingTaskId == teachingTaskId)
.OrderBy(x => x.ExperimentProject!.Code)
.Select(x => new PublishedExperimentSheet(
x.Id,
x.ExperimentProject!.Code,
x.ExperimentProject.Name,
x.ContributionWeight,
x.Records.Select(record => new PublishedExperimentScore(
record.StudentId,
record.TotalScore)).ToList()))
.AsSplitQuery()
.ToListAsync(cancellationToken);
var scores = new Dictionary<Guid, decimal?>();
foreach (var studentId in studentIds.Distinct())
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
var complete = sheets.Count > 0;
foreach (var sheet in sheets)
{
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore)
{
complete = false;
break;
}
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
scores[studentId] = complete && totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
}
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private sealed record PublishedExperimentSheet(
Guid Id,
string Code,
string Name,
decimal ContributionWeight,
List<PublishedExperimentScore> Scores);
private sealed record PublishedExperimentScore(
Guid StudentId,
decimal? TotalScore);
}
public sealed record ExperimentGradeAggregateResult(
int PublishedProjectCount,
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
IReadOnlyDictionary<Guid, decimal?> Scores);
public sealed record ExperimentGradeAggregateProject(
Guid Id,
string Code,
string Name,
decimal ContributionWeight);
@@ -0,0 +1,45 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeCalculator
{
public static bool AreWeightsValid(
decimal contributionWeight,
decimal passScore,
IEnumerable<ExperimentGradeItem> items)
{
var itemList = items.ToList();
return contributionWeight is > 0 and <= 100 &&
passScore is >= 0 and <= 100 &&
itemList.Count > 0 &&
itemList.All(item => item.Weight is > 0 and <= 100) &&
itemList.Sum(item => item.Weight) == 100;
}
public static decimal? CalculateTotal(
ExperimentGradeRecord record,
IReadOnlyCollection<ExperimentGradeItem> items)
{
if (record.SafetyViolation ||
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
return 0;
if (record.ParticipationStatus is
ExperimentParticipationStatus.Pending or
ExperimentParticipationStatus.Excused or
ExperimentParticipationStatus.Exempt)
return null;
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
decimal total = 0;
foreach (var item in items)
{
if (!scores.TryGetValue(item.Id, out var score) ||
!score.Score.HasValue)
return null;
total += score.Score.Value * item.Weight / 100;
}
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
}
}
@@ -0,0 +1,115 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public sealed record AcademicPlanningCourseSnapshot(
Guid CourseId,
string CourseName,
decimal Credits,
int RecommendedSemester,
CurriculumCourseType Type,
IReadOnlyCollection<Guid> PrerequisiteCourseIds);
public sealed record AcademicPlanningPrerequisiteIssue(
Guid CourseId,
Guid PrerequisiteCourseId,
int PlannedSemester,
int? PrerequisitePlannedSemester);
public static class AcademicPlanningRules
{
public const decimal RecommendedSemesterCreditLimit = 24;
public const decimal HeavySemesterCreditLimit = 30;
public static IReadOnlyList<AcademicPlanningPrerequisiteIssue>
FindPrerequisiteIssues(
IEnumerable<AcademicPlanningCourseSnapshot> courses,
IReadOnlySet<Guid> completedCourseIds,
IReadOnlySet<Guid> inProgressCourseIds,
IReadOnlyDictionary<Guid, int> plannedSemesters)
{
var issues = new List<AcademicPlanningPrerequisiteIssue>();
foreach (var course in courses.Where(x =>
plannedSemesters.ContainsKey(x.CourseId)))
{
var plannedSemester = plannedSemesters[course.CourseId];
foreach (var prerequisiteId in course.PrerequisiteCourseIds)
{
if (completedCourseIds.Contains(prerequisiteId) ||
inProgressCourseIds.Contains(prerequisiteId))
continue;
if (!plannedSemesters.TryGetValue(
prerequisiteId,
out var prerequisiteSemester) ||
prerequisiteSemester >= plannedSemester)
{
issues.Add(new AcademicPlanningPrerequisiteIssue(
course.CourseId,
prerequisiteId,
plannedSemester,
plannedSemesters.GetValueOrDefault(prerequisiteId)));
}
}
}
return issues;
}
public static IReadOnlyList<Guid> SuggestNextSemester(
IEnumerable<AcademicPlanningCourseSnapshot> courses,
IReadOnlySet<Guid> completedCourseIds,
IReadOnlySet<Guid> inProgressCourseIds,
int nextSemester,
decimal targetCredits = RecommendedSemesterCreditLimit)
{
var available = courses
.Where(x =>
!completedCourseIds.Contains(x.CourseId) &&
!inProgressCourseIds.Contains(x.CourseId) &&
x.RecommendedSemester <= nextSemester &&
x.PrerequisiteCourseIds.All(prerequisiteId =>
completedCourseIds.Contains(prerequisiteId) ||
inProgressCourseIds.Contains(prerequisiteId)))
.OrderBy(x => x.Type == CurriculumCourseType.Required ? 0 : 1)
.ThenBy(x => x.RecommendedSemester > nextSemester ? 1 : 0)
.ThenBy(x => x.RecommendedSemester)
.ThenBy(x => x.CourseName)
.ToList();
var selected = new List<Guid>();
decimal credits = 0;
foreach (var course in available)
{
var isOverdueRequired =
course.Type == CurriculumCourseType.Required &&
course.RecommendedSemester <= nextSemester;
if (!isOverdueRequired &&
selected.Count > 0 &&
credits + course.Credits > targetCredits)
continue;
selected.Add(course.CourseId);
credits += course.Credits;
if (credits >= targetCredits) break;
}
return selected;
}
public static int EstimateCompletionSemester(
int nextSemester,
int latestPlannedSemester,
decimal remainingCredits,
int remainingRequirementCount)
{
if (remainingCredits <= 0 && remainingRequirementCount <= 0)
return Math.Max(nextSemester - 1, latestPlannedSemester);
var byCredits = (int)Math.Ceiling(
Math.Max(remainingCredits, 0) / RecommendedSemesterCreditLimit);
var byRequirements = (int)Math.Ceiling(
Math.Max(remainingRequirementCount, 0) / 6m);
var additionalSemesters = Math.Max(1, Math.Max(byCredits, byRequirements));
return Math.Max(nextSemester - 1, latestPlannedSemester) +
additionalSemesters;
}
}
@@ -0,0 +1,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();
}
@@ -0,0 +1,12 @@
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
public sealed class OfficialDocumentOptions
{
public const string SectionName = "OfficialDocuments";
public string InstitutionName { get; set; } = "明序大学";
public string IssuingOffice { get; set; } = "教务处";
public string DocumentNumberPrefix { get; set; } = "MXU";
public string? PublicBaseUrl { get; set; }
public string? FontPath { get; set; }
}
@@ -0,0 +1,409 @@
using System.Security.Cryptography;
using QRCoder;
using SkiaSharp;
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
public interface IOfficialDocumentPdfGenerator
{
GeneratedOfficialDocument Generate(
OfficialDocumentSnapshot snapshot,
string verificationUrl);
}
public sealed class OfficialDocumentPdfGenerator(
OfficialDocumentOptions options) : IOfficialDocumentPdfGenerator
{
private const float PageWidth = 595;
private const float PageHeight = 842;
private const float Margin = 44;
private const float ContentWidth = PageWidth - Margin * 2;
private static readonly SKColor Ink = new(31, 48, 65);
private static readonly SKColor Muted = new(91, 107, 120);
private static readonly SKColor Accent = new(26, 91, 82);
private static readonly SKColor Pale = new(236, 244, 242);
private static readonly SKColor Rule = new(208, 219, 220);
public GeneratedOfficialDocument Generate(
OfficialDocumentSnapshot snapshot,
string verificationUrl)
{
using var typeface = ResolveTypeface();
using var stream = new MemoryStream();
using (var document = SKDocument.CreatePdf(stream))
{
if (snapshot.Type == Domain.Academic.OfficialDocumentType.Transcript)
DrawTranscript(document, typeface, snapshot, verificationUrl);
else
DrawStatusCertificate(document, typeface, snapshot, verificationUrl);
document.Close();
}
var content = stream.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
return new GeneratedOfficialDocument(content, hash);
}
private void DrawTranscript(
SKDocument document,
SKTypeface typeface,
OfficialDocumentSnapshot snapshot,
string verificationUrl)
{
const int rowsPerPage = 19;
var pageCount = Math.Max(1, (int)Math.Ceiling(snapshot.Grades.Count / (double)rowsPerPage));
for (var pageIndex = 0; pageIndex < pageCount; pageIndex++)
{
using var canvas = document.BeginPage(PageWidth, PageHeight);
DrawPageFrame(canvas);
DrawHeader(canvas, typeface, snapshot, "官方电子成绩单", "OFFICIAL ACADEMIC TRANSCRIPT");
var y = 145f;
if (pageIndex == 0)
{
y = DrawStudentSummary(canvas, typeface, snapshot.Student, y);
y = DrawTranscriptSummary(canvas, typeface, snapshot, y + 12);
}
else
{
DrawText(canvas, typeface, $"{snapshot.Student.Name} · {snapshot.Student.StudentNumber}",
Margin, y, 10, Muted);
y += 20;
}
var rows = snapshot.Grades.Skip(pageIndex * rowsPerPage).Take(rowsPerPage).ToArray();
y = DrawGradeTable(canvas, typeface, rows, y + 14);
if (pageIndex == pageCount - 1)
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, Math.Min(y + 18, 685));
DrawFooter(canvas, typeface, snapshot, pageIndex + 1, pageCount);
document.EndPage();
}
}
private void DrawStatusCertificate(
SKDocument document,
SKTypeface typeface,
OfficialDocumentSnapshot snapshot,
string verificationUrl)
{
using var canvas = document.BeginPage(PageWidth, PageHeight);
DrawPageFrame(canvas);
DrawHeader(canvas, typeface, snapshot, "学籍状态证明", "CERTIFICATE OF STUDENT STATUS");
var student = snapshot.Student;
var statusText = student.Status switch
{
"在读" => "现为我校在读学生",
"休学" => "现为我校注册学生,当前处于休学状态",
"已毕业" => "曾在我校就读,现已毕业",
"退学" => "曾在我校就读,现已办理退学",
_ => $"在我校的学籍状态为“{student.Status}”"
};
DrawText(canvas, typeface, "证 明", PageWidth / 2, 215, 25, Ink, SKTextAlign.Center, true);
var body = $"兹证明,{student.Name}(学号:{student.StudentNumber})," +
$"于 {student.EnrollmentDate:yyyy年M月d日} 入学," +
$"就读于{student.CollegeName}{student.MajorName}专业,行政班级为{student.ClassName}" +
$"{statusText}。";
var y = 285f;
foreach (var line in WrapText(typeface, body, 16, 448))
{
DrawText(canvas, typeface, line, 74, y, 16, Ink);
y += 32;
}
if (!string.IsNullOrWhiteSpace(snapshot.Purpose))
{
DrawText(canvas, typeface, $"用途:{snapshot.Purpose}", 74, y + 14, 11, Muted);
y += 30;
}
DrawText(canvas, typeface, "特此证明。", 74, y + 28, 16, Ink);
DrawText(canvas, typeface, snapshot.InstitutionName, 435, y + 100, 14, Ink,
SKTextAlign.Center, true);
DrawText(canvas, typeface, snapshot.IssuingOffice, 435, y + 126, 12, Ink,
SKTextAlign.Center);
DrawText(canvas, typeface, snapshot.IssuedAt.ToLocalTime().ToString("yyyy年M月d日"),
435, y + 150, 11, Muted, SKTextAlign.Center);
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, 625);
DrawFooter(canvas, typeface, snapshot, 1, 1);
document.EndPage();
}
private static void DrawPageFrame(SKCanvas canvas)
{
canvas.Clear(SKColors.White);
using var border = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.4f };
using var inner = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
canvas.DrawRect(22, 22, PageWidth - 44, PageHeight - 44, border);
canvas.DrawRect(27, 27, PageWidth - 54, PageHeight - 54, inner);
}
private static void DrawHeader(
SKCanvas canvas,
SKTypeface typeface,
OfficialDocumentSnapshot snapshot,
string title,
string englishTitle)
{
DrawText(canvas, typeface, snapshot.InstitutionName, Margin, 66, 16, Accent, bold: true);
DrawText(canvas, typeface, snapshot.IssuingOffice, PageWidth - Margin, 66, 10, Muted,
SKTextAlign.Right);
DrawText(canvas, typeface, title, PageWidth / 2, 99, 23, Ink, SKTextAlign.Center, true);
DrawText(canvas, typeface, englishTitle, PageWidth / 2, 119, 8.5f, Muted,
SKTextAlign.Center);
using var rule = new SKPaint { Color = Accent, StrokeWidth = 1.2f };
canvas.DrawLine(Margin, 132, PageWidth - Margin, 132, rule);
}
private static float DrawStudentSummary(
SKCanvas canvas,
SKTypeface typeface,
OfficialStudentSnapshot student,
float y)
{
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 86), 5, 5, fill);
DrawLabelValue(canvas, typeface, "姓名", student.Name, Margin + 15, y + 24);
DrawLabelValue(canvas, typeface, "学号", student.StudentNumber, 210, y + 24);
DrawLabelValue(canvas, typeface, "学籍状态", student.Status, 404, y + 24);
DrawLabelValue(canvas, typeface, "学院", student.CollegeName, Margin + 15, y + 57);
DrawLabelValue(canvas, typeface, "专业", student.MajorName, 210, y + 57);
DrawLabelValue(canvas, typeface, "班级", student.ClassName, 404, y + 57);
return y + 86;
}
private static float DrawTranscriptSummary(
SKCanvas canvas,
SKTypeface typeface,
OfficialDocumentSnapshot snapshot,
float y)
{
var values = new[]
{
("课程门数", snapshot.Grades.Count.ToString()),
("修读学分", FormatDecimal(snapshot.TotalCredits)),
("获得学分", FormatDecimal(snapshot.EarnedCredits)),
("平均绩点", snapshot.GradePointAverage?.ToString("0.00") ?? "-")
};
var width = ContentWidth / values.Length;
for (var i = 0; i < values.Length; i++)
{
var center = Margin + width * i + width / 2;
DrawText(canvas, typeface, values[i].Item1, center, y + 12, 8.5f, Muted,
SKTextAlign.Center);
DrawText(canvas, typeface, values[i].Item2, center, y + 33, 15, Accent,
SKTextAlign.Center, true);
}
return y + 42;
}
private static float DrawGradeTable(
SKCanvas canvas,
SKTypeface typeface,
IReadOnlyList<OfficialTranscriptRow> rows,
float y)
{
var widths = new[] { 96f, 64f, 160f, 48f, 55f, 45f, 39f };
var headers = new[] { "学期", "课程代码", "课程名称", "学分", "成绩", "绩点", "状态" };
const float headerHeight = 27;
const float rowHeight = 24;
using var headerPaint = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
using var stripe = new SKPaint { Color = new SKColor(247, 249, 249), Style = SKPaintStyle.Fill };
using var rule = new SKPaint { Color = Rule, StrokeWidth = .5f };
canvas.DrawRect(Margin, y, ContentWidth, headerHeight, headerPaint);
var x = Margin;
for (var i = 0; i < headers.Length; i++)
{
DrawText(canvas, typeface, headers[i], x + widths[i] / 2, y + 18, 8.5f,
SKColors.White, SKTextAlign.Center, true);
x += widths[i];
}
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
var rowY = y + headerHeight + rowIndex * rowHeight;
if (rowIndex % 2 == 1)
canvas.DrawRect(Margin, rowY, ContentWidth, rowHeight, stripe);
canvas.DrawLine(Margin, rowY + rowHeight, PageWidth - Margin, rowY + rowHeight, rule);
var values = new[]
{
row.AcademicTerm,
row.CourseCode,
Ellipsize(typeface, row.CourseName, 8.5f, widths[2] - 8),
FormatDecimal(row.Credits),
row.Score?.ToString("0.#") ?? "-",
row.GradePoint?.ToString("0.0#") ?? "-",
row.ExamStatus
};
x = Margin;
for (var i = 0; i < values.Length; i++)
{
DrawText(canvas, typeface, values[i], x + widths[i] / 2, rowY + 16, 8.5f,
Ink, SKTextAlign.Center);
x += widths[i];
}
}
return y + headerHeight + rows.Count * rowHeight;
}
private static void DrawVerificationBlock(
SKCanvas canvas,
SKTypeface typeface,
OfficialDocumentSnapshot snapshot,
string verificationUrl,
float y)
{
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 84), 5, 5, fill);
DrawQrCode(canvas, verificationUrl, Margin + 10, y + 7, 70);
DrawText(canvas, typeface, "扫码验真", Margin + 94, y + 22, 11, Accent, bold: true);
DrawText(canvas, typeface, $"凭证编号:{snapshot.DocumentNumber}", Margin + 94, y + 43, 9.5f, Ink);
DrawText(canvas, typeface, "二维码指向本系统公开验真页;失效或重签后状态会实时更新。",
Margin + 94, y + 62, 8.5f, Muted);
}
private static void DrawFooter(
SKCanvas canvas,
SKTypeface typeface,
OfficialDocumentSnapshot snapshot,
int page,
int pageCount)
{
using var rule = new SKPaint { Color = Rule, StrokeWidth = .6f };
canvas.DrawLine(Margin, 790, PageWidth - Margin, 790, rule);
DrawText(canvas, typeface,
$"签发:{snapshot.IssuedByName} · {snapshot.IssuedAt.ToLocalTime():yyyy-MM-dd HH:mm}",
Margin, 807, 7.5f, Muted);
DrawText(canvas, typeface, $"第 {page} / {pageCount} 页", PageWidth - Margin, 807, 7.5f,
Muted, SKTextAlign.Right);
}
private static void DrawLabelValue(
SKCanvas canvas,
SKTypeface typeface,
string label,
string value,
float x,
float y)
{
DrawText(canvas, typeface, label, x, y, 8, Muted);
DrawText(canvas, typeface, value, x + 42, y, 10, Ink, bold: true);
}
private static void DrawQrCode(SKCanvas canvas, string content, float x, float y, float size)
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
var modules = data.ModuleMatrix;
const int quiet = 4;
var moduleSize = size / (modules.Count + quiet * 2);
using var paint = new SKPaint { Color = Ink, Style = SKPaintStyle.Fill, IsAntialias = false };
for (var row = 0; row < modules.Count; row++)
for (var column = 0; column < modules[row].Count; column++)
{
if (!modules[row][column]) continue;
canvas.DrawRect(
x + (column + quiet) * moduleSize,
y + (row + quiet) * moduleSize,
moduleSize + .08f,
moduleSize + .08f,
paint);
}
}
private static void DrawText(
SKCanvas canvas,
SKTypeface typeface,
string text,
float x,
float y,
float size,
SKColor color,
SKTextAlign align = SKTextAlign.Left,
bool bold = false)
{
using var font = new SKFont(typeface, size) { Embolden = bold };
using var paint = new SKPaint { Color = color, IsAntialias = true };
canvas.DrawText(text, x, y, align, font, paint);
}
private static IReadOnlyList<string> WrapText(
SKTypeface typeface,
string text,
float size,
float maxWidth)
{
using var font = new SKFont(typeface, size);
var lines = new List<string>();
var current = "";
foreach (var character in text)
{
var candidate = current + character;
if (current.Length > 0 && font.MeasureText(candidate) > maxWidth)
{
var breakIndex = current.LastIndexOfAny([' ', '', '。', '', '', '、']);
if (breakIndex >= current.Length / 2)
{
lines.Add(current[..(breakIndex + 1)].TrimEnd());
current = current[(breakIndex + 1)..].TrimStart() + character;
}
else
{
lines.Add(current);
current = character.ToString();
}
}
else
{
current = candidate;
}
}
if (current.Length > 0) lines.Add(current);
return lines;
}
private static string Ellipsize(
SKTypeface typeface,
string text,
float size,
float maxWidth)
{
using var font = new SKFont(typeface, size);
if (font.MeasureText(text) <= maxWidth) return text;
var value = text;
while (value.Length > 1 && font.MeasureText(value + "…") > maxWidth)
value = value[..^1];
return value + "…";
}
private SKTypeface ResolveTypeface()
{
var candidates = new[]
{
options.FontPath,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
};
foreach (var path in candidates.Where(x => !string.IsNullOrWhiteSpace(x)))
{
if (!File.Exists(path)) continue;
var typeface = SKTypeface.FromFile(path);
if (typeface is not null) return typeface;
}
var fallback = SKTypeface.FromFamilyName("Microsoft YaHei") ??
SKTypeface.FromFamilyName("Noto Sans CJK SC");
return fallback ?? throw new InvalidOperationException(
"未找到可用于官方 PDF 的中文字体。请配置 OfficialDocuments:FontPath。");
}
private static string FormatDecimal(decimal value) => value.ToString("0.##");
}
@@ -0,0 +1,165 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
public sealed class OfficialDocumentService(
AppDbContext db,
OfficialDocumentOptions options,
IOfficialDocumentPdfGenerator pdfGenerator)
{
private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web);
public async Task<OfficialDocument> CreateAsync(
Guid studentId,
OfficialDocumentType type,
string? purpose,
Guid issuedByUserId,
string issuedByName,
string verificationBaseUrl,
Guid? reissuedFromDocumentId,
CancellationToken cancellationToken)
{
var student = await db.Students.AsNoTracking()
.Where(x => x.Id == studentId)
.Select(x => new
{
x.StudentNumber,
x.Name,
x.Gender,
x.EnrollmentYear,
x.EnrollmentDate,
x.Status,
CollegeName = x.AdministrativeClass!.Major!.College!.Name,
MajorName = x.AdministrativeClass.Major.Name,
ClassName = x.AdministrativeClass.Name
})
.SingleOrDefaultAsync(cancellationToken)
?? throw new OfficialDocumentValidationException("学生档案不存在。");
var grades = type == OfficialDocumentType.Transcript
? await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.OrderBy(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
.Select(x => new OfficialTranscriptRow(
x.GradeSheet!.TeachingTask!.AcademicTerm!.Name,
x.GradeSheet.TeachingTask.Course!.Code,
x.GradeSheet.TeachingTask.Course.Name,
x.GradeSheet.TeachingTask.Course.Credits,
x.TotalScore,
x.GradePoint,
GradeExamStatusLabel(x.ExamStatus)))
.ToListAsync(cancellationToken)
: [];
if (type == OfficialDocumentType.Transcript && grades.Count == 0)
throw new OfficialDocumentValidationException("该学生暂无已发布成绩,不能签发官方成绩单。");
var issuedAt = DateTime.UtcNow;
var documentNumber = CreateDocumentNumber(type, issuedAt);
var verificationCode = CreateVerificationCode();
var studentSnapshot = new OfficialStudentSnapshot(
student.StudentNumber,
student.Name,
GenderLabel(student.Gender),
student.CollegeName,
student.MajorName,
student.ClassName,
student.EnrollmentYear,
student.EnrollmentDate,
StudentStatusLabel(student.Status));
var totalCredits = grades.Sum(x => x.Credits);
var passedGrades = grades
.Where(x => x.Score >= 60 || x.ExamStatus == "免修")
.ToArray();
var gpaGrades = grades.Where(x => x.GradePoint.HasValue).ToArray();
var gpaCredits = gpaGrades.Sum(x => x.Credits);
var gpa = gpaCredits > 0
? gpaGrades.Sum(x => x.GradePoint!.Value * x.Credits) / gpaCredits
: (decimal?)null;
var snapshot = new OfficialDocumentSnapshot(
options.InstitutionName,
options.IssuingOffice,
documentNumber,
type,
issuedAt,
issuedByName,
string.IsNullOrWhiteSpace(purpose) ? null : purpose.Trim(),
studentSnapshot,
grades,
totalCredits,
passedGrades.Sum(x => x.Credits),
gpa);
var verificationUrl = $"{verificationBaseUrl.TrimEnd('/')}/verify/{verificationCode}";
var generated = pdfGenerator.Generate(snapshot, verificationUrl);
return new OfficialDocument
{
DocumentNumber = documentNumber,
VerificationCodeHash = HashVerificationCode(verificationCode),
Type = type,
Status = OfficialDocumentStatus.Valid,
StudentId = studentId,
IssuedByUserId = issuedByUserId,
IssuedAt = issuedAt,
Purpose = snapshot.Purpose,
SnapshotJson = JsonSerializer.Serialize(snapshot, SnapshotJsonOptions),
PdfContent = generated.Content,
PdfSha256 = generated.Sha256,
ReissuedFromDocumentId = reissuedFromDocumentId
};
}
public static string HashVerificationCode(string verificationCode) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(verificationCode)))
.ToLowerInvariant();
public static OfficialDocumentSnapshot DeserializeSnapshot(string json) =>
JsonSerializer.Deserialize<OfficialDocumentSnapshot>(json, SnapshotJsonOptions)
?? throw new InvalidOperationException("官方凭证快照无法读取。");
private string CreateDocumentNumber(OfficialDocumentType type, DateTime issuedAt)
{
var typeCode = type == OfficialDocumentType.Transcript ? "TR" : "SC";
var random = Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
return $"{options.DocumentNumberPrefix}-{typeCode}-{issuedAt:yyyyMMdd}-{random}";
}
private static string CreateVerificationCode() =>
Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
private static string GenderLabel(Gender value) => value switch
{
Gender.Male => "男",
Gender.Female => "女",
_ => "未登记"
};
private static string StudentStatusLabel(StudentStatus value) => value switch
{
StudentStatus.Active => "在读",
StudentStatus.Suspended => "休学",
StudentStatus.Graduated => "已毕业",
StudentStatus.Withdrawn => "退学",
_ => "未知"
};
private static string GradeExamStatusLabel(GradeExamStatus value) => value switch
{
GradeExamStatus.Normal => "正常",
GradeExamStatus.Absent => "缺考",
GradeExamStatus.Deferred => "缓考",
GradeExamStatus.Exempt => "免修",
GradeExamStatus.Makeup => "补考",
_ => "未知"
};
}
public sealed class OfficialDocumentValidationException(string message) : Exception(message);
@@ -0,0 +1,41 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
public sealed record OfficialDocumentSnapshot(
string InstitutionName,
string IssuingOffice,
string DocumentNumber,
OfficialDocumentType Type,
DateTime IssuedAt,
string IssuedByName,
string? Purpose,
OfficialStudentSnapshot Student,
IReadOnlyList<OfficialTranscriptRow> Grades,
decimal TotalCredits,
decimal EarnedCredits,
decimal? GradePointAverage);
public sealed record OfficialStudentSnapshot(
string StudentNumber,
string Name,
string Gender,
string CollegeName,
string MajorName,
string ClassName,
int EnrollmentYear,
DateOnly EnrollmentDate,
string Status);
public sealed record OfficialTranscriptRow(
string AcademicTerm,
string CourseCode,
string CourseName,
decimal Credits,
decimal? Score,
decimal? GradePoint,
string ExamStatus);
public sealed record GeneratedOfficialDocument(
byte[] Content,
string Sha256);
@@ -0,0 +1,584 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using MySql.Data.MySqlClient;
namespace Jiaowu.Api.Infrastructure.Operations;
public sealed record BackupArtifact(
string Id,
string FileName,
string Provider,
DateTime CreatedAt,
long SizeBytes,
string Sha256,
string? Note,
DateTime? LastDrillAt,
bool? LastDrillSucceeded,
string? LastDrillDetail,
long? LastDrillDurationMilliseconds);
public sealed record RestoreDrillResult(
string BackupId,
bool Succeeded,
DateTime CompletedAt,
string Detail,
long DurationMilliseconds,
int? TableCount);
public sealed class DatabaseBackupService(
DatabaseOptions databaseOptions,
OperationsOptions options,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<DatabaseBackupService> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly SemaphoreSlim operationLock = new(1, 1);
private readonly string backupDirectory = ResolveBackupDirectory(
options.BackupDirectory,
environment.ContentRootPath);
public async Task<IReadOnlyCollection<BackupArtifact>> ListAsync(
CancellationToken cancellationToken)
{
Directory.CreateDirectory(backupDirectory);
var items = new List<BackupArtifact>();
foreach (var metadataPath in Directory.EnumerateFiles(
backupDirectory,
"*.metadata.json",
SearchOption.TopDirectoryOnly))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var stream = File.OpenRead(metadataPath);
var artifact = await JsonSerializer.DeserializeAsync<BackupArtifact>(
stream,
JsonOptions,
cancellationToken);
if (artifact is not null &&
File.Exists(Path.Combine(backupDirectory, artifact.FileName)))
{
items.Add(artifact);
}
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or JsonException)
{
logger.LogWarning(
exception,
"Unable to read backup metadata {MetadataFile}.",
Path.GetFileName(metadataPath));
}
}
return items
.OrderByDescending(x => x.CreatedAt)
.ToArray();
}
public async Task<BackupArtifact> CreateAsync(
string? note,
CancellationToken cancellationToken)
{
await operationLock.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(backupDirectory);
var createdAt = DateTime.UtcNow;
var id = $"{createdAt:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..29];
var provider = NormalizeProvider(databaseOptions.Provider);
var extension = provider == "SQLite" ? ".sqlite" : ".sql";
var fileName = $"jiaowu-{id}{extension}";
var backupPath = Path.Combine(backupDirectory, fileName);
try
{
if (provider == "SQLite")
await CreateSqliteBackupAsync(backupPath, cancellationToken);
else
await CreateMySqlBackupAsync(backupPath, cancellationToken);
var fileInfo = new FileInfo(backupPath);
var artifact = new BackupArtifact(
id,
fileName,
provider,
createdAt,
fileInfo.Length,
await ComputeHashAsync(backupPath, cancellationToken),
NormalizeNote(note),
null,
null,
null,
null);
await WriteMetadataAsync(artifact, cancellationToken);
return artifact;
}
catch
{
if (File.Exists(backupPath))
File.Delete(backupPath);
throw;
}
}
finally
{
operationLock.Release();
}
}
public async Task<RestoreDrillResult> RunRestoreDrillAsync(
string backupId,
CancellationToken cancellationToken)
{
await operationLock.WaitAsync(cancellationToken);
try
{
var artifact = (await ListAsync(cancellationToken))
.SingleOrDefault(x => x.Id.Equals(backupId, StringComparison.Ordinal));
if (artifact is null)
throw new FileNotFoundException("未找到指定备份。");
var backupPath = Path.Combine(backupDirectory, artifact.FileName);
var actualHash = await ComputeHashAsync(backupPath, cancellationToken);
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(artifact.Sha256),
Convert.FromHexString(actualHash)))
{
var damaged = await CompleteDrillAsync(
artifact,
false,
"备份文件校验和不一致,恢复演练已中止。",
0,
null,
cancellationToken);
return damaged;
}
var stopwatch = Stopwatch.StartNew();
RestoreDrillResult result;
try
{
var tableCount = artifact.Provider == "SQLite"
? await DrillSqliteAsync(backupPath, cancellationToken)
: await DrillMySqlAsync(backupPath, cancellationToken);
stopwatch.Stop();
result = await CompleteDrillAsync(
artifact,
true,
$"已在隔离数据库完成恢复并通过完整性检查,共发现 {tableCount} 张业务表。",
stopwatch.ElapsedMilliseconds,
tableCount,
cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
logger.LogError(
exception,
"Restore drill failed for backup {BackupId}.",
artifact.Id);
result = await CompleteDrillAsync(
artifact,
false,
$"恢复演练失败:{SafeMessage(exception)}",
stopwatch.ElapsedMilliseconds,
null,
cancellationToken);
}
return result;
}
finally
{
operationLock.Release();
}
}
private async Task CreateSqliteBackupAsync(
string backupPath,
CancellationToken cancellationToken)
{
var sourceBuilder = new SqliteConnectionStringBuilder(
configuration.GetConnectionString("SQLite")
?? throw new InvalidOperationException("缺少 ConnectionStrings:SQLite。"));
if (!Path.IsPathRooted(sourceBuilder.DataSource))
{
sourceBuilder.DataSource = Path.GetFullPath(
sourceBuilder.DataSource,
environment.ContentRootPath);
}
var destinationBuilder = new SqliteConnectionStringBuilder
{
DataSource = backupPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
};
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
await using var destination = new SqliteConnection(destinationBuilder.ConnectionString);
await source.OpenAsync(cancellationToken);
await destination.OpenAsync(cancellationToken);
source.BackupDatabase(destination);
}
private async Task CreateMySqlBackupAsync(
string backupPath,
CancellationToken cancellationToken)
{
var connection = GetMySqlConnectionBuilder();
var arguments = new List<string>
{
"--protocol=tcp",
$"--host={connection.Server}",
$"--port={connection.Port}",
$"--user={connection.UserID}",
"--single-transaction",
"--quick",
"--routines",
"--triggers",
"--events",
"--hex-blob",
"--default-character-set=utf8mb4"
};
arguments.AddRange(options.MySqlAdditionalArguments);
arguments.Add(connection.Database);
await using var output = new FileStream(
backupPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
81920,
FileOptions.Asynchronous);
await RunToolAsync(
options.MySqlDumpPath,
arguments,
connection.Password,
standardInput: null,
standardOutput: output,
cancellationToken);
}
private static async Task<int> DrillSqliteAsync(
string backupPath,
CancellationToken cancellationToken)
{
var drillPath = Path.Combine(
Path.GetDirectoryName(backupPath)!,
$".restore-drill-{Guid.NewGuid():N}.sqlite");
try
{
var sourceBuilder = new SqliteConnectionStringBuilder
{
DataSource = backupPath,
Mode = SqliteOpenMode.ReadOnly,
Pooling = false
};
var drillBuilder = new SqliteConnectionStringBuilder
{
DataSource = drillPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
};
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
await using var drill = new SqliteConnection(drillBuilder.ConnectionString);
await source.OpenAsync(cancellationToken);
await drill.OpenAsync(cancellationToken);
source.BackupDatabase(drill);
await using var integrity = drill.CreateCommand();
integrity.CommandText = "PRAGMA integrity_check;";
var integrityResult = Convert.ToString(
await integrity.ExecuteScalarAsync(cancellationToken));
if (!string.Equals(integrityResult, "ok", StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"SQLite 完整性检查返回 {integrityResult ?? ""}。");
await using var tables = drill.CreateCommand();
tables.CommandText =
"SELECT COUNT(*) FROM sqlite_master " +
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
return Convert.ToInt32(await tables.ExecuteScalarAsync(cancellationToken));
}
finally
{
if (File.Exists(drillPath))
File.Delete(drillPath);
if (File.Exists($"{drillPath}-shm"))
File.Delete($"{drillPath}-shm");
if (File.Exists($"{drillPath}-wal"))
File.Delete($"{drillPath}-wal");
}
}
private async Task<int> DrillMySqlAsync(
string backupPath,
CancellationToken cancellationToken)
{
var connection = GetMySqlConnectionBuilder();
var drillDatabase = $"jiaowu_restore_drill_{DateTime.UtcNow:yyyyMMddHHmmss}_" +
Guid.NewGuid().ToString("N")[..8];
var adminBuilder = new MySqlConnectionStringBuilder(connection.ConnectionString)
{
Database = ""
};
await using var admin = new MySqlConnection(adminBuilder.ConnectionString);
await admin.OpenAsync(cancellationToken);
try
{
await using (var create = admin.CreateCommand())
{
create.CommandText =
$"CREATE DATABASE `{drillDatabase}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;";
await create.ExecuteNonQueryAsync(cancellationToken);
}
var arguments = new List<string>
{
"--protocol=tcp",
$"--host={connection.Server}",
$"--port={connection.Port}",
$"--user={connection.UserID}",
"--default-character-set=utf8mb4"
};
arguments.AddRange(options.MySqlAdditionalArguments);
arguments.Add($"--database={drillDatabase}");
await using (var input = new FileStream(
backupPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
81920,
FileOptions.Asynchronous))
{
await RunToolAsync(
options.MySqlClientPath,
arguments,
connection.Password,
input,
standardOutput: null,
cancellationToken);
}
await using var count = admin.CreateCommand();
count.CommandText =
"SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema = @schema AND table_type = 'BASE TABLE';";
count.Parameters.AddWithValue("@schema", drillDatabase);
return Convert.ToInt32(await count.ExecuteScalarAsync(cancellationToken));
}
finally
{
try
{
await using var drop = admin.CreateCommand();
drop.CommandText = $"DROP DATABASE IF EXISTS `{drillDatabase}`;";
await drop.ExecuteNonQueryAsync(CancellationToken.None);
}
catch (Exception exception)
{
logger.LogCritical(
exception,
"Unable to remove isolated restore drill database {DatabaseName}.",
drillDatabase);
}
}
}
private async Task RunToolAsync(
string executable,
IReadOnlyCollection<string> arguments,
string password,
Stream? standardInput,
Stream? standardOutput,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardInput = standardInput is not null,
RedirectStandardOutput = standardOutput is not null,
CreateNoWindow = true
};
foreach (var argument in arguments)
startInfo.ArgumentList.Add(argument);
if (!string.IsNullOrEmpty(password))
startInfo.Environment["MYSQL_PWD"] = password;
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException($"无法启动数据库工具 {executable}。");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(options.ToolTimeoutMinutes));
var errorTask = process.StandardError.ReadToEndAsync(timeout.Token);
var inputTask = standardInput is null
? Task.CompletedTask
: CopyInputAsync(standardInput, process.StandardInput.BaseStream, timeout.Token);
var outputTask = standardOutput is null
? Task.CompletedTask
: process.StandardOutput.BaseStream.CopyToAsync(
standardOutput,
timeout.Token);
try
{
await Task.WhenAll(
process.WaitForExitAsync(timeout.Token),
inputTask,
outputTask);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
throw;
}
var error = await errorTask;
if (process.ExitCode != 0)
throw new InvalidOperationException(
$"数据库工具执行失败(退出码 {process.ExitCode}):{TrimToolError(error)}");
}
private static async Task CopyInputAsync(
Stream input,
Stream processInput,
CancellationToken cancellationToken)
{
await input.CopyToAsync(processInput, cancellationToken);
await processInput.FlushAsync(cancellationToken);
processInput.Close();
}
private MySqlConnectionStringBuilder GetMySqlConnectionBuilder()
{
var value = configuration.GetConnectionString("OperationsMySql");
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException(
"MySQL 备份与恢复演练必须配置独立的 " +
"ConnectionStrings:OperationsMySql 运维账号,不能复用日常业务账号。");
}
var builder = new MySqlConnectionStringBuilder(value);
if (string.IsNullOrWhiteSpace(builder.Database))
throw new InvalidOperationException(
"OperationsMySql 连接字符串未指定业务数据库名称。");
return builder;
}
private async Task<RestoreDrillResult> CompleteDrillAsync(
BackupArtifact artifact,
bool succeeded,
string detail,
long durationMilliseconds,
int? tableCount,
CancellationToken cancellationToken)
{
var completedAt = DateTime.UtcNow;
var updated = artifact with
{
LastDrillAt = completedAt,
LastDrillSucceeded = succeeded,
LastDrillDetail = detail,
LastDrillDurationMilliseconds = durationMilliseconds
};
await WriteMetadataAsync(updated, cancellationToken);
return new RestoreDrillResult(
artifact.Id,
succeeded,
completedAt,
detail,
durationMilliseconds,
tableCount);
}
private async Task WriteMetadataAsync(
BackupArtifact artifact,
CancellationToken cancellationToken)
{
var metadataPath = Path.Combine(
backupDirectory,
$"{artifact.Id}.metadata.json");
var temporaryPath = $"{metadataPath}.{Guid.NewGuid():N}.tmp";
try
{
await using (var stream = new FileStream(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
16384,
FileOptions.Asynchronous))
{
await JsonSerializer.SerializeAsync(
stream,
artifact,
JsonOptions,
cancellationToken);
}
File.Move(temporaryPath, metadataPath, overwrite: true);
}
finally
{
if (File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
}
private static async Task<string> ComputeHashAsync(
string path,
CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
return Convert.ToHexString(
await SHA256.HashDataAsync(stream, cancellationToken));
}
private static string ResolveBackupDirectory(
string configuredPath,
string contentRoot)
{
if (string.IsNullOrWhiteSpace(configuredPath))
throw new InvalidOperationException("Operations:BackupDirectory 不能为空。");
return Path.IsPathRooted(configuredPath)
? Path.GetFullPath(configuredPath)
: Path.GetFullPath(configuredPath, contentRoot);
}
private static string NormalizeProvider(string provider) =>
provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)
? "SQLite"
: provider.Equals("MySql", StringComparison.OrdinalIgnoreCase)
? "MySql"
: throw new InvalidOperationException($"不支持数据库 Provider '{provider}'。");
private static string? NormalizeNote(string? note)
{
if (string.IsNullOrWhiteSpace(note)) return null;
var trimmed = note.Trim();
return trimmed.Length <= 200 ? trimmed : trimmed[..200];
}
private static string SafeMessage(Exception exception)
{
var message = exception.GetBaseException().Message;
return message.Length <= 500 ? message : message[..500];
}
private static string TrimToolError(string error)
{
var trimmed = error.Trim();
if (trimmed.Length == 0) return "未返回错误详情。";
return trimmed.Length <= 500 ? trimmed : trimmed[..500];
}
}
@@ -0,0 +1,200 @@
using System.Diagnostics;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
namespace Jiaowu.Api.Infrastructure.Operations;
public sealed record OperationalComponentHealth(
string Key,
string Label,
string Status,
string Backend,
long? LatencyMilliseconds,
string Detail);
public sealed record OperationalHealthSnapshot(
DateTime CheckedAt,
string OverallStatus,
IReadOnlyCollection<OperationalComponentHealth> Components,
BackgroundJobBacklogSnapshot? Backlog);
public sealed class OperationalHealthService(
AppDbContext db,
IServiceProvider services,
IBackgroundJobTransport transport,
BackgroundJobMonitoringService monitoring,
DatabaseOptions databaseOptions,
AppCacheOptions cacheOptions,
IConfiguration configuration)
{
public async Task<OperationalHealthSnapshot> CheckAsync(
CancellationToken cancellationToken)
{
var database = await CheckDatabaseAsync(cancellationToken);
var cache = await CheckCacheAsync(cancellationToken);
var (messaging, backlog) = await CheckMessagingAsync(cancellationToken);
var components = new[] { database, cache, messaging };
var overall = components.Any(x => x.Status == "unhealthy")
? "unhealthy"
: components.Any(x => x.Status == "warning")
? "warning"
: "healthy";
return new OperationalHealthSnapshot(
DateTime.UtcNow,
overall,
components,
backlog);
}
private async Task<OperationalComponentHealth> CheckDatabaseAsync(
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var canConnect = await db.Database.CanConnectAsync(cancellationToken);
stopwatch.Stop();
return canConnect
? new OperationalComponentHealth(
"database",
"数据库",
"healthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
"连接与基础查询正常。")
: new OperationalComponentHealth(
"database",
"数据库",
"unhealthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
"无法建立数据库连接。");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return new OperationalComponentHealth(
"database",
"数据库",
"unhealthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
SafeMessage(exception));
}
}
private async Task<OperationalComponentHealth> CheckCacheAsync(
CancellationToken cancellationToken)
{
if (!cacheOptions.Enabled)
{
return new OperationalComponentHealth(
"cache",
"缓存",
"warning",
"disabled",
null,
"缓存已通过配置关闭,所有查询将直接访问数据源。");
}
var hasRedisConfiguration = !string.IsNullOrWhiteSpace(
configuration.GetConnectionString("Redis"));
var distributedCache = services.GetService<IDistributedCache>();
if (!hasRedisConfiguration || distributedCache is null)
{
return new OperationalComponentHealth(
"cache",
"缓存",
"healthy",
"memory",
null,
"使用进程内混合缓存;服务重启后缓存会自然重建。");
}
var stopwatch = Stopwatch.StartNew();
try
{
await distributedCache.GetAsync(
"jiaowu:operations:health-probe",
cancellationToken);
stopwatch.Stop();
return new OperationalComponentHealth(
"cache",
"缓存",
"healthy",
"redis",
stopwatch.ElapsedMilliseconds,
"Redis 连接与读取探针正常。");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return new OperationalComponentHealth(
"cache",
"缓存",
"unhealthy",
"redis",
stopwatch.ElapsedMilliseconds,
SafeMessage(exception));
}
}
private async Task<(OperationalComponentHealth Component,
BackgroundJobBacklogSnapshot? Backlog)> CheckMessagingAsync(
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
BackgroundJobBacklogSnapshot? backlog = null;
try
{
var healthy = await transport.CheckHealthAsync(cancellationToken);
backlog = await monitoring.GetSnapshotAsync(cancellationToken);
stopwatch.Stop();
var stuck = backlog.ExpiredLeases > 0 ||
backlog.OldestUnfinishedAgeSeconds > 1800;
var status = !healthy
? "unhealthy"
: stuck
? "warning"
: "healthy";
var detail = !healthy
? "后台任务传输不可用。"
: stuck
? $"发现 {backlog.ExpiredLeases} 个过期租约,最早未完成任务已等待 " +
$"{Math.Round(backlog.OldestUnfinishedAgeSeconds ?? 0)} 秒。"
: $"待发布 {backlog.Pending + backlog.Publishing}" +
$"待处理 {backlog.Published + backlog.Processing}。";
return (
new OperationalComponentHealth(
"messaging",
"后台任务通道",
status,
transport.IsDurable ? "rabbitmq" : "memory",
stopwatch.ElapsedMilliseconds,
detail),
backlog);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return (
new OperationalComponentHealth(
"messaging",
"后台任务通道",
"unhealthy",
transport.IsDurable ? "rabbitmq" : "memory",
stopwatch.ElapsedMilliseconds,
SafeMessage(exception)),
backlog);
}
}
private static string SafeMessage(Exception exception)
{
var message = exception.GetBaseException().Message;
return message.Length <= 300 ? message : message[..300];
}
}
@@ -0,0 +1,13 @@
namespace Jiaowu.Api.Infrastructure.Operations;
public sealed class OperationsOptions
{
public const string SectionName = "Operations";
public string BackupDirectory { get; set; } = "data/backups";
public int BackupWarningHours { get; set; } = 24;
public int ToolTimeoutMinutes { get; set; } = 30;
public string MySqlDumpPath { get; set; } = "mysqldump";
public string MySqlClientPath { get; set; } = "mysql";
public string[] MySqlAdditionalArguments { get; set; } = [];
}
@@ -22,6 +22,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<Student> Students => Set<Student>();
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
public DbSet<Course> Courses => Set<Course>();
public DbSet<CoursePrerequisite> CoursePrerequisites => Set<CoursePrerequisite>();
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
@@ -41,8 +42,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
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<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
Set<CourseSelectionRoundGrade>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
Set<CourseSelectionOffering>();
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
@@ -52,10 +68,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
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 =>
@@ -75,6 +104,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
@@ -89,7 +119,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<GraduationClearanceItem>();
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
Set<GraduationClearanceRecord>();
public DbSet<OfficialDocument> OfficialDocuments => Set<OfficialDocument>();
public DbSet<OfficialDocumentDownload> OfficialDocumentDownloads =>
Set<OfficialDocumentDownload>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
Set<BackgroundJobOutboxMessage>();
public DbSet<AppUpdateRelease> AppUpdateReleases =>
Set<AppUpdateRelease>();
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
@@ -117,6 +155,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.DisplayName).HasMaxLength(50);
entity.Property(x => x.StaffNumber).HasMaxLength(30);
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
entity.HasIndex(x => x.StaffNumber);
});
@@ -125,6 +164,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);
@@ -203,8 +257,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 });
@@ -236,6 +307,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CoursePrerequisite>(entity =>
{
entity.HasIndex(x => new { x.CourseId, x.PrerequisiteCourseId })
.IsUnique();
entity.HasOne(x => x.Course)
.WithMany(x => x.Prerequisites)
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.PrerequisiteCourse)
.WithMany(x => x.RequiredByCourses)
.HasForeignKey(x => x.PrerequisiteCourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CurriculumPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -364,6 +449,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ScheduleEntry>(entity =>
{
entity.Property(x => x.Kind)
.HasDefaultValue(ScheduleEntryKind.Lecture);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
@@ -465,6 +552,176 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<ClassroomReservation>(entity =>
{
entity.Property(x => x.ApplicantName).HasMaxLength(50);
entity.Property(x => x.Purpose).HasMaxLength(200);
entity.Property(x => x.ContactPhone).HasMaxLength(30);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.ApplicantUserId, x.Status, x.CreatedAt });
entity.HasIndex(x => new
{
x.ApplicantCollegeId,
x.Status,
x.ReservationDate
});
entity.HasIndex(x => new
{
x.ClassroomId,
x.ReservationDate,
x.Status,
x.StartPeriod
});
entity.HasOne(x => x.ApplicantUser)
.WithMany()
.HasForeignKey(x => x.ApplicantUserId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ApplicantCollege)
.WithMany()
.HasForeignKey(x => x.ApplicantCollegeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ReviewedByUser)
.WithMany()
.HasForeignKey(x => x.ReviewedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<ExperimentProject>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(40);
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.Property(x => x.Requirements).HasMaxLength(1000);
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentSession>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.ExperimentProjectId,
x.SessionDate,
x.StartPeriod
});
entity.HasIndex(x => new
{
x.ClassroomId,
x.SessionDate,
x.Status,
x.StartPeriod
});
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentBooking>(entity =>
{
entity.HasIndex(x => new { x.ExperimentProjectId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new
{
x.ExperimentSessionId,
x.Status,
x.BookedAt
});
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Bookings)
.HasForeignKey(x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Bookings)
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeSheet>(entity =>
{
entity.Property(x => x.ContributionWeight).HasPrecision(5, 1);
entity.Property(x => x.PassScore).HasPrecision(5, 1);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => x.ExperimentProjectId).IsUnique();
entity.HasOne(x => x.ExperimentProject)
.WithOne(x => x.GradeSheet)
.HasForeignKey<ExperimentGradeSheet>(
x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeItem>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.SortOrder
});
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Items)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeRecord>(entity =>
{
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
entity.Property(x => x.SubmissionReference).HasMaxLength(500);
entity.Property(x => x.TeacherComment).HasMaxLength(500);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.StudentId
}).IsUnique();
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Records)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentSession).WithMany()
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity =>
{
entity.HasKey(x => new
{
x.ExperimentGradeRecordId,
x.ExperimentGradeItemId
});
entity.Property(x => x.Score).HasPrecision(5, 1);
entity.Property(x => x.Comment).HasMaxLength(300);
entity.HasOne(x => x.ExperimentGradeRecord)
.WithMany(x => x.ItemScores)
.HasForeignKey(x => x.ExperimentGradeRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentGradeItem)
.WithMany(x => x.Scores)
.HasForeignKey(x => x.ExperimentGradeItemId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseSelectionRound>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -477,6 +734,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSelectionRoundGrade>(entity =>
{
entity.HasIndex(x => new { x.CourseSelectionRoundId, x.Grade })
.IsUnique();
entity.HasIndex(x => x.Grade);
entity.HasOne(x => x.CourseSelectionRound)
.WithMany(x => x.EligibleGrades)
.HasForeignKey(x => x.CourseSelectionRoundId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseSelectionOffering>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
@@ -500,6 +768,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.StudentId, x.Status });
entity.HasIndex(x => new
{
x.CourseSelectionOfferingId,
x.Status,
x.WaitlistedAt
}).HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt");
entity.HasOne(x => x.CourseSelectionOffering)
.WithMany(x => x.Enrollments)
.HasForeignKey(x => x.CourseSelectionOfferingId)
@@ -527,6 +801,8 @@ 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);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items)
@@ -601,6 +877,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);
@@ -616,6 +915,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()
@@ -633,6 +933,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);
@@ -648,6 +1029,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()
@@ -882,6 +1264,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
entity.HasIndex(x => x.ApplicantUserId);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.SourceScheduleEntryId, x.SourceWeek });
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
@@ -893,10 +1276,26 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<Notification>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasMaxLength(1000);
entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
entity.HasIndex(x => x.MessageDispatchId);
entity.HasIndex(x => x.CreatedAt);
entity.HasOne(x => x.MessageDispatch)
.WithMany(x => x.Notifications)
.HasForeignKey(x => x.MessageDispatchId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessageDispatch>(entity =>
{
entity.Property(x => x.SenderName).HasMaxLength(100);
entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.AudienceName).HasMaxLength(200);
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
});
builder.Entity<AuditLog>(entity =>
@@ -907,6 +1306,99 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.HasIndex(x => x.CreatedAt);
});
builder.Entity<BackgroundJobOutboxMessage>(entity =>
{
entity.Property(x => x.LastError).HasMaxLength(2000);
entity.HasIndex(x => new { x.JobKind, x.JobId }).IsUnique();
entity.HasIndex(x => new { x.State, x.CreatedAt });
entity.HasIndex(x => new { x.State, x.CompletedAt });
entity.HasIndex(x => x.LeaseExpiresAt);
});
builder.Entity<AppUpdateRelease>(entity =>
{
entity.Property(x => x.Platform)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Channel)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Version).HasMaxLength(40);
entity.Property(x => x.NativeVersion).HasMaxLength(40);
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
entity.Property(x => x.FileName).HasMaxLength(180);
entity.Property(x => x.Sha256).HasMaxLength(64);
entity.Property(x => x.BundleContent).HasColumnType("longblob");
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Version
}).IsUnique();
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Status
});
entity.HasIndex(x => x.CreatedAt);
});
builder.Entity<OfficialDocument>(entity =>
{
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
entity.Property(x => x.VerificationCodeHash).HasMaxLength(64);
entity.Property(x => x.Purpose).HasMaxLength(200);
entity.Property(x => x.SnapshotJson).HasColumnType("longtext");
entity.Property(x => x.PdfContent).HasColumnType("longblob");
entity.Property(x => x.PdfSha256).HasMaxLength(64);
entity.Property(x => x.InvalidationReason).HasMaxLength(500);
entity.HasIndex(x => x.DocumentNumber).IsUnique();
entity.HasIndex(x => x.VerificationCodeHash).IsUnique();
entity.HasIndex(x => new { x.StudentId, x.IssuedAt });
entity.HasIndex(x => new { x.Status, x.IssuedAt });
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.IssuedByUser)
.WithMany()
.HasForeignKey(x => x.IssuedByUserId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.InvalidatedByUser)
.WithMany()
.HasForeignKey(x => x.InvalidatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasIndex(x => x.ReissuedFromDocumentId).IsUnique();
entity.HasOne(x => x.ReissuedFromDocument)
.WithMany()
.HasForeignKey(x => x.ReissuedFromDocumentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<OfficialDocumentDownload>(entity =>
{
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.Property(x => x.UserAgent).HasMaxLength(500);
entity.HasIndex(x => new { x.OfficialDocumentId, x.CreatedAt });
entity.HasIndex(x => new { x.DownloadedByUserId, x.CreatedAt });
entity.HasOne(x => x.OfficialDocument)
.WithMany(x => x.Downloads)
.HasForeignKey(x => x.OfficialDocumentId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.DownloadedByUser)
.WithMany()
.HasForeignKey(x => x.DownloadedByUserId)
.OnDelete(DeleteBehavior.Restrict);
});
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -12,7 +12,7 @@ public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbConte
?? "Server=localhost;Port=3306;Database=jiaowu;User=__design_time__;Password=__not_used__;";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySQL(connectionString)
.UseMySQL(MySqlConnectionStringPolicy.ForApplication(connectionString))
.Options;
return new AppDbContext(options);

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