Compare commits
@@ -8,9 +8,16 @@ MYSQL_USER=jiaowu
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
|
||||
RABBITMQ_USER=jiaowu
|
||||
RABBITMQ_PASSWORD=
|
||||
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||
BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY=1
|
||||
BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY=1
|
||||
|
||||
JWT_KEY=
|
||||
ALLOWED_HOSTS=localhost
|
||||
CORS_ORIGIN=http://localhost:8080
|
||||
OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL=http://localhost:8080
|
||||
|
||||
# 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。
|
||||
SEED_ADMIN_USERNAME=demo-admin
|
||||
|
||||
+88
-1
@@ -6,14 +6,101 @@ Database__Provider=MySql
|
||||
Database__ApplyMigrationsOnStartup=false
|
||||
Database__CommandTimeoutSeconds=30
|
||||
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||
# 仅供备份与隔离恢复演练使用。该账号需要读取业务库,并仅能创建/删除
|
||||
# jiaowu_restore_drill_* 临时库;不要在此复用日常业务账号。
|
||||
# ConnectionStrings__OperationsMySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=OPS_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||
# Redis 是可选加速器;留空时应用仅使用进程内缓存。
|
||||
# ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false"
|
||||
|
||||
# 单机可继续使用 InMemory;多实例生产部署建议启用 RabbitMQ。
|
||||
BackgroundJobs__Transport=InMemory
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
BackgroundJobs__ExamArrangementConcurrency=1
|
||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
# RabbitMq__Port=5671
|
||||
# RabbitMq__UserName=jiaowu
|
||||
# RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||
# RabbitMq__VirtualHost=/jiaowu
|
||||
# RabbitMq__UseTls=true
|
||||
# RabbitMq__TlsServerName=rabbitmq.example.edu.cn
|
||||
|
||||
Cache__Enabled=true
|
||||
Cache__KeyPrefix=jiaowu:v1
|
||||
Cache__ReferenceExpirationMinutes=30
|
||||
Cache__ReferenceLocalExpirationSeconds=120
|
||||
Cache__TimetableExpirationMinutes=10
|
||||
Cache__TimetableLocalExpirationSeconds=30
|
||||
Cache__AnalyticsExpirationMinutes=3
|
||||
Cache__AnalyticsLocalExpirationSeconds=30
|
||||
Cache__MaximumPayloadKilobytes=2048
|
||||
|
||||
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
||||
Observability__IncludeSqlText=false
|
||||
Observability__MaximumSqlTextLength=2000
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
||||
|
||||
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||
PerformanceReporting__Enabled=false
|
||||
# PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||
# PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||
# PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||
PerformanceReporting__CacheSeconds=30
|
||||
PerformanceReporting__TimeoutSeconds=10
|
||||
|
||||
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
||||
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
||||
Operations__BackupWarningHours=24
|
||||
Operations__ToolTimeoutMinutes=30
|
||||
Operations__MySqlDumpPath=mysqldump
|
||||
Operations__MySqlClientPath=mysql
|
||||
# 按实际客户端补充 TLS 参数;Oracle MySQL 客户端示例:
|
||||
# Operations__MySqlAdditionalArguments__0=--ssl-mode=VERIFY_IDENTITY
|
||||
# Operations__MySqlAdditionalArguments__1=--ssl-ca=/etc/jiaowu/mysql-ca.pem
|
||||
|
||||
Jwt__Issuer=Jiaowu.Api
|
||||
Jwt__Audience=Jiaowu.Web
|
||||
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||
Jwt__ExpireMinutes=60
|
||||
Jwt__AccessTokenMinutes=10
|
||||
Jwt__WebIdleMinutes=30
|
||||
Jwt__AppIdleMinutes=4320
|
||||
|
||||
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
|
||||
# https://sso.example.edu.cn/realms/mingxu
|
||||
Sso__Enabled=false
|
||||
# Sso__DisplayName=学校统一身份认证
|
||||
# Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||
# Sso__ClientId=jiaowu-web
|
||||
# Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||
# Sso__UserNameClaim=preferred_username
|
||||
# Sso__RequireHttpsMetadata=true
|
||||
# 首次 SSO 登录优先绑定同名本地账号;用户名不同时由用户输入现有账号密码完成绑定。
|
||||
# 不会自动创建账号或授予角色。
|
||||
# Sso__LinkExistingUsersByUserName=true
|
||||
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
|
||||
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||
# 必须与 Keycloak 客户端的 Valid redirect URI 完全一致。
|
||||
# Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||
|
||||
AllowedHosts=jiaowu.example.edu.cn
|
||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||
Cors__Origins__1=capacitor://localhost
|
||||
Cors__Origins__2=https://localhost
|
||||
Cors__Origins__3=http://localhost
|
||||
|
||||
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
||||
OfficialDocuments__InstitutionName=明序大学
|
||||
OfficialDocuments__IssuingOffice=教务处
|
||||
OfficialDocuments__DocumentNumberPrefix=MXU
|
||||
OfficialDocuments__PublicBaseUrl=https://jiaowu.example.edu.cn
|
||||
# 默认使用系统中文字体;仅在自定义字体部署时填写绝对路径。
|
||||
# OfficialDocuments__FontPath=/usr/share/fonts/noto/NotoSansCJK-Regular.ttc
|
||||
|
||||
# 仅首次创建管理员时临时取消注释,创建成功后立即删除。
|
||||
# SeedAdmin__UserName=admin
|
||||
|
||||
@@ -17,3 +17,4 @@ src/Jiaowu.Api/wwwroot/
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
certs/
|
||||
publish/
|
||||
+9
-1
@@ -30,10 +30,14 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||
ASPNETCORE_HTTP_PORTS=8080 \
|
||||
DOTNET_EnableDiagnostics=0
|
||||
DOTNET_EnableDiagnostics=0 \
|
||||
Operations__BackupDirectory=/var/lib/jiaowu/backups \
|
||||
Operations__MySqlDumpPath=mariadb-dump \
|
||||
Operations__MySqlClientPath=mariadb
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish/ ./
|
||||
ARG UID=10001
|
||||
RUN apk add --no-cache font-noto-cjk mariadb-client
|
||||
RUN adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
@@ -42,5 +46,9 @@ RUN adduser \
|
||||
--no-create-home \
|
||||
--uid "${UID}" \
|
||||
appuser
|
||||
RUN mkdir -p /var/lib/jiaowu/backups \
|
||||
&& chown appuser:appuser /var/lib/jiaowu/backups \
|
||||
&& chmod 700 /var/lib/jiaowu/backups
|
||||
VOLUME ["/var/lib/jiaowu/backups"]
|
||||
USER appuser
|
||||
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址。
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||
|
||||
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
||||
|
||||
@@ -45,6 +47,68 @@ dotnet run --project src/Jiaowu.Api
|
||||
|
||||
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
||||
|
||||
## Capacitor Android App
|
||||
|
||||
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
|
||||
Android 工程前先构建并同步原生插件:
|
||||
|
||||
```powershell
|
||||
Set-Location web
|
||||
npm ci
|
||||
npm run build:capacitor
|
||||
npm run cap:sync
|
||||
npm run cap:open:android
|
||||
```
|
||||
|
||||
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
|
||||
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
|
||||
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
|
||||
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
|
||||
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||
|
||||
### App 前端热更新
|
||||
|
||||
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
|
||||
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
|
||||
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
|
||||
前端修复不再需要重新打包。
|
||||
|
||||
生成更新 ZIP:
|
||||
|
||||
```powershell
|
||||
Set-Location web
|
||||
npm ci
|
||||
npm run ota:package -- --version 1.0.1
|
||||
```
|
||||
|
||||
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
|
||||
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
|
||||
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName` 为
|
||||
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`。
|
||||
|
||||
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
|
||||
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
|
||||
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
|
||||
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
|
||||
`--migrate-only`。
|
||||
|
||||
### Android 开屏、快捷入口与桌面组件
|
||||
|
||||
Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
|
||||
中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
|
||||
遵守系统“减少动画”设置。
|
||||
|
||||
长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
|
||||
签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
|
||||
课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
|
||||
退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
|
||||
|
||||
原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
|
||||
`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
|
||||
`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
|
||||
重新构建 App,不能通过前端 OTA 单独下发。
|
||||
|
||||
## MySQL 8.4 生产部署
|
||||
|
||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||
@@ -80,6 +144,50 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
|
||||
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
||||
路径必须是运行服务器上的实际路径。
|
||||
|
||||
### Keycloak 单点登录(可选)
|
||||
|
||||
系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否
|
||||
启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用
|
||||
`preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录
|
||||
外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户
|
||||
需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会
|
||||
自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能
|
||||
绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生
|
||||
自助激活入口不受影响。
|
||||
|
||||
在 Keycloak 中创建 OpenID Connect 客户端,并至少配置:
|
||||
|
||||
- Valid redirect URI:`https://jiaowu.example.edu.cn/signin-keycloak`
|
||||
- Valid post logout redirect URI:`https://jiaowu.example.edu.cn/*`(若后续启用 Keycloak 全局退出)
|
||||
- Standard flow:开启;Implicit flow:关闭;PKCE:`S256`
|
||||
|
||||
然后在 `.env` 中配置:
|
||||
|
||||
```dotenv
|
||||
Sso__Enabled=true
|
||||
Sso__DisplayName=学校统一身份认证
|
||||
Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||
Sso__ClientId=jiaowu-web
|
||||
Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||
Sso__UserNameClaim=preferred_username
|
||||
Sso__RequireHttpsMetadata=true
|
||||
Sso__LinkExistingUsersByUserName=true
|
||||
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||
Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||
```
|
||||
|
||||
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
|
||||
`http://localhost:5173`,Keycloak 测试客户端需同时允许
|
||||
`http://localhost:5255/signin-keycloak`。`Sso__CallbackUrl` 是应用实际发送给 Keycloak
|
||||
的 `redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式
|
||||
配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会
|
||||
显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
|
||||
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
|
||||
|
||||
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
|
||||
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
|
||||
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
|
||||
|
||||
### Linux systemd 服务
|
||||
|
||||
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
|
||||
@@ -99,6 +207,12 @@ sudo chown -R root:jiaowu /opt/jiaowu
|
||||
sudo chmod 0750 /opt/jiaowu
|
||||
sudo chmod 0750 /opt/jiaowu/Jiaowu.Api
|
||||
sudo chmod 0640 /opt/jiaowu/.env
|
||||
sudo install -d \
|
||||
--owner=jiaowu \
|
||||
--group=jiaowu \
|
||||
--mode=0700 \
|
||||
/var/lib/jiaowu/backups
|
||||
sudo apt-get install default-mysql-client
|
||||
```
|
||||
|
||||
如果账号已存在,`useradd` 会报错,可以跳过该命令。RHEL 系发行版的 `nologin` 通常
|
||||
@@ -219,6 +333,151 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
|
||||
|
||||
- `/health/live`:只检查进程存活。
|
||||
- `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
|
||||
- `/health/cache`:检查可选 Redis;未配置 Redis 时返回 `disabled`,Redis
|
||||
故障不会影响数据库就绪探针。
|
||||
- `/health/messaging`:检查后台任务传输;单机内存队列返回 `memory`,启用
|
||||
RabbitMQ 时实际检查代理连接,同时返回各 Outbox 状态的积压数、过期租约数和
|
||||
最老未完成任务的等待时间。
|
||||
|
||||
### 查询缓存与 Redis
|
||||
|
||||
应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为
|
||||
学生激活/基础数据选项、匿名可访问的已发布课表、仪表盘以及统计分析摘要。统计缓存键
|
||||
包含有效数据范围、学院和规范化筛选条件,避免跨学院复用;统计 Excel 导出仍实时查询。
|
||||
选课容量、成绩写入、考勤、审批、通知未读数、权限和后台任务状态仍直接以 MySQL 为准。
|
||||
|
||||
不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装
|
||||
Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如:
|
||||
|
||||
```text
|
||||
ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false
|
||||
```
|
||||
|
||||
仪表盘和统计摘要默认在 Redis 中缓存 3 分钟、进程内缓存 30 秒,可分别通过
|
||||
`Cache__AnalyticsExpirationMinutes` 和 `Cache__AnalyticsLocalExpirationSeconds`
|
||||
调整。该类汇总采用短 TTL 控制数据新鲜度,不要求每个业务写入点同步清理缓存。
|
||||
|
||||
Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和
|
||||
`/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境,
|
||||
同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用
|
||||
独立实例、私有网络、ACL 和 TLS。
|
||||
|
||||
`compose.example.yml` 包含不暴露宿主机端口的 Redis 服务,限制为 256 MB 并使用
|
||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||
|
||||
### OpenTelemetry 与慢查询定位
|
||||
|
||||
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
||||
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
||||
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
||||
仍然有效。
|
||||
|
||||
```text
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
```
|
||||
|
||||
数据库指标包括 `jiaowu.db.command.duration`、`jiaowu.db.command.slow` 和
|
||||
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
|
||||
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
|
||||
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
|
||||
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
|
||||
|
||||
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
||||
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
||||
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
||||
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
||||
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
||||
|
||||
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
||||
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
||||
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
||||
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
||||
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
||||
|
||||
```text
|
||||
PerformanceReporting__Enabled=true
|
||||
PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||
PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||
PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||
PerformanceReporting__CacheSeconds=30
|
||||
PerformanceReporting__TimeoutSeconds=10
|
||||
```
|
||||
|
||||
`PrometheusBaseUrl` 必须指向可访问 `/api/v1/query` 和 `/api/v1/query_range` 的
|
||||
Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置或指标源暂时不可用时,页面
|
||||
会显示明确的空状态,不会改查业务数据库或拖慢正常请求。若 Collector/Prometheus 对
|
||||
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
||||
|
||||
### 后台任务与 RabbitMQ
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
Outbox 消息在同一次 MySQL 提交中完成,后台发布器再将消息投递给任务 Worker;重复
|
||||
投递通过 Outbox 处理租约和唯一任务键抑制。任务状态表仍是前端查询进度与错误信息的
|
||||
唯一来源。
|
||||
|
||||
开发和单机部署默认使用有界进程内队列,不需要 RabbitMQ:
|
||||
|
||||
```text
|
||||
BackgroundJobs__Transport=InMemory
|
||||
```
|
||||
|
||||
多实例生产部署应切换为 RabbitMQ,并配置独立账号、虚拟主机和 TLS:
|
||||
|
||||
```text
|
||||
BackgroundJobs__Transport=RabbitMq
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
BackgroundJobs__ExamArrangementConcurrency=1
|
||||
RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
RabbitMq__Port=5671
|
||||
RabbitMq__UserName=jiaowu
|
||||
RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||
RabbitMq__VirtualHost=/jiaowu
|
||||
RabbitMq__UseTls=true
|
||||
RabbitMq__TlsServerName=rabbitmq.example.edu.cn
|
||||
```
|
||||
|
||||
RabbitMQ 传输使用持久消息、发布确认、手动消费确认、每种任务独立队列和死信队列。
|
||||
默认创建 Quorum Queue,重任务的消费者预取数为 1。三类任务各自至少有一个消费者,
|
||||
因此不同类型的任务不会再互相阻塞;单类任务的并发度可独立设置为 1-16。自动排课
|
||||
通常最消耗 CPU,建议先保持为 1,再根据 CPU、数据库连接池和任务等待时间逐级调到
|
||||
2 或 3;不要只提高预取数。
|
||||
|
||||
Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消息都额外扫描数据库;已完成
|
||||
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
||||
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
||||
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
||||
可接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
||||
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
||||
|
||||
### 运维与审计控制台
|
||||
|
||||
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
|
||||
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
|
||||
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
|
||||
前端菜单隐藏。
|
||||
|
||||
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
||||
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
||||
`ConnectionStrings__OperationsMySql`。该账号不得复用日常业务账号:它需要读取业务库,
|
||||
并只应被授权创建和删除名称为 `jiaowu_restore_drill_*` 的临时演练库。恢复演练不会覆盖
|
||||
当前业务库,流程是“校验 SHA-256 → 恢复到随机临时库 → 检查表结构 → 删除临时库”。
|
||||
|
||||
备份目录必须是仅服务账号可写的持久化目录。示例配置使用
|
||||
`/var/lib/jiaowu/backups`;Compose 已挂载独立命名卷。启用 MySQL TLS 时,还要通过
|
||||
`Operations__MySqlAdditionalArguments__N` 传入与所选命令行客户端匹配的 CA 与主机名
|
||||
校验参数。例如 Oracle MySQL 客户端使用 `--ssl-mode=VERIFY_IDENTITY` 和
|
||||
`--ssl-ca=/etc/jiaowu/mysql-ca.pem`,容器内 MariaDB 客户端使用 `--ssl`、
|
||||
`--ssl-ca=...` 与 `--ssl-verify-server-cert`。
|
||||
|
||||
## 跨平台发布与 Docker
|
||||
|
||||
|
||||
@@ -17,11 +17,15 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- jiaowu-backups:/var/lib/jiaowu/backups
|
||||
|
||||
# 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到
|
||||
# ./certs/mysql-ca.pem,并取消下面三行注释。
|
||||
# volumes:
|
||||
# ./certs/mysql-ca.pem,并在上面的 volumes 中追加以下四行。
|
||||
# - type: bind
|
||||
# source: ./certs/mysql-ca.pem
|
||||
# target: /etc/jiaowu/mysql-ca.pem
|
||||
# read_only: true
|
||||
|
||||
volumes:
|
||||
jiaowu-backups:
|
||||
|
||||
+69
-1
@@ -13,12 +13,29 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
Database__ApplyMigrationsOnStartup: "false"
|
||||
Database__CommandTimeoutSeconds: "30"
|
||||
ConnectionStrings__MySql: "Server=mysql;Port=3306;Database=${MYSQL_DATABASE:-jiaowu_demo};User=${MYSQL_USER:-jiaowu};Password=${MYSQL_PASSWORD:?请在 .env.docker 中设置 MYSQL_PASSWORD};SslMode=Disabled;"
|
||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||
Cache__Enabled: "true"
|
||||
Cache__KeyPrefix: "jiaowu:v1"
|
||||
BackgroundJobs__Transport: RabbitMq
|
||||
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
|
||||
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
|
||||
RabbitMq__HostName: rabbitmq
|
||||
RabbitMq__Port: "5672"
|
||||
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
|
||||
RabbitMq__Password: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}"
|
||||
RabbitMq__VirtualHost: "/"
|
||||
RabbitMq__UseTls: "false"
|
||||
Jwt__Issuer: Jiaowu.Api
|
||||
Jwt__Audience: Jiaowu.Web
|
||||
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
||||
Jwt__ExpireMinutes: "60"
|
||||
Jwt__AccessTokenMinutes: "10"
|
||||
Jwt__WebIdleMinutes: "30"
|
||||
Jwt__AppIdleMinutes: "4320"
|
||||
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
|
||||
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
|
||||
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
|
||||
SeedAdmin__UserName: "${SEED_ADMIN_USERNAME:-}"
|
||||
SeedAdmin__Password: "${SEED_ADMIN_PASSWORD:-}"
|
||||
SeedAdmin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-}"
|
||||
@@ -30,6 +47,49 @@ x-json-logging: &json-logging
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.2-management-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER:-jiaowu}"
|
||||
RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- rabbitmq-diagnostics
|
||||
- -q
|
||||
- ping
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
volumes:
|
||||
- rabbitmq-data:/var/lib/rabbitmq
|
||||
logging: *json-logging
|
||||
|
||||
redis:
|
||||
image: redis:8.8-alpine
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --save
|
||||
- ""
|
||||
- --appendonly
|
||||
- "no"
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lfu
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
logging: *json-logging
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
@@ -69,6 +129,10 @@ services:
|
||||
<<: *jiaowu-image
|
||||
environment: *jiaowu-environment
|
||||
depends_on:
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
@@ -77,6 +141,8 @@ services:
|
||||
- "${JIAOWU_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
volumes:
|
||||
- backup-data:/var/lib/jiaowu/backups
|
||||
logging: *json-logging
|
||||
|
||||
# 工具型一次性服务:普通 docker compose up 不会执行它。
|
||||
@@ -99,3 +165,5 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
rabbitmq-data:
|
||||
backup-data:
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Graduation;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
[Route("api/student/academic-planning")]
|
||||
public sealed class AcademicPlanningController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var loaded = await LoadAsync(cancellationToken);
|
||||
if (loaded.Error is not null) return loaded.Error;
|
||||
var context = loaded.Context!;
|
||||
|
||||
var completion = CurriculumCompletionRules.Evaluate(
|
||||
context.Plan.Modules,
|
||||
context.PassedCourseIds);
|
||||
var planCompletedCredits = context.Courses
|
||||
.Where(x => context.PassedCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits);
|
||||
var suggestionIds = AcademicPlanningRules.SuggestNextSemester(
|
||||
context.CourseSnapshots,
|
||||
context.PassedCourseIds,
|
||||
context.InProgressCourseIds,
|
||||
context.NextSemester);
|
||||
var suggestionIdSet = suggestionIds.ToHashSet();
|
||||
var latestAudit = await db.GraduationAuditResults.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == context.Student.Id &&
|
||||
x.GraduationAuditBatch!.Status ==
|
||||
GraduationAuditBatchStatus.Published)
|
||||
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
|
||||
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
BatchName = x.GraduationAuditBatch!.Name,
|
||||
x.GraduationAuditBatch.GraduationYear,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.GraduationAuditBatch.PublishedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var endSemester = Math.Max(
|
||||
context.Plan.Major!.SchoolingYears * 2,
|
||||
context.NextSemester + 3);
|
||||
return Ok(new
|
||||
{
|
||||
Student = new
|
||||
{
|
||||
context.Student.Id,
|
||||
context.Student.StudentNumber,
|
||||
context.Student.Name,
|
||||
context.Student.EnrollmentYear,
|
||||
context.Student.Status,
|
||||
ClassName = context.Student.AdministrativeClass!.Name,
|
||||
MajorName = context.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName =
|
||||
context.Student.AdministrativeClass.Major.College!.Name
|
||||
},
|
||||
Plan = new
|
||||
{
|
||||
context.Plan.Id,
|
||||
context.Plan.Name,
|
||||
context.Plan.Version,
|
||||
context.Plan.TotalCredits,
|
||||
SchoolingYears = context.Plan.Major.SchoolingYears,
|
||||
CurrentSemester = context.CurrentSemester,
|
||||
NextSemester = context.NextSemester,
|
||||
StandardGraduationSemester =
|
||||
context.Plan.Major.SchoolingYears * 2
|
||||
},
|
||||
Baseline = new
|
||||
{
|
||||
EarnedCredits = context.EarnedCredits,
|
||||
PlanCompletedCredits = planCompletedCredits,
|
||||
CreditGap = Math.Max(
|
||||
context.Plan.TotalCredits - context.EarnedCredits,
|
||||
0),
|
||||
CompletionRate = context.Plan.TotalCredits <= 0
|
||||
? 0
|
||||
: Math.Min(100, Math.Round(
|
||||
planCompletedCredits / context.Plan.TotalCredits * 100,
|
||||
1)),
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
completion.MissingRequirements,
|
||||
InProgressCredits = context.Courses
|
||||
.Where(x => context.InProgressCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits)
|
||||
},
|
||||
Terms = Enumerable.Range(
|
||||
context.NextSemester,
|
||||
endSemester - context.NextSemester + 1)
|
||||
.Select(semester => new
|
||||
{
|
||||
Semester = semester,
|
||||
Label = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
semester),
|
||||
IsBeyondStandard =
|
||||
semester > context.Plan.Major.SchoolingYears * 2
|
||||
}),
|
||||
Courses = context.Courses
|
||||
.OrderBy(x => x.RecommendedSemester)
|
||||
.ThenBy(x => x.CourseCode)
|
||||
.Select(course => new
|
||||
{
|
||||
course.CourseId,
|
||||
course.CourseCode,
|
||||
course.CourseName,
|
||||
course.Credits,
|
||||
course.RecommendedSemester,
|
||||
course.Type,
|
||||
course.ModuleCode,
|
||||
course.ModuleName,
|
||||
Status = context.StatusByCourse[course.CourseId],
|
||||
IsSuggested = suggestionIdSet.Contains(course.CourseId),
|
||||
Prerequisites = course.Prerequisites.Select(item => new
|
||||
{
|
||||
item.CourseId,
|
||||
item.CourseCode,
|
||||
item.CourseName,
|
||||
IsCompleted =
|
||||
context.PassedCourseIds.Contains(item.CourseId),
|
||||
IsInProgress =
|
||||
context.InProgressCourseIds.Contains(item.CourseId)
|
||||
})
|
||||
}),
|
||||
NextSemesterSuggestion = suggestionIds.Select(courseId =>
|
||||
{
|
||||
var course = context.CourseById[courseId];
|
||||
return new
|
||||
{
|
||||
course.CourseId,
|
||||
course.CourseCode,
|
||||
course.CourseName,
|
||||
course.Credits,
|
||||
course.Type,
|
||||
Reason = course.Type == CurriculumCourseType.Required &&
|
||||
course.RecommendedSemester <= context.NextSemester
|
||||
? "计划学期已到,且先修条件已满足"
|
||||
: course.Type == CurriculumCourseType.Required
|
||||
? "必修课程,按培养方案顺序推进"
|
||||
: "用于补足培养模块与总学分"
|
||||
};
|
||||
}),
|
||||
LatestGraduationAudit = latestAudit,
|
||||
Assumptions = new[]
|
||||
{
|
||||
"模拟课程按顺利通过计算,不会写入成绩或正式选课。",
|
||||
$"预计毕业学期按每学期最多 {AcademicPlanningRules.RecommendedSemesterCreditLimit:0} 学分估算。",
|
||||
"当前在读课程按本学期顺利完成计入预测。"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("simulate")]
|
||||
public async Task<ActionResult> Simulate(
|
||||
AcademicPlanningRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var loaded = await LoadAsync(cancellationToken);
|
||||
if (loaded.Error is not null) return loaded.Error;
|
||||
var context = loaded.Context!;
|
||||
|
||||
var selections = request.Terms
|
||||
.SelectMany(term => term.CourseIds.Select(courseId => new
|
||||
{
|
||||
term.Semester,
|
||||
CourseId = courseId
|
||||
}))
|
||||
.ToList();
|
||||
var duplicate = selections
|
||||
.GroupBy(x => x.CourseId)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicate is not null)
|
||||
return ValidationProblem("同一门课程不能安排在多个学期。");
|
||||
if (request.Terms.Any(x =>
|
||||
x.Semester < context.NextSemester ||
|
||||
x.Semester > context.NextSemester + 12))
|
||||
return ValidationProblem("模拟学期超出了可规划范围。");
|
||||
|
||||
var invalidCourse = selections.FirstOrDefault(x =>
|
||||
!context.CourseById.ContainsKey(x.CourseId));
|
||||
if (invalidCourse is not null)
|
||||
return ValidationProblem("模拟计划包含不属于当前培养方案的课程。");
|
||||
var alreadyHandled = selections.FirstOrDefault(x =>
|
||||
context.PassedCourseIds.Contains(x.CourseId) ||
|
||||
context.InProgressCourseIds.Contains(x.CourseId));
|
||||
if (alreadyHandled is not null)
|
||||
return ValidationProblem("已完成或当前在读课程无需重复安排。");
|
||||
|
||||
var plannedSemesters = selections.ToDictionary(
|
||||
x => x.CourseId,
|
||||
x => x.Semester);
|
||||
var projectedCourseIds = context.PassedCourseIds
|
||||
.Concat(context.InProgressCourseIds)
|
||||
.Concat(plannedSemesters.Keys)
|
||||
.ToHashSet();
|
||||
var completion = CurriculumCompletionRules.Evaluate(
|
||||
context.Plan.Modules,
|
||||
projectedCourseIds);
|
||||
var addedCredits = context.Courses
|
||||
.Where(x =>
|
||||
!context.PassedCourseIds.Contains(x.CourseId) &&
|
||||
(context.InProgressCourseIds.Contains(x.CourseId) ||
|
||||
plannedSemesters.ContainsKey(x.CourseId)))
|
||||
.Sum(x => x.Credits);
|
||||
var projectedEarnedCredits = context.EarnedCredits + addedCredits;
|
||||
var creditGap = Math.Max(
|
||||
context.Plan.TotalCredits - projectedEarnedCredits,
|
||||
0);
|
||||
var prerequisiteIssues = AcademicPlanningRules.FindPrerequisiteIssues(
|
||||
context.CourseSnapshots,
|
||||
context.PassedCourseIds,
|
||||
context.InProgressCourseIds,
|
||||
plannedSemesters);
|
||||
var conflicts = prerequisiteIssues.Select(issue =>
|
||||
{
|
||||
var course = context.CourseById[issue.CourseId];
|
||||
var prerequisite = context.AllCourseLabels.GetValueOrDefault(
|
||||
issue.PrerequisiteCourseId,
|
||||
new CourseLabel(
|
||||
issue.PrerequisiteCourseId,
|
||||
"未知课程",
|
||||
"未找到的先修课程"));
|
||||
return new
|
||||
{
|
||||
Type = "Prerequisite",
|
||||
issue.CourseId,
|
||||
course.CourseName,
|
||||
PrerequisiteCourseId = prerequisite.CourseId,
|
||||
PrerequisiteCourseName = prerequisite.CourseName,
|
||||
issue.PlannedSemester,
|
||||
issue.PrerequisitePlannedSemester,
|
||||
Message = issue.PrerequisitePlannedSemester.HasValue
|
||||
? $"《{prerequisite.CourseName}》必须安排在《{course.CourseName}》之前。"
|
||||
: $"《{course.CourseName}》的先修课程《{prerequisite.CourseName}》尚未完成或安排。"
|
||||
};
|
||||
}).ToList();
|
||||
var workloadWarnings = request.Terms
|
||||
.Select(term => new
|
||||
{
|
||||
term.Semester,
|
||||
Credits = term.CourseIds.Sum(courseId =>
|
||||
context.CourseById.GetValueOrDefault(courseId)?.Credits ?? 0)
|
||||
})
|
||||
.Where(x =>
|
||||
x.Credits > AcademicPlanningRules.HeavySemesterCreditLimit)
|
||||
.Select(x => new
|
||||
{
|
||||
Type = "Workload",
|
||||
x.Semester,
|
||||
x.Credits,
|
||||
Message =
|
||||
$"第 {x.Semester} 学期安排了 {x.Credits:0.#} 学分,超过建议上限 {AcademicPlanningRules.HeavySemesterCreditLimit:0.#} 学分。"
|
||||
})
|
||||
.ToList();
|
||||
var timingWarnings = selections
|
||||
.Select(item => new
|
||||
{
|
||||
item.Semester,
|
||||
Course = context.CourseById[item.CourseId]
|
||||
})
|
||||
.Where(x => x.Semester < x.Course.RecommendedSemester)
|
||||
.Select(x => new
|
||||
{
|
||||
Type = "EarlyCourse",
|
||||
x.Course.CourseId,
|
||||
x.Course.CourseName,
|
||||
x.Semester,
|
||||
x.Course.RecommendedSemester,
|
||||
Message =
|
||||
$"《{x.Course.CourseName}》早于培养方案建议学期修读,请确认课程开设条件。"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var unresolvedFailedCourseCount = context.FailedCourseIds.Count(
|
||||
courseId => !projectedCourseIds.Contains(courseId));
|
||||
var conclusion = GraduationAuditRules.Evaluate(
|
||||
true,
|
||||
context.Student.Status,
|
||||
context.Plan.TotalCredits,
|
||||
projectedEarnedCredits,
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
unresolvedFailedCourseCount);
|
||||
var latestPlannedSemester = plannedSemesters.Count == 0
|
||||
? context.NextSemester - 1
|
||||
: plannedSemesters.Values.Max();
|
||||
var estimatedSemester =
|
||||
AcademicPlanningRules.EstimateCompletionSemester(
|
||||
context.NextSemester,
|
||||
latestPlannedSemester,
|
||||
creditGap,
|
||||
completion.RequirementCount -
|
||||
completion.PassedRequirementCount);
|
||||
var remainingRequiredSemesterFloor = context.Courses
|
||||
.Where(course =>
|
||||
course.Type == CurriculumCourseType.Required &&
|
||||
!projectedCourseIds.Contains(course.CourseId))
|
||||
.Select(course => course.RecommendedSemester)
|
||||
.DefaultIfEmpty(estimatedSemester)
|
||||
.Max();
|
||||
estimatedSemester = Math.Max(
|
||||
estimatedSemester,
|
||||
remainingRequiredSemesterFloor);
|
||||
var planCompletedCredits = context.Courses
|
||||
.Where(x => projectedCourseIds.Contains(x.CourseId))
|
||||
.Sum(x => x.Credits);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Projected = new
|
||||
{
|
||||
EarnedCredits = projectedEarnedCredits,
|
||||
PlanCompletedCredits = planCompletedCredits,
|
||||
CreditGap = creditGap,
|
||||
CompletionRate = context.Plan.TotalCredits <= 0
|
||||
? 0
|
||||
: Math.Min(100, Math.Round(
|
||||
planCompletedCredits / context.Plan.TotalCredits * 100,
|
||||
1)),
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
completion.MissingRequirements,
|
||||
UnresolvedFailedCourseCount = unresolvedFailedCourseCount,
|
||||
GraduationConclusion = conclusion,
|
||||
EstimatedGraduationSemester = estimatedSemester,
|
||||
EstimatedGraduationTerm = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
estimatedSemester),
|
||||
IsBeyondStandard =
|
||||
estimatedSemester >
|
||||
context.Plan.Major!.SchoolingYears * 2
|
||||
},
|
||||
Conflicts = conflicts,
|
||||
Warnings = workloadWarnings.Cast<object>()
|
||||
.Concat(timingWarnings)
|
||||
.ToList(),
|
||||
TermSummaries = request.Terms
|
||||
.OrderBy(x => x.Semester)
|
||||
.Select(term => new
|
||||
{
|
||||
term.Semester,
|
||||
Label = FormatSemester(
|
||||
context.Student.EnrollmentYear,
|
||||
term.Semester),
|
||||
CourseCount = term.CourseIds.Count,
|
||||
Credits = term.CourseIds.Sum(courseId =>
|
||||
context.CourseById.GetValueOrDefault(courseId)?.Credits ??
|
||||
0)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<LoadResult> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Major)
|
||||
.ThenInclude(x => x!.College)
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||||
if (student is null)
|
||||
return new LoadResult(
|
||||
null,
|
||||
ConflictProblem("当前账号尚未关联学生档案。"));
|
||||
|
||||
var plan = await db.CurriculumPlans.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.ThenInclude(x => x.Course)
|
||||
.ThenInclude(x => x!.Prerequisites)
|
||||
.ThenInclude(x => x.PrerequisiteCourse)
|
||||
.Where(x =>
|
||||
x.MajorId == student.AdministrativeClass!.MajorId &&
|
||||
x.EffectiveGrade == student.EnrollmentYear &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null)
|
||||
return new LoadResult(
|
||||
null,
|
||||
ConflictProblem(
|
||||
$"{student.EnrollmentYear} 级{student.AdministrativeClass!.Major!.Name}尚未发布培养方案。"));
|
||||
|
||||
var courses = plan.Modules
|
||||
.SelectMany(module => module.Courses.Select(item =>
|
||||
new PlanningCourse(
|
||||
item.CourseId,
|
||||
item.Course!.Code,
|
||||
item.Course.Name,
|
||||
item.Course.Credits,
|
||||
item.RecommendedSemester,
|
||||
item.Type,
|
||||
module.Code,
|
||||
module.Name,
|
||||
item.Course.Prerequisites
|
||||
.Select(prerequisite => new CourseLabel(
|
||||
prerequisite.PrerequisiteCourseId,
|
||||
prerequisite.PrerequisiteCourse!.Code,
|
||||
prerequisite.PrerequisiteCourse.Name))
|
||||
.OrderBy(x => x.CourseCode)
|
||||
.ToList())))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
var planCourseIds = courses.Select(x => x.CourseId).ToArray();
|
||||
|
||||
var gradeAttempts = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Select(x => new GradeAttempt(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.GradeSheet.TeachingTask.Course!.Credits,
|
||||
x.TotalScore,
|
||||
x.ExamStatus))
|
||||
.ToListAsync(cancellationToken);
|
||||
var passedCourseIds = gradeAttempts
|
||||
.Where(x => StudentCourseProgressRules.IsPassed(
|
||||
new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus)))
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
var failedCourseIds = gradeAttempts
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Where(group => !group.Any(x =>
|
||||
StudentCourseProgressRules.IsPassed(
|
||||
new StudentCourseAttemptSnapshot(
|
||||
x.TotalScore,
|
||||
x.ExamStatus))))
|
||||
.Select(group => group.Key)
|
||||
.ToHashSet();
|
||||
var earnedCredits = gradeAttempts
|
||||
.Where(x => passedCourseIds.Contains(x.CourseId))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Sum(group => group.Max(x => x.Credits));
|
||||
|
||||
var inProgressCourseIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.AcademicTerm!.IsCurrent &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClassId ==
|
||||
student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)))
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var inProgressSet = inProgressCourseIds
|
||||
.Where(planCourseIds.Contains)
|
||||
.Where(courseId => !passedCourseIds.Contains(courseId))
|
||||
.ToHashSet();
|
||||
|
||||
var attemptsByCourse = gradeAttempts
|
||||
.Where(x => planCourseIds.Contains(x.CourseId))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
var statuses = courses.ToDictionary(
|
||||
x => x.CourseId,
|
||||
x => StudentCourseProgressRules.Evaluate(
|
||||
attemptsByCourse.GetValueOrDefault(x.CourseId, [])
|
||||
.Select(attempt => new StudentCourseAttemptSnapshot(
|
||||
attempt.TotalScore,
|
||||
attempt.ExamStatus)),
|
||||
inProgressSet.Contains(x.CourseId)).Status);
|
||||
var currentTerm = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsCurrent)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var currentSemester = CalculateCurrentSemester(
|
||||
student.EnrollmentYear,
|
||||
currentTerm);
|
||||
var snapshots = courses.Select(x =>
|
||||
new AcademicPlanningCourseSnapshot(
|
||||
x.CourseId,
|
||||
x.CourseName,
|
||||
x.Credits,
|
||||
x.RecommendedSemester,
|
||||
x.Type,
|
||||
x.Prerequisites.Select(p => p.CourseId).ToArray()))
|
||||
.ToList();
|
||||
var allLabels = courses
|
||||
.Select(x => new CourseLabel(
|
||||
x.CourseId,
|
||||
x.CourseCode,
|
||||
x.CourseName))
|
||||
.Concat(courses.SelectMany(x => x.Prerequisites))
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
|
||||
return new LoadResult(
|
||||
new PlanningContext(
|
||||
student,
|
||||
plan,
|
||||
courses,
|
||||
courses.ToDictionary(x => x.CourseId),
|
||||
snapshots,
|
||||
allLabels,
|
||||
passedCourseIds,
|
||||
inProgressSet,
|
||||
failedCourseIds,
|
||||
statuses,
|
||||
earnedCredits,
|
||||
currentSemester,
|
||||
currentSemester + 1),
|
||||
null);
|
||||
}
|
||||
|
||||
private static int CalculateCurrentSemester(
|
||||
int enrollmentYear,
|
||||
AcademicTerm? currentTerm)
|
||||
{
|
||||
if (currentTerm is not null)
|
||||
{
|
||||
return Math.Max(
|
||||
1,
|
||||
currentTerm.Season == TermSeason.Autumn
|
||||
? (currentTerm.StartDate.Year - enrollmentYear) * 2 + 1
|
||||
: (currentTerm.StartDate.Year - enrollmentYear - 1) * 2 + 2);
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
return Math.Max(
|
||||
1,
|
||||
today.Month >= 8
|
||||
? (today.Year - enrollmentYear) * 2 + 1
|
||||
: (today.Year - enrollmentYear - 1) * 2 + 2);
|
||||
}
|
||||
|
||||
private static string FormatSemester(int enrollmentYear, int semester)
|
||||
{
|
||||
var startYear = enrollmentYear + (semester - 1) / 2;
|
||||
var season = semester % 2 == 1 ? "秋季学期" : "春季学期";
|
||||
return $"{startYear}—{startYear + 1} 学年{season}";
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法进行学业规划",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private sealed record GradeAttempt(
|
||||
Guid CourseId,
|
||||
decimal Credits,
|
||||
decimal? TotalScore,
|
||||
GradeExamStatus ExamStatus);
|
||||
|
||||
private sealed record CourseLabel(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName);
|
||||
|
||||
private sealed record PlanningCourse(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
string ModuleCode,
|
||||
string ModuleName,
|
||||
IReadOnlyList<CourseLabel> Prerequisites);
|
||||
|
||||
private sealed record PlanningContext(
|
||||
Student Student,
|
||||
CurriculumPlan Plan,
|
||||
IReadOnlyList<PlanningCourse> Courses,
|
||||
IReadOnlyDictionary<Guid, PlanningCourse> CourseById,
|
||||
IReadOnlyList<AcademicPlanningCourseSnapshot> CourseSnapshots,
|
||||
IReadOnlyDictionary<Guid, CourseLabel> AllCourseLabels,
|
||||
IReadOnlySet<Guid> PassedCourseIds,
|
||||
IReadOnlySet<Guid> InProgressCourseIds,
|
||||
IReadOnlySet<Guid> FailedCourseIds,
|
||||
IReadOnlyDictionary<Guid, StudentCourseProgressStatus> StatusByCourse,
|
||||
decimal EarnedCredits,
|
||||
int CurrentSemester,
|
||||
int NextSemester);
|
||||
|
||||
private sealed record LoadResult(
|
||||
PlanningContext? Context,
|
||||
ActionResult? Error);
|
||||
}
|
||||
|
||||
public sealed record AcademicPlanningRequest(
|
||||
IReadOnlyCollection<AcademicPlanningTermRequest> Terms);
|
||||
|
||||
public sealed record AcademicPlanningTermRequest(
|
||||
[Range(1, 30)] int Semester,
|
||||
IReadOnlyCollection<Guid> CourseIds);
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/app-updates")]
|
||||
public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
private const long MaximumBundleBytes = 30 * 1024 * 1024;
|
||||
private const long MaximumExpandedBytes = 150 * 1024 * 1024;
|
||||
private const int MaximumZipEntries = 10_000;
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("app-updates")]
|
||||
[HttpGet("latest")]
|
||||
public async Task<ActionResult<AppUpdateCheckResponse>> GetLatest(
|
||||
[FromQuery, Required] string platform,
|
||||
[FromQuery, Required] string nativeVersion,
|
||||
[FromQuery] string channel = "production",
|
||||
[FromQuery] string? currentVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
if (!TryParseChannel(channel, out var parsedChannel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
|
||||
var normalizedNativeVersion = nativeVersion.Trim();
|
||||
if (!NativeVersionRegex().IsMatch(normalizedNativeVersion))
|
||||
return ValidationProblem("原生版本格式无效。");
|
||||
|
||||
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Platform == parsedPlatform &&
|
||||
x.Channel == parsedChannel &&
|
||||
x.NativeVersion == normalizedNativeVersion &&
|
||||
x.Status == AppUpdateReleaseStatus.Published)
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (release is null ||
|
||||
string.Equals(
|
||||
release.Version,
|
||||
currentVersion?.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Ok(new AppUpdateCheckResponse(
|
||||
false,
|
||||
release?.Version,
|
||||
normalizedNativeVersion,
|
||||
parsedPlatform,
|
||||
parsedChannel,
|
||||
null,
|
||||
release?.ReleaseNotes,
|
||||
release?.FileSize,
|
||||
release?.Sha256,
|
||||
release?.PublishedAt));
|
||||
}
|
||||
|
||||
return Ok(new AppUpdateCheckResponse(
|
||||
true,
|
||||
release.Version,
|
||||
release.NativeVersion,
|
||||
release.Platform,
|
||||
release.Channel,
|
||||
$"app-updates/releases/{release.Id}/bundle",
|
||||
release.ReleaseNotes,
|
||||
release.FileSize,
|
||||
release.Sha256,
|
||||
release.PublishedAt));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("app-updates")]
|
||||
[HttpGet("releases/{id:guid}/bundle")]
|
||||
public async Task<IActionResult> DownloadBundle(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Id == id &&
|
||||
x.Status != AppUpdateReleaseStatus.Draft)
|
||||
.Select(x => new
|
||||
{
|
||||
x.BundleContent,
|
||||
x.FileName,
|
||||
x.Sha256
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (release is null) return NotFound();
|
||||
|
||||
Response.Headers.ETag = $"\"sha256-{release.Sha256}\"";
|
||||
Response.Headers.CacheControl = "public,max-age=31536000,immutable";
|
||||
return File(
|
||||
release.BundleContent,
|
||||
"application/zip",
|
||||
release.FileName,
|
||||
enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpGet("releases")]
|
||||
public async Task<ActionResult<PagedResult<AppUpdateReleaseItem>>> GetReleases(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? platform = null,
|
||||
[FromQuery] string? channel = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (page < 1 || pageSize is < 1 or > 100)
|
||||
return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。");
|
||||
|
||||
var query = db.AppUpdateReleases.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
query = query.Where(x => x.Platform == parsedPlatform);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(channel))
|
||||
{
|
||||
if (!TryParseChannel(channel, out var parsedChannel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
query = query.Where(x => x.Channel == parsedChannel);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await query
|
||||
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new AppUpdateReleaseItem(
|
||||
x.Id,
|
||||
x.Platform,
|
||||
x.Channel,
|
||||
x.Version,
|
||||
x.NativeVersion,
|
||||
x.Status,
|
||||
x.ReleaseNotes,
|
||||
x.FileName,
|
||||
x.FileSize,
|
||||
x.Sha256,
|
||||
x.CreatedByUserName,
|
||||
x.CreatedAt,
|
||||
x.PublishedByUserName,
|
||||
x.PublishedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return Ok(new PagedResult<AppUpdateReleaseItem>(
|
||||
rows,
|
||||
total,
|
||||
page,
|
||||
pageSize));
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)]
|
||||
[HttpPost("releases")]
|
||||
public async Task<ActionResult<AppUpdateReleaseItem>> UploadRelease(
|
||||
[FromForm] AppUpdateUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryParsePlatform(request.Platform, out var platform))
|
||||
return ValidationProblem("平台必须为 android 或 ios。");
|
||||
if (!TryParseChannel(request.Channel, out var channel))
|
||||
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||
|
||||
var version = request.Version.Trim();
|
||||
var nativeVersion = request.NativeVersion.Trim();
|
||||
if (!ReleaseVersionRegex().IsMatch(version))
|
||||
return ValidationProblem(
|
||||
"热更新版本必须使用语义版本,例如 1.0.1 或 1.0.1-beta.1。");
|
||||
if (!NativeVersionRegex().IsMatch(nativeVersion))
|
||||
return ValidationProblem("原生版本格式无效,例如 1.0 或 1.0.0。");
|
||||
if (request.ReleaseNotes?.Trim().Length > 1000)
|
||||
return ValidationProblem("更新说明不能超过 1000 字。");
|
||||
if (request.Bundle.Length is <= 0 or > MaximumBundleBytes)
|
||||
return ValidationProblem("更新包必须大于 0 字节且不超过 30 MB。");
|
||||
|
||||
var exists = await db.AppUpdateReleases.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x =>
|
||||
x.Platform == platform &&
|
||||
x.Channel == channel &&
|
||||
x.NativeVersion == nativeVersion &&
|
||||
x.Version == version,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "更新版本已存在",
|
||||
Detail = "同一平台、通道和原生版本下不能重复上传相同版本。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
await using var source = request.Bundle.OpenReadStream();
|
||||
await using var buffer = new MemoryStream(
|
||||
checked((int)request.Bundle.Length));
|
||||
await source.CopyToAsync(buffer, cancellationToken);
|
||||
var content = buffer.ToArray();
|
||||
var archiveError = ValidateArchive(content);
|
||||
if (archiveError is not null) return ValidationProblem(archiveError);
|
||||
|
||||
var fileName = Path.GetFileName(request.Bundle.FileName.Trim());
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length > 180)
|
||||
fileName = $"jiaowu-web-{version}.zip";
|
||||
|
||||
var release = new AppUpdateRelease
|
||||
{
|
||||
Platform = platform,
|
||||
Channel = channel,
|
||||
Version = version,
|
||||
NativeVersion = nativeVersion,
|
||||
ReleaseNotes = NullIfWhiteSpace(request.ReleaseNotes),
|
||||
FileName = fileName,
|
||||
FileSize = content.LongLength,
|
||||
Sha256 = Convert.ToHexString(
|
||||
SHA256.HashData(content))
|
||||
.ToLowerInvariant(),
|
||||
BundleContent = content,
|
||||
CreatedByUserName = User.Identity?.Name ?? "unknown"
|
||||
};
|
||||
db.AppUpdateReleases.Add(release);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetReleases),
|
||||
new { id = release.Id },
|
||||
ToItem(release));
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpPost("releases/{id:guid}/publish")]
|
||||
public async Task<ActionResult<AppUpdateReleaseItem>> PublishRelease(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
var published = await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
var target = await db.AppUpdateReleases
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (target is null) return null;
|
||||
|
||||
var currentlyPublished = await db.AppUpdateReleases
|
||||
.Where(x =>
|
||||
x.Id != target.Id &&
|
||||
x.Platform == target.Platform &&
|
||||
x.Channel == target.Channel &&
|
||||
x.NativeVersion == target.NativeVersion &&
|
||||
x.Status == AppUpdateReleaseStatus.Published)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var release in currentlyPublished)
|
||||
release.Status = AppUpdateReleaseStatus.Archived;
|
||||
|
||||
target.Status = AppUpdateReleaseStatus.Published;
|
||||
target.PublishedAt = DateTime.UtcNow;
|
||||
target.PublishedByUserName = User.Identity?.Name ?? "unknown";
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return ToItem(target);
|
||||
});
|
||||
|
||||
return published is null ? NotFound() : Ok(published);
|
||||
}
|
||||
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[HttpDelete("releases/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteRelease(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await db.AppUpdateReleases
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (release is null) return NotFound();
|
||||
if (release.Status == AppUpdateReleaseStatus.Published)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "正式版本不能删除",
|
||||
Detail = "请先发布另一个兼容版本,再删除已归档的更新包。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
db.AppUpdateReleases.Remove(release);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static string? ValidateArchive(byte[] content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(content, writable: false);
|
||||
using var archive = new ZipArchive(
|
||||
stream,
|
||||
ZipArchiveMode.Read,
|
||||
leaveOpen: false);
|
||||
if (archive.Entries.Count is 0 or > MaximumZipEntries)
|
||||
return $"更新包文件数量必须在 1 到 {MaximumZipEntries} 之间。";
|
||||
|
||||
long expandedBytes = 0;
|
||||
var hasRootIndex = false;
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var normalized = entry.FullName.Replace('\\', '/');
|
||||
if (normalized.StartsWith('/') ||
|
||||
normalized.Split('/').Any(part => part == ".."))
|
||||
{
|
||||
return "更新包包含不安全的文件路径。";
|
||||
}
|
||||
if (string.Equals(
|
||||
normalized,
|
||||
"index.html",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hasRootIndex = true;
|
||||
}
|
||||
expandedBytes = checked(expandedBytes + entry.Length);
|
||||
if (expandedBytes > MaximumExpandedBytes)
|
||||
return "更新包解压后的总大小不能超过 150 MB。";
|
||||
}
|
||||
|
||||
return hasRootIndex
|
||||
? null
|
||||
: "更新包根目录必须包含 index.html;请直接压缩 dist 目录中的内容。";
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is InvalidDataException or IOException or OverflowException)
|
||||
{
|
||||
return "更新包不是有效的 ZIP 文件,或文件结构已损坏。";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParsePlatform(
|
||||
string value,
|
||||
out AppUpdatePlatform platform) =>
|
||||
Enum.TryParse(value.Trim(), ignoreCase: true, out platform) &&
|
||||
Enum.IsDefined(platform);
|
||||
|
||||
private static bool TryParseChannel(
|
||||
string value,
|
||||
out AppUpdateChannel channel) =>
|
||||
Enum.TryParse(value.Trim(), ignoreCase: true, out channel) &&
|
||||
Enum.IsDefined(channel);
|
||||
|
||||
private static string? NullIfWhiteSpace(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static AppUpdateReleaseItem ToItem(AppUpdateRelease release) =>
|
||||
new(
|
||||
release.Id,
|
||||
release.Platform,
|
||||
release.Channel,
|
||||
release.Version,
|
||||
release.NativeVersion,
|
||||
release.Status,
|
||||
release.ReleaseNotes,
|
||||
release.FileName,
|
||||
release.FileSize,
|
||||
release.Sha256,
|
||||
release.CreatedByUserName,
|
||||
release.CreatedAt,
|
||||
release.PublishedByUserName,
|
||||
release.PublishedAt);
|
||||
|
||||
[GeneratedRegex(
|
||||
@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ReleaseVersionRegex();
|
||||
|
||||
[GeneratedRegex(
|
||||
@"^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex NativeVersionRegex();
|
||||
}
|
||||
|
||||
public sealed class AppUpdateUploadRequest
|
||||
{
|
||||
[Required]
|
||||
public required IFormFile Bundle { get; init; }
|
||||
|
||||
[Required, MaxLength(20)]
|
||||
public required string Platform { get; init; }
|
||||
|
||||
[Required, MaxLength(20)]
|
||||
public required string Channel { get; init; }
|
||||
|
||||
[Required, MaxLength(40)]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[Required, MaxLength(40)]
|
||||
public required string NativeVersion { get; init; }
|
||||
|
||||
[MaxLength(1000)]
|
||||
public string? ReleaseNotes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AppUpdateCheckResponse(
|
||||
bool Available,
|
||||
string? Version,
|
||||
string NativeVersion,
|
||||
AppUpdatePlatform Platform,
|
||||
AppUpdateChannel Channel,
|
||||
string? DownloadUrl,
|
||||
string? ReleaseNotes,
|
||||
long? FileSize,
|
||||
string? Sha256,
|
||||
DateTime? PublishedAt);
|
||||
|
||||
public sealed record AppUpdateReleaseItem(
|
||||
Guid Id,
|
||||
AppUpdatePlatform Platform,
|
||||
AppUpdateChannel Channel,
|
||||
string Version,
|
||||
string NativeVersion,
|
||||
AppUpdateReleaseStatus Status,
|
||||
string? ReleaseNotes,
|
||||
string FileName,
|
||||
long FileSize,
|
||||
string Sha256,
|
||||
string CreatedByUserName,
|
||||
DateTime CreatedAt,
|
||||
string? PublishedByUserName,
|
||||
DateTime? PublishedAt);
|
||||
@@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var studentId = await GetStudentIdAsync(ct);
|
||||
if (studentId is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
studentId.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有免修申请在审核中。");
|
||||
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||
@@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
sid.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有缓考申请在审核中。");
|
||||
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||
@@ -200,7 +206,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
|
||||
.FirstAsync(ct);
|
||||
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
|
||||
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct);
|
||||
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}",
|
||||
cancellationToken: ct,
|
||||
category: NotificationCategory.Grade);
|
||||
return Created("", new { gm.Id });
|
||||
}
|
||||
|
||||
@@ -213,7 +221,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
|
||||
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.AcademicAdmin,
|
||||
"成绩修改待校级审核",
|
||||
$"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》",
|
||||
linkUrl: "/approvals",
|
||||
cancellationToken: ct,
|
||||
category: NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -221,7 +236,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
|
||||
{
|
||||
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
var gm = await db.GradeModifications
|
||||
.Include(x => x.GradeRecord)
|
||||
.ThenInclude(x => x!.Student)
|
||||
.Include(x => x.GradeRecord)
|
||||
.ThenInclude(x => x!.GradeSheet)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (gm is null) return NotFound();
|
||||
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
|
||||
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
|
||||
@@ -229,7 +251,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
gm.ApplicantUserId,
|
||||
"成绩修改已通过",
|
||||
"您的成绩修改申请已通过三级审批并生效。",
|
||||
"/approvals",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
var studentUserId = gm.GradeRecord.Student?.UserId;
|
||||
if (studentUserId.HasValue &&
|
||||
studentUserId.Value != gm.ApplicantUserId)
|
||||
{
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
studentUserId.Value,
|
||||
"成绩已更新",
|
||||
$"《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》成绩已由 {gm.CurrentScore} 调整为 {gm.RequestedScore}。",
|
||||
"/grades",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -243,17 +285,87 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
|
||||
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
gm.ApplicantUserId,
|
||||
"成绩修改已驳回",
|
||||
gm.ReviewComment ?? "审核未通过。",
|
||||
"/approvals",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════════ Course Substitution ═══════════════
|
||||
[HttpGet("substitutions/mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
return Ok(await db.CourseSubstitutions.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new StudentCourseSubstitutionRecord(
|
||||
x.Id,
|
||||
x.Status,
|
||||
x.Reason,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.OriginalCourse!.Code,
|
||||
x.OriginalCourse.Name,
|
||||
x.SubstituteCourse!.Code,
|
||||
x.SubstituteCourse.Name))
|
||||
.ToListAsync(ct));
|
||||
}
|
||||
|
||||
[HttpPost("substitutions")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
if (req.OriginalCourseId == req.SubstituteCourseId)
|
||||
return ConflictProblem("被替代课程和替代课程不能相同。");
|
||||
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
|
||||
.AnyAsync(task =>
|
||||
task.CourseId == req.OriginalCourseId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
ct);
|
||||
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(record =>
|
||||
record.StudentId == sid.Value &&
|
||||
record.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
record.GradeSheet.TeachingTask!.CourseId ==
|
||||
req.OriginalCourseId &&
|
||||
record.TotalScore < 60,
|
||||
ct);
|
||||
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
|
||||
if (!canReplaceOriginal)
|
||||
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
|
||||
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(x =>
|
||||
x.StudentId == sid.Value &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
|
||||
x.TotalScore >= 60,
|
||||
ct);
|
||||
if (!hasPassedSubstitute)
|
||||
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
|
||||
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有替代申请在审核中。");
|
||||
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
||||
@@ -319,16 +431,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════════ Student enrolled courses ═══════════════
|
||||
// ═══════════════ Student course options ═══════════════
|
||||
[HttpGet("my-courses")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
var courses = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
|
||||
var courses = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)))
|
||||
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new StudentApprovalCourseOption(
|
||||
x.Id,
|
||||
x.CourseId,
|
||||
x.TaskNumber,
|
||||
x.Course!.Code,
|
||||
x.Course.Name,
|
||||
x.Course.Credits,
|
||||
x.AcademicTerm!.Name,
|
||||
x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.ThenBy(item => item.Teacher!.Name)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == x.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
|
||||
x.SchedulingMode))
|
||||
.ToListAsync(ct);
|
||||
return Ok(courses);
|
||||
}
|
||||
@@ -341,8 +487,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (sid is null) return StudentNotFound();
|
||||
var grades = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
|
||||
.OrderBy(x => x.CourseCode)
|
||||
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||||
.Select(x => new StudentApprovalGradeOption(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.GradeSheet.TeachingTask.Course!.Code,
|
||||
x.GradeSheet.TeachingTask.Course.Name,
|
||||
x.GradeSheet.TeachingTask.Course.Credits,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
x.ExamStatus,
|
||||
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTask.TaskNumber,
|
||||
x.GradeSheet.PublishedAt))
|
||||
.ToListAsync(ct);
|
||||
return Ok(grades);
|
||||
}
|
||||
@@ -377,12 +533,33 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||
private async Task<bool> IsStudentCourseAvailableForApplicationAsync(
|
||||
Guid studentId,
|
||||
Guid teachingTaskId,
|
||||
CancellationToken ct) =>
|
||||
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
|
||||
task.Id == teachingTaskId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == studentId)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == studentId &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
ct);
|
||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
||||
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct); }
|
||||
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, "/approvals", ct, NotificationCategory.Approval); }
|
||||
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct, category: NotificationCategory.Grade); }
|
||||
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||
|
||||
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
|
||||
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
|
||||
@@ -390,6 +567,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
||||
public sealed record StudentApprovalCourseOption(
|
||||
Guid TeachingTaskId,
|
||||
Guid CourseId,
|
||||
string TaskNumber,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
string AcademicTermName,
|
||||
IEnumerable<string> TeacherNames,
|
||||
bool HasPublishedSchedule,
|
||||
TeachingTaskSchedulingMode SchedulingMode);
|
||||
public sealed record StudentApprovalGradeOption(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
decimal? TotalScore,
|
||||
decimal? GradePoint,
|
||||
GradeExamStatus ExamStatus,
|
||||
string AcademicTermName,
|
||||
string TaskNumber,
|
||||
DateTime? PublishedAt);
|
||||
public sealed record StudentCourseSubstitutionRecord(
|
||||
Guid Id,
|
||||
ApprovalStatus Status,
|
||||
string Reason,
|
||||
string? ReviewComment,
|
||||
DateTime SubmittedAt,
|
||||
string OriginalCourseCode,
|
||||
string OriginalCourseName,
|
||||
string SubstituteCourseCode,
|
||||
string SubstituteCourseName);
|
||||
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
||||
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
||||
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -48,9 +50,15 @@ public sealed class AttendanceController(
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course!.Name,
|
||||
StudentCount = db.CourseEnrollments.Count(enrollment =>
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id),
|
||||
StudentCount = db.Students.Count(student =>
|
||||
(student.Status == StudentStatus.Active &&
|
||||
x.Classes.Any(assignment =>
|
||||
assignment.AdministrativeClassId ==
|
||||
student.AdministrativeClassId)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id)),
|
||||
SheetCount = db.AttendanceSheets.Count(sheet =>
|
||||
sheet.TeachingTaskId == x.Id)
|
||||
})
|
||||
@@ -104,15 +112,13 @@ public sealed class AttendanceController(
|
||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
var studentIds = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
var studentIds = await TeachingTaskRosterQuery
|
||||
.ForTask(db, request.TeachingTaskId)
|
||||
.AsNoTracking()
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (studentIds.Count == 0)
|
||||
return ConflictProblem("该教学班没有有效选课学生。");
|
||||
return ConflictProblem("该教学班没有有效学生。");
|
||||
|
||||
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
|
||||
if (!Enum.IsDefined(checkInMethod))
|
||||
@@ -175,7 +181,6 @@ public sealed class AttendanceController(
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInToken,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt
|
||||
});
|
||||
@@ -195,7 +200,6 @@ public sealed class AttendanceController(
|
||||
x.AttendanceDate,
|
||||
x.Status,
|
||||
x.CheckInMethod,
|
||||
x.CheckInToken,
|
||||
x.CheckInStartsAt,
|
||||
x.CheckInEndsAt,
|
||||
x.TargetLatitude,
|
||||
@@ -242,6 +246,103 @@ public sealed class AttendanceController(
|
||||
cancellationToken);
|
||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||
var now = DateTime.UtcNow;
|
||||
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.Where(x => x.AttendanceSheetId == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
x.CreatedAt,
|
||||
x.IsSuccessful,
|
||||
x.FailureCode,
|
||||
x.DeviceIdentifierHash,
|
||||
x.DevicePlatform,
|
||||
x.IpAddress,
|
||||
x.RiskFlags
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var attemptsByStudent = attempts
|
||||
.GroupBy(x => x.StudentId)
|
||||
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
|
||||
var deviceHashes = attempts
|
||||
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
|
||||
.Select(x => x.DeviceIdentifierHash!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
if (deviceHashes.Count > 0)
|
||||
{
|
||||
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsSuccessful &&
|
||||
x.CreatedAt >= now.AddHours(-24) &&
|
||||
x.DeviceIdentifierHash != null)
|
||||
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
|
||||
.GroupBy(x => x.DeviceIdentifierHash!)
|
||||
.Select(x => new
|
||||
{
|
||||
DeviceIdentifierHash = x.Key,
|
||||
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
deviceReuseCounts = reuseRows.ToDictionary(
|
||||
x => x.DeviceIdentifierHash,
|
||||
x => x.StudentCount,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var responseRecords = sheet.Records.Select(r =>
|
||||
{
|
||||
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
|
||||
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
|
||||
var riskFlags = studentAttempts
|
||||
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
|
||||
riskFlags.Add("RepeatedFailures");
|
||||
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
|
||||
riskFlags.Add("HighFrequency");
|
||||
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
|
||||
? deviceReuseCounts.GetValueOrDefault(deviceHash)
|
||||
: 0;
|
||||
if (sharedDeviceStudentCount > 1)
|
||||
riskFlags.Add("SharedDevice");
|
||||
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
|
||||
? attempts
|
||||
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.Count()
|
||||
: 0;
|
||||
|
||||
return new
|
||||
{
|
||||
r.StudentId,
|
||||
r.StudentNumber,
|
||||
r.Name,
|
||||
r.ClassName,
|
||||
r.Status,
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters,
|
||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||
IsDeferred = deferredStudentIds.Contains(r.StudentId),
|
||||
CheckInAudit = latestSuccess is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
latestSuccess.IpAddress,
|
||||
latestSuccess.DevicePlatform,
|
||||
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
|
||||
SharedDeviceStudentCount = sharedDeviceStudentCount,
|
||||
SameIpStudentCount = sameIpStudentCount
|
||||
},
|
||||
AttemptCount = studentAttempts.Count,
|
||||
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
|
||||
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
|
||||
};
|
||||
}).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = new
|
||||
@@ -252,7 +353,6 @@ public sealed class AttendanceController(
|
||||
sheet.AttendanceDate,
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
sheet.TargetLatitude,
|
||||
@@ -271,21 +371,16 @@ public sealed class AttendanceController(
|
||||
sheet.TaskName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
Records = sheet.Records.Select(r => new
|
||||
Records = responseRecords,
|
||||
RiskSummary = new
|
||||
{
|
||||
r.StudentId,
|
||||
r.StudentNumber,
|
||||
r.Name,
|
||||
r.ClassName,
|
||||
r.Status,
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters,
|
||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
||||
})
|
||||
RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
|
||||
SharedDeviceStudentCount = responseRecords.Count(
|
||||
x => x.RiskFlags.Contains("SharedDevice")),
|
||||
FrequentAttemptStudentCount = responseRecords.Count(
|
||||
x => x.RiskFlags.Contains("HighFrequency") ||
|
||||
x.RiskFlags.Contains("RepeatedFailures"))
|
||||
}
|
||||
},
|
||||
CanEdit = canEdit
|
||||
});
|
||||
@@ -498,6 +593,39 @@ public sealed class AttendanceController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}/qr-challenge")]
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> GetQrChallenge(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.AttendanceSheets
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanManageSheet(sheet)) return Forbid();
|
||||
if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||
string.IsNullOrWhiteSpace(sheet.CheckInToken))
|
||||
return ConflictProblem("该考勤表不是扫码签到。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return ConflictProblem("签到尚未开始或已经结束。");
|
||||
|
||||
var challenge = AttendanceCheckInChallenge.Create(
|
||||
sheet.Id,
|
||||
sheet.CheckInToken,
|
||||
now);
|
||||
return Ok(challenge);
|
||||
}
|
||||
|
||||
// ═══════════════ Student endpoints ═══════════════
|
||||
|
||||
[HttpGet("check-in-info")]
|
||||
@@ -511,11 +639,13 @@ public sealed class AttendanceController(
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return NotFound();
|
||||
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
|
||||
return NotFound();
|
||||
|
||||
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.AttendanceSheet!.CheckInToken == token.Trim())
|
||||
x.AttendanceSheetId == sheetId)
|
||||
.Select(x => new
|
||||
{
|
||||
SheetId = x.AttendanceSheetId,
|
||||
@@ -525,6 +655,7 @@ public sealed class AttendanceController(
|
||||
x.AttendanceSheet.CheckInMethod,
|
||||
x.AttendanceSheet.CheckInStartsAt,
|
||||
x.AttendanceSheet.CheckInEndsAt,
|
||||
x.AttendanceSheet.CheckInToken,
|
||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||
@@ -534,6 +665,13 @@ public sealed class AttendanceController(
|
||||
if (activity is null) return NotFound();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||
!AttendanceCheckInChallenge.IsValid(
|
||||
token,
|
||||
activity.SheetId,
|
||||
activity.CheckInToken,
|
||||
now))
|
||||
return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
activity.SheetId,
|
||||
@@ -605,8 +743,11 @@ public sealed class AttendanceController(
|
||||
.Where(x => x.StudentId == studentId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
var token = request.Token.Trim();
|
||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
||||
if (!AttendanceCheckInChallenge.TryReadSheetId(
|
||||
request.Token.Trim(),
|
||||
out var tokenSheetId))
|
||||
return NotFound();
|
||||
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
|
||||
}
|
||||
else if (request.AttendanceSheetId.HasValue)
|
||||
{
|
||||
@@ -622,8 +763,59 @@ public sealed class AttendanceController(
|
||||
if (record?.AttendanceSheet is null) return NotFound();
|
||||
var sheet = record.AttendanceSheet;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
async Task<ActionResult> RejectAttemptAsync(
|
||||
string failureCode,
|
||||
string detail,
|
||||
double? distanceMeters = null)
|
||||
{
|
||||
await AddCheckInAttemptAsync(
|
||||
sheet,
|
||||
studentId.Value,
|
||||
request,
|
||||
false,
|
||||
failureCode,
|
||||
distanceMeters,
|
||||
now,
|
||||
cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return ConflictProblem(detail);
|
||||
}
|
||||
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
|
||||
!AttendanceCheckInChallenge.IsValid(
|
||||
request.Token,
|
||||
sheet.Id,
|
||||
sheet.CheckInToken,
|
||||
now))
|
||||
return await RejectAttemptAsync(
|
||||
"InvalidQrChallenge",
|
||||
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return await RejectAttemptAsync(
|
||||
"CheckInClosed",
|
||||
"签到尚未开始或已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return await RejectAttemptAsync(
|
||||
"ManualSheet",
|
||||
"该考勤表不支持学生在线签到。");
|
||||
if (record.CheckInAt.HasValue)
|
||||
{
|
||||
await AddCheckInAttemptAsync(
|
||||
sheet,
|
||||
studentId.Value,
|
||||
request,
|
||||
true,
|
||||
null,
|
||||
record.CheckInDistanceMeters,
|
||||
now,
|
||||
cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AlreadyCheckedIn = true,
|
||||
@@ -631,37 +823,35 @@ public sealed class AttendanceController(
|
||||
record.CheckInDistanceMeters
|
||||
});
|
||||
}
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return ConflictProblem("签到尚未开始或已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return ConflictProblem("该考勤表不支持学生在线签到。");
|
||||
|
||||
double? distanceMeters = null;
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token) ||
|
||||
!string.Equals(
|
||||
sheet.CheckInToken,
|
||||
request.Token.Trim(),
|
||||
StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
}
|
||||
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||
{
|
||||
if (request.Latitude is < -90 or > 90 ||
|
||||
request.Longitude is < -180 or > 180 ||
|
||||
request.Latitude is null ||
|
||||
request.Longitude is null)
|
||||
return ConflictProblem("未获取到有效的当前位置。");
|
||||
return await RejectAttemptAsync(
|
||||
"InvalidLocation",
|
||||
"未获取到有效的当前位置。");
|
||||
if (sheet.TargetLatitude is null ||
|
||||
sheet.TargetLongitude is null ||
|
||||
sheet.LocationRadiusMeters is null)
|
||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
||||
return await RejectAttemptAsync(
|
||||
"LocationNotConfigured",
|
||||
"签到活动没有配置有效的位置范围。");
|
||||
var maximumAllowedAccuracyMeters = Math.Min(
|
||||
100d,
|
||||
sheet.LocationRadiusMeters.Value);
|
||||
if (request.AccuracyMeters is null ||
|
||||
!double.IsFinite(request.AccuracyMeters.Value) ||
|
||||
request.AccuracyMeters <= 0 ||
|
||||
request.AccuracyMeters > maximumAllowedAccuracyMeters)
|
||||
{
|
||||
return await RejectAttemptAsync(
|
||||
"InsufficientAccuracy",
|
||||
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
|
||||
}
|
||||
|
||||
distanceMeters = CalculateDistanceMeters(
|
||||
(double)sheet.TargetLatitude.Value,
|
||||
@@ -670,8 +860,10 @@ public sealed class AttendanceController(
|
||||
(double)request.Longitude.Value);
|
||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
||||
return await RejectAttemptAsync(
|
||||
"OutsideGeofence",
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
|
||||
distanceMeters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,6 +880,15 @@ public sealed class AttendanceController(
|
||||
? request.AccuracyMeters
|
||||
: null;
|
||||
record.CheckInDistanceMeters = distanceMeters;
|
||||
await AddCheckInAttemptAsync(
|
||||
sheet,
|
||||
studentId.Value,
|
||||
request,
|
||||
true,
|
||||
null,
|
||||
distanceMeters,
|
||||
now,
|
||||
cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
@@ -793,7 +994,8 @@ public sealed class AttendanceController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"考勤申诉待处理",
|
||||
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
|
||||
"/teacher-attendance", cancellationToken);
|
||||
"/teacher-attendance", cancellationToken,
|
||||
NotificationCategory.Attendance);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -905,7 +1107,7 @@ public sealed class AttendanceController(
|
||||
request.Comment is not null
|
||||
? $"您的考勤申诉{result}。意见:{request.Comment}"
|
||||
: $"您的考勤申诉{result}。",
|
||||
null, cancellationToken);
|
||||
null, cancellationToken, NotificationCategory.Attendance);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -980,6 +1182,103 @@ public sealed class AttendanceController(
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddCheckInAttemptAsync(
|
||||
AttendanceSheet sheet,
|
||||
Guid studentId,
|
||||
AttendanceCheckInRequest request,
|
||||
bool isSuccessful,
|
||||
string? failureCode,
|
||||
double? distanceMeters,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
|
||||
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (deviceIdentifierHash is null)
|
||||
{
|
||||
riskFlags.Add("MissingDeviceId");
|
||||
}
|
||||
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
|
||||
x =>
|
||||
x.IsSuccessful &&
|
||||
x.StudentId != studentId &&
|
||||
x.DeviceIdentifierHash == deviceIdentifierHash &&
|
||||
x.CreatedAt >= now.AddHours(-24),
|
||||
cancellationToken))
|
||||
{
|
||||
riskFlags.Add("SharedDevice");
|
||||
}
|
||||
|
||||
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.StudentId == studentId &&
|
||||
x.CreatedAt >= now.AddMinutes(-2),
|
||||
cancellationToken);
|
||||
if (recentAttemptCount >= 5)
|
||||
riskFlags.Add("HighFrequency");
|
||||
|
||||
if (!isSuccessful)
|
||||
{
|
||||
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.StudentId == studentId &&
|
||||
!x.IsSuccessful &&
|
||||
x.CreatedAt >= now.AddMinutes(-5),
|
||||
cancellationToken);
|
||||
if (recentFailureCount >= 2)
|
||||
riskFlags.Add("RepeatedFailures");
|
||||
}
|
||||
|
||||
var context = ControllerContext.HttpContext;
|
||||
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
|
||||
{
|
||||
AttendanceSheetId = sheet.Id,
|
||||
StudentId = studentId,
|
||||
CheckInMethod = sheet.CheckInMethod,
|
||||
IsSuccessful = isSuccessful,
|
||||
FailureCode = failureCode,
|
||||
DeviceIdentifierHash = deviceIdentifierHash,
|
||||
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
|
||||
IpAddress = Limit(
|
||||
context?.Connection.RemoteIpAddress?.ToString(),
|
||||
64),
|
||||
UserAgent = Limit(
|
||||
context?.Request.Headers.UserAgent.ToString(),
|
||||
500),
|
||||
RiskFlags = riskFlags.Count == 0
|
||||
? null
|
||||
: string.Join(',', riskFlags.OrderBy(x => x)),
|
||||
Latitude = request.Latitude,
|
||||
Longitude = request.Longitude,
|
||||
AccuracyMeters = request.AccuracyMeters,
|
||||
DistanceMeters = distanceMeters,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
private static string? HashDeviceIdentifier(string? deviceId)
|
||||
{
|
||||
var normalized = Normalize(deviceId)?.ToLowerInvariant();
|
||||
return normalized is null
|
||||
? null
|
||||
: Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ParseRiskFlags(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? []
|
||||
: value.Split(
|
||||
',',
|
||||
StringSplitOptions.RemoveEmptyEntries |
|
||||
StringSplitOptions.TrimEntries);
|
||||
|
||||
private static string? Limit(string? value, int maximumLength) =>
|
||||
value is null || value.Length <= maximumLength
|
||||
? value
|
||||
: value[..maximumLength];
|
||||
|
||||
private static bool IsCheckInOpen(
|
||||
AttendanceSheetStatus status,
|
||||
AttendanceCheckInMethod method,
|
||||
@@ -1031,16 +1330,14 @@ public sealed class AttendanceController(
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (course is null) return null;
|
||||
|
||||
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)
|
||||
var enrolledStudents = await TeachingTaskRosterQuery
|
||||
.ForTask(db, teachingTaskId)
|
||||
.AsNoTracking()
|
||||
.Select(x => new AttendanceStatisticsStudentIdentity(
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
x.Student.AdministrativeClass!.Name))
|
||||
.Distinct()
|
||||
x.Id,
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.AdministrativeClass!.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var submittedSheets = await db.AttendanceSheets.AsNoTracking()
|
||||
@@ -1376,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
|
||||
[MaxLength(64)] string? Token,
|
||||
[Range(-90, 90)] decimal? Latitude,
|
||||
[Range(-180, 180)] decimal? Longitude,
|
||||
[Range(0, 5000)] double? AccuracyMeters);
|
||||
[Range(0, 5000)] double? AccuracyMeters,
|
||||
[MaxLength(128)] string? DeviceId = null,
|
||||
[MaxLength(32)] string? DevicePlatform = null);
|
||||
|
||||
public sealed record AttendanceCourseStatistics(
|
||||
AttendanceStatisticsCourse Course,
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -17,35 +18,51 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class AuthController(
|
||||
AppDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService) : ControllerBase
|
||||
IAuthSessionService authSessionService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("activation-options")]
|
||||
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
|
||||
{
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Colleges = colleges,
|
||||
Majors = majors,
|
||||
Classes = classes,
|
||||
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
|
||||
});
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.ActivationOptions,
|
||||
async token =>
|
||||
{
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
|
||||
.ToListAsync(token);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new ActivationMajorOption(
|
||||
x.Id, x.Code, x.Name, x.CollegeId))
|
||||
.ToListAsync(token);
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.Major!.IsEnabled &&
|
||||
x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new ActivationClassOption(
|
||||
x.Id, x.Code, x.Name, x.Grade, x.MajorId))
|
||||
.ToListAsync(token);
|
||||
return new ActivationOptionsResponse(
|
||||
colleges,
|
||||
majors,
|
||||
classes,
|
||||
classes.Select(x => x.Grade)
|
||||
.Distinct()
|
||||
.OrderByDescending(x => x)
|
||||
.ToList());
|
||||
},
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -118,8 +135,11 @@ public sealed class AuthController(
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
||||
public async Task<ActionResult<LoginResponse>> Login(
|
||||
LoginRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByNameAsync(request.UserName);
|
||||
if (user is null || !user.IsEnabled)
|
||||
@@ -149,15 +169,47 @@ public sealed class AuthController(
|
||||
await userManager.UpdateAsync(user);
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
|
||||
return new LoginResponse(
|
||||
tokenService.Create(user, roles),
|
||||
new CurrentUserResponse(
|
||||
user.Id,
|
||||
user.UserName!,
|
||||
user.DisplayName,
|
||||
roles,
|
||||
user.CollegeId,
|
||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
||||
var session = await authSessionService.CreateAsync(
|
||||
user,
|
||||
roles,
|
||||
request.IsNativeApp
|
||||
? AuthenticationClientType.App
|
||||
: AuthenticationClientType.Web,
|
||||
cancellationToken);
|
||||
return CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("token-refresh")]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await authSessionService.RefreshAsync(
|
||||
request.RefreshToken,
|
||||
cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return Unauthorized(new ProblemDetails
|
||||
{
|
||||
Title = "登录已过期",
|
||||
Detail = "登录已过期或刷新令牌已失效,请重新登录。",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
});
|
||||
}
|
||||
|
||||
return CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
@@ -195,11 +247,29 @@ public sealed class AuthController(
|
||||
Detail = detail,
|
||||
Status = status
|
||||
});
|
||||
|
||||
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
|
||||
new(
|
||||
session.AccessToken,
|
||||
session.AccessTokenExpiresAt,
|
||||
session.RefreshToken,
|
||||
session.SessionExpiresAt,
|
||||
new CurrentUserResponse(
|
||||
session.User.Id,
|
||||
session.User.UserName!,
|
||||
session.User.DisplayName,
|
||||
session.Roles,
|
||||
session.User.CollegeId,
|
||||
EffectiveDataScopeResolver.Resolve(session.Roles).ToString()));
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(
|
||||
[Required, MaxLength(100)] string UserName,
|
||||
[Required, MaxLength(100)] string Password);
|
||||
[Required, MaxLength(100)] string Password,
|
||||
bool IsNativeApp = false);
|
||||
|
||||
public sealed record RefreshTokenRequest(
|
||||
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
|
||||
|
||||
public sealed record StudentActivationRequest(
|
||||
[Required, MaxLength(50)] string Name,
|
||||
@@ -210,7 +280,12 @@ public sealed record StudentActivationRequest(
|
||||
Guid AdministrativeClassId,
|
||||
[Required, MinLength(8), MaxLength(100)] string Password);
|
||||
|
||||
public sealed record LoginResponse(string Token, CurrentUserResponse User);
|
||||
public sealed record LoginResponse(
|
||||
string Token,
|
||||
DateTime AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTime SessionExpiresAt,
|
||||
CurrentUserResponse User);
|
||||
|
||||
public sealed record CurrentUserResponse(
|
||||
Guid Id,
|
||||
@@ -219,3 +294,24 @@ public sealed record CurrentUserResponse(
|
||||
IEnumerable<string> Roles,
|
||||
Guid? CollegeId,
|
||||
string EffectiveDataScope);
|
||||
|
||||
public sealed record ActivationOptionsResponse(
|
||||
IReadOnlyList<ActivationCollegeOption> Colleges,
|
||||
IReadOnlyList<ActivationMajorOption> Majors,
|
||||
IReadOnlyList<ActivationClassOption> Classes,
|
||||
IReadOnlyList<int> Grades);
|
||||
|
||||
public sealed record ActivationCollegeOption(Guid Id, string Code, string Name);
|
||||
|
||||
public sealed record ActivationMajorOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId);
|
||||
|
||||
public sealed record ActivationClassOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Grade,
|
||||
Guid MajorId);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/base-data")]
|
||||
public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Administrators =
|
||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||
@@ -20,9 +21,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
[HttpGet("campuses")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.Campuses.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("campuses"),
|
||||
token => db.Campuses.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("campuses")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -52,21 +58,32 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
if (entity is null) return NotFound();
|
||||
ApplyCatalog(entity, request);
|
||||
entity.Address = request.Description?.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("colleges")]
|
||||
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Colleges.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
|
||||
CampusName = x.Campus != null ? x.Campus.Name : null,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("colleges"),
|
||||
token => db.Colleges.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new CollegeListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ShortName,
|
||||
x.CampusId,
|
||||
x.Campus != null ? x.Campus.Name : null,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("colleges")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -104,29 +121,46 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
ApplyCatalog(entity, request);
|
||||
entity.ShortName = request.ShortName?.Trim();
|
||||
entity.CampusId = request.CampusId;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("majors")]
|
||||
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Majors.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.CollegeId,
|
||||
CollegeName = x.College!.Name,
|
||||
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("majors"),
|
||||
token => db.Majors.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new MajorListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.CollegeId,
|
||||
x.College!.Name,
|
||||
x.DegreeType,
|
||||
x.SchoolingYears,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("course-categories")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.CourseCategories.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("course-categories"),
|
||||
token => db.CourseCategories.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("course-categories")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -154,7 +188,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
ApplyCatalog(entity, request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -193,23 +227,35 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.CollegeId = request.CollegeId;
|
||||
entity.DegreeType = request.DegreeType.Trim();
|
||||
entity.SchoolingYears = request.SchoolingYears;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classes")]
|
||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) =>
|
||||
Ok(await db.AdministrativeClasses.AsNoTracking()
|
||||
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
x.Grade, x.CounselorUserId, x.CounselorName,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("classes"),
|
||||
token => db.AdministrativeClasses.AsNoTracking()
|
||||
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||
.Select(x => new AdministrativeClassListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.MajorId,
|
||||
x.Major!.Name,
|
||||
x.Major.College!.Name,
|
||||
x.Grade,
|
||||
x.CounselorUserId,
|
||||
x.CounselorName,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("counselors")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -279,18 +325,23 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.Grade = request.Grade;
|
||||
entity.CounselorUserId = request.CounselorUserId;
|
||||
entity.CounselorName = counselorName;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("terms")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.AcademicTerms.AsNoTracking()
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("terms"),
|
||||
token => db.AcademicTerms.AsNoTracking()
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("terms")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.StartDate = request.StartDate;
|
||||
entity.EndDate = request.EndDate;
|
||||
entity.IsCurrent = request.IsCurrent;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
entity.IsCurrent = true;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
|
||||
entity.IsArchived = true;
|
||||
entity.ArchivedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
|
||||
entity.IsArchived = false;
|
||||
entity.ArchivedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classrooms")]
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Classrooms.AsNoTracking()
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.BuildingId,
|
||||
CampusId = x.Building!.CampusId,
|
||||
BuildingName = x.Building!.Name,
|
||||
CampusName = x.Building.Campus!.Name,
|
||||
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("classrooms"),
|
||||
token => db.Classrooms.AsNoTracking()
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new ClassroomListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.BuildingId,
|
||||
x.Building!.CampusId,
|
||||
x.Building.Name,
|
||||
x.Building.Campus!.Name,
|
||||
x.Capacity,
|
||||
x.RoomType,
|
||||
x.Equipment,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("buildings")]
|
||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Buildings.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.CampusId,
|
||||
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("buildings"),
|
||||
token => db.Buildings.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new BuildingListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.CampusId,
|
||||
x.Campus!.Name,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("buildings")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.Capacity = request.Capacity;
|
||||
entity.RoomType = request.RoomType.Trim();
|
||||
entity.Equipment = request.Equipment?.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
db.Remove(entity);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
db.Add(entity);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
@@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
return CreatedAtAction(action, new { id = entity.Id }, entity);
|
||||
}
|
||||
|
||||
private async Task SaveAndInvalidateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> GetCounselorNameAsync(
|
||||
Guid? userId,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -649,3 +730,60 @@ public sealed record ClassroomRequest(
|
||||
[Required, MaxLength(40)] string RoomType,
|
||||
[MaxLength(300)] string? Equipment)
|
||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||
|
||||
public sealed record CollegeListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? ShortName,
|
||||
Guid? CampusId,
|
||||
string? CampusName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record MajorListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId,
|
||||
string CollegeName,
|
||||
string DegreeType,
|
||||
int SchoolingYears,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record AdministrativeClassListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
string CollegeName,
|
||||
int Grade,
|
||||
Guid? CounselorUserId,
|
||||
string? CounselorName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record BuildingListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CampusId,
|
||||
string CampusName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record ClassroomListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid BuildingId,
|
||||
Guid CampusId,
|
||||
string BuildingName,
|
||||
string CampusName,
|
||||
int Capacity,
|
||||
string RoomType,
|
||||
string? Equipment,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = Administrators)]
|
||||
[Route("api/base-data")]
|
||||
public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Administrators =
|
||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||
@@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.BaseData,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/classroom-reservations")]
|
||||
public sealed class ClassroomReservationsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope scope,
|
||||
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
|
||||
{
|
||||
[HttpGet("options")]
|
||||
public async Task<ActionResult> GetOptions(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(term => term.IsEnabled && !term.IsArchived)
|
||||
.OrderByDescending(term => term.IsCurrent)
|
||||
.ThenByDescending(term => term.StartDate)
|
||||
.Select(term => new
|
||||
{
|
||||
term.Id,
|
||||
term.Name,
|
||||
term.StartDate,
|
||||
term.EndDate,
|
||||
term.IsCurrent,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == term.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var selectedTermId = academicTermId
|
||||
?? terms.FirstOrDefault(term =>
|
||||
term.IsCurrent && term.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault(term => term.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault()?.Id;
|
||||
var campuses = await db.Campuses.AsNoTracking()
|
||||
.Where(campus => campus.IsEnabled)
|
||||
.OrderBy(campus => campus.SortOrder)
|
||||
.ThenBy(campus => campus.Code)
|
||||
.Select(campus => new { campus.Id, campus.Code, campus.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var buildings = await db.Buildings.AsNoTracking()
|
||||
.Where(building =>
|
||||
building.IsEnabled &&
|
||||
building.Campus!.IsEnabled)
|
||||
.OrderBy(building => building.Campus!.SortOrder)
|
||||
.ThenBy(building => building.SortOrder)
|
||||
.ThenBy(building => building.Code)
|
||||
.Select(building => new
|
||||
{
|
||||
building.Id,
|
||||
building.Code,
|
||||
building.Name,
|
||||
building.CampusId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(classroom =>
|
||||
classroom.IsEnabled &&
|
||||
classroom.Building!.IsEnabled &&
|
||||
classroom.Building.Campus!.IsEnabled)
|
||||
.OrderBy(classroom => classroom.Building!.Campus!.SortOrder)
|
||||
.ThenBy(classroom => classroom.Building!.SortOrder)
|
||||
.ThenBy(classroom => classroom.Code)
|
||||
.Select(classroom => new
|
||||
{
|
||||
classroom.Id,
|
||||
classroom.Code,
|
||||
classroom.Name,
|
||||
classroom.BuildingId,
|
||||
classroom.Capacity,
|
||||
classroom.RoomType
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var timeSlots = await LoadTimeSlotsAsync(
|
||||
selectedTermId,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Applicant = applicant.Context is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
applicant.Context.User.Id,
|
||||
applicant.Context.User.DisplayName,
|
||||
CollegeId = applicant.Context.CollegeId,
|
||||
CollegeName = applicant.Context.CollegeName
|
||||
},
|
||||
ApplicantProblem = applicant.Error,
|
||||
Terms = terms,
|
||||
SelectedTermId = selectedTermId,
|
||||
Campuses = campuses,
|
||||
Buildings = buildings,
|
||||
Classrooms = classrooms,
|
||||
TimeSlots = timeSlots
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("availability")]
|
||||
public async Task<ActionResult> GetAvailability(
|
||||
Guid academicTermId,
|
||||
DateOnly reservationDate,
|
||||
[Range(1, 30)] int startPeriod,
|
||||
[Range(1, 6)] int periodCount,
|
||||
Guid? campusId,
|
||||
Guid? buildingId,
|
||||
[Range(1, 10000)] int? attendeeCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ValidateSlotAsync(
|
||||
academicTermId,
|
||||
reservationDate,
|
||||
startPeriod,
|
||||
periodCount,
|
||||
cancellationToken);
|
||||
if (validation.Error is not null) return validation.Error;
|
||||
|
||||
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||
validation.Term!,
|
||||
reservationDate,
|
||||
startPeriod,
|
||||
periodCount,
|
||||
null,
|
||||
cancellationToken);
|
||||
var rooms = db.Classrooms.AsNoTracking()
|
||||
.Where(classroom =>
|
||||
classroom.IsEnabled &&
|
||||
classroom.Building!.IsEnabled &&
|
||||
classroom.Building.Campus!.IsEnabled)
|
||||
.WhereNotIn(occupiedIds, classroom => classroom.Id);
|
||||
if (campusId.HasValue)
|
||||
rooms = rooms.Where(room =>
|
||||
room.Building!.CampusId == campusId.Value);
|
||||
if (buildingId.HasValue)
|
||||
rooms = rooms.Where(room => room.BuildingId == buildingId.Value);
|
||||
if (attendeeCount.HasValue)
|
||||
rooms = rooms.Where(room => room.Capacity >= attendeeCount.Value);
|
||||
|
||||
var items = await rooms
|
||||
.OrderBy(room => room.Building!.Campus!.SortOrder)
|
||||
.ThenBy(room => room.Building!.SortOrder)
|
||||
.ThenBy(room => room.Code)
|
||||
.Select(room => new
|
||||
{
|
||||
room.Id,
|
||||
room.Code,
|
||||
room.Name,
|
||||
room.Capacity,
|
||||
room.RoomType,
|
||||
room.BuildingId,
|
||||
BuildingName = room.Building!.Name,
|
||||
CampusId = room.Building.CampusId,
|
||||
CampusName = room.Building.Campus!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Items = items });
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetMine(
|
||||
ClassroomReservationStatus? status,
|
||||
[Range(1, int.MaxValue)] int page = 1,
|
||||
[Range(1, 100)] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = db.ClassroomReservations.AsNoTracking()
|
||||
.Where(reservation =>
|
||||
reservation.ApplicantUserId == scope.Current.UserId);
|
||||
if (status.HasValue)
|
||||
query = query.Where(reservation => reservation.Status == status.Value);
|
||||
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("review")]
|
||||
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetReviewQueue(
|
||||
ClassroomReservationStatus? status,
|
||||
[Range(1, int.MaxValue)] int page = 1,
|
||||
[Range(1, 100)] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collegeError = ReviewCollegeError();
|
||||
if (collegeError is not null) return collegeError;
|
||||
var collegeId = scope.Current.CollegeId!.Value;
|
||||
var query = db.ClassroomReservations.AsNoTracking()
|
||||
.Where(reservation => reservation.ApplicantCollegeId == collegeId);
|
||||
if (status.HasValue)
|
||||
query = query.Where(reservation => reservation.Status == status.Value);
|
||||
else
|
||||
query = query.Where(reservation =>
|
||||
reservation.Status == ClassroomReservationStatus.Submitted);
|
||||
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(
|
||||
CreateClassroomReservationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Purpose))
|
||||
return ValidationProblem("请填写借用用途。");
|
||||
if (string.IsNullOrWhiteSpace(request.ContactPhone))
|
||||
return ValidationProblem("请填写联系电话。");
|
||||
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||
if (applicant.Context is null)
|
||||
return ConflictProblem(
|
||||
applicant.Error ?? "当前账号未配置申请人学院,无法提交申请。");
|
||||
|
||||
var validation = await ValidateSlotAsync(
|
||||
request.AcademicTermId,
|
||||
request.ReservationDate,
|
||||
request.StartPeriod,
|
||||
request.PeriodCount,
|
||||
cancellationToken);
|
||||
if (validation.Error is not null) return validation.Error;
|
||||
|
||||
var classroom = await db.Classrooms.AsNoTracking()
|
||||
.Where(room =>
|
||||
room.Id == request.ClassroomId &&
|
||||
room.IsEnabled &&
|
||||
room.Building!.IsEnabled &&
|
||||
room.Building.Campus!.IsEnabled)
|
||||
.Select(room => new
|
||||
{
|
||||
room.Id,
|
||||
room.Name,
|
||||
room.Code,
|
||||
room.Capacity,
|
||||
BuildingName = room.Building!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (classroom is null) return NotFound();
|
||||
if (request.AttendeeCount > classroom.Capacity)
|
||||
return ValidationProblem(
|
||||
$"申请人数超过教室容量({classroom.Capacity} 人)。");
|
||||
|
||||
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||
validation.Term!,
|
||||
request.ReservationDate,
|
||||
request.StartPeriod,
|
||||
request.PeriodCount,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (occupiedIds.Contains(classroom.Id))
|
||||
return ConflictProblem("所选教室在该时段已有课程、考试或已批准预约。");
|
||||
|
||||
var duplicate = await db.ClassroomReservations.AsNoTracking()
|
||||
.AnyAsync(reservation =>
|
||||
reservation.ApplicantUserId == applicant.Context.User.Id &&
|
||||
reservation.ClassroomId == classroom.Id &&
|
||||
reservation.ReservationDate == request.ReservationDate &&
|
||||
reservation.Status == ClassroomReservationStatus.Submitted &&
|
||||
reservation.StartPeriod <
|
||||
request.StartPeriod + request.PeriodCount &&
|
||||
request.StartPeriod <
|
||||
reservation.StartPeriod + reservation.PeriodCount,
|
||||
cancellationToken);
|
||||
if (duplicate)
|
||||
return ConflictProblem("您已提交过同一教室、重叠时段的申请。");
|
||||
|
||||
var reservation = new ClassroomReservation
|
||||
{
|
||||
ApplicantUserId = applicant.Context.User.Id,
|
||||
ApplicantName = applicant.Context.User.DisplayName,
|
||||
ApplicantCollegeId = applicant.Context.CollegeId,
|
||||
AcademicTermId = request.AcademicTermId,
|
||||
ClassroomId = request.ClassroomId,
|
||||
ReservationDate = request.ReservationDate,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
AttendeeCount = request.AttendeeCount,
|
||||
Purpose = request.Purpose.Trim(),
|
||||
ContactPhone = request.ContactPhone.Trim(),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
db.ClassroomReservations.Add(reservation);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.CollegeAdmin,
|
||||
"教室借用申请待审核",
|
||||
$"{reservation.ApplicantName} 申请于 {reservation.ReservationDate:yyyy-MM-dd} " +
|
||||
$"借用 {classroom.BuildingName} {classroom.Name}。",
|
||||
reservation.ApplicantCollegeId,
|
||||
"/classroom-reservations",
|
||||
cancellationToken,
|
||||
NotificationCategory.Approval);
|
||||
return Created("", new { reservation.Id });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
public async Task<ActionResult> Cancel(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var reservation = await db.ClassroomReservations
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.Id == id &&
|
||||
item.ApplicantUserId == scope.Current.UserId,
|
||||
cancellationToken);
|
||||
if (reservation is null) return NotFound();
|
||||
if (reservation.Status is not (
|
||||
ClassroomReservationStatus.Submitted or
|
||||
ClassroomReservationStatus.Approved))
|
||||
return ConflictProblem("当前状态不能取消。");
|
||||
if (reservation.ReservationDate < TodayInChina())
|
||||
return ConflictProblem("已过期的预约不能取消。");
|
||||
|
||||
var wasApproved =
|
||||
reservation.Status == ClassroomReservationStatus.Approved;
|
||||
reservation.Status = ClassroomReservationStatus.Cancelled;
|
||||
reservation.CancelledAt = DateTime.UtcNow;
|
||||
reservation.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (wasApproved)
|
||||
{
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.CollegeAdmin,
|
||||
"已批准教室预约被申请人取消",
|
||||
$"{reservation.ApplicantName} 已取消 " +
|
||||
$"{reservation.ReservationDate:yyyy-MM-dd} 的教室预约。",
|
||||
reservation.ApplicantCollegeId,
|
||||
"/classroom-reservations",
|
||||
cancellationToken,
|
||||
NotificationCategory.Approval);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||
public Task<ActionResult> Approve(
|
||||
Guid id,
|
||||
ReviewClassroomReservationRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
ReviewAsync(id, true, request.Comment, cancellationToken);
|
||||
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||
public Task<ActionResult> Reject(
|
||||
Guid id,
|
||||
ReviewClassroomReservationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("驳回时必须填写审核意见。"));
|
||||
return ReviewAsync(id, false, request.Comment, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ActionResult> ReviewAsync(
|
||||
Guid id,
|
||||
bool approve,
|
||||
string? comment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var collegeError = ReviewCollegeError();
|
||||
if (collegeError is not null) return collegeError;
|
||||
var reviewerCollegeId = scope.Current.CollegeId!.Value;
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var reservation = await db.ClassroomReservations
|
||||
.Include(item => item.AcademicTerm)
|
||||
.Include(item => item.Classroom)
|
||||
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
if (reservation is null) return NotFound();
|
||||
if (reservation.ApplicantCollegeId != reviewerCollegeId)
|
||||
return Forbid();
|
||||
if (reservation.Status != ClassroomReservationStatus.Submitted)
|
||||
return ConflictProblem("该申请已处理,不能重复审核。");
|
||||
|
||||
if (approve)
|
||||
{
|
||||
if (reservation.ReservationDate < TodayInChina())
|
||||
return ConflictProblem("预约日期已过,不能批准。");
|
||||
var validation = await ValidateSlotAsync(
|
||||
reservation.AcademicTermId,
|
||||
reservation.ReservationDate,
|
||||
reservation.StartPeriod,
|
||||
reservation.PeriodCount,
|
||||
cancellationToken);
|
||||
if (validation.Error is not null) return validation.Error;
|
||||
if (reservation.AttendeeCount > reservation.Classroom!.Capacity)
|
||||
return ConflictProblem("申请人数已超过教室当前容量,不能批准。");
|
||||
var occupiedIds =
|
||||
await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||
validation.Term!,
|
||||
reservation.ReservationDate,
|
||||
reservation.StartPeriod,
|
||||
reservation.PeriodCount,
|
||||
reservation.Id,
|
||||
cancellationToken);
|
||||
if (occupiedIds.Contains(reservation.ClassroomId))
|
||||
return ConflictProblem(
|
||||
"教室在该时段已被课程、考试或其他已批准预约占用。");
|
||||
reservation.Status = ClassroomReservationStatus.Approved;
|
||||
}
|
||||
else
|
||||
{
|
||||
reservation.Status = ClassroomReservationStatus.Rejected;
|
||||
}
|
||||
|
||||
reservation.ReviewedByUserId = scope.Current.UserId;
|
||||
reservation.ReviewedAt = DateTime.UtcNow;
|
||||
reservation.ReviewComment = Normalize(comment);
|
||||
reservation.UpdatedAt = DateTime.UtcNow;
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
reservation.ApplicantUserId,
|
||||
approve ? "教室借用申请已通过" : "教室借用申请已驳回",
|
||||
approve
|
||||
? $"{reservation.ReservationDate:yyyy-MM-dd} 的教室借用申请已通过。"
|
||||
: reservation.ReviewComment ?? "审核未通过。",
|
||||
"/classroom-reservations",
|
||||
cancellationToken,
|
||||
NotificationCategory.Approval);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
private async Task<PagedResult<ClassroomReservationDto>> BuildPageAsync(
|
||||
IQueryable<ClassroomReservation> query,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(reservation =>
|
||||
reservation.Status == ClassroomReservationStatus.Submitted)
|
||||
.ThenByDescending(reservation => reservation.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(reservation => new ClassroomReservationDto(
|
||||
reservation.Id,
|
||||
reservation.ApplicantUserId,
|
||||
reservation.ApplicantName,
|
||||
reservation.ApplicantCollegeId,
|
||||
reservation.ApplicantCollege!.Name,
|
||||
reservation.AcademicTermId,
|
||||
reservation.AcademicTerm!.Name,
|
||||
reservation.ClassroomId,
|
||||
reservation.Classroom!.Code,
|
||||
reservation.Classroom.Name,
|
||||
reservation.Classroom.Building!.Name,
|
||||
reservation.Classroom.Building.Campus!.Name,
|
||||
reservation.ReservationDate,
|
||||
reservation.StartPeriod,
|
||||
reservation.PeriodCount,
|
||||
reservation.AttendeeCount,
|
||||
reservation.Purpose,
|
||||
reservation.ContactPhone,
|
||||
reservation.Notes,
|
||||
reservation.Status,
|
||||
reservation.ReviewComment,
|
||||
reservation.ReviewedAt,
|
||||
reservation.CancelledAt,
|
||||
reservation.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
return new PagedResult<ClassroomReservationDto>(
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize);
|
||||
}
|
||||
|
||||
private async Task<(
|
||||
AcademicTerm? Term,
|
||||
ActionResult? Error)> ValidateSlotAsync(
|
||||
Guid academicTermId,
|
||||
DateOnly reservationDate,
|
||||
int startPeriod,
|
||||
int periodCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var term = await db.AcademicTerms.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.Id == academicTermId &&
|
||||
item.IsEnabled &&
|
||||
!item.IsArchived,
|
||||
cancellationToken);
|
||||
if (term is null) return (null, NotFound());
|
||||
if (reservationDate < TodayInChina())
|
||||
return (null, ValidationProblem("预约日期不能早于今天。"));
|
||||
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||
return (null, ValidationProblem("预约日期不在所选学期范围内。"));
|
||||
var hasPublishedTimetable = await db.SchedulePlans.AsNoTracking()
|
||||
.AnyAsync(plan =>
|
||||
plan.AcademicTermId == term.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published,
|
||||
cancellationToken);
|
||||
if (!hasPublishedTimetable)
|
||||
return (null, ConflictProblem(
|
||||
"所选学期的正式课表尚未发布,暂不能提交教室预约。"));
|
||||
|
||||
var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray();
|
||||
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.CountAsync(slot =>
|
||||
slot.AcademicTermId == academicTermId,
|
||||
cancellationToken);
|
||||
if (configuredPeriodCount == 0)
|
||||
{
|
||||
if (requestedPeriods.Any(period => period > 12))
|
||||
return (null, ValidationProblem("预约范围包含不存在的节次。"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var enabledPeriods = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(slot =>
|
||||
slot.AcademicTermId == academicTermId &&
|
||||
slot.IsEnabled)
|
||||
.WhereIn(requestedPeriods, slot => slot.PeriodNumber)
|
||||
.CountAsync(cancellationToken);
|
||||
if (enabledPeriods != requestedPeriods.Length)
|
||||
return (null, ValidationProblem("预约范围包含不存在或未启用的节次。"));
|
||||
}
|
||||
return (term, null);
|
||||
}
|
||||
|
||||
private async Task<List<ClassroomReservationTimeSlotDto>> LoadTimeSlotsAsync(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = academicTermId.HasValue
|
||||
? await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(slot =>
|
||||
slot.AcademicTermId == academicTermId.Value &&
|
||||
slot.IsEnabled)
|
||||
.OrderBy(slot => slot.PeriodNumber)
|
||||
.Select(slot => new ClassroomReservationTimeSlotDto(
|
||||
slot.PeriodNumber,
|
||||
slot.Name,
|
||||
slot.StartsAt.ToString("HH:mm"),
|
||||
slot.EndsAt.ToString("HH:mm")))
|
||||
.ToListAsync(cancellationToken)
|
||||
: [];
|
||||
return result.Count > 0
|
||||
? result
|
||||
: Enumerable.Range(1, 12)
|
||||
.Select(period => new ClassroomReservationTimeSlotDto(
|
||||
period,
|
||||
$"第 {period} 节",
|
||||
"",
|
||||
""))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<ApplicantResolution> ResolveApplicantAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (scope.Current.UserId == Guid.Empty)
|
||||
return new(null, "无法识别当前登录账号。");
|
||||
var user = await db.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.Id == scope.Current.UserId &&
|
||||
item.IsEnabled,
|
||||
cancellationToken);
|
||||
if (user is null) return new(null, "当前账号不存在或已停用。");
|
||||
|
||||
var linkedCollegeIds = new List<Guid>();
|
||||
if (user.CollegeId.HasValue) linkedCollegeIds.Add(user.CollegeId.Value);
|
||||
var teacherCollegeId = await db.Teachers.AsNoTracking()
|
||||
.Where(teacher => teacher.UserId == user.Id)
|
||||
.Select(teacher => (Guid?)teacher.CollegeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (teacherCollegeId.HasValue)
|
||||
linkedCollegeIds.Add(teacherCollegeId.Value);
|
||||
var studentCollegeId = await db.Students.AsNoTracking()
|
||||
.Where(student => student.UserId == user.Id)
|
||||
.Select(student =>
|
||||
(Guid?)student.AdministrativeClass!.Major!.CollegeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (studentCollegeId.HasValue)
|
||||
linkedCollegeIds.Add(studentCollegeId.Value);
|
||||
|
||||
var distinctIds = linkedCollegeIds.Distinct().ToArray();
|
||||
if (distinctIds.Length == 0)
|
||||
return new(null, "当前账号未配置所在学院,请联系账号管理员补充学院信息。");
|
||||
if (distinctIds.Length > 1)
|
||||
return new(null, "账号与人员档案的学院信息不一致,请联系管理员修正后再申请。");
|
||||
var college = await db.Colleges.AsNoTracking()
|
||||
.Where(item => item.Id == distinctIds[0] && item.IsEnabled)
|
||||
.Select(item => new { item.Id, item.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return college is null
|
||||
? new(null, "当前账号所属学院不存在或已停用。")
|
||||
: new(
|
||||
new ApplicantContext(user, college.Id, college.Name),
|
||||
null);
|
||||
}
|
||||
|
||||
private ActionResult? ReviewCollegeError()
|
||||
{
|
||||
if (!scope.Current.IsInRole(SystemRoles.CollegeAdmin))
|
||||
return Forbid();
|
||||
return scope.Current.CollegeId.HasValue
|
||||
? null
|
||||
: ConflictProblem("学院管理员账号未配置所属学院,无法审核。");
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法完成教室预约",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static DateOnly TodayInChina()
|
||||
{
|
||||
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var zone = TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
return DateOnly.FromDateTime(
|
||||
TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone).DateTime);
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (InvalidTimeZoneException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return DateOnly.FromDateTime(DateTime.Today);
|
||||
}
|
||||
|
||||
private sealed record ApplicantContext(
|
||||
ApplicationUser User,
|
||||
Guid CollegeId,
|
||||
string CollegeName);
|
||||
|
||||
private sealed record ApplicantResolution(
|
||||
ApplicantContext? Context,
|
||||
string? Error);
|
||||
}
|
||||
|
||||
public sealed record CreateClassroomReservationRequest(
|
||||
Guid AcademicTermId,
|
||||
Guid ClassroomId,
|
||||
DateOnly ReservationDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
[Range(1, 10000)] int AttendeeCount,
|
||||
[Required, StringLength(200)] string Purpose,
|
||||
[Required, StringLength(30)] string ContactPhone,
|
||||
[StringLength(500)] string? Notes);
|
||||
|
||||
public sealed record ReviewClassroomReservationRequest(
|
||||
[StringLength(500)] string? Comment);
|
||||
|
||||
public sealed record ClassroomReservationTimeSlotDto(
|
||||
int PeriodNumber,
|
||||
string Name,
|
||||
string StartTime,
|
||||
string EndTime);
|
||||
|
||||
public sealed record ClassroomReservationDto(
|
||||
Guid Id,
|
||||
Guid ApplicantUserId,
|
||||
string ApplicantName,
|
||||
Guid ApplicantCollegeId,
|
||||
string ApplicantCollegeName,
|
||||
Guid AcademicTermId,
|
||||
string AcademicTermName,
|
||||
Guid ClassroomId,
|
||||
string ClassroomCode,
|
||||
string ClassroomName,
|
||||
string BuildingName,
|
||||
string CampusName,
|
||||
DateOnly ReservationDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
int AttendeeCount,
|
||||
string Purpose,
|
||||
string ContactPhone,
|
||||
string? Notes,
|
||||
ClassroomReservationStatus Status,
|
||||
string? ReviewComment,
|
||||
DateTime? ReviewedAt,
|
||||
DateTime? CancelledAt,
|
||||
DateTime CreatedAt);
|
||||
@@ -3,6 +3,8 @@ using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -27,6 +29,145 @@ public sealed class CourseAdjustmentsController(
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
[HttpGet("task-options")]
|
||||
[Authorize(Roles = Applicants)]
|
||||
public async Task<ActionResult> GetTaskOptions(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = AccessibleTeachingTasks().AsNoTracking()
|
||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
|
||||
return Ok(await source
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
ClassNames = x.Classes
|
||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||
.Select(item => item.AdministrativeClass!.Name)
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{teachingTaskId:guid}/session-options")]
|
||||
[Authorize(Roles = Applicants)]
|
||||
public async Task<ActionResult> GetSessionOptions(
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Id == teachingTaskId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
CourseName = x.Course!.Name,
|
||||
x.AcademicTermId,
|
||||
TermStartDate = x.AcademicTerm!.StartDate,
|
||||
TermEndDate = x.AcademicTerm.EndDate
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
var entries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == teachingTaskId &&
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published)
|
||||
.OrderBy(x => x.DayOfWeek)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.DayOfWeek,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.ClassroomId,
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var occupied = await db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == teachingTaskId &&
|
||||
x.SourceScheduleEntryId != null &&
|
||||
x.SourceWeek != null &&
|
||||
(x.Status == CourseAdjustmentStatus.Submitted ||
|
||||
x.Status == CourseAdjustmentStatus.Approved))
|
||||
.Select(x => new { x.SourceScheduleEntryId, x.SourceWeek })
|
||||
.ToListAsync(cancellationToken);
|
||||
var occupiedOccurrences = occupied
|
||||
.Select(x => (x.SourceScheduleEntryId!.Value, x.SourceWeek!.Value))
|
||||
.ToHashSet();
|
||||
|
||||
var sessions = entries
|
||||
.SelectMany(entry => Enumerable.Range(
|
||||
entry.StartWeek,
|
||||
entry.EndWeek - entry.StartWeek + 1)
|
||||
.Where(week => IncludesWeek(entry.WeekPattern, week))
|
||||
.Select(week => new
|
||||
{
|
||||
ScheduleEntryId = entry.Id,
|
||||
Week = week,
|
||||
Date = ResolveDate(task.TermStartDate, week, entry.DayOfWeek),
|
||||
entry.DayOfWeek,
|
||||
entry.StartPeriod,
|
||||
entry.PeriodCount,
|
||||
entry.ClassroomId,
|
||||
entry.ClassroomName,
|
||||
entry.BuildingName,
|
||||
HasExistingAdjustment = occupiedOccurrences.Contains((entry.Id, week))
|
||||
}))
|
||||
.Where(x => x.Date >= task.TermStartDate && x.Date <= task.TermEndDate)
|
||||
.OrderBy(x => x.Date)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ToList();
|
||||
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Building!.SortOrder)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
BuildingName = x.Building!.Name,
|
||||
CampusName = x.Building.Campus!.Name,
|
||||
x.Capacity
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var periods = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == task.AcademicTermId && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.PeriodNumber,
|
||||
x.Name,
|
||||
StartsAt = x.StartsAt.ToString("HH:mm"),
|
||||
EndsAt = x.EndsAt.ToString("HH:mm")
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
|
||||
}
|
||||
|
||||
// ═══════════════ My adjustments ═══════════════
|
||||
|
||||
[HttpGet("mine")]
|
||||
@@ -100,20 +241,41 @@ public sealed class CourseAdjustmentsController(
|
||||
var validation = await ValidateRequestAsync(request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
var source = RequiresSourceOccurrence(request.Type)
|
||||
? await LoadSourceOccurrenceAsync(
|
||||
request.TeachingTaskId,
|
||||
request.SourceScheduleEntryId,
|
||||
request.SourceWeek,
|
||||
cancellationToken)
|
||||
: null;
|
||||
var targetDayOfWeek = request.TargetDate.HasValue
|
||||
? ToIsoDayOfWeek(request.TargetDate.Value.DayOfWeek)
|
||||
: request.DayOfWeek;
|
||||
|
||||
var adj = new CourseAdjustment
|
||||
{
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
Type = request.Type,
|
||||
ApplicantUserId = userId,
|
||||
Reason = request.Reason.Trim(),
|
||||
SourceScheduleEntryId = source?.Entry.Id,
|
||||
SourceWeek = source?.Week,
|
||||
SourceDate = source?.Date,
|
||||
SourceStartPeriod = source?.Entry.StartPeriod,
|
||||
SourcePeriodCount = source?.Entry.PeriodCount,
|
||||
SourceClassroomId = source?.Entry.ClassroomId,
|
||||
TargetDate = request.TargetDate,
|
||||
DayOfWeek = request.DayOfWeek,
|
||||
DayOfWeek = targetDayOfWeek,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount,
|
||||
ClassroomId = request.ClassroomId,
|
||||
SubstituteTeacherId = request.SubstituteTeacherId,
|
||||
CancelWeek = request.CancelWeek,
|
||||
CancelDate = request.CancelDate
|
||||
CancelWeek = request.Type == CourseAdjustmentType.Cancel
|
||||
? source?.Week
|
||||
: request.CancelWeek,
|
||||
CancelDate = request.Type == CourseAdjustmentType.Cancel
|
||||
? source?.Date
|
||||
: request.CancelDate
|
||||
};
|
||||
|
||||
if (request.Submit)
|
||||
@@ -138,7 +300,8 @@ public sealed class CourseAdjustmentsController(
|
||||
SystemRoles.CollegeAdmin,
|
||||
$"新的{tl}申请",
|
||||
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
|
||||
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
|
||||
taskInfo.CollegeId, "/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +324,25 @@ public sealed class CourseAdjustmentsController(
|
||||
if (adj.Status != CourseAdjustmentStatus.Draft)
|
||||
return ConflictProblem("只有草稿可以提交。");
|
||||
|
||||
var validation = await ValidateRequestAsync(
|
||||
new CourseAdjustmentRequest(
|
||||
adj.TeachingTaskId,
|
||||
adj.Type,
|
||||
adj.Reason,
|
||||
true,
|
||||
adj.TargetDate,
|
||||
adj.DayOfWeek,
|
||||
adj.StartPeriod,
|
||||
adj.PeriodCount,
|
||||
adj.ClassroomId,
|
||||
adj.SubstituteTeacherId,
|
||||
adj.CancelWeek,
|
||||
adj.CancelDate,
|
||||
adj.SourceScheduleEntryId,
|
||||
adj.SourceWeek),
|
||||
cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
adj.Status = CourseAdjustmentStatus.Submitted;
|
||||
adj.SubmittedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -171,12 +353,14 @@ public sealed class CourseAdjustmentsController(
|
||||
$"新的{TypeLabel(adj.Type)}申请",
|
||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
||||
adj.TeachingTask.Course.CollegeId,
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
await NotificationService.SendToRoleAsync(db,
|
||||
SystemRoles.AcademicAdmin,
|
||||
$"新的{TypeLabel(adj.Type)}申请",
|
||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
||||
null, "/course-adjustments", cancellationToken);
|
||||
null, "/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -190,6 +374,12 @@ public sealed class CourseAdjustmentsController(
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.ThenInclude(x => x!.College)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Classes)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (adj is null) return NotFound();
|
||||
|
||||
@@ -200,6 +390,12 @@ public sealed class CourseAdjustmentsController(
|
||||
if (adj.Status != CourseAdjustmentStatus.Submitted)
|
||||
return ConflictProblem("只有待审核的申请可以审批。");
|
||||
|
||||
var scheduleProblem = await ValidateApprovalScheduleAsync(
|
||||
adj,
|
||||
cancellationToken);
|
||||
if (scheduleProblem is not null)
|
||||
return ConflictProblem(scheduleProblem);
|
||||
|
||||
adj.Status = CourseAdjustmentStatus.Approved;
|
||||
adj.ReviewedAt = DateTime.UtcNow;
|
||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||
@@ -213,14 +409,13 @@ public sealed class CourseAdjustmentsController(
|
||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||
"调停课申请已通过",
|
||||
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
|
||||
// Notify affected students
|
||||
var studentUserIds = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.Select(x => x.Student!.UserId)
|
||||
var studentUserIds = await TeachingTaskRosterQuery
|
||||
.ForTask(db, adj.TeachingTaskId)
|
||||
.Select(x => x.UserId)
|
||||
.Where(uid => uid != null)
|
||||
.Select(uid => uid!.Value)
|
||||
.Distinct()
|
||||
@@ -230,7 +425,8 @@ public sealed class CourseAdjustmentsController(
|
||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||
"课程变动通知",
|
||||
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
||||
"/my-timetable", cancellationToken);
|
||||
"/my-timetable", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -242,81 +438,19 @@ public sealed class CourseAdjustmentsController(
|
||||
switch (adj.Type)
|
||||
{
|
||||
case CourseAdjustmentType.Reschedule:
|
||||
// Update existing schedule entries for this teaching task
|
||||
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
|
||||
{
|
||||
var entries = await db.ScheduleEntries
|
||||
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
|
||||
.ToListAsync(ct);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
entry.DayOfWeek = adj.DayOfWeek.Value;
|
||||
entry.StartPeriod = adj.StartPeriod.Value;
|
||||
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
|
||||
}
|
||||
if (adj.ClassroomId.HasValue)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
entry.ClassroomId = adj.ClassroomId.Value;
|
||||
}
|
||||
}
|
||||
var rescheduleSource = await LoadSourceEntryAsync(adj, ct);
|
||||
ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value);
|
||||
db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId));
|
||||
break;
|
||||
|
||||
case CourseAdjustmentType.Cancel:
|
||||
// Cancel: remove schedule entries for the specified week
|
||||
if (adj.CancelWeek.HasValue)
|
||||
{
|
||||
var entries = await db.ScheduleEntries
|
||||
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
|
||||
x.StartWeek <= adj.CancelWeek.Value &&
|
||||
x.EndWeek >= adj.CancelWeek.Value)
|
||||
.ToListAsync(ct);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
// Split the entry to exclude the cancelled week
|
||||
if (entry.StartWeek == adj.CancelWeek.Value &&
|
||||
entry.EndWeek == adj.CancelWeek.Value)
|
||||
{
|
||||
db.ScheduleEntries.Remove(entry);
|
||||
}
|
||||
else if (entry.StartWeek == adj.CancelWeek.Value)
|
||||
{
|
||||
entry.StartWeek = adj.CancelWeek.Value + 1;
|
||||
}
|
||||
else if (entry.EndWeek == adj.CancelWeek.Value)
|
||||
{
|
||||
entry.EndWeek = adj.CancelWeek.Value - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
var cancelSource = await LoadSourceEntryAsync(adj, ct);
|
||||
ExcludeWeek(cancelSource, adj.SourceWeek!.Value);
|
||||
break;
|
||||
|
||||
case CourseAdjustmentType.Makeup:
|
||||
// Makeup: add a temp schedule entry for the makeup date
|
||||
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
|
||||
adj.StartPeriod.HasValue)
|
||||
{
|
||||
var publishedPlan = await db.SchedulePlans
|
||||
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
|
||||
x.Status == SchedulePlanStatus.Published)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (publishedPlan is not null)
|
||||
{
|
||||
db.ScheduleEntries.Add(new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = publishedPlan.Id,
|
||||
TeachingTaskId = adj.TeachingTaskId,
|
||||
ClassroomId = adj.ClassroomId,
|
||||
DayOfWeek = adj.DayOfWeek.Value,
|
||||
StartPeriod = adj.StartPeriod.Value,
|
||||
PeriodCount = adj.PeriodCount ?? 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 1,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd})"
|
||||
});
|
||||
}
|
||||
}
|
||||
var makeupSource = await LoadSourceEntryAsync(adj, ct);
|
||||
db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId));
|
||||
break;
|
||||
|
||||
case CourseAdjustmentType.Substitute:
|
||||
@@ -383,7 +517,8 @@ public sealed class CourseAdjustmentsController(
|
||||
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -393,7 +528,7 @@ public sealed class CourseAdjustmentsController(
|
||||
CourseAdjustmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||||
.Include(x => x.Course)
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == request.TeachingTaskId &&
|
||||
@@ -405,15 +540,13 @@ public sealed class CourseAdjustmentsController(
|
||||
{
|
||||
case CourseAdjustmentType.Reschedule:
|
||||
case CourseAdjustmentType.Makeup:
|
||||
if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7)
|
||||
return ValidationProblem("请选择有效的上课日。");
|
||||
if (!request.TargetDate.HasValue)
|
||||
return ValidationProblem(
|
||||
request.Type == CourseAdjustmentType.Reschedule
|
||||
? "请选择调课后的日期。"
|
||||
: "请选择补课日期。");
|
||||
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
|
||||
return ValidationProblem("请选择起始节次。");
|
||||
if (!request.PeriodCount.HasValue || request.PeriodCount < 1)
|
||||
return ValidationProblem("请选择持续节数。");
|
||||
if (request.Type == CourseAdjustmentType.Makeup &&
|
||||
!request.TargetDate.HasValue)
|
||||
return ValidationProblem("补课必须指定日期。");
|
||||
break;
|
||||
case CourseAdjustmentType.Substitute:
|
||||
if (!request.SubstituteTeacherId.HasValue)
|
||||
@@ -424,15 +557,241 @@ public sealed class CourseAdjustmentsController(
|
||||
return ValidationProblem("代课教师不存在或已离职。");
|
||||
break;
|
||||
case CourseAdjustmentType.Cancel:
|
||||
if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue)
|
||||
return ValidationProblem("停课需指定周次或日期。");
|
||||
break;
|
||||
}
|
||||
|
||||
if (RequiresSourceOccurrence(request.Type))
|
||||
{
|
||||
var source = await LoadSourceOccurrenceAsync(
|
||||
request.TeachingTaskId,
|
||||
request.SourceScheduleEntryId,
|
||||
request.SourceWeek,
|
||||
cancellationToken);
|
||||
if (source is null)
|
||||
return ValidationProblem("请选择当前已发布课表中的具体原课次。");
|
||||
|
||||
var duplicate = await db.CourseAdjustments.AsNoTracking().AnyAsync(x =>
|
||||
x.SourceScheduleEntryId == source.Entry.Id &&
|
||||
x.SourceWeek == source.Week &&
|
||||
(x.Status == CourseAdjustmentStatus.Submitted ||
|
||||
x.Status == CourseAdjustmentStatus.Approved),
|
||||
cancellationToken);
|
||||
if (duplicate)
|
||||
return ConflictProblem("该课次已有待审核或已通过的调停课记录。");
|
||||
|
||||
if (request.TargetDate.HasValue &&
|
||||
(request.TargetDate < source.Term.StartDate ||
|
||||
request.TargetDate > source.Term.EndDate))
|
||||
return ValidationProblem("目标日期必须在本学期起止日期内。");
|
||||
}
|
||||
|
||||
if (request.ClassroomId.HasValue &&
|
||||
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
|
||||
x.Id == request.ClassroomId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("目标教室不存在或已停用。");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateApprovalScheduleAsync(
|
||||
CourseAdjustment adjustment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!RequiresSourceOccurrence(adjustment.Type)) return null;
|
||||
|
||||
var source = await LoadSourceOccurrenceAsync(
|
||||
adjustment.TeachingTaskId,
|
||||
adjustment.SourceScheduleEntryId,
|
||||
adjustment.SourceWeek,
|
||||
cancellationToken);
|
||||
if (source is null)
|
||||
return "原课次已不在当前发布课表中,请退回后重新选择。";
|
||||
if (adjustment.Type == CourseAdjustmentType.Cancel) return null;
|
||||
if (!adjustment.TargetDate.HasValue || !adjustment.StartPeriod.HasValue)
|
||||
return "目标上课日期或节次不完整。";
|
||||
|
||||
var targetWeek = ResolveWeek(source.Term.StartDate, adjustment.TargetDate.Value);
|
||||
var targetDay = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek);
|
||||
var target = new ScheduleEntry
|
||||
{
|
||||
TeachingTaskId = adjustment.TeachingTaskId,
|
||||
TeachingTask = adjustment.TeachingTask,
|
||||
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
||||
DayOfWeek = targetDay,
|
||||
StartPeriod = adjustment.StartPeriod.Value,
|
||||
PeriodCount = adjustment.SourcePeriodCount ?? source.Entry.PeriodCount,
|
||||
StartWeek = targetWeek,
|
||||
EndWeek = targetWeek,
|
||||
WeekPattern = WeekPattern.All
|
||||
};
|
||||
var possibleConflicts = await db.ScheduleEntries.AsNoTracking()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Classes)
|
||||
.Where(x =>
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||
x.Id != adjustment.SourceScheduleEntryId &&
|
||||
x.DayOfWeek == targetDay &&
|
||||
x.StartWeek <= targetWeek &&
|
||||
x.EndWeek >= targetWeek &&
|
||||
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
|
||||
target.StartPeriod < x.StartPeriod + x.PeriodCount)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var existing in possibleConflicts.Where(x =>
|
||||
IncludesWeek(x.WeekPattern, targetWeek)))
|
||||
{
|
||||
var reason = ScheduleConflictDetector.ConflictReason(target, existing);
|
||||
if (reason is not null)
|
||||
return $"目标时间存在{reason}冲突:{existing.TeachingTask!.Name}。";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static System.Linq.Expressions.Expression<
|
||||
private async Task<SourceOccurrence?> LoadSourceOccurrenceAsync(
|
||||
Guid teachingTaskId,
|
||||
Guid? scheduleEntryId,
|
||||
int? week,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!scheduleEntryId.HasValue || !week.HasValue) return null;
|
||||
var entry = await db.ScheduleEntries.AsNoTracking()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == scheduleEntryId.Value &&
|
||||
x.TeachingTaskId == teachingTaskId &&
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
||||
cancellationToken);
|
||||
if (entry?.TeachingTask?.AcademicTerm is null ||
|
||||
week < entry.StartWeek || week > entry.EndWeek ||
|
||||
!IncludesWeek(entry.WeekPattern, week.Value))
|
||||
return null;
|
||||
var date = ResolveDate(
|
||||
entry.TeachingTask.AcademicTerm.StartDate,
|
||||
week.Value,
|
||||
entry.DayOfWeek);
|
||||
if (date < entry.TeachingTask.AcademicTerm.StartDate ||
|
||||
date > entry.TeachingTask.AcademicTerm.EndDate)
|
||||
return null;
|
||||
return new SourceOccurrence(
|
||||
entry,
|
||||
entry.TeachingTask.AcademicTerm,
|
||||
week.Value,
|
||||
date);
|
||||
}
|
||||
|
||||
private Task<ScheduleEntry> LoadSourceEntryAsync(
|
||||
CourseAdjustment adjustment,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ScheduleEntries.SingleAsync(x =>
|
||||
x.Id == adjustment.SourceScheduleEntryId &&
|
||||
x.TeachingTaskId == adjustment.TeachingTaskId &&
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
||||
cancellationToken);
|
||||
|
||||
private void ExcludeWeek(ScheduleEntry entry, int week)
|
||||
{
|
||||
db.ScheduleEntries.Remove(entry);
|
||||
if (entry.StartWeek <= week - 1 &&
|
||||
HasIncludedWeek(entry.WeekPattern, entry.StartWeek, week - 1))
|
||||
db.ScheduleEntries.Add(CloneEntry(entry, entry.StartWeek, week - 1));
|
||||
if (week + 1 <= entry.EndWeek &&
|
||||
HasIncludedWeek(entry.WeekPattern, week + 1, entry.EndWeek))
|
||||
db.ScheduleEntries.Add(CloneEntry(entry, week + 1, entry.EndWeek));
|
||||
}
|
||||
|
||||
private ScheduleEntry CreateTargetEntry(
|
||||
CourseAdjustment adjustment,
|
||||
Guid schedulePlanId)
|
||||
{
|
||||
var targetWeek = ResolveWeek(
|
||||
adjustment.TeachingTask!.AcademicTerm!.StartDate,
|
||||
adjustment.TargetDate!.Value);
|
||||
return new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = schedulePlanId,
|
||||
TeachingTaskId = adjustment.TeachingTaskId,
|
||||
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
||||
DayOfWeek = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek),
|
||||
StartPeriod = adjustment.StartPeriod!.Value,
|
||||
PeriodCount = adjustment.SourcePeriodCount!.Value,
|
||||
StartWeek = targetWeek,
|
||||
EndWeek = targetWeek,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = adjustment.Type == CourseAdjustmentType.Reschedule
|
||||
? $"调课(原第 {adjustment.SourceWeek} 周)"
|
||||
: $"补课(对应原第 {adjustment.SourceWeek} 周课次)"
|
||||
};
|
||||
}
|
||||
|
||||
private static ScheduleEntry CloneEntry(
|
||||
ScheduleEntry source,
|
||||
int startWeek,
|
||||
int endWeek) => new()
|
||||
{
|
||||
SchedulePlanId = source.SchedulePlanId,
|
||||
TeachingTaskId = source.TeachingTaskId,
|
||||
ClassroomId = source.ClassroomId,
|
||||
DayOfWeek = source.DayOfWeek,
|
||||
StartPeriod = source.StartPeriod,
|
||||
PeriodCount = source.PeriodCount,
|
||||
StartWeek = startWeek,
|
||||
EndWeek = endWeek,
|
||||
WeekPattern = source.WeekPattern,
|
||||
Notes = source.Notes
|
||||
};
|
||||
|
||||
private static bool RequiresSourceOccurrence(CourseAdjustmentType type) =>
|
||||
type is CourseAdjustmentType.Reschedule or
|
||||
CourseAdjustmentType.Cancel or
|
||||
CourseAdjustmentType.Makeup;
|
||||
|
||||
private static bool IncludesWeek(WeekPattern pattern, int week) =>
|
||||
pattern == WeekPattern.All ||
|
||||
pattern == WeekPattern.Odd && week % 2 == 1 ||
|
||||
pattern == WeekPattern.Even && week % 2 == 0;
|
||||
|
||||
private static bool HasIncludedWeek(WeekPattern pattern, int startWeek, int endWeek) =>
|
||||
Enumerable.Range(startWeek, endWeek - startWeek + 1)
|
||||
.Any(week => IncludesWeek(pattern, week));
|
||||
|
||||
private static DateOnly ResolveDate(DateOnly termStartDate, int week, int dayOfWeek)
|
||||
{
|
||||
var startDay = (int)termStartDate.DayOfWeek;
|
||||
var daysSinceMonday = (startDay + 6) % 7;
|
||||
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
||||
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
|
||||
}
|
||||
|
||||
private static int ResolveWeek(DateOnly termStartDate, DateOnly date)
|
||||
{
|
||||
var startDay = (int)termStartDate.DayOfWeek;
|
||||
var daysSinceMonday = (startDay + 6) % 7;
|
||||
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
||||
return (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
|
||||
}
|
||||
|
||||
private static int ToIsoDayOfWeek(DayOfWeek dayOfWeek) =>
|
||||
dayOfWeek == System.DayOfWeek.Sunday ? 7 : (int)dayOfWeek;
|
||||
|
||||
private IQueryable<TeachingTask> AccessibleTeachingTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (scope.Scope == DataScope.All) return source;
|
||||
if (scope.Scope == DataScope.College)
|
||||
return source.Where(x =>
|
||||
x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
return source.Where(x =>
|
||||
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
|
||||
return source.Where(_ => false);
|
||||
}
|
||||
|
||||
private System.Linq.Expressions.Expression<
|
||||
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -449,6 +808,17 @@ public sealed class CourseAdjustmentsController(
|
||||
x.Status,
|
||||
x.Reason,
|
||||
x.ReviewComment,
|
||||
x.SourceScheduleEntryId,
|
||||
x.SourceWeek,
|
||||
x.SourceDate,
|
||||
x.SourceStartPeriod,
|
||||
x.SourcePeriodCount,
|
||||
SourceClassroomName = x.SourceClassroomId == null
|
||||
? null
|
||||
: db.Classrooms
|
||||
.Where(room => room.Id == x.SourceClassroomId)
|
||||
.Select(room => room.Building!.Name + " " + room.Name)
|
||||
.FirstOrDefault(),
|
||||
x.TargetDate,
|
||||
x.DayOfWeek,
|
||||
x.StartPeriod,
|
||||
@@ -464,6 +834,12 @@ public sealed class CourseAdjustmentsController(
|
||||
x.CreatedAt
|
||||
};
|
||||
|
||||
private sealed record SourceOccurrence(
|
||||
ScheduleEntry Entry,
|
||||
AcademicTerm Term,
|
||||
int Week,
|
||||
DateOnly Date);
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
@@ -487,7 +863,9 @@ public sealed record CourseAdjustmentRequest(
|
||||
Guid? ClassroomId,
|
||||
Guid? SubstituteTeacherId,
|
||||
int? CancelWeek,
|
||||
DateOnly? CancelDate);
|
||||
DateOnly? CancelDate,
|
||||
Guid? SourceScheduleEntryId = null,
|
||||
int? SourceWeek = null);
|
||||
|
||||
public sealed record RejectionRequest(
|
||||
[MaxLength(500)] string? Comment);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/courses")]
|
||||
public sealed class CoursesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string WriteRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -82,6 +84,17 @@ public sealed class CoursesController(
|
||||
x.Nature,
|
||||
x.AssessmentMethod,
|
||||
x.Description,
|
||||
PrerequisiteCourseIds = x.Prerequisites
|
||||
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||
.Select(item => item.PrerequisiteCourseId),
|
||||
Prerequisites = x.Prerequisites
|
||||
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||
.Select(item => new
|
||||
{
|
||||
item.PrerequisiteCourseId,
|
||||
item.PrerequisiteCourse!.Code,
|
||||
item.PrerequisiteCourse.Name
|
||||
}),
|
||||
x.IsEnabled,
|
||||
x.SortOrder,
|
||||
x.CreatedAt,
|
||||
@@ -132,6 +145,8 @@ public sealed class CoursesController(
|
||||
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
|
||||
x.Credits,
|
||||
x.TotalHours,
|
||||
x.LectureHours,
|
||||
x.PracticeHours,
|
||||
x.Nature,
|
||||
x.AssessmentMethod
|
||||
})
|
||||
@@ -144,9 +159,6 @@ public sealed class CoursesController(
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ValidateAsync(request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
var entity = new Course
|
||||
{
|
||||
Code = request.Code.Trim(),
|
||||
@@ -164,6 +176,16 @@ public sealed class CoursesController(
|
||||
IsEnabled = request.IsEnabled,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
var validation = await ValidateAsync(entity.Id, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
entity.Prerequisites = NormalizePrerequisiteIds(request)
|
||||
.Select(prerequisiteId => new CoursePrerequisite
|
||||
{
|
||||
CourseId = entity.Id,
|
||||
PrerequisiteCourseId = prerequisiteId
|
||||
})
|
||||
.ToList();
|
||||
db.Courses.Add(entity);
|
||||
return await SaveAsync(entity.Id, true, cancellationToken);
|
||||
}
|
||||
@@ -175,10 +197,12 @@ public sealed class CoursesController(
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
||||
var entity = await db.Courses
|
||||
.Include(x => x.Prerequisites)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
|
||||
var validation = await ValidateAsync(request, cancellationToken);
|
||||
var validation = await ValidateAsync(id, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
|
||||
entity.Code = request.Code.Trim();
|
||||
@@ -195,6 +219,19 @@ public sealed class CoursesController(
|
||||
entity.Description = Normalize(request.Description);
|
||||
entity.IsEnabled = request.IsEnabled;
|
||||
entity.SortOrder = request.SortOrder;
|
||||
var prerequisiteIds = NormalizePrerequisiteIds(request).ToHashSet();
|
||||
db.CoursePrerequisites.RemoveRange(
|
||||
entity.Prerequisites.Where(x =>
|
||||
!prerequisiteIds.Contains(x.PrerequisiteCourseId)));
|
||||
foreach (var prerequisiteId in prerequisiteIds.Except(
|
||||
entity.Prerequisites.Select(x => x.PrerequisiteCourseId)))
|
||||
{
|
||||
entity.Prerequisites.Add(new CoursePrerequisite
|
||||
{
|
||||
CourseId = entity.Id,
|
||||
PrerequisiteCourseId = prerequisiteId
|
||||
});
|
||||
}
|
||||
return await SaveAsync(entity.Id, false, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -210,6 +247,7 @@ public sealed class CoursesController(
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ValidateAsync(
|
||||
Guid courseId,
|
||||
CourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -222,9 +260,62 @@ public sealed class CoursesController(
|
||||
return ValidationProblem("所选课程分类不存在或已停用。");
|
||||
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
||||
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
||||
|
||||
var prerequisiteIds = NormalizePrerequisiteIds(request).ToArray();
|
||||
if (prerequisiteIds.Contains(courseId))
|
||||
return ValidationProblem("课程不能把自身设置为先修课程。");
|
||||
var accessiblePrerequisiteCount = await ScopedCourses()
|
||||
.CountAsync(x =>
|
||||
prerequisiteIds.Contains(x.Id) &&
|
||||
x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (accessiblePrerequisiteCount != prerequisiteIds.Length)
|
||||
return ValidationProblem("包含不存在、已停用或不在当前数据范围内的先修课程。");
|
||||
if (await CreatesPrerequisiteCycleAsync(
|
||||
courseId,
|
||||
prerequisiteIds,
|
||||
cancellationToken))
|
||||
return ValidationProblem("先修关系不能形成循环依赖。");
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> CreatesPrerequisiteCycleAsync(
|
||||
Guid courseId,
|
||||
IReadOnlyCollection<Guid> prerequisiteIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (prerequisiteIds.Count == 0) return false;
|
||||
|
||||
var edges = await db.CoursePrerequisites.AsNoTracking()
|
||||
.Where(x => x.CourseId != courseId)
|
||||
.Select(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var prerequisitesByCourse = edges
|
||||
.GroupBy(x => x.CourseId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(x => x.PrerequisiteCourseId).ToArray());
|
||||
|
||||
foreach (var prerequisiteId in prerequisiteIds)
|
||||
{
|
||||
var pending = new Stack<Guid>();
|
||||
var visited = new HashSet<Guid>();
|
||||
pending.Push(prerequisiteId);
|
||||
while (pending.TryPop(out var candidate))
|
||||
{
|
||||
if (candidate == courseId) return true;
|
||||
if (!visited.Add(candidate) ||
|
||||
!prerequisitesByCourse.TryGetValue(candidate, out var next))
|
||||
continue;
|
||||
foreach (var item in next) pending.Push(item);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<Guid> NormalizePrerequisiteIds(CourseRequest request) =>
|
||||
(request.PrerequisiteCourseIds ?? []).Distinct();
|
||||
|
||||
private IQueryable<Course> ScopedCourses()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
@@ -273,6 +364,7 @@ public sealed class CoursesController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -304,4 +396,5 @@ public sealed record CourseRequest(
|
||||
AssessmentMethod AssessmentMethod,
|
||||
[MaxLength(1000)] string? Description,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
int SortOrder,
|
||||
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/courses")]
|
||||
public sealed class CoursesExcelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string WriteRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -141,6 +143,9 @@ public sealed class CoursesExcelController(
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -9,75 +11,303 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/dashboard")]
|
||||
public sealed class DashboardController(AppDbContext db) : ControllerBase
|
||||
public sealed class DashboardController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<DashboardResponse>> Get(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
Guid? restrictedCollegeId = scope.Scope == DataScope.All
|
||||
? null
|
||||
: scope.CollegeId ?? Guid.Empty;
|
||||
var collegeName = restrictedCollegeId.HasValue &&
|
||||
restrictedCollegeId.Value != Guid.Empty
|
||||
? await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.Id == restrictedCollegeId.Value)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var currentTerm = await db.AcademicTerms
|
||||
.AsNoTracking()
|
||||
.Where(x => x.IsCurrent)
|
||||
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
||||
.Select(x => new DashboardTerm(
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.StartDate,
|
||||
x.EndDate))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var currentTermId = currentTerm?.Id;
|
||||
|
||||
return new
|
||||
{
|
||||
CurrentTerm = currentTerm,
|
||||
Counts = new
|
||||
{
|
||||
Campuses = await db.Campuses.CountAsync(cancellationToken),
|
||||
Colleges = await db.Colleges.CountAsync(cancellationToken),
|
||||
Majors = await db.Majors.CountAsync(cancellationToken),
|
||||
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
|
||||
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
|
||||
Teachers = await db.Teachers.CountAsync(cancellationToken),
|
||||
Students = await db.Students.CountAsync(cancellationToken),
|
||||
Courses = await db.Courses.CountAsync(cancellationToken),
|
||||
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
||||
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
||||
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
|
||||
CourseSelectionRounds = await db.CourseSelectionRounds
|
||||
.CountAsync(cancellationToken),
|
||||
CourseSelectionOfferings = await db.CourseSelectionOfferings
|
||||
.CountAsync(cancellationToken),
|
||||
CourseEnrollments = await db.CourseEnrollments
|
||||
.CountAsync(
|
||||
x => x.Status == CourseEnrollmentStatus.Enrolled,
|
||||
cancellationToken),
|
||||
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
|
||||
PublishedGradeSheets = await db.GradeSheets.CountAsync(
|
||||
x => x.Status == GradeSheetStatus.Published,
|
||||
cancellationToken),
|
||||
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
||||
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
|
||||
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
|
||||
StudentStatusChanges = await db.StudentStatusChanges
|
||||
.CountAsync(cancellationToken),
|
||||
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
|
||||
x => x.State == StudentStatusChangeState.Submitted ||
|
||||
x.State == StudentStatusChangeState.CounselorApproved ||
|
||||
x.State == StudentStatusChangeState.CollegeApproved,
|
||||
cancellationToken),
|
||||
GraduationAuditBatches = await db.GraduationAuditBatches
|
||||
.CountAsync(cancellationToken),
|
||||
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
|
||||
.CountAsync(
|
||||
x => x.Status == GraduationAuditBatchStatus.Published,
|
||||
cancellationToken),
|
||||
DegreeAwardBatches = await db.DegreeAwardBatches
|
||||
.CountAsync(cancellationToken),
|
||||
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
|
||||
.CountAsync(
|
||||
x => x.Status == DegreeAwardBatchStatus.Published,
|
||||
cancellationToken),
|
||||
GraduationClearanceBatches = await db.GraduationClearanceBatches
|
||||
.CountAsync(cancellationToken),
|
||||
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
|
||||
.CountAsync(
|
||||
x => x.Status == GraduationClearanceBatchStatus.Open,
|
||||
cancellationToken),
|
||||
Users = await db.Users.CountAsync(cancellationToken)
|
||||
}
|
||||
};
|
||||
var students = db.Students.AsNoTracking()
|
||||
.Where(x =>
|
||||
!restrictedCollegeId.HasValue ||
|
||||
x.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value);
|
||||
var teachers = db.Teachers.AsNoTracking()
|
||||
.Where(x =>
|
||||
!restrictedCollegeId.HasValue ||
|
||||
x.CollegeId == restrictedCollegeId.Value);
|
||||
var courses = db.Courses.AsNoTracking()
|
||||
.Where(x =>
|
||||
!restrictedCollegeId.HasValue ||
|
||||
x.CollegeId == restrictedCollegeId.Value);
|
||||
var teachingTasks = db.TeachingTasks.AsNoTracking()
|
||||
.Where(x =>
|
||||
currentTermId.HasValue &&
|
||||
x.AcademicTermId == currentTermId.Value &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Course!.CollegeId == restrictedCollegeId.Value));
|
||||
var gradeSheets = db.GradeSheets.AsNoTracking()
|
||||
.Where(x =>
|
||||
currentTermId.HasValue &&
|
||||
x.TeachingTask!.AcademicTermId == currentTermId.Value &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var enrollments = db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
currentTermId.HasValue &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.CourseSelectionRound!
|
||||
.AcademicTermId == currentTermId.Value &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.CourseSelectionOffering.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
|
||||
var taskCount = await teachingTasks.CountAsync(cancellationToken);
|
||||
var publishedTaskCount = await teachingTasks.CountAsync(
|
||||
x => x.Status == TeachingTaskStatus.Published,
|
||||
cancellationToken);
|
||||
var scheduledTaskCount = currentTermId.HasValue
|
||||
? await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlan!.AcademicTermId == currentTermId.Value &&
|
||||
x.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value))
|
||||
.Select(x => x.TeachingTaskId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken)
|
||||
: 0;
|
||||
var gradeSheetCount = await gradeSheets.CountAsync(cancellationToken);
|
||||
var publishedGradeSheetCount = await gradeSheets.CountAsync(
|
||||
x => x.Status == GradeSheetStatus.Published,
|
||||
cancellationToken);
|
||||
|
||||
var counts = new DashboardCounts(
|
||||
await students.CountAsync(
|
||||
x => x.Status == StudentStatus.Active,
|
||||
cancellationToken),
|
||||
await teachers.CountAsync(
|
||||
x => x.Status == TeacherStatus.Active,
|
||||
cancellationToken),
|
||||
await courses.CountAsync(
|
||||
x => x.IsEnabled,
|
||||
cancellationToken),
|
||||
taskCount,
|
||||
publishedTaskCount,
|
||||
scheduledTaskCount,
|
||||
await enrollments.CountAsync(cancellationToken),
|
||||
gradeSheetCount,
|
||||
publishedGradeSheetCount,
|
||||
await gradeSheets.CountAsync(
|
||||
x => x.Status == GradeSheetStatus.Submitted,
|
||||
cancellationToken),
|
||||
currentTermId.HasValue
|
||||
? await db.CourseSelectionRounds.AsNoTracking().CountAsync(
|
||||
x => x.AcademicTermId == currentTermId.Value &&
|
||||
x.Status == CourseSelectionRoundStatus.Open,
|
||||
cancellationToken)
|
||||
: 0);
|
||||
|
||||
var pending = await LoadPendingAsync(
|
||||
scope,
|
||||
restrictedCollegeId,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new DashboardResponse(
|
||||
BuildAudience(scope, collegeName),
|
||||
currentTerm,
|
||||
counts,
|
||||
pending,
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
|
||||
private async Task<DashboardPending> LoadPendingAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? restrictedCollegeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isSchoolManager =
|
||||
scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
scope.IsInRole(SystemRoles.AcademicAdmin);
|
||||
var isCollegeManager =
|
||||
!isSchoolManager && scope.IsInRole(SystemRoles.CollegeAdmin);
|
||||
if (!isSchoolManager && !isCollegeManager)
|
||||
return new DashboardPending(0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
var teacherApplications = db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == TeacherCourseApplicationStatus.Pending &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Teacher!.CollegeId == restrictedCollegeId.Value));
|
||||
var gradeSheets = db.GradeSheets.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == GradeSheetStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var courseAdjustments = db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseAdjustmentStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var studentStatusChanges = db.StudentStatusChanges.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.State == (isCollegeManager
|
||||
? StudentStatusChangeState.CounselorApproved
|
||||
: StudentStatusChangeState.CollegeApproved) &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
var gradeModifications = db.GradeModifications.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == (isCollegeManager
|
||||
? GradeModificationStatus.TeacherSubmitted
|
||||
: GradeModificationStatus.CollegeApproved) &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value));
|
||||
|
||||
var generalApprovals =
|
||||
await db.CourseExemptions.AsNoTracking().CountAsync(
|
||||
x => x.Status == ApprovalStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken) +
|
||||
await db.DeferredExams.AsNoTracking().CountAsync(
|
||||
x => x.Status == ApprovalStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.TeachingTask!.Course!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken) +
|
||||
await db.CourseSubstitutions.AsNoTracking().CountAsync(
|
||||
x => x.Status == ApprovalStatus.Submitted &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken) +
|
||||
await db.AttendanceRecords.AsNoTracking().CountAsync(
|
||||
x => x.AppealStatus == AttendanceAppealStatus.Pending &&
|
||||
(!restrictedCollegeId.HasValue ||
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
restrictedCollegeId.Value),
|
||||
cancellationToken);
|
||||
|
||||
var classroomReservations = isCollegeManager &&
|
||||
restrictedCollegeId.HasValue
|
||||
? await db.ClassroomReservations.AsNoTracking().CountAsync(
|
||||
x => x.Status == ClassroomReservationStatus.Submitted &&
|
||||
x.ApplicantCollegeId == restrictedCollegeId.Value,
|
||||
cancellationToken)
|
||||
: 0;
|
||||
|
||||
return new DashboardPending(
|
||||
await teacherApplications.CountAsync(cancellationToken),
|
||||
await gradeSheets.CountAsync(cancellationToken),
|
||||
await courseAdjustments.CountAsync(cancellationToken),
|
||||
await studentStatusChanges.CountAsync(cancellationToken),
|
||||
await gradeModifications.CountAsync(cancellationToken),
|
||||
classroomReservations,
|
||||
generalApprovals);
|
||||
}
|
||||
|
||||
private static DashboardAudience BuildAudience(
|
||||
CurrentUserScope scope,
|
||||
string? collegeName)
|
||||
{
|
||||
if (scope.IsInRole(SystemRoles.SuperAdmin))
|
||||
return new DashboardAudience(
|
||||
"System",
|
||||
"全域教务工作台",
|
||||
"全校",
|
||||
"统筹基础数据、教学运行与系统治理");
|
||||
if (scope.IsInRole(SystemRoles.AcademicAdmin))
|
||||
return new DashboardAudience(
|
||||
"School",
|
||||
"校级教务工作台",
|
||||
"全校",
|
||||
"聚焦跨学院教学运行与校级审核");
|
||||
if (scope.IsInRole(SystemRoles.CollegeAdmin))
|
||||
return new DashboardAudience(
|
||||
"College",
|
||||
"学院教务工作台",
|
||||
collegeName ?? "本学院",
|
||||
"聚焦本学院教学准备、过程审核与成绩归档");
|
||||
if (scope.IsInRole(SystemRoles.Leader))
|
||||
return new DashboardAudience(
|
||||
"Leadership",
|
||||
"教学运行观察台",
|
||||
"全校",
|
||||
"查看全校教学运行与质量数据");
|
||||
if (scope.IsInRole(SystemRoles.Counselor))
|
||||
return new DashboardAudience(
|
||||
"Counselor",
|
||||
"班级工作台",
|
||||
collegeName ?? "所辖班级",
|
||||
"处理学生过程管理与学业支持");
|
||||
return new DashboardAudience(
|
||||
"Teaching",
|
||||
"教学工作台",
|
||||
collegeName ?? "个人教学",
|
||||
"查看课程运行并进入日常教学工作");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DashboardResponse(
|
||||
DashboardAudience Audience,
|
||||
DashboardTerm? CurrentTerm,
|
||||
DashboardCounts Counts,
|
||||
DashboardPending Pending,
|
||||
DateTime GeneratedAt);
|
||||
|
||||
public sealed record DashboardAudience(
|
||||
string Level,
|
||||
string Title,
|
||||
string ScopeName,
|
||||
string Description);
|
||||
|
||||
public sealed record DashboardTerm(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate);
|
||||
|
||||
public sealed record DashboardCounts(
|
||||
int Students,
|
||||
int Teachers,
|
||||
int Courses,
|
||||
int TeachingTasks,
|
||||
int PublishedTeachingTasks,
|
||||
int ScheduledTeachingTasks,
|
||||
int CourseEnrollments,
|
||||
int GradeSheets,
|
||||
int PublishedGradeSheets,
|
||||
int SubmittedGradeSheets,
|
||||
int OpenCourseSelectionRounds);
|
||||
|
||||
public sealed record DashboardPending(
|
||||
int TeacherApplications,
|
||||
int GradeSheets,
|
||||
int CourseAdjustments,
|
||||
int StudentStatusChanges,
|
||||
int GradeModifications,
|
||||
int ClassroomReservations,
|
||||
int GeneralApprovals);
|
||||
|
||||
@@ -60,8 +60,16 @@ public sealed class DegreeAwardsController(
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
|
||||
public async Task<ActionResult> GetBatch(
|
||||
Guid id,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? keyword = null,
|
||||
[FromQuery] DegreeAwardConclusion? conclusion = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var batch = await db.DegreeAwardBatches.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||
if (batch is null) return NotFound();
|
||||
@@ -71,6 +79,52 @@ public sealed class DegreeAwardsController(
|
||||
if (collegeId.HasValue)
|
||||
source = source.Where(x =>
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
|
||||
var summary = await source
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
ResultCount = group.Count(),
|
||||
GrantedCount = group.Count(x =>
|
||||
x.Conclusion == DegreeAwardConclusion.Granted),
|
||||
NotGrantedCount = group.Count(x =>
|
||||
x.Conclusion == DegreeAwardConclusion.NotGranted),
|
||||
OverrideCount = group.Count(x => x.IsOverridden)
|
||||
})
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
source = source.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(normalizedKeyword) ||
|
||||
x.Student.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
|
||||
if (conclusion.HasValue)
|
||||
source = source.Where(x => x.Conclusion == conclusion.Value);
|
||||
|
||||
var total = await source.CountAsync(token);
|
||||
var items = await source.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.AverageGradePoint,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id,
|
||||
@@ -82,24 +136,17 @@ public sealed class DegreeAwardsController(
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await source.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.AverageGradePoint,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
ResultCount = summary?.ResultCount ?? 0,
|
||||
GrantedCount = summary?.GrantedCount ?? 0,
|
||||
NotGrantedCount = summary?.NotGrantedCount ?? 0,
|
||||
OverrideCount = summary?.OverrideCount ?? 0,
|
||||
Results = new
|
||||
{
|
||||
Items = items,
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,25 +180,30 @@ public sealed class DegreeAwardsController(
|
||||
return ConflictProblem("已发布批次不能重新计算。");
|
||||
|
||||
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
|
||||
.Include(x => x.GraduationAuditBatch)
|
||||
.Include(x => x.Student)
|
||||
.ThenInclude(x => x!.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Major)
|
||||
.Where(x =>
|
||||
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||
x.Conclusion == GraduationAuditConclusion.Eligible)
|
||||
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
StudentStatus = x.Student!.Status
|
||||
})
|
||||
.ToListAsync(token);
|
||||
var audits = auditCandidates
|
||||
.GroupBy(x => x.StudentId)
|
||||
.Select(x => x.First())
|
||||
.ToList();
|
||||
var studentIds = audits.Select(x => x.StudentId).ToArray();
|
||||
var gradePoints = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradePoint.HasValue)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Where(x => db.GraduationAuditResults.Any(audit =>
|
||||
audit.StudentId == x.StudentId &&
|
||||
audit.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||
audit.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||
audit.Conclusion == GraduationAuditConclusion.Eligible))
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -159,14 +211,17 @@ public sealed class DegreeAwardsController(
|
||||
x.GradeSheet!.TeachingTask!.Course!.Credits
|
||||
}).ToListAsync(token);
|
||||
|
||||
var oldResults = await db.DegreeAwardResults
|
||||
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
|
||||
db.DegreeAwardResults.RemoveRange(oldResults);
|
||||
await db.SaveChangesAsync(token);
|
||||
var gradePointsByStudent = gradePoints
|
||||
.GroupBy(x => x.StudentId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
|
||||
await db.DegreeAwardResults
|
||||
.Where(x => x.DegreeAwardBatchId == id)
|
||||
.ExecuteDeleteAsync(token);
|
||||
|
||||
foreach (var audit in audits)
|
||||
{
|
||||
var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList();
|
||||
var grades = gradePointsByStudent.GetValueOrDefault(audit.StudentId) ?? [];
|
||||
var credits = grades.Sum(x => x.Credits);
|
||||
var average = credits == 0
|
||||
? 0
|
||||
@@ -175,8 +230,8 @@ public sealed class DegreeAwardsController(
|
||||
2,
|
||||
MidpointRounding.AwayFromZero);
|
||||
var conclusion = DegreeAwardRules.Evaluate(
|
||||
true, audit.Student!.Status, average, batch.MinimumGradePoint);
|
||||
var reason = audit.Student.Status != StudentStatus.Graduated
|
||||
true, audit.StudentStatus, average, batch.MinimumGradePoint);
|
||||
var reason = audit.StudentStatus != StudentStatus.Graduated
|
||||
? "学籍状态尚未转为毕业"
|
||||
: average < batch.MinimumGradePoint
|
||||
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,945 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/experiment-grades")]
|
||||
public sealed class ExperimentGradesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Teacher;
|
||||
|
||||
private const string Reviewers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
private const string Publishers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin;
|
||||
|
||||
[HttpGet("management")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetManagement(
|
||||
Guid? academicTermId,
|
||||
ExperimentGradeSheetStatus? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = ScopedProjects().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExperimentProjectStatus.Published ||
|
||||
x.Status == ExperimentProjectStatus.Closed);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||
if (status.HasValue)
|
||||
source = source.Where(x =>
|
||||
x.GradeSheet != null &&
|
||||
x.GradeSheet.Status == status.Value);
|
||||
|
||||
return Ok(await source
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
ProjectStatus = x.Status,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
AcademicTermId = x.TeachingTask.AcademicTermId,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
CollegeName = x.TeachingTask.Course.College!.Name,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
Sheet = x.GradeSheet == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
x.GradeSheet.Id,
|
||||
x.GradeSheet.Status,
|
||||
x.GradeSheet.ContributionWeight,
|
||||
x.GradeSheet.PassScore,
|
||||
ItemCount = x.GradeSheet.Items.Count,
|
||||
StudentCount = x.GradeSheet.Records.Count,
|
||||
CompletedCount = x.GradeSheet.Records.Count(record =>
|
||||
record.ParticipationStatus !=
|
||||
ExperimentParticipationStatus.Pending),
|
||||
ScoredCount = x.GradeSheet.Records.Count(record =>
|
||||
record.TotalScore != null),
|
||||
PassedCount = x.GradeSheet.Records.Count(record =>
|
||||
record.IsPassed == true),
|
||||
x.GradeSheet.SubmittedAt,
|
||||
x.GradeSheet.ReviewedAt,
|
||||
x.GradeSheet.PublishedAt
|
||||
}
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await CurrentStudentAsync(cancellationToken);
|
||||
if (student is null)
|
||||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
|
||||
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.ExperimentGradeSheet!.Status ==
|
||||
ExperimentGradeSheetStatus.Published)
|
||||
.OrderByDescending(x =>
|
||||
x.ExperimentGradeSheet!.PublishedAt)
|
||||
.ThenBy(x =>
|
||||
x.ExperimentGradeSheet!.ExperimentProject!.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ExperimentGradeSheetId,
|
||||
ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
|
||||
ProjectCode =
|
||||
x.ExperimentGradeSheet.ExperimentProject!.Code,
|
||||
ProjectName =
|
||||
x.ExperimentGradeSheet.ExperimentProject.Name,
|
||||
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
|
||||
CourseCode = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask!.Course!.Code,
|
||||
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.Course.Name,
|
||||
TermName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.AcademicTerm!.Name,
|
||||
x.ExperimentGradeSheet.PassScore,
|
||||
x.ParticipationStatus,
|
||||
x.TotalScore,
|
||||
x.IsPassed,
|
||||
x.SafetyViolation,
|
||||
x.AttemptNumber,
|
||||
x.SubmissionReference,
|
||||
x.SubmittedAt,
|
||||
x.IsLate,
|
||||
x.TeacherComment,
|
||||
Items = x.ItemScores
|
||||
.OrderBy(score =>
|
||||
score.ExperimentGradeItem!.SortOrder)
|
||||
.Select(score => new
|
||||
{
|
||||
score.ExperimentGradeItemId,
|
||||
score.ExperimentGradeItem!.Name,
|
||||
score.ExperimentGradeItem.Kind,
|
||||
score.ExperimentGradeItem.Weight,
|
||||
score.Score,
|
||||
score.Comment
|
||||
}),
|
||||
x.ExperimentGradeSheet.PublishedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("sheets")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateSheet(
|
||||
ExperimentGradeSheetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = BuildItems(request.Items);
|
||||
if (!ExperimentGradeCalculator.AreWeightsValid(
|
||||
request.ContributionWeight,
|
||||
request.PassScore,
|
||||
items))
|
||||
return ValidationProblem(
|
||||
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
|
||||
|
||||
var project = await ScopedProjects()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == request.ExperimentProjectId,
|
||||
cancellationToken);
|
||||
if (project is null) return NotFound();
|
||||
if (project.Status is not (
|
||||
ExperimentProjectStatus.Published or
|
||||
ExperimentProjectStatus.Closed))
|
||||
return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
|
||||
if (!CanInitialize(project.TeachingTask!)) return Forbid();
|
||||
if (await db.ExperimentGradeSheets.AnyAsync(x =>
|
||||
x.ExperimentProjectId == project.Id,
|
||||
cancellationToken))
|
||||
return ConflictProblem("该实验项目已经建立成绩单。");
|
||||
|
||||
var participants = await LoadParticipantsAsync(
|
||||
project,
|
||||
cancellationToken);
|
||||
if (participants.Count == 0)
|
||||
return ConflictProblem("实验项目当前没有可评分学生。");
|
||||
|
||||
var sheet = new ExperimentGradeSheet
|
||||
{
|
||||
ExperimentProjectId = project.Id,
|
||||
ContributionWeight = request.ContributionWeight,
|
||||
PassScore = request.PassScore,
|
||||
Items = items,
|
||||
Records = participants.Select(participant =>
|
||||
new ExperimentGradeRecord
|
||||
{
|
||||
StudentId = participant.StudentId,
|
||||
ExperimentSessionId = participant.SessionId,
|
||||
ItemScores = items.Select(item =>
|
||||
new ExperimentGradeItemScore
|
||||
{
|
||||
ExperimentGradeItemId = item.Id
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
db.ExperimentGradeSheets.Add(sheet);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Created(string.Empty, new { sheet.Id });
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetSheet(
|
||||
Guid id,
|
||||
int recordPage = 1,
|
||||
int recordPageSize = 50,
|
||||
string? studentKeyword = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
recordPage = Math.Max(1, recordPage);
|
||||
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
|
||||
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
|
||||
? null
|
||||
: studentKeyword.Trim();
|
||||
|
||||
var sheet = await ScopedSheets().AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ExperimentProjectId,
|
||||
ProjectCode = x.ExperimentProject!.Code,
|
||||
ProjectName = x.ExperimentProject.Name,
|
||||
x.ExperimentProject.ArrangementMode,
|
||||
x.ExperimentProject.TeachingTaskId,
|
||||
x.ExperimentProject.TeachingTask!.TaskNumber,
|
||||
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||
AcademicTermId =
|
||||
x.ExperimentProject.TeachingTask.AcademicTermId,
|
||||
TermName = x.ExperimentProject.TeachingTask
|
||||
.AcademicTerm!.Name,
|
||||
CourseCollegeId = x.ExperimentProject.TeachingTask
|
||||
.Course!.CollegeId,
|
||||
CourseCollegeName = x.ExperimentProject.TeachingTask
|
||||
.Course.College!.Name,
|
||||
CourseCode =
|
||||
x.ExperimentProject.TeachingTask.Course.Code,
|
||||
CourseName =
|
||||
x.ExperimentProject.TeachingTask.Course.Name,
|
||||
TeacherNames = x.ExperimentProject.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
x.ContributionWeight,
|
||||
x.PassScore,
|
||||
Items = x.Items.OrderBy(item => item.SortOrder)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Kind,
|
||||
item.Weight
|
||||
}),
|
||||
x.Status,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt,
|
||||
x.PublishedAt,
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
||||
.Where(x => x.ExperimentGradeSheetId == id);
|
||||
if (studentKeyword is not null)
|
||||
recordsSource = recordsSource.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||
x.Student.Name.Contains(studentKeyword) ||
|
||||
x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
|
||||
var recordTotal = await recordsSource.CountAsync(cancellationToken);
|
||||
var records = await recordsSource
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Skip((recordPage - 1) * recordPageSize)
|
||||
.Take(recordPageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
x.ExperimentSessionId,
|
||||
Session = x.ExperimentSession == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
x.ExperimentSession.SessionDate,
|
||||
x.ExperimentSession.StartPeriod,
|
||||
x.ExperimentSession.PeriodCount,
|
||||
ClassroomName =
|
||||
x.ExperimentSession.Classroom!.Name
|
||||
},
|
||||
x.ParticipationStatus,
|
||||
x.TotalScore,
|
||||
x.IsPassed,
|
||||
x.SafetyViolation,
|
||||
x.AttemptNumber,
|
||||
x.SubmissionReference,
|
||||
x.SubmittedAt,
|
||||
x.IsLate,
|
||||
x.TeacherComment,
|
||||
ItemScores = x.ItemScores
|
||||
.OrderBy(score =>
|
||||
score.ExperimentGradeItem!.SortOrder)
|
||||
.Select(score => new
|
||||
{
|
||||
score.ExperimentGradeItemId,
|
||||
score.ExperimentGradeItem!.Name,
|
||||
score.Score,
|
||||
score.Comment
|
||||
}),
|
||||
x.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Include(x => x.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.SingleAsync(
|
||||
x => x.Id == sheet.TeachingTaskId,
|
||||
cancellationToken);
|
||||
var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
|
||||
sheet.Status ==
|
||||
ExperimentGradeSheetStatus.Submitted;
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = new
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.ExperimentProjectId,
|
||||
sheet.ProjectCode,
|
||||
sheet.ProjectName,
|
||||
sheet.ArrangementMode,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.TaskNumber,
|
||||
sheet.TaskName,
|
||||
sheet.AcademicTermId,
|
||||
sheet.TermName,
|
||||
sheet.CourseCollegeName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
sheet.TeacherNames,
|
||||
sheet.ContributionWeight,
|
||||
sheet.PassScore,
|
||||
sheet.Items,
|
||||
sheet.Status,
|
||||
sheet.ReviewComment,
|
||||
sheet.SubmittedAt,
|
||||
sheet.ReviewedAt,
|
||||
sheet.PublishedAt,
|
||||
RecordTotal = recordTotal,
|
||||
RecordPage = recordPage,
|
||||
RecordPageSize = recordPageSize,
|
||||
Records = records,
|
||||
sheet.CreatedAt,
|
||||
sheet.UpdatedAt
|
||||
},
|
||||
CanEdit = CanEdit(task) &&
|
||||
sheet.Status is
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned,
|
||||
CanReview = canReview,
|
||||
CanPublish = IsPublisher() &&
|
||||
sheet.Status == ExperimentGradeSheetStatus.Approved
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("sheets/{id:guid}/settings")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateSettings(
|
||||
Guid id,
|
||||
ExperimentGradeSettingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var replacementItems = BuildItems(request.Items);
|
||||
if (!ExperimentGradeCalculator.AreWeightsValid(
|
||||
request.ContributionWeight,
|
||||
request.PassScore,
|
||||
replacementItems))
|
||||
return ValidationProblem(
|
||||
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
|
||||
|
||||
var sheet = await ScopedSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("当前状态不能修改实验评分方案。");
|
||||
|
||||
sheet.ContributionWeight = request.ContributionWeight;
|
||||
sheet.PassScore = request.PassScore;
|
||||
db.ExperimentGradeItems.RemoveRange(sheet.Items);
|
||||
foreach (var record in sheet.Records)
|
||||
{
|
||||
record.ItemScores.Clear();
|
||||
record.TotalScore = null;
|
||||
record.IsPassed = null;
|
||||
}
|
||||
sheet.Items = replacementItems;
|
||||
foreach (var record in sheet.Records)
|
||||
foreach (var item in replacementItems)
|
||||
record.ItemScores.Add(new ExperimentGradeItemScore
|
||||
{
|
||||
ExperimentGradeItemId = item.Id
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("sheets/{id:guid}/records")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateRecords(
|
||||
Guid id,
|
||||
ExperimentGradeRecordsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await ScopedSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("成绩单提交后不能继续修改。");
|
||||
|
||||
var records = sheet.Records.ToDictionary(x => x.Id);
|
||||
if (request.Records.Select(x => x.Id).Distinct().Count() !=
|
||||
request.Records.Count ||
|
||||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||
return ValidationProblem("包含无效或重复的实验成绩记录。");
|
||||
var itemIds = sheet.Items.Select(x => x.Id).ToHashSet();
|
||||
foreach (var item in request.Records)
|
||||
{
|
||||
if (item.AttemptNumber is < 1 or > 20)
|
||||
return ValidationProblem("实验尝试次数应在 1—20 次之间。");
|
||||
if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
|
||||
.Distinct().Count() != item.ItemScores.Count ||
|
||||
item.ItemScores.Any(x =>
|
||||
!itemIds.Contains(x.ExperimentGradeItemId) ||
|
||||
!ValidScore(x.Score)))
|
||||
return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");
|
||||
|
||||
var record = records[item.Id];
|
||||
record.ParticipationStatus = item.ParticipationStatus;
|
||||
record.SafetyViolation = item.SafetyViolation;
|
||||
record.AttemptNumber = item.AttemptNumber;
|
||||
record.SubmissionReference =
|
||||
Normalize(item.SubmissionReference);
|
||||
record.SubmittedAt = item.SubmittedAt;
|
||||
record.IsLate = item.IsLate;
|
||||
record.TeacherComment = Normalize(item.TeacherComment);
|
||||
var scores = record.ItemScores.ToDictionary(
|
||||
x => x.ExperimentGradeItemId);
|
||||
foreach (var score in item.ItemScores)
|
||||
{
|
||||
var target = scores[score.ExperimentGradeItemId];
|
||||
target.Score = score.Score;
|
||||
target.Comment = Normalize(score.Comment);
|
||||
}
|
||||
Recalculate(sheet, record);
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/sync-participants")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> SyncParticipants(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await ScopedSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("成绩单提交后不能同步参与学生。");
|
||||
|
||||
var expected = await LoadParticipantsAsync(
|
||||
sheet.ExperimentProject,
|
||||
cancellationToken);
|
||||
var expectedByStudent = expected.ToDictionary(x => x.StudentId);
|
||||
var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
|
||||
var added = 0;
|
||||
var removed = 0;
|
||||
var retained = 0;
|
||||
var recordsToRemove = new List<ExperimentGradeRecord>();
|
||||
var recordsToAdd = new List<ExperimentGradeRecord>();
|
||||
|
||||
foreach (var participant in expected)
|
||||
{
|
||||
if (existingByStudent.TryGetValue(
|
||||
participant.StudentId,
|
||||
out var record))
|
||||
{
|
||||
if (record.ParticipationStatus ==
|
||||
ExperimentParticipationStatus.Pending &&
|
||||
record.ItemScores.All(x => !x.Score.HasValue))
|
||||
record.ExperimentSessionId = participant.SessionId;
|
||||
retained++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var newRecord = new ExperimentGradeRecord
|
||||
{
|
||||
ExperimentGradeSheetId = sheet.Id,
|
||||
StudentId = participant.StudentId,
|
||||
ExperimentSessionId = participant.SessionId
|
||||
};
|
||||
db.ExperimentGradeRecords.Add(newRecord);
|
||||
recordsToAdd.Add(newRecord);
|
||||
added++;
|
||||
}
|
||||
|
||||
foreach (var record in sheet.Records
|
||||
.Where(x => !expectedByStudent.ContainsKey(x.StudentId))
|
||||
.ToList())
|
||||
{
|
||||
if (record.ParticipationStatus !=
|
||||
ExperimentParticipationStatus.Pending ||
|
||||
record.ItemScores.Any(x => x.Score.HasValue))
|
||||
{
|
||||
retained++;
|
||||
continue;
|
||||
}
|
||||
|
||||
db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
|
||||
recordsToRemove.Add(record);
|
||||
removed++;
|
||||
}
|
||||
|
||||
if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var record in recordsToRemove)
|
||||
sheet.Records.Remove(record);
|
||||
db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
|
||||
}
|
||||
foreach (var record in recordsToAdd)
|
||||
foreach (var item in sheet.Items)
|
||||
record.ItemScores.Add(new ExperimentGradeItemScore
|
||||
{
|
||||
ExperimentGradeItemId = item.Id
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Added = added,
|
||||
Removed = removed,
|
||||
Retained = retained,
|
||||
Total = await db.ExperimentGradeRecords.CountAsync(
|
||||
x => x.ExperimentGradeSheetId == sheet.Id,
|
||||
cancellationToken)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/submit")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Submit(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
return ConflictProblem("只有草稿或已退回实验成绩单可以提交。");
|
||||
if (sheet.Records.Count == 0)
|
||||
return ConflictProblem("实验成绩单没有学生记录。");
|
||||
|
||||
foreach (var record in sheet.Records) Recalculate(sheet, record);
|
||||
if (sheet.Records.Any(x =>
|
||||
x.ParticipationStatus ==
|
||||
ExperimentParticipationStatus.Pending))
|
||||
return ConflictProblem("仍有学生未登记实验参与状态。");
|
||||
if (sheet.Records.Any(x =>
|
||||
x.ParticipationStatus is
|
||||
ExperimentParticipationStatus.Completed or
|
||||
ExperimentParticipationStatus.Makeup &&
|
||||
!x.TotalScore.HasValue))
|
||||
return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Submitted;
|
||||
sheet.SubmittedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.CollegeAdmin,
|
||||
"实验成绩待审核",
|
||||
$"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。",
|
||||
sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
|
||||
"/experiment-grades",
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/approve")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> Approve(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
|
||||
if (!CanReviewCollege(collegeId)) return Forbid();
|
||||
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
|
||||
return ConflictProblem("只有待审核实验成绩单可以通过审核。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Approved;
|
||||
sheet.ReviewedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotifyTeachersAsync(
|
||||
sheet.ExperimentProject.TeachingTaskId,
|
||||
"实验成绩审核通过",
|
||||
$"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/return")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> Return(
|
||||
Guid id,
|
||||
ExperimentGradeReviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
|
||||
if (!CanReviewCollege(collegeId)) return Forbid();
|
||||
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
|
||||
return ConflictProblem("只有待审核实验成绩单可以退回。");
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return ValidationProblem("退回时必须填写修改意见。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Returned;
|
||||
sheet.ReviewedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = request.Comment.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await NotifyTeachersAsync(
|
||||
sheet.ExperimentProject.TeachingTaskId,
|
||||
"实验成绩被退回",
|
||||
$"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}",
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/publish")]
|
||||
[Authorize(Roles = Publishers)]
|
||||
public async Task<ActionResult> Publish(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!IsPublisher()) return Forbid();
|
||||
if (sheet.Status != ExperimentGradeSheetStatus.Approved)
|
||||
return ConflictProblem("只有审核通过的实验成绩单可以发布。");
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
var userIds = await db.ExperimentGradeRecords
|
||||
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
||||
.Select(x => x.Student!.UserId)
|
||||
.Where(x => x != null)
|
||||
.Select(x => x!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
await NotificationService.SendToUserIdsAsync(
|
||||
db,
|
||||
userIds,
|
||||
"实验成绩已发布",
|
||||
$"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
|
||||
"/experiment-grades",
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await ScopedSheets().AnyAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken))
|
||||
return null;
|
||||
return await db.ExperimentGradeSheets
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
|
||||
ExperimentProject project,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (project.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized)
|
||||
{
|
||||
return await TeachingTaskRosterQuery
|
||||
.ForTask(db, project.TeachingTaskId)
|
||||
.AsNoTracking()
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.Select(x => new ExperimentParticipantSeed(x.Id, null))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await db.ExperimentBookings.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExperimentProjectId == project.Id &&
|
||||
x.Status == ExperimentBookingStatus.Booked &&
|
||||
x.Student!.Status == StudentStatus.Active)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new ExperimentParticipantSeed(
|
||||
x.StudentId,
|
||||
x.ExperimentSessionId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static List<ExperimentGradeItem> BuildItems(
|
||||
IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
|
||||
requests?.Select((item, index) => new ExperimentGradeItem
|
||||
{
|
||||
Name = item.Name.Trim(),
|
||||
Kind = item.Kind,
|
||||
Weight = item.Weight,
|
||||
SortOrder = index
|
||||
}).ToList() ?? [];
|
||||
|
||||
private static void Recalculate(
|
||||
ExperimentGradeSheet sheet,
|
||||
ExperimentGradeRecord record)
|
||||
{
|
||||
record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
|
||||
record,
|
||||
sheet.Items.ToList());
|
||||
record.IsPassed = record.TotalScore.HasValue
|
||||
? record.TotalScore.Value >= sheet.PassScore &&
|
||||
!record.SafetyViolation
|
||||
: null;
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> AccessibleTeachingTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (scope.Scope == DataScope.All) return source;
|
||||
if (scope.Scope == DataScope.College)
|
||||
return source.Where(x =>
|
||||
x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
return source.Where(x => x.Teachers.Any(item =>
|
||||
item.Teacher!.UserId == scope.UserId));
|
||||
return source.Where(_ => false);
|
||||
}
|
||||
|
||||
private IQueryable<ExperimentProject> ScopedProjects()
|
||||
{
|
||||
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
||||
return db.ExperimentProjects.Where(x =>
|
||||
taskIds.Contains(x.TeachingTaskId));
|
||||
}
|
||||
|
||||
private IQueryable<ExperimentGradeSheet> ScopedSheets()
|
||||
{
|
||||
var projectIds = ScopedProjects().Select(x => x.Id);
|
||||
return db.ExperimentGradeSheets.Where(x =>
|
||||
projectIds.Contains(x.ExperimentProjectId));
|
||||
}
|
||||
|
||||
private bool CanInitialize(TeachingTask task) =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
IsAssignedTeacher(task);
|
||||
|
||||
private bool CanEdit(TeachingTask task) =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
IsAssignedTeacher(task);
|
||||
|
||||
private bool IsAssignedTeacher(TeachingTask task) =>
|
||||
task.Teachers.Any(x =>
|
||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||
|
||||
private bool CanReviewCollege(Guid collegeId)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
|
||||
!scope.IsInRole(SystemRoles.AcademicAdmin) &&
|
||||
!scope.IsInRole(SystemRoles.CollegeAdmin))
|
||||
return false;
|
||||
return scope.Scope == DataScope.All ||
|
||||
scope.RestrictedCollegeId == collegeId;
|
||||
}
|
||||
|
||||
private bool IsPublisher() =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private Task<Student?> CurrentStudentAsync(
|
||||
CancellationToken cancellationToken) =>
|
||||
db.Students.FirstOrDefaultAsync(x =>
|
||||
x.UserId == currentUserDataScope.Current.UserId &&
|
||||
x.Status == StudentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
private async Task NotifyTeachersAsync(
|
||||
Guid teachingTaskId,
|
||||
string title,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userIds = await db.TeachingTaskTeachers
|
||||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||
.Select(x => x.Teacher!.UserId)
|
||||
.Where(x => x != null)
|
||||
.Select(x => x!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
await NotificationService.SendToUserIdsAsync(
|
||||
db,
|
||||
userIds,
|
||||
title,
|
||||
content,
|
||||
"/experiment-grades",
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
}
|
||||
|
||||
private static bool ValidScore(decimal? score) =>
|
||||
!score.HasValue || score.Value is >= 0 and <= 100;
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法完成实验成绩操作",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private sealed record ExperimentParticipantSeed(
|
||||
Guid StudentId,
|
||||
Guid? SessionId);
|
||||
}
|
||||
|
||||
public sealed record ExperimentGradeSheetRequest(
|
||||
Guid ExperimentProjectId,
|
||||
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal PassScore,
|
||||
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
|
||||
|
||||
public sealed record ExperimentGradeSettingsRequest(
|
||||
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal PassScore,
|
||||
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
|
||||
|
||||
public sealed record ExperimentGradeItemRequest(
|
||||
[Required, MaxLength(60)] string Name,
|
||||
ExperimentGradeItemKind Kind,
|
||||
[Range(typeof(decimal), "0.1", "100")] decimal Weight);
|
||||
|
||||
public sealed record ExperimentGradeRecordsRequest(
|
||||
IReadOnlyCollection<ExperimentGradeRecordRequest> Records);
|
||||
|
||||
public sealed record ExperimentGradeRecordRequest(
|
||||
Guid Id,
|
||||
ExperimentParticipationStatus ParticipationStatus,
|
||||
bool SafetyViolation,
|
||||
[Range(1, 20)] int AttemptNumber,
|
||||
[MaxLength(500)] string? SubmissionReference,
|
||||
DateTime? SubmittedAt,
|
||||
bool IsLate,
|
||||
[MaxLength(500)] string? TeacherComment,
|
||||
IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);
|
||||
|
||||
public sealed record ExperimentGradeItemScoreRequest(
|
||||
Guid ExperimentGradeItemId,
|
||||
decimal? Score,
|
||||
[MaxLength(300)] string? Comment);
|
||||
|
||||
public sealed record ExperimentGradeReviewRequest(
|
||||
[MaxLength(500)] string? Comment);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -40,18 +42,38 @@ public sealed class GradesController(
|
||||
public async Task<ActionResult> GetSheets(
|
||||
Guid? academicTermId,
|
||||
GradeSheetStatus? status,
|
||||
CancellationToken cancellationToken)
|
||||
string? keyword = null,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||
var source = AccessibleTasks().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == TeachingTaskStatus.Published ||
|
||||
x.Status == TeachingTaskStatus.Closed);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (status.HasValue)
|
||||
source = source.Where(x => db.GradeSheets.Any(sheet =>
|
||||
sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
keyword = keyword.Trim();
|
||||
source = source.Where(x =>
|
||||
x.TaskNumber.Contains(keyword) ||
|
||||
x.Name.Contains(keyword) ||
|
||||
x.Course!.Code.Contains(keyword) ||
|
||||
x.Course.Name.Contains(keyword));
|
||||
}
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -79,7 +101,9 @@ public sealed class GradesController(
|
||||
{
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Weight
|
||||
item.Weight,
|
||||
item.SourceType,
|
||||
item.SourceSnapshotAt
|
||||
}),
|
||||
StudentCount = sheet.Records.Count,
|
||||
CompletedCount = sheet.Records.Count(record =>
|
||||
@@ -95,10 +119,7 @@ public sealed class GradesController(
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (status.HasValue)
|
||||
items = items.Where(x => x.Sheet?.Status == status.Value).ToList();
|
||||
return Ok(items);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("sheets")]
|
||||
@@ -134,15 +155,13 @@ public sealed class GradesController(
|
||||
cancellationToken))
|
||||
return ConflictProblem("该教学班已经建立成绩单。");
|
||||
|
||||
var studentIds = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
var studentIds = await TeachingTaskRosterQuery
|
||||
.ForTask(db, task.Id)
|
||||
.AsNoTracking()
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (studentIds.Count == 0)
|
||||
return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。");
|
||||
return ConflictProblem("教学班当前没有有效学生,无法建立成绩单。");
|
||||
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
@@ -165,8 +184,18 @@ public sealed class GradesController(
|
||||
|
||||
[HttpGet("sheets/{id:guid}")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult> GetSheet(
|
||||
Guid id,
|
||||
int recordPage = 1,
|
||||
int recordPageSize = 50,
|
||||
string? studentKeyword = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
recordPage = Math.Max(1, recordPage);
|
||||
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
|
||||
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
|
||||
? null
|
||||
: studentKeyword.Trim();
|
||||
var sheet = await AccessibleSheets().AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
@@ -193,44 +222,58 @@ public sealed class GradesController(
|
||||
{
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Weight
|
||||
item.Weight,
|
||||
item.SourceType,
|
||||
item.SourceSnapshotAt
|
||||
}),
|
||||
x.Status,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt,
|
||||
x.PublishedAt,
|
||||
Records = x.Records
|
||||
.OrderBy(record => record.Student!.StudentNumber)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.StudentId,
|
||||
record.Student!.StudentNumber,
|
||||
record.Student.Name,
|
||||
ClassName = record.Student.AdministrativeClass!.Name,
|
||||
record.RegularScore,
|
||||
record.FinalScore,
|
||||
ItemScores = record.ItemScores
|
||||
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
|
||||
.Select(itemScore => new
|
||||
{
|
||||
itemScore.GradeItemId,
|
||||
itemScore.GradeItem!.Name,
|
||||
itemScore.Score
|
||||
}),
|
||||
record.TotalScore,
|
||||
record.GradePoint,
|
||||
record.ExamStatus,
|
||||
record.Notes,
|
||||
record.UpdatedAt
|
||||
}),
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var recordsSource = db.GradeRecords.AsNoTracking()
|
||||
.Where(record => record.GradeSheetId == id);
|
||||
if (studentKeyword is not null)
|
||||
recordsSource = recordsSource.Where(record =>
|
||||
record.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||
record.Student.Name.Contains(studentKeyword) ||
|
||||
record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
|
||||
var recordTotal = await recordsSource.CountAsync(cancellationToken);
|
||||
var records = await recordsSource
|
||||
.OrderBy(record => record.Student!.StudentNumber)
|
||||
.Skip((recordPage - 1) * recordPageSize)
|
||||
.Take(recordPageSize)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.StudentId,
|
||||
record.Student!.StudentNumber,
|
||||
record.Student.Name,
|
||||
ClassName = record.Student.AdministrativeClass!.Name,
|
||||
record.RegularScore,
|
||||
record.FinalScore,
|
||||
ItemScores = record.ItemScores
|
||||
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
|
||||
.Select(itemScore => new
|
||||
{
|
||||
itemScore.GradeItemId,
|
||||
itemScore.GradeItem!.Name,
|
||||
itemScore.Score
|
||||
}),
|
||||
record.TotalScore,
|
||||
record.GradePoint,
|
||||
record.ExamStatus,
|
||||
record.Notes,
|
||||
record.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Include(x => x.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
@@ -244,7 +287,36 @@ public sealed class GradesController(
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = sheet,
|
||||
Sheet = new
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.TaskNumber,
|
||||
sheet.TaskName,
|
||||
sheet.AcademicTermId,
|
||||
sheet.TermName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
sheet.CourseCollegeId,
|
||||
sheet.CourseCollegeName,
|
||||
sheet.Credits,
|
||||
sheet.TeacherNames,
|
||||
sheet.ClassNames,
|
||||
sheet.RegularWeight,
|
||||
sheet.FinalWeight,
|
||||
sheet.Items,
|
||||
sheet.Status,
|
||||
sheet.ReviewComment,
|
||||
sheet.SubmittedAt,
|
||||
sheet.ReviewedAt,
|
||||
sheet.PublishedAt,
|
||||
RecordTotal = recordTotal,
|
||||
RecordPage = recordPage,
|
||||
RecordPageSize = recordPageSize,
|
||||
Records = records,
|
||||
sheet.CreatedAt,
|
||||
sheet.UpdatedAt
|
||||
},
|
||||
CanEdit = CanEditScores(task) &&
|
||||
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
||||
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
|
||||
@@ -340,7 +412,7 @@ public sealed class GradesController(
|
||||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||
return ValidationProblem("包含无效或重复的成绩记录。");
|
||||
|
||||
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
|
||||
var itemsById = sheet.Items.ToDictionary(item => item.Id);
|
||||
foreach (var item in request.Records)
|
||||
{
|
||||
if (!ValidScore(item.RegularScore) ||
|
||||
@@ -360,8 +432,19 @@ public sealed class GradesController(
|
||||
{
|
||||
if (!ValidScore(scoreEntry.Score))
|
||||
return ValidationProblem("分项成绩必须在 0—100 分之间。");
|
||||
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
|
||||
existing.Score = scoreEntry.Score;
|
||||
if (!itemsById.TryGetValue(
|
||||
scoreEntry.GradeItemId,
|
||||
out var gradeItem) ||
|
||||
!scoreMap.TryGetValue(
|
||||
scoreEntry.GradeItemId,
|
||||
out var existing))
|
||||
continue;
|
||||
if (gradeItem.SourceType ==
|
||||
GradeItemSourceType.ExperimentSummary &&
|
||||
existing.Score != scoreEntry.Score)
|
||||
return ConflictProblem(
|
||||
$"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。");
|
||||
existing.Score = scoreEntry.Score;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +453,66 @@ public sealed class GradesController(
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> ImportExperimentScores(
|
||||
Guid id,
|
||||
ImportExperimentScoresRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets()
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
GradeSheetStatus.Draft or
|
||||
GradeSheetStatus.Returned))
|
||||
return ConflictProblem("成绩单提交后不能导入实验成绩。");
|
||||
|
||||
var targetItem = sheet.Items.FirstOrDefault(x =>
|
||||
x.Id == request.GradeItemId);
|
||||
if (targetItem is null)
|
||||
return ValidationProblem("请选择当前成绩单中的有效分项。");
|
||||
|
||||
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
|
||||
db,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.Records.Select(x => x.StudentId).ToArray(),
|
||||
cancellationToken);
|
||||
if (aggregate.PublishedProjectCount == 0)
|
||||
return ConflictProblem(
|
||||
"该教学任务尚无已发布的实验项目成绩,无法导入。");
|
||||
|
||||
var imported = 0;
|
||||
var missing = 0;
|
||||
foreach (var record in sheet.Records)
|
||||
{
|
||||
var targetScore = record.ItemScores.First(x =>
|
||||
x.GradeItemId == targetItem.Id);
|
||||
targetScore.Score = aggregate.Scores[record.StudentId];
|
||||
if (targetScore.Score.HasValue) imported++;
|
||||
else missing++;
|
||||
Recalculate(sheet, record);
|
||||
}
|
||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
aggregate.PublishedProjectCount,
|
||||
aggregate.Projects,
|
||||
ImportedCount = imported,
|
||||
MissingCount = missing,
|
||||
targetItem.SourceSnapshotAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/submit")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||||
@@ -411,7 +554,8 @@ public sealed class GradesController(
|
||||
$"《{courseName}》成绩已提交,请及时审核。",
|
||||
collegeId,
|
||||
"/grades",
|
||||
cancellationToken);
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -452,7 +596,7 @@ public sealed class GradesController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"成绩审核通过",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
|
||||
"/grades", cancellationToken);
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -497,7 +641,7 @@ public sealed class GradesController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"成绩被退回",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
|
||||
"/grades", cancellationToken);
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -505,7 +649,10 @@ public sealed class GradesController(
|
||||
[Authorize(Roles = Publishers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
||||
var sheet = await AccessibleSheets()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
@@ -514,13 +661,11 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify enrolled students
|
||||
var studentUserIds = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// covers students added through approved roster corrections.
|
||||
var studentUserIds = await db.GradeRecords
|
||||
.Where(x => x.GradeSheetId == sheet.Id)
|
||||
.Select(x => x.Student!.UserId)
|
||||
.Where(id => id != null)
|
||||
.Select(id => id!.Value)
|
||||
@@ -528,8 +673,8 @@ public sealed class GradesController(
|
||||
.ToListAsync(cancellationToken);
|
||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||
"成绩已发布",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
|
||||
"/grades", cancellationToken);
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -928,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
|
||||
Guid GradeItemId,
|
||||
decimal? Score);
|
||||
|
||||
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
|
||||
|
||||
public sealed record GradeReviewRequest(
|
||||
[MaxLength(500)] string? Comment);
|
||||
|
||||
@@ -61,8 +61,16 @@ public sealed class GraduationAuditsController(
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
|
||||
public async Task<ActionResult> GetBatch(
|
||||
Guid id,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? keyword = null,
|
||||
[FromQuery] GraduationAuditConclusion? conclusion = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var batch = await db.GraduationAuditBatches.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||
if (batch is null) return NotFound();
|
||||
@@ -73,6 +81,59 @@ public sealed class GraduationAuditsController(
|
||||
if (collegeId.HasValue)
|
||||
results = results.Where(x =>
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
|
||||
var summary = await results
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
ResultCount = group.Count(),
|
||||
EligibleCount = group.Count(x =>
|
||||
x.Conclusion == GraduationAuditConclusion.Eligible),
|
||||
IneligibleCount = group.Count(x =>
|
||||
x.Conclusion == GraduationAuditConclusion.Ineligible),
|
||||
OverrideCount = group.Count(x => x.IsOverridden)
|
||||
})
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
results = results.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(normalizedKeyword) ||
|
||||
x.Student.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
|
||||
x.Student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
|
||||
if (conclusion.HasValue)
|
||||
results = results.Where(x => x.Conclusion == conclusion.Value);
|
||||
|
||||
var total = await results.CountAsync(token);
|
||||
var items = await results
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.StudentStatusSnapshot,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id,
|
||||
@@ -83,31 +144,17 @@ public sealed class GraduationAuditsController(
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await results
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.StudentStatusSnapshot,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
ResultCount = summary?.ResultCount ?? 0,
|
||||
EligibleCount = summary?.EligibleCount ?? 0,
|
||||
IneligibleCount = summary?.IneligibleCount ?? 0,
|
||||
OverrideCount = summary?.OverrideCount ?? 0,
|
||||
Results = new
|
||||
{
|
||||
Items = items,
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,10 +203,11 @@ public sealed class GraduationAuditsController(
|
||||
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.ToListAsync(token);
|
||||
var studentIds = students.Select(x => x.Id).ToArray();
|
||||
var grades = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Where(x => x.Student!.EnrollmentYear == batch.EnrollmentYear &&
|
||||
(x.Student.Status == StudentStatus.Active ||
|
||||
x.Student.Status == StudentStatus.Suspended))
|
||||
.Select(x => new GradeSnapshot(
|
||||
x.StudentId,
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
@@ -169,16 +217,23 @@ public sealed class GraduationAuditsController(
|
||||
x.ExamStatus))
|
||||
.ToListAsync(token);
|
||||
|
||||
var oldResults = await db.GraduationAuditResults
|
||||
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
|
||||
db.GraduationAuditResults.RemoveRange(oldResults);
|
||||
await db.SaveChangesAsync(token);
|
||||
var plansByMajor = plans
|
||||
.GroupBy(x => x.MajorId)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
var gradesByStudent = grades
|
||||
.GroupBy(x => x.StudentId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
|
||||
await db.GraduationAuditResults
|
||||
.Where(x => x.GraduationAuditBatchId == id)
|
||||
.ExecuteDeleteAsync(token);
|
||||
|
||||
foreach (var student in students)
|
||||
{
|
||||
var plan = plans.FirstOrDefault(x =>
|
||||
x.MajorId == student.AdministrativeClass!.MajorId);
|
||||
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
|
||||
plansByMajor.TryGetValue(
|
||||
student.AdministrativeClass!.MajorId,
|
||||
out var plan);
|
||||
var studentGrades = gradesByStudent.GetValueOrDefault(student.Id) ?? [];
|
||||
var passedCourseIds = studentGrades
|
||||
.Where(IsPassed)
|
||||
.Select(x => x.CourseId)
|
||||
|
||||
@@ -34,31 +34,46 @@ public sealed class GraduationClearanceController(
|
||||
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
|
||||
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
|
||||
}).ToListAsync(token);
|
||||
var records = await ScopedRecords().AsNoTracking()
|
||||
.Select(x => new
|
||||
var statistics = await ScopedRecords().AsNoTracking()
|
||||
.GroupBy(x => x.GraduationClearanceItem!.GraduationClearanceBatchId)
|
||||
.Select(group => new
|
||||
{
|
||||
BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId,
|
||||
x.StudentId, x.Status
|
||||
}).ToListAsync(token);
|
||||
BatchId = group.Key,
|
||||
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
|
||||
RecordCount = group.Count(),
|
||||
CompletedCount = group.Count(x =>
|
||||
x.Status != GraduationClearanceRecordStatus.Pending)
|
||||
}).ToDictionaryAsync(x => x.BatchId, token);
|
||||
return Ok(batches.Select(batch =>
|
||||
{
|
||||
var scoped = records.Where(x => x.BatchId == batch.Id).ToList();
|
||||
statistics.TryGetValue(batch.Id, out var summary);
|
||||
return new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
||||
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
|
||||
StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(),
|
||||
RecordCount = scoped.Count,
|
||||
CompletedCount = scoped.Count(x =>
|
||||
x.Status != GraduationClearanceRecordStatus.Pending)
|
||||
StudentCount = summary?.StudentCount ?? 0,
|
||||
RecordCount = summary?.RecordCount ?? 0,
|
||||
CompletedCount = summary?.CompletedCount ?? 0
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Workers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
|
||||
public async Task<ActionResult> GetBatch(
|
||||
Guid id,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? keyword = null,
|
||||
[FromQuery] string? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var normalizedProgress = progress?.Trim();
|
||||
if (normalizedProgress is not null and not "Pending" and not "Completed")
|
||||
return ValidationProblem("离校办理进度筛选值无效。");
|
||||
|
||||
var batch = await db.GraduationClearanceBatches.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
@@ -72,8 +87,56 @@ public sealed class GraduationClearanceController(
|
||||
})
|
||||
}).FirstOrDefaultAsync(token);
|
||||
if (batch is null) return NotFound();
|
||||
var records = await ScopedRecords().AsNoTracking()
|
||||
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
|
||||
|
||||
var records = ScopedRecords().AsNoTracking()
|
||||
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id);
|
||||
var summary = await records
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
|
||||
RecordCount = group.Count(),
|
||||
CompletedCount = group.Count(x =>
|
||||
x.Status != GraduationClearanceRecordStatus.Pending)
|
||||
})
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
var students = db.Students.AsNoTracking()
|
||||
.Where(student => records.Any(record => record.StudentId == student.Id));
|
||||
var normalizedKeyword = keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
students = students.Where(student =>
|
||||
student.StudentNumber.Contains(normalizedKeyword) ||
|
||||
student.Name.Contains(normalizedKeyword) ||
|
||||
student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
|
||||
student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
|
||||
if (normalizedProgress == "Pending")
|
||||
students = students.Where(student => records.Any(record =>
|
||||
record.StudentId == student.Id &&
|
||||
record.GraduationClearanceItem!.IsRequired &&
|
||||
record.Status == GraduationClearanceRecordStatus.Pending));
|
||||
else if (normalizedProgress == "Completed")
|
||||
students = students.Where(student => !records.Any(record =>
|
||||
record.StudentId == student.Id &&
|
||||
record.GraduationClearanceItem!.IsRequired &&
|
||||
record.Status == GraduationClearanceRecordStatus.Pending));
|
||||
|
||||
var total = await students.CountAsync(token);
|
||||
var studentPage = await students
|
||||
.OrderBy(student => student.StudentNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(student => new
|
||||
{
|
||||
student.Id,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
ClassName = student.AdministrativeClass!.Name,
|
||||
MajorName = student.AdministrativeClass.Major!.Name
|
||||
}).ToListAsync(token);
|
||||
var studentIds = studentPage.Select(student => student.Id).ToArray();
|
||||
var pageRecords = await records
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
|
||||
.Select(x => new
|
||||
@@ -89,11 +152,32 @@ public sealed class GraduationClearanceController(
|
||||
x.GraduationClearanceItem.IsRequired,
|
||||
x.Status, x.Notes, x.CompletedAt
|
||||
}).ToListAsync(token);
|
||||
var recordsByStudent = pageRecords
|
||||
.GroupBy(record => record.StudentId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
var studentItems = studentPage.Select(student => new
|
||||
{
|
||||
StudentId = student.Id,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
student.MajorName,
|
||||
Records = recordsByStudent.GetValueOrDefault(student.Id) ?? []
|
||||
}).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
||||
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
|
||||
Records = records
|
||||
StudentCount = summary?.StudentCount ?? 0,
|
||||
RecordCount = summary?.RecordCount ?? 0,
|
||||
CompletedCount = summary?.CompletedCount ?? 0,
|
||||
Students = new
|
||||
{
|
||||
Items = studentItems,
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,16 +234,15 @@ public sealed class GraduationClearanceController(
|
||||
if (batch.Status != GraduationClearanceBatchStatus.Open)
|
||||
return ConflictProblem("已关闭批次不能重新生成办理记录。");
|
||||
|
||||
var eligibleAudits = await db.GraduationAuditResults.AsNoTracking()
|
||||
var studentIds = await db.GraduationAuditResults.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||
x.Conclusion == GraduationAuditConclusion.Eligible &&
|
||||
x.Student!.Status == StudentStatus.Graduated)
|
||||
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||
.Select(x => new { x.StudentId })
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(token);
|
||||
var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray();
|
||||
var existing = await db.GraduationClearanceRecords.AsNoTracking()
|
||||
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
|
||||
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
|
||||
@@ -180,7 +263,7 @@ public sealed class GraduationClearanceController(
|
||||
created++;
|
||||
}
|
||||
await db.SaveChangesAsync(token);
|
||||
return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created });
|
||||
return Ok(new { StudentCount = studentIds.Count, CreatedRecordCount = created });
|
||||
}
|
||||
|
||||
[HttpPut("records/{id:guid}")]
|
||||
@@ -221,15 +304,17 @@ public sealed class GraduationClearanceController(
|
||||
public async Task<ActionResult> Close(Guid id, CancellationToken token)
|
||||
{
|
||||
var batch = await db.GraduationClearanceBatches
|
||||
.Include(x => x.Items)
|
||||
.ThenInclude(x => x.Records)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||
if (batch is null) return NotFound();
|
||||
if (batch.Status != GraduationClearanceBatchStatus.Open)
|
||||
return ConflictProblem("该批次已经关闭。");
|
||||
var records = batch.Items.SelectMany(item => item.Records.Select(record =>
|
||||
(item.IsRequired, record.Status)));
|
||||
if (!GraduationClearanceRules.CanClose(records))
|
||||
var hasIncompleteRequiredItem = await db.GraduationClearanceRecords
|
||||
.AnyAsync(record =>
|
||||
record.GraduationClearanceItem!.GraduationClearanceBatchId == id &&
|
||||
record.GraduationClearanceItem.IsRequired &&
|
||||
record.Status == GraduationClearanceRecordStatus.Pending,
|
||||
token);
|
||||
if (hasIncompleteRequiredItem)
|
||||
return ConflictProblem("仍有必办离校事项尚未完成。");
|
||||
batch.Status = GraduationClearanceBatchStatus.Closed;
|
||||
batch.ClosedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
@@ -17,9 +18,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class MakeupExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
MakeupExamArrangementService arrangementService,
|
||||
MakeupExamAutoJobQueue autoJobQueue) : ControllerBase
|
||||
MakeupExamEligibilityService eligibilityService) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
@@ -111,6 +110,7 @@ public sealed class MakeupExamsController(
|
||||
item.StartsAt,
|
||||
item.EndsAt,
|
||||
item.RequiredBuildingId,
|
||||
item.RequiredBuildingIds,
|
||||
RequiredBuildingName = item.RequiredBuilding != null
|
||||
? item.RequiredBuilding.Name : null,
|
||||
item.RequiredInvigilatorCount,
|
||||
@@ -138,38 +138,90 @@ public sealed class MakeupExamsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Enrollments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
|
||||
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
|
||||
|
||||
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Status,
|
||||
HasSessions = x.Sessions.Any()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿补考计划可以发布。");
|
||||
if (plan.Sessions.Count == 0)
|
||||
if (!plan.HasSessions)
|
||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||
|
||||
var unassigned = plan.Sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
||||
if (unassigned > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var job = new ExamPublishJob
|
||||
{
|
||||
Kind = ExamPublishJobKind.MakeupExam,
|
||||
PlanId = id,
|
||||
ActivePlanId = id,
|
||||
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||
CurrentStep = "等待后台校验"
|
||||
};
|
||||
db.ExamPublishJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.ExamPublish,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var existing = await FindActivePublishJobAsync(id, cancellationToken);
|
||||
if (existing is not null)
|
||||
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
|
||||
throw;
|
||||
}
|
||||
|
||||
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
|
||||
if (empty > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {empty} 个场次没有登记补考学生。");
|
||||
return AcceptedPublishJob(job, "补考发布任务已提交。");
|
||||
}
|
||||
|
||||
plan.Status = MakeupExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
[HttpGet("publish-jobs/{jobId:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetPublishJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId &&
|
||||
x.Kind == ExamPublishJobKind.MakeupExam,
|
||||
cancellationToken);
|
||||
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/publish-job")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetLatestPublishJob(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.PlanId == planId &&
|
||||
x.Kind == ExamPublishJobKind.MakeupExam)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpPost("plans/{id:guid}/archive")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
|
||||
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Published)
|
||||
@@ -189,6 +241,8 @@ public sealed class MakeupExamsController(
|
||||
CreateMakeupExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
@@ -215,6 +269,7 @@ public sealed class MakeupExamsController(
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredBuildingId = request.RequiredBuildingId,
|
||||
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||
Notes = Normalize(request.Notes),
|
||||
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||
@@ -224,6 +279,81 @@ public sealed class MakeupExamsController(
|
||||
return await SaveAsync(session.Id, true, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("plans/{planId:guid}/sessions/batch")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateSessionsBatch(
|
||||
Guid planId,
|
||||
CreateMakeupExamSessionsBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||
|
||||
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
|
||||
if (taskIds.Length == 0)
|
||||
return ValidationProblem("请至少选择一个教学班。");
|
||||
if (taskIds.Length > 100)
|
||||
return ValidationProblem("一次最多选择100个教学班。");
|
||||
|
||||
var timeResult = ResolveExamTime(plan.AcademicTermId,
|
||||
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||
if (timeResult.Error is not null) return timeResult.Error;
|
||||
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||
|
||||
var validTaskIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.WhereIn(taskIds, x => x.Id)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (validTaskIds.Count != taskIds.Length)
|
||||
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
|
||||
|
||||
var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == planId)
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Select(x => x.TeachingTaskId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (duplicateTaskIds.Count > 0)
|
||||
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。");
|
||||
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
var validation = await ValidateSessionAsync(plan, null,
|
||||
taskId, null, null, startsAt, endsAt, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
}
|
||||
|
||||
db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession
|
||||
{
|
||||
MakeupExamPlanId = planId,
|
||||
TeachingTaskId = taskId,
|
||||
ExamDate = request.ExamDate,
|
||||
StartPeriod = request.StartPeriod,
|
||||
PeriodCount = request.PeriodCount,
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
RequiredBuildingId = request.RequiredBuildingId,
|
||||
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||
Notes = Normalize(request.Notes)
|
||||
}));
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { createdCount = taskIds.Length });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateSession(
|
||||
@@ -232,6 +362,8 @@ public sealed class MakeupExamsController(
|
||||
CreateMakeupExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var session = await db.MakeupExamSessions
|
||||
.Include(x => x.MakeupExamPlan)
|
||||
.Include(x => x.Invigilators)
|
||||
@@ -258,6 +390,7 @@ public sealed class MakeupExamsController(
|
||||
session.StartsAt = startsAt;
|
||||
session.EndsAt = endsAt;
|
||||
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
|
||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||
session.Notes = Normalize(request.Notes);
|
||||
|
||||
@@ -275,6 +408,8 @@ public sealed class MakeupExamsController(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
@@ -292,12 +427,121 @@ public sealed class MakeupExamsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> AutoArrange(
|
||||
Guid planId,
|
||||
ExamAutoArrangeRequest? request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await arrangementService.ArrangeAsync(planId, cancellationToken);
|
||||
if (!result.Success)
|
||||
return ConflictProblem(result.Message);
|
||||
return Ok(new { message = result.Message });
|
||||
request ??= new ExamAutoArrangeRequest();
|
||||
if (!request.AssignClassrooms && !request.AssignInvigilators)
|
||||
return ValidationProblem("请至少选择分配考场或分配监考教师。");
|
||||
|
||||
var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
|
||||
if (sessionIds.Length > 100)
|
||||
return ValidationProblem("一次最多处理100个补考场次。");
|
||||
|
||||
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == planId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Status,
|
||||
TotalSessions = x.Sessions.Count
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null)
|
||||
return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
|
||||
if (sessionIds.Length > 0)
|
||||
{
|
||||
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == planId)
|
||||
.WhereIn(sessionIds, x => x.Id)
|
||||
.CountAsync(cancellationToken);
|
||||
if (selectedSessionCount != sessionIds.Length)
|
||||
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
|
||||
}
|
||||
|
||||
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x => x.MakeupExamPlanId == planId &&
|
||||
(x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running),
|
||||
cancellationToken);
|
||||
if (autoCreateActive)
|
||||
return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
|
||||
|
||||
var existing = await FindActiveArrangementJobAsync(
|
||||
planId,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
|
||||
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var job = new ExamArrangementJob
|
||||
{
|
||||
Kind = ExamArrangementKind.MakeupExam,
|
||||
PlanId = planId,
|
||||
ActivePlanId = planId,
|
||||
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||
SessionIdsJson = sessionIds.Length == 0
|
||||
? null
|
||||
: System.Text.Json.JsonSerializer.Serialize(sessionIds),
|
||||
AssignClassrooms = request.AssignClassrooms,
|
||||
AssignInvigilators = request.AssignInvigilators,
|
||||
TotalSessions = sessionIds.Length == 0
|
||||
? plan.TotalSessions
|
||||
: sessionIds.Length,
|
||||
CurrentStep = "等待后台编排"
|
||||
};
|
||||
db.ExamArrangementJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
existing = await FindActiveArrangementJobAsync(
|
||||
planId,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
|
||||
throw;
|
||||
}
|
||||
|
||||
return AcceptedArrangementJob(job, "补考编排任务已提交。");
|
||||
}
|
||||
|
||||
[HttpGet("arrangement-jobs/{jobId:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetArrangementJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamArrangementJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId &&
|
||||
x.Kind == ExamArrangementKind.MakeupExam,
|
||||
cancellationToken);
|
||||
return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/arrangement-job")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetLatestArrangementJob(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.PlanId == planId &&
|
||||
x.Kind == ExamArrangementKind.MakeupExam)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -315,6 +559,8 @@ public sealed class MakeupExamsController(
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
|
||||
|
||||
// Check for existing active job
|
||||
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
@@ -336,10 +582,12 @@ public sealed class MakeupExamsController(
|
||||
MakeupExamPlanId = planId
|
||||
};
|
||||
db.MakeupExamAutoJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
job.Id));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
autoJobQueue.Enqueue(job.Id);
|
||||
|
||||
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
|
||||
new { jobId = job.Id, status = job.Status.ToString() });
|
||||
}
|
||||
@@ -553,19 +801,43 @@ public sealed class MakeupExamsController(
|
||||
x.TeachingTask!.TaskNumber,
|
||||
CourseName = x.TeachingTask.Course!.Name,
|
||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||
x.StartsAt,
|
||||
Students = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name,
|
||||
e.Reason,
|
||||
e.MakeupScore
|
||||
}).ToList()
|
||||
x.StartsAt
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
if (session is null) return NotFound();
|
||||
|
||||
var students = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.Where(e => e.MakeupExamSessionId == id)
|
||||
.OrderBy(e => e.Student!.StudentNumber)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StudentId,
|
||||
e.Student!.StudentNumber,
|
||||
e.Student.Name,
|
||||
ClassName = e.Student.AdministrativeClass!.Name,
|
||||
e.Reason,
|
||||
e.MakeupScore
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.TaskNumber,
|
||||
session.CourseName,
|
||||
session.ClassroomName,
|
||||
session.StartsAt,
|
||||
Students = students.Select((student, index) => new
|
||||
{
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
student.Reason,
|
||||
student.MakeupScore,
|
||||
SeatNumber = (index + 1).ToString("D3")
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -697,7 +969,7 @@ public sealed class MakeupExamsController(
|
||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
|
||||
var schedule = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.Where(x => x.StudentId == studentId &&
|
||||
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
||||
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
||||
@@ -722,7 +994,49 @@ public sealed class MakeupExamsController(
|
||||
x.MakeupScore,
|
||||
IsMakeup = true
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
if (schedule.Count == 0) return Ok(schedule);
|
||||
|
||||
var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray();
|
||||
var seatRows = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.WhereIn(sessionIds, x => x.MakeupExamSessionId)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.MakeupExamSessionId,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var seatNumbers = seatRows
|
||||
.GroupBy(x => x.MakeupExamSessionId)
|
||||
.SelectMany(group => group.Select((row, index) => new
|
||||
{
|
||||
row.MakeupExamSessionId,
|
||||
row.StudentId,
|
||||
SeatNumber = (index + 1).ToString("D3")
|
||||
}))
|
||||
.ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber);
|
||||
|
||||
return Ok(schedule.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.Reason,
|
||||
x.MakeupScore,
|
||||
SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)),
|
||||
x.IsMakeup
|
||||
}));
|
||||
}
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
@@ -895,6 +1209,87 @@ public sealed class MakeupExamsController(
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Kind == ExamArrangementKind.MakeupExam &&
|
||||
x.ActivePlanId == planId &&
|
||||
(x.Status == ExamArrangementJobStatus.Queued ||
|
||||
x.Status == ExamArrangementJobStatus.Running))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
private ActionResult AcceptedArrangementJob(
|
||||
ExamArrangementJob job,
|
||||
string message) =>
|
||||
AcceptedAtAction(
|
||||
nameof(GetArrangementJob),
|
||||
new { jobId = job.Id },
|
||||
new
|
||||
{
|
||||
jobId = job.Id,
|
||||
status = job.Status.ToString(),
|
||||
message
|
||||
});
|
||||
|
||||
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
|
||||
{
|
||||
job.Id,
|
||||
job.PlanId,
|
||||
Kind = job.Kind.ToString(),
|
||||
Status = job.Status.ToString(),
|
||||
job.TotalSessions,
|
||||
job.ProcessedSessions,
|
||||
job.CurrentStep,
|
||||
job.ResultMessage,
|
||||
job.ErrorMessage,
|
||||
job.AssignClassrooms,
|
||||
job.AssignInvigilators,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt
|
||||
};
|
||||
|
||||
private Task<ExamPublishJob?> FindActivePublishJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Kind == ExamPublishJobKind.MakeupExam &&
|
||||
x.ActivePlanId == planId &&
|
||||
(x.Status == ExamPublishJobStatus.Queued ||
|
||||
x.Status == ExamPublishJobStatus.Running))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
private ActionResult AcceptedPublishJob(
|
||||
ExamPublishJob job,
|
||||
string message) =>
|
||||
AcceptedAtAction(
|
||||
nameof(GetPublishJob),
|
||||
new { jobId = job.Id },
|
||||
new
|
||||
{
|
||||
jobId = job.Id,
|
||||
status = job.Status.ToString(),
|
||||
message
|
||||
});
|
||||
|
||||
private static object ToPublishJobResponse(ExamPublishJob job) => new
|
||||
{
|
||||
job.Id,
|
||||
job.PlanId,
|
||||
Kind = job.Kind.ToString(),
|
||||
Status = job.Status.ToString(),
|
||||
job.CurrentStep,
|
||||
job.ErrorMessage,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt
|
||||
};
|
||||
|
||||
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||
CancellationToken token)
|
||||
{
|
||||
@@ -917,6 +1312,10 @@ public sealed class MakeupExamsController(
|
||||
});
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
|
||||
ids is { Count: > 0 }
|
||||
? System.Text.Json.JsonSerializer.Serialize(ids)
|
||||
: null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -935,10 +1334,21 @@ public sealed record CreateMakeupExamSessionRequest(
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
Guid? RequiredBuildingId,
|
||||
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record CreateMakeupExamSessionsBatchRequest(
|
||||
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||
DateOnly ExamDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 6)] int PeriodCount,
|
||||
Guid? RequiredBuildingId,
|
||||
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record EnrollStudentsRequest(
|
||||
[Required] IReadOnlyCollection<Guid> StudentIds);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -14,20 +17,43 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/notifications")]
|
||||
public sealed class NotificationsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
ILogger<NotificationsController>? logger = null) : ControllerBase
|
||||
{
|
||||
private const int MaximumSelectedRecipients = 500;
|
||||
private const int NotificationBatchSize = 300;
|
||||
private const string Senders =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Counselor + "," +
|
||||
SystemRoles.Teacher;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetNotifications(
|
||||
bool? unreadOnly,
|
||||
NotificationCategory? category,
|
||||
string? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var source = db.Notifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId);
|
||||
if (unreadOnly == true)
|
||||
source = source.Where(x => !x.IsRead);
|
||||
if (category.HasValue)
|
||||
source = source.Where(x => x.Category == category);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var value = keyword.Trim();
|
||||
source = source.Where(x =>
|
||||
x.Title.Contains(value) ||
|
||||
x.Content.Contains(value));
|
||||
}
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
@@ -36,14 +62,38 @@ public sealed class NotificationsController(
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Content,
|
||||
x.Category,
|
||||
x.IsRead,
|
||||
x.LinkUrl,
|
||||
SenderName = x.MessageDispatch == null
|
||||
? "教务系统"
|
||||
: x.MessageDispatch.SenderName,
|
||||
AudienceName = x.MessageDispatch == null
|
||||
? null
|
||||
: x.MessageDispatch.AudienceName,
|
||||
IsManual = x.MessageDispatchId != null,
|
||||
x.CreatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var unreadCount = await db.Notifications
|
||||
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
|
||||
var unreadByCategory = await db.Notifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && !x.IsRead)
|
||||
.GroupBy(x => x.Category)
|
||||
.Select(x => new { Category = x.Key, Count = x.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new { Items = items, Total = total, UnreadCount = unreadCount });
|
||||
return Ok(new
|
||||
{
|
||||
Items = items,
|
||||
Total = total,
|
||||
UnreadCount = unreadCount,
|
||||
UnreadByCategory = unreadByCategory
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("unread-count")]
|
||||
@@ -55,30 +105,802 @@ public sealed class NotificationsController(
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
|
||||
[HttpGet("composer")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> GetComposer(CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
null,
|
||||
MessageAudienceType.School,
|
||||
"全校已启用账号",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
var collegeId = await ResolveSenderCollegeIdAsync(
|
||||
scope,
|
||||
cancellationToken);
|
||||
if (!collegeId.HasValue)
|
||||
{
|
||||
return ConflictProblem(
|
||||
"当前账号未关联学院,暂时无法确定发信范围。");
|
||||
}
|
||||
|
||||
var college = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.Id == collegeId.Value)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (college is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
collegeId.Value,
|
||||
MessageAudienceType.College,
|
||||
$"{college}全院成员",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
var tasks = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status != TeachingTaskStatus.Draft &&
|
||||
task.Teachers.Any(item =>
|
||||
item.Teacher!.UserId == scope.UserId))
|
||||
.OrderByDescending(task => task.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(task => task.AcademicTerm!.StartDate)
|
||||
.ThenBy(task => task.TaskNumber)
|
||||
.Select(task => new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskNumber,
|
||||
CourseName = task.Course!.Name,
|
||||
TermName = task.AcademicTerm!.Name,
|
||||
RecipientCount = db.Students.Count(student =>
|
||||
student.Status == StudentStatus.Active &&
|
||||
student.UserId != null &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClassId ==
|
||||
student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status ==
|
||||
CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!
|
||||
.TeachingTaskId == task.Id)))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AudienceType = MessageAudienceType.TeachingTask,
|
||||
AudienceName = "所带教学班",
|
||||
RecipientCount = 0,
|
||||
TeachingTasks = tasks
|
||||
});
|
||||
}
|
||||
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
[HttpGet("recipients")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> GetRecipients(
|
||||
Guid? collegeId,
|
||||
string? role,
|
||||
Guid? administrativeClassId,
|
||||
Guid? teachingTaskId,
|
||||
string? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 30,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (scope.IsInRole(SystemRoles.Teacher) &&
|
||||
!IsSchoolAdministrator(scope) &&
|
||||
!scope.IsInRole(SystemRoles.CollegeAdmin) &&
|
||||
!scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var resolvedCollegeId = IsSchoolAdministrator(scope)
|
||||
? null
|
||||
: await ResolveSenderCollegeIdAsync(scope, cancellationToken);
|
||||
if (!IsSchoolAdministrator(scope) && !resolvedCollegeId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学院,暂时无法确定收件人范围。");
|
||||
|
||||
var filter = new MessageRecipientFilter(
|
||||
collegeId,
|
||||
Normalize(role),
|
||||
administrativeClassId,
|
||||
teachingTaskId,
|
||||
Normalize(keyword));
|
||||
if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role))
|
||||
return ValidationProblem("所选身份类型无效。");
|
||||
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var source = ApplyRecipientFilter(
|
||||
ScopedRecipientQuery(scope, resolvedCollegeId),
|
||||
filter);
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var users = await source
|
||||
.OrderBy(x => x.DisplayName)
|
||||
.ThenBy(x => x.UserName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(user => new
|
||||
{
|
||||
user.Id,
|
||||
user.DisplayName,
|
||||
user.UserName,
|
||||
user.StaffNumber,
|
||||
CollegeName = db.Colleges
|
||||
.Where(college => college.Id == user.CollegeId)
|
||||
.Select(college => college.Name)
|
||||
.FirstOrDefault(),
|
||||
StudentNumber = db.Students
|
||||
.Where(student => student.UserId == user.Id)
|
||||
.Select(student => student.StudentNumber)
|
||||
.FirstOrDefault(),
|
||||
ClassName = db.Students
|
||||
.Where(student => student.UserId == user.Id)
|
||||
.Select(student => student.AdministrativeClass!.Name)
|
||||
.FirstOrDefault(),
|
||||
TeacherNumber = db.Teachers
|
||||
.Where(teacher => teacher.UserId == user.Id)
|
||||
.Select(teacher => teacher.TeacherNumber)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var userIds = users.Select(x => x.Id).ToArray();
|
||||
var roleRows = await db.UserRoles.AsNoTracking()
|
||||
.Where(x => userIds.Contains(x.UserId))
|
||||
.Join(
|
||||
db.Roles.AsNoTracking(),
|
||||
userRole => userRole.RoleId,
|
||||
roleEntity => roleEntity.Id,
|
||||
(userRole, roleEntity) => new
|
||||
{
|
||||
userRole.UserId,
|
||||
RoleName = roleEntity.Name!
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var rolesByUser = roleRows
|
||||
.GroupBy(x => x.UserId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(x => x.RoleName).Distinct().ToArray());
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = users.Select(user => new
|
||||
{
|
||||
user.Id,
|
||||
user.DisplayName,
|
||||
user.UserName,
|
||||
Number = user.StudentNumber ?? user.TeacherNumber ?? user.StaffNumber,
|
||||
user.CollegeName,
|
||||
user.ClassName,
|
||||
Roles = rolesByUser.GetValueOrDefault(user.Id, [])
|
||||
}),
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> Send(
|
||||
SendMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var title = request.Title.Trim();
|
||||
var content = request.Content.Trim();
|
||||
if (title.Length == 0)
|
||||
return ValidationProblem("请填写消息标题。");
|
||||
if (PlainText(content).Length == 0)
|
||||
return ValidationProblem("请填写消息正文。");
|
||||
if (content.Length > 20000)
|
||||
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
|
||||
var scope = currentUserDataScope.Current;
|
||||
MessageAudienceType audienceType;
|
||||
Guid? audienceId = null;
|
||||
string audienceName;
|
||||
List<Guid> recipientIds;
|
||||
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
var result = await ResolveAdministratorRecipientsAsync(
|
||||
scope,
|
||||
null,
|
||||
request,
|
||||
"全校已启用账号",
|
||||
cancellationToken);
|
||||
if (result.Error is not null) return result.Error;
|
||||
audienceType = result.AudienceType;
|
||||
audienceName = result.AudienceName!;
|
||||
recipientIds = result.RecipientIds!;
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
var collegeId = await ResolveSenderCollegeIdAsync(
|
||||
scope,
|
||||
cancellationToken);
|
||||
if (!collegeId.HasValue)
|
||||
{
|
||||
return ConflictProblem(
|
||||
"当前账号未关联学院,暂时无法确定发信范围。");
|
||||
}
|
||||
|
||||
var collegeName = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.Id == collegeId.Value)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (collegeName is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
var result = await ResolveAdministratorRecipientsAsync(
|
||||
scope,
|
||||
collegeId.Value,
|
||||
request,
|
||||
$"{collegeName}全院成员",
|
||||
cancellationToken);
|
||||
if (result.Error is not null) return result.Error;
|
||||
audienceType = result.AudienceType;
|
||||
audienceId = audienceType == MessageAudienceType.College
|
||||
? collegeId.Value
|
||||
: null;
|
||||
audienceName = result.AudienceName!;
|
||||
recipientIds = result.RecipientIds!;
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
if (!request.TeachingTaskId.HasValue)
|
||||
return ValidationProblem("请选择接收消息的教学班。");
|
||||
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Id == request.TeachingTaskId.Value &&
|
||||
x.Status != TeachingTaskStatus.Draft &&
|
||||
x.Teachers.Any(item =>
|
||||
item.Teacher!.UserId == scope.UserId))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
CourseName = x.Course!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (task is null)
|
||||
{
|
||||
return ConflictProblem(
|
||||
"只能向自己所带且已发布的教学班发信。");
|
||||
}
|
||||
|
||||
audienceType = MessageAudienceType.TeachingTask;
|
||||
audienceId = task.Id;
|
||||
audienceName = $"{task.TaskNumber} · {task.CourseName}";
|
||||
recipientIds = await GetTeachingTaskRecipientIdsAsync(
|
||||
task.Id,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
recipientIds = recipientIds.Distinct().ToList();
|
||||
if (recipientIds.Count == 0)
|
||||
return ConflictProblem("当前发信范围内没有可接收消息的已启用账号。");
|
||||
|
||||
var dispatch = new MessageDispatch
|
||||
{
|
||||
SenderUserId = scope.UserId,
|
||||
SenderName = scope.DisplayName ?? "教务用户",
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = NotificationCategory.General,
|
||||
AudienceType = audienceType,
|
||||
AudienceId = audienceId,
|
||||
AudienceName = audienceName,
|
||||
RecipientCount = recipientIds.Count,
|
||||
LinkUrl = null
|
||||
};
|
||||
try
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.MessageDispatches.Add(dispatch);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
foreach (var batch in recipientIds.Chunk(NotificationBatchSize))
|
||||
{
|
||||
db.Notifications.AddRange(batch.Select(userId => new Notification
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = NotificationCategory.General,
|
||||
LinkUrl = null,
|
||||
MessageDispatchId = dispatch.Id
|
||||
}));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.AudienceName,
|
||||
dispatch.RecipientCount,
|
||||
dispatch.CreatedAt
|
||||
});
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.ReadCommitted);
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
{
|
||||
logger?.LogError(
|
||||
exception,
|
||||
"Failed to send notification dispatch {DispatchId} to {RecipientCount} recipients.",
|
||||
dispatch.Id,
|
||||
recipientIds.Count);
|
||||
return Problem(
|
||||
title: "消息未能发送",
|
||||
detail: "消息数据保存失败。请确认数据库已完成最新升级后重试;本次消息没有发送。",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("sent")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> GetSent(
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var source = db.MessageDispatches.AsNoTracking()
|
||||
.Where(x => x.SenderUserId == userId);
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Content,
|
||||
x.Category,
|
||||
x.AudienceType,
|
||||
x.AudienceName,
|
||||
x.RecipientCount,
|
||||
x.CreatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Items = items, Total = total });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/read")]
|
||||
public async Task<ActionResult> MarkRead(Guid id, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult> MarkRead(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var n = await db.Notifications
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
|
||||
if (n is null) return NotFound();
|
||||
n.IsRead = true;
|
||||
var notification = await db.Notifications
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == id && x.UserId == userId,
|
||||
cancellationToken);
|
||||
if (notification is null) return NotFound();
|
||||
notification.IsRead = true;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("read-all")]
|
||||
public async Task<ActionResult> MarkAllRead(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult> MarkAllRead(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
await db.Notifications
|
||||
.Where(x => x.UserId == userId && !x.IsRead)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true),
|
||||
.ExecuteUpdateAsync(
|
||||
updates => updates.SetProperty(x => x.IsRead, true),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> GetTeachingTaskRecipientIdsAsync(
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.Students.AsNoTracking()
|
||||
.Where(student =>
|
||||
student.Status == StudentStatus.Active &&
|
||||
student.UserId != null &&
|
||||
db.Users.Any(user =>
|
||||
user.Id == student.UserId &&
|
||||
user.IsEnabled) &&
|
||||
(db.TeachingTaskClasses.Any(item =>
|
||||
item.TeachingTaskId == teachingTaskId &&
|
||||
item.AdministrativeClassId ==
|
||||
student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
teachingTaskId)))
|
||||
.Select(x => x.UserId!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Guid?> ResolveSenderCollegeIdAsync(
|
||||
CurrentUserScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (scope.CollegeId.HasValue)
|
||||
return scope.CollegeId;
|
||||
if (!scope.IsInRole(SystemRoles.Counselor))
|
||||
return null;
|
||||
|
||||
var collegeIds = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.CounselorUserId == scope.UserId)
|
||||
.Select(x => x.Major!.CollegeId)
|
||||
.Distinct()
|
||||
.Take(2)
|
||||
.ToListAsync(cancellationToken);
|
||||
return collegeIds.Count == 1 ? collegeIds[0] : null;
|
||||
}
|
||||
|
||||
private static bool IsSchoolAdministrator(CurrentUserScope scope) =>
|
||||
scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
scope.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private async Task<object> BuildAdministratorComposerAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? fixedCollegeId,
|
||||
MessageAudienceType audienceType,
|
||||
string audienceName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => !fixedCollegeId.HasValue || x.Id == fixedCollegeId.Value)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var administrativeClasses = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
(!fixedCollegeId.HasValue ||
|
||||
x.Major!.CollegeId == fixedCollegeId.Value))
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
CollegeId = x.Major!.CollegeId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var teachingTasks = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status != TeachingTaskStatus.Draft &&
|
||||
(!fixedCollegeId.HasValue ||
|
||||
x.Course!.CollegeId == fixedCollegeId.Value))
|
||||
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
CourseName = x.Course!.Name,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
CollegeId = x.Course.CollegeId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var recipientCount = await ScopedRecipientQuery(scope, fixedCollegeId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
return new
|
||||
{
|
||||
AudienceType = audienceType,
|
||||
AudienceName = audienceName,
|
||||
RecipientCount = recipientCount,
|
||||
CanFilterRecipients = true,
|
||||
MaximumSelectedRecipients,
|
||||
Colleges = colleges,
|
||||
Roles = RoleOptions,
|
||||
AdministrativeClasses = administrativeClasses,
|
||||
TeachingTasks = teachingTasks
|
||||
};
|
||||
}
|
||||
|
||||
private IQueryable<ApplicationUser> ScopedRecipientQuery(
|
||||
CurrentUserScope scope,
|
||||
Guid? fixedCollegeId)
|
||||
{
|
||||
var source = db.Users.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Id != scope.UserId);
|
||||
if (IsSchoolAdministrator(scope)) return source;
|
||||
if (!fixedCollegeId.HasValue) return source.Where(_ => false);
|
||||
var collegeId = fixedCollegeId.Value;
|
||||
return source.Where(user =>
|
||||
user.CollegeId == collegeId ||
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
|
||||
db.Teachers.Any(teacher =>
|
||||
teacher.UserId == user.Id &&
|
||||
teacher.CollegeId == collegeId));
|
||||
}
|
||||
|
||||
private IQueryable<ApplicationUser> ApplyRecipientFilter(
|
||||
IQueryable<ApplicationUser> source,
|
||||
MessageRecipientFilter? filter)
|
||||
{
|
||||
if (filter is null) return source;
|
||||
if (filter.CollegeId.HasValue)
|
||||
{
|
||||
var collegeId = filter.CollegeId.Value;
|
||||
source = source.Where(user =>
|
||||
user.CollegeId == collegeId ||
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
|
||||
db.Teachers.Any(teacher =>
|
||||
teacher.UserId == user.Id &&
|
||||
teacher.CollegeId == collegeId));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(filter.Role))
|
||||
{
|
||||
var role = filter.Role;
|
||||
source = source.Where(user =>
|
||||
db.UserRoles.Any(userRole =>
|
||||
userRole.UserId == user.Id &&
|
||||
db.Roles.Any(roleEntity =>
|
||||
roleEntity.Id == userRole.RoleId &&
|
||||
roleEntity.Name == role)));
|
||||
}
|
||||
if (filter.AdministrativeClassId.HasValue)
|
||||
{
|
||||
var classId = filter.AdministrativeClassId.Value;
|
||||
source = source.Where(user =>
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
student.AdministrativeClassId == classId));
|
||||
}
|
||||
if (filter.TeachingTaskId.HasValue)
|
||||
{
|
||||
var taskId = filter.TeachingTaskId.Value;
|
||||
source = source.Where(user =>
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
(db.TeachingTaskClasses.Any(item =>
|
||||
item.TeachingTaskId == taskId &&
|
||||
item.AdministrativeClassId ==
|
||||
student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
taskId))));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword;
|
||||
source = source.Where(user =>
|
||||
user.DisplayName.Contains(keyword) ||
|
||||
(user.UserName != null && user.UserName.Contains(keyword)) ||
|
||||
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
|
||||
db.Students.Any(student =>
|
||||
student.UserId == user.Id &&
|
||||
(student.Name.Contains(keyword) ||
|
||||
student.StudentNumber.Contains(keyword))) ||
|
||||
db.Teachers.Any(teacher =>
|
||||
teacher.UserId == user.Id &&
|
||||
(teacher.Name.Contains(keyword) ||
|
||||
teacher.TeacherNumber.Contains(keyword))));
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
private async Task<RecipientResolution> ResolveAdministratorRecipientsAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? fixedCollegeId,
|
||||
SendMessageRequest request,
|
||||
string defaultAudienceName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = ScopedRecipientQuery(scope, fixedCollegeId);
|
||||
if (request.RecipientMode == MessageRecipientMode.Scope)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
fixedCollegeId.HasValue
|
||||
? MessageAudienceType.College
|
||||
: MessageAudienceType.School,
|
||||
defaultAudienceName,
|
||||
await source.Select(x => x.Id).ToListAsync(cancellationToken),
|
||||
null);
|
||||
}
|
||||
|
||||
if (request.RecipientMode == MessageRecipientMode.Filtered)
|
||||
{
|
||||
if (request.RecipientFilter?.Role is not null &&
|
||||
!SystemRoles.All.Contains(request.RecipientFilter.Role))
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("所选身份类型无效。"));
|
||||
}
|
||||
var recipientIds = await ApplyRecipientFilter(
|
||||
source,
|
||||
request.RecipientFilter)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
return new RecipientResolution(
|
||||
MessageAudienceType.Custom,
|
||||
BuildFilteredAudienceName(request.RecipientFilter),
|
||||
recipientIds,
|
||||
null);
|
||||
}
|
||||
|
||||
if (request.RecipientMode == MessageRecipientMode.Selected)
|
||||
{
|
||||
var requestedIds = request.RecipientUserIds?
|
||||
.Distinct()
|
||||
.ToArray() ?? [];
|
||||
if (requestedIds.Length == 0)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("请至少选择一名收件人。"));
|
||||
}
|
||||
if (requestedIds.Length > MaximumSelectedRecipients)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem(
|
||||
$"单次最多指定 {MaximumSelectedRecipients} 名收件人。"));
|
||||
}
|
||||
|
||||
var authorizedIds = await source
|
||||
.Where(x => requestedIds.Contains(x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (authorizedIds.Count != requestedIds.Length)
|
||||
{
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("所选收件人包含无权限访问或已停用的账号。"));
|
||||
}
|
||||
|
||||
return new RecipientResolution(
|
||||
MessageAudienceType.Custom,
|
||||
$"指定收件人({authorizedIds.Count} 人)",
|
||||
authorizedIds,
|
||||
null);
|
||||
}
|
||||
|
||||
return new RecipientResolution(
|
||||
default,
|
||||
null,
|
||||
null,
|
||||
ValidationProblem("请选择有效的收件方式。"));
|
||||
}
|
||||
|
||||
private static string BuildFilteredAudienceName(MessageRecipientFilter? filter)
|
||||
{
|
||||
if (filter is null) return "当前权限范围内全部账号";
|
||||
var parts = new List<string>();
|
||||
if (filter.CollegeId.HasValue) parts.Add("指定学院");
|
||||
if (filter.Role is not null)
|
||||
parts.Add(RoleOptions.FirstOrDefault(x => x.Value == filter.Role)?.Label ??
|
||||
filter.Role);
|
||||
if (filter.AdministrativeClassId.HasValue) parts.Add("指定行政班");
|
||||
if (filter.TeachingTaskId.HasValue) parts.Add("指定教学班");
|
||||
if (filter.Keyword is not null) parts.Add($"关键词“{filter.Keyword}”");
|
||||
return parts.Count == 0
|
||||
? "当前权限范围内全部账号"
|
||||
: string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string PlainText(string html)
|
||||
{
|
||||
var withoutTags = Regex.Replace(html, "<[^>]+>", " ");
|
||||
return WebUtility.HtmlDecode(withoutTags).Trim();
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static readonly RoleOption[] RoleOptions =
|
||||
[
|
||||
new(SystemRoles.Student, "学生"),
|
||||
new(SystemRoles.Teacher, "任课教师"),
|
||||
new(SystemRoles.Counselor, "辅导员"),
|
||||
new(SystemRoles.CollegeAdmin, "学院管理员"),
|
||||
new(SystemRoles.AcademicAdmin, "校级教务管理员"),
|
||||
new(SystemRoles.Leader, "校领导"),
|
||||
new(SystemRoles.SuperAdmin, "超级管理员")
|
||||
];
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法发送消息",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private ActionResult ValidationProblem(string detail) =>
|
||||
BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "消息内容不完整",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SendMessageRequest(
|
||||
[Required, StringLength(200)] string Title,
|
||||
[Required, StringLength(20000)] string Content,
|
||||
Guid? TeachingTaskId = null,
|
||||
MessageRecipientMode RecipientMode = MessageRecipientMode.Scope,
|
||||
MessageRecipientFilter? RecipientFilter = null,
|
||||
IReadOnlyCollection<Guid>? RecipientUserIds = null);
|
||||
|
||||
public sealed record MessageRecipientFilter(
|
||||
Guid? CollegeId = null,
|
||||
string? Role = null,
|
||||
Guid? AdministrativeClassId = null,
|
||||
Guid? TeachingTaskId = null,
|
||||
string? Keyword = null);
|
||||
|
||||
public enum MessageRecipientMode
|
||||
{
|
||||
Scope = 1,
|
||||
Filtered = 2,
|
||||
Selected = 3
|
||||
}
|
||||
|
||||
public sealed record RoleOption(string Value, string Label);
|
||||
|
||||
internal sealed record RecipientResolution(
|
||||
MessageAudienceType AudienceType,
|
||||
string? AudienceName,
|
||||
List<Guid>? RecipientIds,
|
||||
ActionResult? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Centralized helper to send notifications across the app.
|
||||
/// </summary>
|
||||
@@ -90,13 +912,15 @@ public static class NotificationService
|
||||
string title,
|
||||
string content,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
db.Notifications.Add(new Notification
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -109,22 +933,46 @@ public static class NotificationService
|
||||
string content,
|
||||
Guid? collegeId = null,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
var query = db.Users
|
||||
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
|
||||
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! });
|
||||
.Join(
|
||||
db.UserRoles,
|
||||
user => user.Id,
|
||||
userRole => userRole.UserId,
|
||||
(user, userRole) => new
|
||||
{
|
||||
user.Id,
|
||||
user.IsEnabled,
|
||||
user.CollegeId,
|
||||
userRole.RoleId
|
||||
})
|
||||
.Join(
|
||||
db.Roles,
|
||||
item => item.RoleId,
|
||||
role => role.Id,
|
||||
(item, role) => new
|
||||
{
|
||||
item.Id,
|
||||
item.IsEnabled,
|
||||
item.CollegeId,
|
||||
RoleName = role.Name!
|
||||
});
|
||||
|
||||
var userIds = query.Where(x => x.RoleName == roleName);
|
||||
var userIds = query.Where(x =>
|
||||
x.RoleName == roleName &&
|
||||
x.IsEnabled);
|
||||
|
||||
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
|
||||
if (collegeId.HasValue)
|
||||
{
|
||||
userIds = userIds.Where(x =>
|
||||
db.Teachers.Any(t =>
|
||||
t.UserId == x.Id && t.CollegeId == collegeId.Value));
|
||||
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
|
||||
}
|
||||
|
||||
var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken);
|
||||
var ids = await userIds
|
||||
.Select(x => x.Id)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
db.Notifications.Add(new Notification
|
||||
@@ -132,6 +980,7 @@ public static class NotificationService
|
||||
UserId = id,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
}
|
||||
@@ -144,7 +993,8 @@ public static class NotificationService
|
||||
string title,
|
||||
string content,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
foreach (var userId in userIds.Distinct())
|
||||
{
|
||||
@@ -153,6 +1003,7 @@ public static class NotificationService
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/official-documents")]
|
||||
public sealed class OfficialDocumentsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
OfficialDocumentService service,
|
||||
IOptions<OfficialDocumentOptions> options) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
private const string Readers = Managers + "," + SystemRoles.Student;
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Roles = Readers)]
|
||||
public async Task<ActionResult> List(
|
||||
OfficialDocumentType? type,
|
||||
OfficialDocumentStatus? status,
|
||||
Guid? studentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = AccessibleDocuments().AsNoTracking();
|
||||
if (type.HasValue) source = source.Where(x => x.Type == type);
|
||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
|
||||
|
||||
return Ok(await source
|
||||
.OrderByDescending(x => x.IssuedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.DocumentNumber,
|
||||
x.Type,
|
||||
x.Status,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
StudentName = x.Student.Name,
|
||||
CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name,
|
||||
x.Purpose,
|
||||
x.IssuedAt,
|
||||
IssuedByName = x.Student.UserId == x.IssuedByUserId
|
||||
? "学生自助服务"
|
||||
: x.IssuedByUser!.DisplayName,
|
||||
x.InvalidatedAt,
|
||||
x.InvalidationReason,
|
||||
x.ReissuedFromDocumentId,
|
||||
DownloadCount = x.Downloads.Count,
|
||||
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("students/options")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> StudentOptions(
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = AccessibleStudents().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var value = keyword.Trim();
|
||||
source = source.Where(x =>
|
||||
x.StudentNumber.Contains(value) || x.Name.Contains(value));
|
||||
}
|
||||
|
||||
return Ok(await source.OrderBy(x => x.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.Status,
|
||||
ClassName = x.AdministrativeClass!.Name,
|
||||
MajorName = x.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Issue(
|
||||
IssueOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await AccessibleStudents().AnyAsync(x => x.Id == request.StudentId, cancellationToken))
|
||||
return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var document = await service.CreateAsync(
|
||||
request.StudentId,
|
||||
request.Type,
|
||||
request.Purpose,
|
||||
currentUserDataScope.Current.UserId,
|
||||
currentUserDataScope.Current.DisplayName ?? "教务管理员",
|
||||
VerificationBaseUrl(),
|
||||
null,
|
||||
cancellationToken);
|
||||
db.OfficialDocuments.Add(document);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return CreatedAtAction(nameof(Download), new { id = document.Id },
|
||||
new { document.Id, document.DocumentNumber });
|
||||
}
|
||||
catch (OfficialDocumentValidationException exception)
|
||||
{
|
||||
return ConflictProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> IssueMine(
|
||||
SelfIssueOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var studentId = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联有效学生档案,不能申请电子凭证。");
|
||||
|
||||
var purpose = string.IsNullOrWhiteSpace(request.Purpose)
|
||||
? null
|
||||
: request.Purpose.Trim();
|
||||
var duplicateThreshold = DateTime.UtcNow.AddMinutes(-5);
|
||||
var recentDocument = await db.OfficialDocuments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.Type == request.Type &&
|
||||
x.Status == OfficialDocumentStatus.Valid &&
|
||||
x.Purpose == purpose &&
|
||||
x.IssuedAt >= duplicateThreshold)
|
||||
.OrderByDescending(x => x.IssuedAt)
|
||||
.Select(x => new { x.Id, x.DocumentNumber })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (recentDocument is not null)
|
||||
return Ok(new
|
||||
{
|
||||
recentDocument.Id,
|
||||
recentDocument.DocumentNumber,
|
||||
Reused = true
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var document = await service.CreateAsync(
|
||||
studentId.Value,
|
||||
request.Type,
|
||||
purpose,
|
||||
userId,
|
||||
$"{options.Value.IssuingOffice}自助签发",
|
||||
VerificationBaseUrl(),
|
||||
null,
|
||||
cancellationToken);
|
||||
db.OfficialDocuments.Add(document);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return CreatedAtAction(nameof(Download), new { id = document.Id },
|
||||
new { document.Id, document.DocumentNumber, Reused = false });
|
||||
}
|
||||
catch (OfficialDocumentValidationException exception)
|
||||
{
|
||||
return ConflictProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/download")]
|
||||
[Authorize(Roles = Readers)]
|
||||
public async Task<IActionResult> Download(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await AccessibleDocuments()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (document is null) return NotFound();
|
||||
if (document.Status != OfficialDocumentStatus.Valid)
|
||||
return ConflictProblem("该凭证已经失效或被新凭证替代,不能继续下载。");
|
||||
|
||||
db.OfficialDocumentDownloads.Add(new OfficialDocumentDownload
|
||||
{
|
||||
OfficialDocumentId = document.Id,
|
||||
DownloadedByUserId = currentUserDataScope.Current.UserId,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
UserAgent = Request.Headers.UserAgent.ToString()
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return File(document.PdfContent, "application/pdf", $"{document.DocumentNumber}.pdf");
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/downloads")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> DownloadHistory(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
|
||||
return NotFound();
|
||||
|
||||
return Ok(await db.OfficialDocumentDownloads.AsNoTracking()
|
||||
.Where(x => x.OfficialDocumentId == id)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.CreatedAt,
|
||||
x.DownloadedByUserId,
|
||||
DownloadedByName = x.DownloadedByUser!.DisplayName,
|
||||
x.IpAddress,
|
||||
x.UserAgent
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/invalidate")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Invalidate(
|
||||
Guid id,
|
||||
InvalidateOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await AccessibleDocuments()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (document is null) return NotFound();
|
||||
if (document.Status != OfficialDocumentStatus.Valid)
|
||||
return ConflictProblem("该凭证当前已不是有效状态。");
|
||||
|
||||
document.Status = OfficialDocumentStatus.Invalidated;
|
||||
document.InvalidatedAt = DateTime.UtcNow;
|
||||
document.InvalidatedByUserId = currentUserDataScope.Current.UserId;
|
||||
document.InvalidationReason = request.Reason.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { document.Id, document.Status, document.InvalidatedAt });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reissue")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Reissue(
|
||||
Guid id,
|
||||
ReissueOfficialDocumentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newDocument = await db.ExecuteInRetriableTransactionAsync(async transaction =>
|
||||
{
|
||||
var original = await AccessibleDocuments()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
|
||||
?? throw new OfficialDocumentNotFoundException();
|
||||
if (await db.OfficialDocuments.AnyAsync(
|
||||
x => x.ReissuedFromDocumentId == original.Id,
|
||||
cancellationToken))
|
||||
throw new OfficialDocumentValidationException("该凭证已经重签过,不能重复重签。");
|
||||
|
||||
var replacement = await service.CreateAsync(
|
||||
original.StudentId,
|
||||
original.Type,
|
||||
request.Purpose ?? original.Purpose,
|
||||
currentUserDataScope.Current.UserId,
|
||||
currentUserDataScope.Current.DisplayName ?? "教务管理员",
|
||||
VerificationBaseUrl(),
|
||||
original.Id,
|
||||
cancellationToken);
|
||||
original.Status = OfficialDocumentStatus.Superseded;
|
||||
original.InvalidatedAt = DateTime.UtcNow;
|
||||
original.InvalidatedByUserId = currentUserDataScope.Current.UserId;
|
||||
original.InvalidationReason = request.Reason.Trim();
|
||||
db.OfficialDocuments.Add(replacement);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return replacement;
|
||||
}, cancellationToken);
|
||||
|
||||
return Ok(new { newDocument.Id, newDocument.DocumentNumber });
|
||||
}
|
||||
catch (OfficialDocumentNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (OfficialDocumentValidationException exception)
|
||||
{
|
||||
return ConflictProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("verify/{verificationCode}")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("official-verification")]
|
||||
public async Task<ActionResult> Verify(
|
||||
string verificationCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = verificationCode.Trim();
|
||||
if (normalized.Length is < 12 or > 128)
|
||||
return Ok(new { Found = false, Valid = false });
|
||||
|
||||
var hash = OfficialDocumentService.HashVerificationCode(normalized);
|
||||
var documentNumber = normalized.ToUpperInvariant();
|
||||
var document = await db.OfficialDocuments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.VerificationCodeHash == hash ||
|
||||
x.DocumentNumber == documentNumber,
|
||||
cancellationToken);
|
||||
if (document is null) return Ok(new { Found = false, Valid = false });
|
||||
|
||||
var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson);
|
||||
var replacementNumber = await db.OfficialDocuments.AsNoTracking()
|
||||
.Where(x => x.ReissuedFromDocumentId == document.Id)
|
||||
.Select(x => x.DocumentNumber)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Found = true,
|
||||
Valid = document.Status == OfficialDocumentStatus.Valid,
|
||||
document.Status,
|
||||
document.DocumentNumber,
|
||||
document.Type,
|
||||
snapshot.InstitutionName,
|
||||
StudentName = MaskName(snapshot.Student.Name),
|
||||
StudentNumber = MaskStudentNumber(snapshot.Student.StudentNumber),
|
||||
snapshot.Student.CollegeName,
|
||||
snapshot.Student.MajorName,
|
||||
document.IssuedAt,
|
||||
document.InvalidatedAt,
|
||||
ReplacementDocumentNumber = replacementNumber,
|
||||
PdfSha256 = document.PdfSha256
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<OfficialDocument> AccessibleDocuments()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var source = db.OfficialDocuments.AsQueryable();
|
||||
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||
scope.IsInRole(SystemRoles.CollegeAdmin);
|
||||
if (scope.IsInRole(SystemRoles.Student) && !isManager)
|
||||
return source.Where(x => x.Student!.UserId == scope.UserId);
|
||||
if (scope.RestrictedCollegeId.HasValue)
|
||||
return source.Where(x =>
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
|
||||
scope.RestrictedCollegeId.Value);
|
||||
return source;
|
||||
}
|
||||
|
||||
private IQueryable<Student> AccessibleStudents()
|
||||
{
|
||||
var source = db.Students.AsQueryable();
|
||||
var collegeId = currentUserDataScope.Current.RestrictedCollegeId;
|
||||
return collegeId.HasValue
|
||||
? source.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId.Value)
|
||||
: source;
|
||||
}
|
||||
|
||||
private string VerificationBaseUrl()
|
||||
{
|
||||
var configured = options.Value.PublicBaseUrl;
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.TrimEnd('/');
|
||||
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
}
|
||||
|
||||
private static string MaskName(string name) => name.Length switch
|
||||
{
|
||||
0 => "*",
|
||||
1 => name,
|
||||
2 => name[0] + "*",
|
||||
_ => name[0] + new string('*', name.Length - 2) + name[^1]
|
||||
};
|
||||
|
||||
private static string MaskStudentNumber(string number) => number.Length <= 4
|
||||
? new string('*', number.Length)
|
||||
: number[..2] + new string('*', number.Length - 4) + number[^2..];
|
||||
|
||||
private ConflictObjectResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "当前状态不允许此操作",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record IssueOfficialDocumentRequest(
|
||||
Guid StudentId,
|
||||
OfficialDocumentType Type,
|
||||
[param: StringLength(200)] string? Purpose);
|
||||
|
||||
public sealed record SelfIssueOfficialDocumentRequest(
|
||||
OfficialDocumentType Type,
|
||||
[param: StringLength(200)] string? Purpose);
|
||||
|
||||
public sealed record InvalidateOfficialDocumentRequest(
|
||||
[param: Required, StringLength(500, MinimumLength = 2)] string Reason);
|
||||
|
||||
public sealed record ReissueOfficialDocumentRequest(
|
||||
[param: Required, StringLength(500, MinimumLength = 2)] string Reason,
|
||||
[param: StringLength(200)] string? Purpose);
|
||||
|
||||
file sealed class OfficialDocumentNotFoundException : Exception;
|
||||
@@ -0,0 +1,606 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[Route("api/operations")]
|
||||
public sealed class OperationsController(
|
||||
AppDbContext db,
|
||||
OperationalHealthService healthService,
|
||||
DatabaseBackupService backupService,
|
||||
PerformanceReportService performanceReportService,
|
||||
OperationsOptions options) : ControllerBase
|
||||
{
|
||||
[HttpGet("performance")]
|
||||
public async Task<ActionResult<PerformanceReport>> GetPerformance(
|
||||
[FromQuery] string? range = "1h",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await performanceReportService.GetAsync(
|
||||
range,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return ValidationProblem(
|
||||
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<ActionResult<OperationsSummary>> GetSummary(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var since = DateTime.UtcNow.AddHours(-24);
|
||||
var health = await healthService.CheckAsync(cancellationToken);
|
||||
var backups = await backupService.ListAsync(cancellationToken);
|
||||
var auditCount = await db.AuditLogs.AsNoTracking()
|
||||
.CountAsync(x => x.CreatedAt >= since, cancellationToken);
|
||||
var serverErrorCount = await db.AuditLogs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.CreatedAt >= since && x.StatusCode >= 500,
|
||||
cancellationToken);
|
||||
var failedJobCount = await CountFailedJobsAsync(
|
||||
DateTime.UtcNow.AddDays(-7),
|
||||
cancellationToken);
|
||||
var alerts = await BuildAlertsAsync(
|
||||
health,
|
||||
backups,
|
||||
serverErrorCount,
|
||||
failedJobCount,
|
||||
cancellationToken);
|
||||
return Ok(new OperationsSummary(
|
||||
DateTime.UtcNow,
|
||||
health,
|
||||
new OperationsCounters(
|
||||
auditCount,
|
||||
serverErrorCount,
|
||||
failedJobCount,
|
||||
alerts.Count(x => x.Severity == "critical")),
|
||||
alerts,
|
||||
backups.FirstOrDefault()));
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
public async Task<ActionResult<OperationalHealthSnapshot>> GetHealth(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await healthService.CheckAsync(cancellationToken));
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? method = null,
|
||||
[FromQuery] int? statusCode = null,
|
||||
[FromQuery] string? userName = null,
|
||||
[FromQuery] string? path = null,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagingError = ValidatePaging(page, pageSize);
|
||||
if (pagingError is not null) return pagingError;
|
||||
var rangeError = ValidateRange(from, to);
|
||||
if (rangeError is not null) return rangeError;
|
||||
|
||||
var query = db.AuditLogs.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(method))
|
||||
{
|
||||
var normalizedMethod = method.Trim().ToUpperInvariant();
|
||||
query = query.Where(x => x.Method == normalizedMethod);
|
||||
}
|
||||
if (statusCode.HasValue)
|
||||
query = query.Where(x => x.StatusCode == statusCode.Value);
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
var normalizedUser = userName.Trim();
|
||||
query = query.Where(x =>
|
||||
x.UserName != null && x.UserName.Contains(normalizedUser));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
var normalizedPath = path.Trim();
|
||||
query = query.Where(x => x.Path.Contains(normalizedPath));
|
||||
}
|
||||
query = query.Where(x =>
|
||||
x.CreatedAt >= (from ?? DateTime.UtcNow.AddDays(-1)));
|
||||
if (to.HasValue)
|
||||
query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new AuditLogItem(
|
||||
x.Id,
|
||||
x.UserName,
|
||||
x.Method,
|
||||
x.Path,
|
||||
x.StatusCode,
|
||||
x.IpAddress,
|
||||
x.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<AuditLogItem>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("failed-jobs")]
|
||||
public async Task<ActionResult<PagedResult<FailedBackgroundJobItem>>>
|
||||
GetFailedJobs(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? kind = null,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagingError = ValidatePaging(page, pageSize);
|
||||
if (pagingError is not null) return pagingError;
|
||||
var rangeError = ValidateRange(from, to);
|
||||
if (rangeError is not null) return rangeError;
|
||||
|
||||
var normalizedKind = NormalizeJobKind(kind);
|
||||
if (kind is not null && normalizedKind is null)
|
||||
return ValidationProblem("后台任务类型无效。");
|
||||
|
||||
var effectiveFrom = from ?? DateTime.UtcNow.AddDays(-30);
|
||||
var take = checked(page * pageSize);
|
||||
var rows = new List<FailedBackgroundJobItem>();
|
||||
var total = 0;
|
||||
|
||||
if (normalizedKind is null or "AutomaticSchedule")
|
||||
{
|
||||
var query = db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == AutomaticScheduleJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"AutomaticSchedule",
|
||||
"自动排课",
|
||||
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "SchedulePublish")
|
||||
{
|
||||
var query = db.SchedulePublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == SchedulePublishJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"SchedulePublish",
|
||||
"课表发布",
|
||||
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "MakeupExamAuto")
|
||||
{
|
||||
var query = db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == MakeupExamAutoJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"MakeupExamAuto",
|
||||
"补考自动安排",
|
||||
x.MakeupExamPlan == null ? "补考计划" : x.MakeupExamPlan.Name,
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "ExamArrangement")
|
||||
{
|
||||
var query = db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExamArrangementJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"ExamArrangement",
|
||||
x.Kind == ExamArrangementKind.FormalExam
|
||||
? "正式考试编排"
|
||||
: "补考编排",
|
||||
x.Kind == ExamArrangementKind.FormalExam
|
||||
? "正式考试计划"
|
||||
: "补考计划",
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "ExamPublish")
|
||||
{
|
||||
var query = db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExamPublishJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"ExamPublish",
|
||||
x.Kind == ExamPublishJobKind.FormalExam
|
||||
? "正式考试发布"
|
||||
: "补考发布",
|
||||
x.Kind == ExamPublishJobKind.FormalExam
|
||||
? "正式考试计划"
|
||||
: "补考计划",
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
var pageItems = rows
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToArray();
|
||||
if (pageItems.Length > 0)
|
||||
{
|
||||
var ids = pageItems.Select(x => x.Id).ToArray();
|
||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.JobId))
|
||||
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
||||
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
||||
cancellationToken);
|
||||
pageItems = pageItems
|
||||
.Select(x => x with
|
||||
{
|
||||
ProcessingAttempts = attempts.GetValueOrDefault(x.Id)
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return Ok(new PagedResult<FailedBackgroundJobItem>(
|
||||
pageItems,
|
||||
total,
|
||||
page,
|
||||
pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("backups")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackupArtifact>>> GetBackups(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await backupService.ListAsync(cancellationToken));
|
||||
|
||||
[HttpPost("backups")]
|
||||
public async Task<ActionResult<BackupArtifact>> CreateBackup(
|
||||
CreateBackupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var artifact = await backupService.CreateAsync(
|
||||
request.Note,
|
||||
cancellationToken);
|
||||
return CreatedAtAction(
|
||||
nameof(GetBackups),
|
||||
new { id = artifact.Id },
|
||||
artifact);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
return Problem(
|
||||
title: "数据库备份失败",
|
||||
detail: SafeMessage(exception),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("backups/{backupId}/restore-drill")]
|
||||
public async Task<ActionResult<RestoreDrillResult>> RunRestoreDrill(
|
||||
string backupId,
|
||||
RestoreDrillRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.Confirmation.Equals(
|
||||
"RESTORE_DRILL",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return ValidationProblem(
|
||||
"恢复演练必须明确确认,且不会覆盖当前业务数据库。");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Ok(await backupService.RunRestoreDrillAsync(
|
||||
backupId,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "备份不存在",
|
||||
Detail = "指定备份不存在或其文件已被移除。",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<OperationalAlert>> BuildAlertsAsync(
|
||||
OperationalHealthSnapshot health,
|
||||
IReadOnlyCollection<BackupArtifact> backups,
|
||||
int serverErrorCount,
|
||||
int failedJobCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var alerts = health.Components
|
||||
.Where(x => x.Status != "healthy")
|
||||
.Select(x => new OperationalAlert(
|
||||
$"health-{x.Key}",
|
||||
x.Status == "unhealthy" ? "critical" : "warning",
|
||||
"health",
|
||||
$"{x.Label}状态异常",
|
||||
x.Detail,
|
||||
health.CheckedAt))
|
||||
.ToList();
|
||||
|
||||
if (serverErrorCount > 0)
|
||||
{
|
||||
var latest = await db.AuditLogs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.CreatedAt >= DateTime.UtcNow.AddHours(-24) &&
|
||||
x.StatusCode >= 500)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new { x.Path, x.StatusCode, x.CreatedAt })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
alerts.Add(new OperationalAlert(
|
||||
"http-5xx",
|
||||
"critical",
|
||||
"audit",
|
||||
$"过去 24 小时发生 {serverErrorCount} 次服务端错误",
|
||||
latest is null
|
||||
? "请检查服务日志定位异常。"
|
||||
: $"最近一次为 {latest.StatusCode} {latest.Path}。",
|
||||
latest?.CreatedAt ?? DateTime.UtcNow));
|
||||
}
|
||||
|
||||
if (failedJobCount > 0)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"failed-jobs",
|
||||
"critical",
|
||||
"jobs",
|
||||
$"最近 7 天有 {failedJobCount} 个后台任务失败",
|
||||
"任务已停止或达到重试上限,请在失败任务中查看错误详情。",
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
|
||||
var retryingFailures = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.State != BackgroundJobOutboxState.Completed &&
|
||||
x.LastError != null,
|
||||
cancellationToken);
|
||||
if (retryingFailures > 0)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"retrying-jobs",
|
||||
"warning",
|
||||
"jobs",
|
||||
$"{retryingFailures} 个后台任务正在错误重试",
|
||||
"任务队列仍会自动重试;若持续出现,请检查依赖服务与任务参数。",
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
|
||||
var latestBackup = backups.FirstOrDefault();
|
||||
if (latestBackup is null)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"backup-missing",
|
||||
"critical",
|
||||
"backup",
|
||||
"尚无可验证的数据库备份",
|
||||
"立即创建首个备份,并在创建后执行一次隔离恢复演练。",
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
else
|
||||
{
|
||||
var ageHours = (DateTime.UtcNow - latestBackup.CreatedAt).TotalHours;
|
||||
if (ageHours > options.BackupWarningHours)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"backup-stale",
|
||||
"warning",
|
||||
"backup",
|
||||
$"最近备份已超过 {options.BackupWarningHours} 小时",
|
||||
$"最近备份创建于 {latestBackup.CreatedAt:u}。",
|
||||
latestBackup.CreatedAt));
|
||||
}
|
||||
if (latestBackup.LastDrillSucceeded == false)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"restore-drill-failed",
|
||||
"critical",
|
||||
"backup",
|
||||
"最近一次恢复演练失败",
|
||||
latestBackup.LastDrillDetail ?? "请重新运行演练并检查数据库工具日志。",
|
||||
latestBackup.LastDrillAt ?? latestBackup.CreatedAt));
|
||||
}
|
||||
else if (!latestBackup.LastDrillAt.HasValue)
|
||||
{
|
||||
alerts.Add(new OperationalAlert(
|
||||
"restore-drill-missing",
|
||||
"warning",
|
||||
"backup",
|
||||
"最近备份尚未完成恢复演练",
|
||||
"恢复演练只写入隔离数据库,不会覆盖当前业务数据。",
|
||||
latestBackup.CreatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
return alerts
|
||||
.OrderBy(x => x.Severity == "critical" ? 0 : 1)
|
||||
.ThenByDescending(x => x.OccurredAt)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<int> CountFailedJobsAsync(
|
||||
DateTime from,
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == AutomaticScheduleJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.SchedulePublishJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == SchedulePublishJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == MakeupExamAutoJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.ExamArrangementJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == ExamArrangementJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.ExamPublishJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == ExamPublishJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken);
|
||||
|
||||
private ActionResult? ValidatePaging(int page, int pageSize)
|
||||
{
|
||||
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
|
||||
{
|
||||
return ValidationProblem(
|
||||
"页码必须在 1 到 100000 之间,每页数量必须在 1 到 100 之间。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ActionResult? ValidateRange(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (from.HasValue && to.HasValue && from.Value > to.Value)
|
||||
return ValidationProblem("开始时间不能晚于结束时间。");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeJobKind(string? kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kind)) return null;
|
||||
return kind.Trim() switch
|
||||
{
|
||||
"AutomaticSchedule" => "AutomaticSchedule",
|
||||
"SchedulePublish" => "SchedulePublish",
|
||||
"MakeupExamAuto" => "MakeupExamAuto",
|
||||
"ExamArrangement" => "ExamArrangement",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception exception)
|
||||
{
|
||||
var message = exception.GetBaseException().Message;
|
||||
return message.Length <= 500 ? message : message[..500];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AuditLogItem(
|
||||
Guid Id,
|
||||
string? UserName,
|
||||
string Method,
|
||||
string Path,
|
||||
int StatusCode,
|
||||
string? IpAddress,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record FailedBackgroundJobItem(
|
||||
Guid Id,
|
||||
string Kind,
|
||||
string KindLabel,
|
||||
string Context,
|
||||
string ErrorMessage,
|
||||
DateTime CreatedAt,
|
||||
DateTime? StartedAt,
|
||||
DateTime? CompletedAt,
|
||||
int ProcessingAttempts);
|
||||
|
||||
public sealed record OperationalAlert(
|
||||
string Id,
|
||||
string Severity,
|
||||
string Source,
|
||||
string Title,
|
||||
string Detail,
|
||||
DateTime OccurredAt);
|
||||
|
||||
public sealed record OperationsCounters(
|
||||
int AuditEvents24Hours,
|
||||
int ServerErrors24Hours,
|
||||
int FailedJobs7Days,
|
||||
int CriticalAlerts);
|
||||
|
||||
public sealed record OperationsSummary(
|
||||
DateTime GeneratedAt,
|
||||
OperationalHealthSnapshot Health,
|
||||
OperationsCounters Counters,
|
||||
IReadOnlyCollection<OperationalAlert> Alerts,
|
||||
BackupArtifact? LatestBackup);
|
||||
|
||||
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
|
||||
|
||||
public sealed record RestoreDrillRequest([Required] string Confirmation);
|
||||
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class PersonnelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
UserManager<ApplicationUser> userManager) : ControllerBase
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ReadRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -209,6 +211,9 @@ public sealed class PersonnelController(
|
||||
teacher.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(new { user.Id, UserName = userName });
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -219,6 +224,12 @@ public sealed class PersonnelController(
|
||||
[FromQuery] PersonnelQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = currentUserDataScope.Current.Roles;
|
||||
var canReadFullProfile = roles.Any(role =>
|
||||
role is SystemRoles.SuperAdmin or
|
||||
SystemRoles.AcademicAdmin or
|
||||
SystemRoles.CollegeAdmin or
|
||||
SystemRoles.Counselor);
|
||||
var page = NormalizePage(query.Page);
|
||||
var pageSize = NormalizePageSize(query.PageSize);
|
||||
var source = ApplyStudentScope(db.Students.AsNoTracking());
|
||||
@@ -263,9 +274,26 @@ public sealed class PersonnelController(
|
||||
x.EnrollmentDate,
|
||||
x.Status,
|
||||
x.DateOfBirth,
|
||||
EnglishName = canReadFullProfile ? x.EnglishName : null,
|
||||
IdCardNumber = canReadFullProfile ? x.IdCardNumber : null,
|
||||
Nationality = canReadFullProfile ? x.Nationality : null,
|
||||
Ethnicity = canReadFullProfile ? x.Ethnicity : null,
|
||||
PoliticalStatus = canReadFullProfile ? x.PoliticalStatus : null,
|
||||
NativePlace = canReadFullProfile ? x.NativePlace : null,
|
||||
HouseholdAddress = canReadFullProfile ? x.HouseholdAddress : null,
|
||||
CurrentAddress = canReadFullProfile ? x.CurrentAddress : null,
|
||||
PostalCode = canReadFullProfile ? x.PostalCode : null,
|
||||
x.Phone,
|
||||
x.Email,
|
||||
x.Notes,
|
||||
Qq = canReadFullProfile ? x.Qq : null,
|
||||
x.WeChat,
|
||||
x.EmergencyContactName,
|
||||
x.EmergencyContactRelationship,
|
||||
x.EmergencyContactPhone,
|
||||
x.SpecialTags,
|
||||
x.SpecialNeeds,
|
||||
Biography = canReadFullProfile ? x.Biography : null,
|
||||
Notes = canReadFullProfile ? x.Notes : null,
|
||||
x.UserId,
|
||||
x.CreatedAt
|
||||
})
|
||||
@@ -299,8 +327,26 @@ public sealed class PersonnelController(
|
||||
EnrollmentDate = request.EnrollmentDate,
|
||||
Status = request.Status,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
EnglishName = Normalize(request.EnglishName),
|
||||
IdCardNumber = Normalize(request.IdCardNumber),
|
||||
Nationality = Normalize(request.Nationality),
|
||||
Ethnicity = Normalize(request.Ethnicity),
|
||||
PoliticalStatus = Normalize(request.PoliticalStatus),
|
||||
NativePlace = Normalize(request.NativePlace),
|
||||
HouseholdAddress = Normalize(request.HouseholdAddress),
|
||||
CurrentAddress = Normalize(request.CurrentAddress),
|
||||
PostalCode = Normalize(request.PostalCode),
|
||||
Phone = Normalize(request.Phone),
|
||||
Email = Normalize(request.Email),
|
||||
Qq = Normalize(request.Qq),
|
||||
WeChat = Normalize(request.WeChat),
|
||||
EmergencyContactName = Normalize(request.EmergencyContactName),
|
||||
EmergencyContactRelationship = Normalize(
|
||||
request.EmergencyContactRelationship),
|
||||
EmergencyContactPhone = Normalize(request.EmergencyContactPhone),
|
||||
SpecialTags = Normalize(request.SpecialTags),
|
||||
SpecialNeeds = Normalize(request.SpecialNeeds),
|
||||
Biography = Normalize(request.Biography),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
db.Students.Add(entity);
|
||||
@@ -338,8 +384,26 @@ public sealed class PersonnelController(
|
||||
entity.EnrollmentDate = request.EnrollmentDate;
|
||||
entity.Status = request.Status;
|
||||
entity.DateOfBirth = request.DateOfBirth;
|
||||
entity.EnglishName = Normalize(request.EnglishName);
|
||||
entity.IdCardNumber = Normalize(request.IdCardNumber);
|
||||
entity.Nationality = Normalize(request.Nationality);
|
||||
entity.Ethnicity = Normalize(request.Ethnicity);
|
||||
entity.PoliticalStatus = Normalize(request.PoliticalStatus);
|
||||
entity.NativePlace = Normalize(request.NativePlace);
|
||||
entity.HouseholdAddress = Normalize(request.HouseholdAddress);
|
||||
entity.CurrentAddress = Normalize(request.CurrentAddress);
|
||||
entity.PostalCode = Normalize(request.PostalCode);
|
||||
entity.Phone = Normalize(request.Phone);
|
||||
entity.Email = Normalize(request.Email);
|
||||
entity.Qq = Normalize(request.Qq);
|
||||
entity.WeChat = Normalize(request.WeChat);
|
||||
entity.EmergencyContactName = Normalize(request.EmergencyContactName);
|
||||
entity.EmergencyContactRelationship = Normalize(
|
||||
request.EmergencyContactRelationship);
|
||||
entity.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
|
||||
entity.SpecialTags = Normalize(request.SpecialTags);
|
||||
entity.SpecialNeeds = Normalize(request.SpecialNeeds);
|
||||
entity.Biography = Normalize(request.Biography);
|
||||
entity.Notes = Normalize(request.Notes);
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
@@ -429,6 +493,12 @@ public sealed class PersonnelController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Analytics,
|
||||
cancellationToken);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -442,6 +512,12 @@ public sealed class PersonnelController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Analytics,
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -505,8 +581,25 @@ public sealed record StudentRequest(
|
||||
DateOnly EnrollmentDate,
|
||||
StudentStatus Status,
|
||||
DateOnly? DateOfBirth,
|
||||
[MaxLength(100)] string? EnglishName,
|
||||
[MaxLength(30)] string? IdCardNumber,
|
||||
[MaxLength(50)] string? Nationality,
|
||||
[MaxLength(50)] string? Ethnicity,
|
||||
[MaxLength(50)] string? PoliticalStatus,
|
||||
[MaxLength(100)] string? NativePlace,
|
||||
[MaxLength(300)] string? HouseholdAddress,
|
||||
[MaxLength(300)] string? CurrentAddress,
|
||||
[MaxLength(20)] string? PostalCode,
|
||||
[MaxLength(30)] string? Phone,
|
||||
[EmailAddress, MaxLength(100)] string? Email,
|
||||
[MaxLength(30)] string? Qq,
|
||||
[MaxLength(60)] string? WeChat,
|
||||
[MaxLength(50)] string? EmergencyContactName,
|
||||
[MaxLength(30)] string? EmergencyContactRelationship,
|
||||
[MaxLength(30)] string? EmergencyContactPhone,
|
||||
[MaxLength(300)] string? SpecialTags,
|
||||
[MaxLength(1000)] string? SpecialNeeds,
|
||||
[MaxLength(1000)] string? Biography,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record TeacherAccountActivationRequest(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -14,7 +15,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/personnel")]
|
||||
public sealed class PersonnelExcelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ReadRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -29,6 +31,9 @@ public sealed class PersonnelExcelController(
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
private const string ExportRoles =
|
||||
WriteRoles + "," + SystemRoles.Counselor;
|
||||
|
||||
private static readonly string[] TeacherHeaders =
|
||||
[
|
||||
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
||||
@@ -38,7 +43,10 @@ public sealed class PersonnelExcelController(
|
||||
private static readonly string[] StudentHeaders =
|
||||
[
|
||||
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
||||
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
|
||||
"学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
|
||||
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
|
||||
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
|
||||
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
|
||||
];
|
||||
|
||||
[HttpGet("{kind}/template")]
|
||||
@@ -64,6 +72,7 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
|
||||
[HttpGet("{kind}/export")]
|
||||
[Authorize(Roles = ExportRoles)]
|
||||
public async Task<IActionResult> Export(
|
||||
string kind,
|
||||
[FromQuery] PersonnelQuery query,
|
||||
@@ -94,7 +103,12 @@ public sealed class PersonnelExcelController(
|
||||
.Select(x => Row(
|
||||
x.StudentNumber, x.Name, GenderName(x.Gender),
|
||||
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
||||
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes))
|
||||
StudentStatusName(x.Status), x.DateOfBirth, x.EnglishName,
|
||||
x.IdCardNumber, x.Nationality, x.Ethnicity, x.PoliticalStatus,
|
||||
x.NativePlace, x.HouseholdAddress, x.CurrentAddress, x.PostalCode,
|
||||
x.Phone, x.Email, x.Qq, x.WeChat, x.EmergencyContactName,
|
||||
x.EmergencyContactRelationship, x.EmergencyContactPhone,
|
||||
x.SpecialTags, x.SpecialNeeds, x.Biography, x.Notes))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -142,6 +156,12 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Analytics,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -288,8 +308,25 @@ public sealed class PersonnelExcelController(
|
||||
entity.EnrollmentDate = enrollmentDate.Value;
|
||||
entity.Status = status.Value;
|
||||
entity.DateOfBirth = dateOfBirth;
|
||||
entity.EnglishName = Optional(row, "英文姓名");
|
||||
entity.IdCardNumber = Optional(row, "证件号码");
|
||||
entity.Nationality = Optional(row, "国籍");
|
||||
entity.Ethnicity = Optional(row, "民族");
|
||||
entity.PoliticalStatus = Optional(row, "政治面貌");
|
||||
entity.NativePlace = Optional(row, "籍贯");
|
||||
entity.HouseholdAddress = Optional(row, "户籍地址");
|
||||
entity.CurrentAddress = Optional(row, "现居住地址");
|
||||
entity.PostalCode = Optional(row, "邮政编码");
|
||||
entity.Phone = Optional(row, "联系电话");
|
||||
entity.Email = Optional(row, "电子邮箱");
|
||||
entity.Qq = Optional(row, "QQ");
|
||||
entity.WeChat = Optional(row, "微信");
|
||||
entity.EmergencyContactName = Optional(row, "紧急联系人");
|
||||
entity.EmergencyContactRelationship = Optional(row, "与本人关系");
|
||||
entity.EmergencyContactPhone = Optional(row, "紧急联系电话");
|
||||
entity.SpecialTags = Optional(row, "特殊标记");
|
||||
entity.SpecialNeeds = Optional(row, "特殊情况说明");
|
||||
entity.Biography = Optional(row, "个人简介");
|
||||
entity.Notes = Optional(row, "备注");
|
||||
}
|
||||
return new(created, updated, rows.Count);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -11,7 +12,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||
[Route("api/schedules")]
|
||||
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet("time-slots")]
|
||||
public async Task<ActionResult> GetTimeSlots(
|
||||
@@ -65,6 +66,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
IsEnabled = request.IsEnabled
|
||||
}));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -94,6 +96,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
x.SchedulingMode
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -119,6 +123,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
task.WeeklyHours,
|
||||
task.CourseTotalHours,
|
||||
task.CoursePracticeHours,
|
||||
task.SchedulingMode,
|
||||
HasCustomConstraint = constraint is not null,
|
||||
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||
|
||||
@@ -3,8 +3,10 @@ using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -15,9 +17,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Authorize(Roles = ManagementRoles)]
|
||||
[Route("api/schedules")]
|
||||
public sealed class SchedulesController(
|
||||
AppDbContext db,
|
||||
AutomaticScheduleJobQueue automaticScheduleJobQueue,
|
||||
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
|
||||
AppDbContext db) : ControllerBase
|
||||
{
|
||||
private const string ManagementRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -71,6 +71,7 @@ public sealed class SchedulesController(
|
||||
{
|
||||
entry.Id,
|
||||
entry.TeachingTaskId,
|
||||
entry.Kind,
|
||||
TaskNumber = entry.TeachingTask!.TaskNumber,
|
||||
TaskName = entry.TeachingTask.Name,
|
||||
CourseCode = entry.TeachingTask.Course!.Code,
|
||||
@@ -167,6 +168,7 @@ public sealed class SchedulesController(
|
||||
Entries = source.Entries.Select(entry => new ScheduleEntry
|
||||
{
|
||||
TeachingTaskId = entry.TeachingTaskId,
|
||||
Kind = entry.Kind,
|
||||
ClassroomId = entry.ClassroomId,
|
||||
DayOfWeek = entry.DayOfWeek,
|
||||
StartPeriod = entry.StartPeriod,
|
||||
@@ -241,6 +243,10 @@ public sealed class SchedulesController(
|
||||
CurrentStep = "等待后台检查"
|
||||
};
|
||||
db.SchedulePublishJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -263,7 +269,6 @@ public sealed class SchedulesController(
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
schedulePublishJobQueue.Enqueue(job.Id);
|
||||
return AcceptedAtAction(
|
||||
nameof(GetSchedulePublishJob),
|
||||
new { jobId = job.Id },
|
||||
@@ -321,6 +326,10 @@ public sealed class SchedulesController(
|
||||
RequestedByUserId = requestedByUserId
|
||||
};
|
||||
db.AutomaticScheduleJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -339,7 +348,6 @@ public sealed class SchedulesController(
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
automaticScheduleJobQueue.Enqueue(job.Id);
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = job.Id },
|
||||
@@ -413,6 +421,7 @@ public sealed class SchedulesController(
|
||||
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
entry.TeachingTaskId = request.TeachingTaskId;
|
||||
entry.Kind = request.Kind;
|
||||
entry.ClassroomId = request.ClassroomId;
|
||||
entry.DayOfWeek = request.DayOfWeek;
|
||||
entry.StartPeriod = request.StartPeriod;
|
||||
@@ -492,6 +501,7 @@ public sealed class SchedulesController(
|
||||
.Include(x => x.Classes)
|
||||
.ThenInclude(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Students)
|
||||
.Include(x => x.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||
if (task is null ||
|
||||
task.Status != TeachingTaskStatus.Published ||
|
||||
@@ -501,13 +511,52 @@ public sealed class SchedulesController(
|
||||
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
||||
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
||||
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
||||
var targetHours = TeachingTaskHours.TargetHours(
|
||||
task.Course!,
|
||||
request.Kind);
|
||||
if (targetHours == 0)
|
||||
return ValidationProblem(
|
||||
request.Kind == ScheduleEntryKind.Experiment
|
||||
? "该课程没有实践学时,不能安排实验课。"
|
||||
: "该课程没有理论学时,不能安排理论课。");
|
||||
var existingEntries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlanId == plan.Id &&
|
||||
x.TeachingTaskId == request.TeachingTaskId &&
|
||||
x.Kind == request.Kind &&
|
||||
x.Id != entryId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.PeriodCount
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingHours = existingEntries.Sum(x =>
|
||||
TeachingTaskHours.ScheduledHours(
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.PeriodCount));
|
||||
var proposedHours = TeachingTaskHours.ScheduledHours(
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeekPattern,
|
||||
request.PeriodCount);
|
||||
if (existingHours + proposedHours > targetHours)
|
||||
return ValidationProblem(
|
||||
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "实验" : "理论")}课" +
|
||||
$"共需 {targetHours} 学时;当前操作后将达到 " +
|
||||
$"{existingHours + proposedHours} 学时。");
|
||||
|
||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.TeachingTaskId == request.TeachingTaskId,
|
||||
cancellationToken);
|
||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
||||
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
|
||||
constraint?.RequiresClassroom != false;
|
||||
if (requiresClassroom && !request.ClassroomId.HasValue)
|
||||
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
||||
if (!requiresClassroom && request.ClassroomId.HasValue)
|
||||
@@ -531,6 +580,10 @@ public sealed class SchedulesController(
|
||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||
!IsExperimentRoom(classroom.RoomType))
|
||||
return ValidationProblem(
|
||||
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
|
||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||
classroom.Building!.CampusId != campusId)
|
||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||
@@ -584,6 +637,7 @@ public sealed class SchedulesController(
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
Kind = request.Kind,
|
||||
ClassroomId = request.ClassroomId,
|
||||
DayOfWeek = request.DayOfWeek,
|
||||
StartPeriod = request.StartPeriod,
|
||||
@@ -601,6 +655,12 @@ public sealed class SchedulesController(
|
||||
.Select(int.Parse)
|
||||
.ToHashSet();
|
||||
|
||||
private static bool IsExperimentRoom(string roomType) =>
|
||||
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<ActionResult> SaveAsync(
|
||||
Guid id,
|
||||
bool created,
|
||||
@@ -698,7 +758,8 @@ public sealed record ScheduleEntryRequest(
|
||||
[Range(1, 30)] int StartWeek,
|
||||
[Range(1, 30)] int EndWeek,
|
||||
WeekPattern WeekPattern,
|
||||
[MaxLength(500)] string? Notes);
|
||||
[MaxLength(500)] string? Notes,
|
||||
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
|
||||
|
||||
public sealed record AutomaticScheduleJobResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth/sso")]
|
||||
public sealed class SsoController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAuthSessionService authSessionService,
|
||||
IDistributedCache cache,
|
||||
IOptions<SsoOptions> options,
|
||||
ILogger<SsoController> logger) : ControllerBase
|
||||
{
|
||||
private const string BindingIntentProperty = "sso-binding-intent";
|
||||
private readonly SsoOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("settings")]
|
||||
public ActionResult<SsoSettingsResponse> Settings() =>
|
||||
new SsoSettingsResponse(
|
||||
_options.Enabled,
|
||||
_options.DisplayName,
|
||||
EffectiveCallbackUrl());
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("login")]
|
||||
public async Task<IActionResult> Login(
|
||||
[FromQuery] string? returnUrl = null,
|
||||
[FromQuery] string? bindingIntent = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||
var properties = new AuthenticationProperties();
|
||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||
{
|
||||
var targetUserId = await cache.GetStringAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
if (targetUserId is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
properties.Items[BindingIntentProperty] = bindingIntent;
|
||||
}
|
||||
var completeUrl = Url.Action(
|
||||
nameof(Complete),
|
||||
values: new { returnUrl = safeReturnUrl })!;
|
||||
properties.RedirectUri = completeUrl;
|
||||
try
|
||||
{
|
||||
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
|
||||
return new EmptyResult();
|
||||
}
|
||||
catch (OpenIdConnectProtocolException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
|
||||
EffectiveCallbackUrl());
|
||||
return RedirectToFrontendError(
|
||||
"configuration_error",
|
||||
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
|
||||
}
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpGet("complete")]
|
||||
public async Task<ActionResult> Complete(
|
||||
[FromQuery] string? returnUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var authentication = await HttpContext.AuthenticateAsync(
|
||||
SsoAuthSchemes.ExternalCookie);
|
||||
if (!authentication.Succeeded || authentication.Principal is null)
|
||||
return RedirectToFrontendError("authentication_failed");
|
||||
|
||||
var principal = authentication.Principal;
|
||||
var subject = principal.FindFirstValue("sub") ??
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return RedirectToFrontendError("missing_subject");
|
||||
|
||||
ApplicationUser? user = null;
|
||||
var bindingIntent =
|
||||
authentication.Properties is { } authenticationProperties &&
|
||||
authenticationProperties.Items.TryGetValue(
|
||||
BindingIntentProperty,
|
||||
out var storedBindingIntent)
|
||||
? storedBindingIntent
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||
{
|
||||
var targetUserId = await cache.GetStringAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
user = targetUserId is null
|
||||
? null
|
||||
: await userManager.FindByIdAsync(targetUserId);
|
||||
if (user is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled", "/account");
|
||||
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError, "/account");
|
||||
await cache.RemoveAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
user ??= await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
if (user is null && _options.LinkExistingUsersByUserName)
|
||||
{
|
||||
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
user = await userManager.FindByNameAsync(userName);
|
||||
if (user is not null)
|
||||
{
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
var bindingCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
var externalUserName =
|
||||
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
|
||||
principal.FindFirstValue("name")?.Trim() ??
|
||||
subject;
|
||||
await cache.SetStringAsync(
|
||||
BindingCacheKey(bindingCode),
|
||||
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var bindingPage = BuildFrontendUrl("/sso/bind") +
|
||||
$"?code={Uri.EscapeDataString(bindingCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
return Redirect(bindingPage);
|
||||
}
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled");
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return RedirectToFrontendError("account_update_failed");
|
||||
|
||||
var exchangeCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
await cache.SetStringAsync(
|
||||
ExchangeCacheKey(exchangeCode),
|
||||
user.Id.ToString("D"),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
},
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var callback = BuildFrontendUrl("/sso/callback") +
|
||||
$"?code={Uri.EscapeDataString(exchangeCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
return Redirect(callback);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("exchange")]
|
||||
public async Task<ActionResult<LoginResponse>> Exchange(
|
||||
SsoExchangeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var cacheKey = ExchangeCacheKey(request.Code);
|
||||
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
if (userId is null)
|
||||
return SsoProblem(
|
||||
"统一身份认证结果已失效,请重新登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
await cache.RemoveAsync(cacheKey, cancellationToken);
|
||||
var user = await userManager.FindByIdAsync(userId);
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return SsoProblem(
|
||||
"本地账号不存在、已停用或已锁定。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
var session = await authSessionService.CreateAsync(
|
||||
user,
|
||||
roles,
|
||||
request.IsNativeApp
|
||||
? AuthenticationClientType.App
|
||||
: AuthenticationClientType.Web,
|
||||
cancellationToken);
|
||||
return AuthController.CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("binding")]
|
||||
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
|
||||
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
|
||||
if (ticket is null)
|
||||
return SsoProblem(
|
||||
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("bind")]
|
||||
public async Task<ActionResult<LoginResponse>> Bind(
|
||||
SsoBindRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
|
||||
if (ticket is null)
|
||||
return SsoProblem(
|
||||
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
var user = await userManager.FindByNameAsync(request.UserName.Trim());
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return InvalidLocalCredentials();
|
||||
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
{
|
||||
await userManager.AccessFailedAsync(user);
|
||||
return InvalidLocalCredentials();
|
||||
}
|
||||
|
||||
var subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject);
|
||||
if (subjectOwner is not null && subjectOwner.Id != user.Id)
|
||||
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
|
||||
|
||||
var keycloakLogins = (await userManager.GetLoginsAsync(user))
|
||||
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
|
||||
.ToList();
|
||||
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
|
||||
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
|
||||
|
||||
if (subjectOwner is null)
|
||||
{
|
||||
var linkResult = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject,
|
||||
_options.DisplayName));
|
||||
if (!linkResult.Succeeded)
|
||||
{
|
||||
subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject);
|
||||
if (subjectOwner?.Id != user.Id)
|
||||
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
|
||||
}
|
||||
}
|
||||
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||
|
||||
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
var session = await authSessionService.CreateAsync(
|
||||
user,
|
||||
roles,
|
||||
request.IsNativeApp
|
||||
? AuthenticationClientType.App
|
||||
: AuthenticationClientType.Web,
|
||||
cancellationToken);
|
||||
return AuthController.CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("account")]
|
||||
public async Task<ActionResult<SsoAccountResponse>> Account()
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null)
|
||||
return Unauthorized();
|
||||
|
||||
var login = (await userManager.GetLoginsAsync(user))
|
||||
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||
return new SsoAccountResponse(
|
||||
_options.Enabled,
|
||||
_options.DisplayName,
|
||||
login is not null,
|
||||
EffectiveCallbackUrl());
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("prepare-binding")]
|
||||
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return Unauthorized();
|
||||
if ((await userManager.GetLoginsAsync(user))
|
||||
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
|
||||
{
|
||||
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
|
||||
}
|
||||
|
||||
var intentCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
await cache.SetStringAsync(
|
||||
BindingIntentCacheKey(intentCode),
|
||||
user.Id.ToString("D"),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
cancellationToken);
|
||||
var loginUrl = Url.Action(
|
||||
nameof(Login),
|
||||
values: new
|
||||
{
|
||||
returnUrl = "/account",
|
||||
bindingIntent = intentCode
|
||||
})!;
|
||||
return new SsoBindingStartResponse(loginUrl);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("unbind")]
|
||||
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return Unauthorized();
|
||||
if (!await userManager.HasPasswordAsync(user))
|
||||
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
{
|
||||
await userManager.AccessFailedAsync(user);
|
||||
return InvalidLocalCredentials();
|
||||
}
|
||||
|
||||
var login = (await userManager.GetLoginsAsync(user))
|
||||
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||
if (login is null)
|
||||
return NoContent();
|
||||
|
||||
var result = await userManager.RemoveLoginAsync(
|
||||
user,
|
||||
login.LoginProvider,
|
||||
login.ProviderKey);
|
||||
if (!result.Succeeded)
|
||||
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
||||
!string.IsNullOrWhiteSpace(returnUrl) &&
|
||||
returnUrl.StartsWith('/') &&
|
||||
!returnUrl.StartsWith("//", StringComparison.Ordinal)
|
||||
? returnUrl
|
||||
: "/dashboard";
|
||||
|
||||
private string BuildFrontendUrl(string path) =>
|
||||
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
|
||||
? path
|
||||
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
||||
|
||||
private RedirectResult RedirectToFrontendError(
|
||||
string error,
|
||||
string path = "/login") =>
|
||||
Redirect(BuildFrontendUrl(path) +
|
||||
$"?ssoError={Uri.EscapeDataString(error)}");
|
||||
|
||||
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
|
||||
|
||||
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
|
||||
|
||||
private static string BindingIntentCacheKey(string code) =>
|
||||
$"sso:binding-intent:{code}";
|
||||
|
||||
private async Task<ApplicationUser?> CurrentUserAsync()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return userId is null ? null : await userManager.FindByIdAsync(userId);
|
||||
}
|
||||
|
||||
private async Task<string?> LinkSsoIdentityAsync(
|
||||
ApplicationUser user,
|
||||
string subject)
|
||||
{
|
||||
var subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
if (subjectOwner is not null)
|
||||
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
|
||||
|
||||
if ((await userManager.GetLoginsAsync(user)).Any(x =>
|
||||
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
|
||||
x.ProviderKey != subject))
|
||||
{
|
||||
return "account_already_bound";
|
||||
}
|
||||
|
||||
var result = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject,
|
||||
_options.DisplayName));
|
||||
if (result.Succeeded)
|
||||
return null;
|
||||
|
||||
subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
|
||||
}
|
||||
|
||||
private string EffectiveCallbackUrl()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
|
||||
return _options.CallbackUrl;
|
||||
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
|
||||
}
|
||||
|
||||
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
|
||||
if (json is null)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private UnauthorizedObjectResult InvalidLocalCredentials() =>
|
||||
Unauthorized(new ProblemDetails
|
||||
{
|
||||
Title = "账户绑定失败",
|
||||
Detail = "教务系统账号或密码不正确,或账号已停用。",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
});
|
||||
|
||||
private ObjectResult BindingConflict(string detail) =>
|
||||
SsoProblem(detail, StatusCodes.Status409Conflict);
|
||||
|
||||
private ObjectResult SsoProblem(string detail, int status) =>
|
||||
StatusCode(status, new ProblemDetails
|
||||
{
|
||||
Title = "统一身份认证失败",
|
||||
Detail = detail,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SsoSettingsResponse(
|
||||
bool Enabled,
|
||||
string DisplayName,
|
||||
string CallbackUrl);
|
||||
|
||||
public sealed record SsoExchangeRequest(
|
||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||
bool IsNativeApp = false);
|
||||
|
||||
public sealed record SsoBindingInfoResponse(
|
||||
string ProviderDisplayName,
|
||||
string ExternalUserName);
|
||||
|
||||
public sealed record SsoBindRequest(
|
||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||
[Required, MaxLength(100)] string UserName,
|
||||
[Required, MaxLength(100)] string Password,
|
||||
bool IsNativeApp = false);
|
||||
|
||||
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
||||
|
||||
public sealed record SsoAccountResponse(
|
||||
bool Enabled,
|
||||
string ProviderDisplayName,
|
||||
bool IsBound,
|
||||
string CallbackUrl);
|
||||
|
||||
public sealed record SsoBindingStartResponse(string LoginUrl);
|
||||
|
||||
public sealed record SsoUnbindRequest(
|
||||
[Required, MaxLength(100)] string Password);
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/statistics")]
|
||||
public sealed class StatisticsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache appCache) : ControllerBase
|
||||
{
|
||||
private const string ViewerRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -22,7 +25,12 @@ public sealed class StatisticsController(
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Leader;
|
||||
|
||||
private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId;
|
||||
// HybridCache may execute its source factory without the request's ambient
|
||||
// HttpContext. Keep the authorization scope stable for the whole controller
|
||||
// invocation instead of resolving it again inside that background factory.
|
||||
private readonly CurrentUserScope currentUser = currentUserDataScope.Current;
|
||||
|
||||
private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId;
|
||||
|
||||
private Guid? ResolveCollegeId(Guid? requestedCollegeId)
|
||||
{
|
||||
@@ -34,14 +42,49 @@ public sealed class StatisticsController(
|
||||
// ── 1. Student Statistics ────────────────────────────────────────
|
||||
|
||||
[HttpGet("students/summary")]
|
||||
public async Task<ActionResult<object>> GetStudentSummary(
|
||||
public Task<ActionResult<object>> GetStudentSummary(
|
||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
int? grade, int? enrollmentYear,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetStudentSummaryCore(
|
||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
int? grade, int? enrollmentYear,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"students",
|
||||
effectiveCollegeId,
|
||||
KeyPart(majorId),
|
||||
KeyPart(classId),
|
||||
KeyPart(grade),
|
||||
KeyPart(enrollmentYear)),
|
||||
token => GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var baseQuery = db.Students.AsNoTracking()
|
||||
.Where(s => effectiveCollegeId == null ||
|
||||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
||||
@@ -112,8 +155,16 @@ public sealed class StatisticsController(
|
||||
int? grade, int? enrollmentYear,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("学生统计", new()
|
||||
{
|
||||
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
@@ -128,13 +179,41 @@ public sealed class StatisticsController(
|
||||
// ── 2. Course Statistics ─────────────────────────────────────────
|
||||
|
||||
[HttpGet("courses/summary")]
|
||||
public async Task<ActionResult<object>> GetCourseSummary(
|
||||
public Task<ActionResult<object>> GetCourseSummary(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetCourseSummaryCore(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"courses",
|
||||
effectiveCollegeId,
|
||||
KeyPart(categoryId),
|
||||
KeyPart(nature)),
|
||||
token => GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var baseQuery = db.Courses.AsNoTracking()
|
||||
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
||||
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
||||
@@ -184,8 +263,14 @@ public sealed class StatisticsController(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("课程统计", new()
|
||||
{
|
||||
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
@@ -199,13 +284,46 @@ public sealed class StatisticsController(
|
||||
// ── 3. Grade Statistics ──────────────────────────────────────────
|
||||
|
||||
[HttpGet("grades/summary")]
|
||||
public async Task<ActionResult<object>> GetGradeSummary(
|
||||
public Task<ActionResult<object>> GetGradeSummary(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, CancellationToken cancellationToken)
|
||||
Guid? courseId, CancellationToken cancellationToken) =>
|
||||
GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetGradeSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, bool useCache, CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"grades",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(majorId),
|
||||
KeyPart(classId),
|
||||
KeyPart(courseId)),
|
||||
token => GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||
@@ -302,8 +420,16 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("成绩统计", new()
|
||||
{
|
||||
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
||||
@@ -317,13 +443,41 @@ public sealed class StatisticsController(
|
||||
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
||||
|
||||
[HttpGet("pass-rates/summary")]
|
||||
public async Task<ActionResult<object>> GetPassRateSummary(
|
||||
public Task<ActionResult<object>> GetPassRateSummary(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetPassRateSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"pass-rates",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(courseId)),
|
||||
token => GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||
@@ -425,8 +579,14 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("通过率统计", new()
|
||||
{
|
||||
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
||||
@@ -439,13 +599,38 @@ public sealed class StatisticsController(
|
||||
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
||||
|
||||
[HttpGet("teacher-workload/summary")]
|
||||
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetTeacherWorkloadSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"teacher-workload",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId)),
|
||||
token => GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
||||
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
||||
@@ -524,8 +709,13 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("教师工作量统计", new()
|
||||
{
|
||||
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
|
||||
@@ -537,10 +727,39 @@ public sealed class StatisticsController(
|
||||
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
||||
|
||||
[HttpGet("classroom-utilization/summary")]
|
||||
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetClassroomUtilizationSummaryCore(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"classroom-utilization",
|
||||
RestrictedCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(buildingId),
|
||||
KeyPart(campusId)),
|
||||
token => GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Find published schedule plan
|
||||
var planQuery = db.SchedulePlans.AsNoTracking()
|
||||
.Where(p => p.Status == SchedulePlanStatus.Published);
|
||||
@@ -713,8 +932,14 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("教室利用率统计", new()
|
||||
{
|
||||
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
||||
@@ -727,6 +952,55 @@ public sealed class StatisticsController(
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ActionResult<object>> GetCachedSummaryAsync(
|
||||
string key,
|
||||
Func<CancellationToken, Task<ActionResult<object>>> factory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await appCache.GetOrCreateAsync(
|
||||
key,
|
||||
async token =>
|
||||
{
|
||||
var source = await factory(token);
|
||||
var data = source.Result is ObjectResult { Value: not null } objectResult
|
||||
? objectResult.Value
|
||||
: source.Value;
|
||||
if (data is null)
|
||||
throw new InvalidOperationException(
|
||||
"Statistics cache source did not return a successful value.");
|
||||
|
||||
return JsonSerializer.SerializeToElement(data, data.GetType());
|
||||
},
|
||||
AppCacheProfile.Analytics,
|
||||
[
|
||||
AppCacheTags.Analytics,
|
||||
AppCacheTags.BaseData,
|
||||
AppCacheTags.Timetables
|
||||
],
|
||||
cancellationToken);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string StatisticsKey(
|
||||
string area,
|
||||
Guid? effectiveCollegeId,
|
||||
params string?[] filters) =>
|
||||
AppCacheKeys.Statistics(
|
||||
area,
|
||||
currentUser.Scope.ToString(),
|
||||
effectiveCollegeId,
|
||||
filters);
|
||||
|
||||
private static string KeyPart(Guid? value) =>
|
||||
value?.ToString("N") ?? "-";
|
||||
|
||||
private static string KeyPart(int? value) =>
|
||||
value?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-";
|
||||
|
||||
private static string KeyPart<TEnum>(TEnum? value)
|
||||
where TEnum : struct, Enum =>
|
||||
value?.ToString() ?? "-";
|
||||
|
||||
private FileContentResult ExportSummary(
|
||||
string title,
|
||||
Dictionary<string, IEnumerable<object?[]>> sheets)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
[Route("api/student/profile")]
|
||||
public sealed class StudentProfileController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<StudentProfileDto>> Get(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var profile = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => new StudentProfileDto(
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.AdministrativeClass!.Name,
|
||||
x.AdministrativeClass.Major!.Name,
|
||||
x.AdministrativeClass.Major.College!.Name,
|
||||
x.EnrollmentYear,
|
||||
x.EnrollmentDate,
|
||||
x.Status,
|
||||
x.Gender,
|
||||
x.DateOfBirth,
|
||||
x.EnglishName,
|
||||
x.IdCardNumber,
|
||||
x.Nationality,
|
||||
x.Ethnicity,
|
||||
x.PoliticalStatus,
|
||||
x.NativePlace,
|
||||
x.HouseholdAddress,
|
||||
x.CurrentAddress,
|
||||
x.PostalCode,
|
||||
x.Phone,
|
||||
x.Email,
|
||||
x.Qq,
|
||||
x.WeChat,
|
||||
x.EmergencyContactName,
|
||||
x.EmergencyContactRelationship,
|
||||
x.EmergencyContactPhone,
|
||||
x.SpecialTags,
|
||||
x.SpecialNeeds,
|
||||
x.Biography))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return profile is null ? NotFound() : Ok(profile);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> Update(
|
||||
StudentProfileUpdateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var student = await db.Students.SingleOrDefaultAsync(
|
||||
x => x.UserId == userId,
|
||||
cancellationToken);
|
||||
if (student is null) return NotFound();
|
||||
|
||||
student.Gender = request.Gender;
|
||||
student.DateOfBirth = request.DateOfBirth;
|
||||
student.EnglishName = Normalize(request.EnglishName);
|
||||
student.IdCardNumber = Normalize(request.IdCardNumber);
|
||||
student.Nationality = Normalize(request.Nationality);
|
||||
student.Ethnicity = Normalize(request.Ethnicity);
|
||||
student.PoliticalStatus = Normalize(request.PoliticalStatus);
|
||||
student.NativePlace = Normalize(request.NativePlace);
|
||||
student.HouseholdAddress = Normalize(request.HouseholdAddress);
|
||||
student.CurrentAddress = Normalize(request.CurrentAddress);
|
||||
student.PostalCode = Normalize(request.PostalCode);
|
||||
student.Phone = Normalize(request.Phone);
|
||||
student.Email = Normalize(request.Email);
|
||||
student.Qq = Normalize(request.Qq);
|
||||
student.WeChat = Normalize(request.WeChat);
|
||||
student.EmergencyContactName = Normalize(request.EmergencyContactName);
|
||||
student.EmergencyContactRelationship = Normalize(
|
||||
request.EmergencyContactRelationship);
|
||||
student.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
|
||||
student.SpecialTags = Normalize(request.SpecialTags);
|
||||
student.SpecialNeeds = Normalize(request.SpecialNeeds);
|
||||
student.Biography = Normalize(request.Biography);
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Analytics, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public sealed record StudentProfileDto(
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
string ClassName,
|
||||
string MajorName,
|
||||
string CollegeName,
|
||||
int EnrollmentYear,
|
||||
DateOnly EnrollmentDate,
|
||||
StudentStatus Status,
|
||||
Gender Gender,
|
||||
DateOnly? DateOfBirth,
|
||||
string? EnglishName,
|
||||
string? IdCardNumber,
|
||||
string? Nationality,
|
||||
string? Ethnicity,
|
||||
string? PoliticalStatus,
|
||||
string? NativePlace,
|
||||
string? HouseholdAddress,
|
||||
string? CurrentAddress,
|
||||
string? PostalCode,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? Qq,
|
||||
string? WeChat,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactRelationship,
|
||||
string? EmergencyContactPhone,
|
||||
string? SpecialTags,
|
||||
string? SpecialNeeds,
|
||||
string? Biography);
|
||||
|
||||
public sealed record StudentProfileUpdateRequest(
|
||||
Gender Gender,
|
||||
DateOnly? DateOfBirth,
|
||||
[MaxLength(100)] string? EnglishName,
|
||||
[MaxLength(30)] string? IdCardNumber,
|
||||
[MaxLength(50)] string? Nationality,
|
||||
[MaxLength(50)] string? Ethnicity,
|
||||
[MaxLength(50)] string? PoliticalStatus,
|
||||
[MaxLength(100)] string? NativePlace,
|
||||
[MaxLength(300)] string? HouseholdAddress,
|
||||
[MaxLength(300)] string? CurrentAddress,
|
||||
[MaxLength(20)] string? PostalCode,
|
||||
[MaxLength(30)] string? Phone,
|
||||
[EmailAddress, MaxLength(100)] string? Email,
|
||||
[MaxLength(30)] string? Qq,
|
||||
[MaxLength(60)] string? WeChat,
|
||||
[MaxLength(50)] string? EmergencyContactName,
|
||||
[MaxLength(30)] string? EmergencyContactRelationship,
|
||||
[MaxLength(30)] string? EmergencyContactPhone,
|
||||
[MaxLength(300)] string? SpecialTags,
|
||||
[MaxLength(1000)] string? SpecialNeeds,
|
||||
[MaxLength(1000)] string? Biography);
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/system")]
|
||||
public sealed class SystemController : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("version")]
|
||||
[ProducesResponseType<SystemVersionResponse>(StatusCodes.Status200OK)]
|
||||
public SystemVersionResponse GetVersion()
|
||||
{
|
||||
var assembly = typeof(SystemController).Assembly;
|
||||
var informationalVersion = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
var version = informationalVersion?.Split('+', 2)[0]
|
||||
?? assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown";
|
||||
|
||||
return new SystemVersionResponse(version);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SystemVersionResponse(string Version);
|
||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/teaching-tasks")]
|
||||
public sealed class TeachingTasksController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ManagementRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -80,6 +82,9 @@ public sealed class TeachingTasksController(
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
CourseRegularScheduleHours =
|
||||
x.Course.TotalHours - x.Course.PracticeHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
TeacherNames = x.Teachers
|
||||
@@ -125,6 +130,10 @@ public sealed class TeachingTasksController(
|
||||
x.EndWeek,
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course!.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
CourseRegularScheduleHours =
|
||||
x.Course.TotalHours - x.Course.PracticeHours,
|
||||
TeacherNames = x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
@@ -161,6 +170,9 @@ public sealed class TeachingTasksController(
|
||||
x.WeeklyHours,
|
||||
x.SchedulingMode,
|
||||
CourseTotalHours = x.Course.TotalHours,
|
||||
CoursePracticeHours = x.Course.PracticeHours,
|
||||
CourseRegularScheduleHours =
|
||||
x.Course.TotalHours - x.Course.PracticeHours,
|
||||
x.GenerationBatchCode,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
@@ -389,6 +401,7 @@ public sealed class TeachingTasksController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return Ok(new { AffectedCount = tasks.Count });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -420,7 +433,8 @@ public sealed class TeachingTasksController(
|
||||
course,
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeeklyHours);
|
||||
request.WeeklyHours,
|
||||
TeachingTaskSchedulingMode.Standard);
|
||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
||||
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
||||
@@ -544,6 +558,9 @@ public sealed class TeachingTasksController(
|
||||
db.TeachingTasks.AddRange(created);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
BatchCode = batchCode,
|
||||
@@ -592,7 +609,8 @@ public sealed class TeachingTasksController(
|
||||
course,
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeeklyHours);
|
||||
request.WeeklyHours,
|
||||
request.SchedulingMode);
|
||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||
var collegeId = ScopedCollegeId();
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
@@ -737,6 +755,7 @@ public sealed class TeachingTasksController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -209,7 +209,9 @@ public sealed class TimetableManagementController(
|
||||
|
||||
[ApiController]
|
||||
[Route("api/timetables")]
|
||||
public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
public sealed class FreeClassroomsController(
|
||||
AppDbContext db,
|
||||
ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase
|
||||
{
|
||||
[HttpGet("free-classrooms/options")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
@@ -297,9 +299,9 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var term = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.Id == academicTermId && x.IsEnabled)
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == academicTermId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (term is null) return NotFound();
|
||||
var plan = await db.SchedulePlans.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -329,33 +331,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
if (activePeriodCount != requestedPeriods.Length)
|
||||
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
||||
|
||||
var candidates = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlanId == plan.Id &&
|
||||
x.ClassroomId.HasValue &&
|
||||
x.DayOfWeek == dayOfWeek &&
|
||||
x.StartWeek <= week &&
|
||||
x.EndWeek >= week &&
|
||||
x.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < x.StartPeriod + x.PeriodCount)
|
||||
.Select(x => new
|
||||
{
|
||||
x.ClassroomId,
|
||||
x.WeekPattern,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var occupiedIds = candidates
|
||||
.Where(x =>
|
||||
FreeClassroomRules.MatchesWeek(x.WeekPattern, week) &&
|
||||
FreeClassroomRules.PeriodsOverlap(
|
||||
startPeriod,
|
||||
periodCount,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount))
|
||||
.Select(x => x.ClassroomId.GetValueOrDefault())
|
||||
.ToHashSet();
|
||||
var reservationDate = ResolveReservationDate(term, week, dayOfWeek);
|
||||
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||
return ValidationProblem("所选周次和星期不在学期日期范围内。");
|
||||
var occupiedIds =
|
||||
await reservationAvailability.GetOccupiedClassroomIdsAsync(
|
||||
term,
|
||||
reservationDate,
|
||||
startPeriod,
|
||||
periodCount,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
var rooms = db.Classrooms.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -397,6 +383,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
private static DateOnly ResolveReservationDate(
|
||||
AcademicTerm term,
|
||||
int week,
|
||||
int dayOfWeek)
|
||||
{
|
||||
var startDay = (int)term.StartDate.DayOfWeek;
|
||||
var daysSinceMonday = (startDay + 6) % 7;
|
||||
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
|
||||
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record FreeClassroomTimeSlotDto(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
@@ -14,74 +16,25 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/timetables")]
|
||||
public sealed class TimetablesController(
|
||||
AppDbContext db,
|
||||
TimetableDataService timetableDataService) : ControllerBase
|
||||
TimetableDataService timetableDataService,
|
||||
PersonalCalendarService personalCalendarService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet("options")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
|
||||
{
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicYear,
|
||||
x.Season,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeId = x.Major.CollegeId,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
HasPublishedTimetable = defaultTermId.HasValue &&
|
||||
(db.ScheduleEntries.Any(entry =>
|
||||
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Terms = terms, Colleges = colleges, Majors = majors, Classes = classes });
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.TimetableOptions,
|
||||
LoadOptionsAsync,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[
|
||||
AppCacheTags.BaseData,
|
||||
AppCacheTags.Timetables,
|
||||
AppCacheTags.TimetableOptions
|
||||
],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("classes/{classId:guid}")]
|
||||
@@ -99,14 +52,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacherId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
@@ -118,14 +67,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacherId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
return ExcelFile(result);
|
||||
@@ -138,14 +83,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
return ExcelFile(result);
|
||||
@@ -262,6 +203,101 @@ public sealed class TimetablesController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> EnableMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
{
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("mine/calendar-subscription/rotate")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> RotateMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
user.CalendarSubscriptionCreatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(BuildCalendarSubscriptionResponse(user));
|
||||
}
|
||||
|
||||
[HttpDelete("mine/calendar-subscription")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> DisableMyCalendarSubscription(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
user.CalendarSubscriptionStamp = null;
|
||||
user.CalendarSubscriptionCreatedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("mine/calendar.ics")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> DownloadMyCalendar(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await CurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
||||
if (result is null) return CalendarProfileNotFound();
|
||||
return File(
|
||||
result.Content,
|
||||
"text/calendar; charset=utf-8",
|
||||
$"{SafeFileName(user.DisplayName)}-个人教学日历.ics");
|
||||
}
|
||||
|
||||
[HttpGet("calendar/{userId:guid}/{accessToken}.ics")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetCalendarFeed(
|
||||
Guid userId,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await db.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
if (user is null ||
|
||||
!personalCalendarService.IsAccessTokenValid(user, accessToken))
|
||||
return NotFound();
|
||||
|
||||
var result = await personalCalendarService.BuildAsync(user, cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
var etag = $"\"{Convert.ToHexString(SHA256.HashData(result.Content))}\"";
|
||||
if (Request.Headers.IfNoneMatch.ToString()
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Contains(etag, StringComparer.Ordinal))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
Response.Headers.CacheControl = "private, max-age=300";
|
||||
Response.Headers.ETag = etag;
|
||||
Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
||||
return File(result.Content, "text/calendar; charset=utf-8");
|
||||
}
|
||||
|
||||
private async Task<ActionResult> BuildTimetableAsync(
|
||||
Guid classId,
|
||||
Guid? academicTermId,
|
||||
@@ -269,18 +305,119 @@ public sealed class TimetablesController(
|
||||
CancellationToken cancellationToken,
|
||||
TimetableStudentDto? student = null)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
studentId,
|
||||
student,
|
||||
cancellationToken);
|
||||
var result = studentId.HasValue || student is not null
|
||||
? await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
studentId,
|
||||
student,
|
||||
cancellationToken)
|
||||
: await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
private Task<TimetableData?> GetPublishedTimetableAsync(
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken) =>
|
||||
cache.GetOrCreateAsync(
|
||||
AppCacheKeys.PublishedTimetable(
|
||||
resourceType.ToString().ToLowerInvariant(),
|
||||
resourceId,
|
||||
academicTermId),
|
||||
token => timetableDataService.BuildAsync(
|
||||
resourceType,
|
||||
resourceId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
token),
|
||||
AppCacheProfile.PublishedTimetable,
|
||||
[AppCacheTags.BaseData, AppCacheTags.Timetables],
|
||||
cancellationToken);
|
||||
|
||||
private async Task<TimetableOptionsResponse> LoadOptionsAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.Select(x => new TimetableTermOption(
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicYear,
|
||||
x.Season,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
|
||||
db.ExamPlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == ExamPlanStatus.Published)))
|
||||
.ToListAsync(cancellationToken);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new TimetableClassOption(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
x.MajorId,
|
||||
x.Major!.Name,
|
||||
x.Major.CollegeId,
|
||||
x.Major.College!.Name,
|
||||
defaultTermId.HasValue &&
|
||||
(db.ScheduleEntries.Any(entry =>
|
||||
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
|
||||
db.ExamSessions.Any(session =>
|
||||
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
|
||||
session.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||
session.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)))))
|
||||
.ToListAsync(cancellationToken);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new TimetableCollegeOption(x.Id, x.Code, x.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new TimetableMajorOption(
|
||||
x.Id, x.Code, x.Name, x.CollegeId))
|
||||
.ToListAsync(cancellationToken);
|
||||
return new TimetableOptionsResponse(terms, colleges, majors, classes);
|
||||
}
|
||||
|
||||
private ActionResult ExcelFile(TimetableData result)
|
||||
{
|
||||
var bytes = TimetableExcelExporter.Create(result);
|
||||
@@ -295,4 +432,89 @@ public sealed class TimetablesController(
|
||||
value = value.Replace(character, '-');
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser?> CurrentUserAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||
return null;
|
||||
return await db.Users.FirstOrDefaultAsync(
|
||||
x => x.Id == userId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private PersonalCalendarSubscriptionResponse BuildCalendarSubscriptionResponse(
|
||||
ApplicationUser user)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
return new(false, null, null, null, null);
|
||||
|
||||
var accessToken = personalCalendarService.CreateAccessToken(user);
|
||||
var feedPath =
|
||||
$"timetables/calendar/{user.Id:N}/{accessToken}.ics";
|
||||
var feedUrl =
|
||||
$"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
|
||||
$"/api/{feedPath}";
|
||||
var webcalUrl = feedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
? $"webcal://{feedUrl[8..]}"
|
||||
: feedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
? $"webcal://{feedUrl[7..]}"
|
||||
: feedUrl;
|
||||
return new(
|
||||
true,
|
||||
feedUrl,
|
||||
feedPath,
|
||||
webcalUrl,
|
||||
user.CalendarSubscriptionCreatedAt);
|
||||
}
|
||||
|
||||
private ActionResult CalendarProfileNotFound() => Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "档案未关联",
|
||||
Detail = "当前登录账号没有关联有效的教师或学生档案,请联系教务管理员。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record PersonalCalendarSubscriptionResponse(
|
||||
bool IsEnabled,
|
||||
string? FeedUrl,
|
||||
string? FeedPath,
|
||||
string? WebcalUrl,
|
||||
DateTime? CreatedAt);
|
||||
|
||||
public sealed record TimetableOptionsResponse(
|
||||
IReadOnlyList<TimetableTermOption> Terms,
|
||||
IReadOnlyList<TimetableCollegeOption> Colleges,
|
||||
IReadOnlyList<TimetableMajorOption> Majors,
|
||||
IReadOnlyList<TimetableClassOption> Classes);
|
||||
|
||||
public sealed record TimetableTermOption(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string AcademicYear,
|
||||
TermSeason Season,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
bool IsCurrent,
|
||||
bool IsArchived,
|
||||
bool HasPublishedTimetable);
|
||||
|
||||
public sealed record TimetableCollegeOption(Guid Id, string Code, string Name);
|
||||
|
||||
public sealed record TimetableMajorOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId);
|
||||
|
||||
public sealed record TimetableClassOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Grade,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
Guid CollegeId,
|
||||
string CollegeName,
|
||||
bool HasPublishedTimetable);
|
||||
|
||||
@@ -92,12 +92,12 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
var si = studentInfos.First(s => s.Id == w.StudentId);
|
||||
var r = rules.First(r => r.Type == w.Type);
|
||||
if (r.NotifyStudent && si.UserId.HasValue)
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
||||
if (r.NotifyCounselor)
|
||||
{
|
||||
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||
if (counselorId != default)
|
||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class AttendanceRecord
|
||||
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
|
||||
public DateTime? AppealReviewedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AttendanceCheckInAttempt : EntityBase
|
||||
{
|
||||
public Guid AttendanceSheetId { get; set; }
|
||||
public AttendanceSheet? AttendanceSheet { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public AttendanceCheckInMethod CheckInMethod { get; set; }
|
||||
public bool IsSuccessful { get; set; }
|
||||
public string? FailureCode { get; set; }
|
||||
public string? DeviceIdentifierHash { get; set; }
|
||||
public string? DevicePlatform { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public string? RiskFlags { get; set; }
|
||||
public decimal? Latitude { get; set; }
|
||||
public decimal? Longitude { get; set; }
|
||||
public double? AccuracyMeters { get; set; }
|
||||
public double? DistanceMeters { get; set; }
|
||||
}
|
||||
|
||||
public enum AttendanceSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ClassroomReservation : EntityBase
|
||||
{
|
||||
public Guid ApplicantUserId { get; set; }
|
||||
public ApplicationUser? ApplicantUser { get; set; }
|
||||
public required string ApplicantName { get; set; }
|
||||
public Guid ApplicantCollegeId { get; set; }
|
||||
public College? ApplicantCollege { get; set; }
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public AcademicTerm? AcademicTerm { get; set; }
|
||||
public Guid ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public DateOnly ReservationDate { get; set; }
|
||||
public int StartPeriod { get; set; }
|
||||
public int PeriodCount { get; set; }
|
||||
public int AttendeeCount { get; set; }
|
||||
public required string Purpose { get; set; }
|
||||
public required string ContactPhone { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public ClassroomReservationStatus Status { get; set; } =
|
||||
ClassroomReservationStatus.Submitted;
|
||||
public Guid? ReviewedByUserId { get; set; }
|
||||
public ApplicationUser? ReviewedByUser { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public string? ReviewComment { get; set; }
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ClassroomReservationStatus
|
||||
{
|
||||
Submitted = 1,
|
||||
Approved = 2,
|
||||
Rejected = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
@@ -10,7 +10,15 @@ public sealed class CourseAdjustment : EntityBase
|
||||
public CourseAdjustmentStatus Status { get; set; } = CourseAdjustmentStatus.Draft;
|
||||
public Guid ApplicantUserId { get; set; }
|
||||
|
||||
// For Reschedule / Makeup
|
||||
// The concrete published timetable occurrence being adjusted.
|
||||
public Guid? SourceScheduleEntryId { get; set; }
|
||||
public int? SourceWeek { get; set; }
|
||||
public DateOnly? SourceDate { get; set; }
|
||||
public int? SourceStartPeriod { get; set; }
|
||||
public int? SourcePeriodCount { get; set; }
|
||||
public Guid? SourceClassroomId { get; set; }
|
||||
|
||||
// Target occurrence for Reschedule / Makeup
|
||||
public DateOnly? TargetDate { get; set; }
|
||||
public int? DayOfWeek { get; set; }
|
||||
public int? StartPeriod { get; set; }
|
||||
|
||||
@@ -11,12 +11,21 @@ public sealed class CourseSelectionRound : EntityBase
|
||||
public DateTime EndsAt { get; set; }
|
||||
public DateTime WithdrawalEndsAt { get; set; }
|
||||
public decimal MaxCredits { get; set; } = 30;
|
||||
public int? MaxCourseCount { get; set; }
|
||||
public CourseSelectionRoundStatus Status { get; set; } =
|
||||
CourseSelectionRoundStatus.Draft;
|
||||
public string? Notes { get; set; }
|
||||
public ICollection<CourseSelectionRoundGrade> EligibleGrades { get; set; } = [];
|
||||
public ICollection<CourseSelectionOffering> Offerings { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CourseSelectionRoundGrade : EntityBase
|
||||
{
|
||||
public Guid CourseSelectionRoundId { get; set; }
|
||||
public CourseSelectionRound? CourseSelectionRound { get; set; }
|
||||
public int Grade { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CourseSelectionOffering : EntityBase
|
||||
{
|
||||
public Guid CourseSelectionRoundId { get; set; }
|
||||
@@ -38,6 +47,7 @@ public sealed class CourseEnrollment : EntityBase
|
||||
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
|
||||
public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal;
|
||||
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? WaitlistedAt { get; set; }
|
||||
public DateTime? WithdrawnAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -51,7 +61,10 @@ public enum CourseSelectionRoundStatus
|
||||
public enum CourseEnrollmentStatus
|
||||
{
|
||||
Enrolled = 1,
|
||||
Withdrawn = 2
|
||||
Withdrawn = 2,
|
||||
Waitlisted = 3,
|
||||
Cancelled = 4,
|
||||
Expired = 5
|
||||
}
|
||||
|
||||
public enum EnrollmentType
|
||||
|
||||
@@ -11,6 +11,41 @@ public sealed class ExamPlan : EntityBase
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<ExamSession> Sessions { get; set; } = [];
|
||||
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExamArrangementJob : EntityBase
|
||||
{
|
||||
public ExamArrangementKind Kind { get; set; }
|
||||
public Guid PlanId { get; set; }
|
||||
public Guid? ActivePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public string? SessionIdsJson { get; set; }
|
||||
public bool AssignClassrooms { get; set; }
|
||||
public bool AssignInvigilators { get; set; }
|
||||
public ExamArrangementJobStatus Status { get; set; } =
|
||||
ExamArrangementJobStatus.Queued;
|
||||
public int TotalSessions { get; set; }
|
||||
public int ProcessedSessions { get; set; }
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ResultMessage { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamArrangementKind
|
||||
{
|
||||
FormalExam = 1,
|
||||
MakeupExam = 2
|
||||
}
|
||||
|
||||
public enum ExamArrangementJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
public sealed class ExamSession : EntityBase
|
||||
@@ -28,9 +63,12 @@ public sealed class ExamSession : EntityBase
|
||||
public DateTime EndsAt { get; set; }
|
||||
public Guid? RequiredBuildingId { get; set; }
|
||||
public Building? RequiredBuilding { get; set; }
|
||||
public string? RequiredBuildingIds { get; set; }
|
||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||
public string? Notes { get; set; }
|
||||
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
|
||||
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExamSessionInvigilator
|
||||
@@ -41,9 +79,82 @@ public sealed class ExamSessionInvigilator
|
||||
public Teacher? Teacher { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExamRoomAssignment : EntityBase
|
||||
{
|
||||
public Guid ExamPlanId { get; set; }
|
||||
public ExamPlan? ExamPlan { get; set; }
|
||||
public Guid CourseId { get; set; }
|
||||
public Course? Course { get; set; }
|
||||
public Guid ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public DateOnly ExamDate { get; set; }
|
||||
public int StartPeriod { get; set; }
|
||||
public int PeriodCount { get; set; }
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime EndsAt { get; set; }
|
||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
|
||||
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
|
||||
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExamRoomSession
|
||||
{
|
||||
public Guid ExamRoomId { get; set; }
|
||||
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||
public Guid ExamSessionId { get; set; }
|
||||
public ExamSession? ExamSession { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExamSeatAssignment
|
||||
{
|
||||
public Guid ExamRoomId { get; set; }
|
||||
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||
public Guid ExamSessionId { get; set; }
|
||||
public ExamSession? ExamSession { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public int SeatNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExamRoomInvigilator
|
||||
{
|
||||
public Guid ExamRoomId { get; set; }
|
||||
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||
public Guid TeacherId { get; set; }
|
||||
public Teacher? Teacher { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamPlanStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Published = 2,
|
||||
Archived = 3
|
||||
}
|
||||
|
||||
public sealed class ExamPublishJob : EntityBase
|
||||
{
|
||||
public ExamPublishJobKind Kind { get; set; }
|
||||
public Guid PlanId { get; set; }
|
||||
public Guid? ActivePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamPublishJobKind
|
||||
{
|
||||
FormalExam = 1,
|
||||
MakeupExam = 2
|
||||
}
|
||||
|
||||
public enum ExamPublishJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ExamSignInExportJob : EntityBase
|
||||
{
|
||||
public Guid PlanId { get; set; }
|
||||
public ExamSignInExportJobStatus Status { get; set; } =
|
||||
ExamSignInExportJobStatus.Queued;
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
public byte[]? FileBytes { get; set; }
|
||||
public int FileSize { get; set; }
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamSignInExportJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ExperimentProject : EntityBase
|
||||
{
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
public required string Code { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Requirements { get; set; }
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public ExperimentProjectStatus Status { get; set; } =
|
||||
ExperimentProjectStatus.Draft;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
public ICollection<ExperimentSession> Sessions { get; set; } = [];
|
||||
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||
public ExperimentGradeSheet? GradeSheet { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExperimentSession : EntityBase
|
||||
{
|
||||
public Guid ExperimentProjectId { get; set; }
|
||||
public ExperimentProject? ExperimentProject { get; set; }
|
||||
public Guid ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public DateOnly SessionDate { get; set; }
|
||||
public int StartPeriod { get; set; }
|
||||
public int PeriodCount { get; set; }
|
||||
public int Capacity { get; set; }
|
||||
public int ReservedCount { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public ExperimentSessionStatus Status { get; set; } =
|
||||
ExperimentSessionStatus.Scheduled;
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentBooking : EntityBase
|
||||
{
|
||||
public Guid ExperimentProjectId { get; set; }
|
||||
public ExperimentProject? ExperimentProject { get; set; }
|
||||
public Guid ExperimentSessionId { get; set; }
|
||||
public ExperimentSession? ExperimentSession { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public ExperimentBookingStatus Status { get; set; } =
|
||||
ExperimentBookingStatus.Booked;
|
||||
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExperimentArrangementMode
|
||||
{
|
||||
Centralized = 1,
|
||||
SelfScheduled = 2
|
||||
}
|
||||
|
||||
public enum ExperimentProjectStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Published = 2,
|
||||
Closed = 3
|
||||
}
|
||||
|
||||
public enum ExperimentSessionStatus
|
||||
{
|
||||
Scheduled = 1,
|
||||
Cancelled = 2
|
||||
}
|
||||
|
||||
public enum ExperimentBookingStatus
|
||||
{
|
||||
Booked = 1,
|
||||
Cancelled = 2
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class ExperimentGradeSheet : EntityBase
|
||||
{
|
||||
public Guid ExperimentProjectId { get; set; }
|
||||
public ExperimentProject? ExperimentProject { get; set; }
|
||||
public decimal ContributionWeight { get; set; } = 1;
|
||||
public decimal PassScore { get; set; } = 60;
|
||||
public ExperimentGradeSheetStatus Status { get; set; } =
|
||||
ExperimentGradeSheetStatus.Draft;
|
||||
public string? ReviewComment { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
|
||||
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentGradeItem : EntityBase
|
||||
{
|
||||
public Guid ExperimentGradeSheetId { get; set; }
|
||||
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentGradeItemKind Kind { get; set; } =
|
||||
ExperimentGradeItemKind.Other;
|
||||
public decimal Weight { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentGradeRecord : EntityBase
|
||||
{
|
||||
public Guid ExperimentGradeSheetId { get; set; }
|
||||
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public Guid? ExperimentSessionId { get; set; }
|
||||
public ExperimentSession? ExperimentSession { get; set; }
|
||||
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
|
||||
ExperimentParticipationStatus.Pending;
|
||||
public decimal? TotalScore { get; set; }
|
||||
public bool? IsPassed { get; set; }
|
||||
public bool SafetyViolation { get; set; }
|
||||
public int AttemptNumber { get; set; } = 1;
|
||||
public string? SubmissionReference { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public bool IsLate { get; set; }
|
||||
public string? TeacherComment { get; set; }
|
||||
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ExperimentGradeItemScore
|
||||
{
|
||||
public Guid ExperimentGradeRecordId { get; set; }
|
||||
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
|
||||
public Guid ExperimentGradeItemId { get; set; }
|
||||
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
|
||||
public decimal? Score { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
public enum ExperimentGradeSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Submitted = 2,
|
||||
Approved = 3,
|
||||
Published = 4,
|
||||
Returned = 5
|
||||
}
|
||||
|
||||
public enum ExperimentGradeItemKind
|
||||
{
|
||||
Operation = 1,
|
||||
Report = 2,
|
||||
Result = 3,
|
||||
Defense = 4,
|
||||
Attendance = 5,
|
||||
Safety = 6,
|
||||
Other = 7
|
||||
}
|
||||
|
||||
public enum ExperimentParticipationStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Completed = 2,
|
||||
Absent = 3,
|
||||
Excused = 4,
|
||||
Makeup = 5,
|
||||
Exempt = 6
|
||||
}
|
||||
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
|
||||
public required string Name { get; set; }
|
||||
public decimal Weight { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public GradeItemSourceType SourceType { get; set; } =
|
||||
GradeItemSourceType.Manual;
|
||||
public DateTime? SourceSnapshotAt { get; set; }
|
||||
public ICollection<GradeItemScore> Scores { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -68,3 +71,9 @@ public enum GradeExamStatus
|
||||
Exempt = 4,
|
||||
Makeup = 5
|
||||
}
|
||||
|
||||
public enum GradeItemSourceType
|
||||
{
|
||||
Manual = 1,
|
||||
ExperimentSummary = 2
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
|
||||
public DateTime EndsAt { get; set; }
|
||||
public Guid? RequiredBuildingId { get; set; }
|
||||
public Building? RequiredBuilding { get; set; }
|
||||
public string? RequiredBuildingIds { get; set; }
|
||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||
public string? Notes { get; set; }
|
||||
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||
|
||||
@@ -7,6 +7,43 @@ public sealed class Notification : EntityBase
|
||||
public Guid UserId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public NotificationCategory Category { get; set; } = NotificationCategory.General;
|
||||
public bool IsRead { get; set; }
|
||||
public string? LinkUrl { get; set; }
|
||||
public Guid? MessageDispatchId { get; set; }
|
||||
public MessageDispatch? MessageDispatch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MessageDispatch : EntityBase
|
||||
{
|
||||
public Guid SenderUserId { get; set; }
|
||||
public required string SenderName { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public NotificationCategory Category { get; set; } = NotificationCategory.General;
|
||||
public MessageAudienceType AudienceType { get; set; }
|
||||
public Guid? AudienceId { get; set; }
|
||||
public required string AudienceName { get; set; }
|
||||
public int RecipientCount { get; set; }
|
||||
public string? LinkUrl { get; set; }
|
||||
public ICollection<Notification> Notifications { get; set; } = [];
|
||||
}
|
||||
|
||||
public enum NotificationCategory
|
||||
{
|
||||
General = 1,
|
||||
Approval = 2,
|
||||
Schedule = 3,
|
||||
Grade = 4,
|
||||
Attendance = 5,
|
||||
CourseSelection = 6,
|
||||
Warning = 7
|
||||
}
|
||||
|
||||
public enum MessageAudienceType
|
||||
{
|
||||
School = 1,
|
||||
College = 2,
|
||||
TeachingTask = 3,
|
||||
Custom = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class OfficialDocument : EntityBase
|
||||
{
|
||||
public required string DocumentNumber { get; set; }
|
||||
public required string VerificationCodeHash { get; set; }
|
||||
public OfficialDocumentType Type { get; set; }
|
||||
public OfficialDocumentStatus Status { get; set; } = OfficialDocumentStatus.Valid;
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public Guid IssuedByUserId { get; set; }
|
||||
public ApplicationUser? IssuedByUser { get; set; }
|
||||
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? Purpose { get; set; }
|
||||
public required string SnapshotJson { get; set; }
|
||||
public required byte[] PdfContent { get; set; }
|
||||
public required string PdfSha256 { get; set; }
|
||||
public DateTime? InvalidatedAt { get; set; }
|
||||
public Guid? InvalidatedByUserId { get; set; }
|
||||
public ApplicationUser? InvalidatedByUser { get; set; }
|
||||
public string? InvalidationReason { get; set; }
|
||||
public Guid? ReissuedFromDocumentId { get; set; }
|
||||
public OfficialDocument? ReissuedFromDocument { get; set; }
|
||||
public ICollection<OfficialDocumentDownload> Downloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class OfficialDocumentDownload : EntityBase
|
||||
{
|
||||
public Guid OfficialDocumentId { get; set; }
|
||||
public OfficialDocument? OfficialDocument { get; set; }
|
||||
public Guid DownloadedByUserId { get; set; }
|
||||
public ApplicationUser? DownloadedByUser { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
}
|
||||
|
||||
public enum OfficialDocumentType
|
||||
{
|
||||
Transcript = 1,
|
||||
StudentStatusCertificate = 2
|
||||
}
|
||||
|
||||
public enum OfficialDocumentStatus
|
||||
{
|
||||
Valid = 1,
|
||||
Invalidated = 2,
|
||||
Superseded = 3
|
||||
}
|
||||
@@ -30,8 +30,25 @@ public sealed class Student : EntityBase
|
||||
public DateOnly EnrollmentDate { get; set; }
|
||||
public StudentStatus Status { get; set; } = StudentStatus.Active;
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public string? EnglishName { get; set; }
|
||||
public string? IdCardNumber { get; set; }
|
||||
public string? Nationality { get; set; }
|
||||
public string? Ethnicity { get; set; }
|
||||
public string? PoliticalStatus { get; set; }
|
||||
public string? NativePlace { get; set; }
|
||||
public string? HouseholdAddress { get; set; }
|
||||
public string? CurrentAddress { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Qq { get; set; }
|
||||
public string? WeChat { get; set; }
|
||||
public string? EmergencyContactName { get; set; }
|
||||
public string? EmergencyContactRelationship { get; set; }
|
||||
public string? EmergencyContactPhone { get; set; }
|
||||
public string? SpecialTags { get; set; }
|
||||
public string? SpecialNeeds { get; set; }
|
||||
public string? Biography { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
}
|
||||
@@ -50,6 +67,16 @@ public sealed class Course : CatalogEntity
|
||||
public CourseNature Nature { get; set; }
|
||||
public AssessmentMethod AssessmentMethod { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public ICollection<CoursePrerequisite> Prerequisites { get; set; } = [];
|
||||
public ICollection<CoursePrerequisite> RequiredByCourses { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CoursePrerequisite : EntityBase
|
||||
{
|
||||
public Guid CourseId { get; set; }
|
||||
public Course? Course { get; set; }
|
||||
public Guid PrerequisiteCourseId { get; set; }
|
||||
public Course? PrerequisiteCourse { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CourseCategory : CatalogEntity
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
|
||||
public SchedulePlan? SchedulePlan { get; set; }
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
|
||||
public Guid? ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public int DayOfWeek { get; set; }
|
||||
@@ -129,3 +130,9 @@ public enum WeekPattern
|
||||
Odd = 2,
|
||||
Even = 3
|
||||
}
|
||||
|
||||
public enum ScheduleEntryKind
|
||||
{
|
||||
Lecture = 1,
|
||||
Experiment = 2
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class ApplicationUser : IdentityUser<Guid>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public string? CalendarSubscriptionStamp { get; set; }
|
||||
public DateTime? CalendarSubscriptionCreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ApplicationRole : IdentityRole<Guid>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Jiaowu.Api.Domain.Identity;
|
||||
|
||||
public enum AuthenticationClientType
|
||||
{
|
||||
Web = 0,
|
||||
App = 1
|
||||
}
|
||||
|
||||
public sealed class RefreshSession
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UserId { get; set; }
|
||||
public ApplicationUser? User { get; set; }
|
||||
public required string TokenHash { get; set; }
|
||||
public AuthenticationClientType ClientType { get; set; }
|
||||
public required string SecurityStamp { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastRefreshedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public Guid? ReplacedBySessionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.System;
|
||||
|
||||
public enum AppUpdatePlatform
|
||||
{
|
||||
Android,
|
||||
Ios
|
||||
}
|
||||
|
||||
public enum AppUpdateChannel
|
||||
{
|
||||
Production,
|
||||
Staging
|
||||
}
|
||||
|
||||
public enum AppUpdateReleaseStatus
|
||||
{
|
||||
Draft,
|
||||
Published,
|
||||
Archived
|
||||
}
|
||||
|
||||
public sealed class AppUpdateRelease : EntityBase
|
||||
{
|
||||
public AppUpdatePlatform Platform { get; set; }
|
||||
public AppUpdateChannel Channel { get; set; }
|
||||
public required string Version { get; set; }
|
||||
public required string NativeVersion { get; set; }
|
||||
public AppUpdateReleaseStatus Status { get; set; } =
|
||||
AppUpdateReleaseStatus.Draft;
|
||||
public string? ReleaseNotes { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
public required string Sha256 { get; set; }
|
||||
public required byte[] BundleContent { get; set; }
|
||||
public required string CreatedByUserName { get; set; }
|
||||
public string? PublishedByUserName { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.System;
|
||||
|
||||
public sealed class BackgroundJobOutboxMessage : EntityBase
|
||||
{
|
||||
public BackgroundJobKind JobKind { get; set; }
|
||||
public Guid JobId { get; set; }
|
||||
public BackgroundJobOutboxState State { get; set; } =
|
||||
BackgroundJobOutboxState.Pending;
|
||||
public int PublishAttempts { get; set; }
|
||||
public int ProcessingAttempts { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public Guid? ProcessingToken { get; set; }
|
||||
public DateTime? LeaseExpiresAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
|
||||
public static BackgroundJobOutboxMessage Create(
|
||||
BackgroundJobKind jobKind,
|
||||
Guid jobId) =>
|
||||
new()
|
||||
{
|
||||
JobKind = jobKind,
|
||||
JobId = jobId
|
||||
};
|
||||
}
|
||||
|
||||
public enum BackgroundJobKind
|
||||
{
|
||||
AutomaticSchedule = 1,
|
||||
SchedulePublish = 2,
|
||||
MakeupExamAuto = 3,
|
||||
ExamArrangement = 4,
|
||||
ExamSignInExport = 5,
|
||||
ExamPublish = 6
|
||||
}
|
||||
|
||||
public enum BackgroundJobOutboxState
|
||||
{
|
||||
Pending = 1,
|
||||
Publishing = 2,
|
||||
Published = 3,
|
||||
Processing = 4,
|
||||
Completed = 5
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public interface IAuthSessionService
|
||||
{
|
||||
Task<AuthSessionResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
IEnumerable<string> roles,
|
||||
AuthenticationClientType clientType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthSessionResult?> RefreshAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RevokeAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSessionResult(
|
||||
string AccessToken,
|
||||
DateTime AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTime SessionExpiresAt,
|
||||
ApplicationUser User,
|
||||
IReadOnlyList<string> Roles);
|
||||
|
||||
public sealed class AuthSessionService(
|
||||
AppDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService,
|
||||
IOptions<JwtOptions> options) : IAuthSessionService
|
||||
{
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
public async Task<AuthSessionResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
IEnumerable<string> roles,
|
||||
AuthenticationClientType clientType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var roleList = roles.ToList();
|
||||
var now = DateTime.UtcNow;
|
||||
var rawRefreshToken = CreateRefreshToken();
|
||||
var session = new RefreshSession
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = HashToken(rawRefreshToken),
|
||||
ClientType = clientType,
|
||||
SecurityStamp = user.SecurityStamp ?? string.Empty,
|
||||
CreatedAt = now,
|
||||
LastRefreshedAt = now,
|
||||
ExpiresAt = now.Add(GetIdleTimeout(clientType))
|
||||
};
|
||||
|
||||
await RemoveExpiredSessionsAsync(user.Id, now, cancellationToken);
|
||||
db.RefreshSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return BuildResult(user, roleList, rawRefreshToken, session.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResult?> RefreshAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var now = DateTime.UtcNow;
|
||||
var current = await db.RefreshSessions
|
||||
.Include(x => x.User)
|
||||
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
|
||||
var user = current?.User;
|
||||
if (current is null || user is null || current.RevokedAt.HasValue ||
|
||||
current.ExpiresAt <= now || !user.IsEnabled ||
|
||||
await userManager.IsLockedOutAsync(user) ||
|
||||
!string.Equals(current.SecurityStamp, user.SecurityStamp ?? string.Empty,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newRawToken = CreateRefreshToken();
|
||||
var replacement = new RefreshSession
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = HashToken(newRawToken),
|
||||
ClientType = current.ClientType,
|
||||
SecurityStamp = current.SecurityStamp,
|
||||
CreatedAt = now,
|
||||
LastRefreshedAt = now,
|
||||
ExpiresAt = now.Add(GetIdleTimeout(current.ClientType))
|
||||
};
|
||||
|
||||
var rotated = await db.ExecuteInRetriableTransactionAsync(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var updated = await db.RefreshSessions
|
||||
.Where(x => x.Id == current.Id && x.RevokedAt == null && x.ExpiresAt > now)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.RevokedAt, now)
|
||||
.SetProperty(x => x.ReplacedBySessionId, replacement.Id),
|
||||
cancellationToken);
|
||||
if (updated != 1)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.RefreshSessions.Add(replacement);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return true;
|
||||
},
|
||||
cancellationToken);
|
||||
if (!rotated) return null;
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
return BuildResult(user, roles.ToList(), newRawToken, replacement.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var now = DateTime.UtcNow;
|
||||
await db.RefreshSessions
|
||||
.Where(x => x.TokenHash == tokenHash && x.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(x => x.RevokedAt, now),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private AuthSessionResult BuildResult(
|
||||
ApplicationUser user,
|
||||
IReadOnlyList<string> roles,
|
||||
string refreshToken,
|
||||
DateTime sessionExpiresAt)
|
||||
{
|
||||
var accessToken = tokenService.Create(user, roles);
|
||||
return new AuthSessionResult(
|
||||
accessToken.Token,
|
||||
accessToken.ExpiresAt,
|
||||
refreshToken,
|
||||
sessionExpiresAt,
|
||||
user,
|
||||
roles);
|
||||
}
|
||||
|
||||
private TimeSpan GetIdleTimeout(AuthenticationClientType clientType) =>
|
||||
TimeSpan.FromMinutes(clientType == AuthenticationClientType.App
|
||||
? _options.AppIdleMinutes
|
||||
: _options.WebIdleMinutes);
|
||||
|
||||
private async Task RemoveExpiredSessionsAsync(
|
||||
Guid userId,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retentionCutoff = now.AddDays(-7);
|
||||
await db.RefreshSessions
|
||||
.Where(x => x.UserId == userId &&
|
||||
(x.ExpiresAt < now || x.RevokedAt < retentionCutoff))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string CreateRefreshToken() =>
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
|
||||
private static string HashToken(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -6,5 +6,7 @@ public sealed class JwtOptions
|
||||
public string Issuer { get; set; } = "Jiaowu.Api";
|
||||
public string Audience { get; set; } = "Jiaowu.Web";
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public int ExpireMinutes { get; set; } = 480;
|
||||
public int AccessTokenMinutes { get; set; } = 10;
|
||||
public int WebIdleMinutes { get; set; } = 30;
|
||||
public int AppIdleMinutes { get; set; } = 3 * 24 * 60;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public sealed class SsoOptions
|
||||
{
|
||||
public const string SectionName = "Sso";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string DisplayName { get; set; } = "学校统一身份认证";
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
public string UserNameClaim { get; set; } = "preferred_username";
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public bool LinkExistingUsersByUserName { get; set; } = true;
|
||||
public string FrontendBaseUrl { get; set; } = string.Empty;
|
||||
public string CallbackUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class SsoAuthSchemes
|
||||
{
|
||||
public const string Keycloak = "Keycloak";
|
||||
public const string ExternalCookie = "KeycloakExternal";
|
||||
public const string LoginProvider = "Keycloak";
|
||||
}
|
||||
@@ -9,14 +9,16 @@ namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
string Create(ApplicationUser user, IEnumerable<string> roles);
|
||||
AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles);
|
||||
}
|
||||
|
||||
public sealed record AccessTokenResult(string Token, DateTime ExpiresAt);
|
||||
|
||||
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||
{
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
public string Create(ApplicationUser user, IEnumerable<string> roles)
|
||||
public AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
return new AccessTokenResult(
|
||||
new JwtSecurityTokenHandler().WriteToken(token),
|
||||
expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed record BackgroundJobBacklogSnapshot(
|
||||
int Pending,
|
||||
int Publishing,
|
||||
int Published,
|
||||
int Processing,
|
||||
int ExpiredLeases,
|
||||
DateTime? OldestUnfinishedAt,
|
||||
double? OldestUnfinishedAgeSeconds);
|
||||
|
||||
public sealed class BackgroundJobMonitoringService(AppDbContext db)
|
||||
{
|
||||
public async Task<BackgroundJobBacklogSnapshot> GetSnapshotAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.State != BackgroundJobOutboxState.Completed)
|
||||
.GroupBy(x => x.State)
|
||||
.Select(group => new { State = group.Key, Count = group.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
var countByState = counts.ToDictionary(x => x.State, x => x.Count);
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredLeases = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => (x.State == BackgroundJobOutboxState.Publishing ||
|
||||
x.State == BackgroundJobOutboxState.Processing) &&
|
||||
x.LeaseExpiresAt != null &&
|
||||
x.LeaseExpiresAt < now,
|
||||
cancellationToken);
|
||||
var oldestUnfinishedAt = await db.BackgroundJobOutboxMessages
|
||||
.AsNoTracking()
|
||||
.Where(x => x.State != BackgroundJobOutboxState.Completed)
|
||||
.Select(x => (DateTime?)x.CreatedAt)
|
||||
.MinAsync(cancellationToken);
|
||||
double? ageSeconds = oldestUnfinishedAt.HasValue
|
||||
? Math.Max(0, (now - oldestUnfinishedAt.Value).TotalSeconds)
|
||||
: null;
|
||||
|
||||
return new BackgroundJobBacklogSnapshot(
|
||||
GetCount(BackgroundJobOutboxState.Pending),
|
||||
GetCount(BackgroundJobOutboxState.Publishing),
|
||||
GetCount(BackgroundJobOutboxState.Published),
|
||||
GetCount(BackgroundJobOutboxState.Processing),
|
||||
expiredLeases,
|
||||
oldestUnfinishedAt,
|
||||
ageSeconds);
|
||||
|
||||
int GetCount(BackgroundJobOutboxState state) =>
|
||||
countByState.GetValueOrDefault(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class BackgroundJobOptions
|
||||
{
|
||||
public const string SectionName = "BackgroundJobs";
|
||||
|
||||
public string Transport { get; set; } = "InMemory";
|
||||
public int PollIntervalMilliseconds { get; set; } = 500;
|
||||
public int LeaseSeconds { get; set; } = 120;
|
||||
public ushort PrefetchCount { get; set; } = 1;
|
||||
public int AutomaticScheduleConcurrency { get; set; } = 1;
|
||||
public int SchedulePublishConcurrency { get; set; } = 1;
|
||||
public int MakeupExamAutoConcurrency { get; set; } = 1;
|
||||
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||
public int ExamPublishConcurrency { get; set; } = 1;
|
||||
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||
public bool UseQuorumQueues { get; set; } = true;
|
||||
public int ProcessingAttemptLimit { get; set; } = 5;
|
||||
public int MaintenanceIntervalSeconds { get; set; } = 60;
|
||||
public int CompletedRetentionDays { get; set; } = 14;
|
||||
public int CleanupBatchSize { get; set; } = 500;
|
||||
|
||||
public bool UsesRabbitMq =>
|
||||
Transport.Equals("RabbitMq", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int ConsumerConcurrency(BackgroundJobKind kind) => kind switch
|
||||
{
|
||||
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
|
||||
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
|
||||
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class RabbitMqOptions
|
||||
{
|
||||
public const string SectionName = "RabbitMq";
|
||||
|
||||
public string HostName { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 5672;
|
||||
public string UserName { get; set; } = "guest";
|
||||
public string Password { get; set; } = "guest";
|
||||
public string VirtualHost { get; set; } = "/";
|
||||
public bool UseTls { get; set; }
|
||||
public string? TlsServerName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Diagnostics;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class BackgroundJobOutboxPublisher(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IBackgroundJobTransport transport,
|
||||
BackgroundJobOptions options,
|
||||
BackgroundJobTelemetry telemetry,
|
||||
ILogger<BackgroundJobOutboxPublisher> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await PrepareForStartupAsync(stoppingToken);
|
||||
var nextMaintenanceAt = DateTime.MinValue;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.UtcNow >= nextMaintenanceAt)
|
||||
{
|
||||
await RecoverExpiredProcessingAsync(stoppingToken);
|
||||
await CleanupCompletedAsync(stoppingToken);
|
||||
nextMaintenanceAt = DateTime.UtcNow.AddSeconds(
|
||||
options.MaintenanceIntervalSeconds);
|
||||
}
|
||||
|
||||
var claimed = await ClaimNextAsync(stoppingToken);
|
||||
if (claimed is null)
|
||||
{
|
||||
await Task.Delay(options.PollIntervalMilliseconds, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
await PublishClaimedAsync(claimed.Value.Message, claimed.Value.Token,
|
||||
stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Background job outbox publishing failed.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrepareForStartupAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
if (!transport.IsDurable)
|
||||
{
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x => x.State != BackgroundJobOutboxState.Completed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Pending)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await AddMissingOutboxMessagesAsync(db, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddMissingOutboxMessagesAsync(
|
||||
AppDbContext db,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var automaticJobs = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == AutomaticScheduleJobStatus.Queued ||
|
||||
x.Status == AutomaticScheduleJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.AutomaticSchedule &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var publishJobs = await db.SchedulePublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == SchedulePublishJobStatus.Queued ||
|
||||
x.Status == SchedulePublishJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.SchedulePublish &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var makeupJobs = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.MakeupExamAuto &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamArrangementJobStatus.Queued ||
|
||||
x.Status == ExamArrangementJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamArrangement &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamSignInExportJobStatus.Queued ||
|
||||
x.Status == ExamSignInExportJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamSignInExport &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamPublishJobStatus.Queued ||
|
||||
x.Status == ExamPublishJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamPublish &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missingKeys = automaticJobs
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
.Concat(publishJobs.Select(id =>
|
||||
(BackgroundJobKind.SchedulePublish, id)))
|
||||
.Concat(makeupJobs.Select(id =>
|
||||
(BackgroundJobKind.MakeupExamAuto, id)))
|
||||
.Concat(arrangementJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamArrangement, id)))
|
||||
.Concat(exportJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamSignInExport, id)))
|
||||
.Concat(publishJobs2.Select(id =>
|
||||
(BackgroundJobKind.ExamPublish, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(kind, jobId));
|
||||
}
|
||||
|
||||
if (!db.ChangeTracker.HasChanges())
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
var recovered = await db.BackgroundJobOutboxMessages
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
message => message.JobKind == kind &&
|
||||
message.JobId == jobId,
|
||||
cancellationToken);
|
||||
if (!recovered)
|
||||
throw;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Another application instance recovered {Count} missing outbox messages first.",
|
||||
missingKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupCompletedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cutoff = DateTime.UtcNow.AddDays(-options.CompletedRetentionDays);
|
||||
var completedIds = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.State == BackgroundJobOutboxState.Completed &&
|
||||
x.CompletedAt != null &&
|
||||
x.CompletedAt < cutoff)
|
||||
.OrderBy(x => x.CompletedAt)
|
||||
.Select(x => x.Id)
|
||||
.Take(options.CleanupBatchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (completedIds.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var id in completedIds)
|
||||
{
|
||||
db.BackgroundJobOutboxMessages.Remove(
|
||||
new BackgroundJobOutboxMessage { Id = id });
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
telemetry.RecordCleaned(completedIds.Count);
|
||||
logger.LogInformation(
|
||||
"Removed {Count} completed background job outbox messages older than {Cutoff}.",
|
||||
completedIds.Count,
|
||||
cutoff);
|
||||
}
|
||||
|
||||
private async Task RecoverExpiredProcessingAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var recoveredState = transport.IsDurable
|
||||
? BackgroundJobOutboxState.Published
|
||||
: BackgroundJobOutboxState.Pending;
|
||||
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
(x.State == BackgroundJobOutboxState.Publishing ||
|
||||
x.State == BackgroundJobOutboxState.Processing) &&
|
||||
x.LeaseExpiresAt != null &&
|
||||
x.LeaseExpiresAt < now)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, recoveredState)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<(BackgroundJobEnvelope Message, Guid Token)?> ClaimNextAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var candidateId = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.State == BackgroundJobOutboxState.Pending)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (!candidateId.HasValue)
|
||||
return null;
|
||||
|
||||
var token = Guid.NewGuid();
|
||||
var leaseExpiresAt = DateTime.UtcNow.AddSeconds(options.LeaseSeconds);
|
||||
var claimed = await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == candidateId.Value &&
|
||||
x.State == BackgroundJobOutboxState.Pending)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Publishing)
|
||||
.SetProperty(x => x.ProcessingToken, token)
|
||||
.SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt)
|
||||
.SetProperty(x => x.PublishAttempts, x => x.PublishAttempts + 1)
|
||||
.SetProperty(x => x.LastError, (string?)null),
|
||||
cancellationToken);
|
||||
if (claimed == 0)
|
||||
return null;
|
||||
|
||||
var message = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.Id == candidateId.Value)
|
||||
.Select(x => new BackgroundJobEnvelope(x.Id, x.JobKind, x.JobId))
|
||||
.SingleAsync(cancellationToken);
|
||||
return (message, token);
|
||||
}
|
||||
|
||||
private async Task PublishClaimedAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startedAt = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
await transport.PublishAsync(message, cancellationToken);
|
||||
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == message.OutboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Publishing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Published)
|
||||
.SetProperty(x => x.PublishedAt, DateTime.UtcNow)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
telemetry.RecordPublish(
|
||||
message.JobKind,
|
||||
transport.IsDurable,
|
||||
"published",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var messageText = exception.GetBaseException().Message;
|
||||
if (messageText.Length > 2000)
|
||||
messageText = messageText[..2000];
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == message.OutboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Publishing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Pending)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null)
|
||||
.SetProperty(x => x.LastError, messageText),
|
||||
cancellationToken);
|
||||
telemetry.RecordPublish(
|
||||
message.JobKind,
|
||||
transport.IsDurable,
|
||||
"failed",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
using System.Diagnostics;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed record BackgroundJobRunResult(
|
||||
BackgroundJobRunOutcome Outcome,
|
||||
TimeSpan RetryAfter)
|
||||
{
|
||||
public static BackgroundJobRunResult Completed { get; } =
|
||||
new(BackgroundJobRunOutcome.Completed, TimeSpan.Zero);
|
||||
|
||||
public static BackgroundJobRunResult Retry(TimeSpan retryAfter) =>
|
||||
new(BackgroundJobRunOutcome.Retry, retryAfter);
|
||||
}
|
||||
|
||||
public enum BackgroundJobRunOutcome
|
||||
{
|
||||
Completed,
|
||||
Retry
|
||||
}
|
||||
|
||||
public sealed class BackgroundJobRunner(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IBackgroundJobTransport transport,
|
||||
BackgroundJobOptions options,
|
||||
BackgroundJobTelemetry telemetry,
|
||||
ILogger<BackgroundJobRunner> logger)
|
||||
{
|
||||
public async Task<BackgroundJobRunResult> RunAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startedAt = Stopwatch.GetTimestamp();
|
||||
var token = Guid.NewGuid();
|
||||
var claim = await TryClaimAsync(message, token, cancellationToken);
|
||||
if (!claim.Claimed)
|
||||
{
|
||||
return claim.Completed
|
||||
? BackgroundJobRunResult.Completed
|
||||
: BackgroundJobRunResult.Retry(claim.RetryAfter);
|
||||
}
|
||||
|
||||
if (claim.AttemptsExceeded)
|
||||
{
|
||||
await MarkJobRetryLimitExceededAsync(message, cancellationToken);
|
||||
await MarkCompletedAsync(message.OutboxMessageId, token,
|
||||
cancellationToken);
|
||||
telemetry.RecordProcessing(
|
||||
message.JobKind,
|
||||
"retry-limit",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
return BackgroundJobRunResult.Completed;
|
||||
}
|
||||
|
||||
using var heartbeatCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var heartbeat = RunHeartbeatAsync(
|
||||
message.OutboxMessageId,
|
||||
token,
|
||||
heartbeatCancellation.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
switch (message.JobKind)
|
||||
{
|
||||
case BackgroundJobKind.AutomaticSchedule:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<AutomaticScheduleJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.SchedulePublish:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<SchedulePublishJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.MakeupExamAuto:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<MakeupExamAutoJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamArrangement:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamArrangementJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamSignInExport:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamSignInExportJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamPublish:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamPublishJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
}
|
||||
|
||||
await MarkCompletedAsync(message.OutboxMessageId, token,
|
||||
cancellationToken);
|
||||
telemetry.RecordProcessing(
|
||||
message.JobKind,
|
||||
"completed",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
return BackgroundJobRunResult.Completed;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Background job {JobKind}/{JobId} will be retried.",
|
||||
message.JobKind,
|
||||
message.JobId);
|
||||
await ReleaseForRetryAsync(
|
||||
message.OutboxMessageId,
|
||||
token,
|
||||
exception,
|
||||
CancellationToken.None);
|
||||
telemetry.RecordProcessing(
|
||||
message.JobKind,
|
||||
"retry",
|
||||
Stopwatch.GetElapsedTime(startedAt));
|
||||
return BackgroundJobRunResult.Retry(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await heartbeatCancellation.CancelAsync();
|
||||
try
|
||||
{
|
||||
await heartbeat;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when processing completes or the application stops.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(
|
||||
bool Claimed,
|
||||
bool Completed,
|
||||
bool AttemptsExceeded,
|
||||
TimeSpan RetryAfter)>
|
||||
TryClaimAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
var leaseExpiresAt = now.AddSeconds(options.LeaseSeconds);
|
||||
var claimed = await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == message.OutboxMessageId &&
|
||||
x.JobKind == message.JobKind &&
|
||||
x.JobId == message.JobId &&
|
||||
(x.State == BackgroundJobOutboxState.Pending ||
|
||||
x.State == BackgroundJobOutboxState.Publishing ||
|
||||
x.State == BackgroundJobOutboxState.Published ||
|
||||
(x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.LeaseExpiresAt != null &&
|
||||
x.LeaseExpiresAt < now)))
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Processing)
|
||||
.SetProperty(x => x.ProcessingToken, token)
|
||||
.SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt)
|
||||
.SetProperty(
|
||||
x => x.ProcessingAttempts,
|
||||
x => x.ProcessingAttempts + 1)
|
||||
.SetProperty(x => x.LastError, (string?)null),
|
||||
cancellationToken);
|
||||
if (claimed == 1)
|
||||
{
|
||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.Id == message.OutboxMessageId)
|
||||
.Select(x => x.ProcessingAttempts)
|
||||
.SingleAsync(cancellationToken);
|
||||
return (
|
||||
true,
|
||||
false,
|
||||
attempts > options.ProcessingAttemptLimit,
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
var current = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => x.Id == message.OutboxMessageId)
|
||||
.Select(x => new { x.State, x.LeaseExpiresAt })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (current is null || current.State == BackgroundJobOutboxState.Completed)
|
||||
return (false, true, false, TimeSpan.Zero);
|
||||
|
||||
var retryAfter = current.LeaseExpiresAt.HasValue
|
||||
? current.LeaseExpiresAt.Value - now
|
||||
: TimeSpan.FromSeconds(2);
|
||||
retryAfter = TimeSpan.FromSeconds(Math.Clamp(
|
||||
retryAfter.TotalSeconds,
|
||||
1,
|
||||
options.LeaseSeconds));
|
||||
return (false, false, false, retryAfter);
|
||||
}
|
||||
|
||||
private async Task MarkJobRetryLimitExceededAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var completedAt = DateTime.UtcNow;
|
||||
var error = $"后台任务连续处理失败超过 {options.ProcessingAttemptLimit} 次," +
|
||||
"已停止自动重试,请检查服务日志后重新创建任务。";
|
||||
|
||||
switch (message.JobKind)
|
||||
{
|
||||
case BackgroundJobKind.AutomaticSchedule:
|
||||
await db.AutomaticScheduleJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != AutomaticScheduleJobStatus.Succeeded &&
|
||||
x.Status != AutomaticScheduleJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
AutomaticScheduleJobStatus.Failed)
|
||||
.SetProperty(x => x.ActiveSchedulePlanId, (Guid?)null)
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.SchedulePublish:
|
||||
await db.SchedulePublishJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != SchedulePublishJobStatus.Succeeded &&
|
||||
x.Status != SchedulePublishJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
SchedulePublishJobStatus.Failed)
|
||||
.SetProperty(x => x.ActiveAcademicTermId, (Guid?)null)
|
||||
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.MakeupExamAuto:
|
||||
await db.MakeupExamAutoJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != MakeupExamAutoJobStatus.Succeeded &&
|
||||
x.Status != MakeupExamAutoJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
MakeupExamAutoJobStatus.Failed)
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamArrangement:
|
||||
await db.ExamArrangementJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != ExamArrangementJobStatus.Succeeded &&
|
||||
x.Status != ExamArrangementJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
ExamArrangementJobStatus.Failed)
|
||||
.SetProperty(x => x.ActivePlanId, (Guid?)null)
|
||||
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamPublish:
|
||||
await db.ExamPublishJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != ExamPublishJobStatus.Succeeded &&
|
||||
x.Status != ExamPublishJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
ExamPublishJobStatus.Failed)
|
||||
.SetProperty(x => x.ActivePlanId, (Guid?)null)
|
||||
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(message.JobKind),
|
||||
message.JobKind,
|
||||
null);
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Background job {JobKind}/{JobId} exceeded {AttemptLimit} processing attempts.",
|
||||
message.JobKind,
|
||||
message.JobId,
|
||||
options.ProcessingAttemptLimit);
|
||||
}
|
||||
|
||||
private async Task RunHeartbeatAsync(
|
||||
Guid outboxMessageId,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var intervalSeconds = Math.Max(5, options.LeaseSeconds / 3);
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken);
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == outboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(
|
||||
x => x.LeaseExpiresAt,
|
||||
DateTime.UtcNow.AddSeconds(options.LeaseSeconds)),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkCompletedAsync(
|
||||
Guid outboxMessageId,
|
||||
Guid token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == outboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, BackgroundJobOutboxState.Completed)
|
||||
.SetProperty(x => x.CompletedAt, DateTime.UtcNow)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ReleaseForRetryAsync(
|
||||
Guid outboxMessageId,
|
||||
Guid token,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var message = exception.GetBaseException().Message;
|
||||
if (message.Length > 2000)
|
||||
message = message[..2000];
|
||||
var state = transport.IsDurable
|
||||
? BackgroundJobOutboxState.Published
|
||||
: BackgroundJobOutboxState.Pending;
|
||||
await db.BackgroundJobOutboxMessages
|
||||
.Where(x =>
|
||||
x.Id == outboxMessageId &&
|
||||
x.State == BackgroundJobOutboxState.Processing &&
|
||||
x.ProcessingToken == token)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.State, state)
|
||||
.SetProperty(x => x.ProcessingToken, (Guid?)null)
|
||||
.SetProperty(x => x.LeaseExpiresAt, (DateTime?)null)
|
||||
.SetProperty(x => x.LastError, message),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class BackgroundJobTelemetry : IDisposable
|
||||
{
|
||||
public const string MeterName = "Jiaowu.BackgroundJobs";
|
||||
|
||||
private readonly Meter _meter = new(MeterName, "1.0.0");
|
||||
private readonly Counter<long> _published;
|
||||
private readonly Counter<long> _processed;
|
||||
private readonly Histogram<double> _publishDuration;
|
||||
private readonly Histogram<double> _processingDuration;
|
||||
private readonly Counter<long> _cleaned;
|
||||
|
||||
public BackgroundJobTelemetry()
|
||||
{
|
||||
_published = _meter.CreateCounter<long>(
|
||||
"jiaowu.background_jobs.published",
|
||||
unit: "{message}");
|
||||
_processed = _meter.CreateCounter<long>(
|
||||
"jiaowu.background_jobs.processed",
|
||||
unit: "{job}");
|
||||
_publishDuration = _meter.CreateHistogram<double>(
|
||||
"jiaowu.background_jobs.publish.duration",
|
||||
unit: "ms");
|
||||
_processingDuration = _meter.CreateHistogram<double>(
|
||||
"jiaowu.background_jobs.processing.duration",
|
||||
unit: "ms");
|
||||
_cleaned = _meter.CreateCounter<long>(
|
||||
"jiaowu.background_jobs.outbox.cleaned",
|
||||
unit: "{message}");
|
||||
}
|
||||
|
||||
public void RecordPublish(
|
||||
BackgroundJobKind kind,
|
||||
bool durable,
|
||||
string outcome,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "job.kind", kind.ToString() },
|
||||
{ "messaging.backend", durable ? "rabbitmq" : "memory" },
|
||||
{ "job.outcome", outcome }
|
||||
};
|
||||
_published.Add(1, tags);
|
||||
_publishDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
public void RecordProcessing(
|
||||
BackgroundJobKind kind,
|
||||
string outcome,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "job.kind", kind.ToString() },
|
||||
{ "job.outcome", outcome }
|
||||
};
|
||||
_processed.Add(1, tags);
|
||||
_processingDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
public void RecordCleaned(int count)
|
||||
{
|
||||
if (count > 0)
|
||||
_cleaned.Add(count);
|
||||
}
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed record BackgroundJobEnvelope(
|
||||
Guid OutboxMessageId,
|
||||
BackgroundJobKind JobKind,
|
||||
Guid JobId);
|
||||
|
||||
public interface IBackgroundJobTransport
|
||||
{
|
||||
bool IsDurable { get; }
|
||||
|
||||
ValueTask PublishAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> CheckHealthAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class InMemoryBackgroundJobTransport : IBackgroundJobTransport
|
||||
{
|
||||
private readonly IReadOnlyDictionary<
|
||||
BackgroundJobKind,
|
||||
Channel<BackgroundJobEnvelope>> _channels =
|
||||
Enum.GetValues<BackgroundJobKind>().ToDictionary(
|
||||
kind => kind,
|
||||
_ => Channel.CreateBounded<BackgroundJobEnvelope>(
|
||||
new BoundedChannelOptions(256)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
}));
|
||||
|
||||
public bool IsDurable => false;
|
||||
|
||||
public ValueTask PublishAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken) =>
|
||||
_channels[message.JobKind].Writer.WriteAsync(message, cancellationToken);
|
||||
|
||||
public Task<bool> CheckHealthAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public IAsyncEnumerable<BackgroundJobEnvelope> ReadAllAsync(
|
||||
BackgroundJobKind kind,
|
||||
CancellationToken cancellationToken) =>
|
||||
_channels[kind].Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Jiaowu.Api.Domain.System;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class InMemoryBackgroundJobWorker(
|
||||
InMemoryBackgroundJobTransport transport,
|
||||
BackgroundJobRunner runner,
|
||||
BackgroundJobOptions options,
|
||||
ILogger<InMemoryBackgroundJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var workers = Enum.GetValues<BackgroundJobKind>()
|
||||
.SelectMany(kind => Enumerable.Range(
|
||||
0,
|
||||
options.ConsumerConcurrency(kind))
|
||||
.Select(_ => ConsumeAsync(kind, stoppingToken)))
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(workers);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("In-memory background job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConsumeAsync(
|
||||
BackgroundJobKind kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var message in transport.ReadAllAsync(
|
||||
kind,
|
||||
cancellationToken))
|
||||
{
|
||||
var result = await runner.RunAsync(message, cancellationToken);
|
||||
if (result.Outcome != BackgroundJobRunOutcome.Retry)
|
||||
continue;
|
||||
|
||||
await Task.Delay(result.RetryAfter, cancellationToken);
|
||||
await transport.PublishAsync(message, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class RabbitMqBackgroundJobTransport(
|
||||
BackgroundJobOptions jobOptions,
|
||||
RabbitMqOptions rabbitOptions,
|
||||
ILogger<RabbitMqBackgroundJobTransport> logger)
|
||||
: IBackgroundJobTransport, IAsyncDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private IConnection? _connection;
|
||||
private IChannel? _channel;
|
||||
|
||||
public bool IsDurable => true;
|
||||
|
||||
public async ValueTask PublishAsync(
|
||||
BackgroundJobEnvelope message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(message);
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var channel = await GetChannelAsync(cancellationToken);
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
Persistent = true,
|
||||
ContentType = "application/json",
|
||||
MessageId = message.OutboxMessageId.ToString("D"),
|
||||
Type = message.JobKind.ToString(),
|
||||
AppId = "jiaowu-api"
|
||||
};
|
||||
await channel.BasicPublishAsync(
|
||||
jobOptions.Exchange,
|
||||
RabbitMqBackgroundJobTopology.RoutingKey(message.JobKind),
|
||||
mandatory: true,
|
||||
properties,
|
||||
body,
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ResetConnectionAsync();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var channel = await GetChannelAsync(cancellationToken);
|
||||
return channel.IsOpen;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "RabbitMQ health check failed.");
|
||||
await ResetConnectionAsync();
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IChannel> GetChannelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_channel is { IsOpen: true })
|
||||
return _channel;
|
||||
|
||||
await ResetConnectionAsync();
|
||||
_connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
|
||||
rabbitOptions,
|
||||
"jiaowu-background-job-publisher",
|
||||
consumerDispatchConcurrency: 1,
|
||||
cancellationToken);
|
||||
_channel = await _connection.CreateChannelAsync(
|
||||
new CreateChannelOptions(
|
||||
publisherConfirmationsEnabled: true,
|
||||
publisherConfirmationTrackingEnabled: true),
|
||||
cancellationToken);
|
||||
await RabbitMqBackgroundJobTopology.DeclareAsync(
|
||||
_channel,
|
||||
jobOptions,
|
||||
cancellationToken);
|
||||
return _channel;
|
||||
}
|
||||
|
||||
private async Task ResetConnectionAsync()
|
||||
{
|
||||
if (_channel is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _channel.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The broker may already have closed the channel.
|
||||
}
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The broker may already have closed the connection.
|
||||
}
|
||||
_connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
await ResetConnectionAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RabbitMqBackgroundJobWorker(
|
||||
BackgroundJobOptions jobOptions,
|
||||
RabbitMqOptions rabbitOptions,
|
||||
BackgroundJobRunner runner,
|
||||
ILogger<RabbitMqBackgroundJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
IConnection? connection = null;
|
||||
var channels = new List<IChannel>();
|
||||
try
|
||||
{
|
||||
var consumerCount = RabbitMqBackgroundJobTopology.JobKinds.Sum(
|
||||
jobOptions.ConsumerConcurrency);
|
||||
connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync(
|
||||
rabbitOptions,
|
||||
"jiaowu-background-job-worker",
|
||||
(ushort)consumerCount,
|
||||
stoppingToken);
|
||||
|
||||
foreach (var kind in RabbitMqBackgroundJobTopology.JobKinds)
|
||||
{
|
||||
for (var workerIndex = 0;
|
||||
workerIndex < jobOptions.ConsumerConcurrency(kind);
|
||||
workerIndex++)
|
||||
{
|
||||
var channel = await connection.CreateChannelAsync(
|
||||
new CreateChannelOptions(
|
||||
publisherConfirmationsEnabled: false,
|
||||
publisherConfirmationTrackingEnabled: false),
|
||||
stoppingToken);
|
||||
channels.Add(channel);
|
||||
await RabbitMqBackgroundJobTopology.DeclareAsync(
|
||||
channel,
|
||||
jobOptions,
|
||||
stoppingToken);
|
||||
await channel.BasicQosAsync(
|
||||
0,
|
||||
jobOptions.PrefetchCount,
|
||||
global: false,
|
||||
stoppingToken);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.ReceivedAsync += async (_, eventArgs) =>
|
||||
{
|
||||
using var deliveryCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(
|
||||
eventArgs.CancellationToken,
|
||||
stoppingToken);
|
||||
var deliveryToken = deliveryCancellation.Token;
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer
|
||||
.Deserialize<BackgroundJobEnvelope>(
|
||||
eventArgs.Body.Span);
|
||||
if (message is null || message.JobKind != kind)
|
||||
{
|
||||
await channel.BasicNackAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
requeue: false,
|
||||
deliveryToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await runner.RunAsync(
|
||||
message,
|
||||
deliveryToken);
|
||||
if (result.Outcome ==
|
||||
BackgroundJobRunOutcome.Completed)
|
||||
{
|
||||
await channel.BasicAckAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
deliveryToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(
|
||||
result.RetryAfter,
|
||||
deliveryToken);
|
||||
await channel.BasicNackAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
requeue: true,
|
||||
deliveryToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Closing the channel requeues unacknowledged deliveries.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"RabbitMQ delivery for {JobKind} failed and will be requeued.",
|
||||
kind);
|
||||
if (!channel.IsOpen)
|
||||
return;
|
||||
|
||||
await channel.BasicNackAsync(
|
||||
eventArgs.DeliveryTag,
|
||||
multiple: false,
|
||||
requeue: true,
|
||||
CancellationToken.None);
|
||||
}
|
||||
};
|
||||
await channel.BasicConsumeAsync(
|
||||
RabbitMqBackgroundJobTopology.QueueName(
|
||||
jobOptions,
|
||||
kind),
|
||||
autoAck: false,
|
||||
consumer,
|
||||
stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"{ConsumerCount} RabbitMQ background job consumers are connected to {HostName}:{Port}.",
|
||||
consumerCount,
|
||||
rabbitOptions.HostName,
|
||||
rabbitOptions.Port);
|
||||
while (connection.IsOpen && !stoppingToken.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"RabbitMQ background job consumer connection failed; retrying.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
await channel.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The connection may already have disposed its channels.
|
||||
}
|
||||
}
|
||||
|
||||
if (connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The broker may already have closed the connection.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stoppingToken.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RabbitMqBackgroundJobTopology
|
||||
{
|
||||
public static readonly BackgroundJobKind[] JobKinds =
|
||||
[
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
BackgroundJobKind.ExamSignInExport,
|
||||
BackgroundJobKind.ExamPublish
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
RabbitMqOptions options,
|
||||
string clientName,
|
||||
ushort consumerDispatchConcurrency,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = options.HostName,
|
||||
Port = options.Port,
|
||||
UserName = options.UserName,
|
||||
Password = options.Password,
|
||||
VirtualHost = options.VirtualHost,
|
||||
ClientProvidedName = clientName,
|
||||
AutomaticRecoveryEnabled = false,
|
||||
TopologyRecoveryEnabled = false,
|
||||
RequestedHeartbeat = TimeSpan.FromSeconds(30),
|
||||
ConsumerDispatchConcurrency = consumerDispatchConcurrency
|
||||
};
|
||||
if (options.UseTls)
|
||||
{
|
||||
factory.Ssl = new SslOption
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = string.IsNullOrWhiteSpace(options.TlsServerName)
|
||||
? options.HostName
|
||||
: options.TlsServerName
|
||||
};
|
||||
}
|
||||
|
||||
return await factory.CreateConnectionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task DeclareAsync(
|
||||
IChannel channel,
|
||||
BackgroundJobOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadExchange = options.Exchange + ".dead";
|
||||
await channel.ExchangeDeclareAsync(
|
||||
options.Exchange,
|
||||
ExchangeType.Direct,
|
||||
durable: true,
|
||||
autoDelete: false,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.ExchangeDeclareAsync(
|
||||
deadExchange,
|
||||
ExchangeType.Direct,
|
||||
durable: true,
|
||||
autoDelete: false,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
foreach (var kind in JobKinds)
|
||||
{
|
||||
var routingKey = RoutingKey(kind);
|
||||
var queueName = QueueName(options, kind);
|
||||
var deadQueueName = queueName + ".dead";
|
||||
var queueArguments = QueueArguments(options);
|
||||
queueArguments["x-dead-letter-exchange"] = deadExchange;
|
||||
queueArguments["x-dead-letter-routing-key"] = routingKey;
|
||||
await channel.QueueDeclareAsync(
|
||||
queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: queueArguments,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(
|
||||
queueName,
|
||||
options.Exchange,
|
||||
routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await channel.QueueDeclareAsync(
|
||||
deadQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: QueueArguments(options),
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(
|
||||
deadQueueName,
|
||||
deadExchange,
|
||||
routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public static string RoutingKey(BackgroundJobKind kind) => kind switch
|
||||
{
|
||||
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
|
||||
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
||||
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||
BackgroundJobKind.ExamPublish => "exam.publish",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
|
||||
public static string QueueName(
|
||||
BackgroundJobOptions options,
|
||||
BackgroundJobKind kind) =>
|
||||
$"{options.QueuePrefix}.{RoutingKey(kind)}";
|
||||
|
||||
private static Dictionary<string, object?> QueueArguments(
|
||||
BackgroundJobOptions options)
|
||||
{
|
||||
var arguments = new Dictionary<string, object?>();
|
||||
if (options.UseQuorumQueues)
|
||||
arguments["x-queue-type"] = "quorum";
|
||||
return arguments;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public enum AppCacheProfile
|
||||
{
|
||||
ReferenceData,
|
||||
PublishedTimetable,
|
||||
Analytics
|
||||
}
|
||||
|
||||
public interface IAppCache
|
||||
{
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class HybridAppCache(
|
||||
HybridCache cache,
|
||||
AppCacheOptions options,
|
||||
IHostEnvironment environment,
|
||||
ILogger<HybridAppCache> logger) : IAppCache
|
||||
{
|
||||
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return await factory(cancellationToken);
|
||||
|
||||
var sourceCompleted = false;
|
||||
T? sourceValue = default;
|
||||
try
|
||||
{
|
||||
return await cache.GetOrCreateAsync(
|
||||
$"{prefix}:{key}",
|
||||
async token =>
|
||||
{
|
||||
sourceValue = await factory(token);
|
||||
sourceCompleted = true;
|
||||
return sourceValue;
|
||||
},
|
||||
GetEntryOptions(profile),
|
||||
tags.Select(tag => $"{prefix}:tag:{tag}"),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Redis cache operation failed for {CacheKey}; using the source directly.",
|
||||
key);
|
||||
if (sourceCompleted)
|
||||
return sourceValue!;
|
||||
return await factory(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await cache.RemoveByTagAsync(
|
||||
$"{prefix}:tag:{tag}",
|
||||
cancellationToken);
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.",
|
||||
tag);
|
||||
}
|
||||
}
|
||||
|
||||
private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) =>
|
||||
profile switch
|
||||
{
|
||||
AppCacheProfile.ReferenceData => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds)
|
||||
},
|
||||
AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
|
||||
},
|
||||
AppCacheProfile.Analytics => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
|
||||
};
|
||||
|
||||
private static string BuildPrefix(string configuredPrefix, string environmentName)
|
||||
{
|
||||
var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix)
|
||||
? "jiaowu:v1"
|
||||
: configuredPrefix.Trim().Trim(':');
|
||||
var environmentValue = string.Concat(
|
||||
environmentName.Trim().ToLowerInvariant()
|
||||
.Select(character => char.IsLetterOrDigit(character) ? character : '-'));
|
||||
return $"{prefixValue}:{environmentValue}";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoOpAppCache : IAppCache
|
||||
{
|
||||
public static NoOpAppCache Instance { get; } = new();
|
||||
|
||||
private NoOpAppCache()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken) =>
|
||||
factory(cancellationToken);
|
||||
|
||||
public ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public static class AppCacheKeys
|
||||
{
|
||||
public const string ActivationOptions = "auth:activation-options";
|
||||
public const string Dashboard = "dashboard:summary";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
public static string BaseData(string kind) => $"base-data:{kind}";
|
||||
|
||||
public static string PublishedTimetable(
|
||||
string resourceType,
|
||||
Guid resourceId,
|
||||
Guid? academicTermId) =>
|
||||
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
|
||||
$"{resourceType}:{resourceId:N}";
|
||||
|
||||
public static string Statistics(
|
||||
string area,
|
||||
string dataScope,
|
||||
Guid? effectiveCollegeId,
|
||||
params string?[] filters)
|
||||
{
|
||||
static string Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? "-"
|
||||
: value.Trim().ToLowerInvariant();
|
||||
|
||||
var filterPart = filters.Length == 0
|
||||
? "all"
|
||||
: string.Join(':', filters.Select(Normalize));
|
||||
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class AppCacheTags
|
||||
{
|
||||
public const string BaseData = "base-data";
|
||||
public const string Analytics = "analytics";
|
||||
public const string Timetables = "timetables";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
public static string Timetable(Guid academicTermId) =>
|
||||
$"timetable:term:{academicTermId:N}";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public sealed class AppCacheOptions
|
||||
{
|
||||
public const string SectionName = "Cache";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string KeyPrefix { get; set; } = "jiaowu:v1";
|
||||
public int ReferenceExpirationMinutes { get; set; } = 30;
|
||||
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
|
||||
public int TimetableExpirationMinutes { get; set; } = 10;
|
||||
public int TimetableLocalExpirationSeconds { get; set; } = 30;
|
||||
public int AnalyticsExpirationMinutes { get; set; } = 3;
|
||||
public int AnalyticsLocalExpirationSeconds { get; set; } = 30;
|
||||
public int MaximumPayloadKilobytes { get; set; } = 2048;
|
||||
}
|
||||
@@ -14,9 +14,23 @@ public static class CourseSelectionRules
|
||||
round.Status == CourseSelectionRoundStatus.Open &&
|
||||
nowUtc <= round.WithdrawalEndsAt;
|
||||
|
||||
public static bool CanPromoteWaitlist(CourseSelectionRound round, DateTime nowUtc) =>
|
||||
round.Status == CourseSelectionRoundStatus.Open &&
|
||||
nowUtc <= round.WithdrawalEndsAt;
|
||||
|
||||
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||
nature == CourseNature.GeneralRequired;
|
||||
|
||||
public static bool IsGradeEligible(
|
||||
IEnumerable<int> eligibleGrades,
|
||||
int studentGrade) =>
|
||||
!eligibleGrades.Any() || eligibleGrades.Contains(studentGrade);
|
||||
|
||||
public static bool HasReachedCourseLimit(
|
||||
int? maxCourseCount,
|
||||
int selectedCourseCount) =>
|
||||
maxCourseCount.HasValue && selectedCourseCount >= maxCourseCount.Value;
|
||||
|
||||
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
||||
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
||||
|
||||
@@ -68,4 +82,7 @@ public static class CourseSelectionRules
|
||||
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
||||
public static int RetakeCapacity(int originalCapacity) =>
|
||||
(int)Math.Ceiling(originalCapacity * 1.15);
|
||||
|
||||
public static int EffectiveCapacity(int originalCapacity, bool isRetake) =>
|
||||
isRetake ? RetakeCapacity(originalCapacity) : originalCapacity;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamArrangementJobProcessor(
|
||||
AppDbContext db,
|
||||
ExamArrangementService examArrangementService,
|
||||
MakeupExamArrangementService makeupExamArrangementService,
|
||||
IAppCache cache,
|
||||
ILogger<ExamArrangementJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.ExamArrangementJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is ExamArrangementJobStatus.Succeeded
|
||||
or ExamArrangementJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamArrangementJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.ResultMessage = null;
|
||||
job.CurrentStep = job.Kind == ExamArrangementKind.FormalExam
|
||||
? "正在编排正式考试"
|
||||
: "正在编排补考";
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
var sessionIds = DeserializeSessionIds(job.SessionIdsJson);
|
||||
var result = job.Kind switch
|
||||
{
|
||||
ExamArrangementKind.FormalExam =>
|
||||
await examArrangementService.ArrangeAsync(
|
||||
job.PlanId,
|
||||
sessionIds,
|
||||
job.AssignClassrooms,
|
||||
job.AssignInvigilators,
|
||||
stoppingToken),
|
||||
ExamArrangementKind.MakeupExam =>
|
||||
await makeupExamArrangementService.ArrangeAsync(
|
||||
job.PlanId,
|
||||
sessionIds,
|
||||
job.AssignClassrooms,
|
||||
job.AssignInvigilators,
|
||||
stoppingToken),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"不支持的考试编排类型:{job.Kind}。")
|
||||
};
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await MarkFailedAsync(jobId, result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamArrangementJobStatus.Succeeded;
|
||||
job.ProcessedSessions = job.TotalSessions;
|
||||
job.CurrentStep = "编排完成";
|
||||
job.ResultMessage = result.Message;
|
||||
job.ActivePlanId = null;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
if (job.Kind == ExamArrangementKind.FormalExam)
|
||||
{
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
stoppingToken);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Exam arrangement job {JobId} for {Kind}/{PlanId} completed.",
|
||||
job.Id,
|
||||
job.Kind,
|
||||
job.PlanId);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Exam arrangement job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Exam arrangement job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<Guid>? DeserializeSessionIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
var sessionIds = JsonSerializer.Deserialize<Guid[]>(json);
|
||||
return sessionIds is { Length: > 0 } ? sessionIds : null;
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.ExamArrangementJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
job.Status = ExamArrangementJobStatus.Failed;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "编排失败";
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,44 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamArrangementService(AppDbContext db)
|
||||
{
|
||||
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
|
||||
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
|
||||
private sealed record RoomGroupKey(
|
||||
Guid CourseId,
|
||||
DateOnly ExamDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
string RequiredBuildingIdsKey);
|
||||
|
||||
private sealed record RoomOccupancy(
|
||||
Guid ClassroomId,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt);
|
||||
|
||||
private sealed record InvigilatorOccupancy(
|
||||
Guid TeacherId,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt);
|
||||
|
||||
private sealed record CandidateSeat(
|
||||
Guid ExamSessionId,
|
||||
Guid StudentId,
|
||||
string StudentNumber);
|
||||
|
||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||
Guid planId,
|
||||
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||
bool assignClassrooms,
|
||||
bool assignInvigilators,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!assignClassrooms && !assignInvigilators)
|
||||
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||
|
||||
var plan = await db.ExamPlans
|
||||
.Include(x => x.AcademicTerm)
|
||||
.Include(x => x.Sessions)
|
||||
@@ -20,6 +46,16 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Include(x => x.Rooms)
|
||||
.ThenInclude(x => x.SessionLinks)
|
||||
.Include(x => x.Rooms)
|
||||
.ThenInclude(x => x.Seats)
|
||||
.Include(x => x.Rooms)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||
|
||||
if (plan is null)
|
||||
@@ -27,201 +63,558 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
||||
|
||||
var sessions = plan.Sessions.ToList();
|
||||
if (sessions.Count == 0)
|
||||
return ExamArrangementResult.Fail("考试计划中没有场次。");
|
||||
var requestedIds = (requestedSessionIds ?? [])
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
if (requestedIds.Count > 100)
|
||||
return ExamArrangementResult.Fail("一次最多处理100个考试场次。");
|
||||
if (requestedIds.Count > 0 &&
|
||||
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
||||
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
|
||||
|
||||
var termId = plan.AcademicTermId;
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (timeSlots.Count == 0)
|
||||
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
|
||||
|
||||
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
||||
|
||||
int assignedRooms = 0;
|
||||
int assignedInvigilators = 0;
|
||||
int skippedRooms = 0;
|
||||
int skippedInvigilators = 0;
|
||||
var messages = new List<string>();
|
||||
|
||||
// Track occupied time slots to avoid conflicts
|
||||
var occupiedRooms = sessions
|
||||
.Where(x => x.ClassroomId.HasValue)
|
||||
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
|
||||
.ToList();
|
||||
|
||||
var occupiedInvigilators = sessions
|
||||
.SelectMany(x => x.Invigilators.Select(i =>
|
||||
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
|
||||
.ToList();
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
// Compute StartsAt/EndsAt from time slots
|
||||
foreach (var session in plan.Sessions)
|
||||
ComputeTimesFromSlots(session, timeSlotLookup);
|
||||
var studentCount = await db.CourseEnrollments.CountAsync(
|
||||
x => x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
|
||||
cancellationToken);
|
||||
|
||||
// ── Auto-assign classroom ──
|
||||
if (!session.ClassroomId.HasValue)
|
||||
var explicitlySelected = plan.Sessions
|
||||
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||
.ToList();
|
||||
if (explicitlySelected.Count == 0)
|
||||
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
||||
|
||||
var selectedKeys = explicitlySelected
|
||||
.Select(GroupKey)
|
||||
.ToHashSet();
|
||||
var sessions = plan.Sessions
|
||||
.Where(x => selectedKeys.Contains(GroupKey(x)))
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ToList();
|
||||
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
|
||||
|
||||
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
sessions.Select(x => x.TeachingTaskId),
|
||||
cancellationToken);
|
||||
var sessionByTaskId = sessions
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
|
||||
var replacedRooms = assignClassrooms
|
||||
? plan.Rooms
|
||||
.Where(room => room.SessionLinks.Any(link =>
|
||||
sessionIds.Contains(link.ExamSessionId)))
|
||||
.ToList()
|
||||
: [];
|
||||
var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet();
|
||||
if (replacedRooms.Count > 0)
|
||||
db.ExamRooms.RemoveRange(replacedRooms);
|
||||
|
||||
var occupiedRooms = await LoadOccupiedRoomsAsync(
|
||||
plan,
|
||||
sessionIds,
|
||||
replacedRoomIds,
|
||||
cancellationToken);
|
||||
var rooms = await db.Classrooms.AsNoTracking()
|
||||
.Include(x => x.Building)
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Building!.Name)
|
||||
.ThenBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var targetRooms = new List<ExamRoomAssignment>();
|
||||
var messages = new List<string>();
|
||||
var seatedStudents = 0;
|
||||
var unavailableStudents = 0;
|
||||
|
||||
if (assignClassrooms)
|
||||
{
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var room = await FindBestClassroomAsync(
|
||||
session, studentCount, occupiedRooms, cancellationToken);
|
||||
if (room is not null)
|
||||
session.ClassroomId = null;
|
||||
if (session.Invigilators.Count > 0)
|
||||
{
|
||||
session.ClassroomId = room.Id;
|
||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
||||
assignedRooms++;
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
||||
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||
session.Invigilators.Clear();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
}
|
||||
|
||||
// ── Auto-assign invigilators ──
|
||||
var currentInvigilatorCount = session.Invigilators.Count;
|
||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||
if (needed > 0)
|
||||
foreach (var group in sessions
|
||||
.GroupBy(GroupKey)
|
||||
.OrderBy(x => x.Key.ExamDate)
|
||||
.ThenBy(x => x.Key.StartPeriod)
|
||||
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
|
||||
{
|
||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||
.Select(x => x.TeacherId).ToHashSet();
|
||||
var newlyAssigned = await FindInvigilatorsAsync(
|
||||
session, needed, courseTeacherIds,
|
||||
occupiedInvigilators, cancellationToken);
|
||||
foreach (var teacher in newlyAssigned)
|
||||
var groupSessions = group.ToList();
|
||||
var candidates = InterleaveCandidates(
|
||||
groupSessions,
|
||||
roster,
|
||||
sessionByTaskId);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
session.Invigilators.Add(new ExamSessionInvigilator
|
||||
messages.Add(
|
||||
$"“{groupSessions[0].TeachingTask!.Course!.Name}”没有有效考生。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var availableRooms = rooms
|
||||
.Where(room =>
|
||||
(group.Key.RequiredBuildingIdsKey.Length == 0 ||
|
||||
group.Key.RequiredBuildingIdsKey
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Contains(room.BuildingId.ToString())) &&
|
||||
occupiedRooms.All(occupied =>
|
||||
occupied.ClassroomId != room.Id ||
|
||||
!ExamConflictRules.TimeOverlaps(
|
||||
occupied.StartsAt,
|
||||
occupied.EndsAt,
|
||||
groupSessions[0].StartsAt,
|
||||
groupSessions[0].EndsAt)))
|
||||
.ToList();
|
||||
var selectedRooms = SelectRooms(
|
||||
availableRooms,
|
||||
candidates.Count);
|
||||
if (selectedRooms.Sum(x => x.Capacity / 2) < candidates.Count)
|
||||
{
|
||||
unavailableStudents += candidates.Count;
|
||||
messages.Add(
|
||||
$"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," +
|
||||
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity / 2)} 座。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
foreach (var classroom in selectedRooms)
|
||||
{
|
||||
var roomCandidates = candidates
|
||||
.Skip(offset)
|
||||
.Take(classroom.Capacity / 2)
|
||||
.ToList();
|
||||
if (roomCandidates.Count == 0) break;
|
||||
offset += roomCandidates.Count;
|
||||
|
||||
var room = new ExamRoomAssignment
|
||||
{
|
||||
ExamPlanId = plan.Id,
|
||||
CourseId = group.Key.CourseId,
|
||||
ClassroomId = classroom.Id,
|
||||
ExamDate = group.Key.ExamDate,
|
||||
StartPeriod = group.Key.StartPeriod,
|
||||
PeriodCount = group.Key.PeriodCount,
|
||||
StartsAt = groupSessions[0].StartsAt,
|
||||
EndsAt = groupSessions[0].EndsAt,
|
||||
RequiredInvigilatorCount =
|
||||
groupSessions.Max(x => x.RequiredInvigilatorCount),
|
||||
SessionLinks = roomCandidates
|
||||
.Select(x => x.ExamSessionId)
|
||||
.Distinct()
|
||||
.Select(sessionId => new ExamRoomSession
|
||||
{
|
||||
ExamSessionId = sessionId
|
||||
})
|
||||
.ToList(),
|
||||
Seats = roomCandidates
|
||||
.Select((candidate, index) =>
|
||||
new ExamSeatAssignment
|
||||
{
|
||||
ExamSessionId = candidate.ExamSessionId,
|
||||
StudentId = candidate.StudentId,
|
||||
SeatNumber = index + 1
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
db.ExamRooms.Add(room);
|
||||
targetRooms.Add(room);
|
||||
occupiedRooms.Add(new RoomOccupancy(
|
||||
classroom.Id,
|
||||
room.StartsAt,
|
||||
room.EndsAt));
|
||||
seatedStudents += roomCandidates.Count;
|
||||
}
|
||||
|
||||
messages.Add(
|
||||
$"“{groupSessions[0].TeachingTask!.Course!.Name}”" +
|
||||
$"{groupSessions.Count}个教学班混排至{selectedRooms.Count}个考场。");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
targetRooms.AddRange(plan.Rooms.Where(room =>
|
||||
room.SessionLinks.Any(link =>
|
||||
sessionIds.Contains(link.ExamSessionId))));
|
||||
var linkedSessionIds = targetRooms
|
||||
.SelectMany(x => x.SessionLinks)
|
||||
.Select(x => x.ExamSessionId)
|
||||
.ToHashSet();
|
||||
foreach (var session in sessions.Where(x =>
|
||||
!linkedSessionIds.Contains(x.Id) &&
|
||||
x.ClassroomId.HasValue))
|
||||
{
|
||||
var legacyRoom = CreateLegacyRoom(
|
||||
plan,
|
||||
session,
|
||||
roster.Where(x =>
|
||||
x.TeachingTaskId == session.TeachingTaskId));
|
||||
db.ExamRooms.Add(legacyRoom);
|
||||
targetRooms.Add(legacyRoom);
|
||||
session.ClassroomId = null;
|
||||
if (session.Invigilators.Count > 0)
|
||||
{
|
||||
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||
session.Invigilators.Clear();
|
||||
}
|
||||
}
|
||||
seatedStudents = targetRooms.Sum(x => x.Seats.Count);
|
||||
}
|
||||
|
||||
var assignedInvigilators = 0;
|
||||
var unavailableInvigilators = 0;
|
||||
if (assignInvigilators)
|
||||
{
|
||||
if (targetRooms.Count == 0)
|
||||
return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
|
||||
|
||||
var occupiedInvigilators = plan.Rooms
|
||||
.Where(x => !replacedRoomIds.Contains(x.Id) &&
|
||||
!targetRooms.Any(target => target.Id == x.Id))
|
||||
.SelectMany(room => room.Invigilators.Select(item =>
|
||||
new InvigilatorOccupancy(
|
||||
item.TeacherId,
|
||||
room.StartsAt,
|
||||
room.EndsAt)))
|
||||
.Concat(plan.Sessions
|
||||
.Where(x => !sessionIds.Contains(x.Id))
|
||||
.SelectMany(session => session.Invigilators.Select(item =>
|
||||
new InvigilatorOccupancy(
|
||||
item.TeacherId,
|
||||
session.StartsAt,
|
||||
session.EndsAt))))
|
||||
.ToList();
|
||||
|
||||
foreach (var room in targetRooms
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.ClassroomId))
|
||||
{
|
||||
var needed = room.RequiredInvigilatorCount -
|
||||
room.Invigilators.Count;
|
||||
if (needed <= 0) continue;
|
||||
|
||||
var linkedSessionIds = room.SessionLinks
|
||||
.Select(x => x.ExamSessionId)
|
||||
.ToHashSet();
|
||||
var excludedTeacherIds = sessions
|
||||
.Where(x => linkedSessionIds.Contains(x.Id))
|
||||
.SelectMany(x => x.TeachingTask!.Teachers)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToHashSet();
|
||||
var teachers = await FindInvigilatorsAsync(
|
||||
room,
|
||||
needed,
|
||||
excludedTeacherIds,
|
||||
occupiedInvigilators,
|
||||
cancellationToken);
|
||||
foreach (var teacher in teachers)
|
||||
{
|
||||
room.Invigilators.Add(new ExamRoomInvigilator
|
||||
{
|
||||
ExamSessionId = session.Id,
|
||||
TeacherId = teacher.Id
|
||||
});
|
||||
occupiedInvigilators.Add(new InvigilatorOccupancy(
|
||||
teacher.Id, session.StartsAt, session.EndsAt));
|
||||
teacher.Id,
|
||||
room.StartsAt,
|
||||
room.EndsAt));
|
||||
assignedInvigilators++;
|
||||
}
|
||||
|
||||
if (newlyAssigned.Count < needed)
|
||||
messages.Add(
|
||||
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedInvigilators++;
|
||||
if (teachers.Count < needed)
|
||||
unavailableInvigilators += needed - teachers.Count;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var expandedCount = sessions.Count - explicitlySelected.Count;
|
||||
var detail = messages.Count > 0
|
||||
? $" 详情:{string.Join(";", messages.Take(10))}"
|
||||
: "";
|
||||
return new ExamArrangementResult(
|
||||
true,
|
||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||
$"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
|
||||
$"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
|
||||
(expandedCount > 0
|
||||
? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。"
|
||||
: "") +
|
||||
(unavailableStudents > 0
|
||||
? $" {unavailableStudents}名考生尚未安排考场。"
|
||||
: "") +
|
||||
(unavailableInvigilators > 0
|
||||
? $" 仍缺{unavailableInvigilators}名监考教师。"
|
||||
: "") +
|
||||
detail);
|
||||
}
|
||||
|
||||
private async Task<List<RoomOccupancy>> LoadOccupiedRoomsAsync(
|
||||
ExamPlan plan,
|
||||
IReadOnlySet<Guid> targetSessionIds,
|
||||
IReadOnlySet<Guid> replacedRoomIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var occupied = plan.Rooms
|
||||
.Where(x => !replacedRoomIds.Contains(x.Id))
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId,
|
||||
x.StartsAt,
|
||||
x.EndsAt))
|
||||
.ToList();
|
||||
occupied.AddRange(plan.Sessions
|
||||
.Where(x =>
|
||||
!targetSessionIds.Contains(x.Id) &&
|
||||
x.ClassroomId.HasValue)
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId!.Value,
|
||||
x.StartsAt,
|
||||
x.EndsAt)));
|
||||
|
||||
var externalRooms = await db.ExamRooms.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamPlanId != plan.Id &&
|
||||
x.ExamPlan!.Status != ExamPlanStatus.Archived)
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId,
|
||||
x.StartsAt,
|
||||
x.EndsAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
occupied.AddRange(externalRooms);
|
||||
|
||||
var externalLegacyRooms = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamPlanId != plan.Id &&
|
||||
x.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||
x.ClassroomId != null)
|
||||
.Select(x => new RoomOccupancy(
|
||||
x.ClassroomId!.Value,
|
||||
x.StartsAt,
|
||||
x.EndsAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
occupied.AddRange(externalLegacyRooms);
|
||||
return occupied;
|
||||
}
|
||||
|
||||
private async Task<List<Teacher>> FindInvigilatorsAsync(
|
||||
ExamRoomAssignment room,
|
||||
int needed,
|
||||
HashSet<Guid> excludedTeacherIds,
|
||||
List<InvigilatorOccupancy> occupied,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var busyTeacherIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
room.StartsAt,
|
||||
room.EndsAt))
|
||||
.Select(x => x.TeacherId)
|
||||
.ToHashSet();
|
||||
|
||||
var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamRoomId != room.Id &&
|
||||
x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||
x.ExamRoom.StartsAt < room.EndsAt &&
|
||||
room.StartsAt < x.ExamRoom.EndsAt)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var teacherId in databaseBusyIds)
|
||||
busyTeacherIds.Add(teacherId);
|
||||
|
||||
var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||
x.ExamSession.StartsAt < room.EndsAt &&
|
||||
room.StartsAt < x.ExamSession.EndsAt)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var teacherId in legacyBusyIds)
|
||||
busyTeacherIds.Add(teacherId);
|
||||
foreach (var teacherId in excludedTeacherIds)
|
||||
busyTeacherIds.Add(teacherId);
|
||||
|
||||
var candidates = await InvigilatorCandidateQuery
|
||||
.Create(db, busyTeacherIds)
|
||||
.ToListAsync(cancellationToken);
|
||||
return candidates
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(needed)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<Classroom> SelectRooms(
|
||||
IReadOnlyCollection<Classroom> availableRooms,
|
||||
int candidateCount)
|
||||
{
|
||||
var remainingRooms = availableRooms.ToList();
|
||||
var selected = new List<Classroom>();
|
||||
var remainingSeats = candidateCount;
|
||||
while (remainingSeats > 0 && remainingRooms.Count > 0)
|
||||
{
|
||||
var room = remainingRooms
|
||||
.Where(x => x.Capacity / 2 >= remainingSeats)
|
||||
.OrderBy(x => x.Capacity / 2)
|
||||
.ThenBy(x => x.Name)
|
||||
.FirstOrDefault()
|
||||
?? remainingRooms
|
||||
.OrderByDescending(x => x.Capacity / 2)
|
||||
.ThenBy(x => x.Name)
|
||||
.First();
|
||||
selected.Add(room);
|
||||
remainingRooms.Remove(room);
|
||||
remainingSeats -= room.Capacity / 2;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static List<CandidateSeat> InterleaveCandidates(
|
||||
IReadOnlyCollection<ExamSession> sessions,
|
||||
IReadOnlyCollection<TeachingTaskRosterEntry> roster,
|
||||
IReadOnlyDictionary<Guid, ExamSession> sessionByTaskId)
|
||||
{
|
||||
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
|
||||
var queues = roster
|
||||
.Where(x =>
|
||||
sessionByTaskId.TryGetValue(
|
||||
x.TeachingTaskId,
|
||||
out var session) &&
|
||||
sessionIds.Contains(session.Id))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber)
|
||||
.Select(group => new Queue<CandidateSeat>(
|
||||
group.OrderBy(x => x.StudentNumber)
|
||||
.Select(x => new CandidateSeat(
|
||||
sessionByTaskId[x.TeachingTaskId].Id,
|
||||
x.StudentId,
|
||||
x.StudentNumber))))
|
||||
.ToList();
|
||||
|
||||
var result = new List<CandidateSeat>();
|
||||
var assignedStudentIds = new HashSet<Guid>();
|
||||
while (queues.Any(x => x.Count > 0))
|
||||
{
|
||||
foreach (var queue in queues)
|
||||
{
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var candidate = queue.Dequeue();
|
||||
if (!assignedStudentIds.Add(candidate.StudentId))
|
||||
continue;
|
||||
result.Add(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ExamRoomAssignment CreateLegacyRoom(
|
||||
ExamPlan plan,
|
||||
ExamSession session,
|
||||
IEnumerable<TeachingTaskRosterEntry> roster)
|
||||
{
|
||||
var students = roster
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.ToList();
|
||||
return new ExamRoomAssignment
|
||||
{
|
||||
ExamPlanId = plan.Id,
|
||||
CourseId = session.TeachingTask!.CourseId,
|
||||
ClassroomId = session.ClassroomId!.Value,
|
||||
ExamDate = session.ExamDate,
|
||||
StartPeriod = session.StartPeriod,
|
||||
PeriodCount = session.PeriodCount,
|
||||
StartsAt = session.StartsAt,
|
||||
EndsAt = session.EndsAt,
|
||||
RequiredInvigilatorCount = session.RequiredInvigilatorCount,
|
||||
SessionLinks =
|
||||
[
|
||||
new ExamRoomSession
|
||||
{
|
||||
ExamSessionId = session.Id
|
||||
}
|
||||
],
|
||||
Seats = students.Select((student, index) =>
|
||||
new ExamSeatAssignment
|
||||
{
|
||||
ExamSessionId = session.Id,
|
||||
StudentId = student.StudentId,
|
||||
SeatNumber = index + 1
|
||||
}).ToList(),
|
||||
Invigilators = session.Invigilators.Select(x =>
|
||||
new ExamRoomInvigilator
|
||||
{
|
||||
TeacherId = x.TeacherId
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static RoomGroupKey GroupKey(ExamSession session)
|
||||
{
|
||||
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||
var key = buildingIds.Count == 0
|
||||
? string.Empty
|
||||
: string.Join(",", buildingIds.OrderBy(x => x));
|
||||
return new(
|
||||
session.TeachingTask!.CourseId,
|
||||
session.ExamDate,
|
||||
session.StartPeriod,
|
||||
session.PeriodCount,
|
||||
key);
|
||||
}
|
||||
|
||||
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static void ComputeTimesFromSlots(
|
||||
ExamSession session,
|
||||
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
||||
IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
||||
{
|
||||
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
|
||||
var endSlot = timeSlotLookup.GetValueOrDefault(
|
||||
session.StartPeriod + session.PeriodCount - 1);
|
||||
if (startSlot is null || endSlot is null) return;
|
||||
|
||||
var examDate = session.ExamDate;
|
||||
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
|
||||
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private async Task<Classroom?> FindBestClassroomAsync(
|
||||
ExamSession session,
|
||||
int studentCount,
|
||||
List<RoomOccupancy> occupied,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
|
||||
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
||||
|
||||
// Exclude classrooms already occupied in-memory
|
||||
var occupiedRoomIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet();
|
||||
|
||||
if (occupiedRoomIds.Count > 0)
|
||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
||||
|
||||
// Exclude classrooms occupied by DB sessions not yet tracked in memory
|
||||
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == session.ExamPlanId &&
|
||||
x.Id != session.Id &&
|
||||
x.ClassroomId != null &&
|
||||
x.StartsAt < session.EndsAt &&
|
||||
session.StartsAt < x.EndsAt)
|
||||
.Select(x => x.ClassroomId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (dbOccupiedRooms.Count > 0)
|
||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
||||
|
||||
return await query
|
||||
.OrderBy(x => x.Capacity)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<Teacher>> FindInvigilatorsAsync(
|
||||
ExamSession session,
|
||||
int needed,
|
||||
HashSet<Guid> excludeTeacherIds,
|
||||
List<InvigilatorOccupancy> occupied,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var busyTeacherIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
||||
.Select(x => x.TeacherId)
|
||||
.ToHashSet();
|
||||
|
||||
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
|
||||
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
|
||||
x.ExamSessionId != session.Id &&
|
||||
x.ExamSession!.StartsAt < session.EndsAt &&
|
||||
session.StartsAt < x.ExamSession.EndsAt)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
.ToListAsync(cancellationToken);
|
||||
session.StartsAt = session.ExamDate.ToDateTime(
|
||||
startSlot.StartsAt,
|
||||
DateTimeKind.Utc);
|
||||
session.EndsAt = session.ExamDate.ToDateTime(
|
||||
endSlot.EndsAt,
|
||||
DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExamArrangementResult(bool Success, string Message)
|
||||
{
|
||||
public static ExamArrangementResult Fail(string message) => new(false, message);
|
||||
public static ExamArrangementResult Fail(string message) =>
|
||||
new(false, message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamPublishJobProcessor(
|
||||
AppDbContext db,
|
||||
IAppCache cache,
|
||||
ILogger<ExamPublishJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.ExamPublishJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is ExamPublishJobStatus.Succeeded
|
||||
or ExamPublishJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamPublishJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.CurrentStep = "正在校验考试计划";
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
switch (job.Kind)
|
||||
{
|
||||
case ExamPublishJobKind.FormalExam:
|
||||
await PublishFormalExamAsync(job, stoppingToken);
|
||||
break;
|
||||
case ExamPublishJobKind.MakeupExam:
|
||||
await PublishMakeupExamAsync(job, stoppingToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"不支持的考试发布类型:{job.Kind}。");
|
||||
}
|
||||
|
||||
job.Status = ExamPublishJobStatus.Succeeded;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "发布完成";
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
|
||||
job.Id,
|
||||
job.Kind,
|
||||
job.PlanId);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Exam publish job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (ExamPublishValidationException validationException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
validationException,
|
||||
"Exam publish job {JobId} validation failed.",
|
||||
jobId);
|
||||
await MarkFailedAsync(jobId, validationException.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishFormalExamAsync(
|
||||
ExamPublishJob job,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = await db.ExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||
if (plan is null)
|
||||
throw new ExamPublishValidationException("考试计划不存在。");
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
|
||||
|
||||
// Step 1: check sessions exist — simple count, no JOIN
|
||||
var sessionCount = await db.ExamSessions
|
||||
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
|
||||
if (sessionCount == 0)
|
||||
throw new ExamPublishValidationException(
|
||||
"至少安排一个考试场次后才能发布。");
|
||||
|
||||
// Step 2: load session summaries — only necessary columns, no Include chains
|
||||
var sessions = await db.ExamSessions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.ClassroomId,
|
||||
InvigilatorCount = x.Invigilators.Count,
|
||||
RoomLinkCount = x.RoomLinks.Count
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Step 3: load roster counts — independent query
|
||||
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
|
||||
var rosterCounts = (await TeachingTaskRosterQuery
|
||||
.LoadForTasksAsync(db, taskIds, ct))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.Count());
|
||||
|
||||
// Step 4: validate each session's assignment completeness
|
||||
var unassignedSessionIds = new List<Guid>();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
|
||||
if (session.RoomLinkCount > 0)
|
||||
{
|
||||
// Has room links — check seat assignment via separate query
|
||||
var assignedSeatCount = await db.ExamSeats
|
||||
.CountAsync(seat =>
|
||||
seat.ExamSessionId == session.Id,
|
||||
ct);
|
||||
if (assignedSeatCount != rosterCount)
|
||||
unassignedSessionIds.Add(session.Id);
|
||||
}
|
||||
else if (!session.ClassroomId.HasValue ||
|
||||
session.InvigilatorCount == 0)
|
||||
{
|
||||
unassignedSessionIds.Add(session.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (unassignedSessionIds.Count > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
|
||||
"请先完成自动编排。");
|
||||
|
||||
// Step 5: check room invigilator sufficiency — separate query
|
||||
var insufficientInvigilatorCount = await db.ExamRoomSessions
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||
link.ExamRoom.Invigilators.Count <
|
||||
link.ExamRoom.RequiredInvigilatorCount)
|
||||
.Select(link => link.ExamSessionId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
if (insufficientInvigilatorCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
|
||||
"请先完成自动编排。");
|
||||
|
||||
// Step 6: validate mixed rooms — split into two independent queries
|
||||
// to avoid Seats × SessionLinks Cartesian product
|
||||
|
||||
// 6a: capacity overflow
|
||||
var overCapacityCount = await db.ExamRooms
|
||||
.Where(r => r.ExamPlanId == job.PlanId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
SeatCount = r.Seats.Count,
|
||||
Capacity = r.Classroom!.Capacity
|
||||
})
|
||||
.CountAsync(x => x.SeatCount > x.Capacity, ct);
|
||||
if (overCapacityCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
|
||||
|
||||
// 6b: course mismatch
|
||||
var courseMismatchCount = await db.ExamRoomSessions
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||
link.ExamSession!.TeachingTask!.CourseId !=
|
||||
link.ExamRoom.CourseId)
|
||||
.Select(link => link.ExamRoomId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
if (courseMismatchCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
|
||||
|
||||
// Step 7: publish
|
||||
plan.Status = ExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task PublishMakeupExamAsync(
|
||||
ExamPublishJob job,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||
if (plan is null)
|
||||
throw new ExamPublishValidationException("补考计划不存在。");
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
|
||||
|
||||
// Step 1: check sessions exist — simple count
|
||||
var sessionCount = await db.MakeupExamSessions
|
||||
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
|
||||
if (sessionCount == 0)
|
||||
throw new ExamPublishValidationException(
|
||||
"至少安排一个考试场次后才能发布。");
|
||||
|
||||
// Step 2: load session summaries — only necessary columns
|
||||
var sessions = await db.MakeupExamSessions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ClassroomId,
|
||||
InvigilatorCount = x.Invigilators.Count,
|
||||
EnrollmentCount = x.Enrollments.Count
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Step 3: validate completeness — no Include chain needed
|
||||
var unassigned = sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
|
||||
if (unassigned > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
|
||||
// Step 4: validate enrollments
|
||||
var empty = sessions.Count(x => x.EnrollmentCount == 0);
|
||||
if (empty > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {empty} 个场次没有登记补考学生。");
|
||||
|
||||
// Step 5: publish
|
||||
plan.Status = MakeupExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
job.Status = ExamPublishJobStatus.Failed;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "发布失败";
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExamPublishValidationException(string message)
|
||||
: InvalidOperationException(message);
|
||||
@@ -0,0 +1,212 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamSignInExportJobProcessor(
|
||||
AppDbContext db,
|
||||
ILogger<ExamSignInExportJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.ExamSignInExportJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is ExamSignInExportJobStatus.Succeeded
|
||||
or ExamSignInExportJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamSignInExportJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.CurrentStep = "正在生成考场签名单";
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
var plan = await db.ExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Name,
|
||||
TermName = x.AcademicTerm!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(stoppingToken);
|
||||
if (plan is null)
|
||||
{
|
||||
await MarkFailedAsync(jobId, "考试计划不存在。");
|
||||
return;
|
||||
}
|
||||
|
||||
var rooms = await db.ExamRooms.AsNoTracking()
|
||||
.Include(x => x.Course)
|
||||
.Include(x => x.Classroom)
|
||||
.ThenInclude(x => x!.Building)
|
||||
.Include(x => x.Invigilators)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.Include(x => x.SessionLinks)
|
||||
.ThenInclude(x => x.ExamSession)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.Include(x => x.Seats)
|
||||
.ThenInclude(x => x.Student)
|
||||
.ThenInclude(x => x!.AdministrativeClass)
|
||||
.Where(x => x.ExamPlanId == job.PlanId)
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.Classroom!.Building!.Name)
|
||||
.ThenBy(x => x.Classroom!.Name)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
var legacySessions = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == job.PlanId)
|
||||
.Where(x => !x.RoomLinks.Any())
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(session => new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.ExamDate,
|
||||
session.StartsAt,
|
||||
session.EndsAt,
|
||||
CourseCode = session.TeachingTask!.Course!.Code,
|
||||
CourseName = session.TeachingTask.Course.Name,
|
||||
session.TeachingTask.TaskNumber,
|
||||
BuildingName = session.Classroom != null
|
||||
? session.Classroom.Building!.Name
|
||||
: null,
|
||||
ClassroomName = session.Classroom != null
|
||||
? session.Classroom.Name
|
||||
: null,
|
||||
InvigilatorNames = session.Invigilators
|
||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
})
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (rooms.Count == 0 && legacySessions.Count == 0)
|
||||
{
|
||||
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
|
||||
return;
|
||||
}
|
||||
|
||||
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
legacySessions.Select(x => x.TeachingTaskId),
|
||||
stoppingToken);
|
||||
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
|
||||
|
||||
var sheets = rooms.Select(room => new ExamSignInSessionData(
|
||||
room.Id,
|
||||
room.ExamDate,
|
||||
room.StartsAt,
|
||||
room.EndsAt,
|
||||
room.Course!.Code,
|
||||
room.Course.Name,
|
||||
string.Join(
|
||||
"、",
|
||||
room.SessionLinks
|
||||
.Select(link =>
|
||||
link.ExamSession!.TeachingTask!.TaskNumber)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)),
|
||||
room.Classroom!.Building!.Name,
|
||||
room.Classroom.Name,
|
||||
room.Invigilators
|
||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
.ToList(),
|
||||
room.Seats
|
||||
.OrderBy(seat => seat.SeatNumber)
|
||||
.Select(seat => new ExamSignInStudentData(
|
||||
seat.StudentId,
|
||||
seat.Student!.StudentNumber,
|
||||
seat.Student.Name,
|
||||
seat.Student.AdministrativeClass!.Name,
|
||||
seat.SeatNumber))
|
||||
.ToList()))
|
||||
.Concat(legacySessions.Select(session =>
|
||||
new ExamSignInSessionData(
|
||||
session.Id,
|
||||
session.ExamDate,
|
||||
session.StartsAt,
|
||||
session.EndsAt,
|
||||
session.CourseCode,
|
||||
session.CourseName,
|
||||
session.TaskNumber,
|
||||
session.BuildingName,
|
||||
session.ClassroomName,
|
||||
session.InvigilatorNames.ToList(),
|
||||
studentsByTask[session.TeachingTaskId]
|
||||
.Select((student, index) =>
|
||||
new ExamSignInStudentData(
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
index + 1))
|
||||
.ToList())))
|
||||
.ToList();
|
||||
|
||||
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
|
||||
var bytes = ExamSignInWorkbookExporter.Create(data);
|
||||
|
||||
job.Status = ExamSignInExportJobStatus.Succeeded;
|
||||
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
|
||||
job.FileBytes = bytes;
|
||||
job.FileSize = bytes.Length;
|
||||
job.CurrentStep = "生成完成";
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
|
||||
job.Id,
|
||||
job.PlanId,
|
||||
bytes.Length);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Sign-in export job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
job.Status = ExamSignInExportJobStatus.Failed;
|
||||
job.CurrentStep = "生成失败";
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
|
||||
var normalized = new string(value
|
||||
.Select(c => invalid.Contains(c) ? '_' : c)
|
||||
.ToArray()).Trim();
|
||||
return normalized.Length > 100 ? normalized[..100] : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public static class ExamSignInWorkbookExporter
|
||||
{
|
||||
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
|
||||
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
|
||||
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
|
||||
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
|
||||
|
||||
public static byte[] Create(ExamSignInWorkbookData data)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
AddSummarySheet(workbook, data);
|
||||
|
||||
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"考场汇总"
|
||||
};
|
||||
foreach (var session in data.Sessions
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartsAt)
|
||||
.ThenBy(x => x.BuildingName)
|
||||
.ThenBy(x => x.ClassroomName)
|
||||
.ThenBy(x => x.CourseCode))
|
||||
{
|
||||
AddSignInSheet(
|
||||
workbook,
|
||||
data,
|
||||
session,
|
||||
UniqueSheetName(session, usedNames));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void AddSummarySheet(
|
||||
XLWorkbook workbook,
|
||||
ExamSignInWorkbookData data)
|
||||
{
|
||||
var sheet = workbook.Worksheets.Add("考场汇总");
|
||||
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||
sheet.ShowGridLines = false;
|
||||
|
||||
sheet.Range("A1:G1").Merge();
|
||||
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
|
||||
StyleTitle(sheet.Range("A1:G1"));
|
||||
sheet.Row(1).Height = 34;
|
||||
|
||||
sheet.Range("A2:G2").Merge();
|
||||
sheet.Cell("A2").Value =
|
||||
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||
StyleSubTitle(sheet.Range("A2:G2"));
|
||||
sheet.Row(2).Height = 24;
|
||||
|
||||
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
|
||||
for (var column = 1; column <= headers.Length; column++)
|
||||
sheet.Cell(4, column).Value = headers[column - 1];
|
||||
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
|
||||
|
||||
var row = 5;
|
||||
foreach (var session in data.Sessions
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartsAt)
|
||||
.ThenBy(x => x.BuildingName)
|
||||
.ThenBy(x => x.ClassroomName))
|
||||
{
|
||||
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
|
||||
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
|
||||
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||
sheet.Cell(row, 3).Value = Location(session);
|
||||
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
|
||||
sheet.Cell(row, 5).Value = session.TaskNumber;
|
||||
sheet.Cell(row, 6).Value = session.Students.Count;
|
||||
sheet.Cell(row, 7).Value = Invigilators(session);
|
||||
row++;
|
||||
}
|
||||
|
||||
if (row > 5)
|
||||
{
|
||||
var table = sheet.Range(4, 1, row - 1, headers.Length);
|
||||
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||
table.Style.Border.InsideBorderColor = Rule;
|
||||
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||
table.Style.Border.OutsideBorderColor = Rule;
|
||||
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
sheet.Rows(5, row - 1).Height = 25;
|
||||
}
|
||||
|
||||
sheet.Column(1).Width = 13;
|
||||
sheet.Column(2).Width = 15;
|
||||
sheet.Column(3).Width = 24;
|
||||
sheet.Column(4).Width = 30;
|
||||
sheet.Column(5).Width = 22;
|
||||
sheet.Column(6).Width = 11;
|
||||
sheet.Column(7).Width = 24;
|
||||
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
|
||||
sheet.SheetView.FreezeRows(4);
|
||||
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
|
||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
sheet.PageSetup.Margins
|
||||
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
||||
}
|
||||
|
||||
private static void AddSignInSheet(
|
||||
XLWorkbook workbook,
|
||||
ExamSignInWorkbookData data,
|
||||
ExamSignInSessionData session,
|
||||
string sheetName)
|
||||
{
|
||||
var sheet = workbook.Worksheets.Add(sheetName);
|
||||
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||
sheet.ShowGridLines = false;
|
||||
|
||||
sheet.Range("A1:G1").Merge();
|
||||
sheet.Cell("A1").Value = "考试考场签名单";
|
||||
StyleTitle(sheet.Range("A1:G1"));
|
||||
sheet.Cell("A1").Style.Alignment.Horizontal =
|
||||
XLAlignmentHorizontalValues.Center;
|
||||
sheet.Row(1).Height = 36;
|
||||
|
||||
sheet.Range("A2:G2").Merge();
|
||||
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
|
||||
StyleSubTitle(sheet.Range("A2:G2"));
|
||||
sheet.Cell("A2").Style.Alignment.Horizontal =
|
||||
XLAlignmentHorizontalValues.Center;
|
||||
sheet.Row(2).Height = 23;
|
||||
|
||||
sheet.Range("A3:D3").Merge();
|
||||
sheet.Cell("A3").Value =
|
||||
$"课程:{session.CourseCode} {session.CourseName}({session.TaskNumber})";
|
||||
sheet.Range("E3:G3").Merge();
|
||||
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
|
||||
|
||||
sheet.Range("A4:D4").Merge();
|
||||
sheet.Cell("A4").Value =
|
||||
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||
sheet.Range("E4:G4").Merge();
|
||||
sheet.Cell("E4").Value = $"考场:{Location(session)}";
|
||||
|
||||
sheet.Range("A5:D5").Merge();
|
||||
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
|
||||
sheet.Range("E5:G5").Merge();
|
||||
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
|
||||
|
||||
var metadata = sheet.Range("A3:G5");
|
||||
metadata.Style.Fill.BackgroundColor = XLColor.White;
|
||||
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
|
||||
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
metadata.Style.Alignment.WrapText = true;
|
||||
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
||||
metadata.Style.Border.BottomBorderColor = Rule;
|
||||
sheet.Rows(3, 5).Height = 24;
|
||||
|
||||
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
|
||||
for (var column = 1; column <= headers.Length; column++)
|
||||
sheet.Cell(7, column).Value = headers[column - 1];
|
||||
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
|
||||
sheet.Row(7).Height = 27;
|
||||
|
||||
var row = 8;
|
||||
foreach (var student in session.Students
|
||||
.OrderBy(x => x.SeatNumber)
|
||||
.ThenBy(x => x.StudentNumber))
|
||||
{
|
||||
var index = row - 7;
|
||||
sheet.Cell(row, 1).Value = index;
|
||||
sheet.Cell(row, 2).Value =
|
||||
(student.SeatNumber > 0 ? student.SeatNumber : index)
|
||||
.ToString("D3");
|
||||
sheet.Cell(row, 3).Value = student.StudentNumber;
|
||||
sheet.Cell(row, 4).Value = student.Name;
|
||||
sheet.Cell(row, 5).Value = student.ClassName;
|
||||
sheet.Row(row).Height = 28;
|
||||
row++;
|
||||
}
|
||||
|
||||
if (session.Students.Count == 0)
|
||||
{
|
||||
sheet.Range("A8:G8").Merge();
|
||||
sheet.Cell("A8").Value = "本场次暂无考生";
|
||||
sheet.Cell("A8").Style
|
||||
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
|
||||
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
|
||||
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
|
||||
sheet.Row(8).Height = 28;
|
||||
row = 9;
|
||||
}
|
||||
|
||||
var roster = sheet.Range(7, 1, row - 1, headers.Length);
|
||||
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||
roster.Style.Border.InsideBorderColor = Rule;
|
||||
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
|
||||
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
|
||||
XLAlignmentHorizontalValues.Center;
|
||||
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
|
||||
|
||||
sheet.Column(1).Width = 7;
|
||||
sheet.Column(2).Width = 10;
|
||||
sheet.Column(3).Width = 16;
|
||||
sheet.Column(4).Width = 12;
|
||||
sheet.Column(5).Width = 20;
|
||||
sheet.Column(6).Width = 22;
|
||||
sheet.Column(7).Width = 17;
|
||||
sheet.SheetView.FreezeRows(7);
|
||||
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
|
||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
sheet.PageSetup.Margins
|
||||
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
|
||||
}
|
||||
|
||||
private static void StyleTitle(IXLRange range) =>
|
||||
range.Style
|
||||
.Font.SetBold()
|
||||
.Font.SetFontSize(18)
|
||||
.Font.SetFontColor(XLColor.White)
|
||||
.Fill.SetBackgroundColor(Navy)
|
||||
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||
|
||||
private static void StyleSubTitle(IXLRange range) =>
|
||||
range.Style
|
||||
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
|
||||
.Fill.SetBackgroundColor(Pale)
|
||||
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||
|
||||
private static void StyleHeader(IXLRange range) =>
|
||||
range.Style
|
||||
.Font.SetBold()
|
||||
.Font.SetFontColor(XLColor.White)
|
||||
.Fill.SetBackgroundColor(Teal)
|
||||
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
|
||||
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||
|
||||
private static string Location(ExamSignInSessionData session) =>
|
||||
string.Join(" · ", new[]
|
||||
{
|
||||
session.BuildingName,
|
||||
session.ClassroomName
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
|
||||
? value
|
||||
: "待分配考场";
|
||||
|
||||
private static string Invigilators(ExamSignInSessionData session) =>
|
||||
session.InvigilatorNames.Count > 0
|
||||
? string.Join('、', session.InvigilatorNames)
|
||||
: "待分配";
|
||||
|
||||
private static string UniqueSheetName(
|
||||
ExamSignInSessionData session,
|
||||
ISet<string> usedNames)
|
||||
{
|
||||
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
|
||||
? "待分配"
|
||||
: session.ClassroomName;
|
||||
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
|
||||
var invalidCharacters = new HashSet<char>(":\\/?*[]");
|
||||
var sanitized = new string(raw
|
||||
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
|
||||
.ToArray()).Trim(' ', '\'');
|
||||
if (sanitized.Length == 0) sanitized = "考场";
|
||||
if (sanitized.Length > 31) sanitized = sanitized[..31];
|
||||
|
||||
var candidate = sanitized;
|
||||
var suffix = 2;
|
||||
while (!usedNames.Add(candidate))
|
||||
{
|
||||
var marker = $"-{suffix++}";
|
||||
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
|
||||
candidate = sanitized[..prefixLength] + marker;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExamSignInWorkbookData(
|
||||
string PlanName,
|
||||
string TermName,
|
||||
IReadOnlyList<ExamSignInSessionData> Sessions);
|
||||
|
||||
public sealed record ExamSignInSessionData(
|
||||
Guid SessionId,
|
||||
DateOnly ExamDate,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TaskNumber,
|
||||
string? BuildingName,
|
||||
string? ClassroomName,
|
||||
IReadOnlyList<string> InvigilatorNames,
|
||||
IReadOnlyList<ExamSignInStudentData> Students);
|
||||
|
||||
public sealed record ExamSignInStudentData(
|
||||
Guid StudentId,
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
string ClassName,
|
||||
int SeatNumber = 0);
|
||||
@@ -0,0 +1,15 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
internal static class InvigilatorCandidateQuery
|
||||
{
|
||||
public static IQueryable<Teacher> Create(
|
||||
AppDbContext db,
|
||||
IEnumerable<Guid> excludedTeacherIds) =>
|
||||
db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(excludedTeacherIds, x => x.Id);
|
||||
}
|
||||
@@ -11,8 +11,14 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
|
||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||
Guid planId,
|
||||
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||
bool assignClassrooms,
|
||||
bool assignInvigilators,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!assignClassrooms && !assignInvigilators)
|
||||
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||
|
||||
var plan = await db.MakeupExamPlans
|
||||
.Include(x => x.AcademicTerm)
|
||||
.Include(x => x.Sessions)
|
||||
@@ -20,6 +26,10 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||
|
||||
if (plan is null)
|
||||
@@ -27,9 +37,23 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。");
|
||||
|
||||
var sessions = plan.Sessions.ToList();
|
||||
var requestedIds = (requestedSessionIds ?? [])
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
if (requestedIds.Count > 100)
|
||||
return ExamArrangementResult.Fail("一次最多处理100个补考场次。");
|
||||
if (requestedIds.Count > 0 &&
|
||||
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
||||
return ExamArrangementResult.Fail("所选场次不存在或不属于当前补考计划。");
|
||||
|
||||
var sessions = plan.Sessions
|
||||
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenByDescending(x => x.RequiredInvigilatorCount)
|
||||
.ToList();
|
||||
if (sessions.Count == 0)
|
||||
return ExamArrangementResult.Fail("补考计划中没有场次。");
|
||||
return ExamArrangementResult.Fail("没有可处理的补考场次。");
|
||||
|
||||
var termId = plan.AcademicTermId;
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
@@ -44,8 +68,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
|
||||
int assignedRooms = 0;
|
||||
int assignedInvigilators = 0;
|
||||
int skippedRooms = 0;
|
||||
int skippedInvigilators = 0;
|
||||
int unavailableRooms = 0;
|
||||
int unavailableInvigilators = 0;
|
||||
var messages = new List<string>();
|
||||
|
||||
var occupiedRooms = sessions
|
||||
@@ -66,7 +90,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
|
||||
|
||||
// Auto-assign classroom
|
||||
if (!session.ClassroomId.HasValue)
|
||||
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||
{
|
||||
var room = await FindBestClassroomAsync(
|
||||
session, enrolledCount, occupiedRooms, cancellationToken);
|
||||
@@ -76,24 +100,19 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
||||
assignedRooms++;
|
||||
messages.Add(
|
||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)");
|
||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
unavailableRooms++;
|
||||
messages.Add(
|
||||
$"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedRooms++;
|
||||
}
|
||||
|
||||
// Auto-assign invigilators
|
||||
var currentInvigilatorCount = session.Invigilators.Count;
|
||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||
if (needed > 0)
|
||||
if (assignInvigilators && needed > 0)
|
||||
{
|
||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||
.Select(x => x.TeacherId).ToHashSet();
|
||||
@@ -113,12 +132,11 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
}
|
||||
|
||||
if (newlyAssigned.Count < needed)
|
||||
{
|
||||
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||
messages.Add(
|
||||
$"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedInvigilators++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +144,9 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
|
||||
return new ExamArrangementResult(
|
||||
true,
|
||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
||||
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||
}
|
||||
|
||||
@@ -152,10 +171,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount);
|
||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
|
||||
|
||||
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||
if (session.RequiredBuildingId.HasValue)
|
||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
||||
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||
if (buildingIds.Count > 0)
|
||||
query = query.Where(x => buildingIds.Contains(x.BuildingId));
|
||||
|
||||
var occupiedRoomIds = occupied
|
||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||
@@ -207,11 +229,27 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
var candidates = await InvigilatorCandidateQuery
|
||||
.Create(db, busyTeacherIds)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return candidates
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(needed)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class MakeupExamAutoJobQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void Enqueue(Guid jobId)
|
||||
{
|
||||
if (!_channel.Writer.TryWrite(jobId))
|
||||
throw new InvalidOperationException("补考自动生成任务队列当前不可用。");
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MakeupExamAutoJobWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
MakeupExamAutoJobQueue queue,
|
||||
ILogger<MakeupExamAutoJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not recover makeup exam auto jobs (table may not exist yet).");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider
|
||||
.GetRequiredService<MakeupExamAutoJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Unexpected failure while dispatching makeup exam auto job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Makeup exam auto job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobs = await db.MakeupExamAutoJobs
|
||||
.Where(x =>
|
||||
x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||
x.Status == MakeupExamAutoJobStatus.Running)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
job.Status = MakeupExamAutoJobStatus.Queued;
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
}
|
||||
|
||||
if (jobs.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var job in jobs)
|
||||
queue.Enqueue(job.Id);
|
||||
|
||||
if (jobs.Count > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Recovered {JobCount} queued or interrupted makeup exam auto jobs.",
|
||||
jobs.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MakeupExamAutoJobProcessor(
|
||||
AppDbContext db,
|
||||
MakeupExamEligibilityService eligibilityService,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Experiments;
|
||||
|
||||
internal static class CentralizedExperimentConflictQuery
|
||||
{
|
||||
public static IQueryable<Guid> TaskIdsForTeachers(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> teacherIds) =>
|
||||
db.TeachingTaskTeachers.AsNoTracking()
|
||||
.WhereIn(teacherIds, x => x.TeacherId)
|
||||
.Select(x => x.TeachingTaskId);
|
||||
|
||||
public static IQueryable<Guid> TaskIdsForClasses(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> classIds) =>
|
||||
db.TeachingTaskClasses.AsNoTracking()
|
||||
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||
.Select(x => x.TeachingTaskId);
|
||||
|
||||
public static IQueryable<ScheduleEntry> ScheduleEntries(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||
Guid academicTermId,
|
||||
int dayOfWeek,
|
||||
int week,
|
||||
int startPeriod,
|
||||
int periodCount) =>
|
||||
db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||
x.SchedulePlan.AcademicTermId == academicTermId &&
|
||||
x.DayOfWeek == dayOfWeek &&
|
||||
x.StartWeek <= week &&
|
||||
x.EndWeek >= week &&
|
||||
x.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < x.StartPeriod + x.PeriodCount)
|
||||
.WhereIn(relatedTaskIds, x => x.TeachingTaskId);
|
||||
|
||||
public static IQueryable<ExperimentSession> ExperimentSessions(
|
||||
AppDbContext db,
|
||||
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||
DateOnly date,
|
||||
int startPeriod,
|
||||
int periodCount) =>
|
||||
db.ExperimentSessions.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExperimentSessionStatus.Scheduled &&
|
||||
x.SessionDate == date &&
|
||||
x.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < x.StartPeriod + x.PeriodCount &&
|
||||
x.ExperimentProject!.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized)
|
||||
.WhereIn(
|
||||
relatedTaskIds,
|
||||
x => x.ExperimentProject!.TeachingTaskId);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
public static class ExperimentGradeAggregationService
|
||||
{
|
||||
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
|
||||
AppDbContext db,
|
||||
Guid teachingTaskId,
|
||||
IReadOnlyCollection<Guid> studentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status ==
|
||||
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
||||
x.ExperimentProject!.TeachingTaskId == teachingTaskId)
|
||||
.OrderBy(x => x.ExperimentProject!.Code)
|
||||
.Select(x => new PublishedExperimentSheet(
|
||||
x.Id,
|
||||
x.ExperimentProject!.Code,
|
||||
x.ExperimentProject.Name,
|
||||
x.ContributionWeight,
|
||||
x.Records.Select(record => new PublishedExperimentScore(
|
||||
record.StudentId,
|
||||
record.TotalScore)).ToList()))
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scores = new Dictionary<Guid, decimal?>();
|
||||
foreach (var studentId in studentIds.Distinct())
|
||||
{
|
||||
decimal weightedTotal = 0;
|
||||
decimal totalWeight = 0;
|
||||
var complete = sheets.Count > 0;
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var score = sheet.Scores.FirstOrDefault(x =>
|
||||
x.StudentId == studentId);
|
||||
if (score?.TotalScore is not decimal totalScore)
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||
totalWeight += sheet.ContributionWeight;
|
||||
}
|
||||
scores[studentId] = complete && totalWeight > 0
|
||||
? Math.Round(
|
||||
weightedTotal / totalWeight,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
}
|
||||
|
||||
return new ExperimentGradeAggregateResult(
|
||||
sheets.Count,
|
||||
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ContributionWeight)).ToList(),
|
||||
scores);
|
||||
}
|
||||
|
||||
private sealed record PublishedExperimentSheet(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
decimal ContributionWeight,
|
||||
List<PublishedExperimentScore> Scores);
|
||||
|
||||
private sealed record PublishedExperimentScore(
|
||||
Guid StudentId,
|
||||
decimal? TotalScore);
|
||||
}
|
||||
|
||||
public sealed record ExperimentGradeAggregateResult(
|
||||
int PublishedProjectCount,
|
||||
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
|
||||
IReadOnlyDictionary<Guid, decimal?> Scores);
|
||||
|
||||
public sealed record ExperimentGradeAggregateProject(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
decimal ContributionWeight);
|
||||
@@ -0,0 +1,45 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
public static class ExperimentGradeCalculator
|
||||
{
|
||||
public static bool AreWeightsValid(
|
||||
decimal contributionWeight,
|
||||
decimal passScore,
|
||||
IEnumerable<ExperimentGradeItem> items)
|
||||
{
|
||||
var itemList = items.ToList();
|
||||
return contributionWeight is > 0 and <= 100 &&
|
||||
passScore is >= 0 and <= 100 &&
|
||||
itemList.Count > 0 &&
|
||||
itemList.All(item => item.Weight is > 0 and <= 100) &&
|
||||
itemList.Sum(item => item.Weight) == 100;
|
||||
}
|
||||
|
||||
public static decimal? CalculateTotal(
|
||||
ExperimentGradeRecord record,
|
||||
IReadOnlyCollection<ExperimentGradeItem> items)
|
||||
{
|
||||
if (record.SafetyViolation ||
|
||||
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
|
||||
return 0;
|
||||
if (record.ParticipationStatus is
|
||||
ExperimentParticipationStatus.Pending or
|
||||
ExperimentParticipationStatus.Excused or
|
||||
ExperimentParticipationStatus.Exempt)
|
||||
return null;
|
||||
|
||||
var scores = record.ItemScores.ToDictionary(
|
||||
x => x.ExperimentGradeItemId);
|
||||
decimal total = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!scores.TryGetValue(item.Id, out var score) ||
|
||||
!score.Score.HasValue)
|
||||
return null;
|
||||
total += score.Score.Value * item.Weight / 100;
|
||||
}
|
||||
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Graduation;
|
||||
|
||||
public sealed record AcademicPlanningCourseSnapshot(
|
||||
Guid CourseId,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
IReadOnlyCollection<Guid> PrerequisiteCourseIds);
|
||||
|
||||
public sealed record AcademicPlanningPrerequisiteIssue(
|
||||
Guid CourseId,
|
||||
Guid PrerequisiteCourseId,
|
||||
int PlannedSemester,
|
||||
int? PrerequisitePlannedSemester);
|
||||
|
||||
public static class AcademicPlanningRules
|
||||
{
|
||||
public const decimal RecommendedSemesterCreditLimit = 24;
|
||||
public const decimal HeavySemesterCreditLimit = 30;
|
||||
|
||||
public static IReadOnlyList<AcademicPlanningPrerequisiteIssue>
|
||||
FindPrerequisiteIssues(
|
||||
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||
IReadOnlySet<Guid> completedCourseIds,
|
||||
IReadOnlySet<Guid> inProgressCourseIds,
|
||||
IReadOnlyDictionary<Guid, int> plannedSemesters)
|
||||
{
|
||||
var issues = new List<AcademicPlanningPrerequisiteIssue>();
|
||||
foreach (var course in courses.Where(x =>
|
||||
plannedSemesters.ContainsKey(x.CourseId)))
|
||||
{
|
||||
var plannedSemester = plannedSemesters[course.CourseId];
|
||||
foreach (var prerequisiteId in course.PrerequisiteCourseIds)
|
||||
{
|
||||
if (completedCourseIds.Contains(prerequisiteId) ||
|
||||
inProgressCourseIds.Contains(prerequisiteId))
|
||||
continue;
|
||||
|
||||
if (!plannedSemesters.TryGetValue(
|
||||
prerequisiteId,
|
||||
out var prerequisiteSemester) ||
|
||||
prerequisiteSemester >= plannedSemester)
|
||||
{
|
||||
issues.Add(new AcademicPlanningPrerequisiteIssue(
|
||||
course.CourseId,
|
||||
prerequisiteId,
|
||||
plannedSemester,
|
||||
plannedSemesters.GetValueOrDefault(prerequisiteId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Guid> SuggestNextSemester(
|
||||
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||
IReadOnlySet<Guid> completedCourseIds,
|
||||
IReadOnlySet<Guid> inProgressCourseIds,
|
||||
int nextSemester,
|
||||
decimal targetCredits = RecommendedSemesterCreditLimit)
|
||||
{
|
||||
var available = courses
|
||||
.Where(x =>
|
||||
!completedCourseIds.Contains(x.CourseId) &&
|
||||
!inProgressCourseIds.Contains(x.CourseId) &&
|
||||
x.RecommendedSemester <= nextSemester &&
|
||||
x.PrerequisiteCourseIds.All(prerequisiteId =>
|
||||
completedCourseIds.Contains(prerequisiteId) ||
|
||||
inProgressCourseIds.Contains(prerequisiteId)))
|
||||
.OrderBy(x => x.Type == CurriculumCourseType.Required ? 0 : 1)
|
||||
.ThenBy(x => x.RecommendedSemester > nextSemester ? 1 : 0)
|
||||
.ThenBy(x => x.RecommendedSemester)
|
||||
.ThenBy(x => x.CourseName)
|
||||
.ToList();
|
||||
|
||||
var selected = new List<Guid>();
|
||||
decimal credits = 0;
|
||||
foreach (var course in available)
|
||||
{
|
||||
var isOverdueRequired =
|
||||
course.Type == CurriculumCourseType.Required &&
|
||||
course.RecommendedSemester <= nextSemester;
|
||||
if (!isOverdueRequired &&
|
||||
selected.Count > 0 &&
|
||||
credits + course.Credits > targetCredits)
|
||||
continue;
|
||||
|
||||
selected.Add(course.CourseId);
|
||||
credits += course.Credits;
|
||||
if (credits >= targetCredits) break;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
public static int EstimateCompletionSemester(
|
||||
int nextSemester,
|
||||
int latestPlannedSemester,
|
||||
decimal remainingCredits,
|
||||
int remainingRequirementCount)
|
||||
{
|
||||
if (remainingCredits <= 0 && remainingRequirementCount <= 0)
|
||||
return Math.Max(nextSemester - 1, latestPlannedSemester);
|
||||
|
||||
var byCredits = (int)Math.Ceiling(
|
||||
Math.Max(remainingCredits, 0) / RecommendedSemesterCreditLimit);
|
||||
var byRequirements = (int)Math.Ceiling(
|
||||
Math.Max(remainingRequirementCount, 0) / 6m);
|
||||
var additionalSemesters = Math.Max(1, Math.Max(byCredits, byRequirements));
|
||||
return Math.Max(nextSemester - 1, latestPlannedSemester) +
|
||||
additionalSemesters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
ObservabilityOptions options,
|
||||
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||
: DbCommandInterceptor
|
||||
{
|
||||
public const string ActivitySourceName = "Jiaowu.Api.Database";
|
||||
public const string MeterName = "Jiaowu.Api.Database";
|
||||
|
||||
private static readonly ActivitySource ActivitySource =
|
||||
new(ActivitySourceName);
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
private static readonly Histogram<double> CommandDuration =
|
||||
Meter.CreateHistogram<double>(
|
||||
"jiaowu.db.command.duration",
|
||||
"ms",
|
||||
"EF Core database command duration");
|
||||
private static readonly Counter<long> SlowCommandCount =
|
||||
Meter.CreateCounter<long>(
|
||||
"jiaowu.db.command.slow",
|
||||
"{command}",
|
||||
"EF Core commands exceeding the configured slow-query threshold");
|
||||
private static readonly Counter<long> FailedCommandCount =
|
||||
Meter.CreateCounter<long>(
|
||||
"jiaowu.db.command.failed",
|
||||
"{command}",
|
||||
"Failed EF Core database commands");
|
||||
|
||||
public override DbDataReader ReaderExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
DbDataReader result)
|
||||
{
|
||||
Observe(command, eventData.Duration, "reader");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<DbDataReader> ReaderExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
DbDataReader result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "reader");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override int NonQueryExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result)
|
||||
{
|
||||
Observe(command, eventData.Duration, "nonquery");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<int> NonQueryExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "nonquery");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override object? ScalarExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
object? result)
|
||||
{
|
||||
Observe(command, eventData.Duration, "scalar");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<object?> ScalarExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
object? result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "scalar");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override void CommandFailed(
|
||||
DbCommand command,
|
||||
CommandErrorEventData eventData) =>
|
||||
Observe(
|
||||
command,
|
||||
eventData.Duration,
|
||||
"failed",
|
||||
eventData.Exception.GetType().Name);
|
||||
|
||||
public override Task CommandFailedAsync(
|
||||
DbCommand command,
|
||||
CommandErrorEventData eventData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(
|
||||
command,
|
||||
eventData.Duration,
|
||||
"failed",
|
||||
eventData.Exception.GetType().Name);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override void CommandCanceled(
|
||||
DbCommand command,
|
||||
CommandEndEventData eventData) =>
|
||||
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||
|
||||
public override Task CommandCanceledAsync(
|
||||
DbCommand command,
|
||||
CommandEndEventData eventData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Observe(
|
||||
DbCommand command,
|
||||
TimeSpan duration,
|
||||
string commandKind,
|
||||
string? errorType = null)
|
||||
{
|
||||
if (!options.Enabled) return;
|
||||
|
||||
var queryName = GetQueryName(command.CommandText);
|
||||
var statementHash = GetStatementHash(command.CommandText);
|
||||
var provider = GetProviderName(command);
|
||||
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "db.system.name", provider },
|
||||
{ "db.operation.name", commandKind },
|
||||
{ "db.query.name", queryName }
|
||||
};
|
||||
if (errorType is not null)
|
||||
tags.Add("error.type", errorType);
|
||||
|
||||
var durationMilliseconds = duration.TotalMilliseconds;
|
||||
CommandDuration.Record(durationMilliseconds, tags);
|
||||
if (errorType is not null)
|
||||
FailedCommandCount.Add(1, tags);
|
||||
|
||||
using var activity = ActivitySource.StartActivity(
|
||||
ActivityKind.Client,
|
||||
Activity.Current?.Context ?? default,
|
||||
startTime: DateTimeOffset.UtcNow - duration,
|
||||
name: queryName);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("db.system.name", provider);
|
||||
activity.SetTag("db.operation.name", commandKind);
|
||||
activity.SetTag("db.query.name", queryName);
|
||||
activity.SetTag("db.statement.hash", statementHash);
|
||||
activity.SetTag(
|
||||
"db.namespace",
|
||||
EmptyToNull(command.Connection?.Database));
|
||||
if (options.IncludeSqlText)
|
||||
{
|
||||
activity.SetTag(
|
||||
"db.query.text",
|
||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||
}
|
||||
if (errorType is not null)
|
||||
{
|
||||
activity.SetTag("error.type", errorType);
|
||||
activity.SetStatus(ActivityStatusCode.Error, errorType);
|
||||
}
|
||||
activity.SetEndTime(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (errorType is not null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Database command failed after {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"error {ErrorType}, trace {TraceId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
errorType,
|
||||
traceId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||
return;
|
||||
|
||||
SlowCommandCount.Add(1, tags);
|
||||
if (options.IncludeSqlText)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}). " +
|
||||
"SQL template: {SqlTemplate}",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId,
|
||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetQueryName(string commandText)
|
||||
{
|
||||
using var reader = new StringReader(commandText);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0) continue;
|
||||
if (trimmed.StartsWith("-- ", StringComparison.Ordinal))
|
||||
return Truncate(trimmed[3..].Trim(), 120);
|
||||
return $"{FirstToken(trimmed)}:{GetStatementHash(commandText)}";
|
||||
}
|
||||
|
||||
return $"unknown:{GetStatementHash(commandText)}";
|
||||
}
|
||||
|
||||
internal static string GetStatementHash(string commandText)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandText));
|
||||
return Convert.ToHexString(bytes.AsSpan(0, 6)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string FirstToken(string value)
|
||||
{
|
||||
var end = value.IndexOfAny([' ', '\t', '\r', '\n', '(']);
|
||||
var token = end < 0 ? value : value[..end];
|
||||
return token.Length == 0
|
||||
? "command"
|
||||
: token.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GetProviderName(DbCommand command)
|
||||
{
|
||||
var typeName = command.GetType().FullName ?? command.GetType().Name;
|
||||
if (typeName.Contains("MySql", StringComparison.OrdinalIgnoreCase))
|
||||
return "mysql";
|
||||
if (typeName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
return "sqlite";
|
||||
return "other_sql";
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
private static string Truncate(string value, int maximumLength) =>
|
||||
value.Length <= maximumLength
|
||||
? value
|
||||
: value[..maximumLength];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class ObservabilityOptions
|
||||
{
|
||||
public const string SectionName = "Observability";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string ServiceName { get; set; } = "jiaowu-api";
|
||||
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
||||
public bool IncludeSqlText { get; set; }
|
||||
public int MaximumSqlTextLength { get; set; } = 2000;
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class PerformanceReportService(
|
||||
HttpClient httpClient,
|
||||
IMemoryCache cache,
|
||||
PerformanceReportingOptions options,
|
||||
ObservabilityOptions observability,
|
||||
ILogger<PerformanceReportService> logger)
|
||||
{
|
||||
public async Task<PerformanceReport> GetAsync(
|
||||
string? range,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rangeSpec = PerformanceRange.TryParse(range);
|
||||
if (rangeSpec is null)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(range),
|
||||
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||
|
||||
if (!options.Enabled ||
|
||||
string.IsNullOrWhiteSpace(options.PrometheusBaseUrl))
|
||||
{
|
||||
return PerformanceReport.NotConfigured(
|
||||
rangeSpec.Key,
|
||||
options.GrafanaBaseUrl);
|
||||
}
|
||||
|
||||
var cacheKey = $"performance-report:{rangeSpec.Key}";
|
||||
if (cache.TryGetValue<PerformanceReport>(cacheKey, out var cached))
|
||||
return cached!;
|
||||
|
||||
PerformanceReport report;
|
||||
try
|
||||
{
|
||||
report = await LoadAsync(rangeSpec, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Performance report source is unavailable for range {Range}.",
|
||||
rangeSpec.Key);
|
||||
report = PerformanceReport.Unavailable(
|
||||
rangeSpec.Key,
|
||||
options.GrafanaBaseUrl);
|
||||
}
|
||||
|
||||
cache.Set(
|
||||
cacheKey,
|
||||
report,
|
||||
TimeSpan.FromSeconds(options.CacheSeconds));
|
||||
return report;
|
||||
}
|
||||
|
||||
private async Task<PerformanceReport> LoadAsync(
|
||||
PerformanceRange range,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var from = now - range.Duration;
|
||||
var requestCountSelector = Selector(
|
||||
options.RequestDurationMetric + "_count",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var requestBucketSelector = Selector(
|
||||
options.RequestDurationMetric + "_bucket",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var errorCountSelector = Selector(
|
||||
options.RequestDurationMetric + "_count",
|
||||
(options.ServiceLabel, "=", observability.ServiceName),
|
||||
("http_response_status_code", "=~", "5.."));
|
||||
var databaseCountSelector = Selector(
|
||||
options.DatabaseDurationMetric + "_count",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var databaseBucketSelector = Selector(
|
||||
options.DatabaseDurationMetric + "_bucket",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var slowDatabaseSelector = Selector(
|
||||
options.SlowDatabaseMetric,
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var failedDatabaseSelector = Selector(
|
||||
options.FailedDatabaseMetric,
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
|
||||
var requestCountTask = QueryScalarAsync(
|
||||
$"sum(increase({requestCountSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var serverErrorCountTask = QueryScalarAsync(
|
||||
$"sum(increase({errorCountSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var requestP95Task = QueryScalarAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||
"* 1000",
|
||||
now,
|
||||
cancellationToken);
|
||||
var databaseP95Task = QueryScalarAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le) (rate({databaseBucketSelector}[{range.RateWindow}])))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var slowCountTask = QueryScalarAsync(
|
||||
$"sum(increase({slowDatabaseSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var failedCountTask = QueryScalarAsync(
|
||||
$"sum(increase({failedDatabaseSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var requestTimelineTask = QueryRangeAsync(
|
||||
$"sum(rate({requestCountSelector}[{range.RateWindow}]))",
|
||||
from,
|
||||
now,
|
||||
range.StepSeconds,
|
||||
cancellationToken);
|
||||
var latencyTimelineTask = QueryRangeAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||
"* 1000",
|
||||
from,
|
||||
now,
|
||||
range.StepSeconds,
|
||||
cancellationToken);
|
||||
var routeLatencyTask = QueryVectorAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le, http_route) (rate({requestBucketSelector}" +
|
||||
$"[{range.RateWindow}]))) * 1000",
|
||||
now,
|
||||
cancellationToken);
|
||||
var routeCountTask = QueryVectorAsync(
|
||||
$"sum by (http_route) (increase({requestCountSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var routeErrorTask = QueryVectorAsync(
|
||||
$"sum by (http_route) (increase({errorCountSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var queryLatencyTask = QueryVectorAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le, db_query_name) (rate({databaseBucketSelector}" +
|
||||
$"[{range.RateWindow}])))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var queryCountTask = QueryVectorAsync(
|
||||
$"sum by (db_query_name) (increase({databaseCountSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var querySlowTask = QueryVectorAsync(
|
||||
$"sum by (db_query_name) (increase({slowDatabaseSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
await Task.WhenAll(
|
||||
requestCountTask,
|
||||
serverErrorCountTask,
|
||||
requestP95Task,
|
||||
databaseP95Task,
|
||||
slowCountTask,
|
||||
failedCountTask,
|
||||
requestTimelineTask,
|
||||
latencyTimelineTask,
|
||||
routeLatencyTask,
|
||||
routeCountTask,
|
||||
routeErrorTask,
|
||||
queryLatencyTask,
|
||||
queryCountTask,
|
||||
querySlowTask);
|
||||
|
||||
var requestCount = await requestCountTask;
|
||||
var serverErrorCount = await serverErrorCountTask;
|
||||
double? errorRate = requestCount is > 0 && serverErrorCount.HasValue
|
||||
? serverErrorCount.Value / requestCount.Value * 100
|
||||
: requestCount == 0
|
||||
? 0
|
||||
: null;
|
||||
var timeline = MergeTimeline(
|
||||
await requestTimelineTask,
|
||||
await latencyTimelineTask);
|
||||
var endpoints = MergeRanking(
|
||||
await routeLatencyTask,
|
||||
await routeCountTask,
|
||||
await routeErrorTask,
|
||||
"http_route");
|
||||
var databaseQueries = MergeRanking(
|
||||
await queryLatencyTask,
|
||||
await queryCountTask,
|
||||
await querySlowTask,
|
||||
"db_query_name");
|
||||
|
||||
return new PerformanceReport(
|
||||
"ready",
|
||||
range.Key,
|
||||
from,
|
||||
now,
|
||||
DateTime.UtcNow,
|
||||
"prometheus",
|
||||
EmptyToNull(options.GrafanaBaseUrl),
|
||||
null,
|
||||
new PerformanceHeadline(
|
||||
Round(requestCount),
|
||||
Round(await requestP95Task),
|
||||
Round(errorRate),
|
||||
Round(await databaseP95Task),
|
||||
Round(await slowCountTask),
|
||||
Round(await failedCountTask)),
|
||||
timeline,
|
||||
endpoints,
|
||||
databaseQueries);
|
||||
}
|
||||
|
||||
private async Task<double?> QueryScalarAsync(
|
||||
string query,
|
||||
DateTime time,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vector = await QueryVectorAsync(
|
||||
query,
|
||||
time,
|
||||
cancellationToken);
|
||||
return vector.FirstOrDefault()?.Value;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<PrometheusSample>> QueryVectorAsync(
|
||||
string query,
|
||||
DateTime time,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
"api/v1/query",
|
||||
("query", query),
|
||||
("time", ToUnixSeconds(time).ToString(
|
||||
CultureInfo.InvariantCulture)));
|
||||
using var document = await SendAsync(uri, cancellationToken);
|
||||
var data = document.RootElement.GetProperty("data");
|
||||
var result = data.GetProperty("result");
|
||||
var samples = new List<PrometheusSample>();
|
||||
foreach (var item in result.EnumerateArray())
|
||||
{
|
||||
var labels = ReadLabels(item.GetProperty("metric"));
|
||||
if (!TryReadValue(item.GetProperty("value"), out var value))
|
||||
continue;
|
||||
samples.Add(new PrometheusSample(labels, value));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<PerformanceSeriesPoint>> QueryRangeAsync(
|
||||
string query,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int stepSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
"api/v1/query_range",
|
||||
("query", query),
|
||||
("start", ToUnixSeconds(from).ToString(
|
||||
CultureInfo.InvariantCulture)),
|
||||
("end", ToUnixSeconds(to).ToString(
|
||||
CultureInfo.InvariantCulture)),
|
||||
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)));
|
||||
using var document = await SendAsync(uri, cancellationToken);
|
||||
var result = document.RootElement
|
||||
.GetProperty("data")
|
||||
.GetProperty("result");
|
||||
var first = result.EnumerateArray().FirstOrDefault();
|
||||
if (first.ValueKind == JsonValueKind.Undefined ||
|
||||
!first.TryGetProperty("values", out var values))
|
||||
return [];
|
||||
|
||||
var points = new List<PerformanceSeriesPoint>();
|
||||
foreach (var value in values.EnumerateArray())
|
||||
{
|
||||
if (!TryReadValue(value, out var measurement)) continue;
|
||||
var timestamp = value[0].GetDouble();
|
||||
points.Add(new PerformanceSeriesPoint(
|
||||
DateTimeOffset.FromUnixTimeMilliseconds(
|
||||
checked((long)(timestamp * 1000))).UtcDateTime,
|
||||
measurement));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private async Task<JsonDocument> SendAsync(
|
||||
Uri uri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
if (!string.IsNullOrWhiteSpace(options.BearerToken))
|
||||
{
|
||||
request.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", options.BearerToken);
|
||||
}
|
||||
using var response = await httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(
|
||||
cancellationToken);
|
||||
var document = await JsonDocument.ParseAsync(
|
||||
stream,
|
||||
cancellationToken: cancellationToken);
|
||||
if (!document.RootElement.TryGetProperty("status", out var status) ||
|
||||
status.GetString() != "success")
|
||||
{
|
||||
document.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
"Prometheus 返回了非成功查询状态。");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private Uri BuildUri(
|
||||
string relativePath,
|
||||
params (string Key, string Value)[] parameters)
|
||||
{
|
||||
var baseUri = new Uri(
|
||||
options.PrometheusBaseUrl.TrimEnd('/') + "/",
|
||||
UriKind.Absolute);
|
||||
var query = string.Join(
|
||||
"&",
|
||||
parameters.Select(parameter =>
|
||||
$"{Uri.EscapeDataString(parameter.Key)}=" +
|
||||
$"{Uri.EscapeDataString(parameter.Value)}"));
|
||||
return new Uri(baseUri, $"{relativePath}?{query}");
|
||||
}
|
||||
|
||||
private static string Selector(
|
||||
string metric,
|
||||
params (string Label, string Operator, string Value)[] filters)
|
||||
{
|
||||
var matchers = string.Join(
|
||||
",",
|
||||
filters.Select(filter =>
|
||||
$"{filter.Label}{filter.Operator}\"" +
|
||||
$"{EscapePrometheusValue(filter.Value)}\""));
|
||||
return $"{metric}{{{matchers}}}";
|
||||
}
|
||||
|
||||
private static string EscapePrometheusValue(string value) =>
|
||||
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||
.Replace("\r", "\\r", StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ReadLabels(
|
||||
JsonElement metric)
|
||||
{
|
||||
var result = new Dictionary<string, string>(
|
||||
StringComparer.Ordinal);
|
||||
foreach (var property in metric.EnumerateObject())
|
||||
result[property.Name] = property.Value.GetString() ?? "";
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryReadValue(
|
||||
JsonElement value,
|
||||
out double measurement)
|
||||
{
|
||||
measurement = 0;
|
||||
if (value.ValueKind != JsonValueKind.Array ||
|
||||
value.GetArrayLength() < 2)
|
||||
return false;
|
||||
var raw = value[1].GetString();
|
||||
return double.TryParse(
|
||||
raw,
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out measurement) &&
|
||||
double.IsFinite(measurement);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PerformanceTimelinePoint> MergeTimeline(
|
||||
IReadOnlyList<PerformanceSeriesPoint> requestRate,
|
||||
IReadOnlyList<PerformanceSeriesPoint> latency)
|
||||
{
|
||||
var points = new SortedDictionary<DateTime, PerformanceTimelinePoint>();
|
||||
foreach (var point in requestRate)
|
||||
{
|
||||
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||
point.Timestamp,
|
||||
Math.Round(point.Value, 3),
|
||||
null);
|
||||
}
|
||||
foreach (var point in latency)
|
||||
{
|
||||
points.TryGetValue(point.Timestamp, out var existing);
|
||||
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||
point.Timestamp,
|
||||
existing?.RequestsPerSecond,
|
||||
Math.Round(point.Value, 2));
|
||||
}
|
||||
return points.Values.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PerformanceRankingItem> MergeRanking(
|
||||
IReadOnlyList<PrometheusSample> latency,
|
||||
IReadOnlyList<PrometheusSample> count,
|
||||
IReadOnlyList<PrometheusSample> exceptional,
|
||||
string label)
|
||||
{
|
||||
var names = latency
|
||||
.Concat(count)
|
||||
.Concat(exceptional)
|
||||
.Select(item => item.Labels.GetValueOrDefault(label))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var items = names.Select(name =>
|
||||
{
|
||||
var latencyValue = FindValue(latency, label, name);
|
||||
var countValue = FindValue(count, label, name);
|
||||
var exceptionalValue = FindValue(exceptional, label, name);
|
||||
return new PerformanceRankingItem(
|
||||
name!,
|
||||
Round(latencyValue),
|
||||
Round(countValue),
|
||||
Round(exceptionalValue));
|
||||
});
|
||||
return items
|
||||
.OrderByDescending(item => item.P95Milliseconds ?? -1)
|
||||
.ThenByDescending(item => item.RequestCount ?? -1)
|
||||
.Take(10)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static double? FindValue(
|
||||
IReadOnlyList<PrometheusSample> samples,
|
||||
string label,
|
||||
string? name) =>
|
||||
samples.FirstOrDefault(item =>
|
||||
item.Labels.GetValueOrDefault(label) == name)?.Value;
|
||||
|
||||
private static double ToUnixSeconds(DateTime value) =>
|
||||
new DateTimeOffset(
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
|
||||
/ 1000d;
|
||||
|
||||
private static double? Round(double? value) =>
|
||||
value.HasValue && double.IsFinite(value.Value)
|
||||
? Math.Round(value.Value, 2)
|
||||
: null;
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
private sealed record PrometheusSample(
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
double Value);
|
||||
|
||||
private sealed record PerformanceSeriesPoint(
|
||||
DateTime Timestamp,
|
||||
double Value);
|
||||
}
|
||||
|
||||
public sealed record PerformanceHeadline(
|
||||
double? RequestCount,
|
||||
double? RequestP95Milliseconds,
|
||||
double? ServerErrorRatePercent,
|
||||
double? DatabaseP95Milliseconds,
|
||||
double? SlowDatabaseCommandCount,
|
||||
double? FailedDatabaseCommandCount);
|
||||
|
||||
public sealed record PerformanceTimelinePoint(
|
||||
DateTime Timestamp,
|
||||
double? RequestsPerSecond,
|
||||
double? RequestP95Milliseconds);
|
||||
|
||||
public sealed record PerformanceRankingItem(
|
||||
string Name,
|
||||
double? P95Milliseconds,
|
||||
double? RequestCount,
|
||||
double? ExceptionalCount);
|
||||
|
||||
public sealed record PerformanceReport(
|
||||
string Status,
|
||||
string Range,
|
||||
DateTime? From,
|
||||
DateTime? To,
|
||||
DateTime GeneratedAt,
|
||||
string DataSource,
|
||||
string? DashboardUrl,
|
||||
string? Detail,
|
||||
PerformanceHeadline? Headline,
|
||||
IReadOnlyList<PerformanceTimelinePoint> Timeline,
|
||||
IReadOnlyList<PerformanceRankingItem> Endpoints,
|
||||
IReadOnlyList<PerformanceRankingItem> DatabaseQueries)
|
||||
{
|
||||
public static PerformanceReport NotConfigured(
|
||||
string range,
|
||||
string? dashboardUrl) =>
|
||||
Empty(
|
||||
"not_configured",
|
||||
range,
|
||||
dashboardUrl,
|
||||
"尚未配置 Prometheus 数据源。请先部署指标存储并设置 " +
|
||||
"PerformanceReporting__PrometheusBaseUrl。");
|
||||
|
||||
public static PerformanceReport Unavailable(
|
||||
string range,
|
||||
string? dashboardUrl) =>
|
||||
Empty(
|
||||
"unavailable",
|
||||
range,
|
||||
dashboardUrl,
|
||||
"性能数据源暂时不可用。系统业务不受影响,请检查 Prometheus 与网络配置。");
|
||||
|
||||
private static PerformanceReport Empty(
|
||||
string status,
|
||||
string range,
|
||||
string? dashboardUrl,
|
||||
string detail) =>
|
||||
new(
|
||||
status,
|
||||
range,
|
||||
null,
|
||||
null,
|
||||
DateTime.UtcNow,
|
||||
"prometheus",
|
||||
string.IsNullOrWhiteSpace(dashboardUrl) ? null : dashboardUrl,
|
||||
detail,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[]);
|
||||
}
|
||||
|
||||
internal sealed record PerformanceRange(
|
||||
string Key,
|
||||
TimeSpan Duration,
|
||||
string PrometheusRange,
|
||||
string RateWindow,
|
||||
int StepSeconds)
|
||||
{
|
||||
public static PerformanceRange? TryParse(string? value) =>
|
||||
value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"15m" => new("15m", TimeSpan.FromMinutes(15), "15m", "1m", 30),
|
||||
"1h" => new("1h", TimeSpan.FromHours(1), "1h", "5m", 60),
|
||||
"24h" => new("24h", TimeSpan.FromHours(24), "24h", "15m", 900),
|
||||
"7d" => new("7d", TimeSpan.FromDays(7), "7d", "1h", 3600),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed partial class PerformanceReportingOptions
|
||||
{
|
||||
public const string SectionName = "PerformanceReporting";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string PrometheusBaseUrl { get; set; } = "";
|
||||
public string BearerToken { get; set; } = "";
|
||||
public string GrafanaBaseUrl { get; set; } = "";
|
||||
public int CacheSeconds { get; set; } = 30;
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
public string ServiceLabel { get; set; } = "service_name";
|
||||
public string RequestDurationMetric { get; set; } =
|
||||
"http_server_request_duration_seconds";
|
||||
public string DatabaseDurationMetric { get; set; } =
|
||||
"jiaowu_db_command_duration_milliseconds";
|
||||
public string SlowDatabaseMetric { get; set; } =
|
||||
"jiaowu_db_command_slow_total";
|
||||
public string FailedDatabaseMetric { get; set; } =
|
||||
"jiaowu_db_command_failed_total";
|
||||
|
||||
public static bool IsMetricOrLabelName(string value) =>
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
PrometheusNamePattern().IsMatch(value);
|
||||
|
||||
[GeneratedRegex("^[a-zA-Z_:][a-zA-Z0-9_:]*$")]
|
||||
private static partial Regex PrometheusNamePattern();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public sealed class OfficialDocumentOptions
|
||||
{
|
||||
public const string SectionName = "OfficialDocuments";
|
||||
|
||||
public string InstitutionName { get; set; } = "明序大学";
|
||||
public string IssuingOffice { get; set; } = "教务处";
|
||||
public string DocumentNumberPrefix { get; set; } = "MXU";
|
||||
public string? PublicBaseUrl { get; set; }
|
||||
public string? FontPath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System.Security.Cryptography;
|
||||
using QRCoder;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public interface IOfficialDocumentPdfGenerator
|
||||
{
|
||||
GeneratedOfficialDocument Generate(
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl);
|
||||
}
|
||||
|
||||
public sealed class OfficialDocumentPdfGenerator(
|
||||
OfficialDocumentOptions options) : IOfficialDocumentPdfGenerator
|
||||
{
|
||||
private const float PageWidth = 595;
|
||||
private const float PageHeight = 842;
|
||||
private const float Margin = 44;
|
||||
private const float ContentWidth = PageWidth - Margin * 2;
|
||||
private static readonly SKColor Ink = new(31, 48, 65);
|
||||
private static readonly SKColor Muted = new(91, 107, 120);
|
||||
private static readonly SKColor Accent = new(26, 91, 82);
|
||||
private static readonly SKColor Pale = new(236, 244, 242);
|
||||
private static readonly SKColor Rule = new(208, 219, 220);
|
||||
|
||||
public GeneratedOfficialDocument Generate(
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl)
|
||||
{
|
||||
using var typeface = ResolveTypeface();
|
||||
using var stream = new MemoryStream();
|
||||
using (var document = SKDocument.CreatePdf(stream))
|
||||
{
|
||||
if (snapshot.Type == Domain.Academic.OfficialDocumentType.Transcript)
|
||||
DrawTranscript(document, typeface, snapshot, verificationUrl);
|
||||
else
|
||||
DrawStatusCertificate(document, typeface, snapshot, verificationUrl);
|
||||
document.Close();
|
||||
}
|
||||
|
||||
var content = stream.ToArray();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
|
||||
return new GeneratedOfficialDocument(content, hash);
|
||||
}
|
||||
|
||||
private void DrawTranscript(
|
||||
SKDocument document,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl)
|
||||
{
|
||||
const int rowsPerPage = 19;
|
||||
var pageCount = Math.Max(1, (int)Math.Ceiling(snapshot.Grades.Count / (double)rowsPerPage));
|
||||
for (var pageIndex = 0; pageIndex < pageCount; pageIndex++)
|
||||
{
|
||||
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||
DrawPageFrame(canvas);
|
||||
DrawHeader(canvas, typeface, snapshot, "官方电子成绩单", "OFFICIAL ACADEMIC TRANSCRIPT");
|
||||
|
||||
var y = 145f;
|
||||
if (pageIndex == 0)
|
||||
{
|
||||
y = DrawStudentSummary(canvas, typeface, snapshot.Student, y);
|
||||
y = DrawTranscriptSummary(canvas, typeface, snapshot, y + 12);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawText(canvas, typeface, $"{snapshot.Student.Name} · {snapshot.Student.StudentNumber}",
|
||||
Margin, y, 10, Muted);
|
||||
y += 20;
|
||||
}
|
||||
|
||||
var rows = snapshot.Grades.Skip(pageIndex * rowsPerPage).Take(rowsPerPage).ToArray();
|
||||
y = DrawGradeTable(canvas, typeface, rows, y + 14);
|
||||
|
||||
if (pageIndex == pageCount - 1)
|
||||
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, Math.Min(y + 18, 685));
|
||||
|
||||
DrawFooter(canvas, typeface, snapshot, pageIndex + 1, pageCount);
|
||||
document.EndPage();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawStatusCertificate(
|
||||
SKDocument document,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl)
|
||||
{
|
||||
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||
DrawPageFrame(canvas);
|
||||
DrawHeader(canvas, typeface, snapshot, "学籍状态证明", "CERTIFICATE OF STUDENT STATUS");
|
||||
|
||||
var student = snapshot.Student;
|
||||
var statusText = student.Status switch
|
||||
{
|
||||
"在读" => "现为我校在读学生",
|
||||
"休学" => "现为我校注册学生,当前处于休学状态",
|
||||
"已毕业" => "曾在我校就读,现已毕业",
|
||||
"退学" => "曾在我校就读,现已办理退学",
|
||||
_ => $"在我校的学籍状态为“{student.Status}”"
|
||||
};
|
||||
|
||||
DrawText(canvas, typeface, "证 明", PageWidth / 2, 215, 25, Ink, SKTextAlign.Center, true);
|
||||
var body = $"兹证明,{student.Name}(学号:{student.StudentNumber})," +
|
||||
$"于 {student.EnrollmentDate:yyyy年M月d日} 入学," +
|
||||
$"就读于{student.CollegeName}{student.MajorName}专业,行政班级为{student.ClassName}," +
|
||||
$"{statusText}。";
|
||||
var y = 285f;
|
||||
foreach (var line in WrapText(typeface, body, 16, 448))
|
||||
{
|
||||
DrawText(canvas, typeface, line, 74, y, 16, Ink);
|
||||
y += 32;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.Purpose))
|
||||
{
|
||||
DrawText(canvas, typeface, $"用途:{snapshot.Purpose}", 74, y + 14, 11, Muted);
|
||||
y += 30;
|
||||
}
|
||||
|
||||
DrawText(canvas, typeface, "特此证明。", 74, y + 28, 16, Ink);
|
||||
DrawText(canvas, typeface, snapshot.InstitutionName, 435, y + 100, 14, Ink,
|
||||
SKTextAlign.Center, true);
|
||||
DrawText(canvas, typeface, snapshot.IssuingOffice, 435, y + 126, 12, Ink,
|
||||
SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, snapshot.IssuedAt.ToLocalTime().ToString("yyyy年M月d日"),
|
||||
435, y + 150, 11, Muted, SKTextAlign.Center);
|
||||
|
||||
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, 625);
|
||||
DrawFooter(canvas, typeface, snapshot, 1, 1);
|
||||
document.EndPage();
|
||||
}
|
||||
|
||||
private static void DrawPageFrame(SKCanvas canvas)
|
||||
{
|
||||
canvas.Clear(SKColors.White);
|
||||
using var border = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.4f };
|
||||
using var inner = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
|
||||
canvas.DrawRect(22, 22, PageWidth - 44, PageHeight - 44, border);
|
||||
canvas.DrawRect(27, 27, PageWidth - 54, PageHeight - 54, inner);
|
||||
}
|
||||
|
||||
private static void DrawHeader(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string title,
|
||||
string englishTitle)
|
||||
{
|
||||
DrawText(canvas, typeface, snapshot.InstitutionName, Margin, 66, 16, Accent, bold: true);
|
||||
DrawText(canvas, typeface, snapshot.IssuingOffice, PageWidth - Margin, 66, 10, Muted,
|
||||
SKTextAlign.Right);
|
||||
DrawText(canvas, typeface, title, PageWidth / 2, 99, 23, Ink, SKTextAlign.Center, true);
|
||||
DrawText(canvas, typeface, englishTitle, PageWidth / 2, 119, 8.5f, Muted,
|
||||
SKTextAlign.Center);
|
||||
using var rule = new SKPaint { Color = Accent, StrokeWidth = 1.2f };
|
||||
canvas.DrawLine(Margin, 132, PageWidth - Margin, 132, rule);
|
||||
}
|
||||
|
||||
private static float DrawStudentSummary(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialStudentSnapshot student,
|
||||
float y)
|
||||
{
|
||||
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 86), 5, 5, fill);
|
||||
DrawLabelValue(canvas, typeface, "姓名", student.Name, Margin + 15, y + 24);
|
||||
DrawLabelValue(canvas, typeface, "学号", student.StudentNumber, 210, y + 24);
|
||||
DrawLabelValue(canvas, typeface, "学籍状态", student.Status, 404, y + 24);
|
||||
DrawLabelValue(canvas, typeface, "学院", student.CollegeName, Margin + 15, y + 57);
|
||||
DrawLabelValue(canvas, typeface, "专业", student.MajorName, 210, y + 57);
|
||||
DrawLabelValue(canvas, typeface, "班级", student.ClassName, 404, y + 57);
|
||||
return y + 86;
|
||||
}
|
||||
|
||||
private static float DrawTranscriptSummary(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
float y)
|
||||
{
|
||||
var values = new[]
|
||||
{
|
||||
("课程门数", snapshot.Grades.Count.ToString()),
|
||||
("修读学分", FormatDecimal(snapshot.TotalCredits)),
|
||||
("获得学分", FormatDecimal(snapshot.EarnedCredits)),
|
||||
("平均绩点", snapshot.GradePointAverage?.ToString("0.00") ?? "-")
|
||||
};
|
||||
var width = ContentWidth / values.Length;
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
var center = Margin + width * i + width / 2;
|
||||
DrawText(canvas, typeface, values[i].Item1, center, y + 12, 8.5f, Muted,
|
||||
SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, values[i].Item2, center, y + 33, 15, Accent,
|
||||
SKTextAlign.Center, true);
|
||||
}
|
||||
return y + 42;
|
||||
}
|
||||
|
||||
private static float DrawGradeTable(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
IReadOnlyList<OfficialTranscriptRow> rows,
|
||||
float y)
|
||||
{
|
||||
var widths = new[] { 96f, 64f, 160f, 48f, 55f, 45f, 39f };
|
||||
var headers = new[] { "学期", "课程代码", "课程名称", "学分", "成绩", "绩点", "状态" };
|
||||
const float headerHeight = 27;
|
||||
const float rowHeight = 24;
|
||||
using var headerPaint = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
|
||||
using var stripe = new SKPaint { Color = new SKColor(247, 249, 249), Style = SKPaintStyle.Fill };
|
||||
using var rule = new SKPaint { Color = Rule, StrokeWidth = .5f };
|
||||
canvas.DrawRect(Margin, y, ContentWidth, headerHeight, headerPaint);
|
||||
|
||||
var x = Margin;
|
||||
for (var i = 0; i < headers.Length; i++)
|
||||
{
|
||||
DrawText(canvas, typeface, headers[i], x + widths[i] / 2, y + 18, 8.5f,
|
||||
SKColors.White, SKTextAlign.Center, true);
|
||||
x += widths[i];
|
||||
}
|
||||
|
||||
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
|
||||
{
|
||||
var row = rows[rowIndex];
|
||||
var rowY = y + headerHeight + rowIndex * rowHeight;
|
||||
if (rowIndex % 2 == 1)
|
||||
canvas.DrawRect(Margin, rowY, ContentWidth, rowHeight, stripe);
|
||||
canvas.DrawLine(Margin, rowY + rowHeight, PageWidth - Margin, rowY + rowHeight, rule);
|
||||
var values = new[]
|
||||
{
|
||||
row.AcademicTerm,
|
||||
row.CourseCode,
|
||||
Ellipsize(typeface, row.CourseName, 8.5f, widths[2] - 8),
|
||||
FormatDecimal(row.Credits),
|
||||
row.Score?.ToString("0.#") ?? "-",
|
||||
row.GradePoint?.ToString("0.0#") ?? "-",
|
||||
row.ExamStatus
|
||||
};
|
||||
x = Margin;
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
DrawText(canvas, typeface, values[i], x + widths[i] / 2, rowY + 16, 8.5f,
|
||||
Ink, SKTextAlign.Center);
|
||||
x += widths[i];
|
||||
}
|
||||
}
|
||||
return y + headerHeight + rows.Count * rowHeight;
|
||||
}
|
||||
|
||||
private static void DrawVerificationBlock(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
string verificationUrl,
|
||||
float y)
|
||||
{
|
||||
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 84), 5, 5, fill);
|
||||
DrawQrCode(canvas, verificationUrl, Margin + 10, y + 7, 70);
|
||||
DrawText(canvas, typeface, "扫码验真", Margin + 94, y + 22, 11, Accent, bold: true);
|
||||
DrawText(canvas, typeface, $"凭证编号:{snapshot.DocumentNumber}", Margin + 94, y + 43, 9.5f, Ink);
|
||||
DrawText(canvas, typeface, "二维码指向本系统公开验真页;失效或重签后状态会实时更新。",
|
||||
Margin + 94, y + 62, 8.5f, Muted);
|
||||
}
|
||||
|
||||
private static void DrawFooter(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
OfficialDocumentSnapshot snapshot,
|
||||
int page,
|
||||
int pageCount)
|
||||
{
|
||||
using var rule = new SKPaint { Color = Rule, StrokeWidth = .6f };
|
||||
canvas.DrawLine(Margin, 790, PageWidth - Margin, 790, rule);
|
||||
DrawText(canvas, typeface,
|
||||
$"签发:{snapshot.IssuedByName} · {snapshot.IssuedAt.ToLocalTime():yyyy-MM-dd HH:mm}",
|
||||
Margin, 807, 7.5f, Muted);
|
||||
DrawText(canvas, typeface, $"第 {page} / {pageCount} 页", PageWidth - Margin, 807, 7.5f,
|
||||
Muted, SKTextAlign.Right);
|
||||
}
|
||||
|
||||
private static void DrawLabelValue(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
string label,
|
||||
string value,
|
||||
float x,
|
||||
float y)
|
||||
{
|
||||
DrawText(canvas, typeface, label, x, y, 8, Muted);
|
||||
DrawText(canvas, typeface, value, x + 42, y, 10, Ink, bold: true);
|
||||
}
|
||||
|
||||
private static void DrawQrCode(SKCanvas canvas, string content, float x, float y, float size)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
|
||||
var modules = data.ModuleMatrix;
|
||||
const int quiet = 4;
|
||||
var moduleSize = size / (modules.Count + quiet * 2);
|
||||
using var paint = new SKPaint { Color = Ink, Style = SKPaintStyle.Fill, IsAntialias = false };
|
||||
for (var row = 0; row < modules.Count; row++)
|
||||
for (var column = 0; column < modules[row].Count; column++)
|
||||
{
|
||||
if (!modules[row][column]) continue;
|
||||
canvas.DrawRect(
|
||||
x + (column + quiet) * moduleSize,
|
||||
y + (row + quiet) * moduleSize,
|
||||
moduleSize + .08f,
|
||||
moduleSize + .08f,
|
||||
paint);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawText(
|
||||
SKCanvas canvas,
|
||||
SKTypeface typeface,
|
||||
string text,
|
||||
float x,
|
||||
float y,
|
||||
float size,
|
||||
SKColor color,
|
||||
SKTextAlign align = SKTextAlign.Left,
|
||||
bool bold = false)
|
||||
{
|
||||
using var font = new SKFont(typeface, size) { Embolden = bold };
|
||||
using var paint = new SKPaint { Color = color, IsAntialias = true };
|
||||
canvas.DrawText(text, x, y, align, font, paint);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> WrapText(
|
||||
SKTypeface typeface,
|
||||
string text,
|
||||
float size,
|
||||
float maxWidth)
|
||||
{
|
||||
using var font = new SKFont(typeface, size);
|
||||
var lines = new List<string>();
|
||||
var current = "";
|
||||
foreach (var character in text)
|
||||
{
|
||||
var candidate = current + character;
|
||||
if (current.Length > 0 && font.MeasureText(candidate) > maxWidth)
|
||||
{
|
||||
var breakIndex = current.LastIndexOfAny([' ', ',', '。', ';', ':', '、']);
|
||||
if (breakIndex >= current.Length / 2)
|
||||
{
|
||||
lines.Add(current[..(breakIndex + 1)].TrimEnd());
|
||||
current = current[(breakIndex + 1)..].TrimStart() + character;
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add(current);
|
||||
current = character.ToString();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static string Ellipsize(
|
||||
SKTypeface typeface,
|
||||
string text,
|
||||
float size,
|
||||
float maxWidth)
|
||||
{
|
||||
using var font = new SKFont(typeface, size);
|
||||
if (font.MeasureText(text) <= maxWidth) return text;
|
||||
var value = text;
|
||||
while (value.Length > 1 && font.MeasureText(value + "…") > maxWidth)
|
||||
value = value[..^1];
|
||||
return value + "…";
|
||||
}
|
||||
|
||||
private SKTypeface ResolveTypeface()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
options.FontPath,
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
|
||||
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
|
||||
};
|
||||
foreach (var path in candidates.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
{
|
||||
if (!File.Exists(path)) continue;
|
||||
var typeface = SKTypeface.FromFile(path);
|
||||
if (typeface is not null) return typeface;
|
||||
}
|
||||
|
||||
var fallback = SKTypeface.FromFamilyName("Microsoft YaHei") ??
|
||||
SKTypeface.FromFamilyName("Noto Sans CJK SC");
|
||||
return fallback ?? throw new InvalidOperationException(
|
||||
"未找到可用于官方 PDF 的中文字体。请配置 OfficialDocuments:FontPath。");
|
||||
}
|
||||
|
||||
private static string FormatDecimal(decimal value) => value.ToString("0.##");
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public sealed class OfficialDocumentService(
|
||||
AppDbContext db,
|
||||
OfficialDocumentOptions options,
|
||||
IOfficialDocumentPdfGenerator pdfGenerator)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<OfficialDocument> CreateAsync(
|
||||
Guid studentId,
|
||||
OfficialDocumentType type,
|
||||
string? purpose,
|
||||
Guid issuedByUserId,
|
||||
string issuedByName,
|
||||
string verificationBaseUrl,
|
||||
Guid? reissuedFromDocumentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Where(x => x.Id == studentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.Gender,
|
||||
x.EnrollmentYear,
|
||||
x.EnrollmentDate,
|
||||
x.Status,
|
||||
CollegeName = x.AdministrativeClass!.Major!.College!.Name,
|
||||
MajorName = x.AdministrativeClass.Major.Name,
|
||||
ClassName = x.AdministrativeClass.Name
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new OfficialDocumentValidationException("学生档案不存在。");
|
||||
|
||||
var grades = type == OfficialDocumentType.Transcript
|
||||
? await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.OrderBy(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
|
||||
.Select(x => new OfficialTranscriptRow(
|
||||
x.GradeSheet!.TeachingTask!.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTask.Course!.Code,
|
||||
x.GradeSheet.TeachingTask.Course.Name,
|
||||
x.GradeSheet.TeachingTask.Course.Credits,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
GradeExamStatusLabel(x.ExamStatus)))
|
||||
.ToListAsync(cancellationToken)
|
||||
: [];
|
||||
|
||||
if (type == OfficialDocumentType.Transcript && grades.Count == 0)
|
||||
throw new OfficialDocumentValidationException("该学生暂无已发布成绩,不能签发官方成绩单。");
|
||||
|
||||
var issuedAt = DateTime.UtcNow;
|
||||
var documentNumber = CreateDocumentNumber(type, issuedAt);
|
||||
var verificationCode = CreateVerificationCode();
|
||||
var studentSnapshot = new OfficialStudentSnapshot(
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
GenderLabel(student.Gender),
|
||||
student.CollegeName,
|
||||
student.MajorName,
|
||||
student.ClassName,
|
||||
student.EnrollmentYear,
|
||||
student.EnrollmentDate,
|
||||
StudentStatusLabel(student.Status));
|
||||
var totalCredits = grades.Sum(x => x.Credits);
|
||||
var passedGrades = grades
|
||||
.Where(x => x.Score >= 60 || x.ExamStatus == "免修")
|
||||
.ToArray();
|
||||
var gpaGrades = grades.Where(x => x.GradePoint.HasValue).ToArray();
|
||||
var gpaCredits = gpaGrades.Sum(x => x.Credits);
|
||||
var gpa = gpaCredits > 0
|
||||
? gpaGrades.Sum(x => x.GradePoint!.Value * x.Credits) / gpaCredits
|
||||
: (decimal?)null;
|
||||
var snapshot = new OfficialDocumentSnapshot(
|
||||
options.InstitutionName,
|
||||
options.IssuingOffice,
|
||||
documentNumber,
|
||||
type,
|
||||
issuedAt,
|
||||
issuedByName,
|
||||
string.IsNullOrWhiteSpace(purpose) ? null : purpose.Trim(),
|
||||
studentSnapshot,
|
||||
grades,
|
||||
totalCredits,
|
||||
passedGrades.Sum(x => x.Credits),
|
||||
gpa);
|
||||
var verificationUrl = $"{verificationBaseUrl.TrimEnd('/')}/verify/{verificationCode}";
|
||||
var generated = pdfGenerator.Generate(snapshot, verificationUrl);
|
||||
|
||||
return new OfficialDocument
|
||||
{
|
||||
DocumentNumber = documentNumber,
|
||||
VerificationCodeHash = HashVerificationCode(verificationCode),
|
||||
Type = type,
|
||||
Status = OfficialDocumentStatus.Valid,
|
||||
StudentId = studentId,
|
||||
IssuedByUserId = issuedByUserId,
|
||||
IssuedAt = issuedAt,
|
||||
Purpose = snapshot.Purpose,
|
||||
SnapshotJson = JsonSerializer.Serialize(snapshot, SnapshotJsonOptions),
|
||||
PdfContent = generated.Content,
|
||||
PdfSha256 = generated.Sha256,
|
||||
ReissuedFromDocumentId = reissuedFromDocumentId
|
||||
};
|
||||
}
|
||||
|
||||
public static string HashVerificationCode(string verificationCode) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(verificationCode)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
public static OfficialDocumentSnapshot DeserializeSnapshot(string json) =>
|
||||
JsonSerializer.Deserialize<OfficialDocumentSnapshot>(json, SnapshotJsonOptions)
|
||||
?? throw new InvalidOperationException("官方凭证快照无法读取。");
|
||||
|
||||
private string CreateDocumentNumber(OfficialDocumentType type, DateTime issuedAt)
|
||||
{
|
||||
var typeCode = type == OfficialDocumentType.Transcript ? "TR" : "SC";
|
||||
var random = Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
|
||||
return $"{options.DocumentNumberPrefix}-{typeCode}-{issuedAt:yyyyMMdd}-{random}";
|
||||
}
|
||||
|
||||
private static string CreateVerificationCode() =>
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
|
||||
|
||||
private static string GenderLabel(Gender value) => value switch
|
||||
{
|
||||
Gender.Male => "男",
|
||||
Gender.Female => "女",
|
||||
_ => "未登记"
|
||||
};
|
||||
|
||||
private static string StudentStatusLabel(StudentStatus value) => value switch
|
||||
{
|
||||
StudentStatus.Active => "在读",
|
||||
StudentStatus.Suspended => "休学",
|
||||
StudentStatus.Graduated => "已毕业",
|
||||
StudentStatus.Withdrawn => "退学",
|
||||
_ => "未知"
|
||||
};
|
||||
|
||||
private static string GradeExamStatusLabel(GradeExamStatus value) => value switch
|
||||
{
|
||||
GradeExamStatus.Normal => "正常",
|
||||
GradeExamStatus.Absent => "缺考",
|
||||
GradeExamStatus.Deferred => "缓考",
|
||||
GradeExamStatus.Exempt => "免修",
|
||||
GradeExamStatus.Makeup => "补考",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class OfficialDocumentValidationException(string message) : Exception(message);
|
||||
@@ -0,0 +1,41 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
|
||||
public sealed record OfficialDocumentSnapshot(
|
||||
string InstitutionName,
|
||||
string IssuingOffice,
|
||||
string DocumentNumber,
|
||||
OfficialDocumentType Type,
|
||||
DateTime IssuedAt,
|
||||
string IssuedByName,
|
||||
string? Purpose,
|
||||
OfficialStudentSnapshot Student,
|
||||
IReadOnlyList<OfficialTranscriptRow> Grades,
|
||||
decimal TotalCredits,
|
||||
decimal EarnedCredits,
|
||||
decimal? GradePointAverage);
|
||||
|
||||
public sealed record OfficialStudentSnapshot(
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
string Gender,
|
||||
string CollegeName,
|
||||
string MajorName,
|
||||
string ClassName,
|
||||
int EnrollmentYear,
|
||||
DateOnly EnrollmentDate,
|
||||
string Status);
|
||||
|
||||
public sealed record OfficialTranscriptRow(
|
||||
string AcademicTerm,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
decimal? Score,
|
||||
decimal? GradePoint,
|
||||
string ExamStatus);
|
||||
|
||||
public sealed record GeneratedOfficialDocument(
|
||||
byte[] Content,
|
||||
string Sha256);
|
||||
@@ -0,0 +1,584 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Operations;
|
||||
|
||||
public sealed record BackupArtifact(
|
||||
string Id,
|
||||
string FileName,
|
||||
string Provider,
|
||||
DateTime CreatedAt,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
string? Note,
|
||||
DateTime? LastDrillAt,
|
||||
bool? LastDrillSucceeded,
|
||||
string? LastDrillDetail,
|
||||
long? LastDrillDurationMilliseconds);
|
||||
|
||||
public sealed record RestoreDrillResult(
|
||||
string BackupId,
|
||||
bool Succeeded,
|
||||
DateTime CompletedAt,
|
||||
string Detail,
|
||||
long DurationMilliseconds,
|
||||
int? TableCount);
|
||||
|
||||
public sealed class DatabaseBackupService(
|
||||
DatabaseOptions databaseOptions,
|
||||
OperationsOptions options,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment,
|
||||
ILogger<DatabaseBackupService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly SemaphoreSlim operationLock = new(1, 1);
|
||||
private readonly string backupDirectory = ResolveBackupDirectory(
|
||||
options.BackupDirectory,
|
||||
environment.ContentRootPath);
|
||||
|
||||
public async Task<IReadOnlyCollection<BackupArtifact>> ListAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(backupDirectory);
|
||||
var items = new List<BackupArtifact>();
|
||||
foreach (var metadataPath in Directory.EnumerateFiles(
|
||||
backupDirectory,
|
||||
"*.metadata.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(metadataPath);
|
||||
var artifact = await JsonSerializer.DeserializeAsync<BackupArtifact>(
|
||||
stream,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
if (artifact is not null &&
|
||||
File.Exists(Path.Combine(backupDirectory, artifact.FileName)))
|
||||
{
|
||||
items.Add(artifact);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Unable to read backup metadata {MetadataFile}.",
|
||||
Path.GetFileName(metadataPath));
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<BackupArtifact> CreateAsync(
|
||||
string? note,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(backupDirectory);
|
||||
var createdAt = DateTime.UtcNow;
|
||||
var id = $"{createdAt:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..29];
|
||||
var provider = NormalizeProvider(databaseOptions.Provider);
|
||||
var extension = provider == "SQLite" ? ".sqlite" : ".sql";
|
||||
var fileName = $"jiaowu-{id}{extension}";
|
||||
var backupPath = Path.Combine(backupDirectory, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
if (provider == "SQLite")
|
||||
await CreateSqliteBackupAsync(backupPath, cancellationToken);
|
||||
else
|
||||
await CreateMySqlBackupAsync(backupPath, cancellationToken);
|
||||
|
||||
var fileInfo = new FileInfo(backupPath);
|
||||
var artifact = new BackupArtifact(
|
||||
id,
|
||||
fileName,
|
||||
provider,
|
||||
createdAt,
|
||||
fileInfo.Length,
|
||||
await ComputeHashAsync(backupPath, cancellationToken),
|
||||
NormalizeNote(note),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
await WriteMetadataAsync(artifact, cancellationToken);
|
||||
return artifact;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(backupPath))
|
||||
File.Delete(backupPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RestoreDrillResult> RunRestoreDrillAsync(
|
||||
string backupId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var artifact = (await ListAsync(cancellationToken))
|
||||
.SingleOrDefault(x => x.Id.Equals(backupId, StringComparison.Ordinal));
|
||||
if (artifact is null)
|
||||
throw new FileNotFoundException("未找到指定备份。");
|
||||
|
||||
var backupPath = Path.Combine(backupDirectory, artifact.FileName);
|
||||
var actualHash = await ComputeHashAsync(backupPath, cancellationToken);
|
||||
if (!CryptographicOperations.FixedTimeEquals(
|
||||
Convert.FromHexString(artifact.Sha256),
|
||||
Convert.FromHexString(actualHash)))
|
||||
{
|
||||
var damaged = await CompleteDrillAsync(
|
||||
artifact,
|
||||
false,
|
||||
"备份文件校验和不一致,恢复演练已中止。",
|
||||
0,
|
||||
null,
|
||||
cancellationToken);
|
||||
return damaged;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
RestoreDrillResult result;
|
||||
try
|
||||
{
|
||||
var tableCount = artifact.Provider == "SQLite"
|
||||
? await DrillSqliteAsync(backupPath, cancellationToken)
|
||||
: await DrillMySqlAsync(backupPath, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
result = await CompleteDrillAsync(
|
||||
artifact,
|
||||
true,
|
||||
$"已在隔离数据库完成恢复并通过完整性检查,共发现 {tableCount} 张业务表。",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
tableCount,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Restore drill failed for backup {BackupId}.",
|
||||
artifact.Id);
|
||||
result = await CompleteDrillAsync(
|
||||
artifact,
|
||||
false,
|
||||
$"恢复演练失败:{SafeMessage(exception)}",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateSqliteBackupAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceBuilder = new SqliteConnectionStringBuilder(
|
||||
configuration.GetConnectionString("SQLite")
|
||||
?? throw new InvalidOperationException("缺少 ConnectionStrings:SQLite。"));
|
||||
if (!Path.IsPathRooted(sourceBuilder.DataSource))
|
||||
{
|
||||
sourceBuilder.DataSource = Path.GetFullPath(
|
||||
sourceBuilder.DataSource,
|
||||
environment.ContentRootPath);
|
||||
}
|
||||
|
||||
var destinationBuilder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = backupPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = false
|
||||
};
|
||||
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
|
||||
await using var destination = new SqliteConnection(destinationBuilder.ConnectionString);
|
||||
await source.OpenAsync(cancellationToken);
|
||||
await destination.OpenAsync(cancellationToken);
|
||||
source.BackupDatabase(destination);
|
||||
}
|
||||
|
||||
private async Task CreateMySqlBackupAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = GetMySqlConnectionBuilder();
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--protocol=tcp",
|
||||
$"--host={connection.Server}",
|
||||
$"--port={connection.Port}",
|
||||
$"--user={connection.UserID}",
|
||||
"--single-transaction",
|
||||
"--quick",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
"--events",
|
||||
"--hex-blob",
|
||||
"--default-character-set=utf8mb4"
|
||||
};
|
||||
arguments.AddRange(options.MySqlAdditionalArguments);
|
||||
arguments.Add(connection.Database);
|
||||
|
||||
await using var output = new FileStream(
|
||||
backupPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
81920,
|
||||
FileOptions.Asynchronous);
|
||||
await RunToolAsync(
|
||||
options.MySqlDumpPath,
|
||||
arguments,
|
||||
connection.Password,
|
||||
standardInput: null,
|
||||
standardOutput: output,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<int> DrillSqliteAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var drillPath = Path.Combine(
|
||||
Path.GetDirectoryName(backupPath)!,
|
||||
$".restore-drill-{Guid.NewGuid():N}.sqlite");
|
||||
try
|
||||
{
|
||||
var sourceBuilder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = backupPath,
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
Pooling = false
|
||||
};
|
||||
var drillBuilder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = drillPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = false
|
||||
};
|
||||
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
|
||||
await using var drill = new SqliteConnection(drillBuilder.ConnectionString);
|
||||
await source.OpenAsync(cancellationToken);
|
||||
await drill.OpenAsync(cancellationToken);
|
||||
source.BackupDatabase(drill);
|
||||
|
||||
await using var integrity = drill.CreateCommand();
|
||||
integrity.CommandText = "PRAGMA integrity_check;";
|
||||
var integrityResult = Convert.ToString(
|
||||
await integrity.ExecuteScalarAsync(cancellationToken));
|
||||
if (!string.Equals(integrityResult, "ok", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"SQLite 完整性检查返回 {integrityResult ?? "空结果"}。");
|
||||
|
||||
await using var tables = drill.CreateCommand();
|
||||
tables.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master " +
|
||||
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
|
||||
return Convert.ToInt32(await tables.ExecuteScalarAsync(cancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(drillPath))
|
||||
File.Delete(drillPath);
|
||||
if (File.Exists($"{drillPath}-shm"))
|
||||
File.Delete($"{drillPath}-shm");
|
||||
if (File.Exists($"{drillPath}-wal"))
|
||||
File.Delete($"{drillPath}-wal");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> DrillMySqlAsync(
|
||||
string backupPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = GetMySqlConnectionBuilder();
|
||||
var drillDatabase = $"jiaowu_restore_drill_{DateTime.UtcNow:yyyyMMddHHmmss}_" +
|
||||
Guid.NewGuid().ToString("N")[..8];
|
||||
var adminBuilder = new MySqlConnectionStringBuilder(connection.ConnectionString)
|
||||
{
|
||||
Database = ""
|
||||
};
|
||||
|
||||
await using var admin = new MySqlConnection(adminBuilder.ConnectionString);
|
||||
await admin.OpenAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await using (var create = admin.CreateCommand())
|
||||
{
|
||||
create.CommandText =
|
||||
$"CREATE DATABASE `{drillDatabase}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;";
|
||||
await create.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--protocol=tcp",
|
||||
$"--host={connection.Server}",
|
||||
$"--port={connection.Port}",
|
||||
$"--user={connection.UserID}",
|
||||
"--default-character-set=utf8mb4"
|
||||
};
|
||||
arguments.AddRange(options.MySqlAdditionalArguments);
|
||||
arguments.Add($"--database={drillDatabase}");
|
||||
await using (var input = new FileStream(
|
||||
backupPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
81920,
|
||||
FileOptions.Asynchronous))
|
||||
{
|
||||
await RunToolAsync(
|
||||
options.MySqlClientPath,
|
||||
arguments,
|
||||
connection.Password,
|
||||
input,
|
||||
standardOutput: null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await using var count = admin.CreateCommand();
|
||||
count.CommandText =
|
||||
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||
"WHERE table_schema = @schema AND table_type = 'BASE TABLE';";
|
||||
count.Parameters.AddWithValue("@schema", drillDatabase);
|
||||
return Convert.ToInt32(await count.ExecuteScalarAsync(cancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var drop = admin.CreateCommand();
|
||||
drop.CommandText = $"DROP DATABASE IF EXISTS `{drillDatabase}`;";
|
||||
await drop.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogCritical(
|
||||
exception,
|
||||
"Unable to remove isolated restore drill database {DatabaseName}.",
|
||||
drillDatabase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunToolAsync(
|
||||
string executable,
|
||||
IReadOnlyCollection<string> arguments,
|
||||
string password,
|
||||
Stream? standardInput,
|
||||
Stream? standardOutput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = standardInput is not null,
|
||||
RedirectStandardOutput = standardOutput is not null,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
foreach (var argument in arguments)
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
startInfo.Environment["MYSQL_PWD"] = password;
|
||||
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException($"无法启动数据库工具 {executable}。");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromMinutes(options.ToolTimeoutMinutes));
|
||||
var errorTask = process.StandardError.ReadToEndAsync(timeout.Token);
|
||||
var inputTask = standardInput is null
|
||||
? Task.CompletedTask
|
||||
: CopyInputAsync(standardInput, process.StandardInput.BaseStream, timeout.Token);
|
||||
var outputTask = standardOutput is null
|
||||
? Task.CompletedTask
|
||||
: process.StandardOutput.BaseStream.CopyToAsync(
|
||||
standardOutput,
|
||||
timeout.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(
|
||||
process.WaitForExitAsync(timeout.Token),
|
||||
inputTask,
|
||||
outputTask);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(entireProcessTree: true);
|
||||
throw;
|
||||
}
|
||||
|
||||
var error = await errorTask;
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"数据库工具执行失败(退出码 {process.ExitCode}):{TrimToolError(error)}");
|
||||
}
|
||||
|
||||
private static async Task CopyInputAsync(
|
||||
Stream input,
|
||||
Stream processInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await input.CopyToAsync(processInput, cancellationToken);
|
||||
await processInput.FlushAsync(cancellationToken);
|
||||
processInput.Close();
|
||||
}
|
||||
|
||||
private MySqlConnectionStringBuilder GetMySqlConnectionBuilder()
|
||||
{
|
||||
var value = configuration.GetConnectionString("OperationsMySql");
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"MySQL 备份与恢复演练必须配置独立的 " +
|
||||
"ConnectionStrings:OperationsMySql 运维账号,不能复用日常业务账号。");
|
||||
}
|
||||
var builder = new MySqlConnectionStringBuilder(value);
|
||||
if (string.IsNullOrWhiteSpace(builder.Database))
|
||||
throw new InvalidOperationException(
|
||||
"OperationsMySql 连接字符串未指定业务数据库名称。");
|
||||
return builder;
|
||||
}
|
||||
|
||||
private async Task<RestoreDrillResult> CompleteDrillAsync(
|
||||
BackupArtifact artifact,
|
||||
bool succeeded,
|
||||
string detail,
|
||||
long durationMilliseconds,
|
||||
int? tableCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var completedAt = DateTime.UtcNow;
|
||||
var updated = artifact with
|
||||
{
|
||||
LastDrillAt = completedAt,
|
||||
LastDrillSucceeded = succeeded,
|
||||
LastDrillDetail = detail,
|
||||
LastDrillDurationMilliseconds = durationMilliseconds
|
||||
};
|
||||
await WriteMetadataAsync(updated, cancellationToken);
|
||||
return new RestoreDrillResult(
|
||||
artifact.Id,
|
||||
succeeded,
|
||||
completedAt,
|
||||
detail,
|
||||
durationMilliseconds,
|
||||
tableCount);
|
||||
}
|
||||
|
||||
private async Task WriteMetadataAsync(
|
||||
BackupArtifact artifact,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadataPath = Path.Combine(
|
||||
backupDirectory,
|
||||
$"{artifact.Id}.metadata.json");
|
||||
var temporaryPath = $"{metadataPath}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(
|
||||
temporaryPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
16384,
|
||||
FileOptions.Asynchronous))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
artifact,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
}
|
||||
File.Move(temporaryPath, metadataPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(
|
||||
await SHA256.HashDataAsync(stream, cancellationToken));
|
||||
}
|
||||
|
||||
private static string ResolveBackupDirectory(
|
||||
string configuredPath,
|
||||
string contentRoot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
throw new InvalidOperationException("Operations:BackupDirectory 不能为空。");
|
||||
return Path.IsPathRooted(configuredPath)
|
||||
? Path.GetFullPath(configuredPath)
|
||||
: Path.GetFullPath(configuredPath, contentRoot);
|
||||
}
|
||||
|
||||
private static string NormalizeProvider(string provider) =>
|
||||
provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)
|
||||
? "SQLite"
|
||||
: provider.Equals("MySql", StringComparison.OrdinalIgnoreCase)
|
||||
? "MySql"
|
||||
: throw new InvalidOperationException($"不支持数据库 Provider '{provider}'。");
|
||||
|
||||
private static string? NormalizeNote(string? note)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note)) return null;
|
||||
var trimmed = note.Trim();
|
||||
return trimmed.Length <= 200 ? trimmed : trimmed[..200];
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception exception)
|
||||
{
|
||||
var message = exception.GetBaseException().Message;
|
||||
return message.Length <= 500 ? message : message[..500];
|
||||
}
|
||||
|
||||
private static string TrimToolError(string error)
|
||||
{
|
||||
var trimmed = error.Trim();
|
||||
if (trimmed.Length == 0) return "未返回错误详情。";
|
||||
return trimmed.Length <= 500 ? trimmed : trimmed[..500];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Diagnostics;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Operations;
|
||||
|
||||
public sealed record OperationalComponentHealth(
|
||||
string Key,
|
||||
string Label,
|
||||
string Status,
|
||||
string Backend,
|
||||
long? LatencyMilliseconds,
|
||||
string Detail);
|
||||
|
||||
public sealed record OperationalHealthSnapshot(
|
||||
DateTime CheckedAt,
|
||||
string OverallStatus,
|
||||
IReadOnlyCollection<OperationalComponentHealth> Components,
|
||||
BackgroundJobBacklogSnapshot? Backlog);
|
||||
|
||||
public sealed class OperationalHealthService(
|
||||
AppDbContext db,
|
||||
IServiceProvider services,
|
||||
IBackgroundJobTransport transport,
|
||||
BackgroundJobMonitoringService monitoring,
|
||||
DatabaseOptions databaseOptions,
|
||||
AppCacheOptions cacheOptions,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
public async Task<OperationalHealthSnapshot> CheckAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await CheckDatabaseAsync(cancellationToken);
|
||||
var cache = await CheckCacheAsync(cancellationToken);
|
||||
var (messaging, backlog) = await CheckMessagingAsync(cancellationToken);
|
||||
var components = new[] { database, cache, messaging };
|
||||
var overall = components.Any(x => x.Status == "unhealthy")
|
||||
? "unhealthy"
|
||||
: components.Any(x => x.Status == "warning")
|
||||
? "warning"
|
||||
: "healthy";
|
||||
return new OperationalHealthSnapshot(
|
||||
DateTime.UtcNow,
|
||||
overall,
|
||||
components,
|
||||
backlog);
|
||||
}
|
||||
|
||||
private async Task<OperationalComponentHealth> CheckDatabaseAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var canConnect = await db.Database.CanConnectAsync(cancellationToken);
|
||||
stopwatch.Stop();
|
||||
return canConnect
|
||||
? new OperationalComponentHealth(
|
||||
"database",
|
||||
"数据库",
|
||||
"healthy",
|
||||
databaseOptions.Provider,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
"连接与基础查询正常。")
|
||||
: new OperationalComponentHealth(
|
||||
"database",
|
||||
"数据库",
|
||||
"unhealthy",
|
||||
databaseOptions.Provider,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
"无法建立数据库连接。");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new OperationalComponentHealth(
|
||||
"database",
|
||||
"数据库",
|
||||
"unhealthy",
|
||||
databaseOptions.Provider,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
SafeMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<OperationalComponentHealth> CheckCacheAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!cacheOptions.Enabled)
|
||||
{
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"warning",
|
||||
"disabled",
|
||||
null,
|
||||
"缓存已通过配置关闭,所有查询将直接访问数据源。");
|
||||
}
|
||||
|
||||
var hasRedisConfiguration = !string.IsNullOrWhiteSpace(
|
||||
configuration.GetConnectionString("Redis"));
|
||||
var distributedCache = services.GetService<IDistributedCache>();
|
||||
if (!hasRedisConfiguration || distributedCache is null)
|
||||
{
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"healthy",
|
||||
"memory",
|
||||
null,
|
||||
"使用进程内混合缓存;服务重启后缓存会自然重建。");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await distributedCache.GetAsync(
|
||||
"jiaowu:operations:health-probe",
|
||||
cancellationToken);
|
||||
stopwatch.Stop();
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"healthy",
|
||||
"redis",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
"Redis 连接与读取探针正常。");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new OperationalComponentHealth(
|
||||
"cache",
|
||||
"缓存",
|
||||
"unhealthy",
|
||||
"redis",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
SafeMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(OperationalComponentHealth Component,
|
||||
BackgroundJobBacklogSnapshot? Backlog)> CheckMessagingAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
BackgroundJobBacklogSnapshot? backlog = null;
|
||||
try
|
||||
{
|
||||
var healthy = await transport.CheckHealthAsync(cancellationToken);
|
||||
backlog = await monitoring.GetSnapshotAsync(cancellationToken);
|
||||
stopwatch.Stop();
|
||||
var stuck = backlog.ExpiredLeases > 0 ||
|
||||
backlog.OldestUnfinishedAgeSeconds > 1800;
|
||||
var status = !healthy
|
||||
? "unhealthy"
|
||||
: stuck
|
||||
? "warning"
|
||||
: "healthy";
|
||||
var detail = !healthy
|
||||
? "后台任务传输不可用。"
|
||||
: stuck
|
||||
? $"发现 {backlog.ExpiredLeases} 个过期租约,最早未完成任务已等待 " +
|
||||
$"{Math.Round(backlog.OldestUnfinishedAgeSeconds ?? 0)} 秒。"
|
||||
: $"待发布 {backlog.Pending + backlog.Publishing}," +
|
||||
$"待处理 {backlog.Published + backlog.Processing}。";
|
||||
return (
|
||||
new OperationalComponentHealth(
|
||||
"messaging",
|
||||
"后台任务通道",
|
||||
status,
|
||||
transport.IsDurable ? "rabbitmq" : "memory",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
detail),
|
||||
backlog);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return (
|
||||
new OperationalComponentHealth(
|
||||
"messaging",
|
||||
"后台任务通道",
|
||||
"unhealthy",
|
||||
transport.IsDurable ? "rabbitmq" : "memory",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
SafeMessage(exception)),
|
||||
backlog);
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception exception)
|
||||
{
|
||||
var message = exception.GetBaseException().Message;
|
||||
return message.Length <= 300 ? message : message[..300];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Operations;
|
||||
|
||||
public sealed class OperationsOptions
|
||||
{
|
||||
public const string SectionName = "Operations";
|
||||
|
||||
public string BackupDirectory { get; set; } = "data/backups";
|
||||
public int BackupWarningHours { get; set; } = 24;
|
||||
public int ToolTimeoutMinutes { get; set; } = 30;
|
||||
public string MySqlDumpPath { get; set; } = "mysqldump";
|
||||
public string MySqlClientPath { get; set; } = "mysql";
|
||||
public string[] MySqlAdditionalArguments { get; set; } = [];
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<Student> Students => Set<Student>();
|
||||
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
|
||||
public DbSet<Course> Courses => Set<Course>();
|
||||
public DbSet<CoursePrerequisite> CoursePrerequisites => Set<CoursePrerequisite>();
|
||||
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
||||
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
||||
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
|
||||
@@ -41,8 +42,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<AutomaticScheduleJob>();
|
||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||
Set<SchedulePublishJob>();
|
||||
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||
Set<ClassroomReservation>();
|
||||
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
||||
Set<ExperimentGradeSheet>();
|
||||
public DbSet<ExperimentGradeItem> ExperimentGradeItems =>
|
||||
Set<ExperimentGradeItem>();
|
||||
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
|
||||
Set<ExperimentGradeRecord>();
|
||||
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
|
||||
Set<ExperimentGradeItemScore>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
Set<CourseSelectionRound>();
|
||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||
Set<CourseSelectionRoundGrade>();
|
||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||
Set<CourseSelectionOffering>();
|
||||
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
||||
@@ -52,10 +68,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||
Set<AttendanceCheckInAttempt>();
|
||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||
Set<ExamArrangementJob>();
|
||||
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
|
||||
Set<ExamSignInExportJob>();
|
||||
public DbSet<ExamPublishJob> ExamPublishJobs =>
|
||||
Set<ExamPublishJob>();
|
||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||
Set<ExamSessionInvigilator>();
|
||||
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
|
||||
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
|
||||
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
|
||||
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
|
||||
Set<ExamRoomInvigilator>();
|
||||
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
|
||||
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
|
||||
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
|
||||
@@ -75,6 +104,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
|
||||
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
|
||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||
Set<StudentStatusChange>();
|
||||
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
||||
@@ -89,7 +119,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<GraduationClearanceItem>();
|
||||
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
|
||||
Set<GraduationClearanceRecord>();
|
||||
public DbSet<OfficialDocument> OfficialDocuments => Set<OfficialDocument>();
|
||||
public DbSet<OfficialDocumentDownload> OfficialDocumentDownloads =>
|
||||
Set<OfficialDocumentDownload>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
||||
Set<BackgroundJobOutboxMessage>();
|
||||
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||
Set<AppUpdateRelease>();
|
||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
ModelConfigurationBuilder configurationBuilder)
|
||||
@@ -101,6 +139,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
configurationBuilder.Properties<DateOnly>()
|
||||
.HaveConversion<DateOnlyDateTimeConverter>()
|
||||
.HaveColumnType("date");
|
||||
|
||||
// Connector/NET returns MySQL TIME values as TimeSpan. Convert at the
|
||||
// provider boundary so schedule time slots can still use TimeOnly.
|
||||
configurationBuilder.Properties<TimeOnly>()
|
||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||
.HaveColumnType("time");
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
@@ -111,6 +155,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.DisplayName).HasMaxLength(50);
|
||||
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
|
||||
entity.HasIndex(x => x.StaffNumber);
|
||||
});
|
||||
|
||||
@@ -119,6 +164,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Description).HasMaxLength(100);
|
||||
});
|
||||
|
||||
builder.Entity<RefreshSession>(entity =>
|
||||
{
|
||||
entity.Property(x => x.TokenHash).HasMaxLength(64);
|
||||
entity.Property(x => x.SecurityStamp).HasMaxLength(100);
|
||||
entity.Property(x => x.ClientType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.HasIndex(x => x.TokenHash).IsUnique();
|
||||
entity.HasIndex(x => new { x.UserId, x.ExpiresAt });
|
||||
entity.HasOne(x => x.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
ConfigureCatalog<Campus>(builder);
|
||||
ConfigureCatalog<College>(builder);
|
||||
ConfigureCatalog<Major>(builder);
|
||||
@@ -197,8 +257,25 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.StudentNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.Name).HasMaxLength(50);
|
||||
entity.Property(x => x.EnglishName).HasMaxLength(100);
|
||||
entity.Property(x => x.IdCardNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.Nationality).HasMaxLength(50);
|
||||
entity.Property(x => x.Ethnicity).HasMaxLength(50);
|
||||
entity.Property(x => x.PoliticalStatus).HasMaxLength(50);
|
||||
entity.Property(x => x.NativePlace).HasMaxLength(100);
|
||||
entity.Property(x => x.HouseholdAddress).HasMaxLength(300);
|
||||
entity.Property(x => x.CurrentAddress).HasMaxLength(300);
|
||||
entity.Property(x => x.PostalCode).HasMaxLength(20);
|
||||
entity.Property(x => x.Phone).HasMaxLength(30);
|
||||
entity.Property(x => x.Email).HasMaxLength(100);
|
||||
entity.Property(x => x.Qq).HasMaxLength(30);
|
||||
entity.Property(x => x.WeChat).HasMaxLength(60);
|
||||
entity.Property(x => x.EmergencyContactName).HasMaxLength(50);
|
||||
entity.Property(x => x.EmergencyContactRelationship).HasMaxLength(30);
|
||||
entity.Property(x => x.EmergencyContactPhone).HasMaxLength(30);
|
||||
entity.Property(x => x.SpecialTags).HasMaxLength(300);
|
||||
entity.Property(x => x.SpecialNeeds).HasMaxLength(1000);
|
||||
entity.Property(x => x.Biography).HasMaxLength(1000);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.StudentNumber).IsUnique();
|
||||
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
|
||||
@@ -230,6 +307,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CoursePrerequisite>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||
.IsUnique();
|
||||
entity.HasOne(x => x.Course)
|
||||
.WithMany(x => x.Prerequisites)
|
||||
.HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.PrerequisiteCourse)
|
||||
.WithMany(x => x.RequiredByCourses)
|
||||
.HasForeignKey(x => x.PrerequisiteCourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CurriculumPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -358,6 +449,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
|
||||
builder.Entity<ScheduleEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind)
|
||||
.HasDefaultValue(ScheduleEntryKind.Lecture);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
@@ -459,6 +552,176 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<ClassroomReservation>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ApplicantName).HasMaxLength(50);
|
||||
entity.Property(x => x.Purpose).HasMaxLength(200);
|
||||
entity.Property(x => x.ContactPhone).HasMaxLength(30);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.ApplicantUserId, x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ApplicantCollegeId,
|
||||
x.Status,
|
||||
x.ReservationDate
|
||||
});
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ClassroomId,
|
||||
x.ReservationDate,
|
||||
x.Status,
|
||||
x.StartPeriod
|
||||
});
|
||||
entity.HasOne(x => x.ApplicantUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApplicantUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.ApplicantCollege)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApplicantCollegeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.AcademicTerm)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.AcademicTermId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Classroom)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ClassroomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.ReviewedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ReviewedByUserId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentProject>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Code).HasMaxLength(40);
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
|
||||
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentSession>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentProjectId,
|
||||
x.SessionDate,
|
||||
x.StartPeriod
|
||||
});
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ClassroomId,
|
||||
x.SessionDate,
|
||||
x.Status,
|
||||
x.StartPeriod
|
||||
});
|
||||
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.ExperimentProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Classroom).WithMany()
|
||||
.HasForeignKey(x => x.ClassroomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentBooking>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.ExperimentProjectId, x.StudentId })
|
||||
.IsUnique();
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentSessionId,
|
||||
x.Status,
|
||||
x.BookedAt
|
||||
});
|
||||
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Bookings)
|
||||
.HasForeignKey(x => x.ExperimentProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Bookings)
|
||||
.HasForeignKey(x => x.ExperimentSessionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeSheet>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ContributionWeight).HasPrecision(5, 1);
|
||||
entity.Property(x => x.PassScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.ExperimentProjectId).IsUnique();
|
||||
entity.HasOne(x => x.ExperimentProject)
|
||||
.WithOne(x => x.GradeSheet)
|
||||
.HasForeignKey<ExperimentGradeSheet>(
|
||||
x => x.ExperimentProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeItem>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(60);
|
||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentGradeSheetId,
|
||||
x.SortOrder
|
||||
});
|
||||
entity.HasOne(x => x.ExperimentGradeSheet)
|
||||
.WithMany(x => x.Items)
|
||||
.HasForeignKey(x => x.ExperimentGradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeRecord>(entity =>
|
||||
{
|
||||
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.SubmissionReference).HasMaxLength(500);
|
||||
entity.Property(x => x.TeacherComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExperimentGradeSheetId,
|
||||
x.StudentId
|
||||
}).IsUnique();
|
||||
entity.HasOne(x => x.ExperimentGradeSheet)
|
||||
.WithMany(x => x.Records)
|
||||
.HasForeignKey(x => x.ExperimentGradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.ExperimentSession).WithMany()
|
||||
.HasForeignKey(x => x.ExperimentSessionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentGradeItemScore>(entity =>
|
||||
{
|
||||
entity.HasKey(x => new
|
||||
{
|
||||
x.ExperimentGradeRecordId,
|
||||
x.ExperimentGradeItemId
|
||||
});
|
||||
entity.Property(x => x.Score).HasPrecision(5, 1);
|
||||
entity.Property(x => x.Comment).HasMaxLength(300);
|
||||
entity.HasOne(x => x.ExperimentGradeRecord)
|
||||
.WithMany(x => x.ItemScores)
|
||||
.HasForeignKey(x => x.ExperimentGradeRecordId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExperimentGradeItem)
|
||||
.WithMany(x => x.Scores)
|
||||
.HasForeignKey(x => x.ExperimentGradeItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionRound>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -471,6 +734,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionRoundGrade>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.CourseSelectionRoundId, x.Grade })
|
||||
.IsUnique();
|
||||
entity.HasIndex(x => x.Grade);
|
||||
entity.HasOne(x => x.CourseSelectionRound)
|
||||
.WithMany(x => x.EligibleGrades)
|
||||
.HasForeignKey(x => x.CourseSelectionRoundId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionOffering>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
@@ -494,6 +768,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
|
||||
.IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.Status });
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.CourseSelectionOfferingId,
|
||||
x.Status,
|
||||
x.WaitlistedAt
|
||||
}).HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt");
|
||||
entity.HasOne(x => x.CourseSelectionOffering)
|
||||
.WithMany(x => x.Enrollments)
|
||||
.HasForeignKey(x => x.CourseSelectionOfferingId)
|
||||
@@ -521,6 +801,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(60);
|
||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||
entity.Property(x => x.SourceType)
|
||||
.HasDefaultValue(GradeItemSourceType.Manual);
|
||||
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
||||
entity.HasOne(x => x.GradeSheet)
|
||||
.WithMany(x => x.Items)
|
||||
@@ -595,6 +877,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<AttendanceCheckInAttempt>(entity =>
|
||||
{
|
||||
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
|
||||
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
|
||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||
entity.Property(x => x.RiskFlags).HasMaxLength(300);
|
||||
entity.Property(x => x.Latitude).HasPrecision(10, 7);
|
||||
entity.Property(x => x.Longitude).HasPrecision(10, 7);
|
||||
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
|
||||
entity.HasOne(x => x.AttendanceSheet)
|
||||
.WithMany(x => x.CheckInAttempts)
|
||||
.HasForeignKey(x => x.AttendanceSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExamPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -610,6 +915,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
|
||||
entity.HasIndex(x => x.RequiredBuildingId);
|
||||
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
@@ -627,6 +933,87 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.Teacher).WithMany()
|
||||
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamRoomAssignment>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamRooms");
|
||||
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
||||
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.ExamPlanId,
|
||||
x.ClassroomId,
|
||||
x.StartsAt
|
||||
})
|
||||
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
|
||||
entity.HasIndex(x => x.CourseId)
|
||||
.HasDatabaseName("IX_ExamRooms_CourseId");
|
||||
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
|
||||
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Course).WithMany()
|
||||
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Classroom).WithMany()
|
||||
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamRoomSession>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamRoomSessions");
|
||||
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
|
||||
entity.HasIndex(x => x.ExamSessionId)
|
||||
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
|
||||
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
|
||||
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
|
||||
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamSeatAssignment>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamSeats");
|
||||
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
|
||||
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ExamSeats_Session_Student");
|
||||
entity.HasIndex(x => x.StudentId)
|
||||
.HasDatabaseName("IX_ExamSeats_StudentId");
|
||||
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
|
||||
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
|
||||
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamRoomInvigilator>(entity =>
|
||||
{
|
||||
entity.ToTable("ExamRoomInvigilators");
|
||||
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
|
||||
entity.HasIndex(x => x.TeacherId)
|
||||
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
|
||||
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
|
||||
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Teacher).WithMany()
|
||||
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<ExamArrangementJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
|
||||
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
|
||||
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.RequestedByUserId);
|
||||
});
|
||||
builder.Entity<ExamSignInExportJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||
entity.Property(x => x.FileName).HasMaxLength(200);
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.RequestedByUserId);
|
||||
});
|
||||
builder.Entity<MakeupExamPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -642,6 +1029,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
||||
entity.HasIndex(x => x.RequiredBuildingId);
|
||||
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
@@ -876,6 +1264,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
|
||||
entity.HasIndex(x => x.ApplicantUserId);
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.SourceScheduleEntryId, x.SourceWeek });
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Classroom).WithMany()
|
||||
@@ -887,10 +1276,26 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<Notification>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Title).HasMaxLength(200);
|
||||
entity.Property(x => x.Content).HasMaxLength(1000);
|
||||
entity.Property(x => x.Content).HasColumnType("longtext");
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
||||
entity.HasIndex(x => x.MessageDispatchId);
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
entity.HasOne(x => x.MessageDispatch)
|
||||
.WithMany(x => x.Notifications)
|
||||
.HasForeignKey(x => x.MessageDispatchId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MessageDispatch>(entity =>
|
||||
{
|
||||
entity.Property(x => x.SenderName).HasMaxLength(100);
|
||||
entity.Property(x => x.Title).HasMaxLength(200);
|
||||
entity.Property(x => x.Content).HasColumnType("longtext");
|
||||
entity.Property(x => x.AudienceName).HasMaxLength(200);
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
|
||||
});
|
||||
|
||||
builder.Entity<AuditLog>(entity =>
|
||||
@@ -901,6 +1306,99 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
|
||||
builder.Entity<BackgroundJobOutboxMessage>(entity =>
|
||||
{
|
||||
entity.Property(x => x.LastError).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.JobKind, x.JobId }).IsUnique();
|
||||
entity.HasIndex(x => new { x.State, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.State, x.CompletedAt });
|
||||
entity.HasIndex(x => x.LeaseExpiresAt);
|
||||
});
|
||||
|
||||
builder.Entity<AppUpdateRelease>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Platform)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.Property(x => x.Channel)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.Property(x => x.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.Property(x => x.Version).HasMaxLength(40);
|
||||
entity.Property(x => x.NativeVersion).HasMaxLength(40);
|
||||
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
|
||||
entity.Property(x => x.FileName).HasMaxLength(180);
|
||||
entity.Property(x => x.Sha256).HasMaxLength(64);
|
||||
entity.Property(x => x.BundleContent).HasColumnType("longblob");
|
||||
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
|
||||
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.Platform,
|
||||
x.Channel,
|
||||
x.NativeVersion,
|
||||
x.Version
|
||||
}).IsUnique();
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.Platform,
|
||||
x.Channel,
|
||||
x.NativeVersion,
|
||||
x.Status
|
||||
});
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
|
||||
builder.Entity<OfficialDocument>(entity =>
|
||||
{
|
||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||
entity.Property(x => x.VerificationCodeHash).HasMaxLength(64);
|
||||
entity.Property(x => x.Purpose).HasMaxLength(200);
|
||||
entity.Property(x => x.SnapshotJson).HasColumnType("longtext");
|
||||
entity.Property(x => x.PdfContent).HasColumnType("longblob");
|
||||
entity.Property(x => x.PdfSha256).HasMaxLength(64);
|
||||
entity.Property(x => x.InvalidationReason).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.DocumentNumber).IsUnique();
|
||||
entity.HasIndex(x => x.VerificationCodeHash).IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.IssuedAt });
|
||||
entity.HasIndex(x => new { x.Status, x.IssuedAt });
|
||||
entity.HasOne(x => x.Student)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.IssuedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.IssuedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.InvalidatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.InvalidatedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasIndex(x => x.ReissuedFromDocumentId).IsUnique();
|
||||
entity.HasOne(x => x.ReissuedFromDocument)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ReissuedFromDocumentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<OfficialDocumentDownload>(entity =>
|
||||
{
|
||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.OfficialDocumentId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.DownloadedByUserId, x.CreatedAt });
|
||||
entity.HasOne(x => x.OfficialDocument)
|
||||
.WithMany(x => x.Downloads)
|
||||
.HasForeignKey(x => x.OfficialDocumentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.DownloadedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.DownloadedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
@@ -931,3 +1429,8 @@ public sealed class DateOnlyDateTimeConverter()
|
||||
: ValueConverter<DateOnly, DateTime>(
|
||||
date => date.ToDateTime(TimeOnly.MinValue),
|
||||
value => DateOnly.FromDateTime(value));
|
||||
|
||||
public sealed class TimeOnlyTimeSpanConverter()
|
||||
: ValueConverter<TimeOnly, TimeSpan>(
|
||||
time => time.ToTimeSpan(),
|
||||
value => TimeOnly.FromTimeSpan(value));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user