Compare commits
@@ -8,9 +8,16 @@ MYSQL_USER=jiaowu
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_ROOT_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=
|
JWT_KEY=
|
||||||
ALLOWED_HOSTS=localhost
|
ALLOWED_HOSTS=localhost
|
||||||
CORS_ORIGIN=http://localhost:8080
|
CORS_ORIGIN=http://localhost:8080
|
||||||
|
OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
# 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。
|
# 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。
|
||||||
SEED_ADMIN_USERNAME=demo-admin
|
SEED_ADMIN_USERNAME=demo-admin
|
||||||
|
|||||||
+88
-1
@@ -6,14 +6,101 @@ Database__Provider=MySql
|
|||||||
Database__ApplyMigrationsOnStartup=false
|
Database__ApplyMigrationsOnStartup=false
|
||||||
Database__CommandTimeoutSeconds=30
|
Database__CommandTimeoutSeconds=30
|
||||||
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||||
|
# 仅供备份与隔离恢复演练使用。该账号需要读取业务库,并仅能创建/删除
|
||||||
|
# jiaowu_restore_drill_* 临时库;不要在此复用日常业务账号。
|
||||||
|
# ConnectionStrings__OperationsMySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=OPS_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||||
|
# Redis 是可选加速器;留空时应用仅使用进程内缓存。
|
||||||
|
# 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__Issuer=Jiaowu.Api
|
||||||
Jwt__Audience=Jiaowu.Web
|
Jwt__Audience=Jiaowu.Web
|
||||||
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||||
Jwt__ExpireMinutes=60
|
Jwt__AccessTokenMinutes=10
|
||||||
|
Jwt__WebIdleMinutes=30
|
||||||
|
Jwt__AppIdleMinutes=4320
|
||||||
|
|
||||||
|
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
|
||||||
|
# https://sso.example.edu.cn/realms/mingxu
|
||||||
|
Sso__Enabled=false
|
||||||
|
# Sso__DisplayName=学校统一身份认证
|
||||||
|
# Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||||
|
# Sso__ClientId=jiaowu-web
|
||||||
|
# Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||||
|
# Sso__UserNameClaim=preferred_username
|
||||||
|
# Sso__RequireHttpsMetadata=true
|
||||||
|
# 首次 SSO 登录优先绑定同名本地账号;用户名不同时由用户输入现有账号密码完成绑定。
|
||||||
|
# 不会自动创建账号或授予角色。
|
||||||
|
# Sso__LinkExistingUsersByUserName=true
|
||||||
|
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
|
||||||
|
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||||
|
# 必须与 Keycloak 客户端的 Valid redirect URI 完全一致。
|
||||||
|
# Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||||
|
|
||||||
AllowedHosts=jiaowu.example.edu.cn
|
AllowedHosts=jiaowu.example.edu.cn
|
||||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||||
|
Cors__Origins__1=capacitor://localhost
|
||||||
|
Cors__Origins__2=https://localhost
|
||||||
|
Cors__Origins__3=http://localhost
|
||||||
|
|
||||||
|
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
||||||
|
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
|
# SeedAdmin__UserName=admin
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ src/Jiaowu.Api/wwwroot/
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.docker.example
|
!.env.docker.example
|
||||||
certs/
|
certs/
|
||||||
|
publish/
|
||||||
+9
-1
@@ -30,10 +30,14 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
ASPNETCORE_HTTP_PORTS=8080 \
|
ASPNETCORE_HTTP_PORTS=8080 \
|
||||||
DOTNET_EnableDiagnostics=0
|
DOTNET_EnableDiagnostics=0 \
|
||||||
|
Operations__BackupDirectory=/var/lib/jiaowu/backups \
|
||||||
|
Operations__MySqlDumpPath=mariadb-dump \
|
||||||
|
Operations__MySqlClientPath=mariadb
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=build /app/publish/ ./
|
COPY --from=build /app/publish/ ./
|
||||||
ARG UID=10001
|
ARG UID=10001
|
||||||
|
RUN apk add --no-cache font-noto-cjk mariadb-client
|
||||||
RUN adduser \
|
RUN adduser \
|
||||||
--disabled-password \
|
--disabled-password \
|
||||||
--gecos "" \
|
--gecos "" \
|
||||||
@@ -42,5 +46,9 @@ RUN adduser \
|
|||||||
--no-create-home \
|
--no-create-home \
|
||||||
--uid "${UID}" \
|
--uid "${UID}" \
|
||||||
appuser
|
appuser
|
||||||
|
RUN mkdir -p /var/lib/jiaowu/backups \
|
||||||
|
&& chown appuser:appuser /var/lib/jiaowu/backups \
|
||||||
|
&& chmod 700 /var/lib/jiaowu/backups
|
||||||
|
VOLUME ["/var/lib/jiaowu/backups"]
|
||||||
USER appuser
|
USER appuser
|
||||||
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
|
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址。
|
||||||
|
|
||||||
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
|
||||||
|
|
||||||
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
||||||
|
|
||||||
@@ -45,6 +47,68 @@ dotnet run --project src/Jiaowu.Api
|
|||||||
|
|
||||||
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
||||||
|
|
||||||
|
## Capacitor Android App
|
||||||
|
|
||||||
|
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
|
||||||
|
Android 工程前先构建并同步原生插件:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location web
|
||||||
|
npm ci
|
||||||
|
npm run build:capacitor
|
||||||
|
npm run cap:sync
|
||||||
|
npm run cap:open:android
|
||||||
|
```
|
||||||
|
|
||||||
|
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
|
||||||
|
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
|
||||||
|
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
|
||||||
|
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
|
||||||
|
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||||
|
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||||
|
|
||||||
|
### App 前端热更新
|
||||||
|
|
||||||
|
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
|
||||||
|
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
|
||||||
|
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
|
||||||
|
前端修复不再需要重新打包。
|
||||||
|
|
||||||
|
生成更新 ZIP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location web
|
||||||
|
npm ci
|
||||||
|
npm run ota:package -- --version 1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
|
||||||
|
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
|
||||||
|
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName` 为
|
||||||
|
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`。
|
||||||
|
|
||||||
|
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
|
||||||
|
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
|
||||||
|
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
|
||||||
|
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
|
||||||
|
`--migrate-only`。
|
||||||
|
|
||||||
|
### Android 开屏、快捷入口与桌面组件
|
||||||
|
|
||||||
|
Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
|
||||||
|
中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
|
||||||
|
遵守系统“减少动画”设置。
|
||||||
|
|
||||||
|
长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
|
||||||
|
签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
|
||||||
|
课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
|
||||||
|
退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
|
||||||
|
|
||||||
|
原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
|
||||||
|
`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
|
||||||
|
`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
|
||||||
|
重新构建 App,不能通过前端 OTA 单独下发。
|
||||||
|
|
||||||
## MySQL 8.4 生产部署
|
## MySQL 8.4 生产部署
|
||||||
|
|
||||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||||
@@ -80,6 +144,50 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
|
|||||||
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
||||||
路径必须是运行服务器上的实际路径。
|
路径必须是运行服务器上的实际路径。
|
||||||
|
|
||||||
|
### Keycloak 单点登录(可选)
|
||||||
|
|
||||||
|
系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否
|
||||||
|
启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用
|
||||||
|
`preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录
|
||||||
|
外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户
|
||||||
|
需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会
|
||||||
|
自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能
|
||||||
|
绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生
|
||||||
|
自助激活入口不受影响。
|
||||||
|
|
||||||
|
在 Keycloak 中创建 OpenID Connect 客户端,并至少配置:
|
||||||
|
|
||||||
|
- Valid redirect URI:`https://jiaowu.example.edu.cn/signin-keycloak`
|
||||||
|
- Valid post logout redirect URI:`https://jiaowu.example.edu.cn/*`(若后续启用 Keycloak 全局退出)
|
||||||
|
- Standard flow:开启;Implicit flow:关闭;PKCE:`S256`
|
||||||
|
|
||||||
|
然后在 `.env` 中配置:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
Sso__Enabled=true
|
||||||
|
Sso__DisplayName=学校统一身份认证
|
||||||
|
Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||||
|
Sso__ClientId=jiaowu-web
|
||||||
|
Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||||
|
Sso__UserNameClaim=preferred_username
|
||||||
|
Sso__RequireHttpsMetadata=true
|
||||||
|
Sso__LinkExistingUsersByUserName=true
|
||||||
|
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||||
|
Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
|
||||||
|
`http://localhost:5173`,Keycloak 测试客户端需同时允许
|
||||||
|
`http://localhost:5255/signin-keycloak`。`Sso__CallbackUrl` 是应用实际发送给 Keycloak
|
||||||
|
的 `redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式
|
||||||
|
配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会
|
||||||
|
显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
|
||||||
|
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
|
||||||
|
|
||||||
|
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
|
||||||
|
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
|
||||||
|
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
|
||||||
|
|
||||||
### Linux systemd 服务
|
### Linux systemd 服务
|
||||||
|
|
||||||
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
|
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
|
||||||
@@ -99,6 +207,12 @@ sudo chown -R root:jiaowu /opt/jiaowu
|
|||||||
sudo chmod 0750 /opt/jiaowu
|
sudo chmod 0750 /opt/jiaowu
|
||||||
sudo chmod 0750 /opt/jiaowu/Jiaowu.Api
|
sudo chmod 0750 /opt/jiaowu/Jiaowu.Api
|
||||||
sudo chmod 0640 /opt/jiaowu/.env
|
sudo chmod 0640 /opt/jiaowu/.env
|
||||||
|
sudo install -d \
|
||||||
|
--owner=jiaowu \
|
||||||
|
--group=jiaowu \
|
||||||
|
--mode=0700 \
|
||||||
|
/var/lib/jiaowu/backups
|
||||||
|
sudo apt-get install default-mysql-client
|
||||||
```
|
```
|
||||||
|
|
||||||
如果账号已存在,`useradd` 会报错,可以跳过该命令。RHEL 系发行版的 `nologin` 通常
|
如果账号已存在,`useradd` 会报错,可以跳过该命令。RHEL 系发行版的 `nologin` 通常
|
||||||
@@ -219,6 +333,151 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
|
|||||||
|
|
||||||
- `/health/live`:只检查进程存活。
|
- `/health/live`:只检查进程存活。
|
||||||
- `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
|
- `/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
|
## 跨平台发布与 Docker
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
volumes:
|
||||||
|
- jiaowu-backups:/var/lib/jiaowu/backups
|
||||||
|
|
||||||
# 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到
|
# 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到
|
||||||
# ./certs/mysql-ca.pem,并取消下面三行注释。
|
# ./certs/mysql-ca.pem,并在上面的 volumes 中追加以下四行。
|
||||||
# volumes:
|
|
||||||
# - type: bind
|
# - type: bind
|
||||||
# source: ./certs/mysql-ca.pem
|
# source: ./certs/mysql-ca.pem
|
||||||
# target: /etc/jiaowu/mysql-ca.pem
|
# target: /etc/jiaowu/mysql-ca.pem
|
||||||
# read_only: true
|
# read_only: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jiaowu-backups:
|
||||||
|
|||||||
+69
-1
@@ -13,12 +13,29 @@ x-jiaowu-environment: &jiaowu-environment
|
|||||||
Database__ApplyMigrationsOnStartup: "false"
|
Database__ApplyMigrationsOnStartup: "false"
|
||||||
Database__CommandTimeoutSeconds: "30"
|
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__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__Issuer: Jiaowu.Api
|
||||||
Jwt__Audience: Jiaowu.Web
|
Jwt__Audience: Jiaowu.Web
|
||||||
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
||||||
Jwt__ExpireMinutes: "60"
|
Jwt__AccessTokenMinutes: "10"
|
||||||
|
Jwt__WebIdleMinutes: "30"
|
||||||
|
Jwt__AppIdleMinutes: "4320"
|
||||||
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
|
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
|
||||||
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
|
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
|
||||||
|
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
|
||||||
SeedAdmin__UserName: "${SEED_ADMIN_USERNAME:-}"
|
SeedAdmin__UserName: "${SEED_ADMIN_USERNAME:-}"
|
||||||
SeedAdmin__Password: "${SEED_ADMIN_PASSWORD:-}"
|
SeedAdmin__Password: "${SEED_ADMIN_PASSWORD:-}"
|
||||||
SeedAdmin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-}"
|
SeedAdmin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-}"
|
||||||
@@ -30,6 +47,49 @@ x-json-logging: &json-logging
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
services:
|
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:
|
mysql:
|
||||||
image: mysql:8.4
|
image: mysql:8.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -69,6 +129,10 @@ services:
|
|||||||
<<: *jiaowu-image
|
<<: *jiaowu-image
|
||||||
environment: *jiaowu-environment
|
environment: *jiaowu-environment
|
||||||
depends_on:
|
depends_on:
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
migrate:
|
migrate:
|
||||||
@@ -77,6 +141,8 @@ services:
|
|||||||
- "${JIAOWU_PORT:-8080}:8080"
|
- "${JIAOWU_PORT:-8080}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
|
volumes:
|
||||||
|
- backup-data:/var/lib/jiaowu/backups
|
||||||
logging: *json-logging
|
logging: *json-logging
|
||||||
|
|
||||||
# 工具型一次性服务:普通 docker compose up 不会执行它。
|
# 工具型一次性服务:普通 docker compose up 不会执行它。
|
||||||
@@ -99,3 +165,5 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
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);
|
var studentId = await GetStudentIdAsync(ct);
|
||||||
if (studentId is null) return StudentNotFound();
|
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))
|
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有免修申请在审核中。");
|
return ConflictProblem("该课程已有免修申请在审核中。");
|
||||||
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
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);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
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))
|
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有缓考申请在审核中。");
|
return ConflictProblem("该课程已有缓考申请在审核中。");
|
||||||
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
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 })
|
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
|
||||||
.FirstAsync(ct);
|
.FirstAsync(ct);
|
||||||
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
|
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 });
|
return Created("", new { gm.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +221,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
|
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
|
||||||
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
|
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
|
||||||
await db.SaveChangesAsync(ct);
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||||
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
|
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 is null) return NotFound();
|
||||||
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
|
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
|
||||||
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
|
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!.TotalScore = gm.RequestedScore;
|
||||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||||
await db.SaveChangesAsync(ct);
|
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();
|
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; }
|
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
|
||||||
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
|
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
|
||||||
await db.SaveChangesAsync(ct);
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Course Substitution ═══════════════
|
// ═══════════════ 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")]
|
[HttpPost("substitutions")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
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))
|
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有替代申请在审核中。");
|
return ConflictProblem("该课程已有替代申请在审核中。");
|
||||||
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Student enrolled courses ═══════════════
|
// ═══════════════ Student course options ═══════════════
|
||||||
[HttpGet("my-courses")]
|
[HttpGet("my-courses")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
var courses = await db.CourseEnrollments.AsNoTracking()
|
var courses = await db.TeachingTasks.AsNoTracking()
|
||||||
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
|
.Where(task =>
|
||||||
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
|
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);
|
.ToListAsync(ct);
|
||||||
return Ok(courses);
|
return Ok(courses);
|
||||||
}
|
}
|
||||||
@@ -341,8 +487,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
var grades = await db.GradeRecords.AsNoTracking()
|
var grades = await db.GradeRecords.AsNoTracking()
|
||||||
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
.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 })
|
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||||||
.OrderBy(x => x.CourseCode)
|
.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);
|
.ToListAsync(ct);
|
||||||
return Ok(grades);
|
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<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 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 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, 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, "/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); }
|
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); }
|
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 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 => "代课", _ => "调停课" };
|
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 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 ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
||||||
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [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);
|
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -48,9 +50,15 @@ public sealed class AttendanceController(
|
|||||||
TermName = x.AcademicTerm!.Name,
|
TermName = x.AcademicTerm!.Name,
|
||||||
CourseCode = x.Course!.Code,
|
CourseCode = x.Course!.Code,
|
||||||
CourseName = x.Course!.Name,
|
CourseName = x.Course!.Name,
|
||||||
StudentCount = db.CourseEnrollments.Count(enrollment =>
|
StudentCount = db.Students.Count(student =>
|
||||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
(student.Status == StudentStatus.Active &&
|
||||||
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id),
|
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 =>
|
SheetCount = db.AttendanceSheets.Count(sheet =>
|
||||||
sheet.TeachingTaskId == x.Id)
|
sheet.TeachingTaskId == x.Id)
|
||||||
})
|
})
|
||||||
@@ -104,15 +112,13 @@ public sealed class AttendanceController(
|
|||||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||||
if (task is null) return NotFound();
|
if (task is null) return NotFound();
|
||||||
|
|
||||||
var studentIds = await db.CourseEnrollments.AsNoTracking()
|
var studentIds = await TeachingTaskRosterQuery
|
||||||
.Where(x =>
|
.ForTask(db, request.TeachingTaskId)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
.AsNoTracking()
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId)
|
.Select(x => x.Id)
|
||||||
.Select(x => x.StudentId)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (studentIds.Count == 0)
|
if (studentIds.Count == 0)
|
||||||
return ConflictProblem("该教学班没有有效选课学生。");
|
return ConflictProblem("该教学班没有有效学生。");
|
||||||
|
|
||||||
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
|
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
|
||||||
if (!Enum.IsDefined(checkInMethod))
|
if (!Enum.IsDefined(checkInMethod))
|
||||||
@@ -175,7 +181,6 @@ public sealed class AttendanceController(
|
|||||||
{
|
{
|
||||||
sheet.Id,
|
sheet.Id,
|
||||||
sheet.CheckInMethod,
|
sheet.CheckInMethod,
|
||||||
sheet.CheckInToken,
|
|
||||||
sheet.CheckInStartsAt,
|
sheet.CheckInStartsAt,
|
||||||
sheet.CheckInEndsAt
|
sheet.CheckInEndsAt
|
||||||
});
|
});
|
||||||
@@ -195,7 +200,6 @@ public sealed class AttendanceController(
|
|||||||
x.AttendanceDate,
|
x.AttendanceDate,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.CheckInMethod,
|
x.CheckInMethod,
|
||||||
x.CheckInToken,
|
|
||||||
x.CheckInStartsAt,
|
x.CheckInStartsAt,
|
||||||
x.CheckInEndsAt,
|
x.CheckInEndsAt,
|
||||||
x.TargetLatitude,
|
x.TargetLatitude,
|
||||||
@@ -242,6 +246,103 @@ public sealed class AttendanceController(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.Where(x => x.AttendanceSheetId == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.IsSuccessful,
|
||||||
|
x.FailureCode,
|
||||||
|
x.DeviceIdentifierHash,
|
||||||
|
x.DevicePlatform,
|
||||||
|
x.IpAddress,
|
||||||
|
x.RiskFlags
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var attemptsByStudent = attempts
|
||||||
|
.GroupBy(x => x.StudentId)
|
||||||
|
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
|
||||||
|
var deviceHashes = attempts
|
||||||
|
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
|
||||||
|
.Select(x => x.DeviceIdentifierHash!)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
if (deviceHashes.Count > 0)
|
||||||
|
{
|
||||||
|
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.IsSuccessful &&
|
||||||
|
x.CreatedAt >= now.AddHours(-24) &&
|
||||||
|
x.DeviceIdentifierHash != null)
|
||||||
|
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
|
||||||
|
.GroupBy(x => x.DeviceIdentifierHash!)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
DeviceIdentifierHash = x.Key,
|
||||||
|
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
deviceReuseCounts = reuseRows.ToDictionary(
|
||||||
|
x => x.DeviceIdentifierHash,
|
||||||
|
x => x.StudentCount,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseRecords = sheet.Records.Select(r =>
|
||||||
|
{
|
||||||
|
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
|
||||||
|
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
|
||||||
|
var riskFlags = studentAttempts
|
||||||
|
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
|
||||||
|
riskFlags.Add("RepeatedFailures");
|
||||||
|
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
|
||||||
|
riskFlags.Add("HighFrequency");
|
||||||
|
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
|
||||||
|
? deviceReuseCounts.GetValueOrDefault(deviceHash)
|
||||||
|
: 0;
|
||||||
|
if (sharedDeviceStudentCount > 1)
|
||||||
|
riskFlags.Add("SharedDevice");
|
||||||
|
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
|
||||||
|
? attempts
|
||||||
|
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.Distinct()
|
||||||
|
.Count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
r.StudentId,
|
||||||
|
r.StudentNumber,
|
||||||
|
r.Name,
|
||||||
|
r.ClassName,
|
||||||
|
r.Status,
|
||||||
|
r.Notes,
|
||||||
|
r.CheckInAt,
|
||||||
|
r.CheckedInMethod,
|
||||||
|
r.CheckInAccuracyMeters,
|
||||||
|
r.CheckInDistanceMeters,
|
||||||
|
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||||
|
IsDeferred = deferredStudentIds.Contains(r.StudentId),
|
||||||
|
CheckInAudit = latestSuccess is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
latestSuccess.IpAddress,
|
||||||
|
latestSuccess.DevicePlatform,
|
||||||
|
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
|
||||||
|
SharedDeviceStudentCount = sharedDeviceStudentCount,
|
||||||
|
SameIpStudentCount = sameIpStudentCount
|
||||||
|
},
|
||||||
|
AttemptCount = studentAttempts.Count,
|
||||||
|
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
|
||||||
|
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Sheet = new
|
Sheet = new
|
||||||
@@ -252,7 +353,6 @@ public sealed class AttendanceController(
|
|||||||
sheet.AttendanceDate,
|
sheet.AttendanceDate,
|
||||||
sheet.Status,
|
sheet.Status,
|
||||||
sheet.CheckInMethod,
|
sheet.CheckInMethod,
|
||||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
|
||||||
sheet.CheckInStartsAt,
|
sheet.CheckInStartsAt,
|
||||||
sheet.CheckInEndsAt,
|
sheet.CheckInEndsAt,
|
||||||
sheet.TargetLatitude,
|
sheet.TargetLatitude,
|
||||||
@@ -271,21 +371,16 @@ public sealed class AttendanceController(
|
|||||||
sheet.TaskName,
|
sheet.TaskName,
|
||||||
sheet.CourseCode,
|
sheet.CourseCode,
|
||||||
sheet.CourseName,
|
sheet.CourseName,
|
||||||
Records = sheet.Records.Select(r => new
|
Records = responseRecords,
|
||||||
|
RiskSummary = new
|
||||||
{
|
{
|
||||||
r.StudentId,
|
RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
|
||||||
r.StudentNumber,
|
SharedDeviceStudentCount = responseRecords.Count(
|
||||||
r.Name,
|
x => x.RiskFlags.Contains("SharedDevice")),
|
||||||
r.ClassName,
|
FrequentAttemptStudentCount = responseRecords.Count(
|
||||||
r.Status,
|
x => x.RiskFlags.Contains("HighFrequency") ||
|
||||||
r.Notes,
|
x.RiskFlags.Contains("RepeatedFailures"))
|
||||||
r.CheckInAt,
|
}
|
||||||
r.CheckedInMethod,
|
|
||||||
r.CheckInAccuracyMeters,
|
|
||||||
r.CheckInDistanceMeters,
|
|
||||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
|
||||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
CanEdit = canEdit
|
CanEdit = canEdit
|
||||||
});
|
});
|
||||||
@@ -498,6 +593,39 @@ public sealed class AttendanceController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/qr-challenge")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
|
public async Task<ActionResult> GetQrChallenge(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await db.AttendanceSheets
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanManageSheet(sheet)) return Forbid();
|
||||||
|
if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||||
|
string.IsNullOrWhiteSpace(sheet.CheckInToken))
|
||||||
|
return ConflictProblem("该考勤表不是扫码签到。");
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (!IsCheckInOpen(
|
||||||
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt,
|
||||||
|
now))
|
||||||
|
return ConflictProblem("签到尚未开始或已经结束。");
|
||||||
|
|
||||||
|
var challenge = AttendanceCheckInChallenge.Create(
|
||||||
|
sheet.Id,
|
||||||
|
sheet.CheckInToken,
|
||||||
|
now);
|
||||||
|
return Ok(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════ Student endpoints ═══════════════
|
// ═══════════════ Student endpoints ═══════════════
|
||||||
|
|
||||||
[HttpGet("check-in-info")]
|
[HttpGet("check-in-info")]
|
||||||
@@ -511,11 +639,13 @@ public sealed class AttendanceController(
|
|||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
var activity = await db.AttendanceRecords.AsNoTracking()
|
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.StudentId == studentId.Value &&
|
x.StudentId == studentId.Value &&
|
||||||
x.AttendanceSheet!.CheckInToken == token.Trim())
|
x.AttendanceSheetId == sheetId)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
SheetId = x.AttendanceSheetId,
|
SheetId = x.AttendanceSheetId,
|
||||||
@@ -525,6 +655,7 @@ public sealed class AttendanceController(
|
|||||||
x.AttendanceSheet.CheckInMethod,
|
x.AttendanceSheet.CheckInMethod,
|
||||||
x.AttendanceSheet.CheckInStartsAt,
|
x.AttendanceSheet.CheckInStartsAt,
|
||||||
x.AttendanceSheet.CheckInEndsAt,
|
x.AttendanceSheet.CheckInEndsAt,
|
||||||
|
x.AttendanceSheet.CheckInToken,
|
||||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||||
@@ -534,6 +665,13 @@ public sealed class AttendanceController(
|
|||||||
if (activity is null) return NotFound();
|
if (activity is null) return NotFound();
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||||
|
!AttendanceCheckInChallenge.IsValid(
|
||||||
|
token,
|
||||||
|
activity.SheetId,
|
||||||
|
activity.CheckInToken,
|
||||||
|
now))
|
||||||
|
return NotFound();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
activity.SheetId,
|
activity.SheetId,
|
||||||
@@ -605,8 +743,11 @@ public sealed class AttendanceController(
|
|||||||
.Where(x => x.StudentId == studentId.Value);
|
.Where(x => x.StudentId == studentId.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||||
{
|
{
|
||||||
var token = request.Token.Trim();
|
if (!AttendanceCheckInChallenge.TryReadSheetId(
|
||||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
request.Token.Trim(),
|
||||||
|
out var tokenSheetId))
|
||||||
|
return NotFound();
|
||||||
|
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
|
||||||
}
|
}
|
||||||
else if (request.AttendanceSheetId.HasValue)
|
else if (request.AttendanceSheetId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -622,8 +763,59 @@ public sealed class AttendanceController(
|
|||||||
if (record?.AttendanceSheet is null) return NotFound();
|
if (record?.AttendanceSheet is null) return NotFound();
|
||||||
var sheet = record.AttendanceSheet;
|
var sheet = record.AttendanceSheet;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
async Task<ActionResult> RejectAttemptAsync(
|
||||||
|
string failureCode,
|
||||||
|
string detail,
|
||||||
|
double? distanceMeters = null)
|
||||||
|
{
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
failureCode,
|
||||||
|
distanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return ConflictProblem(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
|
||||||
|
!AttendanceCheckInChallenge.IsValid(
|
||||||
|
request.Token,
|
||||||
|
sheet.Id,
|
||||||
|
sheet.CheckInToken,
|
||||||
|
now))
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"InvalidQrChallenge",
|
||||||
|
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
|
||||||
|
if (!IsCheckInOpen(
|
||||||
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt,
|
||||||
|
now))
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"CheckInClosed",
|
||||||
|
"签到尚未开始或已经结束。");
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"ManualSheet",
|
||||||
|
"该考勤表不支持学生在线签到。");
|
||||||
if (record.CheckInAt.HasValue)
|
if (record.CheckInAt.HasValue)
|
||||||
{
|
{
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
record.CheckInDistanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
AlreadyCheckedIn = true,
|
AlreadyCheckedIn = true,
|
||||||
@@ -631,37 +823,35 @@ public sealed class AttendanceController(
|
|||||||
record.CheckInDistanceMeters
|
record.CheckInDistanceMeters
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!IsCheckInOpen(
|
|
||||||
sheet.Status,
|
|
||||||
sheet.CheckInMethod,
|
|
||||||
sheet.CheckInStartsAt,
|
|
||||||
sheet.CheckInEndsAt,
|
|
||||||
now))
|
|
||||||
return ConflictProblem("签到尚未开始或已经结束。");
|
|
||||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
|
||||||
return ConflictProblem("该考勤表不支持学生在线签到。");
|
|
||||||
|
|
||||||
double? distanceMeters = null;
|
double? distanceMeters = null;
|
||||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Token) ||
|
|
||||||
!string.Equals(
|
|
||||||
sheet.CheckInToken,
|
|
||||||
request.Token.Trim(),
|
|
||||||
StringComparison.Ordinal))
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
|
||||||
{
|
{
|
||||||
if (request.Latitude is < -90 or > 90 ||
|
if (request.Latitude is < -90 or > 90 ||
|
||||||
request.Longitude is < -180 or > 180 ||
|
request.Longitude is < -180 or > 180 ||
|
||||||
request.Latitude is null ||
|
request.Latitude is null ||
|
||||||
request.Longitude is null)
|
request.Longitude is null)
|
||||||
return ConflictProblem("未获取到有效的当前位置。");
|
return await RejectAttemptAsync(
|
||||||
|
"InvalidLocation",
|
||||||
|
"未获取到有效的当前位置。");
|
||||||
if (sheet.TargetLatitude is null ||
|
if (sheet.TargetLatitude is null ||
|
||||||
sheet.TargetLongitude is null ||
|
sheet.TargetLongitude is null ||
|
||||||
sheet.LocationRadiusMeters is null)
|
sheet.LocationRadiusMeters is null)
|
||||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
return await RejectAttemptAsync(
|
||||||
|
"LocationNotConfigured",
|
||||||
|
"签到活动没有配置有效的位置范围。");
|
||||||
|
var maximumAllowedAccuracyMeters = Math.Min(
|
||||||
|
100d,
|
||||||
|
sheet.LocationRadiusMeters.Value);
|
||||||
|
if (request.AccuracyMeters is null ||
|
||||||
|
!double.IsFinite(request.AccuracyMeters.Value) ||
|
||||||
|
request.AccuracyMeters <= 0 ||
|
||||||
|
request.AccuracyMeters > maximumAllowedAccuracyMeters)
|
||||||
|
{
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"InsufficientAccuracy",
|
||||||
|
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
distanceMeters = CalculateDistanceMeters(
|
distanceMeters = CalculateDistanceMeters(
|
||||||
(double)sheet.TargetLatitude.Value,
|
(double)sheet.TargetLatitude.Value,
|
||||||
@@ -670,8 +860,10 @@ public sealed class AttendanceController(
|
|||||||
(double)request.Longitude.Value);
|
(double)request.Longitude.Value);
|
||||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||||
{
|
{
|
||||||
return ConflictProblem(
|
return await RejectAttemptAsync(
|
||||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
"OutsideGeofence",
|
||||||
|
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
|
||||||
|
distanceMeters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,6 +880,15 @@ public sealed class AttendanceController(
|
|||||||
? request.AccuracyMeters
|
? request.AccuracyMeters
|
||||||
: null;
|
: null;
|
||||||
record.CheckInDistanceMeters = distanceMeters;
|
record.CheckInDistanceMeters = distanceMeters;
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
distanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -793,7 +994,8 @@ public sealed class AttendanceController(
|
|||||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||||
"考勤申诉待处理",
|
"考勤申诉待处理",
|
||||||
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
|
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
|
||||||
"/teacher-attendance", cancellationToken);
|
"/teacher-attendance", cancellationToken,
|
||||||
|
NotificationCategory.Attendance);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -905,7 +1107,7 @@ public sealed class AttendanceController(
|
|||||||
request.Comment is not null
|
request.Comment is not null
|
||||||
? $"您的考勤申诉{result}。意见:{request.Comment}"
|
? $"您的考勤申诉{result}。意见:{request.Comment}"
|
||||||
: $"您的考勤申诉{result}。",
|
: $"您的考勤申诉{result}。",
|
||||||
null, cancellationToken);
|
null, cancellationToken, NotificationCategory.Attendance);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -980,6 +1182,103 @@ public sealed class AttendanceController(
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task AddCheckInAttemptAsync(
|
||||||
|
AttendanceSheet sheet,
|
||||||
|
Guid studentId,
|
||||||
|
AttendanceCheckInRequest request,
|
||||||
|
bool isSuccessful,
|
||||||
|
string? failureCode,
|
||||||
|
double? distanceMeters,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
|
||||||
|
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (deviceIdentifierHash is null)
|
||||||
|
{
|
||||||
|
riskFlags.Add("MissingDeviceId");
|
||||||
|
}
|
||||||
|
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.IsSuccessful &&
|
||||||
|
x.StudentId != studentId &&
|
||||||
|
x.DeviceIdentifierHash == deviceIdentifierHash &&
|
||||||
|
x.CreatedAt >= now.AddHours(-24),
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
riskFlags.Add("SharedDevice");
|
||||||
|
}
|
||||||
|
|
||||||
|
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.StudentId == studentId &&
|
||||||
|
x.CreatedAt >= now.AddMinutes(-2),
|
||||||
|
cancellationToken);
|
||||||
|
if (recentAttemptCount >= 5)
|
||||||
|
riskFlags.Add("HighFrequency");
|
||||||
|
|
||||||
|
if (!isSuccessful)
|
||||||
|
{
|
||||||
|
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.StudentId == studentId &&
|
||||||
|
!x.IsSuccessful &&
|
||||||
|
x.CreatedAt >= now.AddMinutes(-5),
|
||||||
|
cancellationToken);
|
||||||
|
if (recentFailureCount >= 2)
|
||||||
|
riskFlags.Add("RepeatedFailures");
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = ControllerContext.HttpContext;
|
||||||
|
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
|
||||||
|
{
|
||||||
|
AttendanceSheetId = sheet.Id,
|
||||||
|
StudentId = studentId,
|
||||||
|
CheckInMethod = sheet.CheckInMethod,
|
||||||
|
IsSuccessful = isSuccessful,
|
||||||
|
FailureCode = failureCode,
|
||||||
|
DeviceIdentifierHash = deviceIdentifierHash,
|
||||||
|
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
|
||||||
|
IpAddress = Limit(
|
||||||
|
context?.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
64),
|
||||||
|
UserAgent = Limit(
|
||||||
|
context?.Request.Headers.UserAgent.ToString(),
|
||||||
|
500),
|
||||||
|
RiskFlags = riskFlags.Count == 0
|
||||||
|
? null
|
||||||
|
: string.Join(',', riskFlags.OrderBy(x => x)),
|
||||||
|
Latitude = request.Latitude,
|
||||||
|
Longitude = request.Longitude,
|
||||||
|
AccuracyMeters = request.AccuracyMeters,
|
||||||
|
DistanceMeters = distanceMeters,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HashDeviceIdentifier(string? deviceId)
|
||||||
|
{
|
||||||
|
var normalized = Normalize(deviceId)?.ToLowerInvariant();
|
||||||
|
return normalized is null
|
||||||
|
? null
|
||||||
|
: Convert.ToHexString(
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ParseRiskFlags(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? []
|
||||||
|
: value.Split(
|
||||||
|
',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries |
|
||||||
|
StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
private static string? Limit(string? value, int maximumLength) =>
|
||||||
|
value is null || value.Length <= maximumLength
|
||||||
|
? value
|
||||||
|
: value[..maximumLength];
|
||||||
|
|
||||||
private static bool IsCheckInOpen(
|
private static bool IsCheckInOpen(
|
||||||
AttendanceSheetStatus status,
|
AttendanceSheetStatus status,
|
||||||
AttendanceCheckInMethod method,
|
AttendanceCheckInMethod method,
|
||||||
@@ -1031,16 +1330,14 @@ public sealed class AttendanceController(
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (course is null) return null;
|
if (course is null) return null;
|
||||||
|
|
||||||
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
|
var enrolledStudents = await TeachingTaskRosterQuery
|
||||||
.Where(x =>
|
.ForTask(db, teachingTaskId)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
.AsNoTracking()
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)
|
|
||||||
.Select(x => new AttendanceStatisticsStudentIdentity(
|
.Select(x => new AttendanceStatisticsStudentIdentity(
|
||||||
x.StudentId,
|
x.Id,
|
||||||
x.Student!.StudentNumber,
|
x.StudentNumber,
|
||||||
x.Student.Name,
|
x.Name,
|
||||||
x.Student.AdministrativeClass!.Name))
|
x.AdministrativeClass!.Name))
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var submittedSheets = await db.AttendanceSheets.AsNoTracking()
|
var submittedSheets = await db.AttendanceSheets.AsNoTracking()
|
||||||
@@ -1376,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
|
|||||||
[MaxLength(64)] string? Token,
|
[MaxLength(64)] string? Token,
|
||||||
[Range(-90, 90)] decimal? Latitude,
|
[Range(-90, 90)] decimal? Latitude,
|
||||||
[Range(-180, 180)] decimal? Longitude,
|
[Range(-180, 180)] decimal? Longitude,
|
||||||
[Range(0, 5000)] double? AccuracyMeters);
|
[Range(0, 5000)] double? AccuracyMeters,
|
||||||
|
[MaxLength(128)] string? DeviceId = null,
|
||||||
|
[MaxLength(32)] string? DevicePlatform = null);
|
||||||
|
|
||||||
public sealed record AttendanceCourseStatistics(
|
public sealed record AttendanceCourseStatistics(
|
||||||
AttendanceStatisticsCourse Course,
|
AttendanceStatisticsCourse Course,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@@ -17,35 +18,51 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
public sealed class AuthController(
|
public sealed class AuthController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ITokenService tokenService) : ControllerBase
|
IAuthSessionService authSessionService,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("activation-options")]
|
[HttpGet("activation-options")]
|
||||||
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
|
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var colleges = await db.Colleges.AsNoTracking()
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Where(x => x.IsEnabled)
|
AppCacheKeys.ActivationOptions,
|
||||||
.OrderBy(x => x.Code)
|
async token =>
|
||||||
.Select(x => new { x.Id, x.Code, x.Name })
|
{
|
||||||
.ToListAsync(cancellationToken);
|
var colleges = await db.Colleges.AsNoTracking()
|
||||||
var majors = await db.Majors.AsNoTracking()
|
.Where(x => x.IsEnabled)
|
||||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
.OrderBy(x => x.Code)
|
||||||
.OrderBy(x => x.Code)
|
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
|
||||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
.ToListAsync(token);
|
||||||
.ToListAsync(cancellationToken);
|
var majors = await db.Majors.AsNoTracking()
|
||||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
.OrderBy(x => x.Code)
|
||||||
.OrderByDescending(x => x.Grade)
|
.Select(x => new ActivationMajorOption(
|
||||||
.ThenBy(x => x.Code)
|
x.Id, x.Code, x.Name, x.CollegeId))
|
||||||
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
|
.ToListAsync(token);
|
||||||
.ToListAsync(cancellationToken);
|
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||||
return Ok(new
|
.Where(x =>
|
||||||
{
|
x.IsEnabled &&
|
||||||
Colleges = colleges,
|
x.Major!.IsEnabled &&
|
||||||
Majors = majors,
|
x.Major.College!.IsEnabled)
|
||||||
Classes = classes,
|
.OrderByDescending(x => x.Grade)
|
||||||
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
|
.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]
|
[AllowAnonymous]
|
||||||
@@ -118,8 +135,11 @@ public sealed class AuthController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
public async Task<ActionResult<LoginResponse>> Login(
|
||||||
|
LoginRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await userManager.FindByNameAsync(request.UserName);
|
var user = await userManager.FindByNameAsync(request.UserName);
|
||||||
if (user is null || !user.IsEnabled)
|
if (user is null || !user.IsEnabled)
|
||||||
@@ -149,15 +169,47 @@ public sealed class AuthController(
|
|||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
var roles = await userManager.GetRolesAsync(user);
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
return new LoginResponse(
|
var session = await authSessionService.CreateAsync(
|
||||||
tokenService.Create(user, roles),
|
user,
|
||||||
new CurrentUserResponse(
|
roles,
|
||||||
user.Id,
|
request.IsNativeApp
|
||||||
user.UserName!,
|
? AuthenticationClientType.App
|
||||||
user.DisplayName,
|
: AuthenticationClientType.Web,
|
||||||
roles,
|
cancellationToken);
|
||||||
user.CollegeId,
|
return CreateLoginResponse(session);
|
||||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("token-refresh")]
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<ActionResult<LoginResponse>> Refresh(
|
||||||
|
RefreshTokenRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await authSessionService.RefreshAsync(
|
||||||
|
request.RefreshToken,
|
||||||
|
cancellationToken);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "登录已过期",
|
||||||
|
Detail = "登录已过期或刷新令牌已失效,请重新登录。",
|
||||||
|
Status = StatusCodes.Status401Unauthorized
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateLoginResponse(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public async Task<IActionResult> Logout(
|
||||||
|
RefreshTokenRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -195,11 +247,29 @@ public sealed class AuthController(
|
|||||||
Detail = detail,
|
Detail = detail,
|
||||||
Status = status
|
Status = status
|
||||||
});
|
});
|
||||||
|
|
||||||
|
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
|
||||||
|
new(
|
||||||
|
session.AccessToken,
|
||||||
|
session.AccessTokenExpiresAt,
|
||||||
|
session.RefreshToken,
|
||||||
|
session.SessionExpiresAt,
|
||||||
|
new CurrentUserResponse(
|
||||||
|
session.User.Id,
|
||||||
|
session.User.UserName!,
|
||||||
|
session.User.DisplayName,
|
||||||
|
session.Roles,
|
||||||
|
session.User.CollegeId,
|
||||||
|
EffectiveDataScopeResolver.Resolve(session.Roles).ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record LoginRequest(
|
public sealed record LoginRequest(
|
||||||
[Required, MaxLength(100)] string UserName,
|
[Required, MaxLength(100)] string UserName,
|
||||||
[Required, MaxLength(100)] string Password);
|
[Required, MaxLength(100)] string Password,
|
||||||
|
bool IsNativeApp = false);
|
||||||
|
|
||||||
|
public sealed record RefreshTokenRequest(
|
||||||
|
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
|
||||||
|
|
||||||
public sealed record StudentActivationRequest(
|
public sealed record StudentActivationRequest(
|
||||||
[Required, MaxLength(50)] string Name,
|
[Required, MaxLength(50)] string Name,
|
||||||
@@ -210,7 +280,12 @@ public sealed record StudentActivationRequest(
|
|||||||
Guid AdministrativeClassId,
|
Guid AdministrativeClassId,
|
||||||
[Required, MinLength(8), MaxLength(100)] string Password);
|
[Required, MinLength(8), MaxLength(100)] string Password);
|
||||||
|
|
||||||
public sealed record LoginResponse(string Token, CurrentUserResponse User);
|
public sealed record LoginResponse(
|
||||||
|
string Token,
|
||||||
|
DateTime AccessTokenExpiresAt,
|
||||||
|
string RefreshToken,
|
||||||
|
DateTime SessionExpiresAt,
|
||||||
|
CurrentUserResponse User);
|
||||||
|
|
||||||
public sealed record CurrentUserResponse(
|
public sealed record CurrentUserResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -219,3 +294,24 @@ public sealed record CurrentUserResponse(
|
|||||||
IEnumerable<string> Roles,
|
IEnumerable<string> Roles,
|
||||||
Guid? CollegeId,
|
Guid? CollegeId,
|
||||||
string EffectiveDataScope);
|
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.Academic;
|
||||||
using Jiaowu.Api.Domain.Common;
|
using Jiaowu.Api.Domain.Common;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/base-data")]
|
[Route("api/base-data")]
|
||||||
public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string Administrators =
|
private const string Administrators =
|
||||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||||
@@ -20,9 +21,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
[HttpGet("campuses")]
|
[HttpGet("campuses")]
|
||||||
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
|
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
await db.Campuses.AsNoTracking()
|
await cache.GetOrCreateAsync(
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
AppCacheKeys.BaseData("campuses"),
|
||||||
.ToListAsync(cancellationToken);
|
token => db.Campuses.AsNoTracking()
|
||||||
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
[HttpPost("campuses")]
|
[HttpPost("campuses")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -52,21 +58,32 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
if (entity is null) return NotFound();
|
if (entity is null) return NotFound();
|
||||||
ApplyCatalog(entity, request);
|
ApplyCatalog(entity, request);
|
||||||
entity.Address = request.Description?.Trim();
|
entity.Address = request.Description?.Trim();
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("colleges")]
|
[HttpGet("colleges")]
|
||||||
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken)
|
||||||
Ok(await db.Colleges.AsNoTracking()
|
{
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("colleges"),
|
||||||
{
|
token => db.Colleges.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
CampusName = x.Campus != null ? x.Campus.Name : null,
|
.Select(x => new CollegeListItem(
|
||||||
x.IsEnabled, x.SortOrder
|
x.Id,
|
||||||
})
|
x.Code,
|
||||||
.ToListAsync(cancellationToken));
|
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")]
|
[HttpPost("colleges")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -104,29 +121,46 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
ApplyCatalog(entity, request);
|
ApplyCatalog(entity, request);
|
||||||
entity.ShortName = request.ShortName?.Trim();
|
entity.ShortName = request.ShortName?.Trim();
|
||||||
entity.CampusId = request.CampusId;
|
entity.CampusId = request.CampusId;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("majors")]
|
[HttpGet("majors")]
|
||||||
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken)
|
||||||
Ok(await db.Majors.AsNoTracking()
|
{
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("majors"),
|
||||||
{
|
token => db.Majors.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.CollegeId,
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
CollegeName = x.College!.Name,
|
.Select(x => new MajorListItem(
|
||||||
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
|
x.Id,
|
||||||
})
|
x.Code,
|
||||||
.ToListAsync(cancellationToken));
|
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")]
|
[HttpGet("course-categories")]
|
||||||
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
|
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
await db.CourseCategories.AsNoTracking()
|
await cache.GetOrCreateAsync(
|
||||||
.OrderBy(x => x.SortOrder)
|
AppCacheKeys.BaseData("course-categories"),
|
||||||
.ThenBy(x => x.Code)
|
token => db.CourseCategories.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
[HttpPost("course-categories")]
|
[HttpPost("course-categories")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -154,7 +188,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
|
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
|
||||||
if (entity is null) return NotFound();
|
if (entity is null) return NotFound();
|
||||||
ApplyCatalog(entity, request);
|
ApplyCatalog(entity, request);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,23 +227,35 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
entity.CollegeId = request.CollegeId;
|
entity.CollegeId = request.CollegeId;
|
||||||
entity.DegreeType = request.DegreeType.Trim();
|
entity.DegreeType = request.DegreeType.Trim();
|
||||||
entity.SchoolingYears = request.SchoolingYears;
|
entity.SchoolingYears = request.SchoolingYears;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classes")]
|
[HttpGet("classes")]
|
||||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken)
|
||||||
Ok(await db.AdministrativeClasses.AsNoTracking()
|
{
|
||||||
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("classes"),
|
||||||
{
|
token => db.AdministrativeClasses.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.MajorId,
|
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||||
MajorName = x.Major!.Name,
|
.Select(x => new AdministrativeClassListItem(
|
||||||
CollegeName = x.Major.College!.Name,
|
x.Id,
|
||||||
x.Grade, x.CounselorUserId, x.CounselorName,
|
x.Code,
|
||||||
x.IsEnabled, x.SortOrder
|
x.Name,
|
||||||
})
|
x.MajorId,
|
||||||
.ToListAsync(cancellationToken));
|
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")]
|
[HttpGet("counselors")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -279,18 +325,23 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
entity.Grade = request.Grade;
|
entity.Grade = request.Grade;
|
||||||
entity.CounselorUserId = request.CounselorUserId;
|
entity.CounselorUserId = request.CounselorUserId;
|
||||||
entity.CounselorName = counselorName;
|
entity.CounselorName = counselorName;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("terms")]
|
[HttpGet("terms")]
|
||||||
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
|
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
await db.AcademicTerms.AsNoTracking()
|
await cache.GetOrCreateAsync(
|
||||||
.OrderByDescending(x => x.IsCurrent)
|
AppCacheKeys.BaseData("terms"),
|
||||||
.ThenBy(x => x.IsArchived)
|
token => db.AcademicTerms.AsNoTracking()
|
||||||
.ThenByDescending(x => x.StartDate)
|
.OrderByDescending(x => x.IsCurrent)
|
||||||
.ToListAsync(cancellationToken);
|
.ThenBy(x => x.IsArchived)
|
||||||
|
.ThenByDescending(x => x.StartDate)
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
[HttpPost("terms")]
|
[HttpPost("terms")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
entity.StartDate = request.StartDate;
|
entity.StartDate = request.StartDate;
|
||||||
entity.EndDate = request.EndDate;
|
entity.EndDate = request.EndDate;
|
||||||
entity.IsCurrent = request.IsCurrent;
|
entity.IsCurrent = request.IsCurrent;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
foreach (var currentTerm in currentTerms)
|
foreach (var currentTerm in currentTerms)
|
||||||
currentTerm.IsCurrent = false;
|
currentTerm.IsCurrent = false;
|
||||||
entity.IsCurrent = true;
|
entity.IsCurrent = true;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
|
|
||||||
entity.IsArchived = true;
|
entity.IsArchived = true;
|
||||||
entity.ArchivedAt = DateTime.UtcNow;
|
entity.ArchivedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
|
|
||||||
entity.IsArchived = false;
|
entity.IsArchived = false;
|
||||||
entity.ArchivedAt = null;
|
entity.ArchivedAt = null;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classrooms")]
|
[HttpGet("classrooms")]
|
||||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
|
||||||
Ok(await db.Classrooms.AsNoTracking()
|
{
|
||||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.ThenBy(x => x.Code)
|
AppCacheKeys.BaseData("classrooms"),
|
||||||
.Select(x => new
|
token => db.Classrooms.AsNoTracking()
|
||||||
{
|
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||||
x.Id, x.Code, x.Name, x.BuildingId,
|
.ThenBy(x => x.Code)
|
||||||
CampusId = x.Building!.CampusId,
|
.Select(x => new ClassroomListItem(
|
||||||
BuildingName = x.Building!.Name,
|
x.Id,
|
||||||
CampusName = x.Building.Campus!.Name,
|
x.Code,
|
||||||
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
|
x.Name,
|
||||||
})
|
x.BuildingId,
|
||||||
.ToListAsync(cancellationToken));
|
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")]
|
[HttpGet("buildings")]
|
||||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken)
|
||||||
Ok(await db.Buildings.AsNoTracking()
|
{
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("buildings"),
|
||||||
{
|
token => db.Buildings.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.CampusId,
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
|
.Select(x => new BuildingListItem(
|
||||||
})
|
x.Id,
|
||||||
.ToListAsync(cancellationToken));
|
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")]
|
[HttpPost("buildings")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
entity.Capacity = request.Capacity;
|
entity.Capacity = request.Capacity;
|
||||||
entity.RoomType = request.RoomType.Trim();
|
entity.RoomType = request.RoomType.Trim();
|
||||||
entity.Equipment = request.Equipment?.Trim();
|
entity.Equipment = request.Equipment?.Trim();
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
db.Remove(entity);
|
db.Remove(entity);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
db.Add(entity);
|
db.Add(entity);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
{
|
{
|
||||||
@@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
return CreatedAtAction(action, new { id = entity.Id }, entity);
|
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(
|
private async Task<string?> GetCounselorNameAsync(
|
||||||
Guid? userId,
|
Guid? userId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -649,3 +730,60 @@ public sealed record ClassroomRequest(
|
|||||||
[Required, MaxLength(40)] string RoomType,
|
[Required, MaxLength(40)] string RoomType,
|
||||||
[MaxLength(300)] string? Equipment)
|
[MaxLength(300)] string? Equipment)
|
||||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
: 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.Academic;
|
||||||
using Jiaowu.Api.Domain.Common;
|
using Jiaowu.Api.Domain.Common;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
[Route("api/base-data")]
|
[Route("api/base-data")]
|
||||||
public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string Administrators =
|
private const string Administrators =
|
||||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||||
@@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.BaseData,
|
||||||
|
cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
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.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -27,6 +29,145 @@ public sealed class CourseAdjustmentsController(
|
|||||||
SystemRoles.AcademicAdmin + "," +
|
SystemRoles.AcademicAdmin + "," +
|
||||||
SystemRoles.CollegeAdmin;
|
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 ═══════════════
|
// ═══════════════ My adjustments ═══════════════
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
@@ -100,20 +241,41 @@ public sealed class CourseAdjustmentsController(
|
|||||||
var validation = await ValidateRequestAsync(request, cancellationToken);
|
var validation = await ValidateRequestAsync(request, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
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
|
var adj = new CourseAdjustment
|
||||||
{
|
{
|
||||||
TeachingTaskId = request.TeachingTaskId,
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
Type = request.Type,
|
Type = request.Type,
|
||||||
ApplicantUserId = userId,
|
ApplicantUserId = userId,
|
||||||
Reason = request.Reason.Trim(),
|
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,
|
TargetDate = request.TargetDate,
|
||||||
DayOfWeek = request.DayOfWeek,
|
DayOfWeek = targetDayOfWeek,
|
||||||
StartPeriod = request.StartPeriod,
|
StartPeriod = request.StartPeriod,
|
||||||
PeriodCount = request.PeriodCount,
|
PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount,
|
||||||
ClassroomId = request.ClassroomId,
|
ClassroomId = request.ClassroomId,
|
||||||
SubstituteTeacherId = request.SubstituteTeacherId,
|
SubstituteTeacherId = request.SubstituteTeacherId,
|
||||||
CancelWeek = request.CancelWeek,
|
CancelWeek = request.Type == CourseAdjustmentType.Cancel
|
||||||
CancelDate = request.CancelDate
|
? source?.Week
|
||||||
|
: request.CancelWeek,
|
||||||
|
CancelDate = request.Type == CourseAdjustmentType.Cancel
|
||||||
|
? source?.Date
|
||||||
|
: request.CancelDate
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.Submit)
|
if (request.Submit)
|
||||||
@@ -138,7 +300,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
SystemRoles.CollegeAdmin,
|
SystemRoles.CollegeAdmin,
|
||||||
$"新的{tl}申请",
|
$"新的{tl}申请",
|
||||||
$"《{taskInfo.Name}》提交了{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)
|
if (adj.Status != CourseAdjustmentStatus.Draft)
|
||||||
return ConflictProblem("只有草稿可以提交。");
|
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.Status = CourseAdjustmentStatus.Submitted;
|
||||||
adj.SubmittedAt = DateTime.UtcNow;
|
adj.SubmittedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -171,12 +353,14 @@ public sealed class CourseAdjustmentsController(
|
|||||||
$"新的{TypeLabel(adj.Type)}申请",
|
$"新的{TypeLabel(adj.Type)}申请",
|
||||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
||||||
adj.TeachingTask.Course.CollegeId,
|
adj.TeachingTask.Course.CollegeId,
|
||||||
"/course-adjustments", cancellationToken);
|
"/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
await NotificationService.SendToRoleAsync(db,
|
await NotificationService.SendToRoleAsync(db,
|
||||||
SystemRoles.AcademicAdmin,
|
SystemRoles.AcademicAdmin,
|
||||||
$"新的{TypeLabel(adj.Type)}申请",
|
$"新的{TypeLabel(adj.Type)}申请",
|
||||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
||||||
null, "/course-adjustments", cancellationToken);
|
null, "/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +374,12 @@ public sealed class CourseAdjustmentsController(
|
|||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.ThenInclude(x => x!.College)
|
.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);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (adj is null) return NotFound();
|
if (adj is null) return NotFound();
|
||||||
|
|
||||||
@@ -200,6 +390,12 @@ public sealed class CourseAdjustmentsController(
|
|||||||
if (adj.Status != CourseAdjustmentStatus.Submitted)
|
if (adj.Status != CourseAdjustmentStatus.Submitted)
|
||||||
return ConflictProblem("只有待审核的申请可以审批。");
|
return ConflictProblem("只有待审核的申请可以审批。");
|
||||||
|
|
||||||
|
var scheduleProblem = await ValidateApprovalScheduleAsync(
|
||||||
|
adj,
|
||||||
|
cancellationToken);
|
||||||
|
if (scheduleProblem is not null)
|
||||||
|
return ConflictProblem(scheduleProblem);
|
||||||
|
|
||||||
adj.Status = CourseAdjustmentStatus.Approved;
|
adj.Status = CourseAdjustmentStatus.Approved;
|
||||||
adj.ReviewedAt = DateTime.UtcNow;
|
adj.ReviewedAt = DateTime.UtcNow;
|
||||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||||
@@ -213,14 +409,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||||
"调停课申请已通过",
|
"调停课申请已通过",
|
||||||
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
||||||
"/course-adjustments", cancellationToken);
|
"/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
|
||||||
// Notify affected students
|
// Notify affected students
|
||||||
var studentUserIds = await db.CourseEnrollments
|
var studentUserIds = await TeachingTaskRosterQuery
|
||||||
.Where(x =>
|
.ForTask(db, adj.TeachingTaskId)
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
|
.Select(x => x.UserId)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
|
||||||
.Select(x => x.Student!.UserId)
|
|
||||||
.Where(uid => uid != null)
|
.Where(uid => uid != null)
|
||||||
.Select(uid => uid!.Value)
|
.Select(uid => uid!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -230,7 +425,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||||
"课程变动通知",
|
"课程变动通知",
|
||||||
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
||||||
"/my-timetable", cancellationToken);
|
"/my-timetable", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -242,81 +438,19 @@ public sealed class CourseAdjustmentsController(
|
|||||||
switch (adj.Type)
|
switch (adj.Type)
|
||||||
{
|
{
|
||||||
case CourseAdjustmentType.Reschedule:
|
case CourseAdjustmentType.Reschedule:
|
||||||
// Update existing schedule entries for this teaching task
|
var rescheduleSource = await LoadSourceEntryAsync(adj, ct);
|
||||||
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
|
ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value);
|
||||||
{
|
db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CourseAdjustmentType.Cancel:
|
case CourseAdjustmentType.Cancel:
|
||||||
// Cancel: remove schedule entries for the specified week
|
var cancelSource = await LoadSourceEntryAsync(adj, ct);
|
||||||
if (adj.CancelWeek.HasValue)
|
ExcludeWeek(cancelSource, adj.SourceWeek!.Value);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CourseAdjustmentType.Makeup:
|
case CourseAdjustmentType.Makeup:
|
||||||
// Makeup: add a temp schedule entry for the makeup date
|
var makeupSource = await LoadSourceEntryAsync(adj, ct);
|
||||||
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
|
db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId));
|
||||||
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})"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CourseAdjustmentType.Substitute:
|
case CourseAdjustmentType.Substitute:
|
||||||
@@ -383,7 +517,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
||||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||||
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
||||||
"/course-adjustments", cancellationToken);
|
"/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +528,7 @@ public sealed class CourseAdjustmentsController(
|
|||||||
CourseAdjustmentRequest request,
|
CourseAdjustmentRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await db.TeachingTasks.AsNoTracking()
|
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
.Include(x => x.Course)
|
.Include(x => x.Course)
|
||||||
.FirstOrDefaultAsync(x =>
|
.FirstOrDefaultAsync(x =>
|
||||||
x.Id == request.TeachingTaskId &&
|
x.Id == request.TeachingTaskId &&
|
||||||
@@ -405,15 +540,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
{
|
{
|
||||||
case CourseAdjustmentType.Reschedule:
|
case CourseAdjustmentType.Reschedule:
|
||||||
case CourseAdjustmentType.Makeup:
|
case CourseAdjustmentType.Makeup:
|
||||||
if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7)
|
if (!request.TargetDate.HasValue)
|
||||||
return ValidationProblem("请选择有效的上课日。");
|
return ValidationProblem(
|
||||||
|
request.Type == CourseAdjustmentType.Reschedule
|
||||||
|
? "请选择调课后的日期。"
|
||||||
|
: "请选择补课日期。");
|
||||||
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
|
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
|
||||||
return ValidationProblem("请选择起始节次。");
|
return ValidationProblem("请选择起始节次。");
|
||||||
if (!request.PeriodCount.HasValue || request.PeriodCount < 1)
|
|
||||||
return ValidationProblem("请选择持续节数。");
|
|
||||||
if (request.Type == CourseAdjustmentType.Makeup &&
|
|
||||||
!request.TargetDate.HasValue)
|
|
||||||
return ValidationProblem("补课必须指定日期。");
|
|
||||||
break;
|
break;
|
||||||
case CourseAdjustmentType.Substitute:
|
case CourseAdjustmentType.Substitute:
|
||||||
if (!request.SubstituteTeacherId.HasValue)
|
if (!request.SubstituteTeacherId.HasValue)
|
||||||
@@ -424,15 +557,241 @@ public sealed class CourseAdjustmentsController(
|
|||||||
return ValidationProblem("代课教师不存在或已离职。");
|
return ValidationProblem("代课教师不存在或已离职。");
|
||||||
break;
|
break;
|
||||||
case CourseAdjustmentType.Cancel:
|
case CourseAdjustmentType.Cancel:
|
||||||
if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue)
|
|
||||||
return ValidationProblem("停课需指定周次或日期。");
|
|
||||||
break;
|
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;
|
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
|
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -449,6 +808,17 @@ public sealed class CourseAdjustmentsController(
|
|||||||
x.Status,
|
x.Status,
|
||||||
x.Reason,
|
x.Reason,
|
||||||
x.ReviewComment,
|
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.TargetDate,
|
||||||
x.DayOfWeek,
|
x.DayOfWeek,
|
||||||
x.StartPeriod,
|
x.StartPeriod,
|
||||||
@@ -464,6 +834,12 @@ public sealed class CourseAdjustmentsController(
|
|||||||
x.CreatedAt
|
x.CreatedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private sealed record SourceOccurrence(
|
||||||
|
ScheduleEntry Entry,
|
||||||
|
AcademicTerm Term,
|
||||||
|
int Week,
|
||||||
|
DateOnly Date);
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
@@ -487,7 +863,9 @@ public sealed record CourseAdjustmentRequest(
|
|||||||
Guid? ClassroomId,
|
Guid? ClassroomId,
|
||||||
Guid? SubstituteTeacherId,
|
Guid? SubstituteTeacherId,
|
||||||
int? CancelWeek,
|
int? CancelWeek,
|
||||||
DateOnly? CancelDate);
|
DateOnly? CancelDate,
|
||||||
|
Guid? SourceScheduleEntryId = null,
|
||||||
|
int? SourceWeek = null);
|
||||||
|
|
||||||
public sealed record RejectionRequest(
|
public sealed record RejectionRequest(
|
||||||
[MaxLength(500)] string? Comment);
|
[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.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/courses")]
|
[Route("api/courses")]
|
||||||
public sealed class CoursesController(
|
public sealed class CoursesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string WriteRoles =
|
private const string WriteRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -82,6 +84,17 @@ public sealed class CoursesController(
|
|||||||
x.Nature,
|
x.Nature,
|
||||||
x.AssessmentMethod,
|
x.AssessmentMethod,
|
||||||
x.Description,
|
x.Description,
|
||||||
|
PrerequisiteCourseIds = x.Prerequisites
|
||||||
|
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||||
|
.Select(item => item.PrerequisiteCourseId),
|
||||||
|
Prerequisites = x.Prerequisites
|
||||||
|
.OrderBy(item => item.PrerequisiteCourse!.Code)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.PrerequisiteCourseId,
|
||||||
|
item.PrerequisiteCourse!.Code,
|
||||||
|
item.PrerequisiteCourse.Name
|
||||||
|
}),
|
||||||
x.IsEnabled,
|
x.IsEnabled,
|
||||||
x.SortOrder,
|
x.SortOrder,
|
||||||
x.CreatedAt,
|
x.CreatedAt,
|
||||||
@@ -132,6 +145,8 @@ public sealed class CoursesController(
|
|||||||
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
|
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
|
||||||
x.Credits,
|
x.Credits,
|
||||||
x.TotalHours,
|
x.TotalHours,
|
||||||
|
x.LectureHours,
|
||||||
|
x.PracticeHours,
|
||||||
x.Nature,
|
x.Nature,
|
||||||
x.AssessmentMethod
|
x.AssessmentMethod
|
||||||
})
|
})
|
||||||
@@ -144,9 +159,6 @@ public sealed class CoursesController(
|
|||||||
CourseRequest request,
|
CourseRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var validation = await ValidateAsync(request, cancellationToken);
|
|
||||||
if (validation is not null) return validation;
|
|
||||||
|
|
||||||
var entity = new Course
|
var entity = new Course
|
||||||
{
|
{
|
||||||
Code = request.Code.Trim(),
|
Code = request.Code.Trim(),
|
||||||
@@ -164,6 +176,16 @@ public sealed class CoursesController(
|
|||||||
IsEnabled = request.IsEnabled,
|
IsEnabled = request.IsEnabled,
|
||||||
SortOrder = request.SortOrder
|
SortOrder = request.SortOrder
|
||||||
};
|
};
|
||||||
|
var validation = await ValidateAsync(entity.Id, request, cancellationToken);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
|
||||||
|
entity.Prerequisites = NormalizePrerequisiteIds(request)
|
||||||
|
.Select(prerequisiteId => new CoursePrerequisite
|
||||||
|
{
|
||||||
|
CourseId = entity.Id,
|
||||||
|
PrerequisiteCourseId = prerequisiteId
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
db.Courses.Add(entity);
|
db.Courses.Add(entity);
|
||||||
return await SaveAsync(entity.Id, true, cancellationToken);
|
return await SaveAsync(entity.Id, true, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -175,10 +197,12 @@ public sealed class CoursesController(
|
|||||||
CourseRequest request,
|
CourseRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
var entity = await db.Courses
|
||||||
|
.Include(x => x.Prerequisites)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (entity is null) return NotFound();
|
if (entity is null) return NotFound();
|
||||||
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
|
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
|
||||||
var validation = await ValidateAsync(request, cancellationToken);
|
var validation = await ValidateAsync(id, request, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (validation is not null) return validation;
|
||||||
|
|
||||||
entity.Code = request.Code.Trim();
|
entity.Code = request.Code.Trim();
|
||||||
@@ -195,6 +219,19 @@ public sealed class CoursesController(
|
|||||||
entity.Description = Normalize(request.Description);
|
entity.Description = Normalize(request.Description);
|
||||||
entity.IsEnabled = request.IsEnabled;
|
entity.IsEnabled = request.IsEnabled;
|
||||||
entity.SortOrder = request.SortOrder;
|
entity.SortOrder = request.SortOrder;
|
||||||
|
var prerequisiteIds = NormalizePrerequisiteIds(request).ToHashSet();
|
||||||
|
db.CoursePrerequisites.RemoveRange(
|
||||||
|
entity.Prerequisites.Where(x =>
|
||||||
|
!prerequisiteIds.Contains(x.PrerequisiteCourseId)));
|
||||||
|
foreach (var prerequisiteId in prerequisiteIds.Except(
|
||||||
|
entity.Prerequisites.Select(x => x.PrerequisiteCourseId)))
|
||||||
|
{
|
||||||
|
entity.Prerequisites.Add(new CoursePrerequisite
|
||||||
|
{
|
||||||
|
CourseId = entity.Id,
|
||||||
|
PrerequisiteCourseId = prerequisiteId
|
||||||
|
});
|
||||||
|
}
|
||||||
return await SaveAsync(entity.Id, false, cancellationToken);
|
return await SaveAsync(entity.Id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +247,7 @@ public sealed class CoursesController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ActionResult?> ValidateAsync(
|
private async Task<ActionResult?> ValidateAsync(
|
||||||
|
Guid courseId,
|
||||||
CourseRequest request,
|
CourseRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -222,9 +260,62 @@ public sealed class CoursesController(
|
|||||||
return ValidationProblem("所选课程分类不存在或已停用。");
|
return ValidationProblem("所选课程分类不存在或已停用。");
|
||||||
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
||||||
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
||||||
|
|
||||||
|
var prerequisiteIds = NormalizePrerequisiteIds(request).ToArray();
|
||||||
|
if (prerequisiteIds.Contains(courseId))
|
||||||
|
return ValidationProblem("课程不能把自身设置为先修课程。");
|
||||||
|
var accessiblePrerequisiteCount = await ScopedCourses()
|
||||||
|
.CountAsync(x =>
|
||||||
|
prerequisiteIds.Contains(x.Id) &&
|
||||||
|
x.IsEnabled,
|
||||||
|
cancellationToken);
|
||||||
|
if (accessiblePrerequisiteCount != prerequisiteIds.Length)
|
||||||
|
return ValidationProblem("包含不存在、已停用或不在当前数据范围内的先修课程。");
|
||||||
|
if (await CreatesPrerequisiteCycleAsync(
|
||||||
|
courseId,
|
||||||
|
prerequisiteIds,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("先修关系不能形成循环依赖。");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CreatesPrerequisiteCycleAsync(
|
||||||
|
Guid courseId,
|
||||||
|
IReadOnlyCollection<Guid> prerequisiteIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (prerequisiteIds.Count == 0) return false;
|
||||||
|
|
||||||
|
var edges = await db.CoursePrerequisites.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId != courseId)
|
||||||
|
.Select(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var prerequisitesByCourse = edges
|
||||||
|
.GroupBy(x => x.CourseId)
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => group.Select(x => x.PrerequisiteCourseId).ToArray());
|
||||||
|
|
||||||
|
foreach (var prerequisiteId in prerequisiteIds)
|
||||||
|
{
|
||||||
|
var pending = new Stack<Guid>();
|
||||||
|
var visited = new HashSet<Guid>();
|
||||||
|
pending.Push(prerequisiteId);
|
||||||
|
while (pending.TryPop(out var candidate))
|
||||||
|
{
|
||||||
|
if (candidate == courseId) return true;
|
||||||
|
if (!visited.Add(candidate) ||
|
||||||
|
!prerequisitesByCourse.TryGetValue(candidate, out var next))
|
||||||
|
continue;
|
||||||
|
foreach (var item in next) pending.Push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Guid> NormalizePrerequisiteIds(CourseRequest request) =>
|
||||||
|
(request.PrerequisiteCourseIds ?? []).Distinct();
|
||||||
|
|
||||||
private IQueryable<Course> ScopedCourses()
|
private IQueryable<Course> ScopedCourses()
|
||||||
{
|
{
|
||||||
var scope = currentUserDataScope.Current;
|
var scope = currentUserDataScope.Current;
|
||||||
@@ -273,6 +364,7 @@ public sealed class CoursesController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -304,4 +396,5 @@ public sealed record CourseRequest(
|
|||||||
AssessmentMethod AssessmentMethod,
|
AssessmentMethod AssessmentMethod,
|
||||||
[MaxLength(1000)] string? Description,
|
[MaxLength(1000)] string? Description,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
int SortOrder);
|
int SortOrder,
|
||||||
|
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/courses")]
|
[Route("api/courses")]
|
||||||
public sealed class CoursesExcelController(
|
public sealed class CoursesExcelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string WriteRoles =
|
private const string WriteRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -141,6 +143,9 @@ public sealed class CoursesExcelController(
|
|||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -9,75 +11,303 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/dashboard")]
|
[Route("api/dashboard")]
|
||||||
public sealed class DashboardController(AppDbContext db) : ControllerBase
|
public sealed class DashboardController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[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
|
var currentTerm = await db.AcademicTerms
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.IsCurrent)
|
.Where(x => x.IsCurrent)
|
||||||
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
.Select(x => new DashboardTerm(
|
||||||
|
x.Id,
|
||||||
|
x.Name,
|
||||||
|
x.StartDate,
|
||||||
|
x.EndDate))
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
var currentTermId = currentTerm?.Id;
|
||||||
|
|
||||||
return new
|
var students = db.Students.AsNoTracking()
|
||||||
{
|
.Where(x =>
|
||||||
CurrentTerm = currentTerm,
|
!restrictedCollegeId.HasValue ||
|
||||||
Counts = new
|
x.AdministrativeClass!.Major!.CollegeId ==
|
||||||
{
|
restrictedCollegeId.Value);
|
||||||
Campuses = await db.Campuses.CountAsync(cancellationToken),
|
var teachers = db.Teachers.AsNoTracking()
|
||||||
Colleges = await db.Colleges.CountAsync(cancellationToken),
|
.Where(x =>
|
||||||
Majors = await db.Majors.CountAsync(cancellationToken),
|
!restrictedCollegeId.HasValue ||
|
||||||
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
|
x.CollegeId == restrictedCollegeId.Value);
|
||||||
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
|
var courses = db.Courses.AsNoTracking()
|
||||||
Teachers = await db.Teachers.CountAsync(cancellationToken),
|
.Where(x =>
|
||||||
Students = await db.Students.CountAsync(cancellationToken),
|
!restrictedCollegeId.HasValue ||
|
||||||
Courses = await db.Courses.CountAsync(cancellationToken),
|
x.CollegeId == restrictedCollegeId.Value);
|
||||||
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
var teachingTasks = db.TeachingTasks.AsNoTracking()
|
||||||
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
.Where(x =>
|
||||||
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
|
currentTermId.HasValue &&
|
||||||
CourseSelectionRounds = await db.CourseSelectionRounds
|
x.AcademicTermId == currentTermId.Value &&
|
||||||
.CountAsync(cancellationToken),
|
(!restrictedCollegeId.HasValue ||
|
||||||
CourseSelectionOfferings = await db.CourseSelectionOfferings
|
x.Course!.CollegeId == restrictedCollegeId.Value));
|
||||||
.CountAsync(cancellationToken),
|
var gradeSheets = db.GradeSheets.AsNoTracking()
|
||||||
CourseEnrollments = await db.CourseEnrollments
|
.Where(x =>
|
||||||
.CountAsync(
|
currentTermId.HasValue &&
|
||||||
x => x.Status == CourseEnrollmentStatus.Enrolled,
|
x.TeachingTask!.AcademicTermId == currentTermId.Value &&
|
||||||
cancellationToken),
|
(!restrictedCollegeId.HasValue ||
|
||||||
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
|
x.TeachingTask.Course!.CollegeId ==
|
||||||
PublishedGradeSheets = await db.GradeSheets.CountAsync(
|
restrictedCollegeId.Value));
|
||||||
x => x.Status == GradeSheetStatus.Published,
|
var enrollments = db.CourseEnrollments.AsNoTracking()
|
||||||
cancellationToken),
|
.Where(x =>
|
||||||
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
currentTermId.HasValue &&
|
||||||
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
|
x.CourseSelectionOffering!.CourseSelectionRound!
|
||||||
StudentStatusChanges = await db.StudentStatusChanges
|
.AcademicTermId == currentTermId.Value &&
|
||||||
.CountAsync(cancellationToken),
|
(!restrictedCollegeId.HasValue ||
|
||||||
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
|
x.CourseSelectionOffering.TeachingTask!.Course!.CollegeId ==
|
||||||
x => x.State == StudentStatusChangeState.Submitted ||
|
restrictedCollegeId.Value));
|
||||||
x.State == StudentStatusChangeState.CounselorApproved ||
|
|
||||||
x.State == StudentStatusChangeState.CollegeApproved,
|
var taskCount = await teachingTasks.CountAsync(cancellationToken);
|
||||||
cancellationToken),
|
var publishedTaskCount = await teachingTasks.CountAsync(
|
||||||
GraduationAuditBatches = await db.GraduationAuditBatches
|
x => x.Status == TeachingTaskStatus.Published,
|
||||||
.CountAsync(cancellationToken),
|
cancellationToken);
|
||||||
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
|
var scheduledTaskCount = currentTermId.HasValue
|
||||||
.CountAsync(
|
? await db.ScheduleEntries.AsNoTracking()
|
||||||
x => x.Status == GraduationAuditBatchStatus.Published,
|
.Where(x =>
|
||||||
cancellationToken),
|
x.SchedulePlan!.AcademicTermId == currentTermId.Value &&
|
||||||
DegreeAwardBatches = await db.DegreeAwardBatches
|
x.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||||
.CountAsync(cancellationToken),
|
(!restrictedCollegeId.HasValue ||
|
||||||
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
|
x.TeachingTask!.Course!.CollegeId ==
|
||||||
.CountAsync(
|
restrictedCollegeId.Value))
|
||||||
x => x.Status == DegreeAwardBatchStatus.Published,
|
.Select(x => x.TeachingTaskId)
|
||||||
cancellationToken),
|
.Distinct()
|
||||||
GraduationClearanceBatches = await db.GraduationClearanceBatches
|
.CountAsync(cancellationToken)
|
||||||
.CountAsync(cancellationToken),
|
: 0;
|
||||||
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
|
var gradeSheetCount = await gradeSheets.CountAsync(cancellationToken);
|
||||||
.CountAsync(
|
var publishedGradeSheetCount = await gradeSheets.CountAsync(
|
||||||
x => x.Status == GraduationClearanceBatchStatus.Open,
|
x => x.Status == GradeSheetStatus.Published,
|
||||||
cancellationToken),
|
cancellationToken);
|
||||||
Users = await db.Users.CountAsync(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}")]
|
[HttpGet("batches/{id:guid}")]
|
||||||
[Authorize(Roles = Reviewers)]
|
[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()
|
var batch = await db.DegreeAwardBatches.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||||
if (batch is null) return NotFound();
|
if (batch is null) return NotFound();
|
||||||
@@ -71,6 +79,52 @@ public sealed class DegreeAwardsController(
|
|||||||
if (collegeId.HasValue)
|
if (collegeId.HasValue)
|
||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
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
|
return Ok(new
|
||||||
{
|
{
|
||||||
batch.Id,
|
batch.Id,
|
||||||
@@ -82,24 +136,17 @@ public sealed class DegreeAwardsController(
|
|||||||
batch.Notes,
|
batch.Notes,
|
||||||
batch.CalculatedAt,
|
batch.CalculatedAt,
|
||||||
batch.PublishedAt,
|
batch.PublishedAt,
|
||||||
Results = await source.OrderBy(x => x.Student!.StudentNumber)
|
ResultCount = summary?.ResultCount ?? 0,
|
||||||
.Select(x => new
|
GrantedCount = summary?.GrantedCount ?? 0,
|
||||||
{
|
NotGrantedCount = summary?.NotGrantedCount ?? 0,
|
||||||
x.Id,
|
OverrideCount = summary?.OverrideCount ?? 0,
|
||||||
x.StudentId,
|
Results = new
|
||||||
x.Student!.StudentNumber,
|
{
|
||||||
x.Student.Name,
|
Items = items,
|
||||||
ClassName = x.Student.AdministrativeClass!.Name,
|
Total = total,
|
||||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
Page = page,
|
||||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
PageSize = pageSize
|
||||||
x.AverageGradePoint,
|
}
|
||||||
x.CalculatedConclusion,
|
|
||||||
x.Conclusion,
|
|
||||||
x.ExceptionReason,
|
|
||||||
x.IsOverridden,
|
|
||||||
x.ReviewComment,
|
|
||||||
x.ReviewedAt
|
|
||||||
}).ToListAsync(token)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,25 +180,30 @@ public sealed class DegreeAwardsController(
|
|||||||
return ConflictProblem("已发布批次不能重新计算。");
|
return ConflictProblem("已发布批次不能重新计算。");
|
||||||
|
|
||||||
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
|
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
|
||||||
.Include(x => x.GraduationAuditBatch)
|
|
||||||
.Include(x => x.Student)
|
|
||||||
.ThenInclude(x => x!.AdministrativeClass)
|
|
||||||
.ThenInclude(x => x!.Major)
|
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||||
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||||
x.Conclusion == GraduationAuditConclusion.Eligible)
|
x.Conclusion == GraduationAuditConclusion.Eligible)
|
||||||
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.StudentId,
|
||||||
|
StudentStatus = x.Student!.Status
|
||||||
|
})
|
||||||
.ToListAsync(token);
|
.ToListAsync(token);
|
||||||
var audits = auditCandidates
|
var audits = auditCandidates
|
||||||
.GroupBy(x => x.StudentId)
|
.GroupBy(x => x.StudentId)
|
||||||
.Select(x => x.First())
|
.Select(x => x.First())
|
||||||
.ToList();
|
.ToList();
|
||||||
var studentIds = audits.Select(x => x.StudentId).ToArray();
|
|
||||||
var gradePoints = await db.GradeRecords.AsNoTracking()
|
var gradePoints = await db.GradeRecords.AsNoTracking()
|
||||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
x.GradePoint.HasValue)
|
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
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.StudentId,
|
x.StudentId,
|
||||||
@@ -159,14 +211,17 @@ public sealed class DegreeAwardsController(
|
|||||||
x.GradeSheet!.TeachingTask!.Course!.Credits
|
x.GradeSheet!.TeachingTask!.Course!.Credits
|
||||||
}).ToListAsync(token);
|
}).ToListAsync(token);
|
||||||
|
|
||||||
var oldResults = await db.DegreeAwardResults
|
var gradePointsByStudent = gradePoints
|
||||||
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
|
.GroupBy(x => x.StudentId)
|
||||||
db.DegreeAwardResults.RemoveRange(oldResults);
|
.ToDictionary(group => group.Key, group => group.ToList());
|
||||||
await db.SaveChangesAsync(token);
|
|
||||||
|
await db.DegreeAwardResults
|
||||||
|
.Where(x => x.DegreeAwardBatchId == id)
|
||||||
|
.ExecuteDeleteAsync(token);
|
||||||
|
|
||||||
foreach (var audit in audits)
|
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 credits = grades.Sum(x => x.Credits);
|
||||||
var average = credits == 0
|
var average = credits == 0
|
||||||
? 0
|
? 0
|
||||||
@@ -175,8 +230,8 @@ public sealed class DegreeAwardsController(
|
|||||||
2,
|
2,
|
||||||
MidpointRounding.AwayFromZero);
|
MidpointRounding.AwayFromZero);
|
||||||
var conclusion = DegreeAwardRules.Evaluate(
|
var conclusion = DegreeAwardRules.Evaluate(
|
||||||
true, audit.Student!.Status, average, batch.MinimumGradePoint);
|
true, audit.StudentStatus, average, batch.MinimumGradePoint);
|
||||||
var reason = audit.Student.Status != StudentStatus.Graduated
|
var reason = audit.StudentStatus != StudentStatus.Graduated
|
||||||
? "学籍状态尚未转为毕业"
|
? "学籍状态尚未转为毕业"
|
||||||
: average < batch.MinimumGradePoint
|
: average < batch.MinimumGradePoint
|
||||||
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
|
? $"平均绩点 {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.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -40,18 +42,38 @@ public sealed class GradesController(
|
|||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
GradeSheetStatus? status,
|
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()
|
var source = AccessibleTasks().AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.Status == TeachingTaskStatus.Published ||
|
x.Status == TeachingTaskStatus.Published ||
|
||||||
x.Status == TeachingTaskStatus.Closed);
|
x.Status == TeachingTaskStatus.Closed);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
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
|
var items = await source
|
||||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
.ThenBy(x => x.TaskNumber)
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -79,7 +101,9 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
item.Id,
|
item.Id,
|
||||||
item.Name,
|
item.Name,
|
||||||
item.Weight
|
item.Weight,
|
||||||
|
item.SourceType,
|
||||||
|
item.SourceSnapshotAt
|
||||||
}),
|
}),
|
||||||
StudentCount = sheet.Records.Count,
|
StudentCount = sheet.Records.Count,
|
||||||
CompletedCount = sheet.Records.Count(record =>
|
CompletedCount = sheet.Records.Count(record =>
|
||||||
@@ -95,10 +119,7 @@ public sealed class GradesController(
|
|||||||
.FirstOrDefault()
|
.FirstOrDefault()
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
if (status.HasValue)
|
|
||||||
items = items.Where(x => x.Sheet?.Status == status.Value).ToList();
|
|
||||||
return Ok(items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets")]
|
[HttpPost("sheets")]
|
||||||
@@ -134,15 +155,13 @@ public sealed class GradesController(
|
|||||||
cancellationToken))
|
cancellationToken))
|
||||||
return ConflictProblem("该教学班已经建立成绩单。");
|
return ConflictProblem("该教学班已经建立成绩单。");
|
||||||
|
|
||||||
var studentIds = await db.CourseEnrollments.AsNoTracking()
|
var studentIds = await TeachingTaskRosterQuery
|
||||||
.Where(x =>
|
.ForTask(db, task.Id)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
.AsNoTracking()
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
|
.Select(x => x.Id)
|
||||||
.Select(x => x.StudentId)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (studentIds.Count == 0)
|
if (studentIds.Count == 0)
|
||||||
return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。");
|
return ConflictProblem("教学班当前没有有效学生,无法建立成绩单。");
|
||||||
|
|
||||||
var sheet = new GradeSheet
|
var sheet = new GradeSheet
|
||||||
{
|
{
|
||||||
@@ -165,8 +184,18 @@ public sealed class GradesController(
|
|||||||
|
|
||||||
[HttpGet("sheets/{id:guid}")]
|
[HttpGet("sheets/{id:guid}")]
|
||||||
[Authorize(Roles = SheetUsers)]
|
[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()
|
var sheet = await AccessibleSheets().AsNoTracking()
|
||||||
.Where(x => x.Id == id)
|
.Where(x => x.Id == id)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
@@ -193,44 +222,58 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
item.Id,
|
item.Id,
|
||||||
item.Name,
|
item.Name,
|
||||||
item.Weight
|
item.Weight,
|
||||||
|
item.SourceType,
|
||||||
|
item.SourceSnapshotAt
|
||||||
}),
|
}),
|
||||||
x.Status,
|
x.Status,
|
||||||
x.ReviewComment,
|
x.ReviewComment,
|
||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
x.ReviewedAt,
|
x.ReviewedAt,
|
||||||
x.PublishedAt,
|
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.CreatedAt,
|
||||||
x.UpdatedAt
|
x.UpdatedAt
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
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()
|
var task = await db.TeachingTasks.AsNoTracking()
|
||||||
.Include(x => x.Teachers)
|
.Include(x => x.Teachers)
|
||||||
.ThenInclude(x => x.Teacher)
|
.ThenInclude(x => x.Teacher)
|
||||||
@@ -244,7 +287,36 @@ public sealed class GradesController(
|
|||||||
|
|
||||||
return Ok(new
|
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) &&
|
CanEdit = CanEditScores(task) &&
|
||||||
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
||||||
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
|
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
|
||||||
@@ -340,7 +412,7 @@ public sealed class GradesController(
|
|||||||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||||
return ValidationProblem("包含无效或重复的成绩记录。");
|
return ValidationProblem("包含无效或重复的成绩记录。");
|
||||||
|
|
||||||
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
|
var itemsById = sheet.Items.ToDictionary(item => item.Id);
|
||||||
foreach (var item in request.Records)
|
foreach (var item in request.Records)
|
||||||
{
|
{
|
||||||
if (!ValidScore(item.RegularScore) ||
|
if (!ValidScore(item.RegularScore) ||
|
||||||
@@ -360,8 +432,19 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
if (!ValidScore(scoreEntry.Score))
|
if (!ValidScore(scoreEntry.Score))
|
||||||
return ValidationProblem("分项成绩必须在 0—100 分之间。");
|
return ValidationProblem("分项成绩必须在 0—100 分之间。");
|
||||||
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
|
if (!itemsById.TryGetValue(
|
||||||
existing.Score = scoreEntry.Score;
|
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);
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
|
||||||
|
[Authorize(Roles = SheetUsers)]
|
||||||
|
public async Task<ActionResult> ImportExperimentScores(
|
||||||
|
Guid id,
|
||||||
|
ImportExperimentScoresRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await AccessibleSheets()
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (
|
||||||
|
GradeSheetStatus.Draft or
|
||||||
|
GradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("成绩单提交后不能导入实验成绩。");
|
||||||
|
|
||||||
|
var targetItem = sheet.Items.FirstOrDefault(x =>
|
||||||
|
x.Id == request.GradeItemId);
|
||||||
|
if (targetItem is null)
|
||||||
|
return ValidationProblem("请选择当前成绩单中的有效分项。");
|
||||||
|
|
||||||
|
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
|
||||||
|
db,
|
||||||
|
sheet.TeachingTaskId,
|
||||||
|
sheet.Records.Select(x => x.StudentId).ToArray(),
|
||||||
|
cancellationToken);
|
||||||
|
if (aggregate.PublishedProjectCount == 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
"该教学任务尚无已发布的实验项目成绩,无法导入。");
|
||||||
|
|
||||||
|
var imported = 0;
|
||||||
|
var missing = 0;
|
||||||
|
foreach (var record in sheet.Records)
|
||||||
|
{
|
||||||
|
var targetScore = record.ItemScores.First(x =>
|
||||||
|
x.GradeItemId == targetItem.Id);
|
||||||
|
targetScore.Score = aggregate.Scores[record.StudentId];
|
||||||
|
if (targetScore.Score.HasValue) imported++;
|
||||||
|
else missing++;
|
||||||
|
Recalculate(sheet, record);
|
||||||
|
}
|
||||||
|
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||||
|
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
aggregate.PublishedProjectCount,
|
||||||
|
aggregate.Projects,
|
||||||
|
ImportedCount = imported,
|
||||||
|
MissingCount = missing,
|
||||||
|
targetItem.SourceSnapshotAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/submit")]
|
[HttpPost("sheets/{id:guid}/submit")]
|
||||||
[Authorize(Roles = SheetUsers)]
|
[Authorize(Roles = SheetUsers)]
|
||||||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||||||
@@ -411,7 +554,8 @@ public sealed class GradesController(
|
|||||||
$"《{courseName}》成绩已提交,请及时审核。",
|
$"《{courseName}》成绩已提交,请及时审核。",
|
||||||
collegeId,
|
collegeId,
|
||||||
"/grades",
|
"/grades",
|
||||||
cancellationToken);
|
cancellationToken,
|
||||||
|
NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +596,7 @@ public sealed class GradesController(
|
|||||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||||
"成绩审核通过",
|
"成绩审核通过",
|
||||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
|
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
|
||||||
"/grades", cancellationToken);
|
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +641,7 @@ public sealed class GradesController(
|
|||||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||||
"成绩被退回",
|
"成绩被退回",
|
||||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
|
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
|
||||||
"/grades", cancellationToken);
|
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +649,10 @@ public sealed class GradesController(
|
|||||||
[Authorize(Roles = Publishers)]
|
[Authorize(Roles = Publishers)]
|
||||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
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,
|
x => x.Id == id,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
@@ -514,13 +661,11 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Notify enrolled students
|
// The grade sheet roster is authoritative at publication time. This also
|
||||||
var studentUserIds = await db.CourseEnrollments
|
// covers students added through approved roster corrections.
|
||||||
.Where(x =>
|
var studentUserIds = await db.GradeRecords
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
|
.Where(x => x.GradeSheetId == sheet.Id)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
|
||||||
.Select(x => x.Student!.UserId)
|
.Select(x => x.Student!.UserId)
|
||||||
.Where(id => id != null)
|
.Where(id => id != null)
|
||||||
.Select(id => id!.Value)
|
.Select(id => id!.Value)
|
||||||
@@ -528,8 +673,8 @@ public sealed class GradesController(
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||||
"成绩已发布",
|
"成绩已发布",
|
||||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
|
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
|
||||||
"/grades", cancellationToken);
|
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
|
|||||||
Guid GradeItemId,
|
Guid GradeItemId,
|
||||||
decimal? Score);
|
decimal? Score);
|
||||||
|
|
||||||
|
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
|
||||||
|
|
||||||
public sealed record GradeReviewRequest(
|
public sealed record GradeReviewRequest(
|
||||||
[MaxLength(500)] string? Comment);
|
[MaxLength(500)] string? Comment);
|
||||||
|
|||||||
@@ -61,8 +61,16 @@ public sealed class GraduationAuditsController(
|
|||||||
|
|
||||||
[HttpGet("batches/{id:guid}")]
|
[HttpGet("batches/{id:guid}")]
|
||||||
[Authorize(Roles = Reviewers)]
|
[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()
|
var batch = await db.GraduationAuditBatches.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||||
if (batch is null) return NotFound();
|
if (batch is null) return NotFound();
|
||||||
@@ -73,6 +81,59 @@ public sealed class GraduationAuditsController(
|
|||||||
if (collegeId.HasValue)
|
if (collegeId.HasValue)
|
||||||
results = results.Where(x =>
|
results = results.Where(x =>
|
||||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
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
|
return Ok(new
|
||||||
{
|
{
|
||||||
batch.Id,
|
batch.Id,
|
||||||
@@ -83,31 +144,17 @@ public sealed class GraduationAuditsController(
|
|||||||
batch.Notes,
|
batch.Notes,
|
||||||
batch.CalculatedAt,
|
batch.CalculatedAt,
|
||||||
batch.PublishedAt,
|
batch.PublishedAt,
|
||||||
Results = await results
|
ResultCount = summary?.ResultCount ?? 0,
|
||||||
.OrderBy(x => x.Student!.StudentNumber)
|
EligibleCount = summary?.EligibleCount ?? 0,
|
||||||
.Select(x => new
|
IneligibleCount = summary?.IneligibleCount ?? 0,
|
||||||
{
|
OverrideCount = summary?.OverrideCount ?? 0,
|
||||||
x.Id,
|
Results = new
|
||||||
x.StudentId,
|
{
|
||||||
x.Student!.StudentNumber,
|
Items = items,
|
||||||
x.Student.Name,
|
Total = total,
|
||||||
ClassName = x.Student.AdministrativeClass!.Name,
|
Page = page,
|
||||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
PageSize = pageSize
|
||||||
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)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +203,11 @@ public sealed class GraduationAuditsController(
|
|||||||
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
|
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
|
||||||
x.Status == CurriculumPlanStatus.Published)
|
x.Status == CurriculumPlanStatus.Published)
|
||||||
.ToListAsync(token);
|
.ToListAsync(token);
|
||||||
var studentIds = students.Select(x => x.Id).ToArray();
|
|
||||||
var grades = await db.GradeRecords.AsNoTracking()
|
var grades = await db.GradeRecords.AsNoTracking()
|
||||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
|
.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(
|
.Select(x => new GradeSnapshot(
|
||||||
x.StudentId,
|
x.StudentId,
|
||||||
x.GradeSheet!.TeachingTask!.CourseId,
|
x.GradeSheet!.TeachingTask!.CourseId,
|
||||||
@@ -169,16 +217,23 @@ public sealed class GraduationAuditsController(
|
|||||||
x.ExamStatus))
|
x.ExamStatus))
|
||||||
.ToListAsync(token);
|
.ToListAsync(token);
|
||||||
|
|
||||||
var oldResults = await db.GraduationAuditResults
|
var plansByMajor = plans
|
||||||
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
|
.GroupBy(x => x.MajorId)
|
||||||
db.GraduationAuditResults.RemoveRange(oldResults);
|
.ToDictionary(group => group.Key, group => group.First());
|
||||||
await db.SaveChangesAsync(token);
|
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)
|
foreach (var student in students)
|
||||||
{
|
{
|
||||||
var plan = plans.FirstOrDefault(x =>
|
plansByMajor.TryGetValue(
|
||||||
x.MajorId == student.AdministrativeClass!.MajorId);
|
student.AdministrativeClass!.MajorId,
|
||||||
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
|
out var plan);
|
||||||
|
var studentGrades = gradesByStudent.GetValueOrDefault(student.Id) ?? [];
|
||||||
var passedCourseIds = studentGrades
|
var passedCourseIds = studentGrades
|
||||||
.Where(IsPassed)
|
.Where(IsPassed)
|
||||||
.Select(x => x.CourseId)
|
.Select(x => x.CourseId)
|
||||||
|
|||||||
@@ -34,31 +34,46 @@ public sealed class GraduationClearanceController(
|
|||||||
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
|
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
|
||||||
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
|
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
|
||||||
}).ToListAsync(token);
|
}).ToListAsync(token);
|
||||||
var records = await ScopedRecords().AsNoTracking()
|
var statistics = await ScopedRecords().AsNoTracking()
|
||||||
.Select(x => new
|
.GroupBy(x => x.GraduationClearanceItem!.GraduationClearanceBatchId)
|
||||||
|
.Select(group => new
|
||||||
{
|
{
|
||||||
BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId,
|
BatchId = group.Key,
|
||||||
x.StudentId, x.Status
|
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
|
||||||
}).ToListAsync(token);
|
RecordCount = group.Count(),
|
||||||
|
CompletedCount = group.Count(x =>
|
||||||
|
x.Status != GraduationClearanceRecordStatus.Pending)
|
||||||
|
}).ToDictionaryAsync(x => x.BatchId, token);
|
||||||
return Ok(batches.Select(batch =>
|
return Ok(batches.Select(batch =>
|
||||||
{
|
{
|
||||||
var scoped = records.Where(x => x.BatchId == batch.Id).ToList();
|
statistics.TryGetValue(batch.Id, out var summary);
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
||||||
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
|
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
|
||||||
StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(),
|
StudentCount = summary?.StudentCount ?? 0,
|
||||||
RecordCount = scoped.Count,
|
RecordCount = summary?.RecordCount ?? 0,
|
||||||
CompletedCount = scoped.Count(x =>
|
CompletedCount = summary?.CompletedCount ?? 0
|
||||||
x.Status != GraduationClearanceRecordStatus.Pending)
|
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("batches/{id:guid}")]
|
[HttpGet("batches/{id:guid}")]
|
||||||
[Authorize(Roles = Workers)]
|
[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()
|
var batch = await db.GraduationClearanceBatches.AsNoTracking()
|
||||||
.Where(x => x.Id == id)
|
.Where(x => x.Id == id)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
@@ -72,8 +87,56 @@ public sealed class GraduationClearanceController(
|
|||||||
})
|
})
|
||||||
}).FirstOrDefaultAsync(token);
|
}).FirstOrDefaultAsync(token);
|
||||||
if (batch is null) return NotFound();
|
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)
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
|
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
@@ -89,11 +152,32 @@ public sealed class GraduationClearanceController(
|
|||||||
x.GraduationClearanceItem.IsRequired,
|
x.GraduationClearanceItem.IsRequired,
|
||||||
x.Status, x.Notes, x.CompletedAt
|
x.Status, x.Notes, x.CompletedAt
|
||||||
}).ToListAsync(token);
|
}).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
|
return Ok(new
|
||||||
{
|
{
|
||||||
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
|
||||||
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
|
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)
|
if (batch.Status != GraduationClearanceBatchStatus.Open)
|
||||||
return ConflictProblem("已关闭批次不能重新生成办理记录。");
|
return ConflictProblem("已关闭批次不能重新生成办理记录。");
|
||||||
|
|
||||||
var eligibleAudits = await db.GraduationAuditResults.AsNoTracking()
|
var studentIds = await db.GraduationAuditResults.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
||||||
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
||||||
x.Conclusion == GraduationAuditConclusion.Eligible &&
|
x.Conclusion == GraduationAuditConclusion.Eligible &&
|
||||||
x.Student!.Status == StudentStatus.Graduated)
|
x.Student!.Status == StudentStatus.Graduated)
|
||||||
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
.Select(x => x.StudentId)
|
||||||
.Select(x => new { x.StudentId })
|
.Distinct()
|
||||||
.ToListAsync(token);
|
.ToListAsync(token);
|
||||||
var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray();
|
|
||||||
var existing = await db.GraduationClearanceRecords.AsNoTracking()
|
var existing = await db.GraduationClearanceRecords.AsNoTracking()
|
||||||
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
|
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
|
||||||
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
|
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
|
||||||
@@ -180,7 +263,7 @@ public sealed class GraduationClearanceController(
|
|||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync(token);
|
await db.SaveChangesAsync(token);
|
||||||
return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created });
|
return Ok(new { StudentCount = studentIds.Count, CreatedRecordCount = created });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("records/{id:guid}")]
|
[HttpPut("records/{id:guid}")]
|
||||||
@@ -221,15 +304,17 @@ public sealed class GraduationClearanceController(
|
|||||||
public async Task<ActionResult> Close(Guid id, CancellationToken token)
|
public async Task<ActionResult> Close(Guid id, CancellationToken token)
|
||||||
{
|
{
|
||||||
var batch = await db.GraduationClearanceBatches
|
var batch = await db.GraduationClearanceBatches
|
||||||
.Include(x => x.Items)
|
|
||||||
.ThenInclude(x => x.Records)
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, token);
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
||||||
if (batch is null) return NotFound();
|
if (batch is null) return NotFound();
|
||||||
if (batch.Status != GraduationClearanceBatchStatus.Open)
|
if (batch.Status != GraduationClearanceBatchStatus.Open)
|
||||||
return ConflictProblem("该批次已经关闭。");
|
return ConflictProblem("该批次已经关闭。");
|
||||||
var records = batch.Items.SelectMany(item => item.Records.Select(record =>
|
var hasIncompleteRequiredItem = await db.GraduationClearanceRecords
|
||||||
(item.IsRequired, record.Status)));
|
.AnyAsync(record =>
|
||||||
if (!GraduationClearanceRules.CanClose(records))
|
record.GraduationClearanceItem!.GraduationClearanceBatchId == id &&
|
||||||
|
record.GraduationClearanceItem.IsRequired &&
|
||||||
|
record.Status == GraduationClearanceRecordStatus.Pending,
|
||||||
|
token);
|
||||||
|
if (hasIncompleteRequiredItem)
|
||||||
return ConflictProblem("仍有必办离校事项尚未完成。");
|
return ConflictProblem("仍有必办离校事项尚未完成。");
|
||||||
batch.Status = GraduationClearanceBatchStatus.Closed;
|
batch.Status = GraduationClearanceBatchStatus.Closed;
|
||||||
batch.ClosedAt = DateTime.UtcNow;
|
batch.ClosedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
@@ -17,9 +18,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
public sealed class MakeupExamsController(
|
public sealed class MakeupExamsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
MakeupExamEligibilityService eligibilityService,
|
MakeupExamEligibilityService eligibilityService) : ControllerBase
|
||||||
MakeupExamArrangementService arrangementService,
|
|
||||||
MakeupExamAutoJobQueue autoJobQueue) : ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string Managers =
|
private const string Managers =
|
||||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
@@ -111,6 +110,7 @@ public sealed class MakeupExamsController(
|
|||||||
item.StartsAt,
|
item.StartsAt,
|
||||||
item.EndsAt,
|
item.EndsAt,
|
||||||
item.RequiredBuildingId,
|
item.RequiredBuildingId,
|
||||||
|
item.RequiredBuildingIds,
|
||||||
RequiredBuildingName = item.RequiredBuilding != null
|
RequiredBuildingName = item.RequiredBuilding != null
|
||||||
? item.RequiredBuilding.Name : null,
|
? item.RequiredBuilding.Name : null,
|
||||||
item.RequiredInvigilatorCount,
|
item.RequiredInvigilatorCount,
|
||||||
@@ -138,38 +138,90 @@ public sealed class MakeupExamsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var plan = await db.MakeupExamPlans
|
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||||
.Include(x => x.Sessions)
|
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
|
||||||
.ThenInclude(x => x.Invigilators)
|
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
|
||||||
.Include(x => x.Sessions)
|
return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
|
||||||
.ThenInclude(x => x.Enrollments)
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Status,
|
||||||
|
HasSessions = x.Sessions.Any()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
return ConflictProblem("只有草稿补考计划可以发布。");
|
return ConflictProblem("只有草稿补考计划可以发布。");
|
||||||
if (plan.Sessions.Count == 0)
|
if (!plan.HasSessions)
|
||||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
var unassigned = plan.Sessions.Count(x =>
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
var job = new ExamPublishJob
|
||||||
if (unassigned > 0)
|
{
|
||||||
return ConflictProblem(
|
Kind = ExamPublishJobKind.MakeupExam,
|
||||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
PlanId = id,
|
||||||
|
ActivePlanId = id,
|
||||||
|
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||||
|
CurrentStep = "等待后台校验"
|
||||||
|
};
|
||||||
|
db.ExamPublishJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(
|
||||||
|
BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.ExamPublish,
|
||||||
|
job.Id));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var existing = await FindActivePublishJobAsync(id, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
|
return AcceptedPublishJob(job, "补考发布任务已提交。");
|
||||||
if (empty > 0)
|
}
|
||||||
return ConflictProblem(
|
|
||||||
$"还有 {empty} 个场次没有登记补考学生。");
|
|
||||||
|
|
||||||
plan.Status = MakeupExamPlanStatus.Published;
|
[HttpGet("publish-jobs/{jobId:guid}")]
|
||||||
plan.PublishedAt = DateTime.UtcNow;
|
[Authorize(Roles = Managers)]
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
public async Task<ActionResult> GetPublishJob(
|
||||||
|
Guid jobId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId &&
|
||||||
|
x.Kind == ExamPublishJobKind.MakeupExam,
|
||||||
|
cancellationToken);
|
||||||
|
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("plans/{planId:guid}/publish-job")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetLatestPublishJob(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.PlanId == planId &&
|
||||||
|
x.Kind == ExamPublishJobKind.MakeupExam)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("plans/{id:guid}/archive")]
|
[HttpPost("plans/{id:guid}/archive")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
|
||||||
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Published)
|
if (plan.Status != MakeupExamPlanStatus.Published)
|
||||||
@@ -189,6 +241,8 @@ public sealed class MakeupExamsController(
|
|||||||
CreateMakeupExamSessionRequest request,
|
CreateMakeupExamSessionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
@@ -215,6 +269,7 @@ public sealed class MakeupExamsController(
|
|||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
RequiredBuildingId = request.RequiredBuildingId,
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes),
|
Notes = Normalize(request.Notes),
|
||||||
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||||
@@ -224,6 +279,81 @@ public sealed class MakeupExamsController(
|
|||||||
return await SaveAsync(session.Id, true, cancellationToken);
|
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}")]
|
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateSession(
|
public async Task<ActionResult> UpdateSession(
|
||||||
@@ -232,6 +362,8 @@ public sealed class MakeupExamsController(
|
|||||||
CreateMakeupExamSessionRequest request,
|
CreateMakeupExamSessionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||||
var session = await db.MakeupExamSessions
|
var session = await db.MakeupExamSessions
|
||||||
.Include(x => x.MakeupExamPlan)
|
.Include(x => x.MakeupExamPlan)
|
||||||
.Include(x => x.Invigilators)
|
.Include(x => x.Invigilators)
|
||||||
@@ -258,6 +390,7 @@ public sealed class MakeupExamsController(
|
|||||||
session.StartsAt = startsAt;
|
session.StartsAt = startsAt;
|
||||||
session.EndsAt = endsAt;
|
session.EndsAt = endsAt;
|
||||||
session.RequiredBuildingId = request.RequiredBuildingId;
|
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||||
|
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
|
||||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||||
session.Notes = Normalize(request.Notes);
|
session.Notes = Normalize(request.Notes);
|
||||||
|
|
||||||
@@ -275,6 +408,8 @@ public sealed class MakeupExamsController(
|
|||||||
Guid id,
|
Guid id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||||
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||||
if (session is null) return NotFound();
|
if (session is null) return NotFound();
|
||||||
@@ -292,12 +427,121 @@ public sealed class MakeupExamsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> AutoArrange(
|
public async Task<ActionResult> AutoArrange(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
ExamAutoArrangeRequest? request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await arrangementService.ArrangeAsync(planId, cancellationToken);
|
request ??= new ExamAutoArrangeRequest();
|
||||||
if (!result.Success)
|
if (!request.AssignClassrooms && !request.AssignInvigilators)
|
||||||
return ConflictProblem(result.Message);
|
return ValidationProblem("请至少选择分配考场或分配监考教师。");
|
||||||
return Ok(new { message = result.Message });
|
|
||||||
|
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 is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
|
||||||
|
|
||||||
// Check for existing active job
|
// Check for existing active job
|
||||||
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||||
@@ -336,10 +582,12 @@ public sealed class MakeupExamsController(
|
|||||||
MakeupExamPlanId = planId
|
MakeupExamPlanId = planId
|
||||||
};
|
};
|
||||||
db.MakeupExamAutoJobs.Add(job);
|
db.MakeupExamAutoJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(
|
||||||
|
BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.MakeupExamAuto,
|
||||||
|
job.Id));
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
autoJobQueue.Enqueue(job.Id);
|
|
||||||
|
|
||||||
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
|
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
|
||||||
new { jobId = job.Id, status = job.Status.ToString() });
|
new { jobId = job.Id, status = job.Status.ToString() });
|
||||||
}
|
}
|
||||||
@@ -553,19 +801,43 @@ public sealed class MakeupExamsController(
|
|||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||||
x.StartsAt,
|
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()
|
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
}).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);
|
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||||
if (!studentId.HasValue)
|
if (!studentId.HasValue)
|
||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
|
var schedule = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
.Where(x => x.StudentId == studentId &&
|
.Where(x => x.StudentId == studentId &&
|
||||||
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
||||||
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
||||||
@@ -722,7 +994,49 @@ public sealed class MakeupExamsController(
|
|||||||
x.MakeupScore,
|
x.MakeupScore,
|
||||||
IsMakeup = true
|
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))
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
{
|
{
|
||||||
@@ -895,6 +1209,87 @@ public sealed class MakeupExamsController(
|
|||||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||||
|
|
||||||
|
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ExamArrangementJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Kind == ExamArrangementKind.MakeupExam &&
|
||||||
|
x.ActivePlanId == planId &&
|
||||||
|
(x.Status == ExamArrangementJobStatus.Queued ||
|
||||||
|
x.Status == ExamArrangementJobStatus.Running))
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
private ActionResult AcceptedArrangementJob(
|
||||||
|
ExamArrangementJob job,
|
||||||
|
string message) =>
|
||||||
|
AcceptedAtAction(
|
||||||
|
nameof(GetArrangementJob),
|
||||||
|
new { jobId = job.Id },
|
||||||
|
new
|
||||||
|
{
|
||||||
|
jobId = job.Id,
|
||||||
|
status = job.Status.ToString(),
|
||||||
|
message
|
||||||
|
});
|
||||||
|
|
||||||
|
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
|
||||||
|
{
|
||||||
|
job.Id,
|
||||||
|
job.PlanId,
|
||||||
|
Kind = job.Kind.ToString(),
|
||||||
|
Status = job.Status.ToString(),
|
||||||
|
job.TotalSessions,
|
||||||
|
job.ProcessedSessions,
|
||||||
|
job.CurrentStep,
|
||||||
|
job.ResultMessage,
|
||||||
|
job.ErrorMessage,
|
||||||
|
job.AssignClassrooms,
|
||||||
|
job.AssignInvigilators,
|
||||||
|
job.CreatedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
private Task<ExamPublishJob?> FindActivePublishJobAsync(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Kind == ExamPublishJobKind.MakeupExam &&
|
||||||
|
x.ActivePlanId == planId &&
|
||||||
|
(x.Status == ExamPublishJobStatus.Queued ||
|
||||||
|
x.Status == ExamPublishJobStatus.Running))
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
private ActionResult AcceptedPublishJob(
|
||||||
|
ExamPublishJob job,
|
||||||
|
string message) =>
|
||||||
|
AcceptedAtAction(
|
||||||
|
nameof(GetPublishJob),
|
||||||
|
new { jobId = job.Id },
|
||||||
|
new
|
||||||
|
{
|
||||||
|
jobId = job.Id,
|
||||||
|
status = job.Status.ToString(),
|
||||||
|
message
|
||||||
|
});
|
||||||
|
|
||||||
|
private static object ToPublishJobResponse(ExamPublishJob job) => new
|
||||||
|
{
|
||||||
|
job.Id,
|
||||||
|
job.PlanId,
|
||||||
|
Kind = job.Kind.ToString(),
|
||||||
|
Status = job.Status.ToString(),
|
||||||
|
job.CurrentStep,
|
||||||
|
job.ErrorMessage,
|
||||||
|
job.CreatedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt
|
||||||
|
};
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -917,6 +1312,10 @@ public sealed class MakeupExamsController(
|
|||||||
});
|
});
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
|
||||||
|
ids is { Count: > 0 }
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(ids)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -935,10 +1334,21 @@ public sealed record CreateMakeupExamSessionRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 6)] int PeriodCount,
|
[Range(1, 6)] int PeriodCount,
|
||||||
Guid? RequiredBuildingId,
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
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(
|
public sealed record EnrollStudentsRequest(
|
||||||
[Required] IReadOnlyCollection<Guid> StudentIds);
|
[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.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -14,20 +17,43 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/notifications")]
|
[Route("api/notifications")]
|
||||||
public sealed class NotificationsController(
|
public sealed class NotificationsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
ILogger<NotificationsController>? logger = null) : ControllerBase
|
||||||
{
|
{
|
||||||
|
private const int MaximumSelectedRecipients = 500;
|
||||||
|
private const int NotificationBatchSize = 300;
|
||||||
|
private const string Senders =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin + "," +
|
||||||
|
SystemRoles.Counselor + "," +
|
||||||
|
SystemRoles.Teacher;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult> GetNotifications(
|
public async Task<ActionResult> GetNotifications(
|
||||||
bool? unreadOnly,
|
bool? unreadOnly,
|
||||||
|
NotificationCategory? category,
|
||||||
|
string? keyword,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
var userId = currentUserDataScope.Current.UserId;
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
var source = db.Notifications.AsNoTracking()
|
var source = db.Notifications.AsNoTracking()
|
||||||
.Where(x => x.UserId == userId);
|
.Where(x => x.UserId == userId);
|
||||||
if (unreadOnly == true)
|
if (unreadOnly == true)
|
||||||
source = source.Where(x => !x.IsRead);
|
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 total = await source.CountAsync(cancellationToken);
|
||||||
var items = await source
|
var items = await source
|
||||||
@@ -36,14 +62,38 @@ public sealed class NotificationsController(
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.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);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var unreadCount = await db.Notifications
|
var unreadCount = await db.Notifications
|
||||||
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
|
.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")]
|
[HttpGet("unread-count")]
|
||||||
@@ -55,30 +105,802 @@ public sealed class NotificationsController(
|
|||||||
return Ok(new { Count = count });
|
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")]
|
[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 userId = currentUserDataScope.Current.UserId;
|
||||||
var n = await db.Notifications
|
var notification = await db.Notifications
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
|
.FirstOrDefaultAsync(
|
||||||
if (n is null) return NotFound();
|
x => x.Id == id && x.UserId == userId,
|
||||||
n.IsRead = true;
|
cancellationToken);
|
||||||
|
if (notification is null) return NotFound();
|
||||||
|
notification.IsRead = true;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("read-all")]
|
[HttpPost("read-all")]
|
||||||
public async Task<ActionResult> MarkAllRead(CancellationToken cancellationToken)
|
public async Task<ActionResult> MarkAllRead(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userId = currentUserDataScope.Current.UserId;
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
await db.Notifications
|
await db.Notifications
|
||||||
.Where(x => x.UserId == userId && !x.IsRead)
|
.Where(x => x.UserId == userId && !x.IsRead)
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true),
|
.ExecuteUpdateAsync(
|
||||||
|
updates => updates.SetProperty(x => x.IsRead, true),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return NoContent();
|
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>
|
/// <summary>
|
||||||
/// Centralized helper to send notifications across the app.
|
/// Centralized helper to send notifications across the app.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -90,13 +912,15 @@ public static class NotificationService
|
|||||||
string title,
|
string title,
|
||||||
string content,
|
string content,
|
||||||
string? linkUrl = null,
|
string? linkUrl = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
NotificationCategory category = NotificationCategory.General)
|
||||||
{
|
{
|
||||||
db.Notifications.Add(new Notification
|
db.Notifications.Add(new Notification
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Title = title,
|
Title = title,
|
||||||
Content = content,
|
Content = content,
|
||||||
|
Category = category,
|
||||||
LinkUrl = linkUrl
|
LinkUrl = linkUrl
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -109,22 +933,46 @@ public static class NotificationService
|
|||||||
string content,
|
string content,
|
||||||
Guid? collegeId = null,
|
Guid? collegeId = null,
|
||||||
string? linkUrl = null,
|
string? linkUrl = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
NotificationCategory category = NotificationCategory.General)
|
||||||
{
|
{
|
||||||
var query = db.Users
|
var query = db.Users
|
||||||
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
|
.Join(
|
||||||
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! });
|
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 =>
|
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
|
||||||
db.Teachers.Any(t =>
|
|
||||||
t.UserId == x.Id && t.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)
|
foreach (var id in ids)
|
||||||
{
|
{
|
||||||
db.Notifications.Add(new Notification
|
db.Notifications.Add(new Notification
|
||||||
@@ -132,6 +980,7 @@ public static class NotificationService
|
|||||||
UserId = id,
|
UserId = id,
|
||||||
Title = title,
|
Title = title,
|
||||||
Content = content,
|
Content = content,
|
||||||
|
Category = category,
|
||||||
LinkUrl = linkUrl
|
LinkUrl = linkUrl
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -144,7 +993,8 @@ public static class NotificationService
|
|||||||
string title,
|
string title,
|
||||||
string content,
|
string content,
|
||||||
string? linkUrl = null,
|
string? linkUrl = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
NotificationCategory category = NotificationCategory.General)
|
||||||
{
|
{
|
||||||
foreach (var userId in userIds.Distinct())
|
foreach (var userId in userIds.Distinct())
|
||||||
{
|
{
|
||||||
@@ -153,6 +1003,7 @@ public static class NotificationService
|
|||||||
UserId = userId,
|
UserId = userId,
|
||||||
Title = title,
|
Title = title,
|
||||||
Content = content,
|
Content = content,
|
||||||
|
Category = category,
|
||||||
LinkUrl = linkUrl
|
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.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
public sealed class PersonnelController(
|
public sealed class PersonnelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
UserManager<ApplicationUser> userManager) : ControllerBase
|
UserManager<ApplicationUser> userManager,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -209,6 +211,9 @@ public sealed class PersonnelController(
|
|||||||
teacher.UserId = user.Id;
|
teacher.UserId = user.Id;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
cancellationToken);
|
||||||
return Ok(new { user.Id, UserName = userName });
|
return Ok(new { user.Id, UserName = userName });
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -219,6 +224,12 @@ public sealed class PersonnelController(
|
|||||||
[FromQuery] PersonnelQuery query,
|
[FromQuery] PersonnelQuery query,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var roles = currentUserDataScope.Current.Roles;
|
||||||
|
var canReadFullProfile = roles.Any(role =>
|
||||||
|
role is SystemRoles.SuperAdmin or
|
||||||
|
SystemRoles.AcademicAdmin or
|
||||||
|
SystemRoles.CollegeAdmin or
|
||||||
|
SystemRoles.Counselor);
|
||||||
var page = NormalizePage(query.Page);
|
var page = NormalizePage(query.Page);
|
||||||
var pageSize = NormalizePageSize(query.PageSize);
|
var pageSize = NormalizePageSize(query.PageSize);
|
||||||
var source = ApplyStudentScope(db.Students.AsNoTracking());
|
var source = ApplyStudentScope(db.Students.AsNoTracking());
|
||||||
@@ -263,9 +274,26 @@ public sealed class PersonnelController(
|
|||||||
x.EnrollmentDate,
|
x.EnrollmentDate,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.DateOfBirth,
|
x.DateOfBirth,
|
||||||
|
EnglishName = canReadFullProfile ? x.EnglishName : null,
|
||||||
|
IdCardNumber = canReadFullProfile ? x.IdCardNumber : null,
|
||||||
|
Nationality = canReadFullProfile ? x.Nationality : null,
|
||||||
|
Ethnicity = canReadFullProfile ? x.Ethnicity : null,
|
||||||
|
PoliticalStatus = canReadFullProfile ? x.PoliticalStatus : null,
|
||||||
|
NativePlace = canReadFullProfile ? x.NativePlace : null,
|
||||||
|
HouseholdAddress = canReadFullProfile ? x.HouseholdAddress : null,
|
||||||
|
CurrentAddress = canReadFullProfile ? x.CurrentAddress : null,
|
||||||
|
PostalCode = canReadFullProfile ? x.PostalCode : null,
|
||||||
x.Phone,
|
x.Phone,
|
||||||
x.Email,
|
x.Email,
|
||||||
x.Notes,
|
Qq = canReadFullProfile ? x.Qq : null,
|
||||||
|
x.WeChat,
|
||||||
|
x.EmergencyContactName,
|
||||||
|
x.EmergencyContactRelationship,
|
||||||
|
x.EmergencyContactPhone,
|
||||||
|
x.SpecialTags,
|
||||||
|
x.SpecialNeeds,
|
||||||
|
Biography = canReadFullProfile ? x.Biography : null,
|
||||||
|
Notes = canReadFullProfile ? x.Notes : null,
|
||||||
x.UserId,
|
x.UserId,
|
||||||
x.CreatedAt
|
x.CreatedAt
|
||||||
})
|
})
|
||||||
@@ -299,8 +327,26 @@ public sealed class PersonnelController(
|
|||||||
EnrollmentDate = request.EnrollmentDate,
|
EnrollmentDate = request.EnrollmentDate,
|
||||||
Status = request.Status,
|
Status = request.Status,
|
||||||
DateOfBirth = request.DateOfBirth,
|
DateOfBirth = request.DateOfBirth,
|
||||||
|
EnglishName = Normalize(request.EnglishName),
|
||||||
|
IdCardNumber = Normalize(request.IdCardNumber),
|
||||||
|
Nationality = Normalize(request.Nationality),
|
||||||
|
Ethnicity = Normalize(request.Ethnicity),
|
||||||
|
PoliticalStatus = Normalize(request.PoliticalStatus),
|
||||||
|
NativePlace = Normalize(request.NativePlace),
|
||||||
|
HouseholdAddress = Normalize(request.HouseholdAddress),
|
||||||
|
CurrentAddress = Normalize(request.CurrentAddress),
|
||||||
|
PostalCode = Normalize(request.PostalCode),
|
||||||
Phone = Normalize(request.Phone),
|
Phone = Normalize(request.Phone),
|
||||||
Email = Normalize(request.Email),
|
Email = Normalize(request.Email),
|
||||||
|
Qq = Normalize(request.Qq),
|
||||||
|
WeChat = Normalize(request.WeChat),
|
||||||
|
EmergencyContactName = Normalize(request.EmergencyContactName),
|
||||||
|
EmergencyContactRelationship = Normalize(
|
||||||
|
request.EmergencyContactRelationship),
|
||||||
|
EmergencyContactPhone = Normalize(request.EmergencyContactPhone),
|
||||||
|
SpecialTags = Normalize(request.SpecialTags),
|
||||||
|
SpecialNeeds = Normalize(request.SpecialNeeds),
|
||||||
|
Biography = Normalize(request.Biography),
|
||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.Students.Add(entity);
|
db.Students.Add(entity);
|
||||||
@@ -338,8 +384,26 @@ public sealed class PersonnelController(
|
|||||||
entity.EnrollmentDate = request.EnrollmentDate;
|
entity.EnrollmentDate = request.EnrollmentDate;
|
||||||
entity.Status = request.Status;
|
entity.Status = request.Status;
|
||||||
entity.DateOfBirth = request.DateOfBirth;
|
entity.DateOfBirth = request.DateOfBirth;
|
||||||
|
entity.EnglishName = Normalize(request.EnglishName);
|
||||||
|
entity.IdCardNumber = Normalize(request.IdCardNumber);
|
||||||
|
entity.Nationality = Normalize(request.Nationality);
|
||||||
|
entity.Ethnicity = Normalize(request.Ethnicity);
|
||||||
|
entity.PoliticalStatus = Normalize(request.PoliticalStatus);
|
||||||
|
entity.NativePlace = Normalize(request.NativePlace);
|
||||||
|
entity.HouseholdAddress = Normalize(request.HouseholdAddress);
|
||||||
|
entity.CurrentAddress = Normalize(request.CurrentAddress);
|
||||||
|
entity.PostalCode = Normalize(request.PostalCode);
|
||||||
entity.Phone = Normalize(request.Phone);
|
entity.Phone = Normalize(request.Phone);
|
||||||
entity.Email = Normalize(request.Email);
|
entity.Email = Normalize(request.Email);
|
||||||
|
entity.Qq = Normalize(request.Qq);
|
||||||
|
entity.WeChat = Normalize(request.WeChat);
|
||||||
|
entity.EmergencyContactName = Normalize(request.EmergencyContactName);
|
||||||
|
entity.EmergencyContactRelationship = Normalize(
|
||||||
|
request.EmergencyContactRelationship);
|
||||||
|
entity.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
|
||||||
|
entity.SpecialTags = Normalize(request.SpecialTags);
|
||||||
|
entity.SpecialNeeds = Normalize(request.SpecialNeeds);
|
||||||
|
entity.Biography = Normalize(request.Biography);
|
||||||
entity.Notes = Normalize(request.Notes);
|
entity.Notes = Normalize(request.Notes);
|
||||||
return await SaveNoContentAsync(cancellationToken);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -429,6 +493,12 @@ public sealed class PersonnelController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Analytics,
|
||||||
|
cancellationToken);
|
||||||
return Created(string.Empty, new { id });
|
return Created(string.Empty, new { id });
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -442,6 +512,12 @@ public sealed class PersonnelController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Analytics,
|
||||||
|
cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -505,8 +581,25 @@ public sealed record StudentRequest(
|
|||||||
DateOnly EnrollmentDate,
|
DateOnly EnrollmentDate,
|
||||||
StudentStatus Status,
|
StudentStatus Status,
|
||||||
DateOnly? DateOfBirth,
|
DateOnly? DateOfBirth,
|
||||||
|
[MaxLength(100)] string? EnglishName,
|
||||||
|
[MaxLength(30)] string? IdCardNumber,
|
||||||
|
[MaxLength(50)] string? Nationality,
|
||||||
|
[MaxLength(50)] string? Ethnicity,
|
||||||
|
[MaxLength(50)] string? PoliticalStatus,
|
||||||
|
[MaxLength(100)] string? NativePlace,
|
||||||
|
[MaxLength(300)] string? HouseholdAddress,
|
||||||
|
[MaxLength(300)] string? CurrentAddress,
|
||||||
|
[MaxLength(20)] string? PostalCode,
|
||||||
[MaxLength(30)] string? Phone,
|
[MaxLength(30)] string? Phone,
|
||||||
[EmailAddress, MaxLength(100)] string? Email,
|
[EmailAddress, MaxLength(100)] string? Email,
|
||||||
|
[MaxLength(30)] string? Qq,
|
||||||
|
[MaxLength(60)] string? WeChat,
|
||||||
|
[MaxLength(50)] string? EmergencyContactName,
|
||||||
|
[MaxLength(30)] string? EmergencyContactRelationship,
|
||||||
|
[MaxLength(30)] string? EmergencyContactPhone,
|
||||||
|
[MaxLength(300)] string? SpecialTags,
|
||||||
|
[MaxLength(1000)] string? SpecialNeeds,
|
||||||
|
[MaxLength(1000)] string? Biography,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
public sealed record TeacherAccountActivationRequest(
|
public sealed record TeacherAccountActivationRequest(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -14,7 +15,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/personnel")]
|
[Route("api/personnel")]
|
||||||
public sealed class PersonnelExcelController(
|
public sealed class PersonnelExcelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -29,6 +31,9 @@ public sealed class PersonnelExcelController(
|
|||||||
SystemRoles.AcademicAdmin + "," +
|
SystemRoles.AcademicAdmin + "," +
|
||||||
SystemRoles.CollegeAdmin;
|
SystemRoles.CollegeAdmin;
|
||||||
|
|
||||||
|
private const string ExportRoles =
|
||||||
|
WriteRoles + "," + SystemRoles.Counselor;
|
||||||
|
|
||||||
private static readonly string[] TeacherHeaders =
|
private static readonly string[] TeacherHeaders =
|
||||||
[
|
[
|
||||||
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
||||||
@@ -38,7 +43,10 @@ public sealed class PersonnelExcelController(
|
|||||||
private static readonly string[] StudentHeaders =
|
private static readonly string[] StudentHeaders =
|
||||||
[
|
[
|
||||||
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
||||||
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
|
"学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
|
||||||
|
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
|
||||||
|
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
|
||||||
|
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
|
||||||
];
|
];
|
||||||
|
|
||||||
[HttpGet("{kind}/template")]
|
[HttpGet("{kind}/template")]
|
||||||
@@ -64,6 +72,7 @@ public sealed class PersonnelExcelController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{kind}/export")]
|
[HttpGet("{kind}/export")]
|
||||||
|
[Authorize(Roles = ExportRoles)]
|
||||||
public async Task<IActionResult> Export(
|
public async Task<IActionResult> Export(
|
||||||
string kind,
|
string kind,
|
||||||
[FromQuery] PersonnelQuery query,
|
[FromQuery] PersonnelQuery query,
|
||||||
@@ -94,7 +103,12 @@ public sealed class PersonnelExcelController(
|
|||||||
.Select(x => Row(
|
.Select(x => Row(
|
||||||
x.StudentNumber, x.Name, GenderName(x.Gender),
|
x.StudentNumber, x.Name, GenderName(x.Gender),
|
||||||
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
||||||
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes))
|
StudentStatusName(x.Status), x.DateOfBirth, x.EnglishName,
|
||||||
|
x.IdCardNumber, x.Nationality, x.Ethnicity, x.PoliticalStatus,
|
||||||
|
x.NativePlace, x.HouseholdAddress, x.CurrentAddress, x.PostalCode,
|
||||||
|
x.Phone, x.Email, x.Qq, x.WeChat, x.EmergencyContactName,
|
||||||
|
x.EmergencyContactRelationship, x.EmergencyContactPhone,
|
||||||
|
x.SpecialTags, x.SpecialNeeds, x.Biography, x.Notes))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +156,12 @@ public sealed class PersonnelExcelController(
|
|||||||
}
|
}
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Analytics,
|
||||||
|
cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -288,8 +308,25 @@ public sealed class PersonnelExcelController(
|
|||||||
entity.EnrollmentDate = enrollmentDate.Value;
|
entity.EnrollmentDate = enrollmentDate.Value;
|
||||||
entity.Status = status.Value;
|
entity.Status = status.Value;
|
||||||
entity.DateOfBirth = dateOfBirth;
|
entity.DateOfBirth = dateOfBirth;
|
||||||
|
entity.EnglishName = Optional(row, "英文姓名");
|
||||||
|
entity.IdCardNumber = Optional(row, "证件号码");
|
||||||
|
entity.Nationality = Optional(row, "国籍");
|
||||||
|
entity.Ethnicity = Optional(row, "民族");
|
||||||
|
entity.PoliticalStatus = Optional(row, "政治面貌");
|
||||||
|
entity.NativePlace = Optional(row, "籍贯");
|
||||||
|
entity.HouseholdAddress = Optional(row, "户籍地址");
|
||||||
|
entity.CurrentAddress = Optional(row, "现居住地址");
|
||||||
|
entity.PostalCode = Optional(row, "邮政编码");
|
||||||
entity.Phone = Optional(row, "联系电话");
|
entity.Phone = Optional(row, "联系电话");
|
||||||
entity.Email = Optional(row, "电子邮箱");
|
entity.Email = Optional(row, "电子邮箱");
|
||||||
|
entity.Qq = Optional(row, "QQ");
|
||||||
|
entity.WeChat = Optional(row, "微信");
|
||||||
|
entity.EmergencyContactName = Optional(row, "紧急联系人");
|
||||||
|
entity.EmergencyContactRelationship = Optional(row, "与本人关系");
|
||||||
|
entity.EmergencyContactPhone = Optional(row, "紧急联系电话");
|
||||||
|
entity.SpecialTags = Optional(row, "特殊标记");
|
||||||
|
entity.SpecialNeeds = Optional(row, "特殊情况说明");
|
||||||
|
entity.Biography = Optional(row, "个人简介");
|
||||||
entity.Notes = Optional(row, "备注");
|
entity.Notes = Optional(row, "备注");
|
||||||
}
|
}
|
||||||
return new(created, updated, rows.Count);
|
return new(created, updated, rows.Count);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -11,7 +12,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||||
[Route("api/schedules")]
|
[Route("api/schedules")]
|
||||||
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("time-slots")]
|
[HttpGet("time-slots")]
|
||||||
public async Task<ActionResult> GetTimeSlots(
|
public async Task<ActionResult> GetTimeSlots(
|
||||||
@@ -65,6 +66,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
IsEnabled = request.IsEnabled
|
IsEnabled = request.IsEnabled
|
||||||
}));
|
}));
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +96,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
|
CourseTotalHours = x.Course.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
x.SchedulingMode
|
x.SchedulingMode
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -119,6 +123,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
task.StartWeek,
|
task.StartWeek,
|
||||||
task.EndWeek,
|
task.EndWeek,
|
||||||
task.WeeklyHours,
|
task.WeeklyHours,
|
||||||
|
task.CourseTotalHours,
|
||||||
|
task.CoursePracticeHours,
|
||||||
task.SchedulingMode,
|
task.SchedulingMode,
|
||||||
HasCustomConstraint = constraint is not null,
|
HasCustomConstraint = constraint is not null,
|
||||||
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ using System.Security.Claims;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -15,9 +17,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Authorize(Roles = ManagementRoles)]
|
[Authorize(Roles = ManagementRoles)]
|
||||||
[Route("api/schedules")]
|
[Route("api/schedules")]
|
||||||
public sealed class SchedulesController(
|
public sealed class SchedulesController(
|
||||||
AppDbContext db,
|
AppDbContext db) : ControllerBase
|
||||||
AutomaticScheduleJobQueue automaticScheduleJobQueue,
|
|
||||||
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string ManagementRoles =
|
private const string ManagementRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -71,6 +71,7 @@ public sealed class SchedulesController(
|
|||||||
{
|
{
|
||||||
entry.Id,
|
entry.Id,
|
||||||
entry.TeachingTaskId,
|
entry.TeachingTaskId,
|
||||||
|
entry.Kind,
|
||||||
TaskNumber = entry.TeachingTask!.TaskNumber,
|
TaskNumber = entry.TeachingTask!.TaskNumber,
|
||||||
TaskName = entry.TeachingTask.Name,
|
TaskName = entry.TeachingTask.Name,
|
||||||
CourseCode = entry.TeachingTask.Course!.Code,
|
CourseCode = entry.TeachingTask.Course!.Code,
|
||||||
@@ -167,6 +168,7 @@ public sealed class SchedulesController(
|
|||||||
Entries = source.Entries.Select(entry => new ScheduleEntry
|
Entries = source.Entries.Select(entry => new ScheduleEntry
|
||||||
{
|
{
|
||||||
TeachingTaskId = entry.TeachingTaskId,
|
TeachingTaskId = entry.TeachingTaskId,
|
||||||
|
Kind = entry.Kind,
|
||||||
ClassroomId = entry.ClassroomId,
|
ClassroomId = entry.ClassroomId,
|
||||||
DayOfWeek = entry.DayOfWeek,
|
DayOfWeek = entry.DayOfWeek,
|
||||||
StartPeriod = entry.StartPeriod,
|
StartPeriod = entry.StartPeriod,
|
||||||
@@ -241,6 +243,10 @@ public sealed class SchedulesController(
|
|||||||
CurrentStep = "等待后台检查"
|
CurrentStep = "等待后台检查"
|
||||||
};
|
};
|
||||||
db.SchedulePublishJobs.Add(job);
|
db.SchedulePublishJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(
|
||||||
|
BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.SchedulePublish,
|
||||||
|
job.Id));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -263,7 +269,6 @@ public sealed class SchedulesController(
|
|||||||
ToResponse(existing));
|
ToResponse(existing));
|
||||||
}
|
}
|
||||||
|
|
||||||
schedulePublishJobQueue.Enqueue(job.Id);
|
|
||||||
return AcceptedAtAction(
|
return AcceptedAtAction(
|
||||||
nameof(GetSchedulePublishJob),
|
nameof(GetSchedulePublishJob),
|
||||||
new { jobId = job.Id },
|
new { jobId = job.Id },
|
||||||
@@ -321,6 +326,10 @@ public sealed class SchedulesController(
|
|||||||
RequestedByUserId = requestedByUserId
|
RequestedByUserId = requestedByUserId
|
||||||
};
|
};
|
||||||
db.AutomaticScheduleJobs.Add(job);
|
db.AutomaticScheduleJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(
|
||||||
|
BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.AutomaticSchedule,
|
||||||
|
job.Id));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -339,7 +348,6 @@ public sealed class SchedulesController(
|
|||||||
ToResponse(existing));
|
ToResponse(existing));
|
||||||
}
|
}
|
||||||
|
|
||||||
automaticScheduleJobQueue.Enqueue(job.Id);
|
|
||||||
return AcceptedAtAction(
|
return AcceptedAtAction(
|
||||||
nameof(GetAutomaticScheduleJob),
|
nameof(GetAutomaticScheduleJob),
|
||||||
new { jobId = job.Id },
|
new { jobId = job.Id },
|
||||||
@@ -413,6 +421,7 @@ public sealed class SchedulesController(
|
|||||||
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (validation is not null) return validation;
|
||||||
entry.TeachingTaskId = request.TeachingTaskId;
|
entry.TeachingTaskId = request.TeachingTaskId;
|
||||||
|
entry.Kind = request.Kind;
|
||||||
entry.ClassroomId = request.ClassroomId;
|
entry.ClassroomId = request.ClassroomId;
|
||||||
entry.DayOfWeek = request.DayOfWeek;
|
entry.DayOfWeek = request.DayOfWeek;
|
||||||
entry.StartPeriod = request.StartPeriod;
|
entry.StartPeriod = request.StartPeriod;
|
||||||
@@ -492,6 +501,7 @@ public sealed class SchedulesController(
|
|||||||
.Include(x => x.Classes)
|
.Include(x => x.Classes)
|
||||||
.ThenInclude(x => x.AdministrativeClass)
|
.ThenInclude(x => x.AdministrativeClass)
|
||||||
.ThenInclude(x => x!.Students)
|
.ThenInclude(x => x!.Students)
|
||||||
|
.Include(x => x.Course)
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||||
if (task is null ||
|
if (task is null ||
|
||||||
task.Status != TeachingTaskStatus.Published ||
|
task.Status != TeachingTaskStatus.Published ||
|
||||||
@@ -501,13 +511,52 @@ public sealed class SchedulesController(
|
|||||||
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
||||||
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
||||||
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
||||||
|
var targetHours = TeachingTaskHours.TargetHours(
|
||||||
|
task.Course!,
|
||||||
|
request.Kind);
|
||||||
|
if (targetHours == 0)
|
||||||
|
return ValidationProblem(
|
||||||
|
request.Kind == ScheduleEntryKind.Experiment
|
||||||
|
? "该课程没有实践学时,不能安排实验课。"
|
||||||
|
: "该课程没有理论学时,不能安排理论课。");
|
||||||
|
var existingEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.SchedulePlanId == plan.Id &&
|
||||||
|
x.TeachingTaskId == request.TeachingTaskId &&
|
||||||
|
x.Kind == request.Kind &&
|
||||||
|
x.Id != entryId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.PeriodCount
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingHours = existingEntries.Sum(x =>
|
||||||
|
TeachingTaskHours.ScheduledHours(
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.PeriodCount));
|
||||||
|
var proposedHours = TeachingTaskHours.ScheduledHours(
|
||||||
|
request.StartWeek,
|
||||||
|
request.EndWeek,
|
||||||
|
request.WeekPattern,
|
||||||
|
request.PeriodCount);
|
||||||
|
if (existingHours + proposedHours > targetHours)
|
||||||
|
return ValidationProblem(
|
||||||
|
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "实验" : "理论")}课" +
|
||||||
|
$"共需 {targetHours} 学时;当前操作后将达到 " +
|
||||||
|
$"{existingHours + proposedHours} 学时。");
|
||||||
|
|
||||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
x => x.TeachingTaskId == request.TeachingTaskId,
|
x => x.TeachingTaskId == request.TeachingTaskId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiresClassroom != false;
|
||||||
if (requiresClassroom && !request.ClassroomId.HasValue)
|
if (requiresClassroom && !request.ClassroomId.HasValue)
|
||||||
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
||||||
if (!requiresClassroom && request.ClassroomId.HasValue)
|
if (!requiresClassroom && request.ClassroomId.HasValue)
|
||||||
@@ -531,6 +580,10 @@ public sealed class SchedulesController(
|
|||||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||||
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
!IsExperimentRoom(classroom.RoomType))
|
||||||
|
return ValidationProblem(
|
||||||
|
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||||
@@ -584,6 +637,7 @@ public sealed class SchedulesController(
|
|||||||
{
|
{
|
||||||
SchedulePlanId = planId,
|
SchedulePlanId = planId,
|
||||||
TeachingTaskId = request.TeachingTaskId,
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
|
Kind = request.Kind,
|
||||||
ClassroomId = request.ClassroomId,
|
ClassroomId = request.ClassroomId,
|
||||||
DayOfWeek = request.DayOfWeek,
|
DayOfWeek = request.DayOfWeek,
|
||||||
StartPeriod = request.StartPeriod,
|
StartPeriod = request.StartPeriod,
|
||||||
@@ -601,6 +655,12 @@ public sealed class SchedulesController(
|
|||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToHashSet();
|
.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(
|
private async Task<ActionResult> SaveAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
bool created,
|
bool created,
|
||||||
@@ -698,7 +758,8 @@ public sealed record ScheduleEntryRequest(
|
|||||||
[Range(1, 30)] int StartWeek,
|
[Range(1, 30)] int StartWeek,
|
||||||
[Range(1, 30)] int EndWeek,
|
[Range(1, 30)] int EndWeek,
|
||||||
WeekPattern WeekPattern,
|
WeekPattern WeekPattern,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes,
|
||||||
|
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
|
||||||
|
|
||||||
public sealed record AutomaticScheduleJobResponse(
|
public sealed record AutomaticScheduleJobResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -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.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/statistics")]
|
[Route("api/statistics")]
|
||||||
public sealed class StatisticsController(
|
public sealed class StatisticsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache appCache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ViewerRoles =
|
private const string ViewerRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -22,7 +25,12 @@ public sealed class StatisticsController(
|
|||||||
SystemRoles.CollegeAdmin + "," +
|
SystemRoles.CollegeAdmin + "," +
|
||||||
SystemRoles.Leader;
|
SystemRoles.Leader;
|
||||||
|
|
||||||
private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId;
|
// HybridCache may execute its source factory without the request's ambient
|
||||||
|
// HttpContext. Keep the authorization scope stable for the whole controller
|
||||||
|
// invocation instead of resolving it again inside that background factory.
|
||||||
|
private readonly CurrentUserScope currentUser = currentUserDataScope.Current;
|
||||||
|
|
||||||
|
private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId;
|
||||||
|
|
||||||
private Guid? ResolveCollegeId(Guid? requestedCollegeId)
|
private Guid? ResolveCollegeId(Guid? requestedCollegeId)
|
||||||
{
|
{
|
||||||
@@ -34,14 +42,49 @@ public sealed class StatisticsController(
|
|||||||
// ── 1. Student Statistics ────────────────────────────────────────
|
// ── 1. Student Statistics ────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("students/summary")]
|
[HttpGet("students/summary")]
|
||||||
public async Task<ActionResult<object>> GetStudentSummary(
|
public Task<ActionResult<object>> GetStudentSummary(
|
||||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
int? grade, int? enrollmentYear,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var baseQuery = db.Students.AsNoTracking()
|
||||||
.Where(s => effectiveCollegeId == null ||
|
.Where(s => effectiveCollegeId == null ||
|
||||||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
||||||
@@ -112,8 +155,16 @@ public sealed class StatisticsController(
|
|||||||
int? grade, int? enrollmentYear,
|
int? grade, int? enrollmentYear,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
|
var summary = await GetStudentSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
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()
|
return ExportSummary("学生统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||||
@@ -128,13 +179,41 @@ public sealed class StatisticsController(
|
|||||||
// ── 2. Course Statistics ─────────────────────────────────────────
|
// ── 2. Course Statistics ─────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("courses/summary")]
|
[HttpGet("courses/summary")]
|
||||||
public async Task<ActionResult<object>> GetCourseSummary(
|
public Task<ActionResult<object>> GetCourseSummary(
|
||||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var baseQuery = db.Courses.AsNoTracking()
|
||||||
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
||||||
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
||||||
@@ -184,8 +263,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
|
var summary = await GetCourseSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
collegeId,
|
||||||
|
categoryId,
|
||||||
|
nature,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("课程统计", new()
|
return ExportSummary("课程统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||||
@@ -199,13 +284,46 @@ public sealed class StatisticsController(
|
|||||||
// ── 3. Grade Statistics ──────────────────────────────────────────
|
// ── 3. Grade Statistics ──────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("grades/summary")]
|
[HttpGet("grades/summary")]
|
||||||
public async Task<ActionResult<object>> GetGradeSummary(
|
public Task<ActionResult<object>> GetGradeSummary(
|
||||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
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);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
.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? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
Guid? courseId, CancellationToken cancellationToken)
|
Guid? courseId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
|
var summary = await GetGradeSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
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()
|
return ExportSummary("成绩统计", new()
|
||||||
{
|
{
|
||||||
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
||||||
@@ -317,13 +443,41 @@ public sealed class StatisticsController(
|
|||||||
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("pass-rates/summary")]
|
[HttpGet("pass-rates/summary")]
|
||||||
public async Task<ActionResult<object>> GetPassRateSummary(
|
public Task<ActionResult<object>> GetPassRateSummary(
|
||||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||||
@@ -425,8 +579,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
|
var summary = await GetPassRateSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
courseId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("通过率统计", new()
|
return ExportSummary("通过率统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
{ "各学院通过率", ((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 ───────────────────────────────
|
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
||||||
|
|
||||||
[HttpGet("teacher-workload/summary")]
|
[HttpGet("teacher-workload/summary")]
|
||||||
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||||
Guid? academicTermId, Guid? collegeId,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
|
||||||
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
||||||
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
||||||
@@ -524,8 +709,13 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? collegeId,
|
Guid? academicTermId, Guid? collegeId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
var summary = await GetTeacherWorkloadSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("教师工作量统计", new()
|
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 }) },
|
{ "教师明细", ((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 ──────────────────────────
|
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
||||||
|
|
||||||
[HttpGet("classroom-utilization/summary")]
|
[HttpGet("classroom-utilization/summary")]
|
||||||
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
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)
|
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
|
// Find published schedule plan
|
||||||
var planQuery = db.SchedulePlans.AsNoTracking()
|
var planQuery = db.SchedulePlans.AsNoTracking()
|
||||||
.Where(p => p.Status == SchedulePlanStatus.Published);
|
.Where(p => p.Status == SchedulePlanStatus.Published);
|
||||||
@@ -713,8 +932,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
|
var summary = await GetClassroomUtilizationSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
buildingId,
|
||||||
|
campusId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("教室利用率统计", new()
|
return ExportSummary("教室利用率统计", new()
|
||||||
{
|
{
|
||||||
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
{ "各教学楼", ((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 ──────────────────────────────────────────────────────
|
// ── 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(
|
private FileContentResult ExportSummary(
|
||||||
string title,
|
string title,
|
||||||
Dictionary<string, IEnumerable<object?[]>> sheets)
|
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.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/teaching-tasks")]
|
[Route("api/teaching-tasks")]
|
||||||
public sealed class TeachingTasksController(
|
public sealed class TeachingTasksController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ManagementRoles =
|
private const string ManagementRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -80,6 +82,9 @@ public sealed class TeachingTasksController(
|
|||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
x.SchedulingMode,
|
x.SchedulingMode,
|
||||||
CourseTotalHours = x.Course.TotalHours,
|
CourseTotalHours = x.Course.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
|
CourseRegularScheduleHours =
|
||||||
|
x.Course.TotalHours - x.Course.PracticeHours,
|
||||||
x.GenerationBatchCode,
|
x.GenerationBatchCode,
|
||||||
x.Status,
|
x.Status,
|
||||||
TeacherNames = x.Teachers
|
TeacherNames = x.Teachers
|
||||||
@@ -125,6 +130,10 @@ public sealed class TeachingTasksController(
|
|||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
x.SchedulingMode,
|
x.SchedulingMode,
|
||||||
|
CourseTotalHours = x.Course!.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
|
CourseRegularScheduleHours =
|
||||||
|
x.Course.TotalHours - x.Course.PracticeHours,
|
||||||
TeacherNames = x.Teachers
|
TeacherNames = x.Teachers
|
||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
.Select(item => item.Teacher!.Name),
|
.Select(item => item.Teacher!.Name),
|
||||||
@@ -161,6 +170,9 @@ public sealed class TeachingTasksController(
|
|||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
x.SchedulingMode,
|
x.SchedulingMode,
|
||||||
CourseTotalHours = x.Course.TotalHours,
|
CourseTotalHours = x.Course.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
|
CourseRegularScheduleHours =
|
||||||
|
x.Course.TotalHours - x.Course.PracticeHours,
|
||||||
x.GenerationBatchCode,
|
x.GenerationBatchCode,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
@@ -389,6 +401,7 @@ public sealed class TeachingTasksController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
return Ok(new { AffectedCount = tasks.Count });
|
return Ok(new { AffectedCount = tasks.Count });
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -420,7 +433,8 @@ public sealed class TeachingTasksController(
|
|||||||
course,
|
course,
|
||||||
request.StartWeek,
|
request.StartWeek,
|
||||||
request.EndWeek,
|
request.EndWeek,
|
||||||
request.WeeklyHours);
|
request.WeeklyHours,
|
||||||
|
TeachingTaskSchedulingMode.Standard);
|
||||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||||
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
||||||
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
||||||
@@ -544,6 +558,9 @@ public sealed class TeachingTasksController(
|
|||||||
db.TeachingTasks.AddRange(created);
|
db.TeachingTasks.AddRange(created);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
BatchCode = batchCode,
|
BatchCode = batchCode,
|
||||||
@@ -592,7 +609,8 @@ public sealed class TeachingTasksController(
|
|||||||
course,
|
course,
|
||||||
request.StartWeek,
|
request.StartWeek,
|
||||||
request.EndWeek,
|
request.EndWeek,
|
||||||
request.WeeklyHours);
|
request.WeeklyHours,
|
||||||
|
request.SchedulingMode);
|
||||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||||
var collegeId = ScopedCollegeId();
|
var collegeId = ScopedCollegeId();
|
||||||
if (!await db.AcademicTerms.AnyAsync(
|
if (!await db.AcademicTerms.AnyAsync(
|
||||||
@@ -737,6 +755,7 @@ public sealed class TeachingTasksController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ public sealed class TimetableManagementController(
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
public sealed class FreeClassroomsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("free-classrooms/options")]
|
[HttpGet("free-classrooms/options")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
@@ -297,9 +299,9 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var term = await db.AcademicTerms.AsNoTracking()
|
var term = await db.AcademicTerms.AsNoTracking()
|
||||||
.Where(x => x.Id == academicTermId && x.IsEnabled)
|
.FirstOrDefaultAsync(
|
||||||
.Select(x => new { x.Id, x.Name })
|
x => x.Id == academicTermId && x.IsEnabled,
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
cancellationToken);
|
||||||
if (term is null) return NotFound();
|
if (term is null) return NotFound();
|
||||||
var plan = await db.SchedulePlans.AsNoTracking()
|
var plan = await db.SchedulePlans.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -329,33 +331,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
|||||||
if (activePeriodCount != requestedPeriods.Length)
|
if (activePeriodCount != requestedPeriods.Length)
|
||||||
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
||||||
|
|
||||||
var candidates = await db.ScheduleEntries.AsNoTracking()
|
var reservationDate = ResolveReservationDate(term, week, dayOfWeek);
|
||||||
.Where(x =>
|
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||||
x.SchedulePlanId == plan.Id &&
|
return ValidationProblem("所选周次和星期不在学期日期范围内。");
|
||||||
x.ClassroomId.HasValue &&
|
var occupiedIds =
|
||||||
x.DayOfWeek == dayOfWeek &&
|
await reservationAvailability.GetOccupiedClassroomIdsAsync(
|
||||||
x.StartWeek <= week &&
|
term,
|
||||||
x.EndWeek >= week &&
|
reservationDate,
|
||||||
x.StartPeriod < startPeriod + periodCount &&
|
startPeriod,
|
||||||
startPeriod < x.StartPeriod + x.PeriodCount)
|
periodCount,
|
||||||
.Select(x => new
|
null,
|
||||||
{
|
cancellationToken);
|
||||||
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 rooms = db.Classrooms.AsNoTracking()
|
var rooms = db.Classrooms.AsNoTracking()
|
||||||
.Where(x =>
|
.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(
|
public sealed record FreeClassroomTimeSlotDto(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Timetables;
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
@@ -14,74 +16,25 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class TimetablesController(
|
public sealed class TimetablesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
TimetableDataService timetableDataService) : ControllerBase
|
TimetableDataService timetableDataService,
|
||||||
|
PersonalCalendarService personalCalendarService,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("options")]
|
[HttpGet("options")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
|
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var terms = await db.AcademicTerms.AsNoTracking()
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Where(x => x.IsEnabled)
|
AppCacheKeys.TimetableOptions,
|
||||||
.OrderByDescending(x => x.StartDate)
|
LoadOptionsAsync,
|
||||||
.Select(x => new
|
AppCacheProfile.ReferenceData,
|
||||||
{
|
[
|
||||||
x.Id,
|
AppCacheTags.BaseData,
|
||||||
x.Name,
|
AppCacheTags.Timetables,
|
||||||
x.AcademicYear,
|
AppCacheTags.TimetableOptions
|
||||||
x.Season,
|
],
|
||||||
x.StartDate,
|
cancellationToken);
|
||||||
x.EndDate,
|
return Ok(result);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classes/{classId:guid}")]
|
[HttpGet("classes/{classId:guid}")]
|
||||||
@@ -99,14 +52,10 @@ public sealed class TimetablesController(
|
|||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await timetableDataService.BuildAsync(
|
var result = await GetPublishedTimetableAsync(
|
||||||
TimetableResourceType.Teacher,
|
TimetableResourceType.Teacher,
|
||||||
teacherId,
|
teacherId,
|
||||||
academicTermId,
|
academicTermId,
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
@@ -118,14 +67,10 @@ public sealed class TimetablesController(
|
|||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await timetableDataService.BuildAsync(
|
var result = await GetPublishedTimetableAsync(
|
||||||
TimetableResourceType.Teacher,
|
TimetableResourceType.Teacher,
|
||||||
teacherId,
|
teacherId,
|
||||||
academicTermId,
|
academicTermId,
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (result is null) return NotFound();
|
if (result is null) return NotFound();
|
||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
@@ -138,14 +83,10 @@ public sealed class TimetablesController(
|
|||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await timetableDataService.BuildAsync(
|
var result = await GetPublishedTimetableAsync(
|
||||||
TimetableResourceType.Class,
|
TimetableResourceType.Class,
|
||||||
classId,
|
classId,
|
||||||
academicTermId,
|
academicTermId,
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (result is null) return NotFound();
|
if (result is null) return NotFound();
|
||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
@@ -262,6 +203,101 @@ public sealed class TimetablesController(
|
|||||||
return NotFound();
|
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(
|
private async Task<ActionResult> BuildTimetableAsync(
|
||||||
Guid classId,
|
Guid classId,
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
@@ -269,18 +305,119 @@ public sealed class TimetablesController(
|
|||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
TimetableStudentDto? student = null)
|
TimetableStudentDto? student = null)
|
||||||
{
|
{
|
||||||
var result = await timetableDataService.BuildAsync(
|
var result = studentId.HasValue || student is not null
|
||||||
TimetableResourceType.Class,
|
? await timetableDataService.BuildAsync(
|
||||||
classId,
|
TimetableResourceType.Class,
|
||||||
academicTermId,
|
classId,
|
||||||
null,
|
academicTermId,
|
||||||
false,
|
null,
|
||||||
studentId,
|
false,
|
||||||
student,
|
studentId,
|
||||||
cancellationToken);
|
student,
|
||||||
|
cancellationToken)
|
||||||
|
: await GetPublishedTimetableAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
classId,
|
||||||
|
academicTermId,
|
||||||
|
cancellationToken);
|
||||||
return result is null ? NotFound() : Ok(result);
|
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)
|
private ActionResult ExcelFile(TimetableData result)
|
||||||
{
|
{
|
||||||
var bytes = TimetableExcelExporter.Create(result);
|
var bytes = TimetableExcelExporter.Create(result);
|
||||||
@@ -295,4 +432,89 @@ public sealed class TimetablesController(
|
|||||||
value = value.Replace(character, '-');
|
value = value.Replace(character, '-');
|
||||||
return value.Trim();
|
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 si = studentInfos.First(s => s.Id == w.StudentId);
|
||||||
var r = rules.First(r => r.Type == w.Type);
|
var r = rules.First(r => r.Type == w.Type);
|
||||||
if (r.NotifyStudent && si.UserId.HasValue)
|
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)
|
if (r.NotifyCounselor)
|
||||||
{
|
{
|
||||||
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||||
if (counselorId != default)
|
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 string? Notes { get; set; }
|
||||||
public DateTime? SubmittedAt { get; set; }
|
public DateTime? SubmittedAt { get; set; }
|
||||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||||
|
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AttendanceRecord
|
public sealed class AttendanceRecord
|
||||||
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
|
|||||||
public DateTime? AppealReviewedAt { get; set; }
|
public DateTime? AppealReviewedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AttendanceCheckInAttempt : EntityBase
|
||||||
|
{
|
||||||
|
public Guid AttendanceSheetId { get; set; }
|
||||||
|
public AttendanceSheet? AttendanceSheet { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public AttendanceCheckInMethod CheckInMethod { get; set; }
|
||||||
|
public bool IsSuccessful { get; set; }
|
||||||
|
public string? FailureCode { get; set; }
|
||||||
|
public string? DeviceIdentifierHash { get; set; }
|
||||||
|
public string? DevicePlatform { get; set; }
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
public string? RiskFlags { get; set; }
|
||||||
|
public decimal? Latitude { get; set; }
|
||||||
|
public decimal? Longitude { get; set; }
|
||||||
|
public double? AccuracyMeters { get; set; }
|
||||||
|
public double? DistanceMeters { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum AttendanceSheetStatus
|
public enum AttendanceSheetStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
|
|||||||
@@ -0,0 +1,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 CourseAdjustmentStatus Status { get; set; } = CourseAdjustmentStatus.Draft;
|
||||||
public Guid ApplicantUserId { get; set; }
|
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 DateOnly? TargetDate { get; set; }
|
||||||
public int? DayOfWeek { get; set; }
|
public int? DayOfWeek { get; set; }
|
||||||
public int? StartPeriod { get; set; }
|
public int? StartPeriod { get; set; }
|
||||||
|
|||||||
@@ -11,12 +11,21 @@ public sealed class CourseSelectionRound : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public DateTime WithdrawalEndsAt { get; set; }
|
public DateTime WithdrawalEndsAt { get; set; }
|
||||||
public decimal MaxCredits { get; set; } = 30;
|
public decimal MaxCredits { get; set; } = 30;
|
||||||
|
public int? MaxCourseCount { get; set; }
|
||||||
public CourseSelectionRoundStatus Status { get; set; } =
|
public CourseSelectionRoundStatus Status { get; set; } =
|
||||||
CourseSelectionRoundStatus.Draft;
|
CourseSelectionRoundStatus.Draft;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public ICollection<CourseSelectionRoundGrade> EligibleGrades { get; set; } = [];
|
||||||
public ICollection<CourseSelectionOffering> Offerings { 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 sealed class CourseSelectionOffering : EntityBase
|
||||||
{
|
{
|
||||||
public Guid CourseSelectionRoundId { get; set; }
|
public Guid CourseSelectionRoundId { get; set; }
|
||||||
@@ -38,6 +47,7 @@ public sealed class CourseEnrollment : EntityBase
|
|||||||
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
|
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
|
||||||
public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal;
|
public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal;
|
||||||
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
|
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? WaitlistedAt { get; set; }
|
||||||
public DateTime? WithdrawnAt { get; set; }
|
public DateTime? WithdrawnAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +61,10 @@ public enum CourseSelectionRoundStatus
|
|||||||
public enum CourseEnrollmentStatus
|
public enum CourseEnrollmentStatus
|
||||||
{
|
{
|
||||||
Enrolled = 1,
|
Enrolled = 1,
|
||||||
Withdrawn = 2
|
Withdrawn = 2,
|
||||||
|
Waitlisted = 3,
|
||||||
|
Cancelled = 4,
|
||||||
|
Expired = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum EnrollmentType
|
public enum EnrollmentType
|
||||||
|
|||||||
@@ -11,6 +11,41 @@ public sealed class ExamPlan : EntityBase
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public DateTime? PublishedAt { get; set; }
|
public DateTime? PublishedAt { get; set; }
|
||||||
public ICollection<ExamSession> Sessions { get; set; } = [];
|
public ICollection<ExamSession> Sessions { get; set; } = [];
|
||||||
|
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamArrangementJob : EntityBase
|
||||||
|
{
|
||||||
|
public ExamArrangementKind Kind { get; set; }
|
||||||
|
public Guid PlanId { get; set; }
|
||||||
|
public Guid? ActivePlanId { get; set; }
|
||||||
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? SessionIdsJson { get; set; }
|
||||||
|
public bool AssignClassrooms { get; set; }
|
||||||
|
public bool AssignInvigilators { get; set; }
|
||||||
|
public ExamArrangementJobStatus Status { get; set; } =
|
||||||
|
ExamArrangementJobStatus.Queued;
|
||||||
|
public int TotalSessions { get; set; }
|
||||||
|
public int ProcessedSessions { get; set; }
|
||||||
|
public string? CurrentStep { get; set; }
|
||||||
|
public string? ResultMessage { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamArrangementKind
|
||||||
|
{
|
||||||
|
FormalExam = 1,
|
||||||
|
MakeupExam = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamArrangementJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ExamSession : EntityBase
|
public sealed class ExamSession : EntityBase
|
||||||
@@ -28,9 +63,12 @@ public sealed class ExamSession : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public Guid? RequiredBuildingId { get; set; }
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
public Building? RequiredBuilding { get; set; }
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public string? RequiredBuildingIds { get; set; }
|
||||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
|
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
|
||||||
|
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ExamSessionInvigilator
|
public sealed class ExamSessionInvigilator
|
||||||
@@ -41,9 +79,82 @@ public sealed class ExamSessionInvigilator
|
|||||||
public Teacher? Teacher { get; set; }
|
public Teacher? Teacher { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ExamRoomAssignment : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExamPlanId { get; set; }
|
||||||
|
public ExamPlan? ExamPlan { get; set; }
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Course? Course { get; set; }
|
||||||
|
public Guid ClassroomId { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
public DateOnly ExamDate { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; }
|
||||||
|
public DateTime StartsAt { get; set; }
|
||||||
|
public DateTime EndsAt { get; set; }
|
||||||
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
|
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
|
||||||
|
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
|
||||||
|
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamRoomSession
|
||||||
|
{
|
||||||
|
public Guid ExamRoomId { get; set; }
|
||||||
|
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||||
|
public Guid ExamSessionId { get; set; }
|
||||||
|
public ExamSession? ExamSession { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamSeatAssignment
|
||||||
|
{
|
||||||
|
public Guid ExamRoomId { get; set; }
|
||||||
|
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||||
|
public Guid ExamSessionId { get; set; }
|
||||||
|
public ExamSession? ExamSession { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public int SeatNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamRoomInvigilator
|
||||||
|
{
|
||||||
|
public Guid ExamRoomId { get; set; }
|
||||||
|
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||||
|
public Guid TeacherId { get; set; }
|
||||||
|
public Teacher? Teacher { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum ExamPlanStatus
|
public enum ExamPlanStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
Published = 2,
|
Published = 2,
|
||||||
Archived = 3
|
Archived = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ExamPublishJob : EntityBase
|
||||||
|
{
|
||||||
|
public ExamPublishJobKind Kind { get; set; }
|
||||||
|
public Guid PlanId { get; set; }
|
||||||
|
public Guid? ActivePlanId { get; set; }
|
||||||
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||||
|
public string? CurrentStep { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamPublishJobKind
|
||||||
|
{
|
||||||
|
FormalExam = 1,
|
||||||
|
MakeupExam = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamPublishJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExamSignInExportJob : EntityBase
|
||||||
|
{
|
||||||
|
public Guid PlanId { get; set; }
|
||||||
|
public ExamSignInExportJobStatus Status { get; set; } =
|
||||||
|
ExamSignInExportJobStatus.Queued;
|
||||||
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public byte[]? FileBytes { get; set; }
|
||||||
|
public int FileSize { get; set; }
|
||||||
|
public string? CurrentStep { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamSignInExportJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExperimentProject : EntityBase
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public required string Code { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Requirements { get; set; }
|
||||||
|
public DateOnly StartDate { get; set; }
|
||||||
|
public DateOnly EndDate { get; set; }
|
||||||
|
public ExperimentProjectStatus Status { get; set; } =
|
||||||
|
ExperimentProjectStatus.Draft;
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public DateTime? ClosedAt { get; set; }
|
||||||
|
public ICollection<ExperimentSession> Sessions { get; set; } = [];
|
||||||
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
public ExperimentGradeSheet? GradeSheet { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentSession : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public Guid ClassroomId { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
public DateOnly SessionDate { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; }
|
||||||
|
public int Capacity { get; set; }
|
||||||
|
public int ReservedCount { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public ExperimentSessionStatus Status { get; set; } =
|
||||||
|
ExperimentSessionStatus.Scheduled;
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentBooking : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public Guid ExperimentSessionId { get; set; }
|
||||||
|
public ExperimentSession? ExperimentSession { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public ExperimentBookingStatus Status { get; set; } =
|
||||||
|
ExperimentBookingStatus.Booked;
|
||||||
|
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentArrangementMode
|
||||||
|
{
|
||||||
|
Centralized = 1,
|
||||||
|
SelfScheduled = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentProjectStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Published = 2,
|
||||||
|
Closed = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentSessionStatus
|
||||||
|
{
|
||||||
|
Scheduled = 1,
|
||||||
|
Cancelled = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentBookingStatus
|
||||||
|
{
|
||||||
|
Booked = 1,
|
||||||
|
Cancelled = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeSheet : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public decimal ContributionWeight { get; set; } = 1;
|
||||||
|
public decimal PassScore { get; set; } = 60;
|
||||||
|
public ExperimentGradeSheetStatus Status { get; set; } =
|
||||||
|
ExperimentGradeSheetStatus.Draft;
|
||||||
|
public string? ReviewComment { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public DateTime? ReviewedAt { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
|
||||||
|
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeItem : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentGradeSheetId { get; set; }
|
||||||
|
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public ExperimentGradeItemKind Kind { get; set; } =
|
||||||
|
ExperimentGradeItemKind.Other;
|
||||||
|
public decimal Weight { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeRecord : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentGradeSheetId { get; set; }
|
||||||
|
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public Guid? ExperimentSessionId { get; set; }
|
||||||
|
public ExperimentSession? ExperimentSession { get; set; }
|
||||||
|
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
|
||||||
|
ExperimentParticipationStatus.Pending;
|
||||||
|
public decimal? TotalScore { get; set; }
|
||||||
|
public bool? IsPassed { get; set; }
|
||||||
|
public bool SafetyViolation { get; set; }
|
||||||
|
public int AttemptNumber { get; set; } = 1;
|
||||||
|
public string? SubmissionReference { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public bool IsLate { get; set; }
|
||||||
|
public string? TeacherComment { get; set; }
|
||||||
|
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeItemScore
|
||||||
|
{
|
||||||
|
public Guid ExperimentGradeRecordId { get; set; }
|
||||||
|
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
|
||||||
|
public Guid ExperimentGradeItemId { get; set; }
|
||||||
|
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
|
||||||
|
public decimal? Score { get; set; }
|
||||||
|
public string? Comment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentGradeSheetStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Submitted = 2,
|
||||||
|
Approved = 3,
|
||||||
|
Published = 4,
|
||||||
|
Returned = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentGradeItemKind
|
||||||
|
{
|
||||||
|
Operation = 1,
|
||||||
|
Report = 2,
|
||||||
|
Result = 3,
|
||||||
|
Defense = 4,
|
||||||
|
Attendance = 5,
|
||||||
|
Safety = 6,
|
||||||
|
Other = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentParticipationStatus
|
||||||
|
{
|
||||||
|
Pending = 1,
|
||||||
|
Completed = 2,
|
||||||
|
Absent = 3,
|
||||||
|
Excused = 4,
|
||||||
|
Makeup = 5,
|
||||||
|
Exempt = 6
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public decimal Weight { get; set; }
|
public decimal Weight { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public GradeItemSourceType SourceType { get; set; } =
|
||||||
|
GradeItemSourceType.Manual;
|
||||||
|
public DateTime? SourceSnapshotAt { get; set; }
|
||||||
public ICollection<GradeItemScore> Scores { get; set; } = [];
|
public ICollection<GradeItemScore> Scores { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,3 +71,9 @@ public enum GradeExamStatus
|
|||||||
Exempt = 4,
|
Exempt = 4,
|
||||||
Makeup = 5
|
Makeup = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum GradeItemSourceType
|
||||||
|
{
|
||||||
|
Manual = 1,
|
||||||
|
ExperimentSummary = 2
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public Guid? RequiredBuildingId { get; set; }
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
public Building? RequiredBuilding { get; set; }
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public string? RequiredBuildingIds { get; set; }
|
||||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
|
|||||||
@@ -7,6 +7,43 @@ public sealed class Notification : EntityBase
|
|||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
public required string Content { get; set; }
|
public required string Content { get; set; }
|
||||||
|
public NotificationCategory Category { get; set; } = NotificationCategory.General;
|
||||||
public bool IsRead { get; set; }
|
public bool IsRead { get; set; }
|
||||||
public string? LinkUrl { 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 DateOnly EnrollmentDate { get; set; }
|
||||||
public StudentStatus Status { get; set; } = StudentStatus.Active;
|
public StudentStatus Status { get; set; } = StudentStatus.Active;
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public string? IdCardNumber { get; set; }
|
||||||
|
public string? Nationality { get; set; }
|
||||||
|
public string? Ethnicity { get; set; }
|
||||||
|
public string? PoliticalStatus { get; set; }
|
||||||
|
public string? NativePlace { get; set; }
|
||||||
|
public string? HouseholdAddress { get; set; }
|
||||||
|
public string? CurrentAddress { get; set; }
|
||||||
|
public string? PostalCode { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
public string? Qq { get; set; }
|
||||||
|
public string? WeChat { get; set; }
|
||||||
|
public string? EmergencyContactName { get; set; }
|
||||||
|
public string? EmergencyContactRelationship { get; set; }
|
||||||
|
public string? EmergencyContactPhone { get; set; }
|
||||||
|
public string? SpecialTags { get; set; }
|
||||||
|
public string? SpecialNeeds { get; set; }
|
||||||
|
public string? Biography { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
}
|
}
|
||||||
@@ -50,6 +67,16 @@ public sealed class Course : CatalogEntity
|
|||||||
public CourseNature Nature { get; set; }
|
public CourseNature Nature { get; set; }
|
||||||
public AssessmentMethod AssessmentMethod { get; set; }
|
public AssessmentMethod AssessmentMethod { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
public ICollection<CoursePrerequisite> Prerequisites { get; set; } = [];
|
||||||
|
public ICollection<CoursePrerequisite> RequiredByCourses { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CoursePrerequisite : EntityBase
|
||||||
|
{
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Course? Course { get; set; }
|
||||||
|
public Guid PrerequisiteCourseId { get; set; }
|
||||||
|
public Course? PrerequisiteCourse { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CourseCategory : CatalogEntity
|
public sealed class CourseCategory : CatalogEntity
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
|
|||||||
public SchedulePlan? SchedulePlan { get; set; }
|
public SchedulePlan? SchedulePlan { get; set; }
|
||||||
public Guid TeachingTaskId { get; set; }
|
public Guid TeachingTaskId { get; set; }
|
||||||
public TeachingTask? TeachingTask { get; set; }
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
|
||||||
public Guid? ClassroomId { get; set; }
|
public Guid? ClassroomId { get; set; }
|
||||||
public Classroom? Classroom { get; set; }
|
public Classroom? Classroom { get; set; }
|
||||||
public int DayOfWeek { get; set; }
|
public int DayOfWeek { get; set; }
|
||||||
@@ -129,3 +130,9 @@ public enum WeekPattern
|
|||||||
Odd = 2,
|
Odd = 2,
|
||||||
Even = 3
|
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 bool IsEnabled { get; set; } = true;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? LastLoginAt { get; set; }
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
public string? CalendarSubscriptionStamp { get; set; }
|
||||||
|
public DateTime? CalendarSubscriptionCreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ApplicationRole : IdentityRole<Guid>
|
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 Issuer { get; set; } = "Jiaowu.Api";
|
||||||
public string Audience { get; set; } = "Jiaowu.Web";
|
public string Audience { get; set; } = "Jiaowu.Web";
|
||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
public int ExpireMinutes { get; set; } = 480;
|
public int AccessTokenMinutes { get; set; } = 10;
|
||||||
|
public int WebIdleMinutes { get; set; } = 30;
|
||||||
|
public int AppIdleMinutes { get; set; } = 3 * 24 * 60;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
|
||||||
|
public sealed class SsoOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Sso";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string DisplayName { get; set; } = "学校统一身份认证";
|
||||||
|
public string Authority { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
public string UserNameClaim { get; set; } = "preferred_username";
|
||||||
|
public bool RequireHttpsMetadata { get; set; } = true;
|
||||||
|
public bool LinkExistingUsersByUserName { get; set; } = true;
|
||||||
|
public string FrontendBaseUrl { get; set; } = string.Empty;
|
||||||
|
public string CallbackUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SsoAuthSchemes
|
||||||
|
{
|
||||||
|
public const string Keycloak = "Keycloak";
|
||||||
|
public const string ExternalCookie = "KeycloakExternal";
|
||||||
|
public const string LoginProvider = "Keycloak";
|
||||||
|
}
|
||||||
@@ -9,14 +9,16 @@ namespace Jiaowu.Api.Infrastructure.Auth;
|
|||||||
|
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
string Create(ApplicationUser user, IEnumerable<string> roles);
|
AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AccessTokenResult(string Token, DateTime ExpiresAt);
|
||||||
|
|
||||||
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||||
{
|
{
|
||||||
private readonly JwtOptions _options = options.Value;
|
private readonly JwtOptions _options = options.Value;
|
||||||
|
|
||||||
public string Create(ApplicationUser user, IEnumerable<string> roles)
|
public AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
|||||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
|
||||||
SecurityAlgorithms.HmacSha256);
|
SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
issuer: _options.Issuer,
|
issuer: _options.Issuer,
|
||||||
audience: _options.Audience,
|
audience: _options.Audience,
|
||||||
claims: claims,
|
claims: claims,
|
||||||
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
|
expires: expiresAt,
|
||||||
signingCredentials: credentials);
|
signingCredentials: credentials);
|
||||||
|
|
||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return new AccessTokenResult(
|
||||||
|
new JwtSecurityTokenHandler().WriteToken(token),
|
||||||
|
expiresAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
round.Status == CourseSelectionRoundStatus.Open &&
|
||||||
nowUtc <= round.WithdrawalEndsAt;
|
nowUtc <= round.WithdrawalEndsAt;
|
||||||
|
|
||||||
|
public static bool CanPromoteWaitlist(CourseSelectionRound round, DateTime nowUtc) =>
|
||||||
|
round.Status == CourseSelectionRoundStatus.Open &&
|
||||||
|
nowUtc <= round.WithdrawalEndsAt;
|
||||||
|
|
||||||
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||||
nature == CourseNature.GeneralRequired;
|
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) =>
|
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
||||||
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
||||||
|
|
||||||
@@ -68,4 +82,7 @@ public static class CourseSelectionRules
|
|||||||
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
||||||
public static int RetakeCapacity(int originalCapacity) =>
|
public static int RetakeCapacity(int originalCapacity) =>
|
||||||
(int)Math.Ceiling(originalCapacity * 1.15);
|
(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.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
public sealed class ExamArrangementService(AppDbContext db)
|
public sealed class ExamArrangementService(AppDbContext db)
|
||||||
{
|
{
|
||||||
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
|
private sealed record RoomGroupKey(
|
||||||
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
|
Guid CourseId,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
int StartPeriod,
|
||||||
|
int PeriodCount,
|
||||||
|
string RequiredBuildingIdsKey);
|
||||||
|
|
||||||
|
private sealed record RoomOccupancy(
|
||||||
|
Guid ClassroomId,
|
||||||
|
DateTime StartsAt,
|
||||||
|
DateTime EndsAt);
|
||||||
|
|
||||||
|
private sealed record InvigilatorOccupancy(
|
||||||
|
Guid TeacherId,
|
||||||
|
DateTime StartsAt,
|
||||||
|
DateTime EndsAt);
|
||||||
|
|
||||||
|
private sealed record CandidateSeat(
|
||||||
|
Guid ExamSessionId,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber);
|
||||||
|
|
||||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||||
|
bool assignClassrooms,
|
||||||
|
bool assignInvigilators,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!assignClassrooms && !assignInvigilators)
|
||||||
|
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||||
|
|
||||||
var plan = await db.ExamPlans
|
var plan = await db.ExamPlans
|
||||||
.Include(x => x.AcademicTerm)
|
.Include(x => x.AcademicTerm)
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
@@ -20,6 +46,16 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
.ThenInclude(x => x.TeachingTask)
|
.ThenInclude(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.Rooms)
|
||||||
|
.ThenInclude(x => x.SessionLinks)
|
||||||
|
.Include(x => x.Rooms)
|
||||||
|
.ThenInclude(x => x.Seats)
|
||||||
|
.Include(x => x.Rooms)
|
||||||
|
.ThenInclude(x => x.Invigilators)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||||
|
|
||||||
if (plan is null)
|
if (plan is null)
|
||||||
@@ -27,201 +63,558 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
if (plan.Status != ExamPlanStatus.Draft)
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
||||||
|
|
||||||
var sessions = plan.Sessions.ToList();
|
var requestedIds = (requestedSessionIds ?? [])
|
||||||
if (sessions.Count == 0)
|
.Distinct()
|
||||||
return ExamArrangementResult.Fail("考试计划中没有场次。");
|
.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()
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
|
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
||||||
.OrderBy(x => x.PeriodNumber)
|
.OrderBy(x => x.PeriodNumber)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (timeSlots.Count == 0)
|
if (timeSlots.Count == 0)
|
||||||
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
|
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
|
||||||
|
|
||||||
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
foreach (var session in plan.Sessions)
|
||||||
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
|
|
||||||
ComputeTimesFromSlots(session, timeSlotLookup);
|
ComputeTimesFromSlots(session, timeSlotLookup);
|
||||||
var studentCount = await db.CourseEnrollments.CountAsync(
|
|
||||||
x => x.Status == CourseEnrollmentStatus.Enrolled &&
|
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// ── Auto-assign classroom ──
|
var explicitlySelected = plan.Sessions
|
||||||
if (!session.ClassroomId.HasValue)
|
.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.ClassroomId = null;
|
||||||
session, studentCount, occupiedRooms, cancellationToken);
|
if (session.Invigilators.Count > 0)
|
||||||
if (room is not null)
|
|
||||||
{
|
{
|
||||||
session.ClassroomId = room.Id;
|
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
session.Invigilators.Clear();
|
||||||
assignedRooms++;
|
|
||||||
messages.Add(
|
|
||||||
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedRooms++;
|
|
||||||
messages.Add(
|
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedRooms++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-assign invigilators ──
|
foreach (var group in sessions
|
||||||
var currentInvigilatorCount = session.Invigilators.Count;
|
.GroupBy(GroupKey)
|
||||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
.OrderBy(x => x.Key.ExamDate)
|
||||||
if (needed > 0)
|
.ThenBy(x => x.Key.StartPeriod)
|
||||||
|
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
|
||||||
{
|
{
|
||||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
var groupSessions = group.ToList();
|
||||||
.Select(x => x.TeacherId).ToHashSet();
|
var candidates = InterleaveCandidates(
|
||||||
var newlyAssigned = await FindInvigilatorsAsync(
|
groupSessions,
|
||||||
session, needed, courseTeacherIds,
|
roster,
|
||||||
occupiedInvigilators, cancellationToken);
|
sessionByTaskId);
|
||||||
foreach (var teacher in newlyAssigned)
|
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
|
TeacherId = teacher.Id
|
||||||
});
|
});
|
||||||
occupiedInvigilators.Add(new InvigilatorOccupancy(
|
occupiedInvigilators.Add(new InvigilatorOccupancy(
|
||||||
teacher.Id, session.StartsAt, session.EndsAt));
|
teacher.Id,
|
||||||
|
room.StartsAt,
|
||||||
|
room.EndsAt));
|
||||||
assignedInvigilators++;
|
assignedInvigilators++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newlyAssigned.Count < needed)
|
if (teachers.Count < needed)
|
||||||
messages.Add(
|
unavailableInvigilators += needed - teachers.Count;
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedInvigilators++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var expandedCount = sessions.Count - explicitlySelected.Count;
|
||||||
|
var detail = messages.Count > 0
|
||||||
|
? $" 详情:{string.Join(";", messages.Take(10))}"
|
||||||
|
: "";
|
||||||
return new ExamArrangementResult(
|
return new ExamArrangementResult(
|
||||||
true,
|
true,
|
||||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
$"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
|
||||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
$"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
|
||||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
(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(
|
private static void ComputeTimesFromSlots(
|
||||||
ExamSession session,
|
ExamSession session,
|
||||||
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
||||||
{
|
{
|
||||||
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
|
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
|
||||||
var endSlot = timeSlotLookup.GetValueOrDefault(
|
var endSlot = timeSlotLookup.GetValueOrDefault(
|
||||||
session.StartPeriod + session.PeriodCount - 1);
|
session.StartPeriod + session.PeriodCount - 1);
|
||||||
if (startSlot is null || endSlot is null) return;
|
if (startSlot is null || endSlot is null) return;
|
||||||
|
|
||||||
var examDate = session.ExamDate;
|
session.StartsAt = session.ExamDate.ToDateTime(
|
||||||
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
|
startSlot.StartsAt,
|
||||||
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
|
DateTimeKind.Utc);
|
||||||
}
|
session.EndsAt = session.ExamDate.ToDateTime(
|
||||||
|
endSlot.EndsAt,
|
||||||
private async Task<Classroom?> FindBestClassroomAsync(
|
DateTimeKind.Utc);
|
||||||
ExamSession session,
|
|
||||||
int studentCount,
|
|
||||||
List<RoomOccupancy> occupied,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var query = db.Classrooms.AsNoTracking()
|
|
||||||
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
|
|
||||||
|
|
||||||
if (session.RequiredBuildingId.HasValue)
|
|
||||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
|
||||||
|
|
||||||
// Exclude classrooms already occupied in-memory
|
|
||||||
var occupiedRoomIds = occupied
|
|
||||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
|
||||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
|
||||||
.Select(x => x.ClassroomId)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
if (occupiedRoomIds.Count > 0)
|
|
||||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
|
||||||
|
|
||||||
// Exclude classrooms occupied by DB sessions not yet tracked in memory
|
|
||||||
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
|
|
||||||
.Where(x => x.ExamPlanId == session.ExamPlanId &&
|
|
||||||
x.Id != session.Id &&
|
|
||||||
x.ClassroomId != null &&
|
|
||||||
x.StartsAt < session.EndsAt &&
|
|
||||||
session.StartsAt < x.EndsAt)
|
|
||||||
.Select(x => x.ClassroomId!.Value)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (dbOccupiedRooms.Count > 0)
|
|
||||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
|
||||||
|
|
||||||
return await query
|
|
||||||
.OrderBy(x => x.Capacity)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<Teacher>> FindInvigilatorsAsync(
|
|
||||||
ExamSession session,
|
|
||||||
int needed,
|
|
||||||
HashSet<Guid> excludeTeacherIds,
|
|
||||||
List<InvigilatorOccupancy> occupied,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var busyTeacherIds = occupied
|
|
||||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
|
||||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
|
||||||
.Select(x => x.TeacherId)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
|
|
||||||
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
|
|
||||||
x.ExamSessionId != session.Id &&
|
|
||||||
x.ExamSession!.StartsAt < session.EndsAt &&
|
|
||||||
session.StartsAt < x.ExamSession.EndsAt)
|
|
||||||
.Select(x => x.TeacherId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
|
||||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
|
||||||
|
|
||||||
return await db.Teachers.AsNoTracking()
|
|
||||||
.Where(x => x.Status == TeacherStatus.Active)
|
|
||||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
|
||||||
.OrderBy(x => Guid.NewGuid())
|
|
||||||
.Take(needed)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ExamArrangementResult(bool Success, string Message)
|
public sealed record ExamArrangementResult(bool Success, string Message)
|
||||||
{
|
{
|
||||||
public static ExamArrangementResult Fail(string message) => new(false, message);
|
public static ExamArrangementResult Fail(string message) =>
|
||||||
|
new(false, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class ExamPublishJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
IAppCache cache,
|
||||||
|
ILogger<ExamPublishJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||||
|
if (job is null ||
|
||||||
|
job.Status is ExamPublishJobStatus.Succeeded
|
||||||
|
or ExamPublishJobStatus.Failed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamPublishJobStatus.Running;
|
||||||
|
job.StartedAt ??= DateTime.UtcNow;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
job.CurrentStep = "正在校验考试计划";
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
switch (job.Kind)
|
||||||
|
{
|
||||||
|
case ExamPublishJobKind.FormalExam:
|
||||||
|
await PublishFormalExamAsync(job, stoppingToken);
|
||||||
|
break;
|
||||||
|
case ExamPublishJobKind.MakeupExam:
|
||||||
|
await PublishMakeupExamAsync(job, stoppingToken);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"不支持的考试发布类型:{job.Kind}。");
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamPublishJobStatus.Succeeded;
|
||||||
|
job.ActivePlanId = null;
|
||||||
|
job.CurrentStep = "发布完成";
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
stoppingToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
|
||||||
|
job.Id,
|
||||||
|
job.Kind,
|
||||||
|
job.PlanId);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Exam publish job {JobId} was interrupted by application shutdown.",
|
||||||
|
jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (ExamPublishValidationException validationException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
validationException,
|
||||||
|
"Exam publish job {JobId} validation failed.",
|
||||||
|
jobId);
|
||||||
|
await MarkFailedAsync(jobId, validationException.Message);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
|
||||||
|
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishFormalExamAsync(
|
||||||
|
ExamPublishJob job,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var plan = await db.ExamPlans
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||||
|
if (plan is null)
|
||||||
|
throw new ExamPublishValidationException("考试计划不存在。");
|
||||||
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
|
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
|
||||||
|
|
||||||
|
// Step 1: check sessions exist — simple count, no JOIN
|
||||||
|
var sessionCount = await db.ExamSessions
|
||||||
|
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
|
||||||
|
if (sessionCount == 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
"至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
|
// Step 2: load session summaries — only necessary columns, no Include chains
|
||||||
|
var sessions = await db.ExamSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == job.PlanId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.ClassroomId,
|
||||||
|
InvigilatorCount = x.Invigilators.Count,
|
||||||
|
RoomLinkCount = x.RoomLinks.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Step 3: load roster counts — independent query
|
||||||
|
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
|
||||||
|
var rosterCounts = (await TeachingTaskRosterQuery
|
||||||
|
.LoadForTasksAsync(db, taskIds, ct))
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.ToDictionary(x => x.Key, x => x.Count());
|
||||||
|
|
||||||
|
// Step 4: validate each session's assignment completeness
|
||||||
|
var unassignedSessionIds = new List<Guid>();
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
|
||||||
|
if (session.RoomLinkCount > 0)
|
||||||
|
{
|
||||||
|
// Has room links — check seat assignment via separate query
|
||||||
|
var assignedSeatCount = await db.ExamSeats
|
||||||
|
.CountAsync(seat =>
|
||||||
|
seat.ExamSessionId == session.Id,
|
||||||
|
ct);
|
||||||
|
if (assignedSeatCount != rosterCount)
|
||||||
|
unassignedSessionIds.Add(session.Id);
|
||||||
|
}
|
||||||
|
else if (!session.ClassroomId.HasValue ||
|
||||||
|
session.InvigilatorCount == 0)
|
||||||
|
{
|
||||||
|
unassignedSessionIds.Add(session.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unassignedSessionIds.Count > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
|
||||||
|
"请先完成自动编排。");
|
||||||
|
|
||||||
|
// Step 5: check room invigilator sufficiency — separate query
|
||||||
|
var insufficientInvigilatorCount = await db.ExamRoomSessions
|
||||||
|
.Where(link =>
|
||||||
|
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||||
|
link.ExamRoom.Invigilators.Count <
|
||||||
|
link.ExamRoom.RequiredInvigilatorCount)
|
||||||
|
.Select(link => link.ExamSessionId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
if (insufficientInvigilatorCount > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
|
||||||
|
"请先完成自动编排。");
|
||||||
|
|
||||||
|
// Step 6: validate mixed rooms — split into two independent queries
|
||||||
|
// to avoid Seats × SessionLinks Cartesian product
|
||||||
|
|
||||||
|
// 6a: capacity overflow
|
||||||
|
var overCapacityCount = await db.ExamRooms
|
||||||
|
.Where(r => r.ExamPlanId == job.PlanId)
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
r.Id,
|
||||||
|
SeatCount = r.Seats.Count,
|
||||||
|
Capacity = r.Classroom!.Capacity
|
||||||
|
})
|
||||||
|
.CountAsync(x => x.SeatCount > x.Capacity, ct);
|
||||||
|
if (overCapacityCount > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
|
||||||
|
|
||||||
|
// 6b: course mismatch
|
||||||
|
var courseMismatchCount = await db.ExamRoomSessions
|
||||||
|
.Where(link =>
|
||||||
|
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||||
|
link.ExamSession!.TeachingTask!.CourseId !=
|
||||||
|
link.ExamRoom.CourseId)
|
||||||
|
.Select(link => link.ExamRoomId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
if (courseMismatchCount > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
|
||||||
|
|
||||||
|
// Step 7: publish
|
||||||
|
plan.Status = ExamPlanStatus.Published;
|
||||||
|
plan.PublishedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishMakeupExamAsync(
|
||||||
|
ExamPublishJob job,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var plan = await db.MakeupExamPlans
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||||
|
if (plan is null)
|
||||||
|
throw new ExamPublishValidationException("补考计划不存在。");
|
||||||
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
|
||||||
|
|
||||||
|
// Step 1: check sessions exist — simple count
|
||||||
|
var sessionCount = await db.MakeupExamSessions
|
||||||
|
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
|
||||||
|
if (sessionCount == 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
"至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
|
// Step 2: load session summaries — only necessary columns
|
||||||
|
var sessions = await db.MakeupExamSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlanId == job.PlanId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.ClassroomId,
|
||||||
|
InvigilatorCount = x.Invigilators.Count,
|
||||||
|
EnrollmentCount = x.Enrollments.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Step 3: validate completeness — no Include chain needed
|
||||||
|
var unassigned = sessions.Count(x =>
|
||||||
|
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
|
||||||
|
if (unassigned > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||||
|
|
||||||
|
// Step 4: validate enrollments
|
||||||
|
var empty = sessions.Count(x => x.EnrollmentCount == 0);
|
||||||
|
if (empty > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {empty} 个场次没有登记补考学生。");
|
||||||
|
|
||||||
|
// Step 5: publish
|
||||||
|
plan.Status = MakeupExamPlanStatus.Published;
|
||||||
|
plan.PublishedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (job is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.Status = ExamPublishJobStatus.Failed;
|
||||||
|
job.ActivePlanId = null;
|
||||||
|
job.CurrentStep = "发布失败";
|
||||||
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamPublishValidationException(string message)
|
||||||
|
: InvalidOperationException(message);
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class ExamSignInExportJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<ExamSignInExportJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var job = await db.ExamSignInExportJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||||
|
if (job is null ||
|
||||||
|
job.Status is ExamSignInExportJobStatus.Succeeded
|
||||||
|
or ExamSignInExportJobStatus.Failed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamSignInExportJobStatus.Running;
|
||||||
|
job.StartedAt ??= DateTime.UtcNow;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
job.CurrentStep = "正在生成考场签名单";
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
var plan = await db.ExamPlans.AsNoTracking()
|
||||||
|
.Where(x => x.Id == job.PlanId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Name,
|
||||||
|
TermName = x.AcademicTerm!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(stoppingToken);
|
||||||
|
if (plan is null)
|
||||||
|
{
|
||||||
|
await MarkFailedAsync(jobId, "考试计划不存在。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rooms = await db.ExamRooms.AsNoTracking()
|
||||||
|
.Include(x => x.Course)
|
||||||
|
.Include(x => x.Classroom)
|
||||||
|
.ThenInclude(x => x!.Building)
|
||||||
|
.Include(x => x.Invigilators)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.Include(x => x.SessionLinks)
|
||||||
|
.ThenInclude(x => x.ExamSession)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.Include(x => x.Seats)
|
||||||
|
.ThenInclude(x => x.Student)
|
||||||
|
.ThenInclude(x => x!.AdministrativeClass)
|
||||||
|
.Where(x => x.ExamPlanId == job.PlanId)
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenBy(x => x.Classroom!.Building!.Name)
|
||||||
|
.ThenBy(x => x.Classroom!.Name)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var legacySessions = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == job.PlanId)
|
||||||
|
.Where(x => !x.RoomLinks.Any())
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.Select(session => new
|
||||||
|
{
|
||||||
|
session.Id,
|
||||||
|
session.TeachingTaskId,
|
||||||
|
session.ExamDate,
|
||||||
|
session.StartsAt,
|
||||||
|
session.EndsAt,
|
||||||
|
CourseCode = session.TeachingTask!.Course!.Code,
|
||||||
|
CourseName = session.TeachingTask.Course.Name,
|
||||||
|
session.TeachingTask.TaskNumber,
|
||||||
|
BuildingName = session.Classroom != null
|
||||||
|
? session.Classroom.Building!.Name
|
||||||
|
: null,
|
||||||
|
ClassroomName = session.Classroom != null
|
||||||
|
? session.Classroom.Name
|
||||||
|
: null,
|
||||||
|
InvigilatorNames = session.Invigilators
|
||||||
|
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||||
|
.Select(item => item.Teacher!.Name)
|
||||||
|
})
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (rooms.Count == 0 && legacySessions.Count == 0)
|
||||||
|
{
|
||||||
|
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||||
|
db,
|
||||||
|
legacySessions.Select(x => x.TeachingTaskId),
|
||||||
|
stoppingToken);
|
||||||
|
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
var sheets = rooms.Select(room => new ExamSignInSessionData(
|
||||||
|
room.Id,
|
||||||
|
room.ExamDate,
|
||||||
|
room.StartsAt,
|
||||||
|
room.EndsAt,
|
||||||
|
room.Course!.Code,
|
||||||
|
room.Course.Name,
|
||||||
|
string.Join(
|
||||||
|
"、",
|
||||||
|
room.SessionLinks
|
||||||
|
.Select(link =>
|
||||||
|
link.ExamSession!.TeachingTask!.TaskNumber)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x)),
|
||||||
|
room.Classroom!.Building!.Name,
|
||||||
|
room.Classroom.Name,
|
||||||
|
room.Invigilators
|
||||||
|
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||||
|
.Select(item => item.Teacher!.Name)
|
||||||
|
.ToList(),
|
||||||
|
room.Seats
|
||||||
|
.OrderBy(seat => seat.SeatNumber)
|
||||||
|
.Select(seat => new ExamSignInStudentData(
|
||||||
|
seat.StudentId,
|
||||||
|
seat.Student!.StudentNumber,
|
||||||
|
seat.Student.Name,
|
||||||
|
seat.Student.AdministrativeClass!.Name,
|
||||||
|
seat.SeatNumber))
|
||||||
|
.ToList()))
|
||||||
|
.Concat(legacySessions.Select(session =>
|
||||||
|
new ExamSignInSessionData(
|
||||||
|
session.Id,
|
||||||
|
session.ExamDate,
|
||||||
|
session.StartsAt,
|
||||||
|
session.EndsAt,
|
||||||
|
session.CourseCode,
|
||||||
|
session.CourseName,
|
||||||
|
session.TaskNumber,
|
||||||
|
session.BuildingName,
|
||||||
|
session.ClassroomName,
|
||||||
|
session.InvigilatorNames.ToList(),
|
||||||
|
studentsByTask[session.TeachingTaskId]
|
||||||
|
.Select((student, index) =>
|
||||||
|
new ExamSignInStudentData(
|
||||||
|
student.StudentId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.ClassName,
|
||||||
|
index + 1))
|
||||||
|
.ToList())))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
|
||||||
|
var bytes = ExamSignInWorkbookExporter.Create(data);
|
||||||
|
|
||||||
|
job.Status = ExamSignInExportJobStatus.Succeeded;
|
||||||
|
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
|
||||||
|
job.FileBytes = bytes;
|
||||||
|
job.FileSize = bytes.Length;
|
||||||
|
job.CurrentStep = "生成完成";
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
|
||||||
|
job.Id,
|
||||||
|
job.PlanId,
|
||||||
|
bytes.Length);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Sign-in export job {JobId} was interrupted by application shutdown.",
|
||||||
|
jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
|
||||||
|
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (job is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.Status = ExamSignInExportJobStatus.Failed;
|
||||||
|
job.CurrentStep = "生成失败";
|
||||||
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string value)
|
||||||
|
{
|
||||||
|
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
|
||||||
|
var normalized = new string(value
|
||||||
|
.Select(c => invalid.Contains(c) ? '_' : c)
|
||||||
|
.ToArray()).Trim();
|
||||||
|
return normalized.Length > 100 ? normalized[..100] : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public static class ExamSignInWorkbookExporter
|
||||||
|
{
|
||||||
|
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
|
||||||
|
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
|
||||||
|
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
|
||||||
|
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
|
||||||
|
|
||||||
|
public static byte[] Create(ExamSignInWorkbookData data)
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
AddSummarySheet(workbook, data);
|
||||||
|
|
||||||
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"考场汇总"
|
||||||
|
};
|
||||||
|
foreach (var session in data.Sessions
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartsAt)
|
||||||
|
.ThenBy(x => x.BuildingName)
|
||||||
|
.ThenBy(x => x.ClassroomName)
|
||||||
|
.ThenBy(x => x.CourseCode))
|
||||||
|
{
|
||||||
|
AddSignInSheet(
|
||||||
|
workbook,
|
||||||
|
data,
|
||||||
|
session,
|
||||||
|
UniqueSheetName(session, usedNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSummarySheet(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ExamSignInWorkbookData data)
|
||||||
|
{
|
||||||
|
var sheet = workbook.Worksheets.Add("考场汇总");
|
||||||
|
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||||
|
sheet.ShowGridLines = false;
|
||||||
|
|
||||||
|
sheet.Range("A1:G1").Merge();
|
||||||
|
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
|
||||||
|
StyleTitle(sheet.Range("A1:G1"));
|
||||||
|
sheet.Row(1).Height = 34;
|
||||||
|
|
||||||
|
sheet.Range("A2:G2").Merge();
|
||||||
|
sheet.Cell("A2").Value =
|
||||||
|
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||||
|
StyleSubTitle(sheet.Range("A2:G2"));
|
||||||
|
sheet.Row(2).Height = 24;
|
||||||
|
|
||||||
|
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
|
||||||
|
for (var column = 1; column <= headers.Length; column++)
|
||||||
|
sheet.Cell(4, column).Value = headers[column - 1];
|
||||||
|
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
|
||||||
|
|
||||||
|
var row = 5;
|
||||||
|
foreach (var session in data.Sessions
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartsAt)
|
||||||
|
.ThenBy(x => x.BuildingName)
|
||||||
|
.ThenBy(x => x.ClassroomName))
|
||||||
|
{
|
||||||
|
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
|
||||||
|
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
|
||||||
|
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||||
|
sheet.Cell(row, 3).Value = Location(session);
|
||||||
|
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
|
||||||
|
sheet.Cell(row, 5).Value = session.TaskNumber;
|
||||||
|
sheet.Cell(row, 6).Value = session.Students.Count;
|
||||||
|
sheet.Cell(row, 7).Value = Invigilators(session);
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row > 5)
|
||||||
|
{
|
||||||
|
var table = sheet.Range(4, 1, row - 1, headers.Length);
|
||||||
|
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||||
|
table.Style.Border.InsideBorderColor = Rule;
|
||||||
|
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||||
|
table.Style.Border.OutsideBorderColor = Rule;
|
||||||
|
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||||
|
sheet.Rows(5, row - 1).Height = 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.Column(1).Width = 13;
|
||||||
|
sheet.Column(2).Width = 15;
|
||||||
|
sheet.Column(3).Width = 24;
|
||||||
|
sheet.Column(4).Width = 30;
|
||||||
|
sheet.Column(5).Width = 22;
|
||||||
|
sheet.Column(6).Width = 11;
|
||||||
|
sheet.Column(7).Width = 24;
|
||||||
|
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
|
||||||
|
sheet.SheetView.FreezeRows(4);
|
||||||
|
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
|
||||||
|
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||||
|
sheet.PageSetup.FitToPages(1, 0);
|
||||||
|
sheet.PageSetup.Margins
|
||||||
|
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSignInSheet(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ExamSignInWorkbookData data,
|
||||||
|
ExamSignInSessionData session,
|
||||||
|
string sheetName)
|
||||||
|
{
|
||||||
|
var sheet = workbook.Worksheets.Add(sheetName);
|
||||||
|
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||||
|
sheet.ShowGridLines = false;
|
||||||
|
|
||||||
|
sheet.Range("A1:G1").Merge();
|
||||||
|
sheet.Cell("A1").Value = "考试考场签名单";
|
||||||
|
StyleTitle(sheet.Range("A1:G1"));
|
||||||
|
sheet.Cell("A1").Style.Alignment.Horizontal =
|
||||||
|
XLAlignmentHorizontalValues.Center;
|
||||||
|
sheet.Row(1).Height = 36;
|
||||||
|
|
||||||
|
sheet.Range("A2:G2").Merge();
|
||||||
|
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
|
||||||
|
StyleSubTitle(sheet.Range("A2:G2"));
|
||||||
|
sheet.Cell("A2").Style.Alignment.Horizontal =
|
||||||
|
XLAlignmentHorizontalValues.Center;
|
||||||
|
sheet.Row(2).Height = 23;
|
||||||
|
|
||||||
|
sheet.Range("A3:D3").Merge();
|
||||||
|
sheet.Cell("A3").Value =
|
||||||
|
$"课程:{session.CourseCode} {session.CourseName}({session.TaskNumber})";
|
||||||
|
sheet.Range("E3:G3").Merge();
|
||||||
|
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
|
||||||
|
|
||||||
|
sheet.Range("A4:D4").Merge();
|
||||||
|
sheet.Cell("A4").Value =
|
||||||
|
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||||
|
sheet.Range("E4:G4").Merge();
|
||||||
|
sheet.Cell("E4").Value = $"考场:{Location(session)}";
|
||||||
|
|
||||||
|
sheet.Range("A5:D5").Merge();
|
||||||
|
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
|
||||||
|
sheet.Range("E5:G5").Merge();
|
||||||
|
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
|
||||||
|
|
||||||
|
var metadata = sheet.Range("A3:G5");
|
||||||
|
metadata.Style.Fill.BackgroundColor = XLColor.White;
|
||||||
|
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
|
||||||
|
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||||
|
metadata.Style.Alignment.WrapText = true;
|
||||||
|
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
||||||
|
metadata.Style.Border.BottomBorderColor = Rule;
|
||||||
|
sheet.Rows(3, 5).Height = 24;
|
||||||
|
|
||||||
|
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
|
||||||
|
for (var column = 1; column <= headers.Length; column++)
|
||||||
|
sheet.Cell(7, column).Value = headers[column - 1];
|
||||||
|
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
|
||||||
|
sheet.Row(7).Height = 27;
|
||||||
|
|
||||||
|
var row = 8;
|
||||||
|
foreach (var student in session.Students
|
||||||
|
.OrderBy(x => x.SeatNumber)
|
||||||
|
.ThenBy(x => x.StudentNumber))
|
||||||
|
{
|
||||||
|
var index = row - 7;
|
||||||
|
sheet.Cell(row, 1).Value = index;
|
||||||
|
sheet.Cell(row, 2).Value =
|
||||||
|
(student.SeatNumber > 0 ? student.SeatNumber : index)
|
||||||
|
.ToString("D3");
|
||||||
|
sheet.Cell(row, 3).Value = student.StudentNumber;
|
||||||
|
sheet.Cell(row, 4).Value = student.Name;
|
||||||
|
sheet.Cell(row, 5).Value = student.ClassName;
|
||||||
|
sheet.Row(row).Height = 28;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.Students.Count == 0)
|
||||||
|
{
|
||||||
|
sheet.Range("A8:G8").Merge();
|
||||||
|
sheet.Cell("A8").Value = "本场次暂无考生";
|
||||||
|
sheet.Cell("A8").Style
|
||||||
|
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
|
||||||
|
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
|
||||||
|
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
|
||||||
|
sheet.Row(8).Height = 28;
|
||||||
|
row = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roster = sheet.Range(7, 1, row - 1, headers.Length);
|
||||||
|
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||||
|
roster.Style.Border.InsideBorderColor = Rule;
|
||||||
|
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||||
|
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
|
||||||
|
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||||
|
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
|
||||||
|
XLAlignmentHorizontalValues.Center;
|
||||||
|
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
|
||||||
|
|
||||||
|
sheet.Column(1).Width = 7;
|
||||||
|
sheet.Column(2).Width = 10;
|
||||||
|
sheet.Column(3).Width = 16;
|
||||||
|
sheet.Column(4).Width = 12;
|
||||||
|
sheet.Column(5).Width = 20;
|
||||||
|
sheet.Column(6).Width = 22;
|
||||||
|
sheet.Column(7).Width = 17;
|
||||||
|
sheet.SheetView.FreezeRows(7);
|
||||||
|
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
|
||||||
|
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||||
|
sheet.PageSetup.FitToPages(1, 0);
|
||||||
|
sheet.PageSetup.Margins
|
||||||
|
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StyleTitle(IXLRange range) =>
|
||||||
|
range.Style
|
||||||
|
.Font.SetBold()
|
||||||
|
.Font.SetFontSize(18)
|
||||||
|
.Font.SetFontColor(XLColor.White)
|
||||||
|
.Fill.SetBackgroundColor(Navy)
|
||||||
|
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||||
|
|
||||||
|
private static void StyleSubTitle(IXLRange range) =>
|
||||||
|
range.Style
|
||||||
|
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
|
||||||
|
.Fill.SetBackgroundColor(Pale)
|
||||||
|
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||||
|
|
||||||
|
private static void StyleHeader(IXLRange range) =>
|
||||||
|
range.Style
|
||||||
|
.Font.SetBold()
|
||||||
|
.Font.SetFontColor(XLColor.White)
|
||||||
|
.Fill.SetBackgroundColor(Teal)
|
||||||
|
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
|
||||||
|
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||||
|
|
||||||
|
private static string Location(ExamSignInSessionData session) =>
|
||||||
|
string.Join(" · ", new[]
|
||||||
|
{
|
||||||
|
session.BuildingName,
|
||||||
|
session.ClassroomName
|
||||||
|
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
|
||||||
|
? value
|
||||||
|
: "待分配考场";
|
||||||
|
|
||||||
|
private static string Invigilators(ExamSignInSessionData session) =>
|
||||||
|
session.InvigilatorNames.Count > 0
|
||||||
|
? string.Join('、', session.InvigilatorNames)
|
||||||
|
: "待分配";
|
||||||
|
|
||||||
|
private static string UniqueSheetName(
|
||||||
|
ExamSignInSessionData session,
|
||||||
|
ISet<string> usedNames)
|
||||||
|
{
|
||||||
|
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
|
||||||
|
? "待分配"
|
||||||
|
: session.ClassroomName;
|
||||||
|
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
|
||||||
|
var invalidCharacters = new HashSet<char>(":\\/?*[]");
|
||||||
|
var sanitized = new string(raw
|
||||||
|
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
|
||||||
|
.ToArray()).Trim(' ', '\'');
|
||||||
|
if (sanitized.Length == 0) sanitized = "考场";
|
||||||
|
if (sanitized.Length > 31) sanitized = sanitized[..31];
|
||||||
|
|
||||||
|
var candidate = sanitized;
|
||||||
|
var suffix = 2;
|
||||||
|
while (!usedNames.Add(candidate))
|
||||||
|
{
|
||||||
|
var marker = $"-{suffix++}";
|
||||||
|
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
|
||||||
|
candidate = sanitized[..prefixLength] + marker;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExamSignInWorkbookData(
|
||||||
|
string PlanName,
|
||||||
|
string TermName,
|
||||||
|
IReadOnlyList<ExamSignInSessionData> Sessions);
|
||||||
|
|
||||||
|
public sealed record ExamSignInSessionData(
|
||||||
|
Guid SessionId,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
DateTime StartsAt,
|
||||||
|
DateTime EndsAt,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
string TaskNumber,
|
||||||
|
string? BuildingName,
|
||||||
|
string? ClassroomName,
|
||||||
|
IReadOnlyList<string> InvigilatorNames,
|
||||||
|
IReadOnlyList<ExamSignInStudentData> Students);
|
||||||
|
|
||||||
|
public sealed record ExamSignInStudentData(
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
string ClassName,
|
||||||
|
int SeatNumber = 0);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
internal static class InvigilatorCandidateQuery
|
||||||
|
{
|
||||||
|
public static IQueryable<Teacher> Create(
|
||||||
|
AppDbContext db,
|
||||||
|
IEnumerable<Guid> excludedTeacherIds) =>
|
||||||
|
db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.Status == TeacherStatus.Active)
|
||||||
|
.WhereNotIn(excludedTeacherIds, x => x.Id);
|
||||||
|
}
|
||||||
@@ -11,8 +11,14 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||||
|
bool assignClassrooms,
|
||||||
|
bool assignInvigilators,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!assignClassrooms && !assignInvigilators)
|
||||||
|
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||||
|
|
||||||
var plan = await db.MakeupExamPlans
|
var plan = await db.MakeupExamPlans
|
||||||
.Include(x => x.AcademicTerm)
|
.Include(x => x.AcademicTerm)
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
@@ -20,6 +26,10 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
.ThenInclude(x => x.TeachingTask)
|
.ThenInclude(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||||
|
|
||||||
if (plan is null)
|
if (plan is null)
|
||||||
@@ -27,9 +37,23 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。");
|
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)
|
if (sessions.Count == 0)
|
||||||
return ExamArrangementResult.Fail("补考计划中没有场次。");
|
return ExamArrangementResult.Fail("没有可处理的补考场次。");
|
||||||
|
|
||||||
var termId = plan.AcademicTermId;
|
var termId = plan.AcademicTermId;
|
||||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
@@ -44,8 +68,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
int assignedRooms = 0;
|
int assignedRooms = 0;
|
||||||
int assignedInvigilators = 0;
|
int assignedInvigilators = 0;
|
||||||
int skippedRooms = 0;
|
int unavailableRooms = 0;
|
||||||
int skippedInvigilators = 0;
|
int unavailableInvigilators = 0;
|
||||||
var messages = new List<string>();
|
var messages = new List<string>();
|
||||||
|
|
||||||
var occupiedRooms = sessions
|
var occupiedRooms = sessions
|
||||||
@@ -66,7 +90,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
|
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
|
||||||
|
|
||||||
// Auto-assign classroom
|
// Auto-assign classroom
|
||||||
if (!session.ClassroomId.HasValue)
|
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||||
{
|
{
|
||||||
var room = await FindBestClassroomAsync(
|
var room = await FindBestClassroomAsync(
|
||||||
session, enrolledCount, occupiedRooms, cancellationToken);
|
session, enrolledCount, occupiedRooms, cancellationToken);
|
||||||
@@ -76,24 +100,19 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
||||||
assignedRooms++;
|
assignedRooms++;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)");
|
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
skippedRooms++;
|
unavailableRooms++;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)");
|
$"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedRooms++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-assign invigilators
|
// Auto-assign invigilators
|
||||||
var currentInvigilatorCount = session.Invigilators.Count;
|
var currentInvigilatorCount = session.Invigilators.Count;
|
||||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||||
if (needed > 0)
|
if (assignInvigilators && needed > 0)
|
||||||
{
|
{
|
||||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||||
.Select(x => x.TeacherId).ToHashSet();
|
.Select(x => x.TeacherId).ToHashSet();
|
||||||
@@ -113,12 +132,11 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newlyAssigned.Count < needed)
|
if (newlyAssigned.Count < needed)
|
||||||
|
{
|
||||||
|
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
$"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedInvigilators++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +144,9 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
return new ExamArrangementResult(
|
return new ExamArrangementResult(
|
||||||
true,
|
true,
|
||||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||||
|
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +171,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var query = db.Classrooms.AsNoTracking()
|
var query = db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount);
|
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
|
||||||
|
|
||||||
|
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||||
if (session.RequiredBuildingId.HasValue)
|
if (session.RequiredBuildingId.HasValue)
|
||||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||||
|
if (buildingIds.Count > 0)
|
||||||
|
query = query.Where(x => buildingIds.Contains(x.BuildingId));
|
||||||
|
|
||||||
var occupiedRoomIds = occupied
|
var occupiedRoomIds = occupied
|
||||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||||
@@ -207,11 +229,27 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
||||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||||
|
|
||||||
return await db.Teachers.AsNoTracking()
|
var candidates = await InvigilatorCandidateQuery
|
||||||
.Where(x => x.Status == TeacherStatus.Active)
|
.Create(db, busyTeacherIds)
|
||||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
|
||||||
.OrderBy(x => Guid.NewGuid())
|
|
||||||
.Take(needed)
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.OrderBy(_ => Random.Shared.Next())
|
||||||
|
.Take(needed)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,10 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Channels;
|
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
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(
|
public sealed class MakeupExamAutoJobProcessor(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
MakeupExamEligibilityService eligibilityService,
|
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<Student> Students => Set<Student>();
|
||||||
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
|
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
|
||||||
public DbSet<Course> Courses => Set<Course>();
|
public DbSet<Course> Courses => Set<Course>();
|
||||||
|
public DbSet<CoursePrerequisite> CoursePrerequisites => Set<CoursePrerequisite>();
|
||||||
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
||||||
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
||||||
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
|
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
|
||||||
@@ -41,8 +42,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<AutomaticScheduleJob>();
|
Set<AutomaticScheduleJob>();
|
||||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||||
Set<SchedulePublishJob>();
|
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 =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
|
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||||
|
Set<CourseSelectionRoundGrade>();
|
||||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||||
Set<CourseSelectionOffering>();
|
Set<CourseSelectionOffering>();
|
||||||
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
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<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||||
|
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||||
|
Set<AttendanceCheckInAttempt>();
|
||||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||||
|
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||||
|
Set<ExamArrangementJob>();
|
||||||
|
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
|
||||||
|
Set<ExamSignInExportJob>();
|
||||||
|
public DbSet<ExamPublishJob> ExamPublishJobs =>
|
||||||
|
Set<ExamPublishJob>();
|
||||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||||
Set<ExamSessionInvigilator>();
|
Set<ExamSessionInvigilator>();
|
||||||
|
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
|
||||||
|
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
|
||||||
|
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
|
||||||
|
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
|
||||||
|
Set<ExamRoomInvigilator>();
|
||||||
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
|
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
|
||||||
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
|
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
|
||||||
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
|
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
|
||||||
@@ -75,6 +104,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
|
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
|
||||||
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
|
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
|
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
|
||||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||||
Set<StudentStatusChange>();
|
Set<StudentStatusChange>();
|
||||||
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
||||||
@@ -89,7 +119,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<GraduationClearanceItem>();
|
Set<GraduationClearanceItem>();
|
||||||
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
|
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
|
||||||
Set<GraduationClearanceRecord>();
|
Set<GraduationClearanceRecord>();
|
||||||
|
public DbSet<OfficialDocument> OfficialDocuments => Set<OfficialDocument>();
|
||||||
|
public DbSet<OfficialDocumentDownload> OfficialDocumentDownloads =>
|
||||||
|
Set<OfficialDocumentDownload>();
|
||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
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(
|
protected override void ConfigureConventions(
|
||||||
ModelConfigurationBuilder configurationBuilder)
|
ModelConfigurationBuilder configurationBuilder)
|
||||||
@@ -101,6 +139,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
configurationBuilder.Properties<DateOnly>()
|
configurationBuilder.Properties<DateOnly>()
|
||||||
.HaveConversion<DateOnlyDateTimeConverter>()
|
.HaveConversion<DateOnlyDateTimeConverter>()
|
||||||
.HaveColumnType("date");
|
.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)
|
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.DisplayName).HasMaxLength(50);
|
||||||
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
|
||||||
entity.HasIndex(x => x.StaffNumber);
|
entity.HasIndex(x => x.StaffNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,6 +164,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Description).HasMaxLength(100);
|
entity.Property(x => x.Description).HasMaxLength(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<RefreshSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.TokenHash).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.SecurityStamp).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.ClientType)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.HasIndex(x => x.TokenHash).IsUnique();
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.ExpiresAt });
|
||||||
|
entity.HasOne(x => x.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
ConfigureCatalog<Campus>(builder);
|
ConfigureCatalog<Campus>(builder);
|
||||||
ConfigureCatalog<College>(builder);
|
ConfigureCatalog<College>(builder);
|
||||||
ConfigureCatalog<Major>(builder);
|
ConfigureCatalog<Major>(builder);
|
||||||
@@ -197,8 +257,25 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
{
|
{
|
||||||
entity.Property(x => x.StudentNumber).HasMaxLength(30);
|
entity.Property(x => x.StudentNumber).HasMaxLength(30);
|
||||||
entity.Property(x => x.Name).HasMaxLength(50);
|
entity.Property(x => x.Name).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.EnglishName).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.IdCardNumber).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.Nationality).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.Ethnicity).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.PoliticalStatus).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.NativePlace).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.HouseholdAddress).HasMaxLength(300);
|
||||||
|
entity.Property(x => x.CurrentAddress).HasMaxLength(300);
|
||||||
|
entity.Property(x => x.PostalCode).HasMaxLength(20);
|
||||||
entity.Property(x => x.Phone).HasMaxLength(30);
|
entity.Property(x => x.Phone).HasMaxLength(30);
|
||||||
entity.Property(x => x.Email).HasMaxLength(100);
|
entity.Property(x => x.Email).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.Qq).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.WeChat).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.EmergencyContactName).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.EmergencyContactRelationship).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.EmergencyContactPhone).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.SpecialTags).HasMaxLength(300);
|
||||||
|
entity.Property(x => x.SpecialNeeds).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.Biography).HasMaxLength(1000);
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => x.StudentNumber).IsUnique();
|
entity.HasIndex(x => x.StudentNumber).IsUnique();
|
||||||
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
|
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
|
||||||
@@ -230,6 +307,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CoursePrerequisite>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.CourseId, x.PrerequisiteCourseId })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasOne(x => x.Course)
|
||||||
|
.WithMany(x => x.Prerequisites)
|
||||||
|
.HasForeignKey(x => x.CourseId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.PrerequisiteCourse)
|
||||||
|
.WithMany(x => x.RequiredByCourses)
|
||||||
|
.HasForeignKey(x => x.PrerequisiteCourseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CurriculumPlan>(entity =>
|
builder.Entity<CurriculumPlan>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -358,6 +449,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
builder.Entity<ScheduleEntry>(entity =>
|
builder.Entity<ScheduleEntry>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.Property(x => x.Kind)
|
||||||
|
.HasDefaultValue(ScheduleEntryKind.Lecture);
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new
|
entity.HasIndex(x => new
|
||||||
{
|
{
|
||||||
@@ -459,6 +552,176 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.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 =>
|
builder.Entity<CourseSelectionRound>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -471,6 +734,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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 =>
|
builder.Entity<CourseSelectionOffering>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
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 })
|
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
entity.HasIndex(x => new { x.StudentId, x.Status });
|
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)
|
entity.HasOne(x => x.CourseSelectionOffering)
|
||||||
.WithMany(x => x.Enrollments)
|
.WithMany(x => x.Enrollments)
|
||||||
.HasForeignKey(x => x.CourseSelectionOfferingId)
|
.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.Name).HasMaxLength(60);
|
||||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.SourceType)
|
||||||
|
.HasDefaultValue(GradeItemSourceType.Manual);
|
||||||
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
||||||
entity.HasOne(x => x.GradeSheet)
|
entity.HasOne(x => x.GradeSheet)
|
||||||
.WithMany(x => x.Items)
|
.WithMany(x => x.Items)
|
||||||
@@ -595,6 +877,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<AttendanceCheckInAttempt>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
|
||||||
|
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||||
|
entity.Property(x => x.RiskFlags).HasMaxLength(300);
|
||||||
|
entity.Property(x => x.Latitude).HasPrecision(10, 7);
|
||||||
|
entity.Property(x => x.Longitude).HasPrecision(10, 7);
|
||||||
|
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
|
||||||
|
entity.HasOne(x => x.AttendanceSheet)
|
||||||
|
.WithMany(x => x.CheckInAttempts)
|
||||||
|
.HasForeignKey(x => x.AttendanceSheetId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Student)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<ExamPlan>(entity =>
|
builder.Entity<ExamPlan>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -610,6 +915,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
|
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
|
||||||
entity.HasIndex(x => x.RequiredBuildingId);
|
entity.HasIndex(x => x.RequiredBuildingId);
|
||||||
|
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||||
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
|
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
|
||||||
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
@@ -627,6 +933,87 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasOne(x => x.Teacher).WithMany()
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
builder.Entity<ExamRoomAssignment>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamRooms");
|
||||||
|
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
||||||
|
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExamPlanId,
|
||||||
|
x.ClassroomId,
|
||||||
|
x.StartsAt
|
||||||
|
})
|
||||||
|
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
|
||||||
|
entity.HasIndex(x => x.CourseId)
|
||||||
|
.HasDatabaseName("IX_ExamRooms_CourseId");
|
||||||
|
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
|
||||||
|
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Course).WithMany()
|
||||||
|
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Classroom).WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamRoomSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamRoomSessions");
|
||||||
|
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
|
||||||
|
entity.HasIndex(x => x.ExamSessionId)
|
||||||
|
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
|
||||||
|
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
|
||||||
|
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
|
||||||
|
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamSeatAssignment>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamSeats");
|
||||||
|
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
|
||||||
|
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_ExamSeats_Session_Student");
|
||||||
|
entity.HasIndex(x => x.StudentId)
|
||||||
|
.HasDatabaseName("IX_ExamSeats_StudentId");
|
||||||
|
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
|
||||||
|
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
|
||||||
|
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamRoomInvigilator>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamRoomInvigilators");
|
||||||
|
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
|
||||||
|
entity.HasIndex(x => x.TeacherId)
|
||||||
|
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
|
||||||
|
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
|
||||||
|
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamArrangementJob>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
|
||||||
|
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||||
|
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
|
||||||
|
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
|
||||||
|
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
|
||||||
|
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => x.RequestedByUserId);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamSignInExportJob>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.FileName).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||||
|
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => x.RequestedByUserId);
|
||||||
|
});
|
||||||
builder.Entity<MakeupExamPlan>(entity =>
|
builder.Entity<MakeupExamPlan>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -642,6 +1029,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
||||||
entity.HasIndex(x => x.RequiredBuildingId);
|
entity.HasIndex(x => x.RequiredBuildingId);
|
||||||
|
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||||
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
||||||
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
@@ -876,6 +1264,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
|
entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
|
||||||
entity.HasIndex(x => x.ApplicantUserId);
|
entity.HasIndex(x => x.ApplicantUserId);
|
||||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.SourceScheduleEntryId, x.SourceWeek });
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(x => x.Classroom).WithMany()
|
entity.HasOne(x => x.Classroom).WithMany()
|
||||||
@@ -887,10 +1276,26 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
builder.Entity<Notification>(entity =>
|
builder.Entity<Notification>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Title).HasMaxLength(200);
|
entity.Property(x => x.Title).HasMaxLength(200);
|
||||||
entity.Property(x => x.Content).HasMaxLength(1000);
|
entity.Property(x => x.Content).HasColumnType("longtext");
|
||||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
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.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 =>
|
builder.Entity<AuditLog>(entity =>
|
||||||
@@ -901,6 +1306,99 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||||
entity.HasIndex(x => x.CreatedAt);
|
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)
|
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -931,3 +1429,8 @@ public sealed class DateOnlyDateTimeConverter()
|
|||||||
: ValueConverter<DateOnly, DateTime>(
|
: ValueConverter<DateOnly, DateTime>(
|
||||||
date => date.ToDateTime(TimeOnly.MinValue),
|
date => date.ToDateTime(TimeOnly.MinValue),
|
||||||
value => DateOnly.FromDateTime(value));
|
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