103 Commits
  • 学业风险雷达已接入首页,复用现有正式预警规则、检测、通知和确认流程:
    学生首页:显示当前生效预警的自然语言摘要与最多 3 条重点事项,可直达处理页。
    辅导员首页:显示所管班级的生效预警学生与详情,可直达完整预警列表。
    风险接口加载失败会明确提示,不会误报“暂无风险”。
    混合“学生 + 辅导员”角色会优先进入辅导员工作台。
  • 新建时可选“自动生成顺序号”:例如 数据库实验 01、02…
    或选“逐行粘贴名称”:每行一个名称,按实际生成顺序对应项目。
    草稿项目支持勾选后“批量改名”,可直接粘贴多行名称。
    服务端会校验名称数量、长度和草稿状态,避免错位或修改已发布项目。
  • 修复实验成绩管理:
    管理列表改为服务端分页,支持 10/20/50 条切换。
    增加学期、成绩状态、开课学院以及项目/课程/教学班/教师关键词筛选。
    数据范围分级:校级管理员可查看全校,院级管理员仅限本学院,教师仅限本人任课项目。
    修复任课教师提交审核时报“无权限”:工作流查询现在会加载任课教师关系。
    增加权限、学院隔离、分页和筛选回归测试。
  • “教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
    配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。
    后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。
    扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
  • 同学期、同课程、同教学班的实验项目合并展示,组内连续排列。
    管理列表改为数据库筛选、按教学任务服务端分页(10–100 条/页)。
    增加开课学院、课程名称/编码、教学班、任课教师、教学任务号筛选。
    增加当前页草稿项目的批量选中、批量发布、批量删除;后端会再次校验权限、状态和发布条件。
    确认框改为固定居中白色底板与阴影,不再出现只有文字按钮、没有底色的情况。
  • 已修复:
    实验课场地区块改为独立卡片布局,校区、教学楼和场地选择不再被嵌套表单挤压。
    批量设置新增独立的“批量指定实验课场地”入口,包含场地性质、实验校区、教学楼和具体场地,并会联动筛选。
    后端会保存并校验批量实验场地范围,避免跨校区/教学楼的无效设置。
  • 发布课表投影改为数据库端删除旧数据、每 2,000 条分批写入并及时释放 EF 跟踪对象,避免大规模发布时内存持续增长。
    为“空闲教室/预约占用”新增 学期 + 周次 + 星期 + 节次 + 场地 查询索引;新发布课表的该查询已优先走课次明细投影。
  • 空闲教室与场地预约占用检查,现在对新发布课表优先查询 PublishedScheduleOccurrences。
    查询直接按“学期 + 周次 + 星期 + 节次”命中投影索引,不再扫描周期规则并在应用层判断单双周。
    历史已发布课表若尚未生成投影,自动回退到原规则表,兼容现有数据。
    调停课审批后会重建受影响教学任务的已发布投影,因此占用结果同步更新。
  • 发布课表时,在同一事务内生成实际周次课次明细。
    已审批的调课、停课、补课、代课后,会重建受影响教学任务所在已发布课表的明细投影。
  • 新增 PublishedScheduleOccurrences:发布时将周期规则按实际周次展开为只读课次明细。
    明细保留课表版本、学期、来源排课条目、教学任务、场地、周次、星期和节次,并建立面向教学任务/周次、课表/场地/周次的索引。
    明细在“发布课表”的同一事务内生成;发布失败不会留下半成品。
  • Merge branch 'codex/shiyan' into master
    # Conflicts:
    #	web/package-lock.json
  • 滑动续期与自动刷新:
    Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。
    App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。
    普通登录和 SSO 使用同一策略。
    刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。
    网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
  • 班级查询按“考试场次”合并,显示为“分散至 N 个考场”,不再生成大量重复卡片。[后端实现 (line 416)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs:416)
    学生登录后只显示本人座位对应的考场,同时修复混排考场可能带出其他课程的问题。[筛选逻辑 (line 561)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs:561)
    多考场卡片提示学生登录查看本人考场及监考教师。[前端展示 (line 1122)](E:/jiaowu/web/src/views/TimetableView.vue:1122)
    教室课表、监考教师课表仍按具体考场显示,不受合并影响。
    已增加班级合并和学生精准考场回归测试。[测试 (line 183)](E:/jiaowu/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs:183)
  • 实验课现在直接进入普通排课流程,不再要求先到实验排课模块逐个安排。
    普通“添加排课”新增“理论课 / 实验课”类型。
    自动排课会分别补足理论学时和实验学时。
    实验课只能安排到实验室、实训室、机房、语音室等场地。
    发布课表时分别校验理论、实验学时;任一未排足都不能发布。
    普通课表及 Excel 导出会标注“实验课”。
    历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
  • 支持为同一学期、同一课程的多个教学任务批量创建相同实验项目。
    新增批量排课表,可为多个实验项目分别设置日期、节次、实验室和容量。
    批量排课采用原子事务:任一项目发生教室、教师、班级或课表冲突,整批不写入。
    已发布项目批量新增场次时,继续按原逻辑通知学生。
    后端继续执行管理范围、教学任务状态、开放日期和容量校验。
    页面在 390px 手机宽度下无横向溢出。
  • 超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:
    请求量、5xx 比例、HTTP/数据库 P95
    请求速率与延迟趋势
    最慢接口排行
    慢查询与数据库查询排行
    Grafana 原始调用链入口
  • 接入 OpenTelemetry 1.17.0,覆盖 HTTP、HttpClient、.NET Runtime 和数据库链路。
    新增 EF Core 数据库拦截器,记录耗时、失败数、慢查询数、TraceId、查询标签和 SQL 哈希;默认不记录完整 SQL及参数。
    未配置 OTLP Collector 时不启动 SDK,避免无收益的性能开销。
    为课表的作息、课程、灵活课程、考试、实验等六类查询增加稳定标签。
    增加可配置的 500ms 慢查询阈值,以及生产环境变量示例。
    README 补充 MySQL 慢查询与 EXPLAIN ANALYZE 操作规范。
  • 课表查询中最明显的性能优化。
    将混合考场查询从“加载整个学期的考场、座位、教师、班级后在内存筛选”,改为在数据库中按班级、教师或教室直接筛选。
    改为窄字段投影,避免创建大量 EF 实体和关系对象。
    添加查询标签,便于后续在 MySQL 慢查询和链路追踪中定位。
    保留现有 HybridCache/可选 Redis 缓存体系,无需新增依赖或数据库迁移。
    增加教室、无关教室、监考教师三种回归场景。
  • 智能开屏:显示“姓名+早上/中午/下午/晚上问候”,支持春节、端午、中秋、国庆等节日文案,可点击跳过:[SmartLaunchScreen.vue](E:/jiaowu/web/src/components/SmartLaunchScreen.vue)
    长按 App 图标:提供“我的课表、考试安排、课堂签到、消息中心”四个入口。签到会按学生/教师角色自动分流:[shortcuts.xml](E:/jiaowu/web/native/android/app/src/main/res/xml/shortcuts.xml)
    桌面小组件:新增“今日课表”和“近期考试”,展示最近同步的数据,点击可进入对应页面:[WidgetRenderer.java](E:/jiaowu/web/native/android/app/src/main/java/edu/mingxu/jiaowu/WidgetRenderer.java)
    安全处理:组件只缓存课程和考试摘要,不保存登录令牌;退出账号会清空组件,隔天未刷新时不会继续展示旧的“今日课表”。
    原生模板已纳入版本管理,每次 npm run cap:sync 会自动恢复到被忽略的 Android 工程:[configure-capacitor.mjs](E:/jiaowu/web/scripts/configure-capacitor.mjs)
  • 已改成“普通排课与实验学时分离”:
    普通课表学时 = 总学时 − 实践学时。
    自动排课、手工排课、课表发布都会阻止把实践学时重复排入。
    全部为实践学时的课程应设为“非排时课程”,再由实验管理安排。
    教学任务和排课页面会显示“常规 / 实践学时”。
    已有教学任务即使仍保存旧周学时,排课时也会按拆分后的课程学时计算。
    无需数据库迁移。
  • 已按“实验成绩独立管理”完成修改。
    每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。
    流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。
    自主预约实验支持同步有效预约名单,已评分记录不会被误删。
    课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。
    实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。
    一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。
    已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。
    界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
  • 考试按实际日期、周次和节次融入周/日/总视图,并用橙色“考试”卡片区分。
    即使固定课表尚未发布,只要考试已发布,也能正常显示。
    考试计划、考场、监考教师和日期都会显示。
    Excel 导出同步包含考试安排。
  • app
  • 变更摘要:考试安排注入课表
    问题根因
    
      TimetableDataService.LoadExamEntriesAsync 只查询旧的 ExamSession(legacy 直接分配教室),完全忽略自动编排生成的
      ExamRoomAssignment(混排考场),导致:
    
      ┌──────────┬──────────────────────────┬───────────────────────────────────┐
      │ 课表类型 │           之前           │               之后                │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 教室课表 │ return [] 硬编码空数组   │ 查询 legacy + mixed room 两类数据 │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 教师课表 │ 只显示监考               │ 监考 + 自己授课的考试             │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 学生课表 │ 只通过 CourseEnrollments │ 同时查 ExamSeats(座位分配)      │
      ├──────────┼──────────────────────────┼───────────────────────────────────┤
      │ 空闲教室 │ 只排除 legacy 考试占用   │ 同时排除混排考场占用              │
      └──────────┴──────────────────────────┴───────────────────────────────────┘
    
      修改的文件
    
      1. TimetableDataService.cs — 重写 LoadExamEntriesAsync
    
      - 拆分为两个数据源:legacy ExamSessions + mixed ExamRoomAssignment(通过 ExamRoomSession 关联)
      - 新增 ApplyLegacyResourceFilter / ApplyMixedResourceFilter
      两个过滤方法,按资源类型(Classroom/Teacher/Student/Class)分别过滤
      - 新增 MapToEntryDto 统一映射到 TimetableEntryDto
      - 新增 ExamSessionProjection record 统一两个数据源的投影
    
      各资源类型的数据来源:
    
      ┌───────────┬────────────────────────────────┬──────────────────────────────────────┐
      │ 资源类型  │          Legacy 过滤           │           Mixed-room 过滤            │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Classroom │ ExamSessions.ClassroomId == id │ ExamRoom.ClassroomId == id           │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Teacher   │ 监考 + 授课教师                │ 监考 + 授课教师                      │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Student   │ CourseEnrollments + 行政班     │ ExamSeats.StudentId                  │
      ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
      │ Class     │ TeachingTask.Classes           │ SessionLinks → ExamSession → Classes │
      └───────────┴────────────────────────────────┴──────────────────────────────────────┘
    
      2. ClassroomReservationAvailabilityService.cs — 补充混排考场占用
    
      在 GetOccupiedClassroomIdsAsync 新增 ExamRoomAssignment 查询,空闲教室查询现在会正确排除混排考场占用的教室。
    
      3. TimetablesController.cs — 课表选项含考试学期
    
      - 学期级的 HasPublishedTimetable 增加 ExamPlans.Status == Published 判断
      - 班级级的 HasPublishedTimetable 增加 ExamSessions 关联判断
  • 参照现有的 ExamArrangementJob / SchedulePublishJob 后台任务模式,将发布改为通过 RabbitMQ
    队列异步执行,拆分查询消除笛卡尔积。
    
      修改的文件(共 13 个)
    
      ┌───────────────────────────────────────────────────────────────┬──────────────────────────────────────────────────┐
      │                             文件                              │                       变更                       │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Domain/Academic/ExamEntities.cs                               │ 新增 ExamPublishJob 实体 + ExamPublishJobStatus  │
      │                                                               │ / ExamPublishJobKind 枚举                        │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Domain/System/BackgroundJobOutboxMessage.cs                   │ BackgroundJobKind 新增 ExamPublish = 6           │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/BackgroundJobOptions.cs         │ 新增 ExamPublishConcurrency 配置项               │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/BackgroundJobRunner.cs          │ RunAsync 和 MarkJobRetryLimitExceeded 添加       │
      │                                                               │ ExamPublish 分支                                 │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs       │ JobKinds 数组和 RoutingKey 添加 ExamPublish →    │
      │                                                               │ "exam.publish"                                   │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs │ 启动恢复逻辑添加 ExamPublishJobs                 │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/Persistence/AppDbContext.cs                    │ 新增 ExamPublishJobs DbSet                       │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Controllers/OperationsController.cs                           │ CountFailedJobsAsync / GetFailedJobs             │
      │                                                               │ 添加考试发布失败统计和筛选                       │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Program.cs                                                    │ 校验 ExamPublishConcurrency + 注册               │
      │                                                               │ ExamPublishJobProcessor                          │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Infrastructure/Exams/ExamPublishJobs.cs                       │ 新文件 —                                         │
      │                                                               │ ExamPublishJobProcessor,拆分查询校验后发布      │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Controllers/ExamsController.cs                                │ Publish 改为创建后台任务 + 202 返回;新增 GET    │
      │                                                               │ publish-jobs/{id} / GET plans/{id}/publish-job   │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ Controllers/MakeupExamsController.cs                          │ 同上改造                                         │
      ├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
      │ tests/.../TeachingWorkflowRosterTests.cs                      │ 更新测试适配新的异步发布模式                     │
      └───────────────────────────────────────────────────────────────┴──────────────────────────────────────────────────┘
    
      笛卡尔积消除
    
      之前:一个 Include 链拉全部 → EF Core 生成 Sessions × Invigilators × RoomLinks × Seats 笛卡尔积
    
      之后:
      - 场次计数:db.ExamSessions.CountAsync(无 JOIN)
      - 场次摘要:Select new { Id, ClassroomId, InvigilatorCount, RoomLinkCount }(只查所需列)
      - 容量超限:db.ExamRooms.Select(r => new { SeatCount = r.Seats.Count, Capacity })(单表 JOIN)
      - 课程冲突:db.ExamRoomSessions.Where(link => ...CourseId != link.ExamRoom!.CourseId)(独立查询)
      - 每个查询只做自己需要的 JOIN,互不干扰
    
      测试结果
    
      213 通过,0 失败,0 跳过
    
      配置方式
    
      - BackgroundJobs__Transport=RabbitMq → 走 RabbitMQ 队列 jiaowu.background-jobs.exam.publish
      - BackgroundJobs__Transport=InMemory(默认) → 走内存 Channel
      - BackgroundJobs__ExamPublishConcurrency=1(默认,可调 1-16)
  • 1. 教室容量按 1/2 计算
    - ExamArrangementService.cs: SelectRooms() 使用 Capacity / 2 选择房间、计算剩余座位和总容量
      - MakeupExamArrangementService.cs: 数据库查询用 x.Capacity >= enrolledCount * 2(等价于 Capacity/2 >=
      enrolledCount),消息显示有效座位数
    
      2. 教学楼限制多选
    
      - Domain: ExamSession 和 MakeupExamSession 新增 RequiredBuildingIds (JSON string),保留旧 RequiredBuildingId 向后兼容
      - Service: RoomGroupKey 改为字符串键确保值相等;GroupKey() 合并新旧字段
      - Controller: 请求 DTO 增加 RequiredBuildingIds (Guid 数组),响应包含该字段
      - DB: MySQL 迁移 + SQLite migrator 添加新列
      - Frontend: <el-select> 改为 multiple,新增 parseBuildingIds() 解析服务器返回的 JSON
    
      3. 导出签名单后台任务
    
      - 新增: ExamSignInExportJob 实体、ExamSignInExportJobProcessor、ExamSignInExportJobStatus 枚举
      - BackgroundJobKind: 新增 ExamSignInExport = 5
      - RabbitMQ: routing key exam.sign-in-export,队列 jiaowu.background-jobs.exam.sign-in-export
      - API: POST /sign-in-export 创建任务返回 202;GET /sign-in-exports/{jobId} 查询状态;GET
      /sign-in-exports/{jobId}/download 下载文件
      - Frontend: 导出改为异步任务 + 轮询 + 自动下载,显示进度条
      - 恢复: OutboxPublisher 启动时恢复未完成的任务,重试超限自动标记失败
  • 自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
    任务参数、状态和结果持久化到 MySQL。
    接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
    服务重启后可恢复未完成任务。
    前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
    编排期间禁止修改、删除或发布对应计划。
    补考原有“一键生成”保留,并与编排任务互斥。
    运维后台增加“考试与补考编排”失败任务筛选。
  • 草稿计划支持选择场次后“批量移除已选场次”,一次最多 100 个。
    批量操作会完整校验:包含不存在、其他计划或已发布计划的场次时整批拒绝,不会部分删除。[ExamsController.cs (line 384)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:384)
    草稿计划新增“删除草稿”按钮,删除前显示计划名称及场次数量确认。[ExamsView.vue (line 292)](E:/jiaowu/web/src/views/ExamsView.vue:292)
    删除草稿计划会级联清理场次和监考关联;已发布计划不能删除。[ExamsController.cs (line 81)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:81)
    删除后会自动切换到其他计划;没有剩余计划时正确显示空状态。
    新增草稿、已发布、跨计划混选及级联删除测试。[ExamDeletionControllerTests.cs (line 17)](E:/jiaowu/tests/Jiaowu.Api.Tests/ExamDeletionControllerTests.cs:17)
  • 已修改完成,同时解决了“考生名单都是 0 人”的根因。
    考生范围统一为“行政班关联学生 + 已选课学生”,并自动去重;考场分配、人数显示、冲突检测、学生考试安排均使用同一口径。[TeachingTaskRosterQuery.cs (line 40)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs:40)
    新增考场签名单 Excel 导出接口。[ExamsController.cs (line 568)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:568)
    考试计划页面新增“导出考场签名单”按钮。[ExamsView.vue (line 255)](E:/jiaowu/web/src/views/ExamsView.vue:255)
    工作簿包含“考场汇总”以及每个考试场次独立的 A4 签到表,字段包括座位号、学号、姓名、行政班、考生签名和备注。[ExamSignInWorkbookExporter.cs (line 12)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs:12)
    未分配考场或监考教师时,也能导出并显示“待分配”。
  • “教务总览”改成可实际工作的分级工作台:
    校级管理员查看全校数据,学院管理员仅查看本学院数据。
    待办按当前审批阶段统计,涵盖授课资格、成绩、调停课、学籍异动、教室借用等。
    增加学期进度、教学任务发布、课表覆盖、成绩发布状态。
    快捷入口根据管理员角色自动调整。
    完善未配置学期、无待办、加载失败等状态。
    适配桌面和 390px 移动端,无横向溢出。
  • “运维与审计控制台”。
    主要能力:
    SuperAdmin 专用入口:组织与权限 → 运维与审计。
    操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。
    汇总自动排课、课表发布、补考安排三类失败后台任务。
    实时检查数据库、缓存、任务通道及积压状态。
    聚合 5xx、失败/重试任务、健康探针和备份时效告警。
    SQLite 在线备份;MySQL 调用原生客户端备份。
    SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。
    MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
  • 发信改为事务内每 300 人分批写入,避免全校群发一次插入过大;数据库异常现在返回明确的 503,不再只显示模糊 500。
    校级、学院级管理员支持按学院、身份、行政班、教学班、姓名/学号/工号筛选,也可指定最多 500 名收件人。所有结果均由服务端重新校验权限。
    接入 CKEditor 5,支持标题、列表、引用和链接;正文扩展为 MySQL longtext。
    富文本在收件箱、详情弹窗和已发送记录中统一经 DOMPurify 净化后展示。
    页面改用系统现有的冷白、靛蓝、青绿色令牌,新增“选择收件人 → 编辑内容”工作台和移动端适配。
298 changed files with 239964 additions and 1865 deletions
+3
View File
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
MYSQL_PASSWORD= MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD= MYSQL_ROOT_PASSWORD=
CLICKHOUSE_USER=jiaowu_analytics
CLICKHOUSE_PASSWORD=
RABBITMQ_USER=jiaowu RABBITMQ_USER=jiaowu
RABBITMQ_PASSWORD= RABBITMQ_PASSWORD=
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1 BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
+75 -1
View File
@@ -2,10 +2,20 @@
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://0.0.0.0:8080 ASPNETCORE_URLS=http://0.0.0.0:8080
# 反向代理必须在转发请求时设置 X-Forwarded-For 和 X-Forwarded-Proto。
# 仅填写实际直接连接 API 的代理 IP;多个代理依次使用 __0、__1……。
# 使用 Docker 时通常是宿主机/代理容器在 Docker 网络中的 IP,而非访客 IP。
# 默认仅信任 127.0.0.1 和 ::1。
# ReverseProxy__TrustedProxies__0=127.0.0.1
# ReverseProxy__TrustedProxies__1=::1
Database__Provider=MySql Database__Provider=MySql
Database__ApplyMigrationsOnStartup=false Database__ApplyMigrationsOnStartup=false
Database__CommandTimeoutSeconds=30 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;" 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 是可选加速器;留空时应用仅使用进程内缓存。 # Redis 是可选加速器;留空时应用仅使用进程内缓存。
# ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false" # ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false"
@@ -14,6 +24,8 @@ BackgroundJobs__Transport=InMemory
BackgroundJobs__AutomaticScheduleConcurrency=1 BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1 BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1 BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
BackgroundJobs__CourseGradeStatisticsRefreshConcurrency=1
# RabbitMq__HostName=rabbitmq.example.edu.cn # RabbitMq__HostName=rabbitmq.example.edu.cn
# RabbitMq__Port=5671 # RabbitMq__Port=5671
# RabbitMq__UserName=jiaowu # RabbitMq__UserName=jiaowu
@@ -32,13 +44,75 @@ Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30 Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048 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
# ClickHouse 仅作为异步分析读模型,不参与教务事务写入。启用前请为应用创建
# 仅能操作该分析库的独立账号,并通过 TLS 或受信任的内网访问。
ClickHouseAnalytics__Enabled=false
# ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
# ClickHouseAnalytics__Database=jiaowu_analytics
# ClickHouseAnalytics__UserName=jiaowu_analytics
# ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
ClickHouseAnalytics__CreateSchemaOnStartup=true
ClickHouseAnalytics__SyncIntervalSeconds=60
ClickHouseAnalytics__SourceLookbackDays=90
ClickHouseAnalytics__BatchSize=1000
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
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__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web Jwt__Audience=Jiaowu.Web
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES 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 AllowedHosts=jiaowu.example.edu.cn
Cors__Origins__0=https://jiaowu.example.edu.cn Cors__Origins__0=https://jiaowu.example.edu.cn
Cors__Origins__1=capacitor://localhost
Cors__Origins__2=https://localhost
Cors__Origins__3=http://localhost
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。 # 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
OfficialDocuments__InstitutionName=明序大学 OfficialDocuments__InstitutionName=明序大学
Submodule Academic-Affairs-System.wiki added at fe88e71716
+9 -2
View File
@@ -30,11 +30,14 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production \ ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080 \ 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 EXPOSE 8080
COPY --from=build /app/publish/ ./ COPY --from=build /app/publish/ ./
ARG UID=10001 ARG UID=10001
RUN apk add --no-cache font-noto-cjk RUN apk add --no-cache font-noto-cjk mariadb-client
RUN adduser \ RUN adduser \
--disabled-password \ --disabled-password \
--gecos "" \ --gecos "" \
@@ -43,5 +46,9 @@ RUN adduser \
--no-create-home \ --no-create-home \
--uid "${UID}" \ --uid "${UID}" \
appuser 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 USER appuser
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"] ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
+198
View File
@@ -47,6 +47,68 @@ dotnet run --project src/Jiaowu.Api
访问 `http://localhost:5255``/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html` 访问 `http://localhost:5255``/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`
## Capacitor Android App
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
Android 工程前先构建并同步原生插件:
```powershell
Set-Location web
npm ci
npm run build:capacitor
npm run cap:sync
npm run cap:open:android
```
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
最低版本为 API 26;相机和精确位置权限均按需申请。
### App 前端热更新
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
前端修复不再需要重新打包。
生成更新 ZIP
```powershell
Set-Location web
npm ci
npm run ota:package -- --version 1.0.1
```
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName`
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
`--migrate-only`
### Android 开屏、快捷入口与桌面组件
Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
遵守系统“减少动画”设置。
长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
重新构建 App,不能通过前端 OTA 单独下发。
## MySQL 8.4 生产部署 ## MySQL 8.4 生产部署
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立: 非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
@@ -82,6 +144,50 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
`chmod 600 .env`Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书 `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 服务 ### Linux systemd 服务
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于 仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
@@ -101,6 +207,12 @@ sudo chown -R root:jiaowu /opt/jiaowu
sudo chmod 0750 /opt/jiaowu sudo chmod 0750 /opt/jiaowu
sudo chmod 0750 /opt/jiaowu/Jiaowu.Api sudo chmod 0750 /opt/jiaowu/Jiaowu.Api
sudo chmod 0640 /opt/jiaowu/.env 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` 通常 如果账号已存在,`useradd` 会报错,可以跳过该命令。RHEL 系发行版的 `nologin` 通常
@@ -254,6 +366,71 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis `allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。 如需连接外部 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` 配置项适配,无需改前端。
### ClickHouse 分析读模型
ClickHouse 仅用于考勤、操作审计和成绩趋势的多维聚合,MySQL 仍是所有教务业务的唯一写入源。默认关闭;启用后,后台工作器以可重试的滚动窗口投影 MySQL 当前事实到 `ReplacingMergeTree` 表,重复投递不会改变读结果。
生产环境请为分析库创建独立账号,并限制其只能访问 `ClickHouseAnalytics__Database`。推荐通过 HTTPS 或内网连接:
```ini
ClickHouseAnalytics__Enabled=true
ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
ClickHouseAnalytics__Database=jiaowu_analytics
ClickHouseAnalytics__UserName=jiaowu_analytics
ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
```
分析概览通过 `GET /api/clickhouse-analytics/overview` 提供;学院管理员只能读取本学院的考勤和成绩趋势,跨学院的操作审计仅对全校数据范围角色开放。ClickHouse 暂时不可用时,业务写入不会失败,工作器会在下一个周期重试。
### 后台任务与 RabbitMQ ### 后台任务与 RabbitMQ
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与 自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
@@ -274,6 +451,7 @@ BackgroundJobs__Transport=RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency=1 BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1 BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1 BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
RabbitMq__HostName=rabbitmq.example.edu.cn RabbitMq__HostName=rabbitmq.example.edu.cn
RabbitMq__Port=5671 RabbitMq__Port=5671
RabbitMq__UserName=jiaowu RabbitMq__UserName=jiaowu
@@ -297,6 +475,26 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用 消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。 `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 ## 跨平台发布与 Docker
`.gitea/workflows/publish.yml` 只在推送 `v*` 标签或手动运行时执行,普通分支 push `.gitea/workflows/publish.yml` 只在推送 `v*` 标签或手动运行时执行,普通分支 push
+6 -2
View File
@@ -17,11 +17,15 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes:
- jiaowu-backups:/var/lib/jiaowu/backups
# 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到 # 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到
# ./certs/mysql-ca.pem,并取消下面三行注释 # ./certs/mysql-ca.pem,并在上面的 volumes 中追加以下四行
# volumes:
# - type: bind # - type: bind
# source: ./certs/mysql-ca.pem # source: ./certs/mysql-ca.pem
# target: /etc/jiaowu/mysql-ca.pem # target: /etc/jiaowu/mysql-ca.pem
# read_only: true # read_only: true
volumes:
jiaowu-backups:
+34 -1
View File
@@ -16,10 +16,16 @@ x-jiaowu-environment: &jiaowu-environment
ConnectionStrings__Redis: "redis:6379,abortConnect=false" ConnectionStrings__Redis: "redis:6379,abortConnect=false"
Cache__Enabled: "true" Cache__Enabled: "true"
Cache__KeyPrefix: "jiaowu:v1" Cache__KeyPrefix: "jiaowu:v1"
ClickHouseAnalytics__Enabled: "true"
ClickHouseAnalytics__Endpoint: "http://clickhouse:8123"
ClickHouseAnalytics__Database: "jiaowu_analytics"
ClickHouseAnalytics__UserName: "${CLICKHOUSE_USER:-jiaowu_analytics}"
ClickHouseAnalytics__Password: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
BackgroundJobs__Transport: RabbitMq BackgroundJobs__Transport: RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}" BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}" BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}" BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
RabbitMq__HostName: rabbitmq RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672" RabbitMq__Port: "5672"
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}" RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
@@ -29,7 +35,9 @@ x-jiaowu-environment: &jiaowu-environment
Jwt__Issuer: Jiaowu.Api Jwt__Issuer: Jiaowu.Api
Jwt__Audience: Jiaowu.Web Jwt__Audience: Jiaowu.Web
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}" Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
Jwt__ExpireMinutes: "60" Jwt__AccessTokenMinutes: "10"
Jwt__WebIdleMinutes: "30"
Jwt__AppIdleMinutes: "4320"
AllowedHosts: "${ALLOWED_HOSTS:-localhost}" AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}" Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}" OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
@@ -44,6 +52,25 @@ x-json-logging: &json-logging
max-file: "3" max-file: "3"
services: services:
clickhouse:
image: clickhouse/clickhouse-server:25.8-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: jiaowu_analytics
CLICKHOUSE_USER: "${CLICKHOUSE_USER:-jiaowu_analytics}"
CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
healthcheck:
test:
- CMD-SHELL
- wget -qO- http://localhost:8123/ping | grep -q Ok
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
- clickhouse-data:/var/lib/clickhouse
logging: *json-logging
rabbitmq: rabbitmq:
image: rabbitmq:4.2-management-alpine image: rabbitmq:4.2-management-alpine
restart: unless-stopped restart: unless-stopped
@@ -132,12 +159,16 @@ services:
condition: service_started condition: service_started
mysql: mysql:
condition: service_healthy condition: service_healthy
clickhouse:
condition: service_healthy
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
ports: ports:
- "${JIAOWU_PORT:-8080}:8080" - "${JIAOWU_PORT:-8080}:8080"
restart: unless-stopped restart: unless-stopped
init: true init: true
volumes:
- backup-data:/var/lib/jiaowu/backups
logging: *json-logging logging: *json-logging
# 工具型一次性服务:普通 docker compose up 不会执行它。 # 工具型一次性服务:普通 docker compose up 不会执行它。
@@ -160,4 +191,6 @@ services:
volumes: volumes:
mysql-data: mysql-data:
clickhouse-data:
rabbitmq-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);
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text;
using ClosedXML.Excel; using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
{ {
sheet.Id, sheet.Id,
sheet.CheckInMethod, sheet.CheckInMethod,
sheet.CheckInToken,
sheet.CheckInStartsAt, sheet.CheckInStartsAt,
sheet.CheckInEndsAt sheet.CheckInEndsAt
}); });
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
x.AttendanceDate, x.AttendanceDate,
x.Status, x.Status,
x.CheckInMethod, x.CheckInMethod,
x.CheckInToken,
x.CheckInStartsAt, x.CheckInStartsAt,
x.CheckInEndsAt, x.CheckInEndsAt,
x.TargetLatitude, x.TargetLatitude,
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
cancellationToken); cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage; var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x => x.AttendanceSheetId == id)
.Select(x => new
{
x.StudentId,
x.CreatedAt,
x.IsSuccessful,
x.FailureCode,
x.DeviceIdentifierHash,
x.DevicePlatform,
x.IpAddress,
x.RiskFlags
})
.ToListAsync(cancellationToken);
var attemptsByStudent = attempts
.GroupBy(x => x.StudentId)
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
var deviceHashes = attempts
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
.Select(x => x.DeviceIdentifierHash!)
.Distinct(StringComparer.Ordinal)
.ToList();
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
if (deviceHashes.Count > 0)
{
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
.Where(x =>
x.IsSuccessful &&
x.CreatedAt >= now.AddHours(-24) &&
x.DeviceIdentifierHash != null)
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
.GroupBy(x => x.DeviceIdentifierHash!)
.Select(x => new
{
DeviceIdentifierHash = x.Key,
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
})
.ToListAsync(cancellationToken);
deviceReuseCounts = reuseRows.ToDictionary(
x => x.DeviceIdentifierHash,
x => x.StudentCount,
StringComparer.Ordinal);
}
var responseRecords = sheet.Records.Select(r =>
{
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
var riskFlags = studentAttempts
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
.ToHashSet(StringComparer.Ordinal);
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
riskFlags.Add("RepeatedFailures");
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
riskFlags.Add("HighFrequency");
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
? deviceReuseCounts.GetValueOrDefault(deviceHash)
: 0;
if (sharedDeviceStudentCount > 1)
riskFlags.Add("SharedDevice");
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
? attempts
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
.Select(x => x.StudentId)
.Distinct()
.Count()
: 0;
return new
{
r.StudentId,
r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
r.CheckInAt,
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId),
CheckInAudit = latestSuccess is null
? null
: new
{
latestSuccess.IpAddress,
latestSuccess.DevicePlatform,
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
SharedDeviceStudentCount = sharedDeviceStudentCount,
SameIpStudentCount = sameIpStudentCount
},
AttemptCount = studentAttempts.Count,
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
};
}).ToList();
return Ok(new return Ok(new
{ {
Sheet = new Sheet = new
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
sheet.AttendanceDate, sheet.AttendanceDate,
sheet.Status, sheet.Status,
sheet.CheckInMethod, sheet.CheckInMethod,
CheckInToken = canManage ? sheet.CheckInToken : null,
sheet.CheckInStartsAt, sheet.CheckInStartsAt,
sheet.CheckInEndsAt, sheet.CheckInEndsAt,
sheet.TargetLatitude, sheet.TargetLatitude,
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
sheet.TaskName, sheet.TaskName,
sheet.CourseCode, sheet.CourseCode,
sheet.CourseName, sheet.CourseName,
Records = sheet.Records.Select(r => new Records = responseRecords,
RiskSummary = new
{ {
r.StudentId, RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
r.StudentNumber, SharedDeviceStudentCount = responseRecords.Count(
r.Name, x => x.RiskFlags.Contains("SharedDevice")),
r.ClassName, FrequentAttemptStudentCount = responseRecords.Count(
r.Status, x => x.RiskFlags.Contains("HighFrequency") ||
r.Notes, x.RiskFlags.Contains("RepeatedFailures"))
r.CheckInAt, }
r.CheckedInMethod,
r.CheckInAccuracyMeters,
r.CheckInDistanceMeters,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
}, },
CanEdit = canEdit CanEdit = canEdit
}); });
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
return NoContent(); return NoContent();
} }
[HttpGet("sheets/{id:guid}/qr-challenge")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetQrChallenge(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
string.IsNullOrWhiteSpace(sheet.CheckInToken))
return ConflictProblem("该考勤表不是扫码签到。");
var now = DateTime.UtcNow;
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
var challenge = AttendanceCheckInChallenge.Create(
sheet.Id,
sheet.CheckInToken,
now);
return Ok(challenge);
}
// ═══════════════ Student endpoints ═══════════════ // ═══════════════ Student endpoints ═══════════════
[HttpGet("check-in-info")] [HttpGet("check-in-info")]
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
return ConflictProblem("当前账号未关联学生档案。"); return ConflictProblem("当前账号未关联学生档案。");
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
return NotFound(); return NotFound();
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
return NotFound();
var activity = await db.AttendanceRecords.AsNoTracking() var activity = await db.AttendanceRecords.AsNoTracking()
.Where(x => .Where(x =>
x.StudentId == studentId.Value && x.StudentId == studentId.Value &&
x.AttendanceSheet!.CheckInToken == token.Trim()) x.AttendanceSheetId == sheetId)
.Select(x => new .Select(x => new
{ {
SheetId = x.AttendanceSheetId, SheetId = x.AttendanceSheetId,
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
x.AttendanceSheet.CheckInMethod, x.AttendanceSheet.CheckInMethod,
x.AttendanceSheet.CheckInStartsAt, x.AttendanceSheet.CheckInStartsAt,
x.AttendanceSheet.CheckInEndsAt, x.AttendanceSheet.CheckInEndsAt,
x.AttendanceSheet.CheckInToken,
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code, CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
CourseName = x.AttendanceSheet.TeachingTask.Course.Name, CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber, TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
if (activity is null) return NotFound(); if (activity is null) return NotFound();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
!AttendanceCheckInChallenge.IsValid(
token,
activity.SheetId,
activity.CheckInToken,
now))
return NotFound();
return Ok(new return Ok(new
{ {
activity.SheetId, activity.SheetId,
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
.Where(x => x.StudentId == studentId.Value); .Where(x => x.StudentId == studentId.Value);
if (!string.IsNullOrWhiteSpace(request.Token)) if (!string.IsNullOrWhiteSpace(request.Token))
{ {
var token = request.Token.Trim(); if (!AttendanceCheckInChallenge.TryReadSheetId(
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token); request.Token.Trim(),
out var tokenSheetId))
return NotFound();
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
} }
else if (request.AttendanceSheetId.HasValue) else if (request.AttendanceSheetId.HasValue)
{ {
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
if (record?.AttendanceSheet is null) return NotFound(); if (record?.AttendanceSheet is null) return NotFound();
var sheet = record.AttendanceSheet; var sheet = record.AttendanceSheet;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
async Task<ActionResult> RejectAttemptAsync(
string failureCode,
string detail,
double? distanceMeters = null)
{
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
false,
failureCode,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return ConflictProblem(detail);
}
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
!AttendanceCheckInChallenge.IsValid(
request.Token,
sheet.Id,
sheet.CheckInToken,
now))
return await RejectAttemptAsync(
"InvalidQrChallenge",
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return await RejectAttemptAsync(
"CheckInClosed",
"签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return await RejectAttemptAsync(
"ManualSheet",
"该考勤表不支持学生在线签到。");
if (record.CheckInAt.HasValue) if (record.CheckInAt.HasValue)
{ {
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
record.CheckInDistanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return Ok(new return Ok(new
{ {
AlreadyCheckedIn = true, AlreadyCheckedIn = true,
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
record.CheckInDistanceMeters record.CheckInDistanceMeters
}); });
} }
if (!IsCheckInOpen(
sheet.Status,
sheet.CheckInMethod,
sheet.CheckInStartsAt,
sheet.CheckInEndsAt,
now))
return ConflictProblem("签到尚未开始或已经结束。");
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
return ConflictProblem("该考勤表不支持学生在线签到。");
double? distanceMeters = null; double? distanceMeters = null;
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode) if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
{
if (string.IsNullOrWhiteSpace(request.Token) ||
!string.Equals(
sheet.CheckInToken,
request.Token.Trim(),
StringComparison.Ordinal))
return NotFound();
}
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
{ {
if (request.Latitude is < -90 or > 90 || if (request.Latitude is < -90 or > 90 ||
request.Longitude is < -180 or > 180 || request.Longitude is < -180 or > 180 ||
request.Latitude is null || request.Latitude is null ||
request.Longitude is null) request.Longitude is null)
return ConflictProblem("未获取到有效的当前位置。"); return await RejectAttemptAsync(
"InvalidLocation",
"未获取到有效的当前位置。");
if (sheet.TargetLatitude is null || if (sheet.TargetLatitude is null ||
sheet.TargetLongitude is null || sheet.TargetLongitude is null ||
sheet.LocationRadiusMeters is null) sheet.LocationRadiusMeters is null)
return ConflictProblem("签到活动没有配置有效的位置范围。"); return await RejectAttemptAsync(
"LocationNotConfigured",
"签到活动没有配置有效的位置范围。");
var maximumAllowedAccuracyMeters = Math.Min(
100d,
sheet.LocationRadiusMeters.Value);
if (request.AccuracyMeters is null ||
!double.IsFinite(request.AccuracyMeters.Value) ||
request.AccuracyMeters <= 0 ||
request.AccuracyMeters > maximumAllowedAccuracyMeters)
{
return await RejectAttemptAsync(
"InsufficientAccuracy",
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
}
distanceMeters = CalculateDistanceMeters( distanceMeters = CalculateDistanceMeters(
(double)sheet.TargetLatitude.Value, (double)sheet.TargetLatitude.Value,
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
(double)request.Longitude.Value); (double)request.Longitude.Value);
if (distanceMeters > sheet.LocationRadiusMeters.Value) if (distanceMeters > sheet.LocationRadiusMeters.Value)
{ {
return ConflictProblem( return await RejectAttemptAsync(
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。"); "OutsideGeofence",
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
distanceMeters);
} }
} }
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
? request.AccuracyMeters ? request.AccuracyMeters
: null; : null;
record.CheckInDistanceMeters = distanceMeters; record.CheckInDistanceMeters = distanceMeters;
await AddCheckInAttemptAsync(
sheet,
studentId.Value,
request,
true,
null,
distanceMeters,
now,
cancellationToken);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Ok(new return Ok(new
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
} }
private async Task AddCheckInAttemptAsync(
AttendanceSheet sheet,
Guid studentId,
AttendanceCheckInRequest request,
bool isSuccessful,
string? failureCode,
double? distanceMeters,
DateTime now,
CancellationToken cancellationToken)
{
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
if (deviceIdentifierHash is null)
{
riskFlags.Add("MissingDeviceId");
}
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
x =>
x.IsSuccessful &&
x.StudentId != studentId &&
x.DeviceIdentifierHash == deviceIdentifierHash &&
x.CreatedAt >= now.AddHours(-24),
cancellationToken))
{
riskFlags.Add("SharedDevice");
}
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
x.CreatedAt >= now.AddMinutes(-2),
cancellationToken);
if (recentAttemptCount >= 5)
riskFlags.Add("HighFrequency");
if (!isSuccessful)
{
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
.CountAsync(
x => x.StudentId == studentId &&
!x.IsSuccessful &&
x.CreatedAt >= now.AddMinutes(-5),
cancellationToken);
if (recentFailureCount >= 2)
riskFlags.Add("RepeatedFailures");
}
var context = ControllerContext.HttpContext;
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
{
AttendanceSheetId = sheet.Id,
StudentId = studentId,
CheckInMethod = sheet.CheckInMethod,
IsSuccessful = isSuccessful,
FailureCode = failureCode,
DeviceIdentifierHash = deviceIdentifierHash,
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
IpAddress = Limit(
context?.Connection.RemoteIpAddress?.ToString(),
64),
UserAgent = Limit(
context?.Request.Headers.UserAgent.ToString(),
500),
RiskFlags = riskFlags.Count == 0
? null
: string.Join(',', riskFlags.OrderBy(x => x)),
Latitude = request.Latitude,
Longitude = request.Longitude,
AccuracyMeters = request.AccuracyMeters,
DistanceMeters = distanceMeters,
CreatedAt = now,
UpdatedAt = now
});
}
private static string? HashDeviceIdentifier(string? deviceId)
{
var normalized = Normalize(deviceId)?.ToLowerInvariant();
return normalized is null
? null
: Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
}
private static IEnumerable<string> ParseRiskFlags(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(
',',
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries);
private static string? Limit(string? value, int maximumLength) =>
value is null || value.Length <= maximumLength
? value
: value[..maximumLength];
private static bool IsCheckInOpen( private static bool IsCheckInOpen(
AttendanceSheetStatus status, AttendanceSheetStatus status,
AttendanceCheckInMethod method, AttendanceCheckInMethod method,
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
[MaxLength(64)] string? Token, [MaxLength(64)] string? Token,
[Range(-90, 90)] decimal? Latitude, [Range(-90, 90)] decimal? Latitude,
[Range(-180, 180)] decimal? Longitude, [Range(-180, 180)] decimal? Longitude,
[Range(0, 5000)] double? AccuracyMeters); [Range(0, 5000)] double? AccuracyMeters,
[MaxLength(128)] string? DeviceId = null,
[MaxLength(32)] string? DevicePlatform = null);
public sealed record AttendanceCourseStatistics( public sealed record AttendanceCourseStatistics(
AttendanceStatisticsCourse Course, AttendanceStatisticsCourse Course,
+70 -12
View File
@@ -18,7 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class AuthController( public sealed class AuthController(
AppDbContext db, AppDbContext db,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ITokenService tokenService, IAuthSessionService authSessionService,
IAppCache cache) : ControllerBase IAppCache cache) : ControllerBase
{ {
[AllowAnonymous] [AllowAnonymous]
@@ -135,8 +135,11 @@ public sealed class AuthController(
} }
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("login")] [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); var user = await userManager.FindByNameAsync(request.UserName);
if (user is null || !user.IsEnabled) if (user is null || !user.IsEnabled)
@@ -166,15 +169,47 @@ public sealed class AuthController(
await userManager.UpdateAsync(user); await userManager.UpdateAsync(user);
var roles = await userManager.GetRolesAsync(user); var roles = await userManager.GetRolesAsync(user);
return new LoginResponse( var session = await authSessionService.CreateAsync(
tokenService.Create(user, roles), user,
new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
roles, roles,
user.CollegeId, request.IsNativeApp
EffectiveDataScopeResolver.Resolve(roles).ToString())); ? 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] [Authorize]
@@ -212,11 +247,29 @@ public sealed class AuthController(
Detail = detail, Detail = detail,
Status = status 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( public sealed record LoginRequest(
[Required, MaxLength(100)] string UserName, [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( public sealed record StudentActivationRequest(
[Required, MaxLength(50)] string Name, [Required, MaxLength(50)] string Name,
@@ -227,7 +280,12 @@ public sealed record StudentActivationRequest(
Guid AdministrativeClassId, Guid AdministrativeClassId,
[Required, MinLength(8), MaxLength(100)] string Password); [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( public sealed record CurrentUserResponse(
Guid Id, Guid Id,
@@ -491,6 +491,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
x.Building.Campus!.Name, x.Building.Campus!.Name,
x.Capacity, x.Capacity,
x.RoomType, x.RoomType,
x.TeachingVenueNature,
x.Equipment, x.Equipment,
x.IsEnabled, x.IsEnabled,
x.SortOrder)) x.SortOrder))
@@ -557,6 +558,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
BuildingId = request.BuildingId, BuildingId = request.BuildingId,
Capacity = request.Capacity, Capacity = request.Capacity,
RoomType = request.RoomType.Trim(), RoomType = request.RoomType.Trim(),
TeachingVenueNature = request.TeachingVenueNature == 0
? TeachingVenueNature.GeneralClassroom
: request.TeachingVenueNature,
Equipment = request.Equipment?.Trim(), Equipment = request.Equipment?.Trim(),
SortOrder = request.SortOrder, SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled IsEnabled = request.IsEnabled
@@ -577,6 +581,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
entity.BuildingId = request.BuildingId; entity.BuildingId = request.BuildingId;
entity.Capacity = request.Capacity; entity.Capacity = request.Capacity;
entity.RoomType = request.RoomType.Trim(); entity.RoomType = request.RoomType.Trim();
entity.TeachingVenueNature = request.TeachingVenueNature == 0
? TeachingVenueNature.GeneralClassroom
: request.TeachingVenueNature;
entity.Equipment = request.Equipment?.Trim(); entity.Equipment = request.Equipment?.Trim();
await SaveAndInvalidateAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
@@ -728,6 +735,7 @@ public sealed record ClassroomRequest(
Guid BuildingId, Guid BuildingId,
[Range(1, 1000)] int Capacity, [Range(1, 1000)] int Capacity,
[Required, MaxLength(40)] string RoomType, [Required, MaxLength(40)] string RoomType,
TeachingVenueNature TeachingVenueNature,
[MaxLength(300)] string? Equipment) [MaxLength(300)] string? Equipment)
: CatalogRequest(Code, Name, SortOrder, IsEnabled); : CatalogRequest(Code, Name, SortOrder, IsEnabled);
@@ -784,6 +792,7 @@ public sealed record ClassroomListItem(
string CampusName, string CampusName,
int Capacity, int Capacity,
string RoomType, string RoomType,
TeachingVenueNature TeachingVenueNature,
string? Equipment, string? Equipment,
bool IsEnabled, bool IsEnabled,
int SortOrder); int SortOrder);
@@ -27,7 +27,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"], ["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"], ["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"], ["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"], ["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "教学场地性质", "设备", "排序", "状态"],
["course-categories"] = ["编码", "名称", "排序", "状态"] ["course-categories"] = ["编码", "名称", "排序", "状态"]
}; };
@@ -70,7 +70,10 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
IReadOnlyList<ExcelRow> rows; IReadOnlyList<ExcelRow> rows;
try try
{ {
rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken); var requiredHeaders = kind.Equals("classrooms", StringComparison.OrdinalIgnoreCase)
? headers.Where(x => x != "教学场地性质").ToArray()
: headers;
rows = await ExcelWorkbookHelper.ReadAsync(file, requiredHeaders, cancellationToken);
} }
catch (InvalidDataException exception) catch (InvalidDataException exception)
{ {
@@ -165,6 +168,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
"classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building) "classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building)
.OrderBy(x => x.Code).ToListAsync(cancellationToken)) .OrderBy(x => x.Code).ToListAsync(cancellationToken))
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType, .Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
VenueNatureName(x.TeachingVenueNature),
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(), x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
"course-categories" => (await db.CourseCategories.AsNoTracking() "course-categories" => (await db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code) .OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
@@ -473,8 +477,9 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
var buildingCode = Required(row, "所属教学楼编码", errors); var buildingCode = Required(row, "所属教学楼编码", errors);
var capacity = ParseInt(row, "容量", 1, 1000, errors); var capacity = ParseInt(row, "容量", 1, 1000, errors);
var roomType = Required(row, "教室类型", errors); var roomType = Required(row, "教室类型", errors);
var venueNature = ParseVenueNature(row, roomType, errors);
if (code is null || name is null || buildingCode is null || if (code is null || name is null || buildingCode is null ||
capacity is null || roomType is null) continue; capacity is null || roomType is null || venueNature is null) continue;
if (!buildings.TryGetValue(buildingCode, out var building)) if (!buildings.TryGetValue(buildingCode, out var building))
{ {
errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。"); errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。");
@@ -488,7 +493,8 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
Code = code, Code = code,
Name = name, Name = name,
BuildingId = building.Id, BuildingId = building.Id,
RoomType = roomType RoomType = roomType,
TeachingVenueNature = venueNature.Value
}; };
db.Classrooms.Add(entity); db.Classrooms.Add(entity);
existing[code] = entity; existing[code] = entity;
@@ -499,6 +505,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
entity.BuildingId = building.Id; entity.BuildingId = building.Id;
entity.Capacity = capacity.Value; entity.Capacity = capacity.Value;
entity.RoomType = roomType; entity.RoomType = roomType;
entity.TeachingVenueNature = venueNature.Value;
entity.Equipment = Optional(row, "设备"); entity.Equipment = Optional(row, "设备");
} }
return new(created, updated, rows.Count); return new(created, updated, rows.Count);
@@ -597,6 +604,57 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
return true; return true;
} }
private static TeachingVenueNature? ParseVenueNature(
ExcelRow row,
string? roomType,
List<string> errors)
{
var value = Optional(row, "教学场地性质");
if (value is null) return InferVenueNature(roomType ?? string.Empty);
var result = (TeachingVenueNature)0;
foreach (var part in value.Split(['、', '', ',', ';', ''],
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
result |= part switch
{
"普通教室" => TeachingVenueNature.GeneralClassroom,
"实验室" => TeachingVenueNature.Laboratory,
"实训室" => TeachingVenueNature.TrainingRoom,
"计算机机房" or "机房" => TeachingVenueNature.ComputerLab,
"语音室" => TeachingVenueNature.LanguageLab,
"体育场地" => TeachingVenueNature.SportsVenue,
"艺术场地" => TeachingVenueNature.ArtsVenue,
_ => (TeachingVenueNature)0
};
if (part is not ("普通教室" or "实验室" or "实训室" or "计算机机房" or "机房" or "语音室" or "体育场地" or "艺术场地"))
errors.Add($"第 {row.RowNumber} 行:“教学场地性质”包含不支持的值“{part}”。");
}
return result == 0 ? null : result;
}
private static TeachingVenueNature InferVenueNature(string roomType) =>
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.Laboratory | TeachingVenueNature.ComputerLab
: roomType.Contains("语音", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.Laboratory | TeachingVenueNature.LanguageLab
: roomType.Contains("实训", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.TrainingRoom
: roomType.Contains("实验", StringComparison.OrdinalIgnoreCase)
? TeachingVenueNature.Laboratory
: TeachingVenueNature.GeneralClassroom;
private static string VenueNatureName(TeachingVenueNature value) => string.Join("、",
new[]
{
(TeachingVenueNature.GeneralClassroom, "普通教室"),
(TeachingVenueNature.Laboratory, "实验室"),
(TeachingVenueNature.TrainingRoom, "实训室"),
(TeachingVenueNature.ComputerLab, "计算机机房"),
(TeachingVenueNature.LanguageLab, "语音室"),
(TeachingVenueNature.SportsVenue, "体育场地"),
(TeachingVenueNature.ArtsVenue, "艺术场地")
}.Where(x => (value & x.Item1) != 0).Select(x => x.Item2));
private static bool ParseBoolean( private static bool ParseBoolean(
ExcelRow row, string header, bool defaultValue, List<string> errors) ExcelRow row, string header, bool defaultValue, List<string> errors)
{ {
@@ -0,0 +1,88 @@
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Analytics;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/clickhouse-analytics")]
public sealed class ClickHouseAnalyticsController(
ClickHouseAnalyticsClient client,
ClickHouseAnalyticsOptions options,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," + SystemRoles.Leader;
[HttpGet("status")]
public ActionResult GetStatus() => Ok(new
{
options.Enabled,
options.SyncIntervalSeconds,
options.SourceLookbackDays,
options.BatchSize,
options.Database
});
[HttpGet("overview")]
public async Task<ActionResult> GetOverview(
DateOnly? from,
DateOnly? to,
CancellationToken cancellationToken)
{
if (!options.Enabled)
return Conflict(new ProblemDetails
{
Title = "ClickHouse 分析未启用",
Detail = "请先配置 ClickHouseAnalytics 并启动分析库。",
Status = StatusCodes.Status409Conflict
});
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
var start = from ?? end.AddDays(-29);
if (start > end || end.DayNumber - start.DayNumber > 366)
return ValidationProblem("分析时间范围应为 1 到 366 天,且开始日期不能晚于结束日期。");
var scope = currentUserDataScope.Current;
var collegeFilter = scope.RestrictedCollegeId is { } collegeId
? $" AND collegeId = toUUID('{collegeId:D}')"
: string.Empty;
var dateFilter = $"attendanceDate >= toDate('{start:yyyy-MM-dd}') AND attendanceDate <= toDate('{end:yyyy-MM-dd}')";
var attendance = await client.QueryAsync($"""
SELECT attendanceDate, count() AS total, countIf(status = 1) AS present,
countIf(status = 2) AS absent, countIf(status = 3) AS late
FROM {options.Database}.attendanceRecords FINAL
WHERE {dateFilter}{collegeFilter}
GROUP BY attendanceDate ORDER BY attendanceDate
""", cancellationToken);
var grades = await client.QueryAsync($"""
SELECT academicTermId, any(academicTermName) AS academicTermName,
sum(studentCount) AS studentCount,
round(sum(averageScore * studentCount) / nullIf(sum(studentCount), 0), 2) AS averageScore,
round(sum(passedCount) / nullIf(sum(studentCount), 0), 4) AS passRate
FROM {options.Database}.gradeStatistics FINAL
WHERE 1 = 1{collegeFilter}
GROUP BY academicTermId ORDER BY academicTermName
""", cancellationToken);
// Audit data has no college dimension, so it is never exposed to a
// college-scoped administrator.
var audit = scope.RestrictedCollegeId is null
? await client.QueryAsync($"""
SELECT toDate(occurredAt) AS date, count() AS total,
countIf(statusCode >= 400) AS failed
FROM {options.Database}.auditEvents FINAL
WHERE occurredAt >= toDateTime('{start:yyyy-MM-dd}')
AND occurredAt < toDateTime('{end.AddDays(1):yyyy-MM-dd}')
GROUP BY date ORDER BY date
""", cancellationToken)
: [];
return Ok(new { Start = start, End = end, Attendance = attendance, Grades = grades, Audit = audit });
}
}
@@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -286,6 +287,8 @@ public sealed class CourseAdjustmentsController(
db.CourseAdjustments.Add(adj); db.CourseAdjustments.Add(adj);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await new PublishedTimetableProjectionService(db)
.RebuildPublishedPlansForTaskAsync(adj.TeachingTaskId, cancellationToken);
if (request.Submit) if (request.Submit)
{ {
@@ -0,0 +1,161 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/course-groups")]
public sealed class CourseGroupsController(AppDbContext db) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ManageRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet]
public async Task<ActionResult> GetAll(CancellationToken cancellationToken)
{
var groups = await db.CourseGroups.AsNoTracking()
.OrderBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Description,
CourseCount = x.Courses.Count,
Courses = x.Courses.OrderBy(item => item.Course!.Code).Select(item => new
{
item.Id,
item.CourseId,
CourseCode = item.Course!.Code,
CourseName = item.Course.Name,
item.Course.Credits,
item.Course.TotalHours,
item.Course.Nature
})
})
.ToListAsync(cancellationToken);
return Ok(groups);
}
[HttpPost]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Create(
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = new CourseGroup
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Description = Normalize(request.Description)
};
db.CourseGroups.Add(group);
return await SaveCreatedAsync(group.Id, cancellationToken);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Update(
Guid id,
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
group.Code = request.Code.Trim();
group.Name = request.Name.Trim();
group.Description = Normalize(request.Description);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
db.CourseGroups.Remove(group);
return await SaveNoContentAsync(cancellationToken);
}
[HttpPost("{id:guid}/courses")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> AddCourse(
Guid id,
CourseGroupCourseRequest request,
CancellationToken cancellationToken)
{
if (!await db.CourseGroups.AnyAsync(x => x.Id == id, cancellationToken)) return NotFound();
if (!await db.Courses.AnyAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken))
return ValidationProblem("所选课程不存在或已停用。");
db.CourseGroupCourses.Add(new CourseGroupCourse { CourseGroupId = id, CourseId = request.CourseId });
return await SaveCreatedAsync(id, cancellationToken);
}
[HttpDelete("{id:guid}/courses/{courseId:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> RemoveCourse(
Guid id,
Guid courseId,
CancellationToken cancellationToken)
{
var item = await db.CourseGroupCourses.FirstOrDefaultAsync(
x => x.CourseGroupId == id && x.CourseId == courseId,
cancellationToken);
if (item is null) return NotFound();
db.CourseGroupCourses.Remove(item);
return await SaveNoContentAsync(cancellationToken);
}
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record CourseGroupRequest(
[Required, MaxLength(30)] string Code,
[Required, MaxLength(100)] string Name,
[MaxLength(500)] string? Description);
public sealed record CourseGroupCourseRequest(Guid CourseId);
@@ -1011,7 +1011,11 @@ public sealed class CourseSelectionsController(
x.TeachingTask!.Status == TeachingTaskStatus.Published && x.TeachingTask!.Status == TeachingTaskStatus.Published &&
(x.IsOpenToAll || (x.IsOpenToAll ||
x.TeachingTask.Classes.Any(item => x.TeachingTask.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId))) item.AdministrativeClassId == student.AdministrativeClassId) ||
x.Enrollments.Any(item =>
item.StudentId == student.Id &&
(item.Status == CourseEnrollmentStatus.Enrolled ||
item.Status == CourseEnrollmentStatus.Waitlisted))))
.OrderBy(x => x.TeachingTask!.Course!.Code) .OrderBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new StudentOfferingDto( .Select(x => new StudentOfferingDto(
x.Id, x.Id,
@@ -1639,13 +1643,25 @@ public sealed class CourseSelectionsController(
var students = await LoadTeachingTaskRosterAsync(id, cancellationToken); var students = await LoadTeachingTaskRosterAsync(id, cancellationToken);
var bytes = ExcelWorkbookHelper.Create( var bytes = ExcelWorkbookHelper.Create(
"教学班名单", "教学班名单",
["学号", "姓名", "班级", "专业", "进入方式", "选课时间"], [
"学号", "姓名", "班级", "专业", "联系电话", "电子邮箱",
"微信", "紧急联系人", "与本人关系", "紧急联系电话",
"特殊标记", "特殊情况说明", "进入方式", "选课时间"
],
students.Select(student => new List<object?> students.Select(student => new List<object?>
{ {
student.StudentNumber, student.StudentNumber,
student.Name, student.Name,
student.ClassName, student.ClassName,
student.MajorName, student.MajorName,
student.Phone,
student.Email,
student.WeChat,
student.EmergencyContactName,
student.EmergencyContactRelationship,
student.EmergencyContactPhone,
student.SpecialTags,
student.SpecialNeeds,
student.EnrolledAt.HasValue ? "选课" : "行政班关联", student.EnrolledAt.HasValue ? "选课" : "行政班关联",
student.EnrolledAt?.ToString("yyyy-MM-dd HH:mm") ?? "-" student.EnrolledAt?.ToString("yyyy-MM-dd HH:mm") ?? "-"
}).ToList<IReadOnlyList<object?>>()); }).ToList<IReadOnlyList<object?>>());
@@ -1754,6 +1770,14 @@ public sealed class CourseSelectionsController(
x.Name, x.Name,
x.AdministrativeClass!.Name, x.AdministrativeClass!.Name,
x.AdministrativeClass.Major!.Name, x.AdministrativeClass.Major!.Name,
x.Phone,
x.Email,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
db.CourseEnrollments db.CourseEnrollments
.Where(enrollment => .Where(enrollment =>
enrollment.StudentId == x.Id && enrollment.StudentId == x.Id &&
@@ -2154,6 +2178,14 @@ public sealed class CourseSelectionsController(
string Name, string Name,
string ClassName, string ClassName,
string MajorName, string MajorName,
string? Phone,
string? Email,
string? WeChat,
string? EmergencyContactName,
string? EmergencyContactRelationship,
string? EmergencyContactPhone,
string? SpecialTags,
string? SpecialNeeds,
DateTime? EnrolledAt); DateTime? EnrolledAt);
} }
@@ -84,6 +84,17 @@ public sealed class CoursesController(
x.Nature, x.Nature,
x.AssessmentMethod, x.AssessmentMethod,
x.Description, 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.IsEnabled,
x.SortOrder, x.SortOrder,
x.CreatedAt, x.CreatedAt,
@@ -134,6 +145,8 @@ public sealed class CoursesController(
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits, x.Credits,
x.TotalHours, x.TotalHours,
x.LectureHours,
x.PracticeHours,
x.Nature, x.Nature,
x.AssessmentMethod x.AssessmentMethod
}) })
@@ -146,9 +159,6 @@ public sealed class CoursesController(
CourseRequest request, CourseRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
var entity = new Course var entity = new Course
{ {
Code = request.Code.Trim(), Code = request.Code.Trim(),
@@ -166,6 +176,16 @@ public sealed class CoursesController(
IsEnabled = request.IsEnabled, IsEnabled = request.IsEnabled,
SortOrder = request.SortOrder 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); db.Courses.Add(entity);
return await SaveAsync(entity.Id, true, cancellationToken); return await SaveAsync(entity.Id, true, cancellationToken);
} }
@@ -177,10 +197,12 @@ public sealed class CoursesController(
CourseRequest request, CourseRequest request,
CancellationToken cancellationToken) 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 (entity is null) return NotFound();
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid(); 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; if (validation is not null) return validation;
entity.Code = request.Code.Trim(); entity.Code = request.Code.Trim();
@@ -197,6 +219,19 @@ public sealed class CoursesController(
entity.Description = Normalize(request.Description); entity.Description = Normalize(request.Description);
entity.IsEnabled = request.IsEnabled; entity.IsEnabled = request.IsEnabled;
entity.SortOrder = request.SortOrder; 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); return await SaveAsync(entity.Id, false, cancellationToken);
} }
@@ -212,6 +247,7 @@ public sealed class CoursesController(
} }
private async Task<ActionResult?> ValidateAsync( private async Task<ActionResult?> ValidateAsync(
Guid courseId,
CourseRequest request, CourseRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -224,9 +260,62 @@ public sealed class CoursesController(
return ValidationProblem("所选课程分类不存在或已停用。"); return ValidationProblem("所选课程分类不存在或已停用。");
if (request.LectureHours + request.PracticeHours > request.TotalHours) if (request.LectureHours + request.PracticeHours > request.TotalHours)
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。"); 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; 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() private IQueryable<Course> ScopedCourses()
{ {
var scope = currentUserDataScope.Current; var scope = currentUserDataScope.Current;
@@ -307,4 +396,5 @@ public sealed record CourseRequest(
AssessmentMethod AssessmentMethod, AssessmentMethod AssessmentMethod,
[MaxLength(1000)] string? Description, [MaxLength(1000)] string? Description,
bool IsEnabled, bool IsEnabled,
int SortOrder); int SortOrder,
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
@@ -432,6 +432,48 @@ public sealed class CurriculumPlansController(
return await SaveCreatedAsync(item.Id, cancellationToken); return await SaveCreatedAsync(item.Id, cancellationToken);
} }
[HttpPost("{planId:guid}/modules/{moduleId:guid}/course-groups/{groupId:guid}")]
public async Task<ActionResult> AddCourseGroup(
Guid planId,
Guid moduleId,
Guid groupId,
CurriculumCourseGroupImportRequest request,
CancellationToken cancellationToken)
{
var plan = await ModifiablePlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
return ValidationProblem("建议学期超出了该专业学制。");
if (!await db.CurriculumModules.AnyAsync(
x => x.Id == moduleId && x.CurriculumPlanId == planId,
cancellationToken))
return NotFound();
var courseIds = await db.CourseGroupCourses.AsNoTracking()
.Where(x => x.CourseGroupId == groupId && x.Course!.IsEnabled)
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (courseIds.Count == 0)
return ValidationProblem("课程组不存在,或其中没有可用课程。");
var existingCourseIds = await db.CurriculumCourses.AsNoTracking()
.Where(x => x.CurriculumModule!.CurriculumPlanId == planId &&
courseIds.Contains(x.CourseId))
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (existingCourseIds.Count > 0)
return ConflictProblem("课程组中有课程已存在于该培养方案,请先移除重复课程后再导入。");
db.CurriculumCourses.AddRange(courseIds.Select(courseId => new CurriculumCourse
{
CurriculumModuleId = moduleId,
CourseId = courseId,
RecommendedSemester = request.RecommendedSemester,
Type = request.Type,
Notes = Normalize(request.Notes)
}));
return await SaveNoContentAsync(cancellationToken);
}
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")] [HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
public async Task<ActionResult> UpdateCourse( public async Task<ActionResult> UpdateCourse(
Guid planId, Guid planId,
@@ -586,3 +628,8 @@ public sealed record CurriculumCourseRequest(
[Range(1, 20)] int RecommendedSemester, [Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type, CurriculumCourseType Type,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
public sealed record CurriculumCourseGroupImportRequest(
[Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type,
[MaxLength(500)] string? Notes);
+471 -75
View File
@@ -1,11 +1,10 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers; namespace Jiaowu.Api.Controllers;
@@ -14,89 +13,486 @@ namespace Jiaowu.Api.Controllers;
[Route("api/dashboard")] [Route("api/dashboard")]
public sealed class DashboardController( public sealed class DashboardController(
AppDbContext db, AppDbContext db,
IAppCache appCache, ICurrentUserDataScope currentUserDataScope) : ControllerBase
IOptions<JsonOptions> jsonOptions) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken) public async Task<ActionResult<DashboardResponse>> Get(
CancellationToken cancellationToken)
{ {
var response = await appCache.GetOrCreateAsync( var scope = currentUserDataScope.Current;
AppCacheKeys.Dashboard, Guid? restrictedCollegeId = scope.Scope == DataScope.All
LoadAsync, ? null
AppCacheProfile.Analytics, : scope.CollegeId ?? Guid.Empty;
[AppCacheTags.Analytics], var collegeName = restrictedCollegeId.HasValue &&
cancellationToken); restrictedCollegeId.Value != Guid.Empty
return response; ? await db.Colleges.AsNoTracking()
} .Where(x => x.Id == restrictedCollegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
{
var currentTerm = await db.AcademicTerms var currentTerm = await db.AcademicTerms
.AsNoTracking() .AsNoTracking()
.Where(x => x.IsCurrent) .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;
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,
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
DateTime.UtcNow));
}
[HttpGet("greeting")]
public async Task<ActionResult<DashboardGreeting>> GetGreeting(
CancellationToken cancellationToken)
{
var currentTermId = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
return JsonSerializer.SerializeToElement( return Ok(await BuildGreetingAsync(
new currentUserDataScope.Current,
currentTermId,
null,
null,
cancellationToken));
}
private async Task<DashboardGreeting> BuildGreetingAsync(
CurrentUserScope scope,
Guid? currentTermId,
DashboardCounts? counts,
DashboardPending? pending,
CancellationToken cancellationToken)
{ {
CurrentTerm = currentTerm, var name = string.IsNullOrWhiteSpace(scope.DisplayName) ? "" : $"{scope.DisplayName}";
Counts = new var greeting = GetTimeGreeting();
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Leader) ||
scope.IsInRole(SystemRoles.Counselor);
if (!isManager && scope.IsInRole(SystemRoles.Student))
{ {
Campuses = await db.Campuses.CountAsync(cancellationToken), var studentId = await db.Students.AsNoTracking()
Colleges = await db.Colleges.CountAsync(cancellationToken), .Where(x => x.UserId == scope.UserId)
Majors = await db.Majors.CountAsync(cancellationToken), .Select(x => (Guid?)x.Id)
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken), .FirstOrDefaultAsync(cancellationToken);
Classrooms = await db.Classrooms.CountAsync(cancellationToken), if (!studentId.HasValue)
Teachers = await db.Teachers.CountAsync(cancellationToken), return new DashboardGreeting("Student", $"{name}{greeting}", "绑定学籍后,将为你生成课程与成绩学习概览。", "学习节奏", "关联学生档案后,可从课程安排、成绩和考试中生成学习状态摘要。", []);
Students = await db.Students.CountAsync(cancellationToken),
Courses = await db.Courses.CountAsync(cancellationToken), var student = await db.Students.AsNoTracking()
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken), .Where(x => x.Id == studentId.Value)
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken), .Select(x => new { x.AdministrativeClassId })
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken), .FirstAsync(cancellationToken);
CourseSelectionRounds = await db.CourseSelectionRounds var currentTasks = db.TeachingTasks.AsNoTracking().Where(task =>
.CountAsync(cancellationToken), currentTermId.HasValue &&
CourseSelectionOfferings = await db.CourseSelectionOfferings task.AcademicTermId == currentTermId.Value &&
.CountAsync(cancellationToken), task.Status == TeachingTaskStatus.Published &&
CourseEnrollments = await db.CourseEnrollments (task.Classes.Any(item =>
.CountAsync( item.AdministrativeClassId == student.AdministrativeClassId) ||
x => x.Status == CourseEnrollmentStatus.Enrolled, db.CourseEnrollments.Any(enrollment =>
cancellationToken), enrollment.StudentId == studentId.Value &&
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken), enrollment.Status == CourseEnrollmentStatus.Enrolled &&
PublishedGradeSheets = await db.GradeSheets.CountAsync( enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)));
x => x.Status == GradeSheetStatus.Published, var taskWorkload = await currentTasks.Select(task => new
cancellationToken), {
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), task.Id,
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken), task.CourseId,
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken), Credits = task.Course!.Credits,
StudentStatusChanges = await db.StudentStatusChanges IsClassAssigned = task.Classes.Any(item =>
.CountAsync(cancellationToken), item.AdministrativeClassId == student.AdministrativeClassId)
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync( }).ToListAsync(cancellationToken);
x => x.State == StudentStatusChangeState.Submitted || var currentCourses = taskWorkload
x.State == StudentStatusChangeState.CounselorApproved || .GroupBy(x => x.CourseId)
x.State == StudentStatusChangeState.CollegeApproved, .Select(x => x.First())
cancellationToken), .ToList();
GraduationAuditBatches = await db.GraduationAuditBatches var courseCount = currentCourses.Count;
.CountAsync(cancellationToken), var courseCredits = currentCourses.Sum(x => x.Credits);
PublishedGraduationAuditBatches = await db.GraduationAuditBatches var classAssignedCount = taskWorkload.Count(x => x.IsClassAssigned);
.CountAsync( var selfSelectedCount = taskWorkload.Count(x => !x.IsClassAssigned);
x => x.Status == GraduationAuditBatchStatus.Published, var publishedGrades = db.GradeRecords.AsNoTracking().Where(x =>
cancellationToken), x.StudentId == studentId.Value &&
DegreeAwardBatches = await db.DegreeAwardBatches x.GradeSheet!.Status == GradeSheetStatus.Published &&
.CountAsync(cancellationToken), currentTermId.HasValue &&
PublishedDegreeAwardBatches = await db.DegreeAwardBatches x.GradeSheet.TeachingTask!.AcademicTermId == currentTermId.Value);
.CountAsync( var gradeCount = await publishedGrades.CountAsync(cancellationToken);
x => x.Status == DegreeAwardBatchStatus.Published, var average = await publishedGrades
cancellationToken), .Where(x => x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue)
GraduationClearanceBatches = await db.GraduationClearanceBatches .AverageAsync(x => (decimal?)x.TotalScore, cancellationToken);
.CountAsync(cancellationToken), var failed = await publishedGrades.CountAsync(x =>
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue && x.TotalScore < 60,
.CountAsync( cancellationToken);
x => x.Status == GraduationClearanceBatchStatus.Open,
cancellationToken), var subtitle = failed > 0
Users = await db.Users.CountAsync(cancellationToken) ? $"已发布成绩中有 {failed} 门课程需要重点关注,建议优先查看课程反馈。"
: courseCount > 0
? $"本学期已有 {courseCount} 门课程、{courseCredits:0.#} 学分进入你的学习安排。"
: "本学期暂未发现为你安排或确认选课的课程,可先查看培养方案和选课安排。";
var narrative = courseCount == 0
? "你的当前学习安排尚未形成:系统还没有找到行政班已安排课程或已确认选课。"
: failed > 0
? $"本学期已形成 {courseCount} 门课程安排,其中 {classAssignedCount} 个教学班来自行政班安排;已发布成绩中有 {failed} 门需要重点关注。"
: gradeCount > 0
? $"本学期有 {courseCount} 门课程进入学习安排,已发布 {gradeCount} 门成绩,当前没有不及格记录。"
: $"本学期有 {courseCount} 门课程进入学习安排,包含 {classAssignedCount} 个行政班教学班和 {selfSelectedCount} 个自主选课教学班,成绩发布后会在这里更新。";
return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", narrative,
[
new DashboardGreetingInsight("本学期课程", $"{courseCount} 门", $"共 {courseCredits:0.#} 学分", "calm"),
new DashboardGreetingInsight("已发布成绩", $"{gradeCount} 门", average.HasValue ? $"平均分 {average.Value:0.0}" : "等待成绩发布", "calm"),
new DashboardGreetingInsight("重点关注", $"{failed} 门", failed > 0 ? "建议尽早安排复习与答疑" : "当前无不及格记录", failed > 0 ? "attention" : "positive")
]);
} }
},
jsonOptions.Value.JsonSerializerOptions); if (!isManager && scope.IsInRole(SystemRoles.Teacher))
{
var teacherId = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == scope.UserId && x.Status == TeacherStatus.Active)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!teacherId.HasValue)
return new DashboardGreeting("Teacher", $"{name}{greeting}", "绑定教师档案后,将为你生成本学期教学负荷概览。", "教学节奏", "关联教师档案后,可从教学班、授课学时和成绩进度生成今日工作摘要。", []);
var tasks = db.TeachingTasks.AsNoTracking().Where(x =>
currentTermId.HasValue && x.AcademicTermId == currentTermId.Value &&
x.Teachers.Any(t => t.TeacherId == teacherId.Value));
var teachingClasses = await tasks.CountAsync(cancellationToken);
var estimatedHours = await tasks.SumAsync(
x => (int?)(x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), cancellationToken) ?? 0;
var gradeSheets = db.GradeSheets.AsNoTracking().Where(x =>
x.TeachingTask!.Teachers.Any(t => t.TeacherId == teacherId.Value) &&
currentTermId.HasValue && x.TeachingTask.AcademicTermId == currentTermId.Value);
var pendingGrades = await gradeSheets.CountAsync(x =>
x.Status == GradeSheetStatus.Draft ||
x.Status == GradeSheetStatus.Returned, cancellationToken);
var submittedGrades = await gradeSheets.CountAsync(x => x.Status == GradeSheetStatus.Submitted, cancellationToken);
var subtitle = pendingGrades > 0
? $"有 {pendingGrades} 张成绩登记册尚待提交,完成后可进入审核流程。"
: teachingClasses > 0
? $"本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时。"
: "本学期暂未分配教学班,请留意教学任务安排。";
var narrative = pendingGrades > 0
? $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时;有 {pendingGrades} 张成绩登记册等待提交。"
: $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时,目前没有待提交的成绩登记册。";
return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", narrative,
[
new DashboardGreetingInsight("教学班", $"{teachingClasses} 个", $"预计 {estimatedHours} 学时", "calm"),
new DashboardGreetingInsight("待提交成绩", $"{pendingGrades} 张", pendingGrades > 0 ? "请在截止日前完成登记" : "当前无需提交", pendingGrades > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("审核中成绩", $"{submittedGrades} 张", submittedGrades > 0 ? "等待审核结果" : "暂无审核中登记册", "calm")
]);
}
var actionable = pending is null ? 0 : pending.TeacherApplications + pending.GradeSheets +
pending.CourseAdjustments + pending.StudentStatusChanges + pending.GradeModifications +
pending.ClassroomReservations + pending.GeneralApprovals;
var taskCount = counts?.TeachingTasks ?? 0;
var scheduledCount = counts?.ScheduledTeachingTasks ?? 0;
var subtitleForManager = actionable > 0
? $"当前有 {actionable} 项待办需要跟进,优先处理时效性审核事项。"
: taskCount > 0
? $"本学期 {taskCount} 个教学班正在运行,当前没有积压待办。"
: "当前学期运行数据已就绪,可从教学任务开始推进。";
var managerNarrative = actionable > 0
? $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表;{actionable} 项待办正等待处理。"
: $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表,暂未发现需要你处理的积压事项。";
return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", managerNarrative,
[
new DashboardGreetingInsight("当前待办", $"{actionable} 项", actionable > 0 ? "优先处理可操作事项" : "暂无积压", actionable > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("本学期教学班", $"{taskCount} 个", "教学运行规模", "calm"),
new DashboardGreetingInsight("已进入课表", $"{scheduledCount} 个", taskCount > 0 ? $"覆盖 {Math.Round(scheduledCount * 100d / taskCount)}% 教学班" : "等待教学任务发布", "calm")
]);
}
private static string GetTimeGreeting()
{
var hour = DateTime.UtcNow.AddHours(8).Hour;
return hour < 11 ? "早上好" : hour < 14 ? "中午好" : hour < 18 ? "下午好" : "晚上好";
}
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,
DashboardGreeting Greeting,
DateTime GeneratedAt);
public sealed record DashboardGreeting(
string Role,
string Title,
string Subtitle,
string Label,
string Narrative,
IReadOnlyList<DashboardGreetingInsight> Insights);
public sealed record DashboardGreetingInsight(
string Label,
string Value,
string Hint,
string Tone);
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);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,596 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/grade-analytics")]
public sealed class GradeAnalyticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader + "," +
SystemRoles.Teacher;
private const string ScheduleManagers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
{
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
var defaults = new CourseGradeStatisticsRefreshSetting();
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
var lastRunAt = setting?.LastRunAt;
return Ok(new
{
IsEnabled = enabled,
IntervalSeconds = intervalSeconds,
BatchSize = batchSize,
LastRunAt = lastRunAt,
NextRunAt = enabled && lastRunAt.HasValue
? lastRunAt.Value.AddSeconds(intervalSeconds)
: null as DateTime?
});
}
[HttpPut("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> SaveRefreshSchedule(
SaveGradeStatisticsRefreshScheduleRequest request,
CancellationToken cancellationToken)
{
if (request.IntervalSeconds is < 10 or > 86400)
return BadRequest(new ProblemDetails
{
Title = "刷新间隔应在 10 秒到 24 小时之间。",
Status = StatusCodes.Status400BadRequest
});
if (request.BatchSize is < 1 or > 5000)
return BadRequest(new ProblemDetails
{
Title = "单次刷新批量应在 1 到 5000 之间。",
Status = StatusCodes.Status400BadRequest
});
var setting = await db.CourseGradeStatisticsRefreshSettings
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
if (setting is null)
{
setting = new CourseGradeStatisticsRefreshSetting();
db.CourseGradeStatisticsRefreshSettings.Add(setting);
}
setting.IsEnabled = request.IsEnabled;
setting.IntervalSeconds = request.IntervalSeconds;
setting.BatchSize = request.BatchSize;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId,
string? keyword,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (keyword is not null)
source = source.Where(x =>
x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword));
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TermName = x.TeachingTask.AcademicTerm!.Name,
x.AcademicTermId,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
public async Task<ActionResult> GetTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
return Ok(report);
}
[HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")]
public async Task<ActionResult> ExportTeachingClassAnalysisReport(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
.Select(x => new AnalysisTarget(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
x.TeachingTask.Course!.CollegeId,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Course.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.AcademicTerm!.Name))
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
token => BuildReportAsync(sheet, token),
AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics],
cancellationToken);
if (report.IsRefreshing || report.Summary is null)
return Conflict(new ProblemDetails
{
Title = "成绩统计尚未生成",
Detail = "请先重新计算当前教学班,待统计完成后再导出。",
Status = StatusCodes.Status409Conflict
});
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx";
return File(
content,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileName);
}
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
public async Task<ActionResult> RefreshTeachingClassAnalysis(
Guid gradeSheetId,
CancellationToken cancellationToken)
{
var exists = await db.GradeSheets.AsNoTracking()
.AnyAsync(x => x.Id == gradeSheetId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
cancellationToken);
if (!exists) return NotFound();
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
await db.SaveChangesAsync(cancellationToken);
return Accepted(new { job.Id });
}
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
AnalysisTarget target,
CancellationToken cancellationToken)
{
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new TeachingClassMetrics(
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.CalculatedAt,
x.ScoreBands.OrderBy(band => band.SortOrder)
.Select(band => new ScoreBand(
band.Label,
band.LowerBound,
band.UpperBound,
band.StudentCount))
.ToArray()))
.FirstOrDefaultAsync(cancellationToken);
if (statistic is null)
return new TeachingClassAnalysisReport(
true,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
null,
[],
[],
[],
null);
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.OrderByDescending(x => x.AverageScore)
.Select(x => new
{
x.GradeSheetId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name).ToArray(),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name).ToArray(),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate
})
.ToListAsync(cancellationToken);
var peers = peerRows.Select(x => new TeachingClassComparison(
x.GradeSheetId,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
string.Join("、", x.TeacherNames),
string.Join("、", x.ClassNames),
x.StudentCount,
x.HighestScore,
x.AverageScore,
x.MedianScore,
x.LowestScore,
x.StandardDeviation,
x.PassRate,
x.ExcellentRate,
x.GradeSheetId == target.GradeSheetId)).ToArray();
var classProfiles = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == target.GradeSheetId)
.Select(x => new ClassProfile(
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.Name,
x.Student.AdministrativeClass.MajorId,
x.Student.AdministrativeClass.Major!.Name,
x.Student.AdministrativeClass.Major.CollegeId,
x.Student.AdministrativeClass.Major.College!.Name))
.Distinct()
.ToListAsync(cancellationToken);
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ToListAsync(cancellationToken);
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.TeachingTask!.Teachers.Any(link =>
selectedTeacherIds.Contains(link.TeacherId)))
.Select(x => new
{
x.AcademicTermId,
TermName = x.TeachingTask!.AcademicTerm!.Name,
x.TeachingTask.AcademicTerm.StartDate,
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.AverageScore
})
.ToListAsync(cancellationToken);
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == target.CourseId &&
x.Scope == CourseGradeStatisticScope.University)
.Select(x => new
{
x.AcademicTermId,
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.Name).First(),
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
.Select(term => term.StartDate).First(),
x.StudentCount,
x.AverageScore,
x.PassRate,
ExcellentRate = x.StudentCount == 0 ? 0m :
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
})
.ToListAsync(cancellationToken);
var teacherByTerm = historicalTaskRows
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
.ToDictionary(group => group.Key.AcademicTermId, group =>
{
var count = group.Sum(x => x.StudentCount);
return new HistoricalSeriesValue(
count,
count == 0 ? 0m : Math.Round(
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
count == 0 ? 0m : Math.Round(
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
});
var history = courseHistory
.OrderBy(x => x.StartDate)
.Select(x => new HistoricalComparison(
x.AcademicTermId,
x.TermName,
x.StudentCount,
x.AverageScore,
x.PassRate,
x.ExcellentRate,
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
.ToArray();
var university = scopeStatistics.FirstOrDefault(x =>
x.Scope == CourseGradeStatisticScope.University);
return new TeachingClassAnalysisReport(
false,
target.GradeSheetId,
target.TeachingTaskId,
target.TaskNumber,
target.TaskName,
target.CourseCode,
target.CourseName,
target.TermName,
statistic,
peers,
benchmarks,
history,
university is null ? null : new ComparisonDelta(
Math.Round(statistic.AverageScore - university.AverageScore, 1),
Math.Round(statistic.PassRate - university.PassRate, 2),
university.AverageScore,
university.PassRate));
}
private static ScopeBenchmark[] BuildBenchmarks(
IEnumerable<ClassProfile> classProfiles,
IReadOnlyCollection<CourseGradeStatistic> statistics)
{
var profiles = classProfiles.ToArray();
var rows = new List<ScopeBenchmark>();
foreach (var profile in profiles)
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
profile.ClassId, "行政班", profile.ClassName);
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
profile.MajorId, "专业", profile.MajorName);
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
profile.CollegeId, "学院", profile.CollegeName);
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
null, "全校", "全校同课程");
return rows.ToArray();
}
private static void AddBenchmark(
ICollection<ScopeBenchmark> target,
IEnumerable<CourseGradeStatistic> source,
CourseGradeStatisticScope scope,
Guid? entityId,
string scopeLabel,
string name)
{
var item = source.FirstOrDefault(x =>
x.Scope == scope && x.ScopeEntityId == entityId);
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
target.Add(new ScopeBenchmark(
scopeLabel,
name,
item.StudentCount,
item.HighestScore,
item.AverageScore,
item.LowestScore,
item.PassRate));
}
private IQueryable<TeachingTask> VisibleTeachingTasks()
{
var scope = currentUserDataScope.Current;
var source = db.TeachingTasks.AsQueryable();
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars();
return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character));
}
private sealed record AnalysisTarget(
Guid GradeSheetId,
Guid TeachingTaskId,
Guid CourseId,
Guid AcademicTermId,
Guid CourseCollegeId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName);
private sealed record ClassProfile(
Guid ClassId,
string ClassName,
Guid MajorId,
string MajorName,
Guid CollegeId,
string CollegeName);
public sealed record ScoreBand(
string Label,
decimal LowerBound,
decimal? UpperBound,
int StudentCount);
public sealed record TeachingClassMetrics(
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
DateTime CalculatedAt,
IReadOnlyList<ScoreBand> ScoreBands);
public sealed record TeachingClassComparison(
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string TeacherNames,
string ClassNames,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
bool IsSelected);
public sealed record ScopeBenchmark(
string Scope,
string Name,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal LowestScore,
decimal PassRate);
public sealed record HistoricalSeriesValue(
int StudentCount,
decimal AverageScore,
decimal PassRate,
decimal ExcellentRate);
public sealed record HistoricalComparison(
Guid AcademicTermId,
string TermName,
int CourseStudentCount,
decimal CourseAverageScore,
decimal CoursePassRate,
decimal CourseExcellentRate,
HistoricalSeriesValue? Instructor);
public sealed record ComparisonDelta(
decimal AverageScoreDifference,
decimal PassRateDifference,
decimal UniversityAverageScore,
decimal UniversityPassRate);
public sealed record TeachingClassAnalysisReport(
bool IsRefreshing,
Guid GradeSheetId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
string TermName,
TeachingClassMetrics? Summary,
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
IReadOnlyList<HistoricalComparison> History,
ComparisonDelta? UniversityDelta);
}
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
bool IsEnabled,
int IntervalSeconds,
int BatchSize);
+268 -10
View File
@@ -3,7 +3,9 @@ using System.Globalization;
using Jiaowu.Api.Contracts; using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/grades")] [Route("api/grades")]
public sealed class GradesController( public sealed class GradesController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
IAppCache? cache = null) : ControllerBase
{ {
private const string SheetUsers = private const string SheetUsers =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -37,6 +40,9 @@ public sealed class GradesController(
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin; SystemRoles.AcademicAdmin;
private const string StatisticsUsers =
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
[HttpGet("sheets")] [HttpGet("sheets")]
[Authorize(Roles = SheetUsers)] [Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheets( public async Task<ActionResult> GetSheets(
@@ -101,7 +107,9 @@ public sealed class GradesController(
{ {
item.Id, item.Id,
item.Name, item.Name,
item.Weight item.Weight,
item.SourceType,
item.SourceSnapshotAt
}), }),
StudentCount = sheet.Records.Count, StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record => CompletedCount = sheet.Records.Count(record =>
@@ -143,6 +151,7 @@ public sealed class GradesController(
var task = await AccessibleTasks() var task = await AccessibleTasks()
.Include(x => x.Teachers) .Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed)) if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
@@ -220,7 +229,9 @@ public sealed class GradesController(
{ {
item.Id, item.Id,
item.Name, item.Name,
item.Weight item.Weight,
item.SourceType,
item.SourceSnapshotAt
}), }),
x.Status, x.Status,
x.ReviewComment, x.ReviewComment,
@@ -408,7 +419,7 @@ public sealed class GradesController(
request.Records.Any(x => !records.ContainsKey(x.Id))) request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。"); return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet(); var itemsById = sheet.Items.ToDictionary(item => item.Id);
foreach (var item in request.Records) foreach (var item in request.Records)
{ {
if (!ValidScore(item.RegularScore) || if (!ValidScore(item.RegularScore) ||
@@ -428,7 +439,18 @@ public sealed class GradesController(
{ {
if (!ValidScore(scoreEntry.Score)) if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。"); return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing)) if (!itemsById.TryGetValue(
scoreEntry.GradeItemId,
out var gradeItem) ||
!scoreMap.TryGetValue(
scoreEntry.GradeItemId,
out var existing))
continue;
if (gradeItem.SourceType ==
GradeItemSourceType.ExperimentSummary &&
existing.Score != scoreEntry.Score)
return ConflictProblem(
$"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。");
existing.Score = scoreEntry.Score; existing.Score = scoreEntry.Score;
} }
} }
@@ -438,6 +460,66 @@ public sealed class GradesController(
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> ImportExperimentScores(
Guid id,
ImportExperimentScoresRequest request,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (
GradeSheetStatus.Draft or
GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能导入实验成绩。");
var targetItem = sheet.Items.FirstOrDefault(x =>
x.Id == request.GradeItemId);
if (targetItem is null)
return ValidationProblem("请选择当前成绩单中的有效分项。");
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
db,
sheet.TeachingTaskId,
sheet.Records.Select(x => x.StudentId).ToArray(),
cancellationToken);
if (aggregate.PublishedProjectCount == 0)
return ConflictProblem(
"该教学任务尚无已发布的实验项目成绩,无法导入。");
var imported = 0;
var missing = 0;
foreach (var record in sheet.Records)
{
var targetScore = record.ItemScores.First(x =>
x.GradeItemId == targetItem.Id);
targetScore.Score = aggregate.Scores[record.StudentId];
if (targetScore.Score.HasValue) imported++;
else missing++;
Recalculate(sheet, record);
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
aggregate.PublishedProjectCount,
aggregate.Projects,
ImportedCount = imported,
MissingCount = missing,
targetItem.SourceSnapshotAt
});
}
[HttpPost("sheets/{id:guid}/submit")] [HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = SheetUsers)] [Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
@@ -624,7 +706,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]); headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
var rows = sheet.Records.Select(record => var rows = sheet.Records.Select(record =>
{ {
@@ -642,6 +724,7 @@ public sealed class GradesController(
values.Add(score); values.Add(score);
} }
values.Add(record.FinalScore); values.Add(record.FinalScore);
values.Add(null);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" : values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" : record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" : record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
@@ -654,13 +737,56 @@ public sealed class GradesController(
{ {
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。", "请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
"成绩列填写 0—100 的数值,留空表示暂未录入。", "成绩列填写 0—100 的数值,留空表示暂未录入。",
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。", "考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("", itemNames)}。", $"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("", itemNames)}。",
"导入后会自动重新计算总评成绩和绩点。" "导入后会自动重新计算总评成绩和绩点。"
}; };
var regularColumn = 4;
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
var finalColumn = regularColumn + itemColumns.Length + 1;
var totalColumn = finalColumn + 1;
var statusColumn = totalColumn + 1;
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
(itemColumns[index], item.Weight)));
weightedColumns.Add((finalColumn, sheet.FinalWeight));
var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
var scoreColumns = weightedColumns.Select(x => x.Column)
.Append(totalColumn)
.ToArray();
var bytes = ExcelWorkbookHelper.Create( var bytes = ExcelWorkbookHelper.Create(
"成绩导入", headers, rows, instructions); "成绩导入", headers, rows, instructions,
(worksheet, rowNumber) =>
{
var componentReferences = requiredColumns
.Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
.ToArray();
var weightedExpression = string.Join("+", weightedColumns.Select(x =>
$"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
var formula =
$"=IF(OR({statusReference}=\"\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
var cell = worksheet.Cell(rowNumber, totalColumn);
cell.FormulaA1 = formula;
cell.Style.NumberFormat.Format = "0.0";
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
foreach (var scoreColumn in scoreColumns)
{
var conditionalFormat = worksheet
.Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
.AddConditionalFormat();
var failingScoreFormat = conditionalFormat.WhenLessThan(60);
failingScoreFormat.Fill.BackgroundColor =
ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
failingScoreFormat.Font.FontColor =
ClosedXML.Excel.XLColor.FromHtml("#B42318");
}
});
var taskName = sheet.TeachingTask!.Name; var taskName = sheet.TeachingTask!.Name;
return File(bytes, ExcelWorkbookHelper.ContentType, return File(bytes, ExcelWorkbookHelper.ContentType,
$"成绩导入模板-{taskName}.xlsx"); $"成绩导入模板-{taskName}.xlsx");
@@ -693,13 +819,15 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]); headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows; IReadOnlyList<ExcelRow> rows;
try try
{ {
rows = await ExcelWorkbookHelper.ReadAsync( rows = await ExcelWorkbookHelper.ReadAsync(
file, headers, cancellationToken); file,
headers.Where(x => x != "总分(自动计算)").ToArray(),
cancellationToken);
} }
catch (InvalidDataException exception) catch (InvalidDataException exception)
{ {
@@ -735,7 +863,7 @@ public sealed class GradesController(
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors); var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue; if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse final score // Parse final score. The formula-driven total column is intentionally ignored.
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors); var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue; if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
@@ -827,6 +955,7 @@ public sealed class GradesController(
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
GradeSheetId = x.GradeSheetId,
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId, AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name, TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTaskId, x.GradeSheet.TeachingTaskId,
@@ -843,6 +972,121 @@ public sealed class GradesController(
return Ok(new { Student = student, Records = records }); return Ok(new { Student = student, Records = records });
} }
[HttpGet("sheets/{id:guid}/statistics")]
[Authorize(Roles = StatisticsUsers)]
public async Task<ActionResult> GetCourseStatistics(
Guid id,
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var sheet = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.Status,
x.TeachingTask!.CourseId,
x.TeachingTask.AcademicTermId,
CourseName = x.TeachingTask.Course!.Name,
CourseCode = x.TeachingTask.Course.Code,
TermName = x.TeachingTask.AcademicTerm!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
Guid? classId = null;
Guid? majorId = null;
Guid? collegeId = null;
if (scope.IsInRole(SystemRoles.Student))
{
if (sheet.Status != GradeSheetStatus.Published)
return NotFound();
var student = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
.Select(x => new
{
x.Student!.AdministrativeClassId,
MajorId = x.Student.AdministrativeClass!.MajorId,
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
})
.FirstOrDefaultAsync(cancellationToken);
if (student is null) return Forbid();
classId = student.AdministrativeClassId;
majorId = student.MajorId;
collegeId = student.CollegeId;
}
else if (scope.Scope == DataScope.College)
{
collegeId = scope.RestrictedCollegeId;
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
await db.Courses.Where(x => x.Id == sheet.CourseId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
return Forbid();
}
else if (scope.IsInRole(SystemRoles.Counselor))
{
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.CounselorUserId == scope.UserId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (allowedClassIds.Count == 0) return Forbid();
// A counselor sees their classes plus the matching major/college
// benchmarks, never an unrelated class-level statistic.
classId = allowedClassIds.First();
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
.Select(x => x.MajorId).FirstAsync(cancellationToken);
collegeId = await db.Majors.Where(x => x.Id == majorId)
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
}
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
{
var source = db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == sheet.CourseId &&
x.AcademicTermId == sheet.AcademicTermId);
return await source.Select(x => new
{
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
x.Below60Count, x.From60To69Count, x.From70To79Count,
x.From80To89Count, x.From90To100Count,
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
x.CalculatedAt
}).ToListAsync(token);
}, AppCacheProfile.Analytics,
[AppCacheTags.CourseGradeStatistics], cancellationToken);
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
{
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
x.ScopeEntityId == entityId);
return item is null ? null : new
{
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
item.CalculatedAt,
Distribution = new[]
{
new { Range = "059", Count = item.Below60Count },
new { Range = "6069", Count = item.From60To69Count },
new { Range = "7079", Count = item.From70To79Count },
new { Range = "8089", Count = item.From80To89Count },
new { Range = "90100", Count = item.From90To100Count }
}
};
}
return Ok(new
{
sheet.CourseName, sheet.CourseCode, sheet.TermName,
IsRefreshing = !statistics.Any(),
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
? Find(CourseGradeStatisticScope.University, null) : null
});
}
private IQueryable<TeachingTask> AccessibleTasks() private IQueryable<TeachingTask> AccessibleTasks()
{ {
var source = db.TeachingTasks.AsQueryable(); var source = db.TeachingTasks.AsQueryable();
@@ -966,6 +1210,18 @@ public sealed class GradesController(
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string ColumnLetter(int column)
{
var result = string.Empty;
while (column > 0)
{
column--;
result = (char)('A' + column % 26) + result;
column /= 26;
}
return result;
}
} }
public sealed record GradeSheetRequest( public sealed record GradeSheetRequest(
@@ -998,5 +1254,7 @@ public sealed record GradeItemScoreRequest(
Guid GradeItemId, Guid GradeItemId,
decimal? Score); decimal? Score);
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest( public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment); [MaxLength(500)] string? Comment);
@@ -18,8 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class MakeupExamsController( public sealed class MakeupExamsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService, MakeupExamEligibilityService eligibilityService) : ControllerBase
MakeupExamArrangementService arrangementService) : ControllerBase
{ {
private const string Managers = private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
@@ -111,6 +110,7 @@ public sealed class MakeupExamsController(
item.StartsAt, item.StartsAt,
item.EndsAt, item.EndsAt,
item.RequiredBuildingId, item.RequiredBuildingId,
item.RequiredBuildingIds,
RequiredBuildingName = item.RequiredBuilding != null RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null, ? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount, item.RequiredInvigilatorCount,
@@ -138,38 +138,90 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{ {
var plan = await db.MakeupExamPlans if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
.Include(x => x.Sessions) return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
.ThenInclude(x => x.Invigilators) if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
.Include(x => x.Sessions) return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
.ThenInclude(x => x.Enrollments)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Status,
HasSessions = x.Sessions.Any()
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿补考计划可以发布。"); return ConflictProblem("只有草稿补考计划可以发布。");
if (plan.Sessions.Count == 0) if (!plan.HasSessions)
return ConflictProblem("至少安排一个考试场次后才能发布。"); return ConflictProblem("至少安排一个考试场次后才能发布。");
var unassigned = plan.Sessions.Count(x => var userId = currentUserDataScope.Current.UserId;
!x.ClassroomId.HasValue || x.Invigilators.Count == 0); var job = new ExamPublishJob
if (unassigned > 0) {
return ConflictProblem( Kind = ExamPublishJobKind.MakeupExam,
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。"); PlanId = id,
ActivePlanId = id,
RequestedByUserId = userId == Guid.Empty ? null : userId,
CurrentStep = "等待后台校验"
};
db.ExamPublishJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamPublish,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
var existing = await FindActivePublishJobAsync(id, cancellationToken);
if (existing is not null)
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
throw;
}
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0); return AcceptedPublishJob(job, "补考发布任务已提交。");
if (empty > 0) }
return ConflictProblem(
$"还有 {empty} 个场次没有登记补考学生。");
plan.Status = MakeupExamPlanStatus.Published; [HttpGet("publish-jobs/{jobId:guid}")]
plan.PublishedAt = DateTime.UtcNow; [Authorize(Roles = Managers)]
return await SaveAsync(id, false, cancellationToken); public async Task<ActionResult> GetPublishJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamPublishJobKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
}
[HttpGet("plans/{planId:guid}/publish-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestPublishJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamPublishJobKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
} }
[HttpPost("plans/{id:guid}/archive")] [HttpPost("plans/{id:guid}/archive")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Published) if (plan.Status != MakeupExamPlanStatus.Published)
@@ -189,6 +241,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request, CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -215,6 +269,7 @@ public sealed class MakeupExamsController(
StartsAt = startsAt, StartsAt = startsAt,
EndsAt = endsAt, EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId, RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount, RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes), Notes = Normalize(request.Notes),
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id => Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
@@ -231,6 +286,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionsBatchRequest request, CreateMakeupExamSessionsBatchRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -281,6 +338,7 @@ public sealed class MakeupExamsController(
StartsAt = startsAt, StartsAt = startsAt,
EndsAt = endsAt, EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId, RequiredBuildingId = request.RequiredBuildingId,
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
RequiredInvigilatorCount = request.RequiredInvigilatorCount, RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes) Notes = Normalize(request.Notes)
})); }));
@@ -304,6 +362,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request, CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan) .Include(x => x.MakeupExamPlan)
.Include(x => x.Invigilators) .Include(x => x.Invigilators)
@@ -330,6 +390,7 @@ public sealed class MakeupExamsController(
session.StartsAt = startsAt; session.StartsAt = startsAt;
session.EndsAt = endsAt; session.EndsAt = endsAt;
session.RequiredBuildingId = request.RequiredBuildingId; session.RequiredBuildingId = request.RequiredBuildingId;
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes); session.Notes = Normalize(request.Notes);
@@ -347,6 +408,8 @@ public sealed class MakeupExamsController(
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan) var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
if (session is null) return NotFound(); if (session is null) return NotFound();
@@ -368,15 +431,117 @@ public sealed class MakeupExamsController(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
request ??= new ExamAutoArrangeRequest(); request ??= new ExamAutoArrangeRequest();
var result = await arrangementService.ArrangeAsync( if (!request.AssignClassrooms && !request.AssignInvigilators)
planId, return ValidationProblem("请至少选择分配考场或分配监考教师。");
request.SessionIds,
request.AssignClassrooms, var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
request.AssignInvigilators, if (sessionIds.Length > 100)
return ValidationProblem("一次最多处理100个补考场次。");
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == planId)
.Select(x => new
{
x.Status,
TotalSessions = x.Sessions.Count
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null)
return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
if (sessionIds.Length > 0)
{
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId)
.WhereIn(sessionIds, x => x.Id)
.CountAsync(cancellationToken);
if (selectedSessionCount != sessionIds.Length)
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
}
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
.AnyAsync(
x => x.MakeupExamPlanId == planId &&
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running),
cancellationToken); cancellationToken);
if (!result.Success) if (autoCreateActive)
return ConflictProblem(result.Message); return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
return Ok(new { message = result.Message });
var existing = await FindActiveArrangementJobAsync(
planId,
cancellationToken);
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
var userId = currentUserDataScope.Current.UserId;
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.MakeupExam,
PlanId = planId,
ActivePlanId = planId,
RequestedByUserId = userId == Guid.Empty ? null : userId,
SessionIdsJson = sessionIds.Length == 0
? null
: System.Text.Json.JsonSerializer.Serialize(sessionIds),
AssignClassrooms = request.AssignClassrooms,
AssignInvigilators = request.AssignInvigilators,
TotalSessions = sessionIds.Length == 0
? plan.TotalSessions
: sessionIds.Length,
CurrentStep = "等待后台编排"
};
db.ExamArrangementJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamArrangement,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
existing = await FindActiveArrangementJobAsync(
planId,
cancellationToken);
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
throw;
}
return AcceptedArrangementJob(job, "补考编排任务已提交。");
}
[HttpGet("arrangement-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetArrangementJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamArrangementKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
}
[HttpGet("plans/{planId:guid}/arrangement-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestArrangementJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamArrangementKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -394,6 +559,8 @@ public sealed class MakeupExamsController(
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动生成。"); return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
// Check for existing active job // Check for existing active job
var existing = await db.MakeupExamAutoJobs.AsNoTracking() var existing = await db.MakeupExamAutoJobs.AsNoTracking()
@@ -570,6 +737,7 @@ public sealed class MakeupExamsController(
.Include(x => x.Enrollments) .Include(x => x.Enrollments)
.Include(x => x.TeachingTask!) .Include(x => x.TeachingTask!)
.ThenInclude(x => x.Teachers) .ThenInclude(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null) return NotFound(); if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published) if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
@@ -1042,6 +1210,87 @@ public sealed class MakeupExamsController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamArrangementKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedArrangementJob(
ExamArrangementJob job,
string message) =>
AcceptedAtAction(
nameof(GetArrangementJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.TotalSessions,
job.ProcessedSessions,
job.CurrentStep,
job.ResultMessage,
job.ErrorMessage,
job.AssignClassrooms,
job.AssignInvigilators,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private Task<ExamPublishJob?> FindActivePublishJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamPublishJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamPublishJobKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedPublishJob(
ExamPublishJob job,
string message) =>
AcceptedAtAction(
nameof(GetPublishJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToPublishJobResponse(ExamPublishJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.CurrentStep,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private async Task<ActionResult> SaveAsync(Guid id, bool created, private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token) CancellationToken token)
{ {
@@ -1064,6 +1313,10 @@ public sealed class MakeupExamsController(
}); });
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
ids is { Count: > 0 }
? System.Text.Json.JsonSerializer.Serialize(ids)
: null;
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -1082,6 +1335,7 @@ public sealed record CreateMakeupExamSessionRequest(
[Range(1, 30)] int StartPeriod, [Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount, [Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId, Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount, [Range(1, 10)] int RequiredInvigilatorCount,
IReadOnlyCollection<Guid>? InvigilatorIds, IReadOnlyCollection<Guid>? InvigilatorIds,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
@@ -1092,6 +1346,7 @@ public sealed record CreateMakeupExamSessionsBatchRequest(
[Range(1, 30)] int StartPeriod, [Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount, [Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId, Guid? RequiredBuildingId,
IReadOnlyCollection<Guid>? RequiredBuildingIds,
[Range(1, 10)] int RequiredInvigilatorCount, [Range(1, 10)] int RequiredInvigilatorCount,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Net;
using System.Text.RegularExpressions;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -14,8 +17,11 @@ namespace Jiaowu.Api.Controllers;
[Route("api/notifications")] [Route("api/notifications")]
public sealed class NotificationsController( public sealed class NotificationsController(
AppDbContext db, 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 = private const string Senders =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," + SystemRoles.AcademicAdmin + "," +
@@ -52,6 +58,7 @@ public sealed class NotificationsController(
var total = await source.CountAsync(cancellationToken); var total = await source.CountAsync(cancellationToken);
var items = await source var items = await source
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(x => new .Select(x => new
@@ -106,17 +113,12 @@ public sealed class NotificationsController(
var scope = currentUserDataScope.Current; var scope = currentUserDataScope.Current;
if (IsSchoolAdministrator(scope)) if (IsSchoolAdministrator(scope))
{ {
var recipientCount = await db.Users.AsNoTracking() return Ok(await BuildAdministratorComposerAsync(
.CountAsync( scope,
x => x.IsEnabled && x.Id != scope.UserId, null,
cancellationToken); MessageAudienceType.School,
return Ok(new "全校已启用账号",
{ cancellationToken));
AudienceType = MessageAudienceType.School,
AudienceName = "全校已启用账号",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
} }
if (scope.IsInRole(SystemRoles.CollegeAdmin) || if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
@@ -138,19 +140,12 @@ public sealed class NotificationsController(
if (college is null) if (college is null)
return ConflictProblem("当前账号关联的学院不存在。"); return ConflictProblem("当前账号关联的学院不存在。");
var recipientCount = await db.Users.AsNoTracking() return Ok(await BuildAdministratorComposerAsync(
.CountAsync( scope,
x => x.IsEnabled && collegeId.Value,
x.Id != scope.UserId && MessageAudienceType.College,
x.CollegeId == collegeId.Value, $"{college}全院成员",
cancellationToken); cancellationToken));
return Ok(new
{
AudienceType = MessageAudienceType.College,
AudienceName = $"{college}全院成员",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
} }
if (scope.IsInRole(SystemRoles.Teacher)) if (scope.IsInRole(SystemRoles.Teacher))
@@ -195,6 +190,114 @@ public sealed class NotificationsController(
return Forbid(); 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")] [HttpPost("send")]
[Authorize(Roles = Senders)] [Authorize(Roles = Senders)]
public async Task<ActionResult> Send( public async Task<ActionResult> Send(
@@ -205,8 +308,10 @@ public sealed class NotificationsController(
var content = request.Content.Trim(); var content = request.Content.Trim();
if (title.Length == 0) if (title.Length == 0)
return ValidationProblem("请填写消息标题。"); return ValidationProblem("请填写消息标题。");
if (content.Length == 0) if (PlainText(content).Length == 0)
return ValidationProblem("请填写消息正文。"); return ValidationProblem("请填写消息正文。");
if (content.Length > 20000)
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
var scope = currentUserDataScope.Current; var scope = currentUserDataScope.Current;
MessageAudienceType audienceType; MessageAudienceType audienceType;
Guid? audienceId = null; Guid? audienceId = null;
@@ -215,12 +320,16 @@ public sealed class NotificationsController(
if (IsSchoolAdministrator(scope)) if (IsSchoolAdministrator(scope))
{ {
audienceType = MessageAudienceType.School; var result = await ResolveAdministratorRecipientsAsync(
audienceName = "全校已启用账号"; scope,
recipientIds = await db.Users.AsNoTracking() null,
.Where(x => x.IsEnabled && x.Id != scope.UserId) request,
.Select(x => x.Id) "全校已启用账号",
.ToListAsync(cancellationToken); 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) || else if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor)) scope.IsInRole(SystemRoles.Counselor))
@@ -241,16 +350,19 @@ public sealed class NotificationsController(
if (collegeName is null) if (collegeName is null)
return ConflictProblem("当前账号关联的学院不存在。"); return ConflictProblem("当前账号关联的学院不存在。");
audienceType = MessageAudienceType.College; var result = await ResolveAdministratorRecipientsAsync(
audienceId = collegeId.Value; scope,
audienceName = $"{collegeName}全院成员"; collegeId.Value,
recipientIds = await db.Users.AsNoTracking() request,
.Where(x => $"{collegeName}全院成员",
x.IsEnabled && cancellationToken);
x.Id != scope.UserId && if (result.Error is not null) return result.Error;
x.CollegeId == collegeId.Value) audienceType = result.AudienceType;
.Select(x => x.Id) audienceId = audienceType == MessageAudienceType.College
.ToListAsync(cancellationToken); ? collegeId.Value
: null;
audienceName = result.AudienceName!;
recipientIds = result.RecipientIds!;
} }
else if (scope.IsInRole(SystemRoles.Teacher)) else if (scope.IsInRole(SystemRoles.Teacher))
{ {
@@ -305,17 +417,31 @@ public sealed class NotificationsController(
RecipientCount = recipientIds.Count, RecipientCount = recipientIds.Count,
LinkUrl = null LinkUrl = null
}; };
dispatch.Notifications = recipientIds.Select(userId => new Notification 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, UserId = userId,
Title = title, Title = title,
Content = content, Content = content,
Category = NotificationCategory.General, Category = NotificationCategory.General,
LinkUrl = null LinkUrl = null,
}).ToList(); MessageDispatchId = dispatch.Id
}));
db.MessageDispatches.Add(dispatch);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
db.ChangeTracker.Clear();
}
await transaction.CommitAsync(cancellationToken);
return Ok(new return Ok(new
{ {
dispatch.Id, dispatch.Id,
@@ -323,6 +449,22 @@ public sealed class NotificationsController(
dispatch.RecipientCount, dispatch.RecipientCount,
dispatch.CreatedAt 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")] [HttpGet("sent")]
@@ -433,6 +575,286 @@ public sealed class NotificationsController(
scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin); 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) => private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails Conflict(new ProblemDetails
{ {
@@ -451,9 +873,34 @@ public sealed class NotificationsController(
} }
public sealed record SendMessageRequest( public sealed record SendMessageRequest(
[property: Required, StringLength(200)] string Title, [Required, StringLength(200)] string Title,
[property: Required, StringLength(1000)] string Content, [Required, StringLength(20000)] string Content,
Guid? TeachingTaskId = null); 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> /// <summary>
/// Centralized helper to send notifications across the app. /// Centralized helper to send notifications across the app.
@@ -0,0 +1,648 @@
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("swagger")]
public async Task<ActionResult<SwaggerDocumentationSettings>> GetSwaggerSettings(
CancellationToken cancellationToken) =>
Ok(new SwaggerDocumentationSettings(await IsSwaggerEnabledAsync(cancellationToken)));
[HttpPut("swagger")]
public async Task<ActionResult<SwaggerDocumentationSettings>> UpdateSwaggerSettings(
UpdateSwaggerDocumentationSettings request,
CancellationToken cancellationToken)
{
var setting = await db.SystemFeatureSettings.SingleOrDefaultAsync(
x => x.Key == SystemFeatureKeys.SwaggerDocumentation,
cancellationToken);
if (setting is null)
{
setting = new SystemFeatureSetting
{
Key = SystemFeatureKeys.SwaggerDocumentation,
IsEnabled = request.IsEnabled
};
db.SystemFeatureSettings.Add(setting);
}
else
{
setting.IsEnabled = request.IsEnabled;
setting.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
return Ok(new SwaggerDocumentationSettings(setting.IsEnabled));
}
[HttpGet("audit-logs")]
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
[FromQuery] int page = 1,
[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 async Task<bool> IsSwaggerEnabledAsync(CancellationToken cancellationToken) =>
await db.SystemFeatureSettings.AsNoTracking()
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
.Select(x => (bool?)x.IsEnabled)
.SingleOrDefaultAsync(cancellationToken) ?? false;
private ActionResult? ValidatePaging(int page, int pageSize)
{
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
{
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);
public sealed record SwaggerDocumentationSettings(bool IsEnabled);
public sealed record UpdateSwaggerDocumentationSettings(bool IsEnabled);
@@ -0,0 +1,238 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/other-exams")]
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
.ToListAsync(ct));
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
{
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
var name = Normalize(request.Name);
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
if (error is not null) return ValidationProblem(error);
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
db.OtherExamBatches.Add(batch);
await db.SaveChangesAsync(ct);
return Ok(new { batch.Id });
}
[HttpGet("students/lookup")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
{
var number = Normalize(studentNumber);
if (number is null) return ValidationProblem("请输入学号。");
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
if (batch is null) return NotFound();
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
return Ok(new { Batch = batch, Results = results });
}
[HttpPut("batches/{id:guid}/results")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
var result = await ReplaceResultsAsync(batch, request.Results, ct);
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
}
[HttpGet("batches/{id:guid}/template")]
[Authorize(Roles = Managers)]
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
var headers = HeadersFor(batch);
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
}
[HttpPost("batches/{id:guid}/import")]
[Authorize(Roles = Managers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
IReadOnlyList<ExcelRow> rows;
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
var inputs = new List<OtherExamResultRequest>();
var errors = new List<string>();
foreach (var row in rows)
{
var number = row["学号"].Trim();
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var result = await ReplaceResultsAsync(batch, inputs, ct);
return result ?? Ok(new { updated = inputs.Count });
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
{
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
batch.Status = OtherExamBatchStatus.Published;
batch.PublicationCount++;
batch.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Ok(new { batch.PublicationCount, batch.PublishedAt });
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Mine(CancellationToken ct)
{
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
return Ok(new { Best = best, History = history });
}
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
{
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
var studentRows = await db.Students
.Where(x => numbers.Contains(x.StudentNumber))
.ToListAsync(ct);
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
foreach (var item in inputs)
{
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
if (error is not null) return ValidationProblem(error);
}
var studentIds = students.Values.Select(x => x.Id).ToList();
var beforeCount = await db.OtherExamResults.AsNoTracking()
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
{
db.OtherExamResults.RemoveRange(batch.Results);
batch.Status = OtherExamBatchStatus.Draft;
await db.SaveChangesAsync(ct);
batch.Results = inputs.Select(x =>
{
var student = students[x.StudentNumber.Trim()];
return new OtherExamResult
{
OtherExamBatchId = batch.Id,
StudentId = student.Id,
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
Score = x.Score,
Level = Normalize(x.Level),
IsPassed = x.IsPassed,
Notes = Normalize(x.Notes)
};
}).ToList();
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return null;
}, ct);
}
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
{
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
_ => ["学号", "是否合格", "备注"]
};
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
{
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
}
private static bool? ParsePass(string value, int row, List<string> errors)
{
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
}
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
{
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
_ => null
};
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
{
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
_ => null
};
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
{
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var index = Array.IndexOf(levels, level ?? "");
return index >= 0 ? levels.Length - index : -1;
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
return ValidationProblem(ModelState);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
}
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
@@ -224,6 +224,12 @@ public sealed class PersonnelController(
[FromQuery] PersonnelQuery query, [FromQuery] PersonnelQuery query,
CancellationToken cancellationToken) 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 page = NormalizePage(query.Page);
var pageSize = NormalizePageSize(query.PageSize); var pageSize = NormalizePageSize(query.PageSize);
var source = ApplyStudentScope(db.Students.AsNoTracking()); var source = ApplyStudentScope(db.Students.AsNoTracking());
@@ -268,9 +274,26 @@ public sealed class PersonnelController(
x.EnrollmentDate, x.EnrollmentDate,
x.Status, x.Status,
x.DateOfBirth, 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.Phone,
x.Email, 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.UserId,
x.CreatedAt x.CreatedAt
}) })
@@ -304,8 +327,26 @@ public sealed class PersonnelController(
EnrollmentDate = request.EnrollmentDate, EnrollmentDate = request.EnrollmentDate,
Status = request.Status, Status = request.Status,
DateOfBirth = request.DateOfBirth, 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), Phone = Normalize(request.Phone),
Email = Normalize(request.Email), 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) Notes = Normalize(request.Notes)
}; };
db.Students.Add(entity); db.Students.Add(entity);
@@ -343,8 +384,26 @@ public sealed class PersonnelController(
entity.EnrollmentDate = request.EnrollmentDate; entity.EnrollmentDate = request.EnrollmentDate;
entity.Status = request.Status; entity.Status = request.Status;
entity.DateOfBirth = request.DateOfBirth; 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.Phone = Normalize(request.Phone);
entity.Email = Normalize(request.Email); 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); entity.Notes = Normalize(request.Notes);
return await SaveNoContentAsync(cancellationToken); return await SaveNoContentAsync(cancellationToken);
} }
@@ -522,8 +581,25 @@ public sealed record StudentRequest(
DateOnly EnrollmentDate, DateOnly EnrollmentDate,
StudentStatus Status, StudentStatus Status,
DateOnly? DateOfBirth, 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, [MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email, [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); [MaxLength(500)] string? Notes);
public sealed record TeacherAccountActivationRequest( public sealed record TeacherAccountActivationRequest(
@@ -31,6 +31,9 @@ public sealed class PersonnelExcelController(
SystemRoles.AcademicAdmin + "," + SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin; SystemRoles.CollegeAdmin;
private const string ExportRoles =
WriteRoles + "," + SystemRoles.Counselor;
private static readonly string[] TeacherHeaders = private static readonly string[] TeacherHeaders =
[ [
"工号", "姓名", "性别", "学院编码", "职称", "任职状态", "工号", "姓名", "性别", "学院编码", "职称", "任职状态",
@@ -40,7 +43,10 @@ public sealed class PersonnelExcelController(
private static readonly string[] StudentHeaders = private static readonly string[] StudentHeaders =
[ [
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期", "学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注" "学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
]; ];
[HttpGet("{kind}/template")] [HttpGet("{kind}/template")]
@@ -66,6 +72,7 @@ public sealed class PersonnelExcelController(
} }
[HttpGet("{kind}/export")] [HttpGet("{kind}/export")]
[Authorize(Roles = ExportRoles)]
public async Task<IActionResult> Export( public async Task<IActionResult> Export(
string kind, string kind,
[FromQuery] PersonnelQuery query, [FromQuery] PersonnelQuery query,
@@ -96,7 +103,12 @@ public sealed class PersonnelExcelController(
.Select(x => Row( .Select(x => Row(
x.StudentNumber, x.Name, GenderName(x.Gender), x.StudentNumber, x.Name, GenderName(x.Gender),
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate, 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(); .ToList();
} }
@@ -296,8 +308,25 @@ public sealed class PersonnelExcelController(
entity.EnrollmentDate = enrollmentDate.Value; entity.EnrollmentDate = enrollmentDate.Value;
entity.Status = status.Value; entity.Status = status.Value;
entity.DateOfBirth = dateOfBirth; 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.Phone = Optional(row, "联系电话");
entity.Email = 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, "备注"); entity.Notes = Optional(row, "备注");
} }
return new(created, updated, rows.Count); return new(created, updated, rows.Count);
@@ -96,6 +96,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
x.SchedulingMode x.SchedulingMode
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -103,6 +105,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task => return Ok(tasks.Select(task =>
{ {
@@ -121,6 +124,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
task.StartWeek, task.StartWeek,
task.EndWeek, task.EndWeek,
task.WeeklyHours, task.WeeklyHours,
task.CourseTotalHours,
task.CoursePracticeHours,
task.SchedulingMode, task.SchedulingMode,
HasCustomConstraint = constraint is not null, HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
@@ -128,11 +133,16 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
: constraint?.RequiresClassroom ?? true, : constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId, constraint?.RequiredCampusId,
constraint?.RequiredBuildingId, constraint?.RequiredBuildingId,
constraint?.ExperimentRequiredCampusId,
constraint?.ExperimentRequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks), AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
constraint?.EarliestPeriod, constraint?.EarliestPeriod,
constraint?.LatestPeriod, constraint?.LatestPeriod,
AllowedClassroomIds = constraint?.AllowedClassrooms AllowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) ?? [] .Select(x => x.ClassroomId) ?? [],
AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId) ?? [],
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
}; };
})); }));
} }
@@ -163,6 +173,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
{ {
var flexibleConstraint = await db.TeachingTaskScheduleConstraints var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null) if (flexibleConstraint is not null)
{ {
@@ -190,6 +201,22 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
cancellationToken)) cancellationToken))
return ValidationProblem("指定校区不存在或已停用。"); return ValidationProblem("指定校区不存在或已停用。");
Building? experimentBuilding = null;
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null) return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking() var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(request.AllowedClassroomIds, x => x.Id) .WhereIn(request.AllowedClassroomIds, x => x.Id)
@@ -203,8 +230,23 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId)) allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。"); return ValidationProblem("指定教室必须位于所选校区。");
var allowedExperimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
var allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(allowedExperimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != allowedExperimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null && allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
var constraint = await db.TeachingTaskScheduleConstraints var constraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (constraint is null) if (constraint is null)
{ {
@@ -218,16 +260,28 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredBuildingId = request.RequiresClassroom constraint.RequiredBuildingId = request.RequiresClassroom
? request.RequiredBuildingId ? request.RequiredBuildingId
: null; : null;
constraint.ExperimentRequiredCampusId = request.RequiresClassroom
? request.ExperimentRequiredCampusId
: null;
constraint.ExperimentRequiredBuildingId = request.RequiresClassroom
? request.ExperimentRequiredBuildingId
: null;
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0 constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
? null ? null
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order()); : string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
constraint.EarliestPeriod = request.EarliestPeriod; constraint.EarliestPeriod = request.EarliestPeriod;
constraint.LatestPeriod = request.LatestPeriod; constraint.LatestPeriod = request.LatestPeriod;
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = request.RequiresClassroom constraint.AllowedClassrooms = request.RequiresClassroom
? request.AllowedClassroomIds.Distinct().Select(classroomId => ? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: []; : [];
constraint.AllowedExperimentClassrooms = request.RequiresClassroom
? allowedExperimentRoomIds.Select(classroomId =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return NoContent(); return NoContent();
} }
@@ -253,18 +307,23 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
!request.RequiresClassroom.HasValue && !request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null && request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope && !request.UpdateClassroomScope &&
!request.UpdateExperimentClassroomScope &&
!request.AllowedExperimentVenueNatures.HasValue &&
!request.UpdatePeriodRange && !request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue && !request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue) !request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。"); return ValidationProblem("请至少选择一项需要批量修改的设置。");
if (request.UpdateClassroomScope && request.RequiresClassroom == false) if (request.UpdateClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。"); return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
if (request.UpdateExperimentClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定实验场地时,场地要求不能设置为不占用教室。");
var tasks = await db.TeachingTasks var tasks = await db.TeachingTasks
.Where(x => .Where(x =>
x.AcademicTermId == request.AcademicTermId && x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published) x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id) .WhereIn(taskIds, x => x.Id)
.Include(x => x.Course)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length) if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。"); return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
@@ -275,9 +334,16 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
(request.SchedulingMode ?? task.SchedulingMode) == (request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible)) TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。"); return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
if (request.UpdateExperimentClassroomScope && tasks.Any(task =>
(request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定实验场地,请先将当前筛选结果限定为正常排课课程。");
Building? building = null; Building? building = null;
List<Classroom> allowedRooms = []; List<Classroom> allowedRooms = [];
var experimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
Building? experimentBuilding = null;
List<Classroom> allowedExperimentRooms = [];
if (request.UpdateClassroomScope) if (request.UpdateClassroomScope)
{ {
if (request.RequiredBuildingId.HasValue) if (request.RequiredBuildingId.HasValue)
@@ -314,10 +380,42 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.Building!.CampusId != request.RequiredCampusId)) x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。"); return ValidationProblem("指定教室必须位于所选校区。");
} }
if (request.UpdateExperimentClassroomScope)
{
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null)
return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选实验校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(experimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != experimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null &&
allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
}
var constraints = await db.TeachingTaskScheduleConstraints var constraints = await db.TeachingTaskScheduleConstraints
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks) foreach (var task in tasks)
{ {
@@ -336,6 +434,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
request.RequiresClassroom.HasValue || request.RequiresClassroom.HasValue ||
request.AllowedDayOfWeeks is not null || request.AllowedDayOfWeeks is not null ||
request.UpdateClassroomScope || request.UpdateClassroomScope ||
request.UpdateExperimentClassroomScope ||
request.AllowedExperimentVenueNatures.HasValue ||
request.UpdatePeriodRange; request.UpdatePeriodRange;
if (!changesConstraint) continue; if (!changesConstraint) continue;
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id }; constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
@@ -351,7 +451,10 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredCampusId = null; constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null; constraint.RequiredBuildingId = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = []; constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
} }
} }
if (request.AllowedDayOfWeeks is not null) if (request.AllowedDayOfWeeks is not null)
@@ -373,6 +476,17 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
ClassroomId = room.Id ClassroomId = room.Id
}).ToList(); }).ToList();
} }
if (task.Course?.PracticeHours > 0 && request.AllowedExperimentVenueNatures.HasValue)
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures.Value;
if (task.Course?.PracticeHours > 0 && request.UpdateExperimentClassroomScope)
{
constraint.ExperimentRequiredCampusId = request.ExperimentRequiredCampusId;
constraint.ExperimentRequiredBuildingId = request.ExperimentRequiredBuildingId;
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedExperimentClassrooms = allowedExperimentRooms.Select(room =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = room.Id }).ToList();
}
if (request.UpdatePeriodRange) if (request.UpdatePeriodRange)
{ {
constraint.EarliestPeriod = request.EarliestPeriod; constraint.EarliestPeriod = request.EarliestPeriod;
@@ -396,11 +510,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiresClassroom = false; constraint.RequiresClassroom = false;
constraint.RequiredCampusId = null; constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null; constraint.RequiredBuildingId = null;
constraint.ExperimentRequiredCampusId = null;
constraint.ExperimentRequiredBuildingId = null;
constraint.AllowedDayOfWeeks = null; constraint.AllowedDayOfWeeks = null;
constraint.EarliestPeriod = null; constraint.EarliestPeriod = null;
constraint.LatestPeriod = null; constraint.LatestPeriod = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = []; constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
} }
private ActionResult ConflictProblem(string detail) => private ActionResult ConflictProblem(string detail) =>
@@ -434,7 +552,11 @@ public sealed record TeachingTaskScheduleConstraintRequest(
IReadOnlyList<Guid> AllowedClassroomIds, IReadOnlyList<Guid> AllowedClassroomIds,
IReadOnlyList<int> AllowedDayOfWeeks, IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod); [Range(1, 30)] int? LatestPeriod,
TeachingVenueNature AllowedExperimentVenueNatures = 0,
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
public sealed record TeachingTaskScheduleConstraintBatchRequest( public sealed record TeachingTaskScheduleConstraintBatchRequest(
Guid AcademicTermId, Guid AcademicTermId,
@@ -448,4 +570,9 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
IReadOnlyList<Guid>? AllowedClassroomIds, IReadOnlyList<Guid>? AllowedClassroomIds,
bool UpdatePeriodRange, bool UpdatePeriodRange,
[Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod); [Range(1, 30)] int? LatestPeriod,
bool UpdateExperimentClassroomScope = false,
TeachingVenueNature? AllowedExperimentVenueNatures = null,
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -70,6 +71,7 @@ public sealed class SchedulesController(
{ {
entry.Id, entry.Id,
entry.TeachingTaskId, entry.TeachingTaskId,
entry.Kind,
TaskNumber = entry.TeachingTask!.TaskNumber, TaskNumber = entry.TeachingTask!.TaskNumber,
TaskName = entry.TeachingTask.Name, TaskName = entry.TeachingTask.Name,
CourseCode = entry.TeachingTask.Course!.Code, CourseCode = entry.TeachingTask.Course!.Code,
@@ -166,6 +168,7 @@ public sealed class SchedulesController(
Entries = source.Entries.Select(entry => new ScheduleEntry Entries = source.Entries.Select(entry => new ScheduleEntry
{ {
TeachingTaskId = entry.TeachingTaskId, TeachingTaskId = entry.TeachingTaskId,
Kind = entry.Kind,
ClassroomId = entry.ClassroomId, ClassroomId = entry.ClassroomId,
DayOfWeek = entry.DayOfWeek, DayOfWeek = entry.DayOfWeek,
StartPeriod = entry.StartPeriod, StartPeriod = entry.StartPeriod,
@@ -351,6 +354,25 @@ public sealed class SchedulesController(
ToResponse(job)); ToResponse(job));
} }
[HttpGet("plans/{planId:guid}/preflight")]
public async Task<ActionResult> Preflight(Guid planId, CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.Name, CourseName = x.Course!.Name })
.ToListAsync(cancellationToken);
var scheduled = await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlanId == planId)
.Select(x => x.TeachingTaskId).Distinct().ToListAsync(cancellationToken);
var missing = tasks.Where(x => !scheduled.Contains(x.Id))
.Select(x => $"《{x.CourseName}》{x.Name}").Take(20).ToList();
return Ok(new { totalTasks = tasks.Count, scheduledTasks = scheduled.Count, unscheduledTasks = missing.Count, messages = missing });
}
[HttpGet("auto-schedule-jobs/{jobId:guid}")] [HttpGet("auto-schedule-jobs/{jobId:guid}")]
public async Task<ActionResult<AutomaticScheduleJobResponse>> public async Task<ActionResult<AutomaticScheduleJobResponse>>
GetAutomaticScheduleJob( GetAutomaticScheduleJob(
@@ -418,6 +440,7 @@ public sealed class SchedulesController(
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken); var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
if (validation is not null) return validation; if (validation is not null) return validation;
entry.TeachingTaskId = request.TeachingTaskId; entry.TeachingTaskId = request.TeachingTaskId;
entry.Kind = request.Kind;
entry.ClassroomId = request.ClassroomId; entry.ClassroomId = request.ClassroomId;
entry.DayOfWeek = request.DayOfWeek; entry.DayOfWeek = request.DayOfWeek;
entry.StartPeriod = request.StartPeriod; entry.StartPeriod = request.StartPeriod;
@@ -497,6 +520,7 @@ public sealed class SchedulesController(
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null || if (task is null ||
task.Status != TeachingTaskStatus.Published || task.Status != TeachingTaskStatus.Published ||
@@ -506,13 +530,53 @@ public sealed class SchedulesController(
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。"); return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
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() var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
x => x.TeachingTaskId == request.TeachingTaskId, x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken); cancellationToken);
var requiresClassroom = constraint?.RequiresClassroom ?? true; var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
constraint?.RequiresClassroom != false;
if (requiresClassroom && !request.ClassroomId.HasValue) if (requiresClassroom && !request.ClassroomId.HasValue)
return ValidationProblem("该课程需要占用教室,请选择教室。"); return ValidationProblem("该课程需要占用教室,请选择教室。");
if (!requiresClassroom && request.ClassroomId.HasValue) if (!requiresClassroom && request.ClassroomId.HasValue)
@@ -536,18 +600,40 @@ public sealed class SchedulesController(
x => x.Id == request.ClassroomId && x.IsEnabled, x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken); cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。"); if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (constraint?.RequiredCampusId is Guid campusId && if (request.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId) classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。"); return ValidationProblem("所选教室不在该课程指定的校区。");
if (constraint?.RequiredBuildingId is Guid buildingId && if (request.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId) classroom.BuildingId != buildingId)
return ValidationProblem("所选教室不在该课程指定的教学楼。"); return ValidationProblem("所选教室不在该课程指定的教学楼。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
classroom.Building!.CampusId != experimentCampusId)
return ValidationProblem("所选场地不在该实验课指定的校区。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
classroom.BuildingId != experimentBuildingId)
return ValidationProblem("所选场地不在该实验课指定的教学楼。");
var allowedClassroomIds = constraint?.AllowedClassrooms var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) .Select(x => x.ClassroomId)
.ToHashSet() ?? []; .ToHashSet() ?? [];
if (allowedClassroomIds.Count > 0 && if (request.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id)) !allowedClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选教室不在该课程指定的教室范围内。"); return ValidationProblem("所选教室不在该课程指定的教室范围内。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
allowedNatures != 0 &&
(classroom.TeachingVenueNature & allowedNatures) == 0)
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (request.Kind == ScheduleEntryKind.Experiment &&
allowedExperimentClassroomIds.Count > 0 &&
!allowedExperimentClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选场地不在该实验课指定的场地范围内。");
} }
var studentCount = task.Classes.Sum(x => var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student => x.AdministrativeClass!.Students.Count(student =>
@@ -589,6 +675,7 @@ public sealed class SchedulesController(
{ {
SchedulePlanId = planId, SchedulePlanId = planId,
TeachingTaskId = request.TeachingTaskId, TeachingTaskId = request.TeachingTaskId,
Kind = request.Kind,
ClassroomId = request.ClassroomId, ClassroomId = request.ClassroomId,
DayOfWeek = request.DayOfWeek, DayOfWeek = request.DayOfWeek,
StartPeriod = request.StartPeriod, StartPeriod = request.StartPeriod,
@@ -606,6 +693,7 @@ public sealed class SchedulesController(
.Select(int.Parse) .Select(int.Parse)
.ToHashSet(); .ToHashSet();
private async Task<ActionResult> SaveAsync( private async Task<ActionResult> SaveAsync(
Guid id, Guid id,
bool created, bool created,
@@ -703,7 +791,8 @@ public sealed record ScheduleEntryRequest(
[Range(1, 30)] int StartWeek, [Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek, [Range(1, 30)] int EndWeek,
WeekPattern WeekPattern, WeekPattern WeekPattern,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes,
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
public sealed record AutomaticScheduleJobResponse( public sealed record AutomaticScheduleJobResponse(
Guid Id, Guid Id,
+550
View File
@@ -0,0 +1,550 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/auth/sso")]
public sealed class SsoController(
UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService,
IDistributedCache cache,
IOptions<SsoOptions> options,
ILogger<SsoController> logger) : ControllerBase
{
private const string BindingIntentProperty = "sso-binding-intent";
private readonly SsoOptions _options = options.Value;
[AllowAnonymous]
[HttpGet("settings")]
public ActionResult<SsoSettingsResponse> Settings() =>
new SsoSettingsResponse(
_options.Enabled,
_options.DisplayName,
EffectiveCallbackUrl());
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpGet("login")]
public async Task<IActionResult> Login(
[FromQuery] string? returnUrl = null,
[FromQuery] string? bindingIntent = null,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var properties = new AuthenticationProperties();
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
if (targetUserId is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
properties.Items[BindingIntentProperty] = bindingIntent;
}
var completeUrl = Url.Action(
nameof(Complete),
values: new { returnUrl = safeReturnUrl })!;
properties.RedirectUri = completeUrl;
try
{
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
return new EmptyResult();
}
catch (OpenIdConnectProtocolException exception)
{
logger.LogWarning(
exception,
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
EffectiveCallbackUrl());
return RedirectToFrontendError(
"configuration_error",
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
}
}
[AllowAnonymous]
[ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("complete")]
public async Task<ActionResult> Complete(
[FromQuery] string? returnUrl,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var authentication = await HttpContext.AuthenticateAsync(
SsoAuthSchemes.ExternalCookie);
if (!authentication.Succeeded || authentication.Principal is null)
return RedirectToFrontendError("authentication_failed");
var principal = authentication.Principal;
var subject = principal.FindFirstValue("sub") ??
principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(subject))
return RedirectToFrontendError("missing_subject");
ApplicationUser? user = null;
var bindingIntent =
authentication.Properties is { } authenticationProperties &&
authenticationProperties.Items.TryGetValue(
BindingIntentProperty,
out var storedBindingIntent)
? storedBindingIntent
: null;
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
user = targetUserId is null
? null
: await userManager.FindByIdAsync(targetUserId);
if (user is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled", "/account");
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError, "/account");
await cache.RemoveAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
}
user ??= await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (user is null && _options.LinkExistingUsersByUserName)
{
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
if (!string.IsNullOrWhiteSpace(userName))
{
user = await userManager.FindByNameAsync(userName);
if (user is not null)
{
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError);
}
}
}
if (user is null)
{
var bindingCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
var externalUserName =
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
principal.FindFirstValue("name")?.Trim() ??
subject;
await cache.SetStringAsync(
BindingCacheKey(bindingCode),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var bindingPage = BuildFrontendUrl("/sso/bind") +
$"?code={Uri.EscapeDataString(bindingCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(bindingPage);
}
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled");
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return RedirectToFrontendError("account_update_failed");
var exchangeCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
ExchangeCacheKey(exchangeCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var callback = BuildFrontendUrl("/sso/callback") +
$"?code={Uri.EscapeDataString(exchangeCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(callback);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("exchange")]
public async Task<ActionResult<LoginResponse>> Exchange(
SsoExchangeRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var cacheKey = ExchangeCacheKey(request.Code);
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
if (userId is null)
return SsoProblem(
"统一身份认证结果已失效,请重新登录。",
StatusCodes.Status401Unauthorized);
await cache.RemoveAsync(cacheKey, cancellationToken);
var user = await userManager.FindByIdAsync(userId);
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return SsoProblem(
"本地账号不存在、已停用或已锁定。",
StatusCodes.Status401Unauthorized);
var roles = await userManager.GetRolesAsync(user);
var session = await authSessionService.CreateAsync(
user,
roles,
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return AuthController.CreateLoginResponse(session);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpGet("binding")]
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
}
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("bind")]
public async Task<ActionResult<LoginResponse>> Bind(
SsoBindRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
var user = await userManager.FindByNameAsync(request.UserName.Trim());
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return InvalidLocalCredentials();
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner is not null && subjectOwner.Id != user.Id)
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
var keycloakLogins = (await userManager.GetLoginsAsync(user))
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
.ToList();
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
if (subjectOwner is null)
{
var linkResult = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
ticket.Subject,
_options.DisplayName));
if (!linkResult.Succeeded)
{
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner?.Id != user.Id)
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
}
}
await userManager.ResetAccessFailedCountAsync(user);
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
var roles = await userManager.GetRolesAsync(user);
var session = await authSessionService.CreateAsync(
user,
roles,
request.IsNativeApp
? AuthenticationClientType.App
: AuthenticationClientType.Web,
cancellationToken);
return AuthController.CreateLoginResponse(session);
}
[Authorize]
[HttpGet("account")]
public async Task<ActionResult<SsoAccountResponse>> Account()
{
var user = await CurrentUserAsync();
if (user is null)
return Unauthorized();
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
return new SsoAccountResponse(
_options.Enabled,
_options.DisplayName,
login is not null,
EffectiveCallbackUrl());
}
[Authorize]
[HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if ((await userManager.GetLoginsAsync(user))
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
{
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
}
var intentCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
BindingIntentCacheKey(intentCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
var loginUrl = Url.Action(
nameof(Login),
values: new
{
returnUrl = "/account",
bindingIntent = intentCode
})!;
return new SsoBindingStartResponse(loginUrl);
}
[Authorize]
[EnableRateLimiting("public-auth")]
[HttpPost("unbind")]
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
{
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if (!await userManager.HasPasswordAsync(user))
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
if (login is null)
return NoContent();
var result = await userManager.RemoveLoginAsync(
user,
login.LoginProvider,
login.ProviderKey);
if (!result.Succeeded)
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await userManager.ResetAccessFailedCountAsync(user);
return NoContent();
}
internal static string NormalizeReturnUrl(string? returnUrl) =>
!string.IsNullOrWhiteSpace(returnUrl) &&
returnUrl.StartsWith('/') &&
!returnUrl.StartsWith("//", StringComparison.Ordinal)
? returnUrl
: "/dashboard";
private string BuildFrontendUrl(string path) =>
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
? path
: _options.FrontendBaseUrl.TrimEnd('/') + path;
private RedirectResult RedirectToFrontendError(
string error,
string path = "/login") =>
Redirect(BuildFrontendUrl(path) +
$"?ssoError={Uri.EscapeDataString(error)}");
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
private static string BindingIntentCacheKey(string code) =>
$"sso:binding-intent:{code}";
private async Task<ApplicationUser?> CurrentUserAsync()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return userId is null ? null : await userManager.FindByIdAsync(userId);
}
private async Task<string?> LinkSsoIdentityAsync(
ApplicationUser user,
string subject)
{
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (subjectOwner is not null)
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
if ((await userManager.GetLoginsAsync(user)).Any(x =>
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
x.ProviderKey != subject))
{
return "account_already_bound";
}
var result = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
subject,
_options.DisplayName));
if (result.Succeeded)
return null;
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
}
private string EffectiveCallbackUrl()
{
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
return _options.CallbackUrl;
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
}
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
string code,
CancellationToken cancellationToken)
{
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
if (json is null)
return null;
try
{
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
}
catch (JsonException)
{
return null;
}
}
private UnauthorizedObjectResult InvalidLocalCredentials() =>
Unauthorized(new ProblemDetails
{
Title = "账户绑定失败",
Detail = "教务系统账号或密码不正确,或账号已停用。",
Status = StatusCodes.Status401Unauthorized
});
private ObjectResult BindingConflict(string detail) =>
SsoProblem(detail, StatusCodes.Status409Conflict);
private ObjectResult SsoProblem(string detail, int status) =>
StatusCode(status, new ProblemDetails
{
Title = "统一身份认证失败",
Detail = detail,
Status = status
});
}
public sealed record SsoSettingsResponse(
bool Enabled,
string DisplayName,
string CallbackUrl);
public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
bool IsNativeApp = false);
public sealed record SsoBindingInfoResponse(
string ProviderDisplayName,
string ExternalUserName);
public sealed record SsoBindRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
[Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password,
bool IsNativeApp = false);
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
public sealed record SsoAccountResponse(
bool Enabled,
string ProviderDisplayName,
bool IsBound,
string CallbackUrl);
public sealed record SsoBindingStartResponse(string LoginUrl);
public sealed record SsoUnbindRequest(
[Required, MaxLength(100)] string Password);
@@ -25,7 +25,12 @@ public sealed class StatisticsController(
SystemRoles.CollegeAdmin + "," + SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader; SystemRoles.Leader;
private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId; // HybridCache may execute its source factory without the request's ambient
// HttpContext. Keep the authorization scope stable for the whole controller
// invocation instead of resolving it again inside that background factory.
private readonly CurrentUserScope currentUser = currentUserDataScope.Current;
private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId;
private Guid? ResolveCollegeId(Guid? requestedCollegeId) private Guid? ResolveCollegeId(Guid? requestedCollegeId)
{ {
@@ -957,13 +962,14 @@ public sealed class StatisticsController(
async token => async token =>
{ {
var source = await factory(token); var source = await factory(token);
if (source.Result is not null || source.Value is null) var data = source.Result is ObjectResult { Value: not null } objectResult
? objectResult.Value
: source.Value;
if (data is null)
throw new InvalidOperationException( throw new InvalidOperationException(
"Statistics cache source did not return a successful value."); "Statistics cache source did not return a successful value.");
return JsonSerializer.SerializeToElement( return JsonSerializer.SerializeToElement(data, data.GetType());
source.Value,
source.Value.GetType());
}, },
AppCacheProfile.Analytics, AppCacheProfile.Analytics,
[ [
@@ -981,7 +987,7 @@ public sealed class StatisticsController(
params string?[] filters) => params string?[] filters) =>
AppCacheKeys.Statistics( AppCacheKeys.Statistics(
area, area,
currentUserDataScope.Current.Scope.ToString(), currentUser.Scope.ToString(),
effectiveCollegeId, effectiveCollegeId,
filters); filters);
@@ -0,0 +1,158 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.Student)]
[Route("api/student/profile")]
public sealed class StudentProfileController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<StudentProfileDto>> Get(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var profile = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new StudentProfileDto(
x.StudentNumber,
x.Name,
x.AdministrativeClass!.Name,
x.AdministrativeClass.Major!.Name,
x.AdministrativeClass.Major.College!.Name,
x.EnrollmentYear,
x.EnrollmentDate,
x.Status,
x.Gender,
x.DateOfBirth,
x.EnglishName,
x.IdCardNumber,
x.Nationality,
x.Ethnicity,
x.PoliticalStatus,
x.NativePlace,
x.HouseholdAddress,
x.CurrentAddress,
x.PostalCode,
x.Phone,
x.Email,
x.Qq,
x.WeChat,
x.EmergencyContactName,
x.EmergencyContactRelationship,
x.EmergencyContactPhone,
x.SpecialTags,
x.SpecialNeeds,
x.Biography))
.SingleOrDefaultAsync(cancellationToken);
return profile is null ? NotFound() : Ok(profile);
}
[HttpPut]
public async Task<IActionResult> Update(
StudentProfileUpdateRequest request,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.SingleOrDefaultAsync(
x => x.UserId == userId,
cancellationToken);
if (student is null) return NotFound();
student.Gender = request.Gender;
student.DateOfBirth = request.DateOfBirth;
student.EnglishName = Normalize(request.EnglishName);
student.IdCardNumber = Normalize(request.IdCardNumber);
student.Nationality = Normalize(request.Nationality);
student.Ethnicity = Normalize(request.Ethnicity);
student.PoliticalStatus = Normalize(request.PoliticalStatus);
student.NativePlace = Normalize(request.NativePlace);
student.HouseholdAddress = Normalize(request.HouseholdAddress);
student.CurrentAddress = Normalize(request.CurrentAddress);
student.PostalCode = Normalize(request.PostalCode);
student.Phone = Normalize(request.Phone);
student.Email = Normalize(request.Email);
student.Qq = Normalize(request.Qq);
student.WeChat = Normalize(request.WeChat);
student.EmergencyContactName = Normalize(request.EmergencyContactName);
student.EmergencyContactRelationship = Normalize(
request.EmergencyContactRelationship);
student.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
student.SpecialTags = Normalize(request.SpecialTags);
student.SpecialNeeds = Normalize(request.SpecialNeeds);
student.Biography = Normalize(request.Biography);
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Analytics, cancellationToken);
return NoContent();
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record StudentProfileDto(
string StudentNumber,
string Name,
string ClassName,
string MajorName,
string CollegeName,
int EnrollmentYear,
DateOnly EnrollmentDate,
StudentStatus Status,
Gender Gender,
DateOnly? DateOfBirth,
string? EnglishName,
string? IdCardNumber,
string? Nationality,
string? Ethnicity,
string? PoliticalStatus,
string? NativePlace,
string? HouseholdAddress,
string? CurrentAddress,
string? PostalCode,
string? Phone,
string? Email,
string? Qq,
string? WeChat,
string? EmergencyContactName,
string? EmergencyContactRelationship,
string? EmergencyContactPhone,
string? SpecialTags,
string? SpecialNeeds,
string? Biography);
public sealed record StudentProfileUpdateRequest(
Gender Gender,
DateOnly? DateOfBirth,
[MaxLength(100)] string? EnglishName,
[MaxLength(30)] string? IdCardNumber,
[MaxLength(50)] string? Nationality,
[MaxLength(50)] string? Ethnicity,
[MaxLength(50)] string? PoliticalStatus,
[MaxLength(100)] string? NativePlace,
[MaxLength(300)] string? HouseholdAddress,
[MaxLength(300)] string? CurrentAddress,
[MaxLength(20)] string? PostalCode,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(30)] string? Qq,
[MaxLength(60)] string? WeChat,
[MaxLength(50)] string? EmergencyContactName,
[MaxLength(30)] string? EmergencyContactRelationship,
[MaxLength(30)] string? EmergencyContactPhone,
[MaxLength(300)] string? SpecialTags,
[MaxLength(1000)] string? SpecialNeeds,
[MaxLength(1000)] string? Biography);
@@ -0,0 +1,28 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/system")]
public sealed class SystemController : ControllerBase
{
[AllowAnonymous]
[HttpGet("version")]
[ProducesResponseType<SystemVersionResponse>(StatusCodes.Status200OK)]
public SystemVersionResponse GetVersion()
{
var assembly = typeof(SystemController).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
var version = informationalVersion?.Split('+', 2)[0]
?? assembly.GetName().Version?.ToString(3)
?? "unknown";
return new SystemVersionResponse(version);
}
}
public sealed record SystemVersionResponse(string Version);
@@ -82,6 +82,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
TeacherNames = x.Teachers TeacherNames = x.Teachers
@@ -127,6 +130,10 @@ public sealed class TeachingTasksController(
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course!.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
@@ -163,6 +170,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
x.Notes, x.Notes,
@@ -423,7 +433,8 @@ public sealed class TeachingTasksController(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours,
TeachingTaskSchedulingMode.Standard);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
@@ -598,7 +609,8 @@ public sealed class TeachingTasksController(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours,
request.SchedulingMode);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId(); var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
@@ -367,7 +367,10 @@ public sealed class TimetablesController(
db.TeachingTasks.Any(task => db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id && task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible))) task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
db.ExamPlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == ExamPlanStatus.Published)))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
@@ -394,7 +397,12 @@ public sealed class TimetablesController(
task.AcademicTermId == defaultTermId.Value && task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id))))) task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
db.ExamSessions.Any(session =>
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
session.ExamPlan.Status == ExamPlanStatus.Published &&
session.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)))))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var colleges = await db.Colleges.AsNoTracking() var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
@@ -21,32 +21,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) => public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId) Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt }) .OrderBy(x => x.Type).Select(x => new { x.Id, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
.ToListAsync(ct)); .ToListAsync(ct));
[HttpPut("rules")] [HttpPut("rules")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct) public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
{ {
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct); var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
db.WarningRules.RemoveRange(existing); var incomingTypes = rules.Select(x => x.Type).ToHashSet();
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
foreach (var r in rules) foreach (var r in rules)
{ {
db.WarningRules.Add(new WarningRule var entity = existing.FirstOrDefault(x => x.Type == r.Type);
if (entity is null)
{
entity = new WarningRule
{ {
AcademicTermId = academicTermId, AcademicTermId = academicTermId,
Type = r.Type, Type = r.Type,
Name = r.Name.Trim(), Name = r.Name.Trim()
Threshold = r.Threshold, };
IsEnabled = r.IsEnabled, db.WarningRules.Add(entity);
NotifyStudent = r.NotifyStudent, }
NotifyCounselor = r.NotifyCounselor,
Description = r.Description?.Trim(), entity.Name = r.Name.Trim();
AutoCheckEnabled = r.AutoCheckEnabled, entity.Threshold = r.Threshold;
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek, entity.IsEnabled = r.IsEnabled;
CheckHour = r.CheckHour, entity.NotifyStudent = r.NotifyStudent;
CheckMinute = r.CheckMinute entity.NotifyCounselor = r.NotifyCounselor;
}); entity.Description = r.Description?.Trim();
entity.AutoCheckEnabled = r.AutoCheckEnabled;
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
entity.CheckHour = r.CheckHour;
entity.CheckMinute = r.CheckMinute;
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
@@ -120,7 +133,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
} }
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId); if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
if (type.HasValue) q = q.Where(x => x.Type == type); if (type.HasValue) q = q.Where(x => x.Type == type);
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct)); return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
} }
// ═══════════ Student ═══════════ // ═══════════ Student ═══════════
@@ -131,7 +144,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
var sid = await GetStudentIdAsync(ct); var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound(); if (sid is null) return StudentNotFound();
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt) return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId }) .Select(x => new { x.Id, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
.ToListAsync(ct)); .ToListAsync(ct));
} }
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; } public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = []; public ICollection<AttendanceRecord> Records { get; set; } = [];
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
} }
public sealed class AttendanceRecord public sealed class AttendanceRecord
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
public DateTime? AppealReviewedAt { get; set; } public DateTime? AppealReviewedAt { get; set; }
} }
public sealed class AttendanceCheckInAttempt : EntityBase
{
public Guid AttendanceSheetId { get; set; }
public AttendanceSheet? AttendanceSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public AttendanceCheckInMethod CheckInMethod { get; set; }
public bool IsSuccessful { get; set; }
public string? FailureCode { get; set; }
public string? DeviceIdentifierHash { get; set; }
public string? DevicePlatform { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? RiskFlags { get; set; }
public decimal? Latitude { get; set; }
public decimal? Longitude { get; set; }
public double? AccuracyMeters { get; set; }
public double? DistanceMeters { get; set; }
}
public enum AttendanceSheetStatus public enum AttendanceSheetStatus
{ {
Draft = 1, Draft = 1,
@@ -0,0 +1,14 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class CourseGradeStatisticsRefreshSetting : EntityBase
{
public const string DefaultKey = "default";
public string Key { get; set; } = DefaultKey;
public bool IsEnabled { get; set; } = true;
public int IntervalSeconds { get; set; } = 300;
public int BatchSize { get; set; } = 100;
public DateTime? LastRunAt { get; set; }
}
@@ -38,6 +38,22 @@ public sealed class CurriculumCourse : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
} }
public sealed class CourseGroup : EntityBase
{
public required string Code { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public ICollection<CourseGroupCourse> Courses { get; set; } = [];
}
public sealed class CourseGroupCourse : EntityBase
{
public Guid CourseGroupId { get; set; }
public CourseGroup? CourseGroup { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
}
public enum CurriculumPlanStatus public enum CurriculumPlanStatus
{ {
Draft = 1, Draft = 1,
@@ -11,6 +11,42 @@ public sealed class ExamPlan : EntityBase
public string? Notes { get; set; } public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; } public DateTime? PublishedAt { get; set; }
public ICollection<ExamSession> Sessions { get; set; } = []; public ICollection<ExamSession> Sessions { get; set; } = [];
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
}
public sealed class ExamArrangementJob : EntityBase
{
public ExamArrangementKind Kind { get; set; }
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? ProjectIdsJson { get; set; }
public string? SessionIdsJson { get; set; }
public bool AssignClassrooms { get; set; }
public bool AssignInvigilators { get; set; }
public ExamArrangementJobStatus Status { get; set; } =
ExamArrangementJobStatus.Queued;
public int TotalSessions { get; set; }
public int ProcessedSessions { get; set; }
public string? CurrentStep { get; set; }
public string? ResultMessage { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamArrangementKind
{
FormalExam = 1,
MakeupExam = 2
}
public enum ExamArrangementJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
} }
public sealed class ExamSession : EntityBase public sealed class ExamSession : EntityBase
@@ -28,9 +64,12 @@ public sealed class ExamSession : EntityBase
public DateTime EndsAt { get; set; } public DateTime EndsAt { get; set; }
public Guid? RequiredBuildingId { get; set; } public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; } public Building? RequiredBuilding { get; set; }
public string? RequiredBuildingIds { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2; public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; } public string? Notes { get; set; }
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = []; public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
} }
public sealed class ExamSessionInvigilator public sealed class ExamSessionInvigilator
@@ -41,9 +80,84 @@ public sealed class ExamSessionInvigilator
public Teacher? Teacher { get; set; } public Teacher? Teacher { get; set; }
} }
public sealed class ExamRoomAssignment : EntityBase
{
public Guid ExamPlanId { get; set; }
public ExamPlan? ExamPlan { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly ExamDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2;
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
}
public sealed class ExamRoomSession
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
}
public sealed class ExamSeatAssignment
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int SeatNumber { get; set; }
}
public sealed class ExamRoomInvigilator
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
}
public enum ExamPlanStatus public enum ExamPlanStatus
{ {
Draft = 1, Draft = 1,
Published = 2, Published = 2,
Archived = 3 Archived = 3
} }
public sealed class ExamPublishJob : EntityBase
{
public ExamPublishJobKind Kind { get; set; }
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? ProjectIdsJson { get; set; }
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamPublishJobKind
{
FormalExam = 1,
MakeupExam = 2,
ExperimentProjects = 3
}
public enum ExamPublishJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
@@ -0,0 +1,26 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExamSignInExportJob : EntityBase
{
public Guid PlanId { get; set; }
public ExamSignInExportJobStatus Status { get; set; } =
ExamSignInExportJobStatus.Queued;
public Guid? RequestedByUserId { get; set; }
public string? FileName { get; set; }
public byte[]? FileBytes { get; set; }
public int FileSize { get; set; }
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamSignInExportJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
@@ -0,0 +1,85 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExperimentProject : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
public Guid? ScheduleEntryId { get; set; }
public ScheduleEntry? ScheduleEntry { get; set; }
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
public int? ScheduleWeek { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
public ExperimentArrangementMode ArrangementMode { get; set; }
public string? Description { get; set; }
public string? Requirements { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public ExperimentProjectStatus Status { get; set; } =
ExperimentProjectStatus.Draft;
public DateTime? PublishedAt { get; set; }
public DateTime? ClosedAt { get; set; }
public ICollection<ExperimentSession> Sessions { get; set; } = [];
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
public ExperimentGradeSheet? GradeSheet { get; set; }
}
public sealed class ExperimentSession : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly SessionDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public int Capacity { get; set; }
public int ReservedCount { get; set; }
public string? Notes { get; set; }
public ExperimentSessionStatus Status { get; set; } =
ExperimentSessionStatus.Scheduled;
public DateTime? CancelledAt { get; set; }
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
}
public sealed class ExperimentBooking : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public Guid ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public ExperimentBookingStatus Status { get; set; } =
ExperimentBookingStatus.Booked;
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
public DateTime? CancelledAt { get; set; }
}
public enum ExperimentArrangementMode
{
Centralized = 1,
SelfScheduled = 2
}
public enum ExperimentProjectStatus
{
Draft = 1,
Published = 2,
Closed = 3
}
public enum ExperimentSessionStatus
{
Scheduled = 1,
Cancelled = 2
}
public enum ExperimentBookingStatus
{
Booked = 1,
Cancelled = 2
}
@@ -0,0 +1,108 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExperimentGradeSheet : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public decimal ContributionWeight { get; set; } = 1;
public decimal PassScore { get; set; } = 60;
public ExperimentGradeSheetStatus Status { get; set; } =
ExperimentGradeSheetStatus.Draft;
public string? ReviewComment { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
}
public sealed class ExperimentGradeItem : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public required string Name { get; set; }
public ExperimentGradeItemKind Kind { get; set; } =
ExperimentGradeItemKind.Other;
public decimal Weight { get; set; }
public int SortOrder { get; set; }
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
}
public sealed class ExperimentGradeRecord : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid? ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
ExperimentParticipationStatus.Pending;
public decimal? TotalScore { get; set; }
public bool? IsPassed { get; set; }
public bool SafetyViolation { get; set; }
public int AttemptNumber { get; set; } = 1;
public string? SubmissionReference { get; set; }
public DateTime? SubmittedAt { get; set; }
public bool IsLate { get; set; }
public string? TeacherComment { get; set; }
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
}
/// <summary>
/// A student's persisted experiment-part score for one teaching task.
/// The score is the weighted average of every published experiment project.
/// </summary>
public sealed class ExperimentCourseGrade : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public decimal? WeightedAverageScore { get; set; }
public decimal TotalWeight { get; set; }
public int PublishedProjectCount { get; set; }
public DateTime RefreshedAt { get; set; }
}
public sealed class ExperimentGradeItemScore
{
public Guid ExperimentGradeRecordId { get; set; }
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
public Guid ExperimentGradeItemId { get; set; }
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
public decimal? Score { get; set; }
public string? Comment { get; set; }
}
public enum ExperimentGradeSheetStatus
{
Draft = 1,
Submitted = 2,
Approved = 3,
Published = 4,
Returned = 5
}
public enum ExperimentGradeItemKind
{
Operation = 1,
Report = 2,
Result = 3,
Defense = 4,
Attendance = 5,
Safety = 6,
Other = 7
}
public enum ExperimentParticipationStatus
{
Pending = 1,
Completed = 2,
Absent = 3,
Excused = 4,
Makeup = 5,
Exempt = 6
}
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
public required string Name { get; set; } public required string Name { get; set; }
public decimal Weight { get; set; } public decimal Weight { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public GradeItemSourceType SourceType { get; set; } =
GradeItemSourceType.Manual;
public DateTime? SourceSnapshotAt { get; set; }
public ICollection<GradeItemScore> Scores { get; set; } = []; public ICollection<GradeItemScore> Scores { get; set; } = [];
} }
@@ -51,6 +54,99 @@ public sealed class GradeItemScore
public decimal? Score { get; set; } public decimal? Score { get; set; }
} }
/// <summary>
/// Persisted course-result aggregate. One course/term is materialized at each
/// organizational level so the result-analysis page never aggregates raw
/// grade records on request.
/// </summary>
public sealed class CourseGradeStatistic : EntityBase
{
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public CourseGradeStatisticScope Scope { get; set; }
public Guid? ScopeEntityId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int Below60Count { get; set; }
public int From60To69Count { get; set; }
public int From70To79Count { get; set; }
public int From80To89Count { get; set; }
public int From90To100Count { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal LowestScore { get; set; }
public decimal PassRate { get; set; }
public DateTime CalculatedAt { get; set; }
}
/// <summary>
/// Materialized analysis for one published teaching class. Course/term
/// organizational benchmarks stay in <see cref="CourseGradeStatistic"/>;
/// this table is the grain used for peer-class and historical comparisons.
/// </summary>
public sealed class TeachingTaskGradeStatistic : EntityBase
{
public Guid GradeSheetId { get; set; }
public GradeSheet? GradeSheet { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid CourseId { get; set; }
public Guid AcademicTermId { get; set; }
public int StudentCount { get; set; }
public int PassedCount { get; set; }
public int ExcellentCount { get; set; }
public decimal HighestScore { get; set; }
public decimal AverageScore { get; set; }
public decimal MedianScore { get; set; }
public decimal LowestScore { get; set; }
public decimal StandardDeviation { get; set; }
public decimal PassRate { get; set; }
public decimal ExcellentRate { get; set; }
public DateTime CalculatedAt { get; set; }
public ICollection<TeachingTaskGradeScoreBand> ScoreBands { get; set; } = [];
}
/// <summary>
/// Flexible score-band rows are kept separately so future band definitions do
/// not require widening the teaching-class summary table.
/// </summary>
public sealed class TeachingTaskGradeScoreBand : EntityBase
{
public Guid TeachingTaskGradeStatisticId { get; set; }
public TeachingTaskGradeStatistic? TeachingTaskGradeStatistic { get; set; }
public required string Label { get; set; }
public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
public int StudentCount { get; set; }
public int SortOrder { get; set; }
}
public enum CourseGradeStatisticScope
{
AdministrativeClass = 1,
Major = 2,
College = 3,
University = 4
}
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
{
public Guid GradeSheetId { get; set; }
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
CourseGradeStatisticsRefreshJobStatus.Queued;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}
public enum CourseGradeStatisticsRefreshJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum GradeSheetStatus public enum GradeSheetStatus
{ {
Draft = 1, Draft = 1,
@@ -68,3 +164,9 @@ public enum GradeExamStatus
Exempt = 4, Exempt = 4,
Makeup = 5 Makeup = 5
} }
public enum GradeItemSourceType
{
Manual = 1,
ExperimentSummary = 2
}
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
public DateTime EndsAt { get; set; } public DateTime EndsAt { get; set; }
public Guid? RequiredBuildingId { get; set; } public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; } public Building? RequiredBuilding { get; set; }
public string? RequiredBuildingIds { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2; public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; } public string? Notes { get; set; }
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = []; public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
@@ -44,5 +44,6 @@ public enum MessageAudienceType
{ {
School = 1, School = 1,
College = 2, College = 2,
TeachingTask = 3 TeachingTask = 3,
Custom = 4
} }
@@ -46,9 +46,35 @@ public sealed class Classroom : CatalogEntity
public Building? Building { get; set; } public Building? Building { get; set; }
public int Capacity { get; set; } public int Capacity { get; set; }
public string RoomType { get; set; } = "普通教室"; public string RoomType { get; set; } = "普通教室";
public TeachingVenueNature TeachingVenueNature { get; set; } =
TeachingVenueNature.GeneralClassroom;
public string? Equipment { get; set; } public string? Equipment { get; set; }
} }
[Flags]
public enum TeachingVenueNature
{
GeneralClassroom = 1,
Laboratory = 2,
TrainingRoom = 4,
ComputerLab = 8,
LanguageLab = 16,
SportsVenue = 32,
ArtsVenue = 64
}
public static class TeachingVenueNatureRules
{
public const TeachingVenueNature ExperimentTeaching =
TeachingVenueNature.Laboratory |
TeachingVenueNature.TrainingRoom |
TeachingVenueNature.ComputerLab |
TeachingVenueNature.LanguageLab;
public static bool SupportsExperiment(TeachingVenueNature value) =>
(value & ExperimentTeaching) != 0;
}
public sealed class AcademicTerm : CatalogEntity public sealed class AcademicTerm : CatalogEntity
{ {
public required string AcademicYear { get; set; } public required string AcademicYear { get; set; }
@@ -0,0 +1,44 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class OtherExamBatch : EntityBase
{
public string? ExamCode { get; set; }
public required string Name { get; set; }
public string? Organizer { get; set; }
public DateOnly ExamDate { get; set; }
public OtherExamMetricKind MetricKind { get; set; }
public decimal? MaxScore { get; set; }
public string? LevelOptions { get; set; }
public OtherExamBatchStatus Status { get; set; } = OtherExamBatchStatus.Draft;
public int PublicationCount { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<OtherExamResult> Results { get; set; } = [];
}
public sealed class OtherExamResult : EntityBase
{
public Guid OtherExamBatchId { get; set; }
public OtherExamBatch? OtherExamBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int AttemptNumber { get; set; } = 1;
public decimal? Score { get; set; }
public string? Level { get; set; }
public bool? IsPassed { get; set; }
public string? Notes { get; set; }
}
public enum OtherExamMetricKind
{
PassFail = 1,
Level = 2,
Score = 3
}
public enum OtherExamBatchStatus
{
Draft = 1,
Published = 2
}
@@ -30,8 +30,25 @@ public sealed class Student : EntityBase
public DateOnly EnrollmentDate { get; set; } public DateOnly EnrollmentDate { get; set; }
public StudentStatus Status { get; set; } = StudentStatus.Active; public StudentStatus Status { get; set; } = StudentStatus.Active;
public DateOnly? DateOfBirth { get; set; } 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? Phone { get; set; }
public string? Email { 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 string? Notes { get; set; }
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
} }
@@ -50,6 +67,16 @@ public sealed class Course : CatalogEntity
public CourseNature Nature { get; set; } public CourseNature Nature { get; set; }
public AssessmentMethod AssessmentMethod { get; set; } public AssessmentMethod AssessmentMethod { get; set; }
public string? Description { 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 public sealed class CourseCategory : CatalogEntity
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
public SchedulePlan? SchedulePlan { get; set; } public SchedulePlan? SchedulePlan { get; set; }
public Guid TeachingTaskId { get; set; } public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; } public TeachingTask? TeachingTask { get; set; }
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
public Guid? ClassroomId { get; set; } public Guid? ClassroomId { get; set; }
public Classroom? Classroom { get; set; } public Classroom? Classroom { get; set; }
public int DayOfWeek { get; set; } public int DayOfWeek { get; set; }
@@ -51,10 +52,16 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
public Campus? RequiredCampus { get; set; } public Campus? RequiredCampus { get; set; }
public Guid? RequiredBuildingId { get; set; } public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; } public Building? RequiredBuilding { get; set; }
public Guid? ExperimentRequiredCampusId { get; set; }
public Campus? ExperimentRequiredCampus { get; set; }
public Guid? ExperimentRequiredBuildingId { get; set; }
public Building? ExperimentRequiredBuilding { get; set; }
public string? AllowedDayOfWeeks { get; set; } public string? AllowedDayOfWeeks { get; set; }
public int? EarliestPeriod { get; set; } public int? EarliestPeriod { get; set; }
public int? LatestPeriod { get; set; } public int? LatestPeriod { get; set; }
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = []; public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
public ICollection<TeachingTaskAllowedExperimentClassroom> AllowedExperimentClassrooms { get; set; } = [];
} }
public sealed class TeachingTaskAllowedClassroom public sealed class TeachingTaskAllowedClassroom
@@ -65,6 +72,31 @@ public sealed class TeachingTaskAllowedClassroom
public Classroom? Classroom { get; set; } public Classroom? Classroom { get; set; }
} }
public sealed class PublishedScheduleOccurrence : EntityBase
{
public Guid SchedulePlanId { get; set; }
public Guid AcademicTermId { get; set; }
public Guid ScheduleEntryId { get; set; }
public Guid TeachingTaskId { get; set; }
public Guid? ClassroomId { get; set; }
public int Week { get; set; }
public int DayOfWeek { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public ScheduleEntryKind Kind { get; set; }
public ScheduleEntry? ScheduleEntry { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class TeachingTaskAllowedExperimentClassroom
{
public Guid TeachingTaskScheduleConstraintId { get; set; }
public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class AutomaticScheduleJob : EntityBase public sealed class AutomaticScheduleJob : EntityBase
{ {
public Guid SchedulePlanId { get; set; } public Guid SchedulePlanId { get; set; }
@@ -129,3 +161,9 @@ public enum WeekPattern
Odd = 2, Odd = 2,
Even = 3 Even = 3
} }
public enum ScheduleEntryKind
{
Lecture = 1,
Experiment = 2
}
@@ -0,0 +1,22 @@
namespace Jiaowu.Api.Domain.Identity;
public enum AuthenticationClientType
{
Web = 0,
App = 1
}
public sealed class RefreshSession
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public ApplicationUser? User { get; set; }
public required string TokenHash { get; set; }
public AuthenticationClientType ClientType { get; set; }
public required string SecurityStamp { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastRefreshedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
public Guid? ReplacedBySessionId { get; set; }
}
@@ -0,0 +1,40 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.System;
public enum AppUpdatePlatform
{
Android,
Ios
}
public enum AppUpdateChannel
{
Production,
Staging
}
public enum AppUpdateReleaseStatus
{
Draft,
Published,
Archived
}
public sealed class AppUpdateRelease : EntityBase
{
public AppUpdatePlatform Platform { get; set; }
public AppUpdateChannel Channel { get; set; }
public required string Version { get; set; }
public required string NativeVersion { get; set; }
public AppUpdateReleaseStatus Status { get; set; } =
AppUpdateReleaseStatus.Draft;
public string? ReleaseNotes { get; set; }
public required string FileName { get; set; }
public long FileSize { get; set; }
public required string Sha256 { get; set; }
public required byte[] BundleContent { get; set; }
public required string CreatedByUserName { get; set; }
public string? PublishedByUserName { get; set; }
public DateTime? PublishedAt { get; set; }
}
@@ -30,7 +30,11 @@ public enum BackgroundJobKind
{ {
AutomaticSchedule = 1, AutomaticSchedule = 1,
SchedulePublish = 2, SchedulePublish = 2,
MakeupExamAuto = 3 MakeupExamAuto = 3,
ExamArrangement = 4,
ExamSignInExport = 5,
ExamPublish = 6,
CourseGradeStatisticsRefresh = 7
} }
public enum BackgroundJobOutboxState public enum BackgroundJobOutboxState
@@ -0,0 +1,14 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.System;
public static class SystemFeatureKeys
{
public const string SwaggerDocumentation = "SwaggerDocumentation";
}
public sealed class SystemFeatureSetting : EntityBase
{
public required string Key { get; set; }
public bool IsEnabled { get; set; }
}
@@ -0,0 +1,122 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Jiaowu.Api.Infrastructure.Analytics;
public sealed class ClickHouseAnalyticsClient(
HttpClient httpClient,
ClickHouseAnalyticsOptions options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public bool IsEnabled => options.Enabled;
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
await ExecuteAsync($"CREATE DATABASE IF NOT EXISTS {options.Database}", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.auditEvents
(
id UUID,
occurredAt DateTime64(3, 'UTC'),
userId Nullable(UUID),
method LowCardinality(String),
path String,
statusCode UInt16,
ipAddress Nullable(String),
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(occurredAt)
ORDER BY (id)
""", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.attendanceRecords
(
attendanceSheetId UUID,
studentId UUID,
attendanceDate Date,
teachingTaskId UUID,
academicTermId UUID,
collegeId UUID,
status UInt8,
checkInAt Nullable(DateTime64(3, 'UTC')),
checkedInMethod Nullable(UInt8),
appealStatus UInt8,
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(attendanceDate)
ORDER BY (attendanceSheetId, studentId)
""", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.gradeStatistics
(
gradeSheetId UUID,
teachingTaskId UUID,
courseId UUID,
academicTermId UUID,
collegeId UUID,
academicTermName LowCardinality(String),
studentCount UInt32,
passedCount UInt32,
excellentCount UInt32,
averageScore Decimal(8, 2),
passRate Decimal(8, 4),
excellentRate Decimal(8, 4),
calculatedAt DateTime64(3, 'UTC'),
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(calculatedAt)
ORDER BY (gradeSheetId)
""", cancellationToken);
}
public async Task InsertAsync<T>(string table, IReadOnlyCollection<T> rows, CancellationToken cancellationToken)
{
if (rows.Count == 0) return;
var payload = JsonSerializer.Serialize(rows, JsonOptions);
await ExecuteAsync(
$"INSERT INTO {options.Database}.{table} FORMAT JSONEachRow\n{ToJsonLines(payload)}",
cancellationToken);
}
public async Task<JsonElement[]> QueryAsync(string sql, CancellationToken cancellationToken)
{
using var response = await SendAsync(sql + " FORMAT JSON", cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"ClickHouse 查询失败 ({(int)response.StatusCode}){content}");
using var document = JsonDocument.Parse(content);
return document.RootElement.GetProperty("data")
.EnumerateArray().Select(x => x.Clone()).ToArray();
}
private async Task ExecuteAsync(string sql, CancellationToken cancellationToken)
{
using var response = await SendAsync(sql, cancellationToken);
if (response.IsSuccessStatusCode) return;
var content = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HttpRequestException($"ClickHouse 写入失败 ({(int)response.StatusCode}){content}");
}
private Task<HttpResponseMessage> SendAsync(string sql, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Post, "")
{
Content = new StringContent(sql, Encoding.UTF8, "text/plain")
};
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(
$"{options.UserName}:{options.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
return httpClient.SendAsync(request, cancellationToken);
}
private static string ToJsonLines(string json)
{
using var document = JsonDocument.Parse(json);
return string.Join('\n', document.RootElement.EnumerateArray().Select(x => x.GetRawText()));
}
}
@@ -0,0 +1,24 @@
using System.Text.RegularExpressions;
namespace Jiaowu.Api.Infrastructure.Analytics;
public sealed partial class ClickHouseAnalyticsOptions
{
public const string SectionName = "ClickHouseAnalytics";
public bool Enabled { get; set; }
public string Endpoint { get; set; } = "http://localhost:8123";
public string Database { get; set; } = "jiaowu_analytics";
public string UserName { get; set; } = "jiaowu_analytics";
public string Password { get; set; } = "";
public bool CreateSchemaOnStartup { get; set; } = true;
public int SyncIntervalSeconds { get; set; } = 60;
public int SourceLookbackDays { get; set; } = 90;
public int BatchSize { get; set; } = 1000;
[GeneratedRegex("^[a-zA-Z_][a-zA-Z0-9_]{0,62}$")]
private static partial Regex IdentifierPattern();
public bool HasValidIdentifiers() =>
IdentifierPattern().IsMatch(Database);
}
@@ -0,0 +1,123 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Analytics;
/// <summary>
/// Projects MySQL facts to ClickHouse. The projection is deliberately
/// best-effort: business writes never depend on an analytics database.
/// ReplacingMergeTree plus FINAL reads make repeated lookback batches safe.
/// </summary>
public sealed class ClickHouseAnalyticsProjectionWorker(
IServiceScopeFactory scopeFactory,
ClickHouseAnalyticsClient client,
ClickHouseAnalyticsOptions options,
ILogger<ClickHouseAnalyticsProjectionWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Enabled) return;
try
{
if (options.CreateSchemaOnStartup)
await client.EnsureSchemaAsync(stoppingToken);
}
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(exception, "ClickHouse 分析表初始化失败,将在下一轮重试。");
}
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(options.SyncIntervalSeconds));
do
{
try
{
await ProjectAsync(stoppingToken);
}
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(exception, "ClickHouse 分析投影失败,将在下一轮重试。");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task ProjectAsync(CancellationToken cancellationToken)
{
var projectedAt = DateTime.UtcNow;
var from = projectedAt.AddDays(-options.SourceLookbackDays);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var auditCount = await ProjectAuditsAsync(db, from, projectedAt, cancellationToken);
var attendanceCount = await ProjectAttendanceAsync(db, from.Date, projectedAt, cancellationToken);
var gradeCount = await ProjectGradesAsync(db, projectedAt, cancellationToken);
logger.LogInformation(
"ClickHouse 分析投影完成:审计 {AuditCount},考勤 {AttendanceCount},成绩 {GradeCount}。",
auditCount, attendanceCount, gradeCount);
}
private async Task<int> ProjectAuditsAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorAt = from;
var cursorId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.AuditLogs.AsNoTracking()
.Where(x => x.CreatedAt > cursorAt || x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) > 0)
.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(options.BatchSize)
.Select(x => new { x.Id, OccurredAt = x.CreatedAt, x.UserId, x.Method, x.Path, x.StatusCode, x.IpAddress, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("auditEvents", rows, cancellationToken);
count += rows.Count;
cursorAt = rows[^1].OccurredAt;
cursorId = rows[^1].Id;
}
}
private async Task<int> ProjectAttendanceAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorDate = from;
var cursorSheetId = Guid.Empty;
var cursorStudentId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.AttendanceRecords.AsNoTracking()
.Where(x => x.AttendanceSheet!.AttendanceDate > cursorDate ||
x.AttendanceSheet.AttendanceDate == cursorDate &&
(x.AttendanceSheetId.CompareTo(cursorSheetId) > 0 ||
x.AttendanceSheetId == cursorSheetId && x.StudentId.CompareTo(cursorStudentId) > 0))
.OrderBy(x => x.AttendanceSheet!.AttendanceDate).ThenBy(x => x.AttendanceSheetId).ThenBy(x => x.StudentId).Take(options.BatchSize)
.Select(x => new { x.AttendanceSheetId, x.StudentId, AttendanceDate = x.AttendanceSheet!.AttendanceDate, TeachingTaskId = x.AttendanceSheet.TeachingTaskId, AcademicTermId = x.AttendanceSheet.TeachingTask!.AcademicTermId, CollegeId = x.AttendanceSheet.TeachingTask.Course!.CollegeId, Status = (byte)x.Status, x.CheckInAt, CheckedInMethod = x.CheckedInMethod == null ? null : (byte?)x.CheckedInMethod, AppealStatus = (byte)x.AppealStatus, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("attendanceRecords", rows, cancellationToken);
count += rows.Count;
cursorDate = rows[^1].AttendanceDate;
cursorSheetId = rows[^1].AttendanceSheetId;
cursorStudentId = rows[^1].StudentId;
}
}
private async Task<int> ProjectGradesAsync(AppDbContext db, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorAt = DateTime.MinValue;
var cursorSheetId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CalculatedAt > cursorAt || x.CalculatedAt == cursorAt && x.GradeSheetId.CompareTo(cursorSheetId) > 0)
.OrderBy(x => x.CalculatedAt).ThenBy(x => x.GradeSheetId).Take(options.BatchSize)
.Select(x => new { x.GradeSheetId, x.TeachingTaskId, x.CourseId, x.AcademicTermId, CollegeId = x.TeachingTask!.Course!.CollegeId, AcademicTermName = x.TeachingTask.AcademicTerm!.Name, x.StudentCount, x.PassedCount, x.ExcellentCount, x.AverageScore, x.PassRate, x.ExcellentRate, x.CalculatedAt, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("gradeStatistics", rows, cancellationToken);
count += rows.Count;
cursorAt = rows[^1].CalculatedAt;
cursorSheetId = rows[^1].GradeSheetId;
}
}
}
@@ -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 Issuer { get; set; } = "Jiaowu.Api";
public string Audience { get; set; } = "Jiaowu.Web"; public string Audience { get; set; } = "Jiaowu.Web";
public string Key { get; set; } = string.Empty; 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 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 public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
{ {
private readonly JwtOptions _options = options.Value; 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> var claims = new List<Claim>
{ {
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)), new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
SecurityAlgorithms.HmacSha256); SecurityAlgorithms.HmacSha256);
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
var token = new JwtSecurityToken( var token = new JwtSecurityToken(
issuer: _options.Issuer, issuer: _options.Issuer,
audience: _options.Audience, audience: _options.Audience,
claims: claims, claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes), expires: expiresAt,
signingCredentials: credentials); signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token); return new AccessTokenResult(
new JwtSecurityTokenHandler().WriteToken(token),
expiresAt);
} }
} }
@@ -13,6 +13,10 @@ public sealed class BackgroundJobOptions
public int AutomaticScheduleConcurrency { get; set; } = 1; public int AutomaticScheduleConcurrency { get; set; } = 1;
public int SchedulePublishConcurrency { get; set; } = 1; public int SchedulePublishConcurrency { get; set; } = 1;
public int MakeupExamAutoConcurrency { get; set; } = 1; public int MakeupExamAutoConcurrency { get; set; } = 1;
public int ExamArrangementConcurrency { get; set; } = 1;
public int ExamSignInExportConcurrency { get; set; } = 1;
public int ExamPublishConcurrency { get; set; } = 1;
public int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
public string Exchange { get; set; } = "jiaowu.background-jobs"; public string Exchange { get; set; } = "jiaowu.background-jobs";
public string QueuePrefix { get; set; } = "jiaowu.background-jobs"; public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
public bool UseQuorumQueues { get; set; } = true; public bool UseQuorumQueues { get; set; } = true;
@@ -29,6 +33,11 @@ public sealed class BackgroundJobOptions
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency, BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency, BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency, BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
BackgroundJobKind.CourseGradeStatisticsRefresh =>
CourseGradeStatisticsRefreshConcurrency,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
}; };
} }
@@ -103,6 +103,42 @@ public sealed class BackgroundJobOutboxPublisher(
message.JobId == x.Id)) message.JobId == x.Id))
.Select(x => x.Id) .Select(x => x.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamArrangement &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamSignInExportJobStatus.Queued ||
x.Status == ExamSignInExportJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamSignInExport &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamPublishJobStatus.Queued ||
x.Status == ExamPublishJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamPublish &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
.Where(x =>
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var missingKeys = automaticJobs var missingKeys = automaticJobs
.Select(id => (BackgroundJobKind.AutomaticSchedule, id)) .Select(id => (BackgroundJobKind.AutomaticSchedule, id))
@@ -110,6 +146,14 @@ public sealed class BackgroundJobOutboxPublisher(
(BackgroundJobKind.SchedulePublish, id))) (BackgroundJobKind.SchedulePublish, id)))
.Concat(makeupJobs.Select(id => .Concat(makeupJobs.Select(id =>
(BackgroundJobKind.MakeupExamAuto, id))) (BackgroundJobKind.MakeupExamAuto, id)))
.Concat(arrangementJobs.Select(id =>
(BackgroundJobKind.ExamArrangement, id)))
.Concat(exportJobs.Select(id =>
(BackgroundJobKind.ExamSignInExport, id)))
.Concat(publishJobs2.Select(id =>
(BackgroundJobKind.ExamPublish, id)))
.Concat(gradeStatisticsJobs.Select(id =>
(BackgroundJobKind.CourseGradeStatisticsRefresh, id)))
.ToList(); .ToList();
foreach (var (kind, jobId) in missingKeys) foreach (var (kind, jobId) in missingKeys)
{ {
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -85,6 +86,26 @@ public sealed class BackgroundJobRunner(
.GetRequiredService<MakeupExamAutoJobProcessor>() .GetRequiredService<MakeupExamAutoJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken); .ProcessAsync(message.JobId, cancellationToken);
break; break;
case BackgroundJobKind.ExamArrangement:
await scope.ServiceProvider
.GetRequiredService<ExamArrangementJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamSignInExport:
await scope.ServiceProvider
.GetRequiredService<ExamSignInExportJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await scope.ServiceProvider
.GetRequiredService<ExamPublishJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await scope.ServiceProvider
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
$"Unsupported background job kind '{message.JobKind}'."); $"Unsupported background job kind '{message.JobKind}'.");
@@ -259,6 +280,53 @@ public sealed class BackgroundJobRunner(
.SetProperty(x => x.CompletedAt, completedAt), .SetProperty(x => x.CompletedAt, completedAt),
cancellationToken); cancellationToken);
break; break;
case BackgroundJobKind.ExamArrangement:
await db.ExamArrangementJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamArrangementJobStatus.Succeeded &&
x.Status != ExamArrangementJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamArrangementJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.ExamPublish:
await db.ExamPublishJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamPublishJobStatus.Succeeded &&
x.Status != ExamPublishJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamPublishJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
case BackgroundJobKind.CourseGradeStatisticsRefresh:
await db.CourseGradeStatisticsRefreshJobs
.Where(x => x.Id == message.JobId &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.Status,
CourseGradeStatisticsRefreshJobStatus.Failed)
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
default: default:
throw new ArgumentOutOfRangeException( throw new ArgumentOutOfRangeException(
nameof(message.JobKind), nameof(message.JobKind),
@@ -316,7 +316,11 @@ internal static class RabbitMqBackgroundJobTopology
[ [
BackgroundJobKind.AutomaticSchedule, BackgroundJobKind.AutomaticSchedule,
BackgroundJobKind.SchedulePublish, BackgroundJobKind.SchedulePublish,
BackgroundJobKind.MakeupExamAuto BackgroundJobKind.MakeupExamAuto,
BackgroundJobKind.ExamArrangement,
BackgroundJobKind.ExamSignInExport,
BackgroundJobKind.ExamPublish,
BackgroundJobKind.CourseGradeStatisticsRefresh
]; ];
public static async Task<IConnection> CreateConnectionAsync( public static async Task<IConnection> CreateConnectionAsync(
@@ -412,6 +416,10 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.AutomaticSchedule => "schedule.automatic", BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
BackgroundJobKind.SchedulePublish => "schedule.publish", BackgroundJobKind.SchedulePublish => "schedule.publish",
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic", BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
BackgroundJobKind.ExamArrangement => "exam.arrangement",
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
BackgroundJobKind.ExamPublish => "exam.publish",
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
}; };
@@ -182,12 +182,19 @@ public static class AppCacheKeys
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" + return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}"; $"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
} }
public static string CourseGradeStatistics(Guid gradeSheetId) =>
$"grade-statistics:sheet:{gradeSheetId:N}";
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
} }
public static class AppCacheTags public static class AppCacheTags
{ {
public const string BaseData = "base-data"; public const string BaseData = "base-data";
public const string Analytics = "analytics"; public const string Analytics = "analytics";
public const string CourseGradeStatistics = "grade-statistics";
public const string Timetables = "timetables"; public const string Timetables = "timetables";
public const string TimetableOptions = "timetable:options"; public const string TimetableOptions = "timetable:options";
@@ -0,0 +1,126 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementJobProcessor(
AppDbContext db,
ExamArrangementService examArrangementService,
MakeupExamArrangementService makeupExamArrangementService,
IAppCache cache,
ILogger<ExamArrangementJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamArrangementJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamArrangementJobStatus.Succeeded
or ExamArrangementJobStatus.Failed)
{
return;
}
job.Status = ExamArrangementJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.ResultMessage = null;
job.CurrentStep = job.Kind == ExamArrangementKind.FormalExam
? "正在编排正式考试"
: "正在编排补考";
await db.SaveChangesAsync(stoppingToken);
var sessionIds = DeserializeSessionIds(job.SessionIdsJson);
var result = job.Kind switch
{
ExamArrangementKind.FormalExam =>
await examArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
ExamArrangementKind.MakeupExam =>
await makeupExamArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
_ => throw new InvalidOperationException(
$"不支持的考试编排类型:{job.Kind}。")
};
if (!result.Success)
{
await MarkFailedAsync(jobId, result.Message);
return;
}
job.Status = ExamArrangementJobStatus.Succeeded;
job.ProcessedSessions = job.TotalSessions;
job.CurrentStep = "编排完成";
job.ResultMessage = result.Message;
job.ActivePlanId = null;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
if (job.Kind == ExamArrangementKind.FormalExam)
{
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
stoppingToken);
}
logger.LogInformation(
"Exam arrangement job {JobId} for {Kind}/{PlanId} completed.",
job.Id,
job.Kind,
job.PlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Exam arrangement job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Exam arrangement job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private static IReadOnlyCollection<Guid>? DeserializeSessionIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
var sessionIds = JsonSerializer.Deserialize<Guid[]>(json);
return sessionIds is { Length: > 0 } ? sessionIds : null;
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamArrangementJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamArrangementJobStatus.Failed;
job.ActivePlanId = null;
job.CurrentStep = "编排失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
@@ -1,13 +1,33 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams; namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementService(AppDbContext db) public sealed class ExamArrangementService(AppDbContext db)
{ {
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt); private sealed record RoomGroupKey(
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt); Guid CourseId,
DateOnly ExamDate,
int StartPeriod,
int PeriodCount,
string RequiredBuildingIdsKey);
private sealed record RoomOccupancy(
Guid ClassroomId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record InvigilatorOccupancy(
Guid TeacherId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record CandidateSeat(
Guid ExamSessionId,
Guid StudentId,
string StudentNumber);
public async Task<ExamArrangementResult> ArrangeAsync( public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId, Guid planId,
@@ -26,6 +46,16 @@ public sealed class ExamArrangementService(AppDbContext db)
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask) .ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers) .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); .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null) if (plan is null)
@@ -42,201 +72,549 @@ public sealed class ExamArrangementService(AppDbContext db)
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count) plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。"); return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
var sessions = plan.Sessions
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenByDescending(x => x.RequiredInvigilatorCount)
.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var termId = plan.AcademicTermId;
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == termId && x.IsEnabled) .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber) .OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (timeSlots.Count == 0) if (timeSlots.Count == 0)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
foreach (var session in plan.Sessions)
ComputeTimesFromSlots(session, timeSlotLookup);
int assignedRooms = 0; var explicitlySelected = plan.Sessions
int assignedInvigilators = 0; .Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
int unavailableRooms = 0; .ToList();
int unavailableInvigilators = 0; if (explicitlySelected.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var selectedKeys = explicitlySelected
.Select(GroupKey)
.ToHashSet();
var sessions = plan.Sessions
.Where(x => selectedKeys.Contains(GroupKey(x)))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ToList();
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
sessions.Select(x => x.TeachingTaskId),
cancellationToken);
var sessionByTaskId = sessions
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.First());
var replacedRooms = assignClassrooms
? plan.Rooms
.Where(room => room.SessionLinks.Any(link =>
sessionIds.Contains(link.ExamSessionId)))
.ToList()
: [];
var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet();
if (replacedRooms.Count > 0)
db.ExamRooms.RemoveRange(replacedRooms);
var occupiedRooms = await LoadOccupiedRoomsAsync(
plan,
sessionIds,
replacedRoomIds,
cancellationToken);
var rooms = await db.Classrooms.AsNoTracking()
.Include(x => x.Building)
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
var targetRooms = new List<ExamRoomAssignment>();
var messages = new List<string>(); var messages = new List<string>();
var seatedStudents = 0;
var unavailableStudents = 0;
// Track occupied time slots to avoid conflicts if (assignClassrooms)
var occupiedRooms = sessions {
.Where(x => x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
.ToList();
var occupiedInvigilators = sessions
.SelectMany(x => x.Invigilators.Select(i =>
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
.ToList();
foreach (var session in sessions) foreach (var session in sessions)
{ {
// Compute StartsAt/EndsAt from time slots session.ClassroomId = null;
ComputeTimesFromSlots(session, timeSlotLookup); if (session.Invigilators.Count > 0)
var studentCount = await db.CourseEnrollments.CountAsync( {
x => x.Status == CourseEnrollmentStatus.Enrolled && db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId, session.Invigilators.Clear();
cancellationToken); }
}
// ── Auto-assign classroom ── foreach (var group in sessions
if (assignClassrooms && !session.ClassroomId.HasValue) .GroupBy(GroupKey)
.OrderBy(x => x.Key.ExamDate)
.ThenBy(x => x.Key.StartPeriod)
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
{ {
var room = await FindBestClassroomAsync( var groupSessions = group.ToList();
session, studentCount, occupiedRooms, cancellationToken); var candidates = InterleaveCandidates(
if (room is not null) groupSessions,
roster,
sessionByTaskId);
if (candidates.Count == 0)
{ {
session.ClassroomId = room.Id;
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++;
messages.Add( messages.Add(
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)"); $"“{groupSessions[0].TeachingTask!.Course!.Name}”没有有效考生。");
continue;
}
var availableRooms = rooms
.Where(room =>
(group.Key.RequiredBuildingIdsKey.Length == 0 ||
group.Key.RequiredBuildingIdsKey
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Contains(room.BuildingId.ToString())) &&
occupiedRooms.All(occupied =>
occupied.ClassroomId != room.Id ||
!ExamConflictRules.TimeOverlaps(
occupied.StartsAt,
occupied.EndsAt,
groupSessions[0].StartsAt,
groupSessions[0].EndsAt)))
.ToList();
var selectedRooms = SelectRooms(
availableRooms,
candidates.Count);
if (selectedRooms.Sum(x => x.Capacity / 2) < candidates.Count)
{
unavailableStudents += candidates.Count;
messages.Add(
$"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," +
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity / 2)} 座。");
continue;
}
var offset = 0;
foreach (var classroom in selectedRooms)
{
var roomCandidates = candidates
.Skip(offset)
.Take(classroom.Capacity / 2)
.ToList();
if (roomCandidates.Count == 0) break;
offset += roomCandidates.Count;
var room = new ExamRoomAssignment
{
ExamPlanId = plan.Id,
CourseId = group.Key.CourseId,
ClassroomId = classroom.Id,
ExamDate = group.Key.ExamDate,
StartPeriod = group.Key.StartPeriod,
PeriodCount = group.Key.PeriodCount,
StartsAt = groupSessions[0].StartsAt,
EndsAt = groupSessions[0].EndsAt,
RequiredInvigilatorCount =
groupSessions.Max(x => x.RequiredInvigilatorCount),
SessionLinks = roomCandidates
.Select(x => x.ExamSessionId)
.Distinct()
.Select(sessionId => new ExamRoomSession
{
ExamSessionId = sessionId
})
.ToList(),
Seats = roomCandidates
.Select((candidate, index) =>
new ExamSeatAssignment
{
ExamSessionId = candidate.ExamSessionId,
StudentId = candidate.StudentId,
SeatNumber = index + 1
})
.ToList()
};
db.ExamRooms.Add(room);
targetRooms.Add(room);
occupiedRooms.Add(new RoomOccupancy(
classroom.Id,
room.StartsAt,
room.EndsAt));
seatedStudents += roomCandidates.Count;
}
messages.Add(
$"“{groupSessions[0].TeachingTask!.Course!.Name}”" +
$"{groupSessions.Count}个教学班混排至{selectedRooms.Count}个考场。");
}
} }
else else
{ {
unavailableRooms++; targetRooms.AddRange(plan.Rooms.Where(room =>
messages.Add( room.SessionLinks.Any(link =>
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)"); sessionIds.Contains(link.ExamSessionId))));
var linkedSessionIds = targetRooms
.SelectMany(x => x.SessionLinks)
.Select(x => x.ExamSessionId)
.ToHashSet();
foreach (var session in sessions.Where(x =>
!linkedSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue))
{
var legacyRoom = CreateLegacyRoom(
plan,
session,
roster.Where(x =>
x.TeachingTaskId == session.TeachingTaskId));
db.ExamRooms.Add(legacyRoom);
targetRooms.Add(legacyRoom);
session.ClassroomId = null;
if (session.Invigilators.Count > 0)
{
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators.Clear();
} }
} }
// ── Auto-assign invigilators ── seatedStudents = targetRooms.Sum(x => x.Seats.Count);
var currentInvigilatorCount = session.Invigilators.Count; }
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (assignInvigilators && needed > 0) var assignedInvigilators = 0;
var unavailableInvigilators = 0;
if (assignInvigilators)
{ {
var courseTeacherIds = session.TeachingTask!.Teachers if (targetRooms.Count == 0)
.Select(x => x.TeacherId).ToHashSet(); return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
var newlyAssigned = await FindInvigilatorsAsync(
session, needed, courseTeacherIds, var occupiedInvigilators = plan.Rooms
occupiedInvigilators, cancellationToken); .Where(x => !replacedRoomIds.Contains(x.Id) &&
foreach (var teacher in newlyAssigned) !targetRooms.Any(target => target.Id == x.Id))
.SelectMany(room => room.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
room.StartsAt,
room.EndsAt)))
.Concat(plan.Sessions
.Where(x => !sessionIds.Contains(x.Id))
.SelectMany(session => session.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
session.StartsAt,
session.EndsAt))))
.ToList();
foreach (var room in targetRooms
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.ClassroomId))
{ {
session.Invigilators.Add(new ExamSessionInvigilator var needed = room.RequiredInvigilatorCount -
room.Invigilators.Count;
if (needed <= 0) continue;
var linkedSessionIds = room.SessionLinks
.Select(x => x.ExamSessionId)
.ToHashSet();
var excludedTeacherIds = sessions
.Where(x => linkedSessionIds.Contains(x.Id))
.SelectMany(x => x.TeachingTask!.Teachers)
.Select(x => x.TeacherId)
.ToHashSet();
var teachers = await FindInvigilatorsAsync(
room,
needed,
excludedTeacherIds,
occupiedInvigilators,
cancellationToken);
foreach (var teacher in teachers)
{
room.Invigilators.Add(new ExamRoomInvigilator
{ {
ExamSessionId = session.Id,
TeacherId = teacher.Id TeacherId = teacher.Id
}); });
occupiedInvigilators.Add(new InvigilatorOccupancy( occupiedInvigilators.Add(new InvigilatorOccupancy(
teacher.Id, session.StartsAt, session.EndsAt)); teacher.Id,
room.StartsAt,
room.EndsAt));
assignedInvigilators++; assignedInvigilators++;
} }
if (newlyAssigned.Count < needed) if (teachers.Count < needed)
{ unavailableInvigilators += needed - teachers.Count;
unavailableInvigilators += needed - newlyAssigned.Count;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
} }
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
var expandedCount = sessions.Count - explicitlySelected.Count;
var detail = messages.Count > 0
? $" 详情:{string.Join("", messages.Take(10))}"
: "";
return new ExamArrangementResult( return new ExamArrangementResult(
true, true,
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" + $"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") + $"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") + (expandedCount > 0
(messages.Count > 0 ? $" 详情:{string.Join("", messages.Take(10))}" : "")); ? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。"
: "") +
(unavailableStudents > 0
? $" {unavailableStudents}名考生尚未安排考场。"
: "") +
(unavailableInvigilators > 0
? $" 仍缺{unavailableInvigilators}名监考教师。"
: "") +
detail);
}
private async Task<List<RoomOccupancy>> LoadOccupiedRoomsAsync(
ExamPlan plan,
IReadOnlySet<Guid> targetSessionIds,
IReadOnlySet<Guid> replacedRoomIds,
CancellationToken cancellationToken)
{
var occupied = plan.Rooms
.Where(x => !replacedRoomIds.Contains(x.Id))
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToList();
occupied.AddRange(plan.Sessions
.Where(x =>
!targetSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt)));
var externalRooms = await db.ExamRooms.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived)
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalRooms);
var externalLegacyRooms = await db.ExamSessions.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ClassroomId != null)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalLegacyRooms);
return occupied;
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamRoomAssignment room,
int needed,
HashSet<Guid> excludedTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt,
x.EndsAt,
room.StartsAt,
room.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking()
.Where(x =>
x.ExamRoomId != room.Id &&
x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamRoom.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamRoom.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in databaseBusyIds)
busyTeacherIds.Add(teacherId);
var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x =>
x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamSession.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in legacyBusyIds)
busyTeacherIds.Add(teacherId);
foreach (var teacherId in excludedTeacherIds)
busyTeacherIds.Add(teacherId);
var candidates = await InvigilatorCandidateQuery
.Create(db, busyTeacherIds)
.ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
}
private static List<Classroom> SelectRooms(
IReadOnlyCollection<Classroom> availableRooms,
int candidateCount)
{
var remainingRooms = availableRooms.ToList();
var selected = new List<Classroom>();
var remainingSeats = candidateCount;
while (remainingSeats > 0 && remainingRooms.Count > 0)
{
var room = remainingRooms
.Where(x => x.Capacity / 2 >= remainingSeats)
.OrderBy(x => x.Capacity / 2)
.ThenBy(x => x.Name)
.FirstOrDefault()
?? remainingRooms
.OrderByDescending(x => x.Capacity / 2)
.ThenBy(x => x.Name)
.First();
selected.Add(room);
remainingRooms.Remove(room);
remainingSeats -= room.Capacity / 2;
}
return selected;
}
private static List<CandidateSeat> InterleaveCandidates(
IReadOnlyCollection<ExamSession> sessions,
IReadOnlyCollection<TeachingTaskRosterEntry> roster,
IReadOnlyDictionary<Guid, ExamSession> sessionByTaskId)
{
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var queues = roster
.Where(x =>
sessionByTaskId.TryGetValue(
x.TeachingTaskId,
out var session) &&
sessionIds.Contains(session.Id))
.GroupBy(x => x.TeachingTaskId)
.OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber)
.Select(group => new Queue<CandidateSeat>(
group.OrderBy(x => x.StudentNumber)
.Select(x => new CandidateSeat(
sessionByTaskId[x.TeachingTaskId].Id,
x.StudentId,
x.StudentNumber))))
.ToList();
var result = new List<CandidateSeat>();
var assignedStudentIds = new HashSet<Guid>();
while (queues.Any(x => x.Count > 0))
{
foreach (var queue in queues)
{
while (queue.Count > 0)
{
var candidate = queue.Dequeue();
if (!assignedStudentIds.Add(candidate.StudentId))
continue;
result.Add(candidate);
break;
}
}
}
return result;
}
private static ExamRoomAssignment CreateLegacyRoom(
ExamPlan plan,
ExamSession session,
IEnumerable<TeachingTaskRosterEntry> roster)
{
var students = roster
.OrderBy(x => x.StudentNumber)
.ToList();
return new ExamRoomAssignment
{
ExamPlanId = plan.Id,
CourseId = session.TeachingTask!.CourseId,
ClassroomId = session.ClassroomId!.Value,
ExamDate = session.ExamDate,
StartPeriod = session.StartPeriod,
PeriodCount = session.PeriodCount,
StartsAt = session.StartsAt,
EndsAt = session.EndsAt,
RequiredInvigilatorCount = session.RequiredInvigilatorCount,
SessionLinks =
[
new ExamRoomSession
{
ExamSessionId = session.Id
}
],
Seats = students.Select((student, index) =>
new ExamSeatAssignment
{
ExamSessionId = session.Id,
StudentId = student.StudentId,
SeatNumber = index + 1
}).ToList(),
Invigilators = session.Invigilators.Select(x =>
new ExamRoomInvigilator
{
TeacherId = x.TeacherId
}).ToList()
};
}
private static RoomGroupKey GroupKey(ExamSession session)
{
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
if (session.RequiredBuildingId.HasValue)
buildingIds.Add(session.RequiredBuildingId.Value);
var key = buildingIds.Count == 0
? string.Empty
: string.Join(",", buildingIds.OrderBy(x => x));
return new(
session.TeachingTask!.CourseId,
session.ExamDate,
session.StartPeriod,
session.PeriodCount,
key);
}
private static HashSet<Guid> ParseBuildingIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
}
catch
{
return [];
}
} }
private static void ComputeTimesFromSlots( private static void ComputeTimesFromSlots(
ExamSession session, ExamSession session,
Dictionary<int, ScheduleTimeSlot> timeSlotLookup) IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
{ {
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod); var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
var endSlot = timeSlotLookup.GetValueOrDefault( var endSlot = timeSlotLookup.GetValueOrDefault(
session.StartPeriod + session.PeriodCount - 1); session.StartPeriod + session.PeriodCount - 1);
if (startSlot is null || endSlot is null) return; if (startSlot is null || endSlot is null) return;
var examDate = session.ExamDate; session.StartsAt = session.ExamDate.ToDateTime(
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc); startSlot.StartsAt,
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc); DateTimeKind.Utc);
} session.EndsAt = session.ExamDate.ToDateTime(
endSlot.EndsAt,
private async Task<Classroom?> FindBestClassroomAsync( DateTimeKind.Utc);
ExamSession session,
int studentCount,
List<RoomOccupancy> occupied,
CancellationToken cancellationToken)
{
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
// Exclude classrooms already occupied in-memory
var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.ClassroomId)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
// Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == session.ExamPlanId &&
x.Id != session.Id &&
x.ClassroomId != null &&
x.StartsAt < session.EndsAt &&
session.StartsAt < x.EndsAt)
.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query
.OrderBy(x => x.Capacity)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamSession session,
int needed,
HashSet<Guid> excludeTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
x.ExamSessionId != session.Id &&
x.ExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken);
} }
} }
public sealed record ExamArrangementResult(bool Success, string Message) public sealed record ExamArrangementResult(bool Success, string Message)
{ {
public static ExamArrangementResult Fail(string message) => new(false, message); public static ExamArrangementResult Fail(string message) =>
new(false, message);
} }
@@ -0,0 +1,328 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamPublishJobProcessor(
AppDbContext db,
IAppCache cache,
ILogger<ExamPublishJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamPublishJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamPublishJobStatus.Succeeded
or ExamPublishJobStatus.Failed)
{
return;
}
job.Status = ExamPublishJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.CurrentStep = "正在校验考试计划";
await db.SaveChangesAsync(stoppingToken);
switch (job.Kind)
{
case ExamPublishJobKind.FormalExam:
await PublishFormalExamAsync(job, stoppingToken);
break;
case ExamPublishJobKind.MakeupExam:
await PublishMakeupExamAsync(job, stoppingToken);
break;
case ExamPublishJobKind.ExperimentProjects:
await PublishExperimentProjectsAsync(job, stoppingToken);
break;
default:
throw new InvalidOperationException(
$"不支持的考试发布类型:{job.Kind}。");
}
job.Status = ExamPublishJobStatus.Succeeded;
job.ActivePlanId = null;
job.CurrentStep = "发布完成";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
stoppingToken);
logger.LogInformation(
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
job.Id,
job.Kind,
job.PlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Exam publish job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (ExamPublishValidationException validationException)
{
logger.LogWarning(
validationException,
"Exam publish job {JobId} validation failed.",
jobId);
await MarkFailedAsync(jobId, validationException.Message);
}
catch (Exception exception)
{
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private async Task PublishFormalExamAsync(
ExamPublishJob job,
CancellationToken ct)
{
var plan = await db.ExamPlans
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
if (plan is null)
throw new ExamPublishValidationException("考试计划不存在。");
if (plan.Status != ExamPlanStatus.Draft)
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
// Step 1: check sessions exist — simple count, no JOIN
var sessionCount = await db.ExamSessions
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
if (sessionCount == 0)
throw new ExamPublishValidationException(
"至少安排一个考试场次后才能发布。");
// Step 2: load session summaries — only necessary columns, no Include chains
var sessions = await db.ExamSessions
.AsNoTracking()
.Where(x => x.ExamPlanId == job.PlanId)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.ClassroomId,
InvigilatorCount = x.Invigilators.Count,
RoomLinkCount = x.RoomLinks.Count
})
.ToListAsync(ct);
// Step 3: load roster counts — independent query
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
var rosterCounts = (await TeachingTaskRosterQuery
.LoadForTasksAsync(db, taskIds, ct))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
// Step 4: validate each session's assignment completeness
var unassignedSessionIds = new List<Guid>();
foreach (var session in sessions)
{
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
if (session.RoomLinkCount > 0)
{
// Has room links — check seat assignment via separate query
var assignedSeatCount = await db.ExamSeats
.CountAsync(seat =>
seat.ExamSessionId == session.Id,
ct);
if (assignedSeatCount != rosterCount)
unassignedSessionIds.Add(session.Id);
}
else if (!session.ClassroomId.HasValue ||
session.InvigilatorCount == 0)
{
unassignedSessionIds.Add(session.Id);
}
}
if (unassignedSessionIds.Count > 0)
throw new ExamPublishValidationException(
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
"请先完成自动编排。");
// Step 5: check room invigilator sufficiency — separate query
var insufficientInvigilatorCount = await db.ExamRoomSessions
.Where(link =>
link.ExamRoom!.ExamPlanId == job.PlanId &&
link.ExamRoom.Invigilators.Count <
link.ExamRoom.RequiredInvigilatorCount)
.Select(link => link.ExamSessionId)
.Distinct()
.CountAsync(ct);
if (insufficientInvigilatorCount > 0)
throw new ExamPublishValidationException(
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
"请先完成自动编排。");
// Step 6: validate mixed rooms — split into two independent queries
// to avoid Seats × SessionLinks Cartesian product
// 6a: capacity overflow
var overCapacityCount = await db.ExamRooms
.Where(r => r.ExamPlanId == job.PlanId)
.Select(r => new
{
r.Id,
SeatCount = r.Seats.Count,
Capacity = r.Classroom!.Capacity
})
.CountAsync(x => x.SeatCount > x.Capacity, ct);
if (overCapacityCount > 0)
throw new ExamPublishValidationException(
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
// 6b: course mismatch
var courseMismatchCount = await db.ExamRoomSessions
.Where(link =>
link.ExamRoom!.ExamPlanId == job.PlanId &&
link.ExamSession!.TeachingTask!.CourseId !=
link.ExamRoom.CourseId)
.Select(link => link.ExamRoomId)
.Distinct()
.CountAsync(ct);
if (courseMismatchCount > 0)
throw new ExamPublishValidationException(
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
// Step 7: publish
plan.Status = ExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
private async Task PublishMakeupExamAsync(
ExamPublishJob job,
CancellationToken ct)
{
var plan = await db.MakeupExamPlans
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
if (plan is null)
throw new ExamPublishValidationException("补考计划不存在。");
if (plan.Status != MakeupExamPlanStatus.Draft)
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
// Step 1: check sessions exist — simple count
var sessionCount = await db.MakeupExamSessions
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
if (sessionCount == 0)
throw new ExamPublishValidationException(
"至少安排一个考试场次后才能发布。");
// Step 2: load session summaries — only necessary columns
var sessions = await db.MakeupExamSessions
.AsNoTracking()
.Where(x => x.MakeupExamPlanId == job.PlanId)
.Select(x => new
{
x.Id,
x.ClassroomId,
InvigilatorCount = x.Invigilators.Count,
EnrollmentCount = x.Enrollments.Count
})
.ToListAsync(ct);
// Step 3: validate completeness — no Include chain needed
var unassigned = sessions.Count(x =>
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
if (unassigned > 0)
throw new ExamPublishValidationException(
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
// Step 4: validate enrollments
var empty = sessions.Count(x => x.EnrollmentCount == 0);
if (empty > 0)
throw new ExamPublishValidationException(
$"还有 {empty} 个场次没有登记补考学生。");
// Step 5: publish
plan.Status = MakeupExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct)
{
var ids = JsonSerializer.Deserialize<List<Guid>>(job.ProjectIdsJson ?? "[]")?
.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
if (ids.Count is 0 or > 100)
throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。");
var projects = await db.ExperimentProjects
.Include(x => x.Sessions)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Where(x => ids.Contains(x.Id))
.ToListAsync(ct);
if (projects.Count != ids.Count)
throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。");
foreach (var project in projects)
{
if (project.Status != ExperimentProjectStatus.Draft)
throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。");
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
if (!hasSchedule)
throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。");
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
}
job.CurrentStep = "正在发布实验项目";
await db.SaveChangesAsync(ct);
var publishedAt = DateTime.UtcNow;
foreach (var project in projects)
{
project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = publishedAt;
}
await db.SaveChangesAsync(ct);
foreach (var project in projects)
{
var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId)
.Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct);
if (userIds.Count == 0) continue;
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约";
await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments", ct, NotificationCategory.Schedule);
}
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamPublishJobStatus.Failed;
job.ActivePlanId = null;
job.CurrentStep = "发布失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
public sealed class ExamPublishValidationException(string message)
: InvalidOperationException(message);
@@ -0,0 +1,212 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamSignInExportJobProcessor(
AppDbContext db,
ILogger<ExamSignInExportJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamSignInExportJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamSignInExportJobStatus.Succeeded
or ExamSignInExportJobStatus.Failed)
{
return;
}
job.Status = ExamSignInExportJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.CurrentStep = "正在生成考场签名单";
await db.SaveChangesAsync(stoppingToken);
var plan = await db.ExamPlans.AsNoTracking()
.Where(x => x.Id == job.PlanId)
.Select(x => new
{
x.Name,
TermName = x.AcademicTerm!.Name
})
.FirstOrDefaultAsync(stoppingToken);
if (plan is null)
{
await MarkFailedAsync(jobId, "考试计划不存在。");
return;
}
var rooms = await db.ExamRooms.AsNoTracking()
.Include(x => x.Course)
.Include(x => x.Classroom)
.ThenInclude(x => x!.Building)
.Include(x => x.Invigilators)
.ThenInclude(x => x.Teacher)
.Include(x => x.SessionLinks)
.ThenInclude(x => x.ExamSession)
.ThenInclude(x => x!.TeachingTask)
.Include(x => x.Seats)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Where(x => x.ExamPlanId == job.PlanId)
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.Classroom!.Building!.Name)
.ThenBy(x => x.Classroom!.Name)
.AsSplitQuery()
.ToListAsync(stoppingToken);
var legacySessions = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == job.PlanId)
.Where(x => !x.RoomLinks.Any())
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.Select(session => new
{
session.Id,
session.TeachingTaskId,
session.ExamDate,
session.StartsAt,
session.EndsAt,
CourseCode = session.TeachingTask!.Course!.Code,
CourseName = session.TeachingTask.Course.Name,
session.TeachingTask.TaskNumber,
BuildingName = session.Classroom != null
? session.Classroom.Building!.Name
: null,
ClassroomName = session.Classroom != null
? session.Classroom.Name
: null,
InvigilatorNames = session.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
})
.ToListAsync(stoppingToken);
if (rooms.Count == 0 && legacySessions.Count == 0)
{
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
return;
}
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
legacySessions.Select(x => x.TeachingTaskId),
stoppingToken);
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
var sheets = rooms.Select(room => new ExamSignInSessionData(
room.Id,
room.ExamDate,
room.StartsAt,
room.EndsAt,
room.Course!.Code,
room.Course.Name,
string.Join(
"、",
room.SessionLinks
.Select(link =>
link.ExamSession!.TeachingTask!.TaskNumber)
.Distinct()
.OrderBy(x => x)),
room.Classroom!.Building!.Name,
room.Classroom.Name,
room.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
.ToList(),
room.Seats
.OrderBy(seat => seat.SeatNumber)
.Select(seat => new ExamSignInStudentData(
seat.StudentId,
seat.Student!.StudentNumber,
seat.Student.Name,
seat.Student.AdministrativeClass!.Name,
seat.SeatNumber))
.ToList()))
.Concat(legacySessions.Select(session =>
new ExamSignInSessionData(
session.Id,
session.ExamDate,
session.StartsAt,
session.EndsAt,
session.CourseCode,
session.CourseName,
session.TaskNumber,
session.BuildingName,
session.ClassroomName,
session.InvigilatorNames.ToList(),
studentsByTask[session.TeachingTaskId]
.Select((student, index) =>
new ExamSignInStudentData(
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName,
index + 1))
.ToList())))
.ToList();
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
var bytes = ExamSignInWorkbookExporter.Create(data);
job.Status = ExamSignInExportJobStatus.Succeeded;
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
job.FileBytes = bytes;
job.FileSize = bytes.Length;
job.CurrentStep = "生成完成";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
logger.LogInformation(
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
job.Id,
job.PlanId,
bytes.Length);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Sign-in export job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamSignInExportJobStatus.Failed;
job.CurrentStep = "生成失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
private static string SanitizeFileName(string value)
{
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
var normalized = new string(value
.Select(c => invalid.Contains(c) ? '_' : c)
.ToArray()).Trim();
return normalized.Length > 100 ? normalized[..100] : normalized;
}
}
@@ -0,0 +1,305 @@
using ClosedXML.Excel;
namespace Jiaowu.Api.Infrastructure.Exams;
public static class ExamSignInWorkbookExporter
{
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
public static byte[] Create(ExamSignInWorkbookData data)
{
using var workbook = new XLWorkbook();
AddSummarySheet(workbook, data);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"考场汇总"
};
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName)
.ThenBy(x => x.CourseCode))
{
AddSignInSheet(
workbook,
data,
session,
UniqueSheetName(session, usedNames));
}
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
private static void AddSummarySheet(
XLWorkbook workbook,
ExamSignInWorkbookData data)
{
var sheet = workbook.Worksheets.Add("考场汇总");
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Row(1).Height = 34;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value =
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Row(2).Height = 24;
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(4, column).Value = headers[column - 1];
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
var row = 5;
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName))
{
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Cell(row, 3).Value = Location(session);
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
sheet.Cell(row, 5).Value = session.TaskNumber;
sheet.Cell(row, 6).Value = session.Students.Count;
sheet.Cell(row, 7).Value = Invigilators(session);
row++;
}
if (row > 5)
{
var table = sheet.Range(4, 1, row - 1, headers.Length);
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
table.Style.Border.InsideBorderColor = Rule;
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
table.Style.Border.OutsideBorderColor = Rule;
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Rows(5, row - 1).Height = 25;
}
sheet.Column(1).Width = 13;
sheet.Column(2).Width = 15;
sheet.Column(3).Width = 24;
sheet.Column(4).Width = 30;
sheet.Column(5).Width = 22;
sheet.Column(6).Width = 11;
sheet.Column(7).Width = 24;
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
sheet.SheetView.FreezeRows(4);
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
}
private static void AddSignInSheet(
XLWorkbook workbook,
ExamSignInWorkbookData data,
ExamSignInSessionData session,
string sheetName)
{
var sheet = workbook.Worksheets.Add(sheetName);
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = "考试考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Cell("A1").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(1).Height = 36;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Cell("A2").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(2).Height = 23;
sheet.Range("A3:D3").Merge();
sheet.Cell("A3").Value =
$"课程:{session.CourseCode} {session.CourseName}{session.TaskNumber}";
sheet.Range("E3:G3").Merge();
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
sheet.Range("A4:D4").Merge();
sheet.Cell("A4").Value =
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Range("E4:G4").Merge();
sheet.Cell("E4").Value = $"考场:{Location(session)}";
sheet.Range("A5:D5").Merge();
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
sheet.Range("E5:G5").Merge();
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
var metadata = sheet.Range("A3:G5");
metadata.Style.Fill.BackgroundColor = XLColor.White;
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
metadata.Style.Alignment.WrapText = true;
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
metadata.Style.Border.BottomBorderColor = Rule;
sheet.Rows(3, 5).Height = 24;
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(7, column).Value = headers[column - 1];
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
sheet.Row(7).Height = 27;
var row = 8;
foreach (var student in session.Students
.OrderBy(x => x.SeatNumber)
.ThenBy(x => x.StudentNumber))
{
var index = row - 7;
sheet.Cell(row, 1).Value = index;
sheet.Cell(row, 2).Value =
(student.SeatNumber > 0 ? student.SeatNumber : index)
.ToString("D3");
sheet.Cell(row, 3).Value = student.StudentNumber;
sheet.Cell(row, 4).Value = student.Name;
sheet.Cell(row, 5).Value = student.ClassName;
sheet.Row(row).Height = 28;
row++;
}
if (session.Students.Count == 0)
{
sheet.Range("A8:G8").Merge();
sheet.Cell("A8").Value = "本场次暂无考生";
sheet.Cell("A8").Style
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
sheet.Row(8).Height = 28;
row = 9;
}
var roster = sheet.Range(7, 1, row - 1, headers.Length);
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
roster.Style.Border.InsideBorderColor = Rule;
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
sheet.Column(1).Width = 7;
sheet.Column(2).Width = 10;
sheet.Column(3).Width = 16;
sheet.Column(4).Width = 12;
sheet.Column(5).Width = 20;
sheet.Column(6).Width = 22;
sheet.Column(7).Width = 17;
sheet.SheetView.FreezeRows(7);
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
}
private static void StyleTitle(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontSize(18)
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Navy)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleSubTitle(IXLRange range) =>
range.Style
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(Pale)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleHeader(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Teal)
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static string Location(ExamSignInSessionData session) =>
string.Join(" · ", new[]
{
session.BuildingName,
session.ClassroomName
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
? value
: "待分配考场";
private static string Invigilators(ExamSignInSessionData session) =>
session.InvigilatorNames.Count > 0
? string.Join('、', session.InvigilatorNames)
: "待分配";
private static string UniqueSheetName(
ExamSignInSessionData session,
ISet<string> usedNames)
{
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
? "待分配"
: session.ClassroomName;
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
var invalidCharacters = new HashSet<char>(":\\/?*[]");
var sanitized = new string(raw
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
.ToArray()).Trim(' ', '\'');
if (sanitized.Length == 0) sanitized = "考场";
if (sanitized.Length > 31) sanitized = sanitized[..31];
var candidate = sanitized;
var suffix = 2;
while (!usedNames.Add(candidate))
{
var marker = $"-{suffix++}";
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
candidate = sanitized[..prefixLength] + marker;
}
return candidate;
}
}
public sealed record ExamSignInWorkbookData(
string PlanName,
string TermName,
IReadOnlyList<ExamSignInSessionData> Sessions);
public sealed record ExamSignInSessionData(
Guid SessionId,
DateOnly ExamDate,
DateTime StartsAt,
DateTime EndsAt,
string CourseCode,
string CourseName,
string TaskNumber,
string? BuildingName,
string? ClassroomName,
IReadOnlyList<string> InvigilatorNames,
IReadOnlyList<ExamSignInStudentData> Students);
public sealed record ExamSignInStudentData(
Guid StudentId,
string StudentNumber,
string Name,
string ClassName,
int SeatNumber = 0);
@@ -0,0 +1,15 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
internal static class InvigilatorCandidateQuery
{
public static IQueryable<Teacher> Create(
AppDbContext db,
IEnumerable<Guid> excludedTeacherIds) =>
db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.WhereNotIn(excludedTeacherIds, x => x.Id);
}
@@ -26,6 +26,10 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask) .ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers) .ThenInclude(x => x!.Teachers)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.AsSplitQuery()
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null) if (plan is null)
@@ -96,7 +100,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt)); occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++; assignedRooms++;
messages.Add( messages.Add(
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)"); $"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
} }
else else
{ {
@@ -167,10 +171,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var query = db.Classrooms.AsNoTracking() var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount); .Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
if (session.RequiredBuildingId.HasValue) if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value); buildingIds.Add(session.RequiredBuildingId.Value);
if (buildingIds.Count > 0)
query = query.Where(x => buildingIds.Contains(x.BuildingId));
var occupiedRoomIds = occupied var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps( .Where(x => ExamConflictRules.TimeOverlaps(
@@ -222,11 +229,27 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
foreach (var id in dbBusyIds) busyTeacherIds.Add(id); foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking() var candidates = await InvigilatorCandidateQuery
.Where(x => x.Status == TeacherStatus.Active) .Create(db, busyTeacherIds)
.WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
}
private static HashSet<Guid> ParseBuildingIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
}
catch
{
return [];
}
} }
} }
@@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper
string sheetName, string sheetName,
IReadOnlyList<string> headers, IReadOnlyList<string> headers,
IEnumerable<IReadOnlyList<object?>> rows, IEnumerable<IReadOnlyList<object?>> rows,
IReadOnlyList<string>? instructions = null) IReadOnlyList<string>? instructions = null,
Action<IXLWorksheet, int>? configureRow = null)
{ {
using var workbook = new XLWorkbook(); using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add(sheetName); var sheet = workbook.Worksheets.Add(sheetName);
@@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper
{ {
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]); SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
} }
configureRow?.Invoke(sheet, rowNumber);
rowNumber++; rowNumber++;
} }
@@ -0,0 +1,59 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Experiments;
internal static class CentralizedExperimentConflictQuery
{
public static IQueryable<Guid> TaskIdsForTeachers(
AppDbContext db,
IReadOnlyCollection<Guid> teacherIds) =>
db.TeachingTaskTeachers.AsNoTracking()
.WhereIn(teacherIds, x => x.TeacherId)
.Select(x => x.TeachingTaskId);
public static IQueryable<Guid> TaskIdsForClasses(
AppDbContext db,
IReadOnlyCollection<Guid> classIds) =>
db.TeachingTaskClasses.AsNoTracking()
.WhereIn(classIds, x => x.AdministrativeClassId)
.Select(x => x.TeachingTaskId);
public static IQueryable<ScheduleEntry> ScheduleEntries(
AppDbContext db,
IReadOnlyCollection<Guid> relatedTaskIds,
Guid academicTermId,
int dayOfWeek,
int week,
int startPeriod,
int periodCount) =>
db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.SchedulePlan.AcademicTermId == academicTermId &&
x.DayOfWeek == dayOfWeek &&
x.StartWeek <= week &&
x.EndWeek >= week &&
x.StartPeriod < startPeriod + periodCount &&
startPeriod < x.StartPeriod + x.PeriodCount)
.WhereIn(relatedTaskIds, x => x.TeachingTaskId);
public static IQueryable<ExperimentSession> ExperimentSessions(
AppDbContext db,
IReadOnlyCollection<Guid> relatedTaskIds,
DateOnly date,
int startPeriod,
int periodCount) =>
db.ExperimentSessions.AsNoTracking()
.Where(x =>
x.Status == ExperimentSessionStatus.Scheduled &&
x.SessionDate == date &&
x.StartPeriod < startPeriod + periodCount &&
startPeriod < x.StartPeriod + x.PeriodCount &&
x.ExperimentProject!.ArrangementMode ==
ExperimentArrangementMode.Centralized)
.WhereIn(
relatedTaskIds,
x => x.ExperimentProject!.TeachingTaskId);
}
@@ -0,0 +1,228 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
/// <summary>
/// Repairs missing or stale materialized grade statistics on a database-
/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
/// worker batches changes made during bulk imports.
/// </summary>
public sealed class CourseGradeStatisticsRefreshWorker(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
ILogger<CourseGradeStatisticsRefreshWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Database-configured course grade statistics scheduler started.");
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), timeProvider);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var scheduler = scope.ServiceProvider
.GetRequiredService<CourseGradeStatisticsRefreshScheduler>();
var queued = await scheduler.EnqueueDueAsync(
timeProvider.GetUtcNow().UtcDateTime,
stoppingToken);
if (queued > 0)
logger.LogInformation(
"Scheduled course grade statistics scan queued {Count} refresh jobs.",
queued);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Scheduled course grade statistics scan failed.");
}
if (!await timer.WaitForNextTickAsync(stoppingToken)) break;
}
}
}
public sealed class CourseGradeStatisticsRefreshScheduler(
AppDbContext db,
ILogger<CourseGradeStatisticsRefreshScheduler> logger)
{
public async Task<int> EnqueueDueAsync(
DateTime utcNow,
CancellationToken cancellationToken) =>
await ExecuteWithLeaseAsync(async () =>
{
var setting = await db.CourseGradeStatisticsRefreshSettings
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
if (setting is null)
{
setting = new CourseGradeStatisticsRefreshSetting();
db.CourseGradeStatisticsRefreshSettings.Add(setting);
}
var interval = TimeSpan.FromSeconds(
Math.Clamp(setting.IntervalSeconds, 10, 86400));
if (!setting.IsEnabled ||
setting.LastRunAt.HasValue && utcNow < setting.LastRunAt.Value + interval)
{
if (db.Entry(setting).State == EntityState.Added)
await db.SaveChangesAsync(cancellationToken);
return 0;
}
setting.LastRunAt = utcNow;
var queued = await EnqueueStaleCoreAsync(setting.BatchSize, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return queued;
}, cancellationToken);
public async Task<int> EnqueueStaleAsync(
int batchSize,
CancellationToken cancellationToken) =>
await ExecuteWithLeaseAsync(async () =>
{
var queued = await EnqueueStaleCoreAsync(batchSize, cancellationToken);
if (queued > 0) await db.SaveChangesAsync(cancellationToken);
return queued;
}, cancellationToken);
private async Task<int> ExecuteWithLeaseAsync(
Func<Task<int>> action,
CancellationToken cancellationToken)
{
var usesMySqlLease = db.Database.ProviderName?.Contains(
"MySql",
StringComparison.OrdinalIgnoreCase) == true;
if (usesMySqlLease && !await TryAcquireMySqlLeaseAsync(cancellationToken))
{
await db.Database.CloseConnectionAsync();
logger.LogDebug("Another instance owns the grade statistics refresh lease.");
return 0;
}
try
{
return await action();
}
finally
{
if (usesMySqlLease)
await ReleaseMySqlLeaseAsync();
}
}
private async Task<int> EnqueueStaleCoreAsync(
int batchSize,
CancellationToken cancellationToken)
{
batchSize = Math.Clamp(batchSize, 1, 5000);
var activeTargets = await (
from job in db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
join sheet in db.GradeSheets.AsNoTracking()
on job.GradeSheetId equals sheet.Id
where job.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
job.Status == CourseGradeStatisticsRefreshJobStatus.Running
select new CourseTermTarget(
sheet.TeachingTask!.CourseId,
sheet.TeachingTask.AcademicTermId))
.Distinct()
.ToListAsync(cancellationToken);
var active = activeTargets.ToHashSet();
var rows = await db.GradeSheets.AsNoTracking()
.Where(sheet =>
sheet.Status == GradeSheetStatus.Published &&
sheet.Records.Any(record => record.TotalScore != null))
.Select(sheet => new RefreshCandidate(
sheet.Id,
sheet.TeachingTask!.CourseId,
sheet.TeachingTask.AcademicTermId,
sheet.UpdatedAt,
sheet.Records
.Where(record => record.TotalScore != null)
.Max(record => record.UpdatedAt),
db.TeachingTaskGradeStatistics
.Where(statistic => statistic.GradeSheetId == sheet.Id)
.Select(statistic => (DateTime?)statistic.CalculatedAt)
.FirstOrDefault()))
.ToListAsync(cancellationToken);
var stale = rows
.Where(row =>
row.CalculatedAt is null ||
row.SheetUpdatedAt > row.CalculatedAt ||
row.RecordsUpdatedAt > row.CalculatedAt)
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
.Where(group => !active.Contains(group.Key))
.Select(group => group
.OrderByDescending(row => row.RecordsUpdatedAt)
.ThenByDescending(row => row.SheetUpdatedAt)
.First())
.Take(batchSize)
.ToArray();
foreach (var candidate in stale)
{
var job = new CourseGradeStatisticsRefreshJob
{
GradeSheetId = candidate.GradeSheetId
};
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
}
if (stale.Length == 0) return 0;
logger.LogDebug(
"Queued {Count} stale course grade statistics targets.",
stale.Length);
return stale.Length;
}
private async Task<bool> TryAcquireMySqlLeaseAsync(
CancellationToken cancellationToken)
{
await db.Database.OpenConnectionAsync(cancellationToken);
await using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = "SELECT GET_LOCK('jiaowu:grade-statistics-refresh', 0);";
var result = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt32(result) == 1;
}
private async Task ReleaseMySqlLeaseAsync()
{
try
{
await using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = "SELECT RELEASE_LOCK('jiaowu:grade-statistics-refresh');";
await command.ExecuteScalarAsync(CancellationToken.None);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to release grade statistics refresh lease.");
}
finally
{
await db.Database.CloseConnectionAsync();
}
}
private sealed record RefreshCandidate(
Guid GradeSheetId,
Guid CourseId,
Guid AcademicTermId,
DateTime SheetUpdatedAt,
DateTime RecordsUpdatedAt,
DateTime? CalculatedAt);
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -0,0 +1,250 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
/// <summary>
/// Rebuilds a course/term's denormalized result statistics. The operation is
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
/// </summary>
public sealed class CourseGradeStatisticsRefreshJobProcessor(
AppDbContext db,
IAppCache cache,
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
{
var job = await db.CourseGradeStatisticsRefreshJobs
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
return;
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
job.StartedAt = DateTime.UtcNow;
job.ErrorMessage = null;
await db.SaveChangesAsync(cancellationToken);
var sheetData = await db.GradeSheets.AsNoTracking()
.Where(x => x.Id == job.GradeSheetId)
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
.FirstOrDefaultAsync(cancellationToken);
if (sheetData is null)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return;
}
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
try
{
// Statistics shown to students are based only on formally published
// scores. This prevents an unfinished class from exposing data.
var scores = await db.GradeRecords.AsNoTracking()
.Where(x => x.TotalScore != null &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
.Select(x => new ScoreRow(
x.GradeSheetId,
x.GradeSheet!.TeachingTaskId,
x.TotalScore!.Value,
x.Student!.AdministrativeClassId,
x.Student.AdministrativeClass!.MajorId,
x.Student.AdministrativeClass.Major!.CollegeId))
.ToListAsync(cancellationToken);
var now = DateTime.UtcNow;
var rebuilt = new List<CourseGradeStatistic>();
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.Major,
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
AddStatistics(CourseGradeStatisticScope.College,
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
AddUniversityStatistic(scores, rebuilt, target, now);
var rebuiltTeachingTasks = scores
.GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId })
.Select(group => CreateTeachingTaskStatistic(
group.Key.GradeSheetId,
group.Key.TeachingTaskId,
group.Select(x => x.Score),
target,
now))
.ToList();
await db.CourseGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.ExecuteDeleteAsync(cancellationToken);
if (rebuilt.Count > 0)
db.CourseGradeStatistics.AddRange(rebuilt);
var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics
.Where(x => x.CourseId == target.CourseId &&
x.AcademicTermId == target.AcademicTermId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (oldTeachingTaskStatisticIds.Count > 0)
{
await db.TeachingTaskGradeScoreBands
.Where(x => oldTeachingTaskStatisticIds.Contains(
x.TeachingTaskGradeStatisticId))
.ExecuteDeleteAsync(cancellationToken);
await db.TeachingTaskGradeStatistics
.Where(x => oldTeachingTaskStatisticIds.Contains(x.Id))
.ExecuteDeleteAsync(cancellationToken);
}
if (rebuiltTeachingTasks.Count > 0)
db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks);
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
job.CompletedAt = now;
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
cancellationToken);
}
catch (Exception exception)
{
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
exception.GetBaseException().Message.Length)];
await db.SaveChangesAsync(CancellationToken.None);
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
throw;
}
}
private static void AddStatistics(
CourseGradeStatisticScope scope,
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
foreach (var group in groups)
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
}
private static void AddUniversityStatistic(
IReadOnlyCollection<ScoreRow> scores,
ICollection<CourseGradeStatistic> target,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
if (scores.Count > 0)
target.Add(Create(CourseGradeStatisticScope.University, null,
scores.Select(x => x.Score), targetInfo, calculatedAt));
}
private static CourseGradeStatistic Create(
CourseGradeStatisticScope scope,
Guid? scopeEntityId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.ToArray();
var passed = scores.Count(x => x >= 60m);
return new CourseGradeStatistic
{
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
Scope = scope,
ScopeEntityId = scopeEntityId,
StudentCount = scores.Length,
PassedCount = passed,
Below60Count = scores.Count(x => x < 60m),
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
From90To100Count = scores.Count(x => x >= 90m),
HighestScore = scores.Max(),
AverageScore = Math.Round(scores.Average(), 1),
LowestScore = scores.Min(),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
}
private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic(
Guid gradeSheetId,
Guid teachingTaskId,
IEnumerable<decimal> source,
StatisticsTarget targetInfo,
DateTime calculatedAt)
{
var scores = source.OrderBy(x => x).ToArray();
var passed = scores.Count(x => x >= 60m);
var excellent = scores.Count(x => x >= 90m);
var average = scores.Average();
var middle = scores.Length / 2;
var median = scores.Length % 2 == 0
? (scores[middle - 1] + scores[middle]) / 2m
: scores[middle];
var variance = scores.Average(x =>
(double)((x - average) * (x - average)));
var statistic = new TeachingTaskGradeStatistic
{
GradeSheetId = gradeSheetId,
TeachingTaskId = teachingTaskId,
CourseId = targetInfo.CourseId,
AcademicTermId = targetInfo.AcademicTermId,
StudentCount = scores.Length,
PassedCount = passed,
ExcellentCount = excellent,
HighestScore = scores.Max(),
AverageScore = Math.Round(average, 1),
MedianScore = Math.Round(median, 1),
LowestScore = scores.Min(),
StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2),
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2),
CalculatedAt = calculatedAt
};
statistic.ScoreBands =
[
CreateBand(statistic.Id, "059", 0m, 60m,
scores.Count(x => x < 60m), 0),
CreateBand(statistic.Id, "6069", 60m, 70m,
scores.Count(x => x >= 60m && x < 70m), 1),
CreateBand(statistic.Id, "7079", 70m, 80m,
scores.Count(x => x >= 70m && x < 80m), 2),
CreateBand(statistic.Id, "8089", 80m, 90m,
scores.Count(x => x >= 80m && x < 90m), 3),
CreateBand(statistic.Id, "90100", 90m, null,
scores.Count(x => x >= 90m), 4)
];
return statistic;
}
private static TeachingTaskGradeScoreBand CreateBand(
Guid statisticId,
string label,
decimal lowerBound,
decimal? upperBound,
int count,
int sortOrder) => new()
{
TeachingTaskGradeStatisticId = statisticId,
Label = label,
LowerBound = lowerBound,
UpperBound = upperBound,
StudentCount = count,
SortOrder = sortOrder
};
private sealed record ScoreRow(
Guid GradeSheetId,
Guid TeachingTaskId,
decimal Score,
Guid ClassId,
Guid MajorId,
Guid CollegeId);
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -0,0 +1,146 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService
{
public static async Task RefreshTeachingTaskAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var existing = await db.ExperimentCourseGrades
.Where(x => x.TeachingTaskId == teachingTaskId)
.ToDictionaryAsync(x => x.StudentId, cancellationToken);
var studentIds = sheets
.SelectMany(x => x.Scores.Select(score => score.StudentId))
.Distinct()
.ToArray();
var refreshedAt = DateTime.UtcNow;
var totalWeight = sheets.Sum(x => x.ContributionWeight);
foreach (var studentId in studentIds)
{
var score = CalculateStudentScore(sheets, studentId);
if (!existing.Remove(studentId, out var aggregate))
{
aggregate = new Domain.Academic.ExperimentCourseGrade
{
TeachingTaskId = teachingTaskId,
StudentId = studentId
};
db.ExperimentCourseGrades.Add(aggregate);
}
aggregate.WeightedAverageScore = score;
aggregate.TotalWeight = totalWeight;
aggregate.PublishedProjectCount = sheets.Count;
aggregate.RefreshedAt = refreshedAt;
}
db.ExperimentCourseGrades.RemoveRange(existing.Values);
await db.SaveChangesAsync(cancellationToken);
}
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db,
Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken)
{
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var requestedStudentIds = studentIds.Distinct().ToArray();
var persistedScores = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId)
.WhereIn(requestedStudentIds, x => x.StudentId)
.ToDictionaryAsync(
x => x.StudentId,
x => x.WeightedAverageScore,
cancellationToken);
var scores = requestedStudentIds.ToDictionary(
studentId => studentId,
studentId => persistedScores.GetValueOrDefault(studentId));
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private static Task<List<PublishedExperimentSheet>> LoadPublishedSheetsAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken) =>
db.ExperimentGradeSheets.AsNoTracking()
.Where(x =>
x.Status ==
Domain.Academic.ExperimentGradeSheetStatus.Published &&
x.ExperimentProject!.TeachingTaskId == teachingTaskId)
.OrderBy(x => x.ExperimentProject!.Code)
.Select(x => new PublishedExperimentSheet(
x.Id,
x.ExperimentProject!.Code,
x.ExperimentProject.Name,
x.ContributionWeight,
x.Records.Select(record => new PublishedExperimentScore(
record.StudentId,
record.TotalScore)).ToList()))
.AsSplitQuery()
.ToListAsync(cancellationToken);
private static decimal? CalculateStudentScore(
IReadOnlyCollection<PublishedExperimentSheet> sheets,
Guid studentId)
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
if (sheets.Count == 0) return null;
foreach (var sheet in sheets)
{
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore) return null;
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
return totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
}
private sealed record PublishedExperimentSheet(
Guid Id,
string Code,
string Name,
decimal ContributionWeight,
List<PublishedExperimentScore> Scores);
private sealed record PublishedExperimentScore(
Guid StudentId,
decimal? TotalScore);
}
public sealed record ExperimentGradeAggregateResult(
int PublishedProjectCount,
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
IReadOnlyDictionary<Guid, decimal?> Scores);
public sealed record ExperimentGradeAggregateProject(
Guid Id,
string Code,
string Name,
decimal ContributionWeight);
@@ -0,0 +1,45 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeCalculator
{
public static bool AreWeightsValid(
decimal contributionWeight,
decimal passScore,
IEnumerable<ExperimentGradeItem> items)
{
var itemList = items.ToList();
return contributionWeight is > 0 and <= 100 &&
passScore is >= 0 and <= 100 &&
itemList.Count > 0 &&
itemList.All(item => item.Weight is > 0 and <= 100) &&
itemList.Sum(item => item.Weight) == 100;
}
public static decimal? CalculateTotal(
ExperimentGradeRecord record,
IReadOnlyCollection<ExperimentGradeItem> items)
{
if (record.SafetyViolation ||
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
return 0;
if (record.ParticipationStatus is
ExperimentParticipationStatus.Pending or
ExperimentParticipationStatus.Excused or
ExperimentParticipationStatus.Exempt)
return null;
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
decimal total = 0;
foreach (var item in items)
{
if (!scores.TryGetValue(item.Id, out var score) ||
!score.Score.HasValue)
return null;
total += score.Score.Value * item.Weight / 100;
}
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
}
}
@@ -0,0 +1,514 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Jiaowu.Api.Controllers;
using SkiaSharp;
using A = DocumentFormat.OpenXml.Drawing;
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
using W = DocumentFormat.OpenXml.Wordprocessing;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class GradeAnalysisWordReportGenerator
{
private const string Blue = "2E74B5";
private const string DarkBlue = "1F4D78";
private const string Ink = "263238";
private const string Muted = "68707A";
private const string LightFill = "F2F4F7";
private const int ContentWidth = 9360;
public static byte[] Generate(
GradeAnalyticsController.TeachingClassAnalysisReport report,
DateTime generatedAt)
{
ArgumentNullException.ThrowIfNull(report.Summary);
using var stream = new MemoryStream();
using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true))
{
document.PackageProperties.Title = $"{report.CourseName}成绩分析报告";
document.PackageProperties.Subject = "教学班成绩统计与对比分析";
document.PackageProperties.Creator = "教务管理系统";
document.PackageProperties.Created = generatedAt;
var mainPart = document.AddMainDocumentPart();
mainPart.Document = new Document(new Body());
var settingsPart = mainPart.AddNewPart<DocumentSettingsPart>();
settingsPart.Settings = new Settings(new EvenAndOddHeaders());
settingsPart.Settings.Save();
AddStyles(mainPart);
var headerFooterIds = AddHeaderAndFooter(mainPart);
BuildBody(mainPart, report, generatedAt, headerFooterIds);
mainPart.Document.Save();
}
return stream.ToArray();
}
private static void BuildBody(
MainDocumentPart mainPart,
GradeAnalyticsController.TeachingClassAnalysisReport report,
DateTime generatedAt,
HeaderFooterIds headerFooterIds)
{
var body = mainPart.Document?.Body
?? throw new InvalidOperationException("The report document body has not been initialized.");
var summary = report.Summary!;
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220));
body.Append(MetadataTable([
("课程", $"{report.CourseCode} {report.CourseName}"),
("教学班", $"{report.TaskNumber} {report.TaskName}"),
("学期", report.TermName),
("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")),
("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")),
("统计对象", $"{summary.StudentCount} 份已发布有效成绩")
]));
body.Append(Heading("一、分析摘要", 1));
body.Append(Callout(BuildExecutiveSummary(report)));
body.Append(MetricsTable(summary));
body.Append(Heading("二、分数段分布", 1));
body.Append(Paragraph("图 1 当前教学班各分数段人数", 20, false, Muted, 80, 80));
body.Append(ImageParagraph(mainPart, DrawScoreBands(summary.ScoreBands), "分数段分布图", 6.3, 3.0));
body.Append(DataTable(
["分数段", "下限", "上限", "人数", "占比"],
summary.ScoreBands.Select(x => new[]
{
x.Label,
x.LowerBound.ToString("0.#"),
x.UpperBound?.ToString("0.#") ?? "无上限",
x.StudentCount.ToString(),
Percent(x.StudentCount, summary.StudentCount)
}),
[1800, 1500, 1500, 1500, 3060]));
body.Append(Heading("三、同课程教学班对比", 1));
body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80));
var peerChartHeight = Math.Clamp(1.45 + report.PeerTeachingClasses.Count * 0.32, 1.8, 3.35);
body.Append(ImageParagraph(mainPart, DrawPeerAverages(report.PeerTeachingClasses), "教学班平均分对比图", 6.3, peerChartHeight));
body.Append(DataTable(
["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"],
report.PeerTeachingClasses.Select(x => new[]
{
$"{x.TaskNumber}{(x.IsSelected ? "" : "")}\n{x.TeacherNames}",
x.StudentCount.ToString(),
Score(x.AverageScore),
Score(x.MedianScore),
x.StandardDeviation.ToString("0.00"),
Rate(x.PassRate),
Rate(x.ExcellentRate)
}),
[2600, 820, 1050, 1050, 1050, 1395, 1395]));
body.Append(Heading("四、各范围基准", 1));
body.Append(Paragraph("范围基准按当前课程、当前学期聚合;同一教学班包含多个来源行政班时,将分别列示可用基准。", 22, false, Muted, 0, 100));
body.Append(DataTable(
["范围", "对象", "人数", "最高分", "平均分", "最低分", "合格率"],
report.ScopeBenchmarks.Select(x => new[]
{
x.Scope, x.Name, x.StudentCount.ToString(), Score(x.HighestScore),
Score(x.AverageScore), Score(x.LowestScore), Rate(x.PassRate)
}),
[980, 2200, 900, 1200, 1200, 1200, 1680]));
body.Append(Heading("五、历年成绩趋势", 1));
body.Append(Paragraph("图 3 同课程全校与当前任课教师历年平均分", 20, false, Muted, 80, 80));
body.Append(ImageParagraph(mainPart, DrawHistory(report.History), "历年平均分趋势图", 6.3, 3.15));
body.Append(DataTable(
["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"],
report.History.Select(x => new[]
{
x.TermName,
x.CourseStudentCount.ToString(),
Score(x.CourseAverageScore),
Rate(x.CoursePassRate),
x.Instructor?.StudentCount.ToString() ?? "—",
x.Instructor is null ? "—" : Score(x.Instructor.AverageScore),
x.Instructor is null ? "—" : Rate(x.Instructor.PassRate)
}),
[1700, 1050, 1200, 1450, 1050, 1200, 1710]));
body.Append(Heading("六、统计口径与使用说明", 1));
body.Append(Paragraph("1. 本报告仅统计已正式发布且纳入当前统计任务的有效成绩,不包含草稿、未发布成绩或学生逐人成绩明细。", 22, false, Ink, 0, 80));
body.Append(Paragraph("2. 合格率按成绩达到 60 分计算,优秀率按成绩达到 90 分计算;平均分、中位数和标准差均基于同一批有效成绩。", 22, false, Ink, 0, 80));
body.Append(Paragraph("3. 同课程教学班对比限定为当前学期;历年对比同时展示课程全校口径和当前任课教师所带教学班的加权汇总。", 22, false, Ink, 0, 80));
body.Append(Paragraph("4. 统计结果用于教学诊断和质量改进,不应脱离样本量、课程难度、考核方式等背景作单一排名或评价。", 22, false, Ink, 0, 80));
body.Append(new SectionProperties(
new HeaderReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultHeader },
new HeaderReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenHeader },
new FooterReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultFooter },
new FooterReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenFooter },
new PageSize { Width = 12240, Height = 15840 },
new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 708, Footer = 708 }));
}
private static string BuildExecutiveSummary(GradeAnalyticsController.TeachingClassAnalysisReport report)
{
var summary = report.Summary!;
var band = summary.ScoreBands.OrderByDescending(x => x.StudentCount).FirstOrDefault();
var parts = new List<string>
{
$"本教学班共纳入 {summary.StudentCount} 份有效成绩,平均分 {Score(summary.AverageScore)},中位数 {Score(summary.MedianScore)},合格率 {Rate(summary.PassRate)},优秀率 {Rate(summary.ExcellentRate)}。"
};
if (band is not null)
parts.Add($"人数最多的分数段为 {band.Label},共 {band.StudentCount} 人,占 {Percent(band.StudentCount, summary.StudentCount)}。 ");
if (report.UniversityDelta is { } delta)
parts.Add($"与本学期全校同课程相比,平均分{Direction(delta.AverageScoreDifference, "")},合格率{Direction(delta.PassRateDifference, "")}。 ");
parts.Add($"成绩标准差为 {summary.StandardDeviation:0.00},分数范围 {Score(summary.LowestScore)}{Score(summary.HighestScore)}。建议结合分数段、同课程教学班和历年趋势综合研判。 ");
return string.Concat(parts);
}
private static string Direction(decimal value, string unit) =>
value > 0 ? $"高 {value:0.0} {unit}" : value < 0 ? $"低 {Math.Abs(value):0.0} {unit}" : "持平";
private static W.Table MetadataTable(IEnumerable<(string Label, string Value)> items)
{
var rows = items.Select(x => new[] { x.Label, x.Value });
return DataTable(["项目", "内容"], rows, [1800, 7560], false);
}
private static W.Table MetricsTable(GradeAnalyticsController.TeachingClassMetrics value)
{
return DataTable(
["指标", "结果", "指标", "结果"],
[
["最高分", Score(value.HighestScore), "最低分", Score(value.LowestScore)],
["平均分", Score(value.AverageScore), "中位数", Score(value.MedianScore)],
["合格人数", $"{value.PassedCount} 人", "合格率", Rate(value.PassRate)],
["优秀人数", $"{value.ExcellentCount} 人", "优秀率", Rate(value.ExcellentRate)],
["标准差", value.StandardDeviation.ToString("0.00"), "有效成绩", $"{value.StudentCount} 份"]
],
[1800, 2880, 1800, 2880]);
}
private static W.Table DataTable(
IReadOnlyList<string> headers,
IEnumerable<string[]> rows,
IReadOnlyList<int> widths,
bool shadeHeader = true)
{
var table = new W.Table();
table.Append(new TableProperties(
new TableWidth { Width = ContentWidth.ToString(), Type = TableWidthUnitValues.Dxa },
new TableIndentation { Width = 120, Type = TableWidthUnitValues.Dxa },
new TableLayout { Type = TableLayoutValues.Fixed },
new TableBorders(
Border<TopBorder>(), Border<LeftBorder>(), Border<BottomBorder>(),
Border<RightBorder>(), Border<InsideHorizontalBorder>(), Border<InsideVerticalBorder>()),
new TableCellMarginDefault(
new TopMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
new TableCellLeftMargin { Width = 120, Type = TableWidthValues.Dxa },
new BottomMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
new TableCellRightMargin { Width = 120, Type = TableWidthValues.Dxa })));
table.Append(new TableGrid(widths.Select(x => new GridColumn { Width = x.ToString() })));
table.Append(Row(headers, widths, shadeHeader ? LightFill : "FFFFFF", true, true));
foreach (var row in rows)
table.Append(Row(row, widths, "FFFFFF", false, false));
return table;
}
private static TableRow Row(
IReadOnlyList<string> values,
IReadOnlyList<int> widths,
string fill,
bool bold,
bool repeat)
{
var row = new TableRow();
if (repeat) row.AppendChild(new TableRowProperties(new TableHeader()));
for (var i = 0; i < widths.Count; i++)
{
var cell = new TableCell();
cell.Append(new TableCellProperties(
new TableCellWidth { Width = widths[i].ToString(), Type = TableWidthUnitValues.Dxa },
new Shading { Fill = fill, Val = ShadingPatternValues.Clear }));
var lines = (i < values.Count ? values[i] : "").Split('\n');
foreach (var line in lines)
cell.Append(Paragraph(line, 19, bold, Ink, 0, 0));
row.Append(cell);
}
return row;
}
private static T Border<T>() where T : BorderType, new() =>
new() { Val = BorderValues.Single, Color = "D6DBE1", Size = 4 };
private static W.Table Callout(string text)
{
return DataTable(["核心结论"], [[text]], [ContentWidth]);
}
private static Paragraph Heading(string text, int level)
{
var paragraph = new Paragraph(new ParagraphProperties(new ParagraphStyleId { Val = $"Heading{level}" }));
paragraph.Append(Run(text, level == 1 ? 32 : 26, true, level == 1 ? Blue : DarkBlue));
return paragraph;
}
private static Paragraph Paragraph(
string text,
int size,
bool bold,
string color,
int before,
int after)
{
var paragraph = new Paragraph(new ParagraphProperties(
new SpacingBetweenLines { Before = before.ToString(), After = after.ToString(), Line = "264", LineRule = LineSpacingRuleValues.Auto }));
paragraph.Append(Run(text, size, bold, color));
return paragraph;
}
private static Run Run(string text, int size, bool bold, string color)
{
return new Run(
new RunProperties(
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
new Bold { Val = bold },
new Color { Val = color },
new FontSize { Val = size.ToString() },
new FontSizeComplexScript { Val = size.ToString() }),
new Text(text) { Space = SpaceProcessingModeValues.Preserve });
}
private static Paragraph ImageParagraph(
MainDocumentPart mainPart,
byte[] image,
string description,
double widthInches,
double heightInches)
{
var part = mainPart.AddImagePart(ImagePartType.Png);
using (var stream = new MemoryStream(image)) part.FeedData(stream);
var relationshipId = mainPart.GetIdOfPart(part);
var width = (long)(widthInches * 914400L);
var height = (long)(heightInches * 914400L);
var drawing = new W.Drawing(
new DW.Inline(
new DW.Extent { Cx = width, Cy = height },
new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 },
new DW.DocProperties { Id = (UInt32Value)(uint)(mainPart.ImageParts.Count()), Name = description, Description = description },
new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }),
new A.Graphic(new A.GraphicData(
new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties { Id = 0, Name = description, Description = description },
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip { Embed = relationshipId, CompressionState = A.BlipCompressionValues.Print },
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset { X = 0, Y = 0 },
new A.Extents { Cx = width, Cy = height }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })))
{ Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" }))
{ DistanceFromTop = 0, DistanceFromBottom = 0, DistanceFromLeft = 0, DistanceFromRight = 0 });
var paragraph = new Paragraph(new ParagraphProperties(
new Justification { Val = JustificationValues.Center },
new SpacingBetweenLines { Before = "0", After = "120" }));
paragraph.Append(new Run(drawing));
return paragraph;
}
private static void AddStyles(MainDocumentPart mainPart)
{
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
var normal = new Style(
new StyleName { Val = "Normal" },
new StyleRunProperties(
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
new FontSize { Val = "22" }, new Color { Val = Ink }),
new StyleParagraphProperties(
new SpacingBetweenLines { Before = "0", After = "120", Line = "264", LineRule = LineSpacingRuleValues.Auto }))
{ Type = StyleValues.Paragraph, StyleId = "Normal", Default = true };
var h1 = new Style(
new StyleName { Val = "heading 1" },
new BasedOn { Val = "Normal" },
new NextParagraphStyle { Val = "Normal" },
new StyleRunProperties(new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, new Bold(), new Color { Val = Blue }, new FontSize { Val = "32" }),
new StyleParagraphProperties(new KeepNext(), new SpacingBetweenLines { Before = "320", After = "160" }))
{ Type = StyleValues.Paragraph, StyleId = "Heading1" };
stylesPart.Styles = new Styles(normal, h1);
stylesPart.Styles.Save();
}
private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart)
{
var defaultHeader = mainPart.AddNewPart<HeaderPart>();
defaultHeader.Header = CreateHeader();
defaultHeader.Header.Save();
var evenHeader = mainPart.AddNewPart<HeaderPart>();
evenHeader.Header = CreateHeader();
evenHeader.Header.Save();
var defaultFooter = mainPart.AddNewPart<FooterPart>();
defaultFooter.Footer = CreateFooter();
defaultFooter.Footer.Save();
var evenFooter = mainPart.AddNewPart<FooterPart>();
evenFooter.Footer = CreateFooter();
evenFooter.Footer.Save();
return new HeaderFooterIds(
mainPart.GetIdOfPart(defaultHeader),
mainPart.GetIdOfPart(evenHeader),
mainPart.GetIdOfPart(defaultFooter),
mainPart.GetIdOfPart(evenFooter));
}
private static Header CreateHeader() =>
new(Paragraph("成绩分析报告 | 教务管理系统", 18, false, Muted, 0, 0));
private static Footer CreateFooter()
{
var footerParagraph = new Paragraph(new ParagraphProperties(new Justification { Val = JustificationValues.Right }));
footerParagraph.Append(Run("第 ", 18, false, Muted));
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
footerParagraph.Append(new Run(new FieldCode(" PAGE ")));
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
footerParagraph.Append(Run(" 页", 18, false, Muted));
return new Footer(footerParagraph);
}
private static byte[] DrawScoreBands(IReadOnlyList<GradeAnalyticsController.ScoreBand> rows)
{
return DrawChart(1200, 540, (canvas, typeface) =>
{
DrawAxes(canvas, typeface, "人数", 70, 35, 1080, 430);
var max = Math.Max(1, rows.Max(x => x.StudentCount));
var barWidth = 150f;
var gap = (1000f - rows.Count * barWidth) / Math.Max(1, rows.Count);
for (var i = 0; i < rows.Count; i++)
{
var x = 105 + gap / 2 + i * (barWidth + gap);
var height = rows[i].StudentCount / (float)max * 330;
using var paint = new SKPaint { Color = new SKColor(46, 116, 181), IsAntialias = true };
canvas.DrawRoundRect(new SKRect(x, 430 - height, x + barWidth, 430), 8, 8, paint);
DrawText(canvas, typeface, rows[i].StudentCount.ToString(), x + barWidth / 2, 415 - height, 24, Ink, SKTextAlign.Center, true);
DrawText(canvas, typeface, rows[i].Label, x + barWidth / 2, 475, 18, Muted, SKTextAlign.Center);
}
});
}
private static byte[] DrawPeerAverages(IReadOnlyList<GradeAnalyticsController.TeachingClassComparison> rows)
{
var visible = rows.Take(8).ToArray();
var height = Math.Max(250, 120 + visible.Length * 62);
return DrawChart(1200, height, (canvas, typeface) =>
{
var top = 55f;
var rowHeight = 62f;
DrawText(canvas, typeface, "0", 280, 40, 20, Muted, SKTextAlign.Center);
DrawText(canvas, typeface, "50", 700, 40, 20, Muted, SKTextAlign.Center);
DrawText(canvas, typeface, "100", 1120, 40, 20, Muted, SKTextAlign.Center);
for (var i = 0; i < visible.Length; i++)
{
var y = top + i * rowHeight;
var value = Math.Clamp((float)visible[i].AverageScore, 0, 100);
DrawText(canvas, typeface, visible[i].TaskNumber, 245, y + 28, 21, visible[i].IsSelected ? Blue : Ink, SKTextAlign.Right, visible[i].IsSelected);
using var track = new SKPaint { Color = new SKColor(235, 239, 244) };
using var fill = new SKPaint { Color = visible[i].IsSelected ? new SKColor(46, 116, 181) : new SKColor(155, 177, 202) };
canvas.DrawRoundRect(new SKRect(280, y, 1120, y + 34), 6, 6, track);
canvas.DrawRoundRect(new SKRect(280, y, 280 + value / 100 * 840, y + 34), 6, 6, fill);
DrawText(canvas, typeface, value.ToString("0.0"), 290 + value / 100 * 840, y + 27, 20, Ink, SKTextAlign.Left, true);
}
});
}
private static byte[] DrawHistory(IReadOnlyList<GradeAnalyticsController.HistoricalComparison> rows)
{
return DrawChart(1200, 570, (canvas, typeface) =>
{
DrawAxes(canvas, typeface, "平均分", 70, 35, 1080, 430);
if (rows.Count == 0) return;
var min = 0f;
var max = 100f;
var points = rows.Select((x, i) => new SKPoint(
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
430 - ((float)x.CourseAverageScore - min) / (max - min) * 350)).ToArray();
DrawLineSeries(canvas, typeface, points, rows.Select(x => x.CourseAverageScore).ToArray(), new SKColor(46, 116, 181));
var teacher = rows.Select((x, i) => x.Instructor is null ? (SKPoint?)null : new SKPoint(
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
430 - ((float)x.Instructor.AverageScore - min) / (max - min) * 350)).ToArray();
DrawOptionalLineSeries(canvas, typeface, teacher, rows.Select(x => x.Instructor?.AverageScore).ToArray(), new SKColor(211, 133, 45));
for (var i = 0; i < rows.Count; i++)
DrawText(canvas, typeface, rows[i].TermName, points[i].X, 480, 20, Muted, SKTextAlign.Center);
using var blue = new SKPaint { Color = new SKColor(46, 116, 181), StrokeWidth = 4 };
using var gold = new SKPaint { Color = new SKColor(211, 133, 45), StrokeWidth = 4 };
canvas.DrawLine(760, 520, 805, 520, blue);
canvas.DrawLine(940, 520, 985, 520, gold);
DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink);
DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink);
});
}
private static byte[] DrawChart(int width, int height, Action<SKCanvas, SKTypeface> draw)
{
using var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.White);
using var typeface = SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.Default;
draw(canvas, typeface);
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 92);
return data.ToArray();
}
private static void DrawAxes(SKCanvas canvas, SKTypeface typeface, string label, float left, float top, float right, float bottom)
{
using var axis = new SKPaint { Color = new SKColor(190, 198, 207), StrokeWidth = 2 };
canvas.DrawLine(left, bottom, right, bottom, axis);
canvas.DrawLine(left, top, left, bottom, axis);
DrawText(canvas, typeface, label, left, 25, 21, Muted);
}
private static void DrawLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint[] points, decimal[] values, SKColor color)
{
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
using var fill = new SKPaint { Color = color, IsAntialias = true };
using var builder = new SKPathBuilder();
builder.MoveTo(points[0]);
foreach (var point in points.Skip(1)) builder.LineTo(point);
using var path = builder.Detach();
canvas.DrawPath(path, paint);
for (var i = 0; i < points.Length; i++)
{
canvas.DrawCircle(points[i], 7, fill);
DrawText(canvas, typeface, values[i].ToString("0.0"), points[i].X, points[i].Y - 14, 19, Ink, SKTextAlign.Center, true);
}
}
private static void DrawOptionalLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint?[] points, decimal?[] values, SKColor color)
{
var available = points.Select((point, index) => (point, index)).Where(x => x.point.HasValue).ToArray();
if (available.Length == 0) return;
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
using var fill = new SKPaint { Color = color, IsAntialias = true };
for (var i = 1; i < available.Length; i++) canvas.DrawLine(available[i - 1].point!.Value, available[i].point!.Value, paint);
foreach (var item in available)
{
var point = item.point!.Value;
canvas.DrawCircle(point, 7, fill);
DrawText(canvas, typeface, values[item.index]!.Value.ToString("0.0"), point.X, point.Y + 28, 19, Ink, SKTextAlign.Center, true);
}
}
private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, float size, string color, SKTextAlign align = SKTextAlign.Left, bool bold = false)
{
using var font = new SKFont(typeface, size) { Embolden = bold };
using var paint = new SKPaint { Color = SKColor.Parse(color), IsAntialias = true };
canvas.DrawText(text, x, y, align, font, paint);
}
private static string Score(decimal value) => value.ToString("0.0");
private static string Rate(decimal value) => $"{value:0.0}%";
private static string Percent(int value, int total) => total == 0 ? "0.0%" : $"{(decimal)value / total * 100m:0.0}%";
private sealed record HeaderFooterIds(
string DefaultHeader,
string EvenHeader,
string DefaultFooter,
string EvenFooter);
}
@@ -0,0 +1,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,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,9 +22,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<Student> Students => Set<Student>(); public DbSet<Student> Students => Set<Student>();
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>(); public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
public DbSet<Course> Courses => Set<Course>(); public DbSet<Course> Courses => Set<Course>();
public DbSet<CoursePrerequisite> CoursePrerequisites => Set<CoursePrerequisite>();
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>(); public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>(); public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>(); public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
public DbSet<CourseGroup> CourseGroups => Set<CourseGroup>();
public DbSet<CourseGroupCourse> CourseGroupCourses => Set<CourseGroupCourse>();
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>(); public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>(); public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>(); public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
@@ -32,17 +35,33 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeacherCourseApplication>(); Set<TeacherCourseApplication>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>(); public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>(); public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<PublishedScheduleOccurrence> PublishedScheduleOccurrences => Set<PublishedScheduleOccurrence>();
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>(); public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints => public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
Set<TeachingTaskScheduleConstraint>(); Set<TeachingTaskScheduleConstraint>();
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms => public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
Set<TeachingTaskAllowedClassroom>(); Set<TeachingTaskAllowedClassroom>();
public DbSet<TeachingTaskAllowedExperimentClassroom> TeachingTaskAllowedExperimentClassrooms =>
Set<TeachingTaskAllowedExperimentClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs => public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>(); Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs => public DbSet<SchedulePublishJob> SchedulePublishJobs =>
Set<SchedulePublishJob>(); Set<SchedulePublishJob>();
public DbSet<ClassroomReservation> ClassroomReservations => public DbSet<ClassroomReservation> ClassroomReservations =>
Set<ClassroomReservation>(); Set<ClassroomReservation>();
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
Set<ExperimentGradeSheet>();
public DbSet<ExperimentGradeItem> ExperimentGradeItems =>
Set<ExperimentGradeItem>();
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
Set<ExperimentGradeRecord>();
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
Set<ExperimentCourseGrade>();
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>(); Set<CourseSelectionRound>();
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades => public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
@@ -54,12 +73,35 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>(); public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<GradeItem> GradeItems => Set<GradeItem>(); public DbSet<GradeItem> GradeItems => Set<GradeItem>();
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>(); public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
Set<CourseGradeStatistic>();
public DbSet<TeachingTaskGradeStatistic> TeachingTaskGradeStatistics =>
Set<TeachingTaskGradeStatistic>();
public DbSet<TeachingTaskGradeScoreBand> TeachingTaskGradeScoreBands =>
Set<TeachingTaskGradeScoreBand>();
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
Set<CourseGradeStatisticsRefreshJob>();
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>(); public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>(); public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
Set<AttendanceCheckInAttempt>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>(); public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
Set<ExamArrangementJob>();
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
Set<ExamSignInExportJob>();
public DbSet<ExamPublishJob> ExamPublishJobs =>
Set<ExamPublishJob>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>(); public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators => public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>(); Set<ExamSessionInvigilator>();
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
Set<ExamRoomInvigilator>();
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>(); public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>(); public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators => public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
@@ -100,6 +142,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages => public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
Set<BackgroundJobOutboxMessage>(); Set<BackgroundJobOutboxMessage>();
public DbSet<AppUpdateRelease> AppUpdateReleases =>
Set<AppUpdateRelease>();
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
Set<SystemFeatureSetting>();
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
Set<CourseGradeStatisticsRefreshSetting>();
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
protected override void ConfigureConventions( protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder) ModelConfigurationBuilder configurationBuilder)
@@ -117,6 +166,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
configurationBuilder.Properties<TimeOnly>() configurationBuilder.Properties<TimeOnly>()
.HaveConversion<TimeOnlyTimeSpanConverter>() .HaveConversion<TimeOnlyTimeSpanConverter>()
.HaveColumnType("time"); .HaveColumnType("time");
// MySQL DATETIME has no offset or DateTimeKind. All system timestamps
// are persisted as UTC, so restore that contract when materializing
// them. System.Text.Json will then emit the trailing "Z", allowing
// browsers to convert timestamps to the viewer's local time correctly.
configurationBuilder.Properties<DateTime>()
.HaveConversion<UtcDateTimeConverter>()
.HaveColumnType("datetime(6)");
} }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@@ -136,6 +193,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Description).HasMaxLength(100); 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<Campus>(builder);
ConfigureCatalog<College>(builder); ConfigureCatalog<College>(builder);
ConfigureCatalog<Major>(builder); ConfigureCatalog<Major>(builder);
@@ -214,8 +286,25 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{ {
entity.Property(x => x.StudentNumber).HasMaxLength(30); entity.Property(x => x.StudentNumber).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(50); 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.Phone).HasMaxLength(30);
entity.Property(x => x.Email).HasMaxLength(100); 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.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => x.StudentNumber).IsUnique(); entity.HasIndex(x => x.StudentNumber).IsUnique();
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status }); entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
@@ -247,6 +336,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .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 => builder.Entity<CurriculumPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -375,6 +478,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ScheduleEntry>(entity => builder.Entity<ScheduleEntry>(entity =>
{ {
entity.Property(x => x.Kind)
.HasDefaultValue(ScheduleEntryKind.Lecture)
.HasSentinel((ScheduleEntryKind)0);
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new entity.HasIndex(x => new
{ {
@@ -422,6 +528,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.WithMany() .WithMany()
.HasForeignKey(x => x.RequiredBuildingId) .HasForeignKey(x => x.RequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredCampus)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredCampusId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredBuilding)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<TeachingTaskAllowedClassroom>(entity => builder.Entity<TeachingTaskAllowedClassroom>(entity =>
@@ -441,6 +555,57 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<CourseGroup>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(100);
entity.Property(x => x.Description).HasMaxLength(500);
entity.HasIndex(x => x.Code).IsUnique();
});
builder.Entity<CourseGroupCourse>(entity =>
{
entity.HasIndex(x => new { x.CourseGroupId, x.CourseId }).IsUnique();
entity.HasOne(x => x.CourseGroup)
.WithMany(x => x.Courses)
.HasForeignKey(x => x.CourseGroupId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course)
.WithMany()
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<PublishedScheduleOccurrence>(entity =>
{
entity.HasIndex(x => new { x.AcademicTermId, x.TeachingTaskId, x.Week });
entity.HasIndex(x => new
{
x.AcademicTermId,
x.Week,
x.DayOfWeek,
x.StartPeriod,
x.ClassroomId
});
entity.HasIndex(x => new { x.SchedulePlanId, x.ClassroomId, x.Week, x.DayOfWeek, x.StartPeriod });
entity.HasIndex(x => new { x.ScheduleEntryId, x.Week }).IsUnique();
entity.HasOne(x => x.ScheduleEntry).WithMany().HasForeignKey(x => x.ScheduleEntryId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany().HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany().HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<TeachingTaskAllowedExperimentClassroom>(entity =>
{
entity.HasKey(x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
entity.HasOne(x => x.TeachingTaskScheduleConstraint)
.WithMany(x => x.AllowedExperimentClassrooms)
.HasForeignKey(x => x.TeachingTaskScheduleConstraintId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AutomaticScheduleJob>(entity => builder.Entity<AutomaticScheduleJob>(entity =>
{ {
entity.Property(x => x.ErrorMessage).HasMaxLength(2000); entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
@@ -519,6 +684,161 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
}); });
builder.Entity<ExperimentProject>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(40);
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.Property(x => x.Requirements).HasMaxLength(1000);
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
entity.HasIndex(x => new
{
x.TeachingTaskId,
x.Code,
x.ScheduleEntryId,
x.ScheduleWeek
})
.IsUnique();
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasIndex(x => x.ScheduleEntryId);
entity.HasOne(x => x.ScheduleEntry).WithMany()
.HasForeignKey(x => x.ScheduleEntryId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentSession>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.ExperimentProjectId,
x.SessionDate,
x.StartPeriod
});
entity.HasIndex(x => new
{
x.ClassroomId,
x.SessionDate,
x.Status,
x.StartPeriod
});
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentBooking>(entity =>
{
entity.HasIndex(x => new { x.ExperimentProjectId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new
{
x.ExperimentSessionId,
x.Status,
x.BookedAt
});
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Bookings)
.HasForeignKey(x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Bookings)
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeSheet>(entity =>
{
entity.Property(x => x.ContributionWeight).HasPrecision(5, 1);
entity.Property(x => x.PassScore).HasPrecision(5, 1);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => x.ExperimentProjectId).IsUnique();
entity.HasOne(x => x.ExperimentProject)
.WithOne(x => x.GradeSheet)
.HasForeignKey<ExperimentGradeSheet>(
x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeItem>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.SortOrder
});
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Items)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeRecord>(entity =>
{
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
entity.Property(x => x.SubmissionReference).HasMaxLength(500);
entity.Property(x => x.TeacherComment).HasMaxLength(500);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.StudentId
}).IsUnique();
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Records)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentSession).WithMany()
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentCourseGrade>(entity =>
{
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
entity.Property(x => x.TotalWeight).HasPrecision(8, 1);
entity.HasIndex(x => new { x.TeachingTaskId, x.StudentId })
.IsUnique();
entity.HasIndex(x => x.StudentId);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity =>
{
entity.HasKey(x => new
{
x.ExperimentGradeRecordId,
x.ExperimentGradeItemId
});
entity.Property(x => x.Score).HasPrecision(5, 1);
entity.Property(x => x.Comment).HasMaxLength(300);
entity.HasOne(x => x.ExperimentGradeRecord)
.WithMany(x => x.ItemScores)
.HasForeignKey(x => x.ExperimentGradeRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentGradeItem)
.WithMany(x => x.Scores)
.HasForeignKey(x => x.ExperimentGradeItemId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseSelectionRound>(entity => builder.Entity<CourseSelectionRound>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -598,6 +918,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{ {
entity.Property(x => x.Name).HasMaxLength(60); entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1); entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.Property(x => x.SourceType)
.HasDefaultValue(GradeItemSourceType.Manual)
.HasSentinel((GradeItemSourceType)0);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder }); entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet) entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items) .WithMany(x => x.Items)
@@ -638,6 +961,72 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<CourseGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.HasIndex(x => new
{
x.CourseId, x.AcademicTermId, x.Scope, x.ScopeEntityId
}).IsUnique().HasDatabaseName("UX_CourseGradeStatistics_Scope");
entity.HasIndex(x => new { x.AcademicTermId, x.Scope, x.ScopeEntityId });
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeStatistic>(entity =>
{
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
entity.Property(x => x.MedianScore).HasPrecision(5, 1);
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
entity.Property(x => x.StandardDeviation).HasPrecision(6, 2);
entity.Property(x => x.PassRate).HasPrecision(5, 2);
entity.Property(x => x.ExcellentRate).HasPrecision(5, 2);
entity.HasIndex(x => x.GradeSheetId).IsUnique();
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
entity.HasIndex(x => new { x.CourseId, x.AcademicTermId });
entity.HasOne(x => x.GradeSheet).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithOne()
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskGradeScoreBand>(entity =>
{
entity.Property(x => x.Label).HasMaxLength(30);
entity.Property(x => x.LowerBound).HasPrecision(5, 1);
entity.Property(x => x.UpperBound).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.TeachingTaskGradeStatisticId,
x.SortOrder
}).IsUnique();
entity.HasOne(x => x.TeachingTaskGradeStatistic)
.WithMany(x => x.ScoreBands)
.HasForeignKey(x => x.TeachingTaskGradeStatisticId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.GradeSheetId);
entity.HasOne<GradeSheet>().WithMany().HasForeignKey(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AttendanceSheet>(entity => builder.Entity<AttendanceSheet>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -672,6 +1061,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<AttendanceCheckInAttempt>(entity =>
{
entity.Property(x => x.FailureCode).HasMaxLength(64);
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.Property(x => x.UserAgent).HasMaxLength(500);
entity.Property(x => x.RiskFlags).HasMaxLength(300);
entity.Property(x => x.Latitude).HasPrecision(10, 7);
entity.Property(x => x.Longitude).HasPrecision(10, 7);
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.CheckInAttempts)
.HasForeignKey(x => x.AttendanceSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamPlan>(entity => builder.Entity<ExamPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -682,11 +1094,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
}); });
builder.Entity<ExamSession>(entity => builder.Entity<ExamSession>(entity =>
{ {
// Exam slot times are China-local wall-clock times, not instants.
// Keep their existing API representation offset-free.
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }); entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate }); entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId); entity.HasIndex(x => x.RequiredBuildingId);
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions) entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany() entity.HasOne(x => x.TeachingTask).WithMany()
@@ -704,6 +1121,89 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany() entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExamRoomAssignment>(entity =>
{
entity.ToTable("ExamRooms");
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
.HasDatabaseName("IX_ExamRooms_Plan_Time");
entity.HasIndex(x => new
{
x.ExamPlanId,
x.ClassroomId,
x.StartsAt
})
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
entity.HasIndex(x => x.CourseId)
.HasDatabaseName("IX_ExamRooms_CourseId");
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course).WithMany()
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomSession>(entity =>
{
entity.ToTable("ExamRoomSessions");
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
entity.HasIndex(x => x.ExamSessionId)
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSeatAssignment>(entity =>
{
entity.ToTable("ExamSeats");
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
.IsUnique()
.HasDatabaseName("UX_ExamSeats_Session_Student");
entity.HasIndex(x => x.StudentId)
.HasDatabaseName("IX_ExamSeats_StudentId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomInvigilator>(entity =>
{
entity.ToTable("ExamRoomInvigilators");
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
entity.HasIndex(x => x.TeacherId)
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamArrangementJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
.IsUnique()
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
});
builder.Entity<ExamSignInExportJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.FileName).HasMaxLength(200);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
});
builder.Entity<MakeupExamPlan>(entity => builder.Entity<MakeupExamPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -714,11 +1214,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
}); });
builder.Entity<MakeupExamSession>(entity => builder.Entity<MakeupExamSession>(entity =>
{ {
// Makeup-exam slot times follow the same wall-clock convention.
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt }); entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate }); entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId); entity.HasIndex(x => x.RequiredBuildingId);
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions) entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany() entity.HasOne(x => x.TeachingTask).WithMany()
@@ -875,6 +1379,27 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.GradeRecord).WithMany() entity.HasOne(x => x.GradeRecord).WithMany()
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<OtherExamBatch>(entity =>
{
entity.Property(x => x.ExamCode).HasMaxLength(60);
entity.Property(x => x.Name).HasMaxLength(150);
entity.Property(x => x.Organizer).HasMaxLength(150);
entity.Property(x => x.LevelOptions).HasMaxLength(500);
entity.Property(x => x.MaxScore).HasPrecision(8, 2);
entity.HasIndex(x => new { x.Status, x.ExamDate });
});
builder.Entity<OtherExamResult>(entity =>
{
entity.Property(x => x.Score).HasPrecision(8, 2);
entity.Property(x => x.Level).HasMaxLength(50);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.OtherExamBatchId, x.StudentId, x.AttemptNumber }).IsUnique();
entity.HasIndex(x => new { x.StudentId, x.OtherExamBatchId });
entity.HasOne(x => x.OtherExamBatch).WithMany(x => x.Results)
.HasForeignKey(x => x.OtherExamBatchId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<WarningRule>(entity => builder.Entity<WarningRule>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(100); entity.Property(x => x.Name).HasMaxLength(100);
@@ -965,10 +1490,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<Notification>(entity => builder.Entity<Notification>(entity =>
{ {
entity.Property(x => x.Title).HasMaxLength(200); 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.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead }); // Each inbox query starts with its recipient. Keep the selected
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt }); // sort fields in the index so large inboxes do not need a filesort.
entity.HasIndex(x => new { x.UserId, x.CreatedAt, x.Id });
entity.HasIndex(x => new { x.UserId, x.IsRead, x.CreatedAt, x.Id });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt, x.Id });
entity.HasIndex(x => x.MessageDispatchId); entity.HasIndex(x => x.MessageDispatchId);
entity.HasIndex(x => x.CreatedAt); entity.HasIndex(x => x.CreatedAt);
entity.HasOne(x => x.MessageDispatch) entity.HasOne(x => x.MessageDispatch)
@@ -981,7 +1509,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{ {
entity.Property(x => x.SenderName).HasMaxLength(100); entity.Property(x => x.SenderName).HasMaxLength(100);
entity.Property(x => x.Title).HasMaxLength(200); 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.AudienceName).HasMaxLength(200); entity.Property(x => x.AudienceName).HasMaxLength(200);
entity.Property(x => x.LinkUrl).HasMaxLength(300); entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt }); entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
@@ -1005,6 +1533,54 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.LeaseExpiresAt); entity.HasIndex(x => x.LeaseExpiresAt);
}); });
builder.Entity<AppUpdateRelease>(entity =>
{
entity.Property(x => x.Platform)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Channel)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(20);
entity.Property(x => x.Version).HasMaxLength(40);
entity.Property(x => x.NativeVersion).HasMaxLength(40);
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
entity.Property(x => x.FileName).HasMaxLength(180);
entity.Property(x => x.Sha256).HasMaxLength(64);
entity.Property(x => x.BundleContent).HasColumnType("longblob");
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Version
}).IsUnique();
entity.HasIndex(x => new
{
x.Platform,
x.Channel,
x.NativeVersion,
x.Status
});
entity.HasIndex(x => x.CreatedAt);
});
builder.Entity<SystemFeatureSetting>(entity =>
{
entity.Property(x => x.Key).HasMaxLength(100);
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<CourseGradeStatisticsRefreshSetting>(entity =>
{
entity.Property(x => x.Key).HasMaxLength(50);
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<OfficialDocument>(entity => builder.Entity<OfficialDocument>(entity =>
{ {
entity.Property(x => x.DocumentNumber).HasMaxLength(50); entity.Property(x => x.DocumentNumber).HasMaxLength(50);
@@ -1087,3 +1663,15 @@ public sealed class TimeOnlyTimeSpanConverter()
: ValueConverter<TimeOnly, TimeSpan>( : ValueConverter<TimeOnly, TimeSpan>(
time => time.ToTimeSpan(), time => time.ToTimeSpan(),
value => TimeOnly.FromTimeSpan(value)); value => TimeOnly.FromTimeSpan(value));
public sealed class UtcDateTimeConverter()
: ValueConverter<DateTime, DateTime>(
value => value.Kind == DateTimeKind.Local
? value.ToUniversalTime()
: DateTime.SpecifyKind(value, DateTimeKind.Utc),
value => DateTime.SpecifyKind(value, DateTimeKind.Utc));
public sealed class UnspecifiedDateTimeConverter()
: ValueConverter<DateTime, DateTime>(
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified),
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AcademicPlanningPrerequisites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CoursePrerequisites",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
PrerequisiteCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CoursePrerequisites", x => x.Id);
table.ForeignKey(
name: "FK_CoursePrerequisites_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CoursePrerequisites_Courses_PrerequisiteCourseId",
column: x => x.PrerequisiteCourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CoursePrerequisites_CourseId_PrerequisiteCourseId",
table: "CoursePrerequisites",
columns: new[] { "CourseId", "PrerequisiteCourseId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CoursePrerequisites_PrerequisiteCourseId",
table: "CoursePrerequisites",
column: "PrerequisiteCourseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CoursePrerequisites");
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class MessageRichContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "Notifications",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(1000)",
oldMaxLength: 1000);
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "MessageDispatches",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(1000)",
oldMaxLength: 1000);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "Notifications",
type: "varchar(1000)",
maxLength: 1000,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext");
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "MessageDispatches",
type: "varchar(1000)",
maxLength: 1000,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext");
}
}
}

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