Compare commits
76
Commits
v1.1.0
...
codex/shiyan
@@ -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);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -80,6 +81,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 +145,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 +207,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 +222,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,15 +237,49 @@ 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;
|
||||||
// Auto-apply: update grade record
|
// Auto-apply: update grade record
|
||||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||||
|
var statisticsJob = new CourseGradeStatisticsRefreshJob
|
||||||
|
{
|
||||||
|
GradeSheetId = gm.GradeRecord.GradeSheetId
|
||||||
|
};
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
|
||||||
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 +293,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 +439,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 +495,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 +541,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 +575,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,60 @@ 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.TeachingVenueNature,
|
||||||
|
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)]
|
||||||
@@ -482,6 +558,9 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
BuildingId = request.BuildingId,
|
BuildingId = request.BuildingId,
|
||||||
Capacity = request.Capacity,
|
Capacity = request.Capacity,
|
||||||
RoomType = request.RoomType.Trim(),
|
RoomType = request.RoomType.Trim(),
|
||||||
|
TeachingVenueNature = request.TeachingVenueNature == 0
|
||||||
|
? TeachingVenueNature.GeneralClassroom
|
||||||
|
: request.TeachingVenueNature,
|
||||||
Equipment = request.Equipment?.Trim(),
|
Equipment = request.Equipment?.Trim(),
|
||||||
SortOrder = request.SortOrder,
|
SortOrder = request.SortOrder,
|
||||||
IsEnabled = request.IsEnabled
|
IsEnabled = request.IsEnabled
|
||||||
@@ -502,8 +581,11 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
entity.BuildingId = request.BuildingId;
|
entity.BuildingId = request.BuildingId;
|
||||||
entity.Capacity = request.Capacity;
|
entity.Capacity = request.Capacity;
|
||||||
entity.RoomType = request.RoomType.Trim();
|
entity.RoomType = request.RoomType.Trim();
|
||||||
|
entity.TeachingVenueNature = request.TeachingVenueNature == 0
|
||||||
|
? TeachingVenueNature.GeneralClassroom
|
||||||
|
: request.TeachingVenueNature;
|
||||||
entity.Equipment = request.Equipment?.Trim();
|
entity.Equipment = request.Equipment?.Trim();
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +615,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 +638,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 +648,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)
|
||||||
@@ -647,5 +735,64 @@ public sealed record ClassroomRequest(
|
|||||||
Guid BuildingId,
|
Guid BuildingId,
|
||||||
[Range(1, 1000)] int Capacity,
|
[Range(1, 1000)] int Capacity,
|
||||||
[Required, MaxLength(40)] string RoomType,
|
[Required, MaxLength(40)] string RoomType,
|
||||||
|
TeachingVenueNature TeachingVenueNature,
|
||||||
[MaxLength(300)] string? Equipment)
|
[MaxLength(300)] string? Equipment)
|
||||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||||
|
|
||||||
|
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,
|
||||||
|
TeachingVenueNature TeachingVenueNature,
|
||||||
|
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}";
|
||||||
@@ -26,7 +27,7 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
||||||
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
||||||
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
||||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
|
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "教学场地性质", "设备", "排序", "状态"],
|
||||||
["course-categories"] = ["编码", "名称", "排序", "状态"]
|
["course-categories"] = ["编码", "名称", "排序", "状态"]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,7 +70,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
IReadOnlyList<ExcelRow> rows;
|
IReadOnlyList<ExcelRow> rows;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken);
|
var requiredHeaders = kind.Equals("classrooms", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? headers.Where(x => x != "教学场地性质").ToArray()
|
||||||
|
: headers;
|
||||||
|
rows = await ExcelWorkbookHelper.ReadAsync(file, requiredHeaders, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (InvalidDataException exception)
|
catch (InvalidDataException exception)
|
||||||
{
|
{
|
||||||
@@ -114,6 +118,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)
|
||||||
@@ -161,6 +168,7 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
"classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building)
|
"classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building)
|
||||||
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
||||||
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
||||||
|
VenueNatureName(x.TeachingVenueNature),
|
||||||
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
||||||
"course-categories" => (await db.CourseCategories.AsNoTracking()
|
"course-categories" => (await db.CourseCategories.AsNoTracking()
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
@@ -469,8 +477,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
var buildingCode = Required(row, "所属教学楼编码", errors);
|
var buildingCode = Required(row, "所属教学楼编码", errors);
|
||||||
var capacity = ParseInt(row, "容量", 1, 1000, errors);
|
var capacity = ParseInt(row, "容量", 1, 1000, errors);
|
||||||
var roomType = Required(row, "教室类型", errors);
|
var roomType = Required(row, "教室类型", errors);
|
||||||
|
var venueNature = ParseVenueNature(row, roomType, errors);
|
||||||
if (code is null || name is null || buildingCode is null ||
|
if (code is null || name is null || buildingCode is null ||
|
||||||
capacity is null || roomType is null) continue;
|
capacity is null || roomType is null || venueNature is null) continue;
|
||||||
if (!buildings.TryGetValue(buildingCode, out var building))
|
if (!buildings.TryGetValue(buildingCode, out var building))
|
||||||
{
|
{
|
||||||
errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。");
|
errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。");
|
||||||
@@ -484,7 +493,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
Code = code,
|
Code = code,
|
||||||
Name = name,
|
Name = name,
|
||||||
BuildingId = building.Id,
|
BuildingId = building.Id,
|
||||||
RoomType = roomType
|
RoomType = roomType,
|
||||||
|
TeachingVenueNature = venueNature.Value
|
||||||
};
|
};
|
||||||
db.Classrooms.Add(entity);
|
db.Classrooms.Add(entity);
|
||||||
existing[code] = entity;
|
existing[code] = entity;
|
||||||
@@ -495,6 +505,7 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
entity.BuildingId = building.Id;
|
entity.BuildingId = building.Id;
|
||||||
entity.Capacity = capacity.Value;
|
entity.Capacity = capacity.Value;
|
||||||
entity.RoomType = roomType;
|
entity.RoomType = roomType;
|
||||||
|
entity.TeachingVenueNature = venueNature.Value;
|
||||||
entity.Equipment = Optional(row, "设备");
|
entity.Equipment = Optional(row, "设备");
|
||||||
}
|
}
|
||||||
return new(created, updated, rows.Count);
|
return new(created, updated, rows.Count);
|
||||||
@@ -593,6 +604,57 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TeachingVenueNature? ParseVenueNature(
|
||||||
|
ExcelRow row,
|
||||||
|
string? roomType,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
var value = Optional(row, "教学场地性质");
|
||||||
|
if (value is null) return InferVenueNature(roomType ?? string.Empty);
|
||||||
|
var result = (TeachingVenueNature)0;
|
||||||
|
foreach (var part in value.Split(['、', ',', ',', ';', ';'],
|
||||||
|
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
result |= part switch
|
||||||
|
{
|
||||||
|
"普通教室" => TeachingVenueNature.GeneralClassroom,
|
||||||
|
"实验室" => TeachingVenueNature.Laboratory,
|
||||||
|
"实训室" => TeachingVenueNature.TrainingRoom,
|
||||||
|
"计算机机房" or "机房" => TeachingVenueNature.ComputerLab,
|
||||||
|
"语音室" => TeachingVenueNature.LanguageLab,
|
||||||
|
"体育场地" => TeachingVenueNature.SportsVenue,
|
||||||
|
"艺术场地" => TeachingVenueNature.ArtsVenue,
|
||||||
|
_ => (TeachingVenueNature)0
|
||||||
|
};
|
||||||
|
if (part is not ("普通教室" or "实验室" or "实训室" or "计算机机房" or "机房" or "语音室" or "体育场地" or "艺术场地"))
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“教学场地性质”包含不支持的值“{part}”。");
|
||||||
|
}
|
||||||
|
return result == 0 ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingVenueNature InferVenueNature(string roomType) =>
|
||||||
|
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? TeachingVenueNature.Laboratory | TeachingVenueNature.ComputerLab
|
||||||
|
: roomType.Contains("语音", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? TeachingVenueNature.Laboratory | TeachingVenueNature.LanguageLab
|
||||||
|
: roomType.Contains("实训", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? TeachingVenueNature.TrainingRoom
|
||||||
|
: roomType.Contains("实验", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? TeachingVenueNature.Laboratory
|
||||||
|
: TeachingVenueNature.GeneralClassroom;
|
||||||
|
|
||||||
|
private static string VenueNatureName(TeachingVenueNature value) => string.Join("、",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
(TeachingVenueNature.GeneralClassroom, "普通教室"),
|
||||||
|
(TeachingVenueNature.Laboratory, "实验室"),
|
||||||
|
(TeachingVenueNature.TrainingRoom, "实训室"),
|
||||||
|
(TeachingVenueNature.ComputerLab, "计算机机房"),
|
||||||
|
(TeachingVenueNature.LanguageLab, "语音室"),
|
||||||
|
(TeachingVenueNature.SportsVenue, "体育场地"),
|
||||||
|
(TeachingVenueNature.ArtsVenue, "艺术场地")
|
||||||
|
}.Where(x => (value & x.Item1) != 0).Select(x => x.Item2));
|
||||||
|
|
||||||
private static bool ParseBoolean(
|
private static bool ParseBoolean(
|
||||||
ExcelRow row, string header, bool defaultValue, List<string> errors)
|
ExcelRow row, string header, bool defaultValue, List<string> errors)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,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
@@ -0,0 +1,528 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = AnalyticsUsers)]
|
||||||
|
[Route("api/grade-analytics")]
|
||||||
|
public sealed class GradeAnalyticsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache? cache = null) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string AnalyticsUsers =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin + "," +
|
||||||
|
SystemRoles.Leader + "," +
|
||||||
|
SystemRoles.Teacher;
|
||||||
|
|
||||||
|
[HttpGet("teaching-classes")]
|
||||||
|
public async Task<ActionResult> GetTeachingClasses(
|
||||||
|
Guid? academicTermId,
|
||||||
|
string? keyword,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||||
|
|
||||||
|
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||||
|
if (academicTermId.HasValue)
|
||||||
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (keyword is not null)
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Name.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Course.Name.Contains(keyword));
|
||||||
|
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||||
|
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.GradeSheetId,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = x.TeachingTask.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
|
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||||
|
x.AcademicTermId,
|
||||||
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.Select(item => item.Teacher!.Name),
|
||||||
|
ClassNames = x.TeachingTask.Classes
|
||||||
|
.Select(item => item.AdministrativeClass!.Name),
|
||||||
|
x.StudentCount,
|
||||||
|
x.AverageScore,
|
||||||
|
x.PassRate,
|
||||||
|
x.ExcellentRate,
|
||||||
|
x.CalculatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
|
||||||
|
public async Task<ActionResult> GetTeachingClassAnalysis(
|
||||||
|
Guid gradeSheetId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.Id == gradeSheetId &&
|
||||||
|
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
|
||||||
|
.Select(x => new AnalysisTarget(
|
||||||
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeachingTask!.CourseId,
|
||||||
|
x.TeachingTask.AcademicTermId,
|
||||||
|
x.TeachingTask.Course!.CollegeId,
|
||||||
|
x.TeachingTask.TaskNumber,
|
||||||
|
x.TeachingTask.Name,
|
||||||
|
x.TeachingTask.Course.Code,
|
||||||
|
x.TeachingTask.Course.Name,
|
||||||
|
x.TeachingTask.AcademicTerm!.Name))
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
|
||||||
|
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
|
||||||
|
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
|
||||||
|
token => BuildReportAsync(sheet, token),
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.CourseGradeStatistics],
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")]
|
||||||
|
public async Task<ActionResult> ExportTeachingClassAnalysisReport(
|
||||||
|
Guid gradeSheetId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.Id == gradeSheetId &&
|
||||||
|
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
|
||||||
|
.Select(x => new AnalysisTarget(
|
||||||
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeachingTask!.CourseId,
|
||||||
|
x.TeachingTask.AcademicTermId,
|
||||||
|
x.TeachingTask.Course!.CollegeId,
|
||||||
|
x.TeachingTask.TaskNumber,
|
||||||
|
x.TeachingTask.Name,
|
||||||
|
x.TeachingTask.Course.Code,
|
||||||
|
x.TeachingTask.Course.Name,
|
||||||
|
x.TeachingTask.AcademicTerm!.Name))
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
|
||||||
|
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
|
||||||
|
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
|
||||||
|
token => BuildReportAsync(sheet, token),
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.CourseGradeStatistics],
|
||||||
|
cancellationToken);
|
||||||
|
if (report.IsRefreshing || report.Summary is null)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "成绩统计尚未生成",
|
||||||
|
Detail = "请先重新计算当前教学班,待统计完成后再导出。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
|
||||||
|
var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx";
|
||||||
|
return File(
|
||||||
|
content,
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
|
||||||
|
public async Task<ActionResult> RefreshTeachingClassAnalysis(
|
||||||
|
Guid gradeSheetId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var exists = await db.GradeSheets.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.Id == gradeSheetId &&
|
||||||
|
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
|
||||||
|
cancellationToken);
|
||||||
|
if (!exists) return NotFound();
|
||||||
|
|
||||||
|
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||||
|
job.Id));
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Accepted(new { job.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
|
||||||
|
AnalysisTarget target,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||||
|
.Select(x => new TeachingClassMetrics(
|
||||||
|
x.StudentCount,
|
||||||
|
x.PassedCount,
|
||||||
|
x.ExcellentCount,
|
||||||
|
x.HighestScore,
|
||||||
|
x.AverageScore,
|
||||||
|
x.MedianScore,
|
||||||
|
x.LowestScore,
|
||||||
|
x.StandardDeviation,
|
||||||
|
x.PassRate,
|
||||||
|
x.ExcellentRate,
|
||||||
|
x.CalculatedAt,
|
||||||
|
x.ScoreBands.OrderBy(band => band.SortOrder)
|
||||||
|
.Select(band => new ScoreBand(
|
||||||
|
band.Label,
|
||||||
|
band.LowerBound,
|
||||||
|
band.UpperBound,
|
||||||
|
band.StudentCount))
|
||||||
|
.ToArray()))
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (statistic is null)
|
||||||
|
return new TeachingClassAnalysisReport(
|
||||||
|
true,
|
||||||
|
target.GradeSheetId,
|
||||||
|
target.TeachingTaskId,
|
||||||
|
target.TaskNumber,
|
||||||
|
target.TaskName,
|
||||||
|
target.CourseCode,
|
||||||
|
target.CourseName,
|
||||||
|
target.TermName,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
null);
|
||||||
|
|
||||||
|
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId == target.CourseId &&
|
||||||
|
x.AcademicTermId == target.AcademicTermId)
|
||||||
|
.OrderByDescending(x => x.AverageScore)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.GradeSheetId,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = x.TeachingTask.Name,
|
||||||
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.Select(item => item.Teacher!.Name).ToArray(),
|
||||||
|
ClassNames = x.TeachingTask.Classes
|
||||||
|
.Select(item => item.AdministrativeClass!.Name).ToArray(),
|
||||||
|
x.StudentCount,
|
||||||
|
x.HighestScore,
|
||||||
|
x.AverageScore,
|
||||||
|
x.MedianScore,
|
||||||
|
x.LowestScore,
|
||||||
|
x.StandardDeviation,
|
||||||
|
x.PassRate,
|
||||||
|
x.ExcellentRate
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var peers = peerRows.Select(x => new TeachingClassComparison(
|
||||||
|
x.GradeSheetId,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.TaskName,
|
||||||
|
string.Join("、", x.TeacherNames),
|
||||||
|
string.Join("、", x.ClassNames),
|
||||||
|
x.StudentCount,
|
||||||
|
x.HighestScore,
|
||||||
|
x.AverageScore,
|
||||||
|
x.MedianScore,
|
||||||
|
x.LowestScore,
|
||||||
|
x.StandardDeviation,
|
||||||
|
x.PassRate,
|
||||||
|
x.ExcellentRate,
|
||||||
|
x.GradeSheetId == target.GradeSheetId)).ToArray();
|
||||||
|
|
||||||
|
var classProfiles = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||||
|
.Select(x => new ClassProfile(
|
||||||
|
x.Student!.AdministrativeClassId,
|
||||||
|
x.Student.AdministrativeClass!.Name,
|
||||||
|
x.Student.AdministrativeClass.MajorId,
|
||||||
|
x.Student.AdministrativeClass.Major!.Name,
|
||||||
|
x.Student.AdministrativeClass.Major.CollegeId,
|
||||||
|
x.Student.AdministrativeClass.Major.College!.Name))
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId == target.CourseId &&
|
||||||
|
x.AcademicTermId == target.AcademicTermId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
|
||||||
|
|
||||||
|
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
|
||||||
|
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId == target.CourseId &&
|
||||||
|
x.TeachingTask!.Teachers.Any(link =>
|
||||||
|
selectedTeacherIds.Contains(link.TeacherId)))
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.AcademicTermId,
|
||||||
|
TermName = x.TeachingTask!.AcademicTerm!.Name,
|
||||||
|
x.TeachingTask.AcademicTerm.StartDate,
|
||||||
|
x.StudentCount,
|
||||||
|
x.PassedCount,
|
||||||
|
x.ExcellentCount,
|
||||||
|
x.AverageScore
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId == target.CourseId &&
|
||||||
|
x.Scope == CourseGradeStatisticScope.University)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.AcademicTermId,
|
||||||
|
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||||
|
.Select(term => term.Name).First(),
|
||||||
|
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||||
|
.Select(term => term.StartDate).First(),
|
||||||
|
x.StudentCount,
|
||||||
|
x.AverageScore,
|
||||||
|
x.PassRate,
|
||||||
|
ExcellentRate = x.StudentCount == 0 ? 0m :
|
||||||
|
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var teacherByTerm = historicalTaskRows
|
||||||
|
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
|
||||||
|
.ToDictionary(group => group.Key.AcademicTermId, group =>
|
||||||
|
{
|
||||||
|
var count = group.Sum(x => x.StudentCount);
|
||||||
|
return new HistoricalSeriesValue(
|
||||||
|
count,
|
||||||
|
count == 0 ? 0m : Math.Round(
|
||||||
|
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
|
||||||
|
count == 0 ? 0m : Math.Round(
|
||||||
|
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
|
||||||
|
count == 0 ? 0m : Math.Round(
|
||||||
|
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
|
||||||
|
});
|
||||||
|
var history = courseHistory
|
||||||
|
.OrderBy(x => x.StartDate)
|
||||||
|
.Select(x => new HistoricalComparison(
|
||||||
|
x.AcademicTermId,
|
||||||
|
x.TermName,
|
||||||
|
x.StudentCount,
|
||||||
|
x.AverageScore,
|
||||||
|
x.PassRate,
|
||||||
|
x.ExcellentRate,
|
||||||
|
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var university = scopeStatistics.FirstOrDefault(x =>
|
||||||
|
x.Scope == CourseGradeStatisticScope.University);
|
||||||
|
return new TeachingClassAnalysisReport(
|
||||||
|
false,
|
||||||
|
target.GradeSheetId,
|
||||||
|
target.TeachingTaskId,
|
||||||
|
target.TaskNumber,
|
||||||
|
target.TaskName,
|
||||||
|
target.CourseCode,
|
||||||
|
target.CourseName,
|
||||||
|
target.TermName,
|
||||||
|
statistic,
|
||||||
|
peers,
|
||||||
|
benchmarks,
|
||||||
|
history,
|
||||||
|
university is null ? null : new ComparisonDelta(
|
||||||
|
Math.Round(statistic.AverageScore - university.AverageScore, 1),
|
||||||
|
Math.Round(statistic.PassRate - university.PassRate, 2),
|
||||||
|
university.AverageScore,
|
||||||
|
university.PassRate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScopeBenchmark[] BuildBenchmarks(
|
||||||
|
IEnumerable<ClassProfile> classProfiles,
|
||||||
|
IReadOnlyCollection<CourseGradeStatistic> statistics)
|
||||||
|
{
|
||||||
|
var profiles = classProfiles.ToArray();
|
||||||
|
var rows = new List<ScopeBenchmark>();
|
||||||
|
foreach (var profile in profiles)
|
||||||
|
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
|
||||||
|
profile.ClassId, "行政班", profile.ClassName);
|
||||||
|
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
|
||||||
|
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
|
||||||
|
profile.MajorId, "专业", profile.MajorName);
|
||||||
|
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
|
||||||
|
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
|
||||||
|
profile.CollegeId, "学院", profile.CollegeName);
|
||||||
|
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
|
||||||
|
null, "全校", "全校同课程");
|
||||||
|
return rows.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddBenchmark(
|
||||||
|
ICollection<ScopeBenchmark> target,
|
||||||
|
IEnumerable<CourseGradeStatistic> source,
|
||||||
|
CourseGradeStatisticScope scope,
|
||||||
|
Guid? entityId,
|
||||||
|
string scopeLabel,
|
||||||
|
string name)
|
||||||
|
{
|
||||||
|
var item = source.FirstOrDefault(x =>
|
||||||
|
x.Scope == scope && x.ScopeEntityId == entityId);
|
||||||
|
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
|
||||||
|
target.Add(new ScopeBenchmark(
|
||||||
|
scopeLabel,
|
||||||
|
name,
|
||||||
|
item.StudentCount,
|
||||||
|
item.HighestScore,
|
||||||
|
item.AverageScore,
|
||||||
|
item.LowestScore,
|
||||||
|
item.PassRate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<TeachingTask> VisibleTeachingTasks()
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var source = db.TeachingTasks.AsQueryable();
|
||||||
|
if (scope.Scope == DataScope.All) return source;
|
||||||
|
if (scope.Scope == DataScope.College)
|
||||||
|
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||||
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
return source.Where(x =>
|
||||||
|
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
|
||||||
|
return source.Where(_ => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string value)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AnalysisTarget(
|
||||||
|
Guid GradeSheetId,
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
Guid CourseId,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
Guid CourseCollegeId,
|
||||||
|
string TaskNumber,
|
||||||
|
string TaskName,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
string TermName);
|
||||||
|
|
||||||
|
private sealed record ClassProfile(
|
||||||
|
Guid ClassId,
|
||||||
|
string ClassName,
|
||||||
|
Guid MajorId,
|
||||||
|
string MajorName,
|
||||||
|
Guid CollegeId,
|
||||||
|
string CollegeName);
|
||||||
|
|
||||||
|
public sealed record ScoreBand(
|
||||||
|
string Label,
|
||||||
|
decimal LowerBound,
|
||||||
|
decimal? UpperBound,
|
||||||
|
int StudentCount);
|
||||||
|
|
||||||
|
public sealed record TeachingClassMetrics(
|
||||||
|
int StudentCount,
|
||||||
|
int PassedCount,
|
||||||
|
int ExcellentCount,
|
||||||
|
decimal HighestScore,
|
||||||
|
decimal AverageScore,
|
||||||
|
decimal MedianScore,
|
||||||
|
decimal LowestScore,
|
||||||
|
decimal StandardDeviation,
|
||||||
|
decimal PassRate,
|
||||||
|
decimal ExcellentRate,
|
||||||
|
DateTime CalculatedAt,
|
||||||
|
IReadOnlyList<ScoreBand> ScoreBands);
|
||||||
|
|
||||||
|
public sealed record TeachingClassComparison(
|
||||||
|
Guid GradeSheetId,
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
string TaskNumber,
|
||||||
|
string TaskName,
|
||||||
|
string TeacherNames,
|
||||||
|
string ClassNames,
|
||||||
|
int StudentCount,
|
||||||
|
decimal HighestScore,
|
||||||
|
decimal AverageScore,
|
||||||
|
decimal MedianScore,
|
||||||
|
decimal LowestScore,
|
||||||
|
decimal StandardDeviation,
|
||||||
|
decimal PassRate,
|
||||||
|
decimal ExcellentRate,
|
||||||
|
bool IsSelected);
|
||||||
|
|
||||||
|
public sealed record ScopeBenchmark(
|
||||||
|
string Scope,
|
||||||
|
string Name,
|
||||||
|
int StudentCount,
|
||||||
|
decimal HighestScore,
|
||||||
|
decimal AverageScore,
|
||||||
|
decimal LowestScore,
|
||||||
|
decimal PassRate);
|
||||||
|
|
||||||
|
public sealed record HistoricalSeriesValue(
|
||||||
|
int StudentCount,
|
||||||
|
decimal AverageScore,
|
||||||
|
decimal PassRate,
|
||||||
|
decimal ExcellentRate);
|
||||||
|
|
||||||
|
public sealed record HistoricalComparison(
|
||||||
|
Guid AcademicTermId,
|
||||||
|
string TermName,
|
||||||
|
int CourseStudentCount,
|
||||||
|
decimal CourseAverageScore,
|
||||||
|
decimal CoursePassRate,
|
||||||
|
decimal CourseExcellentRate,
|
||||||
|
HistoricalSeriesValue? Instructor);
|
||||||
|
|
||||||
|
public sealed record ComparisonDelta(
|
||||||
|
decimal AverageScoreDifference,
|
||||||
|
decimal PassRateDifference,
|
||||||
|
decimal UniversityAverageScore,
|
||||||
|
decimal UniversityPassRate);
|
||||||
|
|
||||||
|
public sealed record TeachingClassAnalysisReport(
|
||||||
|
bool IsRefreshing,
|
||||||
|
Guid GradeSheetId,
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
string TaskNumber,
|
||||||
|
string TaskName,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
string TermName,
|
||||||
|
TeachingClassMetrics? Summary,
|
||||||
|
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
|
||||||
|
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
|
||||||
|
IReadOnlyList<HistoricalComparison> History,
|
||||||
|
ComparisonDelta? UniversityDelta);
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
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.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
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;
|
||||||
@@ -17,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/grades")]
|
[Route("api/grades")]
|
||||||
public sealed class GradesController(
|
public sealed class GradesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache? cache = null) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string SheetUsers =
|
private const string SheetUsers =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -35,23 +40,46 @@ public sealed class GradesController(
|
|||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
SystemRoles.AcademicAdmin;
|
SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
private const string StatisticsUsers =
|
||||||
|
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
|
||||||
|
|
||||||
[HttpGet("sheets")]
|
[HttpGet("sheets")]
|
||||||
[Authorize(Roles = SheetUsers)]
|
[Authorize(Roles = SheetUsers)]
|
||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
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 +107,9 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
item.Id,
|
item.Id,
|
||||||
item.Name,
|
item.Name,
|
||||||
item.Weight
|
item.Weight,
|
||||||
|
item.SourceType,
|
||||||
|
item.SourceSnapshotAt
|
||||||
}),
|
}),
|
||||||
StudentCount = sheet.Records.Count,
|
StudentCount = sheet.Records.Count,
|
||||||
CompletedCount = sheet.Records.Count(record =>
|
CompletedCount = sheet.Records.Count(record =>
|
||||||
@@ -95,10 +125,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 +161,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 +190,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 +228,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 +293,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 +418,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 +438,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 +459,67 @@ 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;
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
|
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 +561,8 @@ public sealed class GradesController(
|
|||||||
$"《{courseName}》成绩已提交,请及时审核。",
|
$"《{courseName}》成绩已提交,请及时审核。",
|
||||||
collegeId,
|
collegeId,
|
||||||
"/grades",
|
"/grades",
|
||||||
cancellationToken);
|
cancellationToken,
|
||||||
|
NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +603,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 +648,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 +656,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 +668,12 @@ 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);
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
|
|
||||||
// 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 +681,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +707,7 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||||
|
|
||||||
var rows = sheet.Records.Select(record =>
|
var rows = sheet.Records.Select(record =>
|
||||||
{
|
{
|
||||||
@@ -571,6 +724,7 @@ public sealed class GradesController(
|
|||||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||||
values.Add(score);
|
values.Add(score);
|
||||||
}
|
}
|
||||||
|
values.Add(null);
|
||||||
values.Add(record.FinalScore);
|
values.Add(record.FinalScore);
|
||||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||||
@@ -584,13 +738,56 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
||||||
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
||||||
|
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
|
||||||
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
||||||
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
||||||
"导入后会自动重新计算总评成绩和绩点。"
|
"导入后会自动重新计算总评成绩和绩点。"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var regularColumn = 4;
|
||||||
|
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||||
|
var totalColumn = regularColumn + itemColumns.Length + 1;
|
||||||
|
var finalColumn = totalColumn + 1;
|
||||||
|
var statusColumn = finalColumn + 1;
|
||||||
|
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||||
|
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||||
|
(itemColumns[index], item.Weight)));
|
||||||
|
weightedColumns.Add((finalColumn, sheet.FinalWeight));
|
||||||
|
var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
|
||||||
|
var scoreColumns = weightedColumns.Select(x => x.Column)
|
||||||
|
.Append(totalColumn)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var bytes = ExcelWorkbookHelper.Create(
|
var bytes = ExcelWorkbookHelper.Create(
|
||||||
"成绩导入", headers, rows, instructions);
|
"成绩导入", headers, rows, instructions,
|
||||||
|
(worksheet, rowNumber) =>
|
||||||
|
{
|
||||||
|
var componentReferences = requiredColumns
|
||||||
|
.Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
|
||||||
|
.ToArray();
|
||||||
|
var weightedExpression = string.Join("+", weightedColumns.Select(x =>
|
||||||
|
$"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
|
||||||
|
var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
|
||||||
|
var formula =
|
||||||
|
$"=IF(OR({statusReference}=\"缺考\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
|
||||||
|
var cell = worksheet.Cell(rowNumber, totalColumn);
|
||||||
|
cell.FormulaA1 = formula;
|
||||||
|
cell.Style.NumberFormat.Format = "0.0";
|
||||||
|
cell.Style.Font.Bold = true;
|
||||||
|
cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
|
||||||
|
|
||||||
|
foreach (var scoreColumn in scoreColumns)
|
||||||
|
{
|
||||||
|
var conditionalFormat = worksheet
|
||||||
|
.Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
|
||||||
|
.AddConditionalFormat();
|
||||||
|
var failingScoreFormat = conditionalFormat.WhenLessThan(60);
|
||||||
|
failingScoreFormat.Fill.BackgroundColor =
|
||||||
|
ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
|
||||||
|
failingScoreFormat.Font.FontColor =
|
||||||
|
ClosedXML.Excel.XLColor.FromHtml("#B42318");
|
||||||
|
}
|
||||||
|
});
|
||||||
var taskName = sheet.TeachingTask!.Name;
|
var taskName = sheet.TeachingTask!.Name;
|
||||||
return File(bytes, ExcelWorkbookHelper.ContentType,
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||||
$"成绩导入模板-{taskName}.xlsx");
|
$"成绩导入模板-{taskName}.xlsx");
|
||||||
@@ -623,13 +820,15 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||||
|
|
||||||
IReadOnlyList<ExcelRow> rows;
|
IReadOnlyList<ExcelRow> rows;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
rows = await ExcelWorkbookHelper.ReadAsync(
|
rows = await ExcelWorkbookHelper.ReadAsync(
|
||||||
file, headers, cancellationToken);
|
file,
|
||||||
|
headers.Where(x => x != "总分(自动计算)").ToArray(),
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch (InvalidDataException exception)
|
catch (InvalidDataException exception)
|
||||||
{
|
{
|
||||||
@@ -665,7 +864,7 @@ public sealed class GradesController(
|
|||||||
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
||||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||||
|
|
||||||
// Parse final score
|
// Parse final score. The formula-driven total column is intentionally ignored.
|
||||||
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
||||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||||
|
|
||||||
@@ -719,6 +918,7 @@ public sealed class GradesController(
|
|||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
return ImportValidationProblem(errors);
|
return ImportValidationProblem(errors);
|
||||||
|
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new { updated, total = rows.Count });
|
return Ok(new { updated, total = rows.Count });
|
||||||
}
|
}
|
||||||
@@ -757,6 +957,7 @@ public sealed class GradesController(
|
|||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
|
GradeSheetId = x.GradeSheetId,
|
||||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||||
x.GradeSheet.TeachingTaskId,
|
x.GradeSheet.TeachingTaskId,
|
||||||
@@ -773,6 +974,121 @@ public sealed class GradesController(
|
|||||||
return Ok(new { Student = student, Records = records });
|
return Ok(new { Student = student, Records = records });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/statistics")]
|
||||||
|
[Authorize(Roles = StatisticsUsers)]
|
||||||
|
public async Task<ActionResult> GetCourseStatistics(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var sheet = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Status,
|
||||||
|
x.TeachingTask!.CourseId,
|
||||||
|
x.TeachingTask.AcademicTermId,
|
||||||
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course.Code,
|
||||||
|
TermName = x.TeachingTask.AcademicTerm!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
|
||||||
|
Guid? classId = null;
|
||||||
|
Guid? majorId = null;
|
||||||
|
Guid? collegeId = null;
|
||||||
|
if (scope.IsInRole(SystemRoles.Student))
|
||||||
|
{
|
||||||
|
if (sheet.Status != GradeSheetStatus.Published)
|
||||||
|
return NotFound();
|
||||||
|
var student = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Student!.AdministrativeClassId,
|
||||||
|
MajorId = x.Student.AdministrativeClass!.MajorId,
|
||||||
|
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (student is null) return Forbid();
|
||||||
|
classId = student.AdministrativeClassId;
|
||||||
|
majorId = student.MajorId;
|
||||||
|
collegeId = student.CollegeId;
|
||||||
|
}
|
||||||
|
else if (scope.Scope == DataScope.College)
|
||||||
|
{
|
||||||
|
collegeId = scope.RestrictedCollegeId;
|
||||||
|
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
|
||||||
|
await db.Courses.Where(x => x.Id == sheet.CourseId)
|
||||||
|
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
else if (scope.IsInRole(SystemRoles.Counselor))
|
||||||
|
{
|
||||||
|
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.CounselorUserId == scope.UserId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (allowedClassIds.Count == 0) return Forbid();
|
||||||
|
// A counselor sees their classes plus the matching major/college
|
||||||
|
// benchmarks, never an unrelated class-level statistic.
|
||||||
|
classId = allowedClassIds.First();
|
||||||
|
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
|
||||||
|
.Select(x => x.MajorId).FirstAsync(cancellationToken);
|
||||||
|
collegeId = await db.Majors.Where(x => x.Id == majorId)
|
||||||
|
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
|
||||||
|
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
|
||||||
|
{
|
||||||
|
var source = db.CourseGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId == sheet.CourseId &&
|
||||||
|
x.AcademicTermId == sheet.AcademicTermId);
|
||||||
|
return await source.Select(x => new
|
||||||
|
{
|
||||||
|
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
|
||||||
|
x.Below60Count, x.From60To69Count, x.From70To79Count,
|
||||||
|
x.From80To89Count, x.From90To100Count,
|
||||||
|
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
|
||||||
|
x.CalculatedAt
|
||||||
|
}).ToListAsync(token);
|
||||||
|
}, AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.CourseGradeStatistics], cancellationToken);
|
||||||
|
|
||||||
|
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
|
||||||
|
{
|
||||||
|
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
|
||||||
|
x.ScopeEntityId == entityId);
|
||||||
|
return item is null ? null : new
|
||||||
|
{
|
||||||
|
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
|
||||||
|
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
|
||||||
|
item.CalculatedAt,
|
||||||
|
Distribution = new[]
|
||||||
|
{
|
||||||
|
new { Range = "0–59", Count = item.Below60Count },
|
||||||
|
new { Range = "60–69", Count = item.From60To69Count },
|
||||||
|
new { Range = "70–79", Count = item.From70To79Count },
|
||||||
|
new { Range = "80–89", Count = item.From80To89Count },
|
||||||
|
new { Range = "90–100", Count = item.From90To100Count }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
sheet.CourseName, sheet.CourseCode, sheet.TermName,
|
||||||
|
IsRefreshing = !statistics.Any(),
|
||||||
|
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
|
||||||
|
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
|
||||||
|
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
|
||||||
|
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
|
||||||
|
? Find(CourseGradeStatisticScope.University, null) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private IQueryable<TeachingTask> AccessibleTasks()
|
private IQueryable<TeachingTask> AccessibleTasks()
|
||||||
{
|
{
|
||||||
var source = db.TeachingTasks.AsQueryable();
|
var source = db.TeachingTasks.AsQueryable();
|
||||||
@@ -845,6 +1161,7 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
QueueCourseStatisticsRefresh(id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
@@ -854,6 +1171,14 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
||||||
|
{
|
||||||
|
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
||||||
|
}
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
@@ -896,6 +1221,18 @@ public sealed class GradesController(
|
|||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static string ColumnLetter(int column)
|
||||||
|
{
|
||||||
|
var result = string.Empty;
|
||||||
|
while (column > 0)
|
||||||
|
{
|
||||||
|
column--;
|
||||||
|
result = (char)('A' + column % 26) + result;
|
||||||
|
column /= 26;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record GradeSheetRequest(
|
public sealed record GradeSheetRequest(
|
||||||
@@ -928,5 +1265,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);
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/other-exams")]
|
||||||
|
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
[HttpGet("batches")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
||||||
|
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
||||||
|
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
[HttpPost("batches")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
|
||||||
|
var name = Normalize(request.Name);
|
||||||
|
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
|
||||||
|
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
|
||||||
|
if (error is not null) return ValidationProblem(error);
|
||||||
|
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
|
||||||
|
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
|
||||||
|
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
|
||||||
|
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
|
||||||
|
db.OtherExamBatches.Add(batch);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Ok(new { batch.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("students/lookup")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var number = Normalize(studentNumber);
|
||||||
|
if (number is null) return ValidationProblem("请输入学号。");
|
||||||
|
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
|
||||||
|
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
|
||||||
|
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("batches/{id:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
|
||||||
|
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
|
||||||
|
if (batch is null) return NotFound();
|
||||||
|
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
|
||||||
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
|
||||||
|
return Ok(new { Batch = batch, Results = results });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("batches/{id:guid}/results")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (batch is null) return NotFound();
|
||||||
|
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||||
|
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("batches/{id:guid}/template")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (batch is null) return NotFound();
|
||||||
|
var headers = HeadersFor(batch);
|
||||||
|
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("batches/{id:guid}/import")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (batch is null) return NotFound();
|
||||||
|
IReadOnlyList<ExcelRow> rows;
|
||||||
|
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
|
||||||
|
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
|
||||||
|
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
|
||||||
|
var inputs = new List<OtherExamResultRequest>();
|
||||||
|
var errors = new List<string>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var number = row["学号"].Trim();
|
||||||
|
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
|
||||||
|
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
|
||||||
|
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
|
||||||
|
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
|
||||||
|
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
|
||||||
|
}
|
||||||
|
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||||
|
var result = await ReplaceResultsAsync(batch, inputs, ct);
|
||||||
|
return result ?? Ok(new { updated = inputs.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("batches/{id:guid}/publish")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (batch is null) return NotFound();
|
||||||
|
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
|
||||||
|
batch.Status = OtherExamBatchStatus.Published;
|
||||||
|
batch.PublicationCount++;
|
||||||
|
batch.PublishedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Ok(new { batch.PublicationCount, batch.PublishedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> Mine(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
|
||||||
|
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
|
||||||
|
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
|
||||||
|
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
|
||||||
|
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
|
||||||
|
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
|
||||||
|
return Ok(new { Best = best, History = history });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
|
||||||
|
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
|
||||||
|
var studentRows = await db.Students
|
||||||
|
.Where(x => numbers.Contains(x.StudentNumber))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||||
|
foreach (var item in inputs)
|
||||||
|
{
|
||||||
|
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
|
||||||
|
if (error is not null) return ValidationProblem(error);
|
||||||
|
}
|
||||||
|
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||||
|
var beforeCount = await db.OtherExamResults.AsNoTracking()
|
||||||
|
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
|
||||||
|
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
|
||||||
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
|
||||||
|
{
|
||||||
|
db.OtherExamResults.RemoveRange(batch.Results);
|
||||||
|
batch.Status = OtherExamBatchStatus.Draft;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
batch.Results = inputs.Select(x =>
|
||||||
|
{
|
||||||
|
var student = students[x.StudentNumber.Trim()];
|
||||||
|
return new OtherExamResult
|
||||||
|
{
|
||||||
|
OtherExamBatchId = batch.Id,
|
||||||
|
StudentId = student.Id,
|
||||||
|
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
|
||||||
|
Score = x.Score,
|
||||||
|
Level = Normalize(x.Level),
|
||||||
|
IsPassed = x.IsPassed,
|
||||||
|
Notes = Normalize(x.Notes)
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
return null;
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
|
||||||
|
{
|
||||||
|
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
|
||||||
|
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
|
||||||
|
_ => ["学号", "是否合格", "备注"]
|
||||||
|
};
|
||||||
|
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
|
||||||
|
{
|
||||||
|
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
|
||||||
|
}
|
||||||
|
private static bool? ParsePass(string value, int row, List<string> errors)
|
||||||
|
{
|
||||||
|
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
|
||||||
|
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
|
||||||
|
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
|
||||||
|
}
|
||||||
|
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
|
||||||
|
{
|
||||||
|
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
|
||||||
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
|
||||||
|
{
|
||||||
|
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
||||||
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
||||||
|
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
||||||
|
{
|
||||||
|
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
||||||
|
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
|
||||||
|
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var index = Array.IndexOf(levels, level ?? "");
|
||||||
|
return index >= 0 ? levels.Length - index : -1;
|
||||||
|
}
|
||||||
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||||
|
{
|
||||||
|
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
||||||
|
return ValidationProblem(ModelState);
|
||||||
|
}
|
||||||
|
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
|
||||||
|
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
|
||||||
|
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
|
||||||
@@ -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
|
||||||
@@ -130,7 +136,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
constraint?.EarliestPeriod,
|
constraint?.EarliestPeriod,
|
||||||
constraint?.LatestPeriod,
|
constraint?.LatestPeriod,
|
||||||
AllowedClassroomIds = constraint?.AllowedClassrooms
|
AllowedClassroomIds = constraint?.AllowedClassrooms
|
||||||
.Select(x => x.ClassroomId) ?? []
|
.Select(x => x.ClassroomId) ?? [],
|
||||||
|
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -221,6 +228,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
||||||
constraint.EarliestPeriod = request.EarliestPeriod;
|
constraint.EarliestPeriod = request.EarliestPeriod;
|
||||||
constraint.LatestPeriod = request.LatestPeriod;
|
constraint.LatestPeriod = request.LatestPeriod;
|
||||||
|
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
|
||||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||||
constraint.AllowedClassrooms = request.RequiresClassroom
|
constraint.AllowedClassrooms = request.RequiresClassroom
|
||||||
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
|
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
|
||||||
@@ -432,7 +440,8 @@ public sealed record TeachingTaskScheduleConstraintRequest(
|
|||||||
IReadOnlyList<Guid> AllowedClassroomIds,
|
IReadOnlyList<Guid> AllowedClassroomIds,
|
||||||
IReadOnlyList<int> AllowedDayOfWeeks,
|
IReadOnlyList<int> AllowedDayOfWeeks,
|
||||||
[Range(1, 30)] int? EarliestPeriod,
|
[Range(1, 30)] int? EarliestPeriod,
|
||||||
[Range(1, 30)] int? LatestPeriod);
|
[Range(1, 30)] int? LatestPeriod,
|
||||||
|
TeachingVenueNature AllowedExperimentVenueNatures = 0);
|
||||||
|
|
||||||
public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
||||||
Guid AcademicTermId,
|
Guid AcademicTermId,
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
||||||
|
return ValidationProblem(
|
||||||
|
$"实验课必须安排在具有实验教学性质的场地;“{classroom.Name}”未标注实验室、实训室、机房或语音室性质。 ");
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||||
@@ -543,6 +596,11 @@ public sealed class SchedulesController(
|
|||||||
if (allowedClassroomIds.Count > 0 &&
|
if (allowedClassroomIds.Count > 0 &&
|
||||||
!allowedClassroomIds.Contains(classroom.Id))
|
!allowedClassroomIds.Contains(classroom.Id))
|
||||||
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
|
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
|
||||||
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
|
||||||
|
allowedNatures != 0 &&
|
||||||
|
(classroom.TeachingVenueNature & allowedNatures) == 0)
|
||||||
|
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
|
||||||
}
|
}
|
||||||
var studentCount = task.Classes.Sum(x =>
|
var studentCount = task.Classes.Sum(x =>
|
||||||
x.AdministrativeClass!.Students.Count(student =>
|
x.AdministrativeClass!.Students.Count(student =>
|
||||||
@@ -584,6 +642,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 +660,7 @@ public sealed class SchedulesController(
|
|||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(
|
private async Task<ActionResult> SaveAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
bool created,
|
bool created,
|
||||||
@@ -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,83 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExperimentProject : EntityBase
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||||
|
public Guid? ScheduleEntryId { get; set; }
|
||||||
|
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||||
|
public 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; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +54,99 @@ public sealed class GradeItemScore
|
|||||||
public decimal? Score { get; set; }
|
public decimal? Score { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted course-result aggregate. One course/term is materialized at each
|
||||||
|
/// organizational level so the result-analysis page never aggregates raw
|
||||||
|
/// grade records on request.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatistic : EntityBase
|
||||||
|
{
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public CourseGradeStatisticScope Scope { get; set; }
|
||||||
|
public Guid? ScopeEntityId { get; set; }
|
||||||
|
public int StudentCount { get; set; }
|
||||||
|
public int PassedCount { get; set; }
|
||||||
|
public int Below60Count { get; set; }
|
||||||
|
public int From60To69Count { get; set; }
|
||||||
|
public int From70To79Count { get; set; }
|
||||||
|
public int From80To89Count { get; set; }
|
||||||
|
public int From90To100Count { get; set; }
|
||||||
|
public decimal HighestScore { get; set; }
|
||||||
|
public decimal AverageScore { get; set; }
|
||||||
|
public decimal LowestScore { get; set; }
|
||||||
|
public decimal PassRate { get; set; }
|
||||||
|
public DateTime CalculatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materialized analysis for one published teaching class. Course/term
|
||||||
|
/// organizational benchmarks stay in <see cref="CourseGradeStatistic"/>;
|
||||||
|
/// this table is the grain used for peer-class and historical comparisons.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TeachingTaskGradeStatistic : EntityBase
|
||||||
|
{
|
||||||
|
public Guid GradeSheetId { get; set; }
|
||||||
|
public GradeSheet? GradeSheet { get; set; }
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public int StudentCount { get; set; }
|
||||||
|
public int PassedCount { get; set; }
|
||||||
|
public int ExcellentCount { get; set; }
|
||||||
|
public decimal HighestScore { get; set; }
|
||||||
|
public decimal AverageScore { get; set; }
|
||||||
|
public decimal MedianScore { get; set; }
|
||||||
|
public decimal LowestScore { get; set; }
|
||||||
|
public decimal StandardDeviation { get; set; }
|
||||||
|
public decimal PassRate { get; set; }
|
||||||
|
public decimal ExcellentRate { get; set; }
|
||||||
|
public DateTime CalculatedAt { get; set; }
|
||||||
|
public ICollection<TeachingTaskGradeScoreBand> ScoreBands { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flexible score-band rows are kept separately so future band definitions do
|
||||||
|
/// not require widening the teaching-class summary table.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TeachingTaskGradeScoreBand : EntityBase
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskGradeStatisticId { get; set; }
|
||||||
|
public TeachingTaskGradeStatistic? TeachingTaskGradeStatistic { get; set; }
|
||||||
|
public required string Label { get; set; }
|
||||||
|
public decimal LowerBound { get; set; }
|
||||||
|
public decimal? UpperBound { get; set; }
|
||||||
|
public int StudentCount { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CourseGradeStatisticScope
|
||||||
|
{
|
||||||
|
AdministrativeClass = 1,
|
||||||
|
Major = 2,
|
||||||
|
College = 3,
|
||||||
|
University = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
|
||||||
|
{
|
||||||
|
public Guid GradeSheetId { get; set; }
|
||||||
|
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
|
||||||
|
CourseGradeStatisticsRefreshJobStatus.Queued;
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CourseGradeStatisticsRefreshJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|
||||||
public enum GradeSheetStatus
|
public enum GradeSheetStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
@@ -68,3 +164,9 @@ public enum GradeExamStatus
|
|||||||
Exempt = 4,
|
Exempt = 4,
|
||||||
Makeup = 5
|
Makeup = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum GradeItemSourceType
|
||||||
|
{
|
||||||
|
Manual = 1,
|
||||||
|
ExperimentSummary = 2
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public Guid? RequiredBuildingId { get; set; }
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
public Building? RequiredBuilding { get; set; }
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public string? RequiredBuildingIds { get; set; }
|
||||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -46,9 +46,35 @@ public sealed class Classroom : CatalogEntity
|
|||||||
public Building? Building { get; set; }
|
public Building? Building { get; set; }
|
||||||
public int Capacity { get; set; }
|
public int Capacity { get; set; }
|
||||||
public string RoomType { get; set; } = "普通教室";
|
public string RoomType { get; set; } = "普通教室";
|
||||||
|
public TeachingVenueNature TeachingVenueNature { get; set; } =
|
||||||
|
TeachingVenueNature.GeneralClassroom;
|
||||||
public string? Equipment { get; set; }
|
public string? Equipment { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum TeachingVenueNature
|
||||||
|
{
|
||||||
|
GeneralClassroom = 1,
|
||||||
|
Laboratory = 2,
|
||||||
|
TrainingRoom = 4,
|
||||||
|
ComputerLab = 8,
|
||||||
|
LanguageLab = 16,
|
||||||
|
SportsVenue = 32,
|
||||||
|
ArtsVenue = 64
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TeachingVenueNatureRules
|
||||||
|
{
|
||||||
|
public const TeachingVenueNature ExperimentTeaching =
|
||||||
|
TeachingVenueNature.Laboratory |
|
||||||
|
TeachingVenueNature.TrainingRoom |
|
||||||
|
TeachingVenueNature.ComputerLab |
|
||||||
|
TeachingVenueNature.LanguageLab;
|
||||||
|
|
||||||
|
public static bool SupportsExperiment(TeachingVenueNature value) =>
|
||||||
|
(value & ExperimentTeaching) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class AcademicTerm : CatalogEntity
|
public sealed class AcademicTerm : CatalogEntity
|
||||||
{
|
{
|
||||||
public required string AcademicYear { get; set; }
|
public required string AcademicYear { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class OtherExamBatch : EntityBase
|
||||||
|
{
|
||||||
|
public string? ExamCode { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public string? Organizer { get; set; }
|
||||||
|
public DateOnly ExamDate { get; set; }
|
||||||
|
public OtherExamMetricKind MetricKind { get; set; }
|
||||||
|
public decimal? MaxScore { get; set; }
|
||||||
|
public string? LevelOptions { get; set; }
|
||||||
|
public OtherExamBatchStatus Status { get; set; } = OtherExamBatchStatus.Draft;
|
||||||
|
public int PublicationCount { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public ICollection<OtherExamResult> Results { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OtherExamResult : EntityBase
|
||||||
|
{
|
||||||
|
public Guid OtherExamBatchId { get; set; }
|
||||||
|
public OtherExamBatch? OtherExamBatch { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public int AttemptNumber { get; set; } = 1;
|
||||||
|
public decimal? Score { get; set; }
|
||||||
|
public string? Level { get; set; }
|
||||||
|
public bool? IsPassed { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OtherExamMetricKind
|
||||||
|
{
|
||||||
|
PassFail = 1,
|
||||||
|
Level = 2,
|
||||||
|
Score = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OtherExamBatchStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Published = 2
|
||||||
|
}
|
||||||
@@ -30,8 +30,25 @@ public sealed class Student : EntityBase
|
|||||||
public DateOnly EnrollmentDate { get; set; }
|
public DateOnly EnrollmentDate { get; set; }
|
||||||
public StudentStatus Status { get; set; } = StudentStatus.Active;
|
public StudentStatus Status { get; set; } = StudentStatus.Active;
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public string? IdCardNumber { get; set; }
|
||||||
|
public string? Nationality { get; set; }
|
||||||
|
public string? Ethnicity { get; set; }
|
||||||
|
public string? PoliticalStatus { get; set; }
|
||||||
|
public string? NativePlace { get; set; }
|
||||||
|
public string? HouseholdAddress { get; set; }
|
||||||
|
public string? CurrentAddress { get; set; }
|
||||||
|
public string? PostalCode { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
public string? Qq { get; set; }
|
||||||
|
public string? WeChat { get; set; }
|
||||||
|
public string? EmergencyContactName { get; set; }
|
||||||
|
public string? EmergencyContactRelationship { get; set; }
|
||||||
|
public string? EmergencyContactPhone { get; set; }
|
||||||
|
public string? SpecialTags { get; set; }
|
||||||
|
public string? SpecialNeeds { get; set; }
|
||||||
|
public string? Biography { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
}
|
}
|
||||||
@@ -50,6 +67,16 @@ public sealed class Course : CatalogEntity
|
|||||||
public CourseNature Nature { get; set; }
|
public CourseNature Nature { get; set; }
|
||||||
public AssessmentMethod AssessmentMethod { get; set; }
|
public AssessmentMethod AssessmentMethod { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
public ICollection<CoursePrerequisite> Prerequisites { get; set; } = [];
|
||||||
|
public ICollection<CoursePrerequisite> RequiredByCourses { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CoursePrerequisite : EntityBase
|
||||||
|
{
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Course? Course { get; set; }
|
||||||
|
public Guid PrerequisiteCourseId { get; set; }
|
||||||
|
public Course? PrerequisiteCourse { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CourseCategory : CatalogEntity
|
public sealed class CourseCategory : CatalogEntity
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
|
|||||||
public SchedulePlan? SchedulePlan { get; set; }
|
public SchedulePlan? SchedulePlan { get; set; }
|
||||||
public Guid TeachingTaskId { get; set; }
|
public Guid TeachingTaskId { get; set; }
|
||||||
public TeachingTask? TeachingTask { get; set; }
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
|
||||||
public Guid? ClassroomId { get; set; }
|
public Guid? ClassroomId { get; set; }
|
||||||
public Classroom? Classroom { get; set; }
|
public Classroom? Classroom { get; set; }
|
||||||
public int DayOfWeek { get; set; }
|
public int DayOfWeek { get; set; }
|
||||||
@@ -54,6 +55,7 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
|
|||||||
public string? AllowedDayOfWeeks { get; set; }
|
public string? AllowedDayOfWeeks { get; set; }
|
||||||
public int? EarliestPeriod { get; set; }
|
public int? EarliestPeriod { get; set; }
|
||||||
public int? LatestPeriod { get; set; }
|
public int? LatestPeriod { get; set; }
|
||||||
|
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
|
||||||
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
|
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,3 +131,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,47 @@
|
|||||||
|
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,
|
||||||
|
CourseGradeStatisticsRefresh = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
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,56 @@
|
|||||||
|
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 int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
|
||||||
|
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||||
|
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||||
|
public bool UseQuorumQueues { get; set; } = true;
|
||||||
|
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,
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh =>
|
||||||
|
CourseGradeStatisticsRefreshConcurrency,
|
||||||
|
_ => 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,340 @@
|
|||||||
|
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 gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||||
|
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
|
||||||
|
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||||
|
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
|
||||||
|
message.JobId == x.Id))
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missingKeys = automaticJobs
|
||||||
|
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||||
|
.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)))
|
||||||
|
.Concat(gradeStatisticsJobs.Select(id =>
|
||||||
|
(BackgroundJobKind.CourseGradeStatisticsRefresh, 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,416 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
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;
|
||||||
|
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
|
||||||
|
.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;
|
||||||
|
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||||
|
await db.CourseGradeStatisticsRefreshJobs
|
||||||
|
.Where(x => x.Id == message.JobId &&
|
||||||
|
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
|
||||||
|
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters
|
||||||
|
.SetProperty(x => x.Status,
|
||||||
|
CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||||
|
.SetProperty(x => x.ErrorMessage, error)
|
||||||
|
.SetProperty(x => x.CompletedAt, completedAt),
|
||||||
|
cancellationToken);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(message.JobKind),
|
||||||
|
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,439 @@
|
|||||||
|
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,
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh
|
||||||
|
];
|
||||||
|
|
||||||
|
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",
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
|
||||||
|
_ => 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,203 @@
|
|||||||
|
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 string CourseGradeStatistics(Guid gradeSheetId) =>
|
||||||
|
$"grade-statistics:sheet:{gradeSheetId:N}";
|
||||||
|
|
||||||
|
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
|
||||||
|
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AppCacheTags
|
||||||
|
{
|
||||||
|
public const string BaseData = "base-data";
|
||||||
|
public const string Analytics = "analytics";
|
||||||
|
public const string CourseGradeStatistics = "grade-statistics";
|
||||||
|
public const string Timetables = "timetables";
|
||||||
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper
|
|||||||
string sheetName,
|
string sheetName,
|
||||||
IReadOnlyList<string> headers,
|
IReadOnlyList<string> headers,
|
||||||
IEnumerable<IReadOnlyList<object?>> rows,
|
IEnumerable<IReadOnlyList<object?>> rows,
|
||||||
IReadOnlyList<string>? instructions = null)
|
IReadOnlyList<string>? instructions = null,
|
||||||
|
Action<IXLWorksheet, int>? configureRow = null)
|
||||||
{
|
{
|
||||||
using var workbook = new XLWorkbook();
|
using var workbook = new XLWorkbook();
|
||||||
var sheet = workbook.Worksheets.Add(sheetName);
|
var sheet = workbook.Worksheets.Add(sheetName);
|
||||||
@@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper
|
|||||||
{
|
{
|
||||||
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
|
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
|
||||||
}
|
}
|
||||||
|
configureRow?.Invoke(sheet, rowNumber);
|
||||||
rowNumber++;
|
rowNumber++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Experiments;
|
||||||
|
|
||||||
|
internal static class CentralizedExperimentConflictQuery
|
||||||
|
{
|
||||||
|
public static IQueryable<Guid> TaskIdsForTeachers(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> teacherIds) =>
|
||||||
|
db.TeachingTaskTeachers.AsNoTracking()
|
||||||
|
.WhereIn(teacherIds, x => x.TeacherId)
|
||||||
|
.Select(x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
public static IQueryable<Guid> TaskIdsForClasses(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> classIds) =>
|
||||||
|
db.TeachingTaskClasses.AsNoTracking()
|
||||||
|
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||||
|
.Select(x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
public static IQueryable<ScheduleEntry> ScheduleEntries(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||||
|
Guid academicTermId,
|
||||||
|
int dayOfWeek,
|
||||||
|
int week,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount) =>
|
||||||
|
db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
|
x.SchedulePlan.AcademicTermId == academicTermId &&
|
||||||
|
x.DayOfWeek == dayOfWeek &&
|
||||||
|
x.StartWeek <= week &&
|
||||||
|
x.EndWeek >= week &&
|
||||||
|
x.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < x.StartPeriod + x.PeriodCount)
|
||||||
|
.WhereIn(relatedTaskIds, x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
public static IQueryable<ExperimentSession> ExperimentSessions(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||||
|
DateOnly date,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount) =>
|
||||||
|
db.ExperimentSessions.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
x.SessionDate == date &&
|
||||||
|
x.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < x.StartPeriod + x.PeriodCount &&
|
||||||
|
x.ExperimentProject!.ArrangementMode ==
|
||||||
|
ExperimentArrangementMode.Centralized)
|
||||||
|
.WhereIn(
|
||||||
|
relatedTaskIds,
|
||||||
|
x => x.ExperimentProject!.TeachingTaskId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuilds a course/term's denormalized result statistics. The operation is
|
||||||
|
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatisticsRefreshJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
IAppCache cache,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.CourseGradeStatisticsRefreshJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||||
|
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
|
||||||
|
job.StartedAt = DateTime.UtcNow;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sheetData = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.Id == job.GradeSheetId)
|
||||||
|
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheetData is null)
|
||||||
|
{
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Statistics shown to students are based only on formally published
|
||||||
|
// scores. This prevents an unfinished class from exposing data.
|
||||||
|
var scores = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.TotalScore != null &&
|
||||||
|
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
|
||||||
|
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
|
||||||
|
.Select(x => new ScoreRow(
|
||||||
|
x.GradeSheetId,
|
||||||
|
x.GradeSheet!.TeachingTaskId,
|
||||||
|
x.TotalScore!.Value,
|
||||||
|
x.Student!.AdministrativeClassId,
|
||||||
|
x.Student.AdministrativeClass!.MajorId,
|
||||||
|
x.Student.AdministrativeClass.Major!.CollegeId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var rebuilt = new List<CourseGradeStatistic>();
|
||||||
|
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
|
||||||
|
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
|
||||||
|
AddStatistics(CourseGradeStatisticScope.Major,
|
||||||
|
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
|
||||||
|
AddStatistics(CourseGradeStatisticScope.College,
|
||||||
|
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
|
||||||
|
AddUniversityStatistic(scores, rebuilt, target, now);
|
||||||
|
|
||||||
|
var rebuiltTeachingTasks = scores
|
||||||
|
.GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId })
|
||||||
|
.Select(group => CreateTeachingTaskStatistic(
|
||||||
|
group.Key.GradeSheetId,
|
||||||
|
group.Key.TeachingTaskId,
|
||||||
|
group.Select(x => x.Score),
|
||||||
|
target,
|
||||||
|
now))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await db.CourseGradeStatistics
|
||||||
|
.Where(x => x.CourseId == target.CourseId &&
|
||||||
|
x.AcademicTermId == target.AcademicTermId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
if (rebuilt.Count > 0)
|
||||||
|
db.CourseGradeStatistics.AddRange(rebuilt);
|
||||||
|
|
||||||
|
var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics
|
||||||
|
.Where(x => x.CourseId == target.CourseId &&
|
||||||
|
x.AcademicTermId == target.AcademicTermId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (oldTeachingTaskStatisticIds.Count > 0)
|
||||||
|
{
|
||||||
|
await db.TeachingTaskGradeScoreBands
|
||||||
|
.Where(x => oldTeachingTaskStatisticIds.Contains(
|
||||||
|
x.TeachingTaskGradeStatisticId))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
await db.TeachingTaskGradeStatistics
|
||||||
|
.Where(x => oldTeachingTaskStatisticIds.Contains(x.Id))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
if (rebuiltTeachingTasks.Count > 0)
|
||||||
|
db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks);
|
||||||
|
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||||
|
job.CompletedAt = now;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
|
||||||
|
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
|
||||||
|
exception.GetBaseException().Message.Length)];
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddStatistics(
|
||||||
|
CourseGradeStatisticScope scope,
|
||||||
|
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
|
||||||
|
ICollection<CourseGradeStatistic> target,
|
||||||
|
StatisticsTarget targetInfo,
|
||||||
|
DateTime calculatedAt)
|
||||||
|
{
|
||||||
|
foreach (var group in groups)
|
||||||
|
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddUniversityStatistic(
|
||||||
|
IReadOnlyCollection<ScoreRow> scores,
|
||||||
|
ICollection<CourseGradeStatistic> target,
|
||||||
|
StatisticsTarget targetInfo,
|
||||||
|
DateTime calculatedAt)
|
||||||
|
{
|
||||||
|
if (scores.Count > 0)
|
||||||
|
target.Add(Create(CourseGradeStatisticScope.University, null,
|
||||||
|
scores.Select(x => x.Score), targetInfo, calculatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CourseGradeStatistic Create(
|
||||||
|
CourseGradeStatisticScope scope,
|
||||||
|
Guid? scopeEntityId,
|
||||||
|
IEnumerable<decimal> source,
|
||||||
|
StatisticsTarget targetInfo,
|
||||||
|
DateTime calculatedAt)
|
||||||
|
{
|
||||||
|
var scores = source.ToArray();
|
||||||
|
var passed = scores.Count(x => x >= 60m);
|
||||||
|
return new CourseGradeStatistic
|
||||||
|
{
|
||||||
|
CourseId = targetInfo.CourseId,
|
||||||
|
AcademicTermId = targetInfo.AcademicTermId,
|
||||||
|
Scope = scope,
|
||||||
|
ScopeEntityId = scopeEntityId,
|
||||||
|
StudentCount = scores.Length,
|
||||||
|
PassedCount = passed,
|
||||||
|
Below60Count = scores.Count(x => x < 60m),
|
||||||
|
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
|
||||||
|
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
|
||||||
|
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
|
||||||
|
From90To100Count = scores.Count(x => x >= 90m),
|
||||||
|
HighestScore = scores.Max(),
|
||||||
|
AverageScore = Math.Round(scores.Average(), 1),
|
||||||
|
LowestScore = scores.Min(),
|
||||||
|
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
|
||||||
|
CalculatedAt = calculatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic(
|
||||||
|
Guid gradeSheetId,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
IEnumerable<decimal> source,
|
||||||
|
StatisticsTarget targetInfo,
|
||||||
|
DateTime calculatedAt)
|
||||||
|
{
|
||||||
|
var scores = source.OrderBy(x => x).ToArray();
|
||||||
|
var passed = scores.Count(x => x >= 60m);
|
||||||
|
var excellent = scores.Count(x => x >= 90m);
|
||||||
|
var average = scores.Average();
|
||||||
|
var middle = scores.Length / 2;
|
||||||
|
var median = scores.Length % 2 == 0
|
||||||
|
? (scores[middle - 1] + scores[middle]) / 2m
|
||||||
|
: scores[middle];
|
||||||
|
var variance = scores.Average(x =>
|
||||||
|
(double)((x - average) * (x - average)));
|
||||||
|
|
||||||
|
var statistic = new TeachingTaskGradeStatistic
|
||||||
|
{
|
||||||
|
GradeSheetId = gradeSheetId,
|
||||||
|
TeachingTaskId = teachingTaskId,
|
||||||
|
CourseId = targetInfo.CourseId,
|
||||||
|
AcademicTermId = targetInfo.AcademicTermId,
|
||||||
|
StudentCount = scores.Length,
|
||||||
|
PassedCount = passed,
|
||||||
|
ExcellentCount = excellent,
|
||||||
|
HighestScore = scores.Max(),
|
||||||
|
AverageScore = Math.Round(average, 1),
|
||||||
|
MedianScore = Math.Round(median, 1),
|
||||||
|
LowestScore = scores.Min(),
|
||||||
|
StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2),
|
||||||
|
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
|
||||||
|
ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2),
|
||||||
|
CalculatedAt = calculatedAt
|
||||||
|
};
|
||||||
|
statistic.ScoreBands =
|
||||||
|
[
|
||||||
|
CreateBand(statistic.Id, "0–59", 0m, 60m,
|
||||||
|
scores.Count(x => x < 60m), 0),
|
||||||
|
CreateBand(statistic.Id, "60–69", 60m, 70m,
|
||||||
|
scores.Count(x => x >= 60m && x < 70m), 1),
|
||||||
|
CreateBand(statistic.Id, "70–79", 70m, 80m,
|
||||||
|
scores.Count(x => x >= 70m && x < 80m), 2),
|
||||||
|
CreateBand(statistic.Id, "80–89", 80m, 90m,
|
||||||
|
scores.Count(x => x >= 80m && x < 90m), 3),
|
||||||
|
CreateBand(statistic.Id, "90–100", 90m, null,
|
||||||
|
scores.Count(x => x >= 90m), 4)
|
||||||
|
];
|
||||||
|
return statistic;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingTaskGradeScoreBand CreateBand(
|
||||||
|
Guid statisticId,
|
||||||
|
string label,
|
||||||
|
decimal lowerBound,
|
||||||
|
decimal? upperBound,
|
||||||
|
int count,
|
||||||
|
int sortOrder) => new()
|
||||||
|
{
|
||||||
|
TeachingTaskGradeStatisticId = statisticId,
|
||||||
|
Label = label,
|
||||||
|
LowerBound = lowerBound,
|
||||||
|
UpperBound = upperBound,
|
||||||
|
StudentCount = count,
|
||||||
|
SortOrder = sortOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record ScoreRow(
|
||||||
|
Guid GradeSheetId,
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
decimal Score,
|
||||||
|
Guid ClassId,
|
||||||
|
Guid MajorId,
|
||||||
|
Guid CollegeId);
|
||||||
|
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,512 @@
|
|||||||
|
using DocumentFormat.OpenXml;
|
||||||
|
using DocumentFormat.OpenXml.Packaging;
|
||||||
|
using DocumentFormat.OpenXml.Wordprocessing;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using SkiaSharp;
|
||||||
|
using A = DocumentFormat.OpenXml.Drawing;
|
||||||
|
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
|
||||||
|
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
|
||||||
|
using W = DocumentFormat.OpenXml.Wordprocessing;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
public static class GradeAnalysisWordReportGenerator
|
||||||
|
{
|
||||||
|
private const string Blue = "2E74B5";
|
||||||
|
private const string DarkBlue = "1F4D78";
|
||||||
|
private const string Ink = "263238";
|
||||||
|
private const string Muted = "68707A";
|
||||||
|
private const string LightFill = "F2F4F7";
|
||||||
|
private const int ContentWidth = 9360;
|
||||||
|
|
||||||
|
public static byte[] Generate(
|
||||||
|
GradeAnalyticsController.TeachingClassAnalysisReport report,
|
||||||
|
DateTime generatedAt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(report.Summary);
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true))
|
||||||
|
{
|
||||||
|
document.PackageProperties.Title = $"{report.CourseName}成绩分析报告";
|
||||||
|
document.PackageProperties.Subject = "教学班成绩统计与对比分析";
|
||||||
|
document.PackageProperties.Creator = "教务管理系统";
|
||||||
|
document.PackageProperties.Created = generatedAt;
|
||||||
|
|
||||||
|
var mainPart = document.AddMainDocumentPart();
|
||||||
|
mainPart.Document = new Document(new Body());
|
||||||
|
var settingsPart = mainPart.AddNewPart<DocumentSettingsPart>();
|
||||||
|
settingsPart.Settings = new Settings(new EvenAndOddHeaders());
|
||||||
|
settingsPart.Settings.Save();
|
||||||
|
AddStyles(mainPart);
|
||||||
|
var headerFooterIds = AddHeaderAndFooter(mainPart);
|
||||||
|
BuildBody(mainPart, report, generatedAt, headerFooterIds);
|
||||||
|
mainPart.Document.Save();
|
||||||
|
}
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildBody(
|
||||||
|
MainDocumentPart mainPart,
|
||||||
|
GradeAnalyticsController.TeachingClassAnalysisReport report,
|
||||||
|
DateTime generatedAt,
|
||||||
|
HeaderFooterIds headerFooterIds)
|
||||||
|
{
|
||||||
|
var body = mainPart.Document.Body!;
|
||||||
|
var summary = report.Summary!;
|
||||||
|
|
||||||
|
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
|
||||||
|
body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220));
|
||||||
|
body.Append(MetadataTable([
|
||||||
|
("课程", $"{report.CourseCode} {report.CourseName}"),
|
||||||
|
("教学班", $"{report.TaskNumber} {report.TaskName}"),
|
||||||
|
("学期", report.TermName),
|
||||||
|
("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")),
|
||||||
|
("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")),
|
||||||
|
("统计对象", $"{summary.StudentCount} 份已发布有效成绩")
|
||||||
|
]));
|
||||||
|
|
||||||
|
body.Append(Heading("一、分析摘要", 1));
|
||||||
|
body.Append(Callout(BuildExecutiveSummary(report)));
|
||||||
|
body.Append(MetricsTable(summary));
|
||||||
|
|
||||||
|
body.Append(Heading("二、分数段分布", 1));
|
||||||
|
body.Append(Paragraph("图 1 当前教学班各分数段人数", 20, false, Muted, 80, 80));
|
||||||
|
body.Append(ImageParagraph(mainPart, DrawScoreBands(summary.ScoreBands), "分数段分布图", 6.3, 3.0));
|
||||||
|
body.Append(DataTable(
|
||||||
|
["分数段", "下限", "上限", "人数", "占比"],
|
||||||
|
summary.ScoreBands.Select(x => new[]
|
||||||
|
{
|
||||||
|
x.Label,
|
||||||
|
x.LowerBound.ToString("0.#"),
|
||||||
|
x.UpperBound?.ToString("0.#") ?? "无上限",
|
||||||
|
x.StudentCount.ToString(),
|
||||||
|
Percent(x.StudentCount, summary.StudentCount)
|
||||||
|
}),
|
||||||
|
[1800, 1500, 1500, 1500, 3060]));
|
||||||
|
|
||||||
|
body.Append(Heading("三、同课程教学班对比", 1));
|
||||||
|
body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80));
|
||||||
|
var peerChartHeight = Math.Clamp(1.45 + report.PeerTeachingClasses.Count * 0.32, 1.8, 3.35);
|
||||||
|
body.Append(ImageParagraph(mainPart, DrawPeerAverages(report.PeerTeachingClasses), "教学班平均分对比图", 6.3, peerChartHeight));
|
||||||
|
body.Append(DataTable(
|
||||||
|
["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"],
|
||||||
|
report.PeerTeachingClasses.Select(x => new[]
|
||||||
|
{
|
||||||
|
$"{x.TaskNumber}{(x.IsSelected ? "(当前)" : "")}\n{x.TeacherNames}",
|
||||||
|
x.StudentCount.ToString(),
|
||||||
|
Score(x.AverageScore),
|
||||||
|
Score(x.MedianScore),
|
||||||
|
x.StandardDeviation.ToString("0.00"),
|
||||||
|
Rate(x.PassRate),
|
||||||
|
Rate(x.ExcellentRate)
|
||||||
|
}),
|
||||||
|
[2600, 820, 1050, 1050, 1050, 1395, 1395]));
|
||||||
|
|
||||||
|
body.Append(Heading("四、各范围基准", 1));
|
||||||
|
body.Append(Paragraph("范围基准按当前课程、当前学期聚合;同一教学班包含多个来源行政班时,将分别列示可用基准。", 22, false, Muted, 0, 100));
|
||||||
|
body.Append(DataTable(
|
||||||
|
["范围", "对象", "人数", "最高分", "平均分", "最低分", "合格率"],
|
||||||
|
report.ScopeBenchmarks.Select(x => new[]
|
||||||
|
{
|
||||||
|
x.Scope, x.Name, x.StudentCount.ToString(), Score(x.HighestScore),
|
||||||
|
Score(x.AverageScore), Score(x.LowestScore), Rate(x.PassRate)
|
||||||
|
}),
|
||||||
|
[980, 2200, 900, 1200, 1200, 1200, 1680]));
|
||||||
|
|
||||||
|
body.Append(Heading("五、历年成绩趋势", 1));
|
||||||
|
body.Append(Paragraph("图 3 同课程全校与当前任课教师历年平均分", 20, false, Muted, 80, 80));
|
||||||
|
body.Append(ImageParagraph(mainPart, DrawHistory(report.History), "历年平均分趋势图", 6.3, 3.15));
|
||||||
|
body.Append(DataTable(
|
||||||
|
["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"],
|
||||||
|
report.History.Select(x => new[]
|
||||||
|
{
|
||||||
|
x.TermName,
|
||||||
|
x.CourseStudentCount.ToString(),
|
||||||
|
Score(x.CourseAverageScore),
|
||||||
|
Rate(x.CoursePassRate),
|
||||||
|
x.Instructor?.StudentCount.ToString() ?? "—",
|
||||||
|
x.Instructor is null ? "—" : Score(x.Instructor.AverageScore),
|
||||||
|
x.Instructor is null ? "—" : Rate(x.Instructor.PassRate)
|
||||||
|
}),
|
||||||
|
[1700, 1050, 1200, 1450, 1050, 1200, 1710]));
|
||||||
|
|
||||||
|
body.Append(Heading("六、统计口径与使用说明", 1));
|
||||||
|
body.Append(Paragraph("1. 本报告仅统计已正式发布且纳入当前统计任务的有效成绩,不包含草稿、未发布成绩或学生逐人成绩明细。", 22, false, Ink, 0, 80));
|
||||||
|
body.Append(Paragraph("2. 合格率按成绩达到 60 分计算,优秀率按成绩达到 90 分计算;平均分、中位数和标准差均基于同一批有效成绩。", 22, false, Ink, 0, 80));
|
||||||
|
body.Append(Paragraph("3. 同课程教学班对比限定为当前学期;历年对比同时展示课程全校口径和当前任课教师所带教学班的加权汇总。", 22, false, Ink, 0, 80));
|
||||||
|
body.Append(Paragraph("4. 统计结果用于教学诊断和质量改进,不应脱离样本量、课程难度、考核方式等背景作单一排名或评价。", 22, false, Ink, 0, 80));
|
||||||
|
|
||||||
|
body.Append(new SectionProperties(
|
||||||
|
new HeaderReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultHeader },
|
||||||
|
new HeaderReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenHeader },
|
||||||
|
new FooterReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultFooter },
|
||||||
|
new FooterReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenFooter },
|
||||||
|
new PageSize { Width = 12240, Height = 15840 },
|
||||||
|
new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 708, Footer = 708 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildExecutiveSummary(GradeAnalyticsController.TeachingClassAnalysisReport report)
|
||||||
|
{
|
||||||
|
var summary = report.Summary!;
|
||||||
|
var band = summary.ScoreBands.OrderByDescending(x => x.StudentCount).FirstOrDefault();
|
||||||
|
var parts = new List<string>
|
||||||
|
{
|
||||||
|
$"本教学班共纳入 {summary.StudentCount} 份有效成绩,平均分 {Score(summary.AverageScore)},中位数 {Score(summary.MedianScore)},合格率 {Rate(summary.PassRate)},优秀率 {Rate(summary.ExcellentRate)}。"
|
||||||
|
};
|
||||||
|
if (band is not null)
|
||||||
|
parts.Add($"人数最多的分数段为 {band.Label},共 {band.StudentCount} 人,占 {Percent(band.StudentCount, summary.StudentCount)}。 ");
|
||||||
|
if (report.UniversityDelta is { } delta)
|
||||||
|
parts.Add($"与本学期全校同课程相比,平均分{Direction(delta.AverageScoreDifference, "分")},合格率{Direction(delta.PassRateDifference, "个百分点")}。 ");
|
||||||
|
parts.Add($"成绩标准差为 {summary.StandardDeviation:0.00},分数范围 {Score(summary.LowestScore)}–{Score(summary.HighestScore)}。建议结合分数段、同课程教学班和历年趋势综合研判。 ");
|
||||||
|
return string.Concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Direction(decimal value, string unit) =>
|
||||||
|
value > 0 ? $"高 {value:0.0} {unit}" : value < 0 ? $"低 {Math.Abs(value):0.0} {unit}" : "持平";
|
||||||
|
|
||||||
|
private static W.Table MetadataTable(IEnumerable<(string Label, string Value)> items)
|
||||||
|
{
|
||||||
|
var rows = items.Select(x => new[] { x.Label, x.Value });
|
||||||
|
return DataTable(["项目", "内容"], rows, [1800, 7560], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static W.Table MetricsTable(GradeAnalyticsController.TeachingClassMetrics value)
|
||||||
|
{
|
||||||
|
return DataTable(
|
||||||
|
["指标", "结果", "指标", "结果"],
|
||||||
|
[
|
||||||
|
["最高分", Score(value.HighestScore), "最低分", Score(value.LowestScore)],
|
||||||
|
["平均分", Score(value.AverageScore), "中位数", Score(value.MedianScore)],
|
||||||
|
["合格人数", $"{value.PassedCount} 人", "合格率", Rate(value.PassRate)],
|
||||||
|
["优秀人数", $"{value.ExcellentCount} 人", "优秀率", Rate(value.ExcellentRate)],
|
||||||
|
["标准差", value.StandardDeviation.ToString("0.00"), "有效成绩", $"{value.StudentCount} 份"]
|
||||||
|
],
|
||||||
|
[1800, 2880, 1800, 2880]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static W.Table DataTable(
|
||||||
|
IReadOnlyList<string> headers,
|
||||||
|
IEnumerable<string[]> rows,
|
||||||
|
IReadOnlyList<int> widths,
|
||||||
|
bool shadeHeader = true)
|
||||||
|
{
|
||||||
|
var table = new W.Table();
|
||||||
|
table.Append(new TableProperties(
|
||||||
|
new TableWidth { Width = ContentWidth.ToString(), Type = TableWidthUnitValues.Dxa },
|
||||||
|
new TableIndentation { Width = 120, Type = TableWidthUnitValues.Dxa },
|
||||||
|
new TableLayout { Type = TableLayoutValues.Fixed },
|
||||||
|
new TableBorders(
|
||||||
|
Border<TopBorder>(), Border<LeftBorder>(), Border<BottomBorder>(),
|
||||||
|
Border<RightBorder>(), Border<InsideHorizontalBorder>(), Border<InsideVerticalBorder>()),
|
||||||
|
new TableCellMarginDefault(
|
||||||
|
new TopMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
|
||||||
|
new TableCellLeftMargin { Width = 120, Type = TableWidthValues.Dxa },
|
||||||
|
new BottomMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
|
||||||
|
new TableCellRightMargin { Width = 120, Type = TableWidthValues.Dxa })));
|
||||||
|
table.Append(new TableGrid(widths.Select(x => new GridColumn { Width = x.ToString() })));
|
||||||
|
table.Append(Row(headers, widths, shadeHeader ? LightFill : "FFFFFF", true, true));
|
||||||
|
foreach (var row in rows)
|
||||||
|
table.Append(Row(row, widths, "FFFFFF", false, false));
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TableRow Row(
|
||||||
|
IReadOnlyList<string> values,
|
||||||
|
IReadOnlyList<int> widths,
|
||||||
|
string fill,
|
||||||
|
bool bold,
|
||||||
|
bool repeat)
|
||||||
|
{
|
||||||
|
var row = new TableRow();
|
||||||
|
if (repeat) row.AppendChild(new TableRowProperties(new TableHeader()));
|
||||||
|
for (var i = 0; i < widths.Count; i++)
|
||||||
|
{
|
||||||
|
var cell = new TableCell();
|
||||||
|
cell.Append(new TableCellProperties(
|
||||||
|
new TableCellWidth { Width = widths[i].ToString(), Type = TableWidthUnitValues.Dxa },
|
||||||
|
new Shading { Fill = fill, Val = ShadingPatternValues.Clear }));
|
||||||
|
var lines = (i < values.Count ? values[i] : "").Split('\n');
|
||||||
|
foreach (var line in lines)
|
||||||
|
cell.Append(Paragraph(line, 19, bold, Ink, 0, 0));
|
||||||
|
row.Append(cell);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Border<T>() where T : BorderType, new() =>
|
||||||
|
new() { Val = BorderValues.Single, Color = "D6DBE1", Size = 4 };
|
||||||
|
|
||||||
|
private static W.Table Callout(string text)
|
||||||
|
{
|
||||||
|
return DataTable(["核心结论"], [[text]], [ContentWidth]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Paragraph Heading(string text, int level)
|
||||||
|
{
|
||||||
|
var paragraph = new Paragraph(new ParagraphProperties(new ParagraphStyleId { Val = $"Heading{level}" }));
|
||||||
|
paragraph.Append(Run(text, level == 1 ? 32 : 26, true, level == 1 ? Blue : DarkBlue));
|
||||||
|
return paragraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Paragraph Paragraph(
|
||||||
|
string text,
|
||||||
|
int size,
|
||||||
|
bool bold,
|
||||||
|
string color,
|
||||||
|
int before,
|
||||||
|
int after)
|
||||||
|
{
|
||||||
|
var paragraph = new Paragraph(new ParagraphProperties(
|
||||||
|
new SpacingBetweenLines { Before = before.ToString(), After = after.ToString(), Line = "264", LineRule = LineSpacingRuleValues.Auto }));
|
||||||
|
paragraph.Append(Run(text, size, bold, color));
|
||||||
|
return paragraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Run Run(string text, int size, bool bold, string color)
|
||||||
|
{
|
||||||
|
return new Run(
|
||||||
|
new RunProperties(
|
||||||
|
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
|
||||||
|
new Bold { Val = bold },
|
||||||
|
new Color { Val = color },
|
||||||
|
new FontSize { Val = size.ToString() },
|
||||||
|
new FontSizeComplexScript { Val = size.ToString() }),
|
||||||
|
new Text(text) { Space = SpaceProcessingModeValues.Preserve });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Paragraph ImageParagraph(
|
||||||
|
MainDocumentPart mainPart,
|
||||||
|
byte[] image,
|
||||||
|
string description,
|
||||||
|
double widthInches,
|
||||||
|
double heightInches)
|
||||||
|
{
|
||||||
|
var part = mainPart.AddImagePart(ImagePartType.Png);
|
||||||
|
using (var stream = new MemoryStream(image)) part.FeedData(stream);
|
||||||
|
var relationshipId = mainPart.GetIdOfPart(part);
|
||||||
|
var width = (long)(widthInches * 914400L);
|
||||||
|
var height = (long)(heightInches * 914400L);
|
||||||
|
var drawing = new W.Drawing(
|
||||||
|
new DW.Inline(
|
||||||
|
new DW.Extent { Cx = width, Cy = height },
|
||||||
|
new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 },
|
||||||
|
new DW.DocProperties { Id = (UInt32Value)(uint)(mainPart.ImageParts.Count()), Name = description, Description = description },
|
||||||
|
new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }),
|
||||||
|
new A.Graphic(new A.GraphicData(
|
||||||
|
new PIC.Picture(
|
||||||
|
new PIC.NonVisualPictureProperties(
|
||||||
|
new PIC.NonVisualDrawingProperties { Id = 0, Name = description, Description = description },
|
||||||
|
new PIC.NonVisualPictureDrawingProperties()),
|
||||||
|
new PIC.BlipFill(
|
||||||
|
new A.Blip { Embed = relationshipId, CompressionState = A.BlipCompressionValues.Print },
|
||||||
|
new A.Stretch(new A.FillRectangle())),
|
||||||
|
new PIC.ShapeProperties(
|
||||||
|
new A.Transform2D(
|
||||||
|
new A.Offset { X = 0, Y = 0 },
|
||||||
|
new A.Extents { Cx = width, Cy = height }),
|
||||||
|
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })))
|
||||||
|
{ Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" }))
|
||||||
|
{ DistanceFromTop = 0, DistanceFromBottom = 0, DistanceFromLeft = 0, DistanceFromRight = 0 });
|
||||||
|
var paragraph = new Paragraph(new ParagraphProperties(
|
||||||
|
new Justification { Val = JustificationValues.Center },
|
||||||
|
new SpacingBetweenLines { Before = "0", After = "120" }));
|
||||||
|
paragraph.Append(new Run(drawing));
|
||||||
|
return paragraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddStyles(MainDocumentPart mainPart)
|
||||||
|
{
|
||||||
|
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
|
||||||
|
var normal = new Style(
|
||||||
|
new StyleName { Val = "Normal" },
|
||||||
|
new StyleRunProperties(
|
||||||
|
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
|
||||||
|
new FontSize { Val = "22" }, new Color { Val = Ink }),
|
||||||
|
new StyleParagraphProperties(
|
||||||
|
new SpacingBetweenLines { Before = "0", After = "120", Line = "264", LineRule = LineSpacingRuleValues.Auto }))
|
||||||
|
{ Type = StyleValues.Paragraph, StyleId = "Normal", Default = true };
|
||||||
|
var h1 = new Style(
|
||||||
|
new StyleName { Val = "heading 1" },
|
||||||
|
new BasedOn { Val = "Normal" },
|
||||||
|
new NextParagraphStyle { Val = "Normal" },
|
||||||
|
new StyleRunProperties(new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, new Bold(), new Color { Val = Blue }, new FontSize { Val = "32" }),
|
||||||
|
new StyleParagraphProperties(new KeepNext(), new SpacingBetweenLines { Before = "320", After = "160" }))
|
||||||
|
{ Type = StyleValues.Paragraph, StyleId = "Heading1" };
|
||||||
|
stylesPart.Styles = new Styles(normal, h1);
|
||||||
|
stylesPart.Styles.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart)
|
||||||
|
{
|
||||||
|
var defaultHeader = mainPart.AddNewPart<HeaderPart>();
|
||||||
|
defaultHeader.Header = CreateHeader();
|
||||||
|
defaultHeader.Header.Save();
|
||||||
|
var evenHeader = mainPart.AddNewPart<HeaderPart>();
|
||||||
|
evenHeader.Header = CreateHeader();
|
||||||
|
evenHeader.Header.Save();
|
||||||
|
var defaultFooter = mainPart.AddNewPart<FooterPart>();
|
||||||
|
defaultFooter.Footer = CreateFooter();
|
||||||
|
defaultFooter.Footer.Save();
|
||||||
|
var evenFooter = mainPart.AddNewPart<FooterPart>();
|
||||||
|
evenFooter.Footer = CreateFooter();
|
||||||
|
evenFooter.Footer.Save();
|
||||||
|
return new HeaderFooterIds(
|
||||||
|
mainPart.GetIdOfPart(defaultHeader),
|
||||||
|
mainPart.GetIdOfPart(evenHeader),
|
||||||
|
mainPart.GetIdOfPart(defaultFooter),
|
||||||
|
mainPart.GetIdOfPart(evenFooter));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Header CreateHeader() =>
|
||||||
|
new(Paragraph("成绩分析报告 | 教务管理系统", 18, false, Muted, 0, 0));
|
||||||
|
|
||||||
|
private static Footer CreateFooter()
|
||||||
|
{
|
||||||
|
var footerParagraph = new Paragraph(new ParagraphProperties(new Justification { Val = JustificationValues.Right }));
|
||||||
|
footerParagraph.Append(Run("第 ", 18, false, Muted));
|
||||||
|
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
|
||||||
|
footerParagraph.Append(new Run(new FieldCode(" PAGE ")));
|
||||||
|
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
|
||||||
|
footerParagraph.Append(Run(" 页", 18, false, Muted));
|
||||||
|
return new Footer(footerParagraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DrawScoreBands(IReadOnlyList<GradeAnalyticsController.ScoreBand> rows)
|
||||||
|
{
|
||||||
|
return DrawChart(1200, 540, (canvas, typeface) =>
|
||||||
|
{
|
||||||
|
DrawAxes(canvas, typeface, "人数", 70, 35, 1080, 430);
|
||||||
|
var max = Math.Max(1, rows.Max(x => x.StudentCount));
|
||||||
|
var barWidth = 150f;
|
||||||
|
var gap = (1000f - rows.Count * barWidth) / Math.Max(1, rows.Count);
|
||||||
|
for (var i = 0; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
var x = 105 + gap / 2 + i * (barWidth + gap);
|
||||||
|
var height = rows[i].StudentCount / (float)max * 330;
|
||||||
|
using var paint = new SKPaint { Color = new SKColor(46, 116, 181), IsAntialias = true };
|
||||||
|
canvas.DrawRoundRect(new SKRect(x, 430 - height, x + barWidth, 430), 8, 8, paint);
|
||||||
|
DrawText(canvas, typeface, rows[i].StudentCount.ToString(), x + barWidth / 2, 415 - height, 24, Ink, SKTextAlign.Center, true);
|
||||||
|
DrawText(canvas, typeface, rows[i].Label, x + barWidth / 2, 475, 18, Muted, SKTextAlign.Center);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DrawPeerAverages(IReadOnlyList<GradeAnalyticsController.TeachingClassComparison> rows)
|
||||||
|
{
|
||||||
|
var visible = rows.Take(8).ToArray();
|
||||||
|
var height = Math.Max(250, 120 + visible.Length * 62);
|
||||||
|
return DrawChart(1200, height, (canvas, typeface) =>
|
||||||
|
{
|
||||||
|
var top = 55f;
|
||||||
|
var rowHeight = 62f;
|
||||||
|
DrawText(canvas, typeface, "0", 280, 40, 20, Muted, SKTextAlign.Center);
|
||||||
|
DrawText(canvas, typeface, "50", 700, 40, 20, Muted, SKTextAlign.Center);
|
||||||
|
DrawText(canvas, typeface, "100", 1120, 40, 20, Muted, SKTextAlign.Center);
|
||||||
|
for (var i = 0; i < visible.Length; i++)
|
||||||
|
{
|
||||||
|
var y = top + i * rowHeight;
|
||||||
|
var value = Math.Clamp((float)visible[i].AverageScore, 0, 100);
|
||||||
|
DrawText(canvas, typeface, visible[i].TaskNumber, 245, y + 28, 21, visible[i].IsSelected ? Blue : Ink, SKTextAlign.Right, visible[i].IsSelected);
|
||||||
|
using var track = new SKPaint { Color = new SKColor(235, 239, 244) };
|
||||||
|
using var fill = new SKPaint { Color = visible[i].IsSelected ? new SKColor(46, 116, 181) : new SKColor(155, 177, 202) };
|
||||||
|
canvas.DrawRoundRect(new SKRect(280, y, 1120, y + 34), 6, 6, track);
|
||||||
|
canvas.DrawRoundRect(new SKRect(280, y, 280 + value / 100 * 840, y + 34), 6, 6, fill);
|
||||||
|
DrawText(canvas, typeface, value.ToString("0.0"), 290 + value / 100 * 840, y + 27, 20, Ink, SKTextAlign.Left, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DrawHistory(IReadOnlyList<GradeAnalyticsController.HistoricalComparison> rows)
|
||||||
|
{
|
||||||
|
return DrawChart(1200, 570, (canvas, typeface) =>
|
||||||
|
{
|
||||||
|
DrawAxes(canvas, typeface, "平均分", 70, 35, 1080, 430);
|
||||||
|
if (rows.Count == 0) return;
|
||||||
|
var min = 0f;
|
||||||
|
var max = 100f;
|
||||||
|
var points = rows.Select((x, i) => new SKPoint(
|
||||||
|
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
|
||||||
|
430 - ((float)x.CourseAverageScore - min) / (max - min) * 350)).ToArray();
|
||||||
|
DrawLineSeries(canvas, typeface, points, rows.Select(x => x.CourseAverageScore).ToArray(), new SKColor(46, 116, 181));
|
||||||
|
var teacher = rows.Select((x, i) => x.Instructor is null ? (SKPoint?)null : new SKPoint(
|
||||||
|
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
|
||||||
|
430 - ((float)x.Instructor.AverageScore - min) / (max - min) * 350)).ToArray();
|
||||||
|
DrawOptionalLineSeries(canvas, typeface, teacher, rows.Select(x => x.Instructor?.AverageScore).ToArray(), new SKColor(211, 133, 45));
|
||||||
|
for (var i = 0; i < rows.Count; i++)
|
||||||
|
DrawText(canvas, typeface, rows[i].TermName, points[i].X, 480, 20, Muted, SKTextAlign.Center);
|
||||||
|
using var blue = new SKPaint { Color = new SKColor(46, 116, 181), StrokeWidth = 4 };
|
||||||
|
using var gold = new SKPaint { Color = new SKColor(211, 133, 45), StrokeWidth = 4 };
|
||||||
|
canvas.DrawLine(760, 520, 805, 520, blue);
|
||||||
|
canvas.DrawLine(940, 520, 985, 520, gold);
|
||||||
|
DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink);
|
||||||
|
DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DrawChart(int width, int height, Action<SKCanvas, SKTypeface> draw)
|
||||||
|
{
|
||||||
|
using var bitmap = new SKBitmap(width, height);
|
||||||
|
using var canvas = new SKCanvas(bitmap);
|
||||||
|
canvas.Clear(SKColors.White);
|
||||||
|
using var typeface = SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.Default;
|
||||||
|
draw(canvas, typeface);
|
||||||
|
using var image = SKImage.FromBitmap(bitmap);
|
||||||
|
using var data = image.Encode(SKEncodedImageFormat.Png, 92);
|
||||||
|
return data.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawAxes(SKCanvas canvas, SKTypeface typeface, string label, float left, float top, float right, float bottom)
|
||||||
|
{
|
||||||
|
using var axis = new SKPaint { Color = new SKColor(190, 198, 207), StrokeWidth = 2 };
|
||||||
|
canvas.DrawLine(left, bottom, right, bottom, axis);
|
||||||
|
canvas.DrawLine(left, top, left, bottom, axis);
|
||||||
|
DrawText(canvas, typeface, label, left, 25, 21, Muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint[] points, decimal[] values, SKColor color)
|
||||||
|
{
|
||||||
|
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
||||||
|
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
||||||
|
using var path = new SKPath();
|
||||||
|
path.MoveTo(points[0]);
|
||||||
|
foreach (var point in points.Skip(1)) path.LineTo(point);
|
||||||
|
canvas.DrawPath(path, paint);
|
||||||
|
for (var i = 0; i < points.Length; i++)
|
||||||
|
{
|
||||||
|
canvas.DrawCircle(points[i], 7, fill);
|
||||||
|
DrawText(canvas, typeface, values[i].ToString("0.0"), points[i].X, points[i].Y - 14, 19, Ink, SKTextAlign.Center, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawOptionalLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint?[] points, decimal?[] values, SKColor color)
|
||||||
|
{
|
||||||
|
var available = points.Select((point, index) => (point, index)).Where(x => x.point.HasValue).ToArray();
|
||||||
|
if (available.Length == 0) return;
|
||||||
|
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
||||||
|
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
||||||
|
for (var i = 1; i < available.Length; i++) canvas.DrawLine(available[i - 1].point!.Value, available[i].point!.Value, paint);
|
||||||
|
foreach (var item in available)
|
||||||
|
{
|
||||||
|
var point = item.point!.Value;
|
||||||
|
canvas.DrawCircle(point, 7, fill);
|
||||||
|
DrawText(canvas, typeface, values[item.index]!.Value.ToString("0.0"), point.X, point.Y + 28, 19, Ink, SKTextAlign.Center, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, float size, string color, SKTextAlign align = SKTextAlign.Left, bool bold = false)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size) { Embolden = bold };
|
||||||
|
using var paint = new SKPaint { Color = SKColor.Parse(color), IsAntialias = true };
|
||||||
|
canvas.DrawText(text, x, y, align, font, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Score(decimal value) => value.ToString("0.0");
|
||||||
|
private static string Rate(decimal value) => $"{value:0.0}%";
|
||||||
|
private static string Percent(int value, int total) => total == 0 ? "0.0%" : $"{(decimal)value / total * 100m:0.0}%";
|
||||||
|
|
||||||
|
private sealed record HeaderFooterIds(
|
||||||
|
string DefaultHeader,
|
||||||
|
string EvenHeader,
|
||||||
|
string DefaultFooter,
|
||||||
|
string EvenFooter);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Graduation;
|
||||||
|
|
||||||
|
public sealed record AcademicPlanningCourseSnapshot(
|
||||||
|
Guid CourseId,
|
||||||
|
string CourseName,
|
||||||
|
decimal Credits,
|
||||||
|
int RecommendedSemester,
|
||||||
|
CurriculumCourseType Type,
|
||||||
|
IReadOnlyCollection<Guid> PrerequisiteCourseIds);
|
||||||
|
|
||||||
|
public sealed record AcademicPlanningPrerequisiteIssue(
|
||||||
|
Guid CourseId,
|
||||||
|
Guid PrerequisiteCourseId,
|
||||||
|
int PlannedSemester,
|
||||||
|
int? PrerequisitePlannedSemester);
|
||||||
|
|
||||||
|
public static class AcademicPlanningRules
|
||||||
|
{
|
||||||
|
public const decimal RecommendedSemesterCreditLimit = 24;
|
||||||
|
public const decimal HeavySemesterCreditLimit = 30;
|
||||||
|
|
||||||
|
public static IReadOnlyList<AcademicPlanningPrerequisiteIssue>
|
||||||
|
FindPrerequisiteIssues(
|
||||||
|
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||||
|
IReadOnlySet<Guid> completedCourseIds,
|
||||||
|
IReadOnlySet<Guid> inProgressCourseIds,
|
||||||
|
IReadOnlyDictionary<Guid, int> plannedSemesters)
|
||||||
|
{
|
||||||
|
var issues = new List<AcademicPlanningPrerequisiteIssue>();
|
||||||
|
foreach (var course in courses.Where(x =>
|
||||||
|
plannedSemesters.ContainsKey(x.CourseId)))
|
||||||
|
{
|
||||||
|
var plannedSemester = plannedSemesters[course.CourseId];
|
||||||
|
foreach (var prerequisiteId in course.PrerequisiteCourseIds)
|
||||||
|
{
|
||||||
|
if (completedCourseIds.Contains(prerequisiteId) ||
|
||||||
|
inProgressCourseIds.Contains(prerequisiteId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!plannedSemesters.TryGetValue(
|
||||||
|
prerequisiteId,
|
||||||
|
out var prerequisiteSemester) ||
|
||||||
|
prerequisiteSemester >= plannedSemester)
|
||||||
|
{
|
||||||
|
issues.Add(new AcademicPlanningPrerequisiteIssue(
|
||||||
|
course.CourseId,
|
||||||
|
prerequisiteId,
|
||||||
|
plannedSemester,
|
||||||
|
plannedSemesters.GetValueOrDefault(prerequisiteId)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<Guid> SuggestNextSemester(
|
||||||
|
IEnumerable<AcademicPlanningCourseSnapshot> courses,
|
||||||
|
IReadOnlySet<Guid> completedCourseIds,
|
||||||
|
IReadOnlySet<Guid> inProgressCourseIds,
|
||||||
|
int nextSemester,
|
||||||
|
decimal targetCredits = RecommendedSemesterCreditLimit)
|
||||||
|
{
|
||||||
|
var available = courses
|
||||||
|
.Where(x =>
|
||||||
|
!completedCourseIds.Contains(x.CourseId) &&
|
||||||
|
!inProgressCourseIds.Contains(x.CourseId) &&
|
||||||
|
x.RecommendedSemester <= nextSemester &&
|
||||||
|
x.PrerequisiteCourseIds.All(prerequisiteId =>
|
||||||
|
completedCourseIds.Contains(prerequisiteId) ||
|
||||||
|
inProgressCourseIds.Contains(prerequisiteId)))
|
||||||
|
.OrderBy(x => x.Type == CurriculumCourseType.Required ? 0 : 1)
|
||||||
|
.ThenBy(x => x.RecommendedSemester > nextSemester ? 1 : 0)
|
||||||
|
.ThenBy(x => x.RecommendedSemester)
|
||||||
|
.ThenBy(x => x.CourseName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var selected = new List<Guid>();
|
||||||
|
decimal credits = 0;
|
||||||
|
foreach (var course in available)
|
||||||
|
{
|
||||||
|
var isOverdueRequired =
|
||||||
|
course.Type == CurriculumCourseType.Required &&
|
||||||
|
course.RecommendedSemester <= nextSemester;
|
||||||
|
if (!isOverdueRequired &&
|
||||||
|
selected.Count > 0 &&
|
||||||
|
credits + course.Credits > targetCredits)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
selected.Add(course.CourseId);
|
||||||
|
credits += course.Credits;
|
||||||
|
if (credits >= targetCredits) break;
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int EstimateCompletionSemester(
|
||||||
|
int nextSemester,
|
||||||
|
int latestPlannedSemester,
|
||||||
|
decimal remainingCredits,
|
||||||
|
int remainingRequirementCount)
|
||||||
|
{
|
||||||
|
if (remainingCredits <= 0 && remainingRequirementCount <= 0)
|
||||||
|
return Math.Max(nextSemester - 1, latestPlannedSemester);
|
||||||
|
|
||||||
|
var byCredits = (int)Math.Ceiling(
|
||||||
|
Math.Max(remainingCredits, 0) / RecommendedSemesterCreditLimit);
|
||||||
|
var byRequirements = (int)Math.Ceiling(
|
||||||
|
Math.Max(remainingRequirementCount, 0) / 6m);
|
||||||
|
var additionalSemesters = Math.Max(1, Math.Max(byCredits, byRequirements));
|
||||||
|
return Math.Max(nextSemester - 1, latestPlannedSemester) +
|
||||||
|
additionalSemesters;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||||
|
ObservabilityOptions options,
|
||||||
|
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||||
|
: DbCommandInterceptor
|
||||||
|
{
|
||||||
|
public const string ActivitySourceName = "Jiaowu.Api.Database";
|
||||||
|
public const string MeterName = "Jiaowu.Api.Database";
|
||||||
|
|
||||||
|
private static readonly ActivitySource ActivitySource =
|
||||||
|
new(ActivitySourceName);
|
||||||
|
private static readonly Meter Meter = new(MeterName);
|
||||||
|
private static readonly Histogram<double> CommandDuration =
|
||||||
|
Meter.CreateHistogram<double>(
|
||||||
|
"jiaowu.db.command.duration",
|
||||||
|
"ms",
|
||||||
|
"EF Core database command duration");
|
||||||
|
private static readonly Counter<long> SlowCommandCount =
|
||||||
|
Meter.CreateCounter<long>(
|
||||||
|
"jiaowu.db.command.slow",
|
||||||
|
"{command}",
|
||||||
|
"EF Core commands exceeding the configured slow-query threshold");
|
||||||
|
private static readonly Counter<long> FailedCommandCount =
|
||||||
|
Meter.CreateCounter<long>(
|
||||||
|
"jiaowu.db.command.failed",
|
||||||
|
"{command}",
|
||||||
|
"Failed EF Core database commands");
|
||||||
|
|
||||||
|
public override DbDataReader ReaderExecuted(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
DbDataReader result)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "reader");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<DbDataReader> ReaderExecutedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
DbDataReader result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "reader");
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int NonQueryExecuted(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
int result)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "nonquery");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<int> NonQueryExecutedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
int result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "nonquery");
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object? ScalarExecuted(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
object? result)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "scalar");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<object?> ScalarExecutedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
object? result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "scalar");
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void CommandFailed(
|
||||||
|
DbCommand command,
|
||||||
|
CommandErrorEventData eventData) =>
|
||||||
|
Observe(
|
||||||
|
command,
|
||||||
|
eventData.Duration,
|
||||||
|
"failed",
|
||||||
|
eventData.Exception.GetType().Name);
|
||||||
|
|
||||||
|
public override Task CommandFailedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandErrorEventData eventData,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(
|
||||||
|
command,
|
||||||
|
eventData.Duration,
|
||||||
|
"failed",
|
||||||
|
eventData.Exception.GetType().Name);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void CommandCanceled(
|
||||||
|
DbCommand command,
|
||||||
|
CommandEndEventData eventData) =>
|
||||||
|
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||||
|
|
||||||
|
public override Task CommandCanceledAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandEndEventData eventData,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Observe(
|
||||||
|
DbCommand command,
|
||||||
|
TimeSpan duration,
|
||||||
|
string commandKind,
|
||||||
|
string? errorType = null)
|
||||||
|
{
|
||||||
|
if (!options.Enabled) return;
|
||||||
|
|
||||||
|
var queryName = GetQueryName(command.CommandText);
|
||||||
|
var statementHash = GetStatementHash(command.CommandText);
|
||||||
|
var provider = GetProviderName(command);
|
||||||
|
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
||||||
|
var tags = new TagList
|
||||||
|
{
|
||||||
|
{ "db.system.name", provider },
|
||||||
|
{ "db.operation.name", commandKind },
|
||||||
|
{ "db.query.name", queryName }
|
||||||
|
};
|
||||||
|
if (errorType is not null)
|
||||||
|
tags.Add("error.type", errorType);
|
||||||
|
|
||||||
|
var durationMilliseconds = duration.TotalMilliseconds;
|
||||||
|
CommandDuration.Record(durationMilliseconds, tags);
|
||||||
|
if (errorType is not null)
|
||||||
|
FailedCommandCount.Add(1, tags);
|
||||||
|
|
||||||
|
using var activity = ActivitySource.StartActivity(
|
||||||
|
ActivityKind.Client,
|
||||||
|
Activity.Current?.Context ?? default,
|
||||||
|
startTime: DateTimeOffset.UtcNow - duration,
|
||||||
|
name: queryName);
|
||||||
|
if (activity is not null)
|
||||||
|
{
|
||||||
|
activity.SetTag("db.system.name", provider);
|
||||||
|
activity.SetTag("db.operation.name", commandKind);
|
||||||
|
activity.SetTag("db.query.name", queryName);
|
||||||
|
activity.SetTag("db.statement.hash", statementHash);
|
||||||
|
activity.SetTag(
|
||||||
|
"db.namespace",
|
||||||
|
EmptyToNull(command.Connection?.Database));
|
||||||
|
if (options.IncludeSqlText)
|
||||||
|
{
|
||||||
|
activity.SetTag(
|
||||||
|
"db.query.text",
|
||||||
|
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||||
|
}
|
||||||
|
if (errorType is not null)
|
||||||
|
{
|
||||||
|
activity.SetTag("error.type", errorType);
|
||||||
|
activity.SetStatus(ActivityStatusCode.Error, errorType);
|
||||||
|
}
|
||||||
|
activity.SetEndTime(DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorType is not null)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
"Database command failed after {DurationMs:F1} ms: " +
|
||||||
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
|
"error {ErrorType}, trace {TraceId}).",
|
||||||
|
durationMilliseconds,
|
||||||
|
queryName,
|
||||||
|
commandKind,
|
||||||
|
provider,
|
||||||
|
statementHash,
|
||||||
|
errorType,
|
||||||
|
traceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SlowCommandCount.Add(1, tags);
|
||||||
|
if (options.IncludeSqlText)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
|
"trace {TraceId}). " +
|
||||||
|
"SQL template: {SqlTemplate}",
|
||||||
|
durationMilliseconds,
|
||||||
|
queryName,
|
||||||
|
commandKind,
|
||||||
|
provider,
|
||||||
|
statementHash,
|
||||||
|
traceId,
|
||||||
|
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
|
"trace {TraceId}).",
|
||||||
|
durationMilliseconds,
|
||||||
|
queryName,
|
||||||
|
commandKind,
|
||||||
|
provider,
|
||||||
|
statementHash,
|
||||||
|
traceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetQueryName(string commandText)
|
||||||
|
{
|
||||||
|
using var reader = new StringReader(commandText);
|
||||||
|
while (reader.ReadLine() is { } line)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.Length == 0) continue;
|
||||||
|
if (trimmed.StartsWith("-- ", StringComparison.Ordinal))
|
||||||
|
return Truncate(trimmed[3..].Trim(), 120);
|
||||||
|
return $"{FirstToken(trimmed)}:{GetStatementHash(commandText)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"unknown:{GetStatementHash(commandText)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetStatementHash(string commandText)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandText));
|
||||||
|
return Convert.ToHexString(bytes.AsSpan(0, 6)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstToken(string value)
|
||||||
|
{
|
||||||
|
var end = value.IndexOfAny([' ', '\t', '\r', '\n', '(']);
|
||||||
|
var token = end < 0 ? value : value[..end];
|
||||||
|
return token.Length == 0
|
||||||
|
? "command"
|
||||||
|
: token.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetProviderName(DbCommand command)
|
||||||
|
{
|
||||||
|
var typeName = command.GetType().FullName ?? command.GetType().Name;
|
||||||
|
if (typeName.Contains("MySql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "mysql";
|
||||||
|
if (typeName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "sqlite";
|
||||||
|
return "other_sql";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? EmptyToNull(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
private static string Truncate(string value, int maximumLength) =>
|
||||||
|
value.Length <= maximumLength
|
||||||
|
? value
|
||||||
|
: value[..maximumLength];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed class ObservabilityOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Observability";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public string ServiceName { get; set; } = "jiaowu-api";
|
||||||
|
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
||||||
|
public bool IncludeSqlText { get; set; }
|
||||||
|
public int MaximumSqlTextLength { get; set; } = 2000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed class PerformanceReportService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IMemoryCache cache,
|
||||||
|
PerformanceReportingOptions options,
|
||||||
|
ObservabilityOptions observability,
|
||||||
|
ILogger<PerformanceReportService> logger)
|
||||||
|
{
|
||||||
|
public async Task<PerformanceReport> GetAsync(
|
||||||
|
string? range,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rangeSpec = PerformanceRange.TryParse(range);
|
||||||
|
if (rangeSpec is null)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(range),
|
||||||
|
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||||
|
|
||||||
|
if (!options.Enabled ||
|
||||||
|
string.IsNullOrWhiteSpace(options.PrometheusBaseUrl))
|
||||||
|
{
|
||||||
|
return PerformanceReport.NotConfigured(
|
||||||
|
rangeSpec.Key,
|
||||||
|
options.GrafanaBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = $"performance-report:{rangeSpec.Key}";
|
||||||
|
if (cache.TryGetValue<PerformanceReport>(cacheKey, out var cached))
|
||||||
|
return cached!;
|
||||||
|
|
||||||
|
PerformanceReport report;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
report = await LoadAsync(rangeSpec, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Performance report source is unavailable for range {Range}.",
|
||||||
|
rangeSpec.Key);
|
||||||
|
report = PerformanceReport.Unavailable(
|
||||||
|
rangeSpec.Key,
|
||||||
|
options.GrafanaBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Set(
|
||||||
|
cacheKey,
|
||||||
|
report,
|
||||||
|
TimeSpan.FromSeconds(options.CacheSeconds));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PerformanceReport> LoadAsync(
|
||||||
|
PerformanceRange range,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var from = now - range.Duration;
|
||||||
|
var requestCountSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var requestBucketSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_bucket",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var errorCountSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName),
|
||||||
|
("http_response_status_code", "=~", "5.."));
|
||||||
|
var databaseCountSelector = Selector(
|
||||||
|
options.DatabaseDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var databaseBucketSelector = Selector(
|
||||||
|
options.DatabaseDurationMetric + "_bucket",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var slowDatabaseSelector = Selector(
|
||||||
|
options.SlowDatabaseMetric,
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var failedDatabaseSelector = Selector(
|
||||||
|
options.FailedDatabaseMetric,
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
|
||||||
|
var requestCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({requestCountSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var serverErrorCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({errorCountSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var requestP95Task = QueryScalarAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||||
|
"* 1000",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var databaseP95Task = QueryScalarAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({databaseBucketSelector}[{range.RateWindow}])))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var slowCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({slowDatabaseSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var failedCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({failedDatabaseSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var requestTimelineTask = QueryRangeAsync(
|
||||||
|
$"sum(rate({requestCountSelector}[{range.RateWindow}]))",
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
range.StepSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
var latencyTimelineTask = QueryRangeAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||||
|
"* 1000",
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
range.StepSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
var routeLatencyTask = QueryVectorAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le, http_route) (rate({requestBucketSelector}" +
|
||||||
|
$"[{range.RateWindow}]))) * 1000",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var routeCountTask = QueryVectorAsync(
|
||||||
|
$"sum by (http_route) (increase({requestCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var routeErrorTask = QueryVectorAsync(
|
||||||
|
$"sum by (http_route) (increase({errorCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var queryLatencyTask = QueryVectorAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le, db_query_name) (rate({databaseBucketSelector}" +
|
||||||
|
$"[{range.RateWindow}])))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var queryCountTask = QueryVectorAsync(
|
||||||
|
$"sum by (db_query_name) (increase({databaseCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var querySlowTask = QueryVectorAsync(
|
||||||
|
$"sum by (db_query_name) (increase({slowDatabaseSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
requestCountTask,
|
||||||
|
serverErrorCountTask,
|
||||||
|
requestP95Task,
|
||||||
|
databaseP95Task,
|
||||||
|
slowCountTask,
|
||||||
|
failedCountTask,
|
||||||
|
requestTimelineTask,
|
||||||
|
latencyTimelineTask,
|
||||||
|
routeLatencyTask,
|
||||||
|
routeCountTask,
|
||||||
|
routeErrorTask,
|
||||||
|
queryLatencyTask,
|
||||||
|
queryCountTask,
|
||||||
|
querySlowTask);
|
||||||
|
|
||||||
|
var requestCount = await requestCountTask;
|
||||||
|
var serverErrorCount = await serverErrorCountTask;
|
||||||
|
double? errorRate = requestCount is > 0 && serverErrorCount.HasValue
|
||||||
|
? serverErrorCount.Value / requestCount.Value * 100
|
||||||
|
: requestCount == 0
|
||||||
|
? 0
|
||||||
|
: null;
|
||||||
|
var timeline = MergeTimeline(
|
||||||
|
await requestTimelineTask,
|
||||||
|
await latencyTimelineTask);
|
||||||
|
var endpoints = MergeRanking(
|
||||||
|
await routeLatencyTask,
|
||||||
|
await routeCountTask,
|
||||||
|
await routeErrorTask,
|
||||||
|
"http_route");
|
||||||
|
var databaseQueries = MergeRanking(
|
||||||
|
await queryLatencyTask,
|
||||||
|
await queryCountTask,
|
||||||
|
await querySlowTask,
|
||||||
|
"db_query_name");
|
||||||
|
|
||||||
|
return new PerformanceReport(
|
||||||
|
"ready",
|
||||||
|
range.Key,
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
"prometheus",
|
||||||
|
EmptyToNull(options.GrafanaBaseUrl),
|
||||||
|
null,
|
||||||
|
new PerformanceHeadline(
|
||||||
|
Round(requestCount),
|
||||||
|
Round(await requestP95Task),
|
||||||
|
Round(errorRate),
|
||||||
|
Round(await databaseP95Task),
|
||||||
|
Round(await slowCountTask),
|
||||||
|
Round(await failedCountTask)),
|
||||||
|
timeline,
|
||||||
|
endpoints,
|
||||||
|
databaseQueries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<double?> QueryScalarAsync(
|
||||||
|
string query,
|
||||||
|
DateTime time,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var vector = await QueryVectorAsync(
|
||||||
|
query,
|
||||||
|
time,
|
||||||
|
cancellationToken);
|
||||||
|
return vector.FirstOrDefault()?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<PrometheusSample>> QueryVectorAsync(
|
||||||
|
string query,
|
||||||
|
DateTime time,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uri = BuildUri(
|
||||||
|
"api/v1/query",
|
||||||
|
("query", query),
|
||||||
|
("time", ToUnixSeconds(time).ToString(
|
||||||
|
CultureInfo.InvariantCulture)));
|
||||||
|
using var document = await SendAsync(uri, cancellationToken);
|
||||||
|
var data = document.RootElement.GetProperty("data");
|
||||||
|
var result = data.GetProperty("result");
|
||||||
|
var samples = new List<PrometheusSample>();
|
||||||
|
foreach (var item in result.EnumerateArray())
|
||||||
|
{
|
||||||
|
var labels = ReadLabels(item.GetProperty("metric"));
|
||||||
|
if (!TryReadValue(item.GetProperty("value"), out var value))
|
||||||
|
continue;
|
||||||
|
samples.Add(new PrometheusSample(labels, value));
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<PerformanceSeriesPoint>> QueryRangeAsync(
|
||||||
|
string query,
|
||||||
|
DateTime from,
|
||||||
|
DateTime to,
|
||||||
|
int stepSeconds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uri = BuildUri(
|
||||||
|
"api/v1/query_range",
|
||||||
|
("query", query),
|
||||||
|
("start", ToUnixSeconds(from).ToString(
|
||||||
|
CultureInfo.InvariantCulture)),
|
||||||
|
("end", ToUnixSeconds(to).ToString(
|
||||||
|
CultureInfo.InvariantCulture)),
|
||||||
|
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)));
|
||||||
|
using var document = await SendAsync(uri, cancellationToken);
|
||||||
|
var result = document.RootElement
|
||||||
|
.GetProperty("data")
|
||||||
|
.GetProperty("result");
|
||||||
|
var first = result.EnumerateArray().FirstOrDefault();
|
||||||
|
if (first.ValueKind == JsonValueKind.Undefined ||
|
||||||
|
!first.TryGetProperty("values", out var values))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var points = new List<PerformanceSeriesPoint>();
|
||||||
|
foreach (var value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!TryReadValue(value, out var measurement)) continue;
|
||||||
|
var timestamp = value[0].GetDouble();
|
||||||
|
points.Add(new PerformanceSeriesPoint(
|
||||||
|
DateTimeOffset.FromUnixTimeMilliseconds(
|
||||||
|
checked((long)(timestamp * 1000))).UtcDateTime,
|
||||||
|
measurement));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonDocument> SendAsync(
|
||||||
|
Uri uri,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.BearerToken))
|
||||||
|
{
|
||||||
|
request.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", options.BearerToken);
|
||||||
|
}
|
||||||
|
using var response = await httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(
|
||||||
|
cancellationToken);
|
||||||
|
var document = await JsonDocument.ParseAsync(
|
||||||
|
stream,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!document.RootElement.TryGetProperty("status", out var status) ||
|
||||||
|
status.GetString() != "success")
|
||||||
|
{
|
||||||
|
document.Dispose();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Prometheus 返回了非成功查询状态。");
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri BuildUri(
|
||||||
|
string relativePath,
|
||||||
|
params (string Key, string Value)[] parameters)
|
||||||
|
{
|
||||||
|
var baseUri = new Uri(
|
||||||
|
options.PrometheusBaseUrl.TrimEnd('/') + "/",
|
||||||
|
UriKind.Absolute);
|
||||||
|
var query = string.Join(
|
||||||
|
"&",
|
||||||
|
parameters.Select(parameter =>
|
||||||
|
$"{Uri.EscapeDataString(parameter.Key)}=" +
|
||||||
|
$"{Uri.EscapeDataString(parameter.Value)}"));
|
||||||
|
return new Uri(baseUri, $"{relativePath}?{query}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Selector(
|
||||||
|
string metric,
|
||||||
|
params (string Label, string Operator, string Value)[] filters)
|
||||||
|
{
|
||||||
|
var matchers = string.Join(
|
||||||
|
",",
|
||||||
|
filters.Select(filter =>
|
||||||
|
$"{filter.Label}{filter.Operator}\"" +
|
||||||
|
$"{EscapePrometheusValue(filter.Value)}\""));
|
||||||
|
return $"{metric}{{{matchers}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapePrometheusValue(string value) =>
|
||||||
|
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||||
|
.Replace("\r", "\\r", StringComparison.Ordinal)
|
||||||
|
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> ReadLabels(
|
||||||
|
JsonElement metric)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
foreach (var property in metric.EnumerateObject())
|
||||||
|
result[property.Name] = property.Value.GetString() ?? "";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadValue(
|
||||||
|
JsonElement value,
|
||||||
|
out double measurement)
|
||||||
|
{
|
||||||
|
measurement = 0;
|
||||||
|
if (value.ValueKind != JsonValueKind.Array ||
|
||||||
|
value.GetArrayLength() < 2)
|
||||||
|
return false;
|
||||||
|
var raw = value[1].GetString();
|
||||||
|
return double.TryParse(
|
||||||
|
raw,
|
||||||
|
NumberStyles.Float,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out measurement) &&
|
||||||
|
double.IsFinite(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PerformanceTimelinePoint> MergeTimeline(
|
||||||
|
IReadOnlyList<PerformanceSeriesPoint> requestRate,
|
||||||
|
IReadOnlyList<PerformanceSeriesPoint> latency)
|
||||||
|
{
|
||||||
|
var points = new SortedDictionary<DateTime, PerformanceTimelinePoint>();
|
||||||
|
foreach (var point in requestRate)
|
||||||
|
{
|
||||||
|
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||||
|
point.Timestamp,
|
||||||
|
Math.Round(point.Value, 3),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
foreach (var point in latency)
|
||||||
|
{
|
||||||
|
points.TryGetValue(point.Timestamp, out var existing);
|
||||||
|
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||||
|
point.Timestamp,
|
||||||
|
existing?.RequestsPerSecond,
|
||||||
|
Math.Round(point.Value, 2));
|
||||||
|
}
|
||||||
|
return points.Values.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PerformanceRankingItem> MergeRanking(
|
||||||
|
IReadOnlyList<PrometheusSample> latency,
|
||||||
|
IReadOnlyList<PrometheusSample> count,
|
||||||
|
IReadOnlyList<PrometheusSample> exceptional,
|
||||||
|
string label)
|
||||||
|
{
|
||||||
|
var names = latency
|
||||||
|
.Concat(count)
|
||||||
|
.Concat(exceptional)
|
||||||
|
.Select(item => item.Labels.GetValueOrDefault(label))
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
var items = names.Select(name =>
|
||||||
|
{
|
||||||
|
var latencyValue = FindValue(latency, label, name);
|
||||||
|
var countValue = FindValue(count, label, name);
|
||||||
|
var exceptionalValue = FindValue(exceptional, label, name);
|
||||||
|
return new PerformanceRankingItem(
|
||||||
|
name!,
|
||||||
|
Round(latencyValue),
|
||||||
|
Round(countValue),
|
||||||
|
Round(exceptionalValue));
|
||||||
|
});
|
||||||
|
return items
|
||||||
|
.OrderByDescending(item => item.P95Milliseconds ?? -1)
|
||||||
|
.ThenByDescending(item => item.RequestCount ?? -1)
|
||||||
|
.Take(10)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? FindValue(
|
||||||
|
IReadOnlyList<PrometheusSample> samples,
|
||||||
|
string label,
|
||||||
|
string? name) =>
|
||||||
|
samples.FirstOrDefault(item =>
|
||||||
|
item.Labels.GetValueOrDefault(label) == name)?.Value;
|
||||||
|
|
||||||
|
private static double ToUnixSeconds(DateTime value) =>
|
||||||
|
new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(value, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
|
||||||
|
/ 1000d;
|
||||||
|
|
||||||
|
private static double? Round(double? value) =>
|
||||||
|
value.HasValue && double.IsFinite(value.Value)
|
||||||
|
? Math.Round(value.Value, 2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? EmptyToNull(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
private sealed record PrometheusSample(
|
||||||
|
IReadOnlyDictionary<string, string> Labels,
|
||||||
|
double Value);
|
||||||
|
|
||||||
|
private sealed record PerformanceSeriesPoint(
|
||||||
|
DateTime Timestamp,
|
||||||
|
double Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PerformanceHeadline(
|
||||||
|
double? RequestCount,
|
||||||
|
double? RequestP95Milliseconds,
|
||||||
|
double? ServerErrorRatePercent,
|
||||||
|
double? DatabaseP95Milliseconds,
|
||||||
|
double? SlowDatabaseCommandCount,
|
||||||
|
double? FailedDatabaseCommandCount);
|
||||||
|
|
||||||
|
public sealed record PerformanceTimelinePoint(
|
||||||
|
DateTime Timestamp,
|
||||||
|
double? RequestsPerSecond,
|
||||||
|
double? RequestP95Milliseconds);
|
||||||
|
|
||||||
|
public sealed record PerformanceRankingItem(
|
||||||
|
string Name,
|
||||||
|
double? P95Milliseconds,
|
||||||
|
double? RequestCount,
|
||||||
|
double? ExceptionalCount);
|
||||||
|
|
||||||
|
public sealed record PerformanceReport(
|
||||||
|
string Status,
|
||||||
|
string Range,
|
||||||
|
DateTime? From,
|
||||||
|
DateTime? To,
|
||||||
|
DateTime GeneratedAt,
|
||||||
|
string DataSource,
|
||||||
|
string? DashboardUrl,
|
||||||
|
string? Detail,
|
||||||
|
PerformanceHeadline? Headline,
|
||||||
|
IReadOnlyList<PerformanceTimelinePoint> Timeline,
|
||||||
|
IReadOnlyList<PerformanceRankingItem> Endpoints,
|
||||||
|
IReadOnlyList<PerformanceRankingItem> DatabaseQueries)
|
||||||
|
{
|
||||||
|
public static PerformanceReport NotConfigured(
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl) =>
|
||||||
|
Empty(
|
||||||
|
"not_configured",
|
||||||
|
range,
|
||||||
|
dashboardUrl,
|
||||||
|
"尚未配置 Prometheus 数据源。请先部署指标存储并设置 " +
|
||||||
|
"PerformanceReporting__PrometheusBaseUrl。");
|
||||||
|
|
||||||
|
public static PerformanceReport Unavailable(
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl) =>
|
||||||
|
Empty(
|
||||||
|
"unavailable",
|
||||||
|
range,
|
||||||
|
dashboardUrl,
|
||||||
|
"性能数据源暂时不可用。系统业务不受影响,请检查 Prometheus 与网络配置。");
|
||||||
|
|
||||||
|
private static PerformanceReport Empty(
|
||||||
|
string status,
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl,
|
||||||
|
string detail) =>
|
||||||
|
new(
|
||||||
|
status,
|
||||||
|
range,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
"prometheus",
|
||||||
|
string.IsNullOrWhiteSpace(dashboardUrl) ? null : dashboardUrl,
|
||||||
|
detail,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record PerformanceRange(
|
||||||
|
string Key,
|
||||||
|
TimeSpan Duration,
|
||||||
|
string PrometheusRange,
|
||||||
|
string RateWindow,
|
||||||
|
int StepSeconds)
|
||||||
|
{
|
||||||
|
public static PerformanceRange? TryParse(string? value) =>
|
||||||
|
value?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"15m" => new("15m", TimeSpan.FromMinutes(15), "15m", "1m", 30),
|
||||||
|
"1h" => new("1h", TimeSpan.FromHours(1), "1h", "5m", 60),
|
||||||
|
"24h" => new("24h", TimeSpan.FromHours(24), "24h", "15m", 900),
|
||||||
|
"7d" => new("7d", TimeSpan.FromDays(7), "7d", "1h", 3600),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed partial class PerformanceReportingOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PerformanceReporting";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string PrometheusBaseUrl { get; set; } = "";
|
||||||
|
public string BearerToken { get; set; } = "";
|
||||||
|
public string GrafanaBaseUrl { get; set; } = "";
|
||||||
|
public int CacheSeconds { get; set; } = 30;
|
||||||
|
public int TimeoutSeconds { get; set; } = 10;
|
||||||
|
public string ServiceLabel { get; set; } = "service_name";
|
||||||
|
public string RequestDurationMetric { get; set; } =
|
||||||
|
"http_server_request_duration_seconds";
|
||||||
|
public string DatabaseDurationMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_duration_milliseconds";
|
||||||
|
public string SlowDatabaseMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_slow_total";
|
||||||
|
public string FailedDatabaseMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_failed_total";
|
||||||
|
|
||||||
|
public static bool IsMetricOrLabelName(string value) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value) &&
|
||||||
|
PrometheusNamePattern().IsMatch(value);
|
||||||
|
|
||||||
|
[GeneratedRegex("^[a-zA-Z_:][a-zA-Z0-9_:]*$")]
|
||||||
|
private static partial Regex PrometheusNamePattern();
|
||||||
|
}
|
||||||
@@ -0,0 +1,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; }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user