Compare commits
@@ -8,9 +8,16 @@ MYSQL_USER=jiaowu
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_ROOT_PASSWORD=
|
MYSQL_ROOT_PASSWORD=
|
||||||
|
|
||||||
|
RABBITMQ_USER=jiaowu
|
||||||
|
RABBITMQ_PASSWORD=
|
||||||
|
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||||
|
BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY=1
|
||||||
|
BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY=1
|
||||||
|
|
||||||
JWT_KEY=
|
JWT_KEY=
|
||||||
ALLOWED_HOSTS=localhost
|
ALLOWED_HOSTS=localhost
|
||||||
CORS_ORIGIN=http://localhost:8080
|
CORS_ORIGIN=http://localhost:8080
|
||||||
|
OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
# 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。
|
# 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。
|
||||||
SEED_ADMIN_USERNAME=demo-admin
|
SEED_ADMIN_USERNAME=demo-admin
|
||||||
|
|||||||
@@ -6,6 +6,31 @@ 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;"
|
||||||
|
# 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
|
||||||
|
# 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
|
||||||
|
|
||||||
Jwt__Issuer=Jiaowu.Api
|
Jwt__Issuer=Jiaowu.Api
|
||||||
Jwt__Audience=Jiaowu.Web
|
Jwt__Audience=Jiaowu.Web
|
||||||
@@ -15,6 +40,14 @@ Jwt__ExpireMinutes=60
|
|||||||
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
|
||||||
|
|
||||||
|
# 二维码使用的公网根地址;反向代理部署时必须填写最终 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
|
||||||
# SeedAdmin__Password=REPLACE_WITH_A_STRONG_ADMIN_PASSWORD
|
# SeedAdmin__Password=REPLACE_WITH_A_STRONG_ADMIN_PASSWORD
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ src/Jiaowu.Api/wwwroot/
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.docker.example
|
!.env.docker.example
|
||||||
certs/
|
certs/
|
||||||
|
publish/
|
||||||
@@ -34,6 +34,7 @@ ENV ASPNETCORE_ENVIRONMENT=Production \
|
|||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=build /app/publish/ ./
|
COPY --from=build /app/publish/ ./
|
||||||
ARG UID=10001
|
ARG UID=10001
|
||||||
|
RUN apk add --no-cache font-noto-cjk
|
||||||
RUN adduser \
|
RUN adduser \
|
||||||
--disabled-password \
|
--disabled-password \
|
||||||
--gecos "" \
|
--gecos "" \
|
||||||
|
|||||||
@@ -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`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
|
||||||
|
|
||||||
@@ -219,6 +221,81 @@ 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` 中配置上述连接串即可。
|
||||||
|
|
||||||
|
### 后台任务与 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
|
||||||
|
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` 数据库迁移,再启动应用实例。
|
||||||
|
|
||||||
## 跨平台发布与 Docker
|
## 跨平台发布与 Docker
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,26 @@ 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}"
|
||||||
|
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__ExpireMinutes: "60"
|
||||||
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 +44,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 +126,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:
|
||||||
@@ -99,3 +160,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
rabbitmq-data:
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
{
|
{
|
||||||
var studentId = await GetStudentIdAsync(ct);
|
var studentId = await GetStudentIdAsync(ct);
|
||||||
if (studentId is null) return StudentNotFound();
|
if (studentId is null) return StudentNotFound();
|
||||||
|
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||||
|
studentId.Value, req.TeachingTaskId, ct))
|
||||||
|
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||||
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有免修申请在审核中。");
|
return ConflictProblem("该课程已有免修申请在审核中。");
|
||||||
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||||
@@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
|
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||||
|
sid.Value, req.TeachingTaskId, ct))
|
||||||
|
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||||
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有缓考申请在审核中。");
|
return ConflictProblem("该课程已有缓考申请在审核中。");
|
||||||
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||||
@@ -200,7 +206,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
|
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
|
||||||
.FirstAsync(ct);
|
.FirstAsync(ct);
|
||||||
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
|
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
|
||||||
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct);
|
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}",
|
||||||
|
cancellationToken: ct,
|
||||||
|
category: NotificationCategory.Grade);
|
||||||
return Created("", new { gm.Id });
|
return Created("", new { gm.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +221,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
|
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
|
||||||
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
|
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
|
await NotificationService.SendToRoleAsync(
|
||||||
|
db,
|
||||||
|
SystemRoles.AcademicAdmin,
|
||||||
|
"成绩修改待校级审核",
|
||||||
|
$"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》",
|
||||||
|
linkUrl: "/approvals",
|
||||||
|
cancellationToken: ct,
|
||||||
|
category: NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||||
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
|
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
|
var gm = await db.GradeModifications
|
||||||
|
.Include(x => x.GradeRecord)
|
||||||
|
.ThenInclude(x => x!.Student)
|
||||||
|
.Include(x => x.GradeRecord)
|
||||||
|
.ThenInclude(x => x!.GradeSheet)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (gm is null) return NotFound();
|
if (gm is null) return NotFound();
|
||||||
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
|
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
|
||||||
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
|
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
|
||||||
@@ -229,7 +251,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
|
await NotificationService.SendAsync(
|
||||||
|
db,
|
||||||
|
gm.ApplicantUserId,
|
||||||
|
"成绩修改已通过",
|
||||||
|
"您的成绩修改申请已通过三级审批并生效。",
|
||||||
|
"/approvals",
|
||||||
|
ct,
|
||||||
|
NotificationCategory.Grade);
|
||||||
|
var studentUserId = gm.GradeRecord.Student?.UserId;
|
||||||
|
if (studentUserId.HasValue &&
|
||||||
|
studentUserId.Value != gm.ApplicantUserId)
|
||||||
|
{
|
||||||
|
await NotificationService.SendAsync(
|
||||||
|
db,
|
||||||
|
studentUserId.Value,
|
||||||
|
"成绩已更新",
|
||||||
|
$"《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》成绩已由 {gm.CurrentScore} 调整为 {gm.RequestedScore}。",
|
||||||
|
"/grades",
|
||||||
|
ct,
|
||||||
|
NotificationCategory.Grade);
|
||||||
|
}
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,17 +285,87 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
|
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
|
||||||
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
|
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", cancellationToken: ct);
|
await NotificationService.SendAsync(
|
||||||
|
db,
|
||||||
|
gm.ApplicantUserId,
|
||||||
|
"成绩修改已驳回",
|
||||||
|
gm.ReviewComment ?? "审核未通过。",
|
||||||
|
"/approvals",
|
||||||
|
ct,
|
||||||
|
NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Course Substitution ═══════════════
|
// ═══════════════ Course Substitution ═══════════════
|
||||||
|
[HttpGet("substitutions/mine")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sid = await GetStudentIdAsync(ct);
|
||||||
|
if (sid is null) return StudentNotFound();
|
||||||
|
return Ok(await db.CourseSubstitutions.AsNoTracking()
|
||||||
|
.Where(x => x.StudentId == sid)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.Select(x => new StudentCourseSubstitutionRecord(
|
||||||
|
x.Id,
|
||||||
|
x.Status,
|
||||||
|
x.Reason,
|
||||||
|
x.ReviewComment,
|
||||||
|
x.SubmittedAt,
|
||||||
|
x.OriginalCourse!.Code,
|
||||||
|
x.OriginalCourse.Name,
|
||||||
|
x.SubstituteCourse!.Code,
|
||||||
|
x.SubstituteCourse.Name))
|
||||||
|
.ToListAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("substitutions")]
|
[HttpPost("substitutions")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
|
if (req.OriginalCourseId == req.SubstituteCourseId)
|
||||||
|
return ConflictProblem("被替代课程和替代课程不能相同。");
|
||||||
|
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.AnyAsync(task =>
|
||||||
|
task.CourseId == req.OriginalCourseId &&
|
||||||
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
!task.AcademicTerm!.IsArchived &&
|
||||||
|
(task.AcademicTerm.IsCurrent ||
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == task.Id &&
|
||||||
|
entry.SchedulePlan!.Status ==
|
||||||
|
SchedulePlanStatus.Published)) &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.Id == sid.Value)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == sid.Value &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||||
|
task.Id)),
|
||||||
|
ct);
|
||||||
|
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
|
||||||
|
.AnyAsync(record =>
|
||||||
|
record.StudentId == sid.Value &&
|
||||||
|
record.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
record.GradeSheet.TeachingTask!.CourseId ==
|
||||||
|
req.OriginalCourseId &&
|
||||||
|
record.TotalScore < 60,
|
||||||
|
ct);
|
||||||
|
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
|
||||||
|
if (!canReplaceOriginal)
|
||||||
|
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
|
||||||
|
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
|
||||||
|
.AnyAsync(x =>
|
||||||
|
x.StudentId == sid.Value &&
|
||||||
|
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
|
||||||
|
x.TotalScore >= 60,
|
||||||
|
ct);
|
||||||
|
if (!hasPassedSubstitute)
|
||||||
|
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
|
||||||
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有替代申请在审核中。");
|
return ConflictProblem("该课程已有替代申请在审核中。");
|
||||||
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
||||||
@@ -319,16 +431,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Student enrolled courses ═══════════════
|
// ═══════════════ Student course options ═══════════════
|
||||||
[HttpGet("my-courses")]
|
[HttpGet("my-courses")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
var courses = await db.CourseEnrollments.AsNoTracking()
|
var courses = await db.TeachingTasks.AsNoTracking()
|
||||||
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
|
.Where(task =>
|
||||||
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
!task.AcademicTerm!.IsArchived &&
|
||||||
|
(task.AcademicTerm.IsCurrent ||
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == task.Id &&
|
||||||
|
entry.SchedulePlan!.Status ==
|
||||||
|
SchedulePlanStatus.Published)) &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.Id == sid.Value)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == sid.Value &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||||
|
task.Id)))
|
||||||
|
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||||
|
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Select(x => new StudentApprovalCourseOption(
|
||||||
|
x.Id,
|
||||||
|
x.CourseId,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.Course!.Code,
|
||||||
|
x.Course.Name,
|
||||||
|
x.Course.Credits,
|
||||||
|
x.AcademicTerm!.Name,
|
||||||
|
x.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.ThenBy(item => item.Teacher!.Name)
|
||||||
|
.Select(item => item.Teacher!.Name),
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == x.Id &&
|
||||||
|
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
|
||||||
|
x.SchedulingMode))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return Ok(courses);
|
return Ok(courses);
|
||||||
}
|
}
|
||||||
@@ -341,8 +487,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
var grades = await db.GradeRecords.AsNoTracking()
|
var grades = await db.GradeRecords.AsNoTracking()
|
||||||
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
|
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||||||
.OrderBy(x => x.CourseCode)
|
.Select(x => new StudentApprovalGradeOption(
|
||||||
|
x.GradeSheet!.TeachingTask!.CourseId,
|
||||||
|
x.GradeSheet.TeachingTask.Course!.Code,
|
||||||
|
x.GradeSheet.TeachingTask.Course.Name,
|
||||||
|
x.GradeSheet.TeachingTask.Course.Credits,
|
||||||
|
x.TotalScore,
|
||||||
|
x.GradePoint,
|
||||||
|
x.ExamStatus,
|
||||||
|
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||||
|
x.GradeSheet.TeachingTask.TaskNumber,
|
||||||
|
x.GradeSheet.PublishedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return Ok(grades);
|
return Ok(grades);
|
||||||
}
|
}
|
||||||
@@ -377,12 +533,33 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||||
|
private async Task<bool> IsStudentCourseAvailableForApplicationAsync(
|
||||||
|
Guid studentId,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
|
||||||
|
task.Id == teachingTaskId &&
|
||||||
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
!task.AcademicTerm!.IsArchived &&
|
||||||
|
(task.AcademicTerm.IsCurrent ||
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == task.Id &&
|
||||||
|
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.Id == studentId)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == studentId &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||||
|
task.Id)),
|
||||||
|
ct);
|
||||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
||||||
|
|
||||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||||
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, cancellationToken: ct); }
|
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, "/approvals", ct, NotificationCategory.Approval); }
|
||||||
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct); }
|
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct, category: NotificationCategory.Grade); }
|
||||||
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
|
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||||
|
|
||||||
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
|
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
|
||||||
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
|
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
|
||||||
@@ -390,6 +567,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
||||||
|
public sealed record StudentApprovalCourseOption(
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
Guid CourseId,
|
||||||
|
string TaskNumber,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
decimal Credits,
|
||||||
|
string AcademicTermName,
|
||||||
|
IEnumerable<string> TeacherNames,
|
||||||
|
bool HasPublishedSchedule,
|
||||||
|
TeachingTaskSchedulingMode SchedulingMode);
|
||||||
|
public sealed record StudentApprovalGradeOption(
|
||||||
|
Guid CourseId,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
decimal Credits,
|
||||||
|
decimal? TotalScore,
|
||||||
|
decimal? GradePoint,
|
||||||
|
GradeExamStatus ExamStatus,
|
||||||
|
string AcademicTermName,
|
||||||
|
string TaskNumber,
|
||||||
|
DateTime? PublishedAt);
|
||||||
|
public sealed record StudentCourseSubstitutionRecord(
|
||||||
|
Guid Id,
|
||||||
|
ApprovalStatus Status,
|
||||||
|
string Reason,
|
||||||
|
string? ReviewComment,
|
||||||
|
DateTime SubmittedAt,
|
||||||
|
string OriginalCourseCode,
|
||||||
|
string OriginalCourseName,
|
||||||
|
string SubstituteCourseCode,
|
||||||
|
string SubstituteCourseName);
|
||||||
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
||||||
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
||||||
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 +49,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 =>
|
||||||
|
(student.Status == StudentStatus.Active &&
|
||||||
|
x.Classes.Any(assignment =>
|
||||||
|
assignment.AdministrativeClassId ==
|
||||||
|
student.AdministrativeClassId)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == student.Id &&
|
||||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id),
|
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 +111,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))
|
||||||
@@ -793,7 +798,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 +911,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();
|
||||||
@@ -1031,16 +1037,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()
|
||||||
|
|||||||
@@ -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
|
ITokenService tokenService,
|
||||||
|
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 result = await cache.GetOrCreateAsync(
|
||||||
|
AppCacheKeys.ActivationOptions,
|
||||||
|
async token =>
|
||||||
{
|
{
|
||||||
var colleges = await db.Colleges.AsNoTracking()
|
var colleges = await db.Colleges.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled)
|
||||||
.OrderBy(x => x.Code)
|
.OrderBy(x => x.Code)
|
||||||
.Select(x => new { x.Id, x.Code, x.Name })
|
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(token);
|
||||||
var majors = await db.Majors.AsNoTracking()
|
var majors = await db.Majors.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||||
.OrderBy(x => x.Code)
|
.OrderBy(x => x.Code)
|
||||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
.Select(x => new ActivationMajorOption(
|
||||||
.ToListAsync(cancellationToken);
|
x.Id, x.Code, x.Name, x.CollegeId))
|
||||||
|
.ToListAsync(token);
|
||||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
.Where(x =>
|
||||||
|
x.IsEnabled &&
|
||||||
|
x.Major!.IsEnabled &&
|
||||||
|
x.Major.College!.IsEnabled)
|
||||||
.OrderByDescending(x => x.Grade)
|
.OrderByDescending(x => x.Grade)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
|
.Select(x => new ActivationClassOption(
|
||||||
.ToListAsync(cancellationToken);
|
x.Id, x.Code, x.Name, x.Grade, x.MajorId))
|
||||||
return Ok(new
|
.ToListAsync(token);
|
||||||
{
|
return new ActivationOptionsResponse(
|
||||||
Colleges = colleges,
|
colleges,
|
||||||
Majors = majors,
|
majors,
|
||||||
Classes = classes,
|
classes,
|
||||||
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
|
classes.Select(x => x.Grade)
|
||||||
});
|
.Distinct()
|
||||||
|
.OrderByDescending(x => x)
|
||||||
|
.ToList());
|
||||||
|
},
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -219,3 +236,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(
|
||||||
|
AppCacheKeys.BaseData("campuses"),
|
||||||
|
token => db.Campuses.AsNoTracking()
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
.ToListAsync(cancellationToken);
|
.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)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
{
|
||||||
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
|
var result = await cache.GetOrCreateAsync(
|
||||||
CampusName = x.Campus != null ? x.Campus.Name : null,
|
AppCacheKeys.BaseData("colleges"),
|
||||||
x.IsEnabled, x.SortOrder
|
token => db.Colleges.AsNoTracking()
|
||||||
})
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
.ToListAsync(cancellationToken));
|
.Select(x => new CollegeListItem(
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.ShortName,
|
||||||
|
x.CampusId,
|
||||||
|
x.Campus != null ? x.Campus.Name : null,
|
||||||
|
x.IsEnabled,
|
||||||
|
x.SortOrder))
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("colleges")]
|
[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)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
{
|
||||||
x.Id, x.Code, x.Name, x.CollegeId,
|
var result = await cache.GetOrCreateAsync(
|
||||||
CollegeName = x.College!.Name,
|
AppCacheKeys.BaseData("majors"),
|
||||||
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
|
token => db.Majors.AsNoTracking()
|
||||||
})
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
.ToListAsync(cancellationToken));
|
.Select(x => new MajorListItem(
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.CollegeId,
|
||||||
|
x.College!.Name,
|
||||||
|
x.DegreeType,
|
||||||
|
x.SchoolingYears,
|
||||||
|
x.IsEnabled,
|
||||||
|
x.SortOrder))
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("course-categories")]
|
[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(
|
||||||
|
AppCacheKeys.BaseData("course-categories"),
|
||||||
|
token => db.CourseCategories.AsNoTracking()
|
||||||
.OrderBy(x => x.SortOrder)
|
.OrderBy(x => x.SortOrder)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
.ToListAsync(cancellationToken);
|
.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)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
{
|
||||||
x.Id, x.Code, x.Name, x.MajorId,
|
var result = await cache.GetOrCreateAsync(
|
||||||
MajorName = x.Major!.Name,
|
AppCacheKeys.BaseData("classes"),
|
||||||
CollegeName = x.Major.College!.Name,
|
token => db.AdministrativeClasses.AsNoTracking()
|
||||||
x.Grade, x.CounselorUserId, x.CounselorName,
|
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||||
x.IsEnabled, x.SortOrder
|
.Select(x => new AdministrativeClassListItem(
|
||||||
})
|
x.Id,
|
||||||
.ToListAsync(cancellationToken));
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.MajorId,
|
||||||
|
x.Major!.Name,
|
||||||
|
x.Major.College!.Name,
|
||||||
|
x.Grade,
|
||||||
|
x.CounselorUserId,
|
||||||
|
x.CounselorName,
|
||||||
|
x.IsEnabled,
|
||||||
|
x.SortOrder))
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("counselors")]
|
[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(
|
||||||
|
AppCacheKeys.BaseData("terms"),
|
||||||
|
token => db.AcademicTerms.AsNoTracking()
|
||||||
.OrderByDescending(x => x.IsCurrent)
|
.OrderByDescending(x => x.IsCurrent)
|
||||||
.ThenBy(x => x.IsArchived)
|
.ThenBy(x => x.IsArchived)
|
||||||
.ThenByDescending(x => x.StartDate)
|
.ThenByDescending(x => x.StartDate)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
[HttpPost("terms")]
|
[HttpPost("terms")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
entity.StartDate = request.StartDate;
|
entity.StartDate = request.StartDate;
|
||||||
entity.EndDate = request.EndDate;
|
entity.EndDate = request.EndDate;
|
||||||
entity.IsCurrent = request.IsCurrent;
|
entity.IsCurrent = request.IsCurrent;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
foreach (var currentTerm in currentTerms)
|
foreach (var currentTerm in currentTerms)
|
||||||
currentTerm.IsCurrent = false;
|
currentTerm.IsCurrent = false;
|
||||||
entity.IsCurrent = true;
|
entity.IsCurrent = true;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
|
|
||||||
entity.IsArchived = true;
|
entity.IsArchived = true;
|
||||||
entity.ArchivedAt = DateTime.UtcNow;
|
entity.ArchivedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
|
|
||||||
entity.IsArchived = false;
|
entity.IsArchived = false;
|
||||||
entity.ArchivedAt = null;
|
entity.ArchivedAt = null;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classrooms")]
|
[HttpGet("classrooms")]
|
||||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
|
||||||
Ok(await db.Classrooms.AsNoTracking()
|
{
|
||||||
|
var result = await cache.GetOrCreateAsync(
|
||||||
|
AppCacheKeys.BaseData("classrooms"),
|
||||||
|
token => db.Classrooms.AsNoTracking()
|
||||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
.Select(x => new
|
.Select(x => new ClassroomListItem(
|
||||||
{
|
x.Id,
|
||||||
x.Id, x.Code, x.Name, x.BuildingId,
|
x.Code,
|
||||||
CampusId = x.Building!.CampusId,
|
x.Name,
|
||||||
BuildingName = x.Building!.Name,
|
x.BuildingId,
|
||||||
CampusName = x.Building.Campus!.Name,
|
x.Building!.CampusId,
|
||||||
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
|
x.Building.Name,
|
||||||
})
|
x.Building.Campus!.Name,
|
||||||
.ToListAsync(cancellationToken));
|
x.Capacity,
|
||||||
|
x.RoomType,
|
||||||
|
x.Equipment,
|
||||||
|
x.IsEnabled,
|
||||||
|
x.SortOrder))
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("buildings")]
|
[HttpGet("buildings")]
|
||||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken)
|
||||||
Ok(await db.Buildings.AsNoTracking()
|
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
{
|
||||||
x.Id, x.Code, x.Name, x.CampusId,
|
var result = await cache.GetOrCreateAsync(
|
||||||
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
|
AppCacheKeys.BaseData("buildings"),
|
||||||
})
|
token => db.Buildings.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken));
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
|
.Select(x => new BuildingListItem(
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.CampusId,
|
||||||
|
x.Campus!.Name,
|
||||||
|
x.IsEnabled,
|
||||||
|
x.SortOrder))
|
||||||
|
.ToListAsync(token),
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("buildings")]
|
[HttpPost("buildings")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
@@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
entity.Capacity = request.Capacity;
|
entity.Capacity = request.Capacity;
|
||||||
entity.RoomType = request.RoomType.Trim();
|
entity.RoomType = request.RoomType.Trim();
|
||||||
entity.Equipment = request.Equipment?.Trim();
|
entity.Equipment = request.Equipment?.Trim();
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
db.Remove(entity);
|
db.Remove(entity);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
db.Add(entity);
|
db.Add(entity);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await SaveAndInvalidateAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
{
|
{
|
||||||
@@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
return CreatedAtAction(action, new { id = entity.Id }, entity);
|
return CreatedAtAction(action, new { id = entity.Id }, entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SaveAndInvalidateAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string?> GetCounselorNameAsync(
|
private async Task<string?> GetCounselorNameAsync(
|
||||||
Guid? userId,
|
Guid? userId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -649,3 +730,60 @@ public sealed record ClassroomRequest(
|
|||||||
[Required, MaxLength(40)] string RoomType,
|
[Required, MaxLength(40)] string RoomType,
|
||||||
[MaxLength(300)] string? Equipment)
|
[MaxLength(300)] string? Equipment)
|
||||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||||
|
|
||||||
|
public sealed record CollegeListItem(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
string? ShortName,
|
||||||
|
Guid? CampusId,
|
||||||
|
string? CampusName,
|
||||||
|
bool IsEnabled,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
public sealed record MajorListItem(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
Guid CollegeId,
|
||||||
|
string CollegeName,
|
||||||
|
string DegreeType,
|
||||||
|
int SchoolingYears,
|
||||||
|
bool IsEnabled,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
public sealed record AdministrativeClassListItem(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
Guid MajorId,
|
||||||
|
string MajorName,
|
||||||
|
string CollegeName,
|
||||||
|
int Grade,
|
||||||
|
Guid? CounselorUserId,
|
||||||
|
string? CounselorName,
|
||||||
|
bool IsEnabled,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
public sealed record BuildingListItem(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
Guid CampusId,
|
||||||
|
string CampusName,
|
||||||
|
bool IsEnabled,
|
||||||
|
int SortOrder);
|
||||||
|
|
||||||
|
public sealed record ClassroomListItem(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
Guid BuildingId,
|
||||||
|
Guid CampusId,
|
||||||
|
string BuildingName,
|
||||||
|
string CampusName,
|
||||||
|
int Capacity,
|
||||||
|
string RoomType,
|
||||||
|
string? Equipment,
|
||||||
|
bool IsEnabled,
|
||||||
|
int SortOrder);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Common;
|
using Jiaowu.Api.Domain.Common;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
[Route("api/base-data")]
|
[Route("api/base-data")]
|
||||||
public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string Administrators =
|
private const string Administrators =
|
||||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||||
@@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.BaseData,
|
||||||
|
cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
|
|||||||
@@ -0,0 +1,708 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/classroom-reservations")]
|
||||||
|
public sealed class ClassroomReservationsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope scope,
|
||||||
|
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("options")]
|
||||||
|
public async Task<ActionResult> GetOptions(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||||
|
var terms = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(term => term.IsEnabled && !term.IsArchived)
|
||||||
|
.OrderByDescending(term => term.IsCurrent)
|
||||||
|
.ThenByDescending(term => term.StartDate)
|
||||||
|
.Select(term => new
|
||||||
|
{
|
||||||
|
term.Id,
|
||||||
|
term.Name,
|
||||||
|
term.StartDate,
|
||||||
|
term.EndDate,
|
||||||
|
term.IsCurrent,
|
||||||
|
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||||
|
plan.AcademicTermId == term.Id &&
|
||||||
|
plan.Status == SchedulePlanStatus.Published)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var selectedTermId = academicTermId
|
||||||
|
?? terms.FirstOrDefault(term =>
|
||||||
|
term.IsCurrent && term.HasPublishedTimetable)?.Id
|
||||||
|
?? terms.FirstOrDefault(term => term.HasPublishedTimetable)?.Id
|
||||||
|
?? terms.FirstOrDefault()?.Id;
|
||||||
|
var campuses = await db.Campuses.AsNoTracking()
|
||||||
|
.Where(campus => campus.IsEnabled)
|
||||||
|
.OrderBy(campus => campus.SortOrder)
|
||||||
|
.ThenBy(campus => campus.Code)
|
||||||
|
.Select(campus => new { campus.Id, campus.Code, campus.Name })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var buildings = await db.Buildings.AsNoTracking()
|
||||||
|
.Where(building =>
|
||||||
|
building.IsEnabled &&
|
||||||
|
building.Campus!.IsEnabled)
|
||||||
|
.OrderBy(building => building.Campus!.SortOrder)
|
||||||
|
.ThenBy(building => building.SortOrder)
|
||||||
|
.ThenBy(building => building.Code)
|
||||||
|
.Select(building => new
|
||||||
|
{
|
||||||
|
building.Id,
|
||||||
|
building.Code,
|
||||||
|
building.Name,
|
||||||
|
building.CampusId
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var classrooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(classroom =>
|
||||||
|
classroom.IsEnabled &&
|
||||||
|
classroom.Building!.IsEnabled &&
|
||||||
|
classroom.Building.Campus!.IsEnabled)
|
||||||
|
.OrderBy(classroom => classroom.Building!.Campus!.SortOrder)
|
||||||
|
.ThenBy(classroom => classroom.Building!.SortOrder)
|
||||||
|
.ThenBy(classroom => classroom.Code)
|
||||||
|
.Select(classroom => new
|
||||||
|
{
|
||||||
|
classroom.Id,
|
||||||
|
classroom.Code,
|
||||||
|
classroom.Name,
|
||||||
|
classroom.BuildingId,
|
||||||
|
classroom.Capacity,
|
||||||
|
classroom.RoomType
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var timeSlots = await LoadTimeSlotsAsync(
|
||||||
|
selectedTermId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Applicant = applicant.Context is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
applicant.Context.User.Id,
|
||||||
|
applicant.Context.User.DisplayName,
|
||||||
|
CollegeId = applicant.Context.CollegeId,
|
||||||
|
CollegeName = applicant.Context.CollegeName
|
||||||
|
},
|
||||||
|
ApplicantProblem = applicant.Error,
|
||||||
|
Terms = terms,
|
||||||
|
SelectedTermId = selectedTermId,
|
||||||
|
Campuses = campuses,
|
||||||
|
Buildings = buildings,
|
||||||
|
Classrooms = classrooms,
|
||||||
|
TimeSlots = timeSlots
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("availability")]
|
||||||
|
public async Task<ActionResult> GetAvailability(
|
||||||
|
Guid academicTermId,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
[Range(1, 30)] int startPeriod,
|
||||||
|
[Range(1, 6)] int periodCount,
|
||||||
|
Guid? campusId,
|
||||||
|
Guid? buildingId,
|
||||||
|
[Range(1, 10000)] int? attendeeCount,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = await ValidateSlotAsync(
|
||||||
|
academicTermId,
|
||||||
|
reservationDate,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (validation.Error is not null) return validation.Error;
|
||||||
|
|
||||||
|
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||||
|
validation.Term!,
|
||||||
|
reservationDate,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
null,
|
||||||
|
cancellationToken);
|
||||||
|
var rooms = db.Classrooms.AsNoTracking()
|
||||||
|
.Where(classroom =>
|
||||||
|
classroom.IsEnabled &&
|
||||||
|
classroom.Building!.IsEnabled &&
|
||||||
|
classroom.Building.Campus!.IsEnabled)
|
||||||
|
.WhereNotIn(occupiedIds, classroom => classroom.Id);
|
||||||
|
if (campusId.HasValue)
|
||||||
|
rooms = rooms.Where(room =>
|
||||||
|
room.Building!.CampusId == campusId.Value);
|
||||||
|
if (buildingId.HasValue)
|
||||||
|
rooms = rooms.Where(room => room.BuildingId == buildingId.Value);
|
||||||
|
if (attendeeCount.HasValue)
|
||||||
|
rooms = rooms.Where(room => room.Capacity >= attendeeCount.Value);
|
||||||
|
|
||||||
|
var items = await rooms
|
||||||
|
.OrderBy(room => room.Building!.Campus!.SortOrder)
|
||||||
|
.ThenBy(room => room.Building!.SortOrder)
|
||||||
|
.ThenBy(room => room.Code)
|
||||||
|
.Select(room => new
|
||||||
|
{
|
||||||
|
room.Id,
|
||||||
|
room.Code,
|
||||||
|
room.Name,
|
||||||
|
room.Capacity,
|
||||||
|
room.RoomType,
|
||||||
|
room.BuildingId,
|
||||||
|
BuildingName = room.Building!.Name,
|
||||||
|
CampusId = room.Building.CampusId,
|
||||||
|
CampusName = room.Building.Campus!.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new { Items = items });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine")]
|
||||||
|
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetMine(
|
||||||
|
ClassroomReservationStatus? status,
|
||||||
|
[Range(1, int.MaxValue)] int page = 1,
|
||||||
|
[Range(1, 100)] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = db.ClassroomReservations.AsNoTracking()
|
||||||
|
.Where(reservation =>
|
||||||
|
reservation.ApplicantUserId == scope.Current.UserId);
|
||||||
|
if (status.HasValue)
|
||||||
|
query = query.Where(reservation => reservation.Status == status.Value);
|
||||||
|
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("review")]
|
||||||
|
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||||
|
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetReviewQueue(
|
||||||
|
ClassroomReservationStatus? status,
|
||||||
|
[Range(1, int.MaxValue)] int page = 1,
|
||||||
|
[Range(1, 100)] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var collegeError = ReviewCollegeError();
|
||||||
|
if (collegeError is not null) return collegeError;
|
||||||
|
var collegeId = scope.Current.CollegeId!.Value;
|
||||||
|
var query = db.ClassroomReservations.AsNoTracking()
|
||||||
|
.Where(reservation => reservation.ApplicantCollegeId == collegeId);
|
||||||
|
if (status.HasValue)
|
||||||
|
query = query.Where(reservation => reservation.Status == status.Value);
|
||||||
|
else
|
||||||
|
query = query.Where(reservation =>
|
||||||
|
reservation.Status == ClassroomReservationStatus.Submitted);
|
||||||
|
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult> Create(
|
||||||
|
CreateClassroomReservationRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Purpose))
|
||||||
|
return ValidationProblem("请填写借用用途。");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ContactPhone))
|
||||||
|
return ValidationProblem("请填写联系电话。");
|
||||||
|
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||||
|
if (applicant.Context is null)
|
||||||
|
return ConflictProblem(
|
||||||
|
applicant.Error ?? "当前账号未配置申请人学院,无法提交申请。");
|
||||||
|
|
||||||
|
var validation = await ValidateSlotAsync(
|
||||||
|
request.AcademicTermId,
|
||||||
|
request.ReservationDate,
|
||||||
|
request.StartPeriod,
|
||||||
|
request.PeriodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (validation.Error is not null) return validation.Error;
|
||||||
|
|
||||||
|
var classroom = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(room =>
|
||||||
|
room.Id == request.ClassroomId &&
|
||||||
|
room.IsEnabled &&
|
||||||
|
room.Building!.IsEnabled &&
|
||||||
|
room.Building.Campus!.IsEnabled)
|
||||||
|
.Select(room => new
|
||||||
|
{
|
||||||
|
room.Id,
|
||||||
|
room.Name,
|
||||||
|
room.Code,
|
||||||
|
room.Capacity,
|
||||||
|
BuildingName = room.Building!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (classroom is null) return NotFound();
|
||||||
|
if (request.AttendeeCount > classroom.Capacity)
|
||||||
|
return ValidationProblem(
|
||||||
|
$"申请人数超过教室容量({classroom.Capacity} 人)。");
|
||||||
|
|
||||||
|
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||||
|
validation.Term!,
|
||||||
|
request.ReservationDate,
|
||||||
|
request.StartPeriod,
|
||||||
|
request.PeriodCount,
|
||||||
|
null,
|
||||||
|
cancellationToken);
|
||||||
|
if (occupiedIds.Contains(classroom.Id))
|
||||||
|
return ConflictProblem("所选教室在该时段已有课程、考试或已批准预约。");
|
||||||
|
|
||||||
|
var duplicate = await db.ClassroomReservations.AsNoTracking()
|
||||||
|
.AnyAsync(reservation =>
|
||||||
|
reservation.ApplicantUserId == applicant.Context.User.Id &&
|
||||||
|
reservation.ClassroomId == classroom.Id &&
|
||||||
|
reservation.ReservationDate == request.ReservationDate &&
|
||||||
|
reservation.Status == ClassroomReservationStatus.Submitted &&
|
||||||
|
reservation.StartPeriod <
|
||||||
|
request.StartPeriod + request.PeriodCount &&
|
||||||
|
request.StartPeriod <
|
||||||
|
reservation.StartPeriod + reservation.PeriodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return ConflictProblem("您已提交过同一教室、重叠时段的申请。");
|
||||||
|
|
||||||
|
var reservation = new ClassroomReservation
|
||||||
|
{
|
||||||
|
ApplicantUserId = applicant.Context.User.Id,
|
||||||
|
ApplicantName = applicant.Context.User.DisplayName,
|
||||||
|
ApplicantCollegeId = applicant.Context.CollegeId,
|
||||||
|
AcademicTermId = request.AcademicTermId,
|
||||||
|
ClassroomId = request.ClassroomId,
|
||||||
|
ReservationDate = request.ReservationDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
AttendeeCount = request.AttendeeCount,
|
||||||
|
Purpose = request.Purpose.Trim(),
|
||||||
|
ContactPhone = request.ContactPhone.Trim(),
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
};
|
||||||
|
db.ClassroomReservations.Add(reservation);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToRoleAsync(
|
||||||
|
db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
"教室借用申请待审核",
|
||||||
|
$"{reservation.ApplicantName} 申请于 {reservation.ReservationDate:yyyy-MM-dd} " +
|
||||||
|
$"借用 {classroom.BuildingName} {classroom.Name}。",
|
||||||
|
reservation.ApplicantCollegeId,
|
||||||
|
"/classroom-reservations",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Approval);
|
||||||
|
return Created("", new { reservation.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/cancel")]
|
||||||
|
public async Task<ActionResult> Cancel(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var reservation = await db.ClassroomReservations
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == id &&
|
||||||
|
item.ApplicantUserId == scope.Current.UserId,
|
||||||
|
cancellationToken);
|
||||||
|
if (reservation is null) return NotFound();
|
||||||
|
if (reservation.Status is not (
|
||||||
|
ClassroomReservationStatus.Submitted or
|
||||||
|
ClassroomReservationStatus.Approved))
|
||||||
|
return ConflictProblem("当前状态不能取消。");
|
||||||
|
if (reservation.ReservationDate < TodayInChina())
|
||||||
|
return ConflictProblem("已过期的预约不能取消。");
|
||||||
|
|
||||||
|
var wasApproved =
|
||||||
|
reservation.Status == ClassroomReservationStatus.Approved;
|
||||||
|
reservation.Status = ClassroomReservationStatus.Cancelled;
|
||||||
|
reservation.CancelledAt = DateTime.UtcNow;
|
||||||
|
reservation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
if (wasApproved)
|
||||||
|
{
|
||||||
|
await NotificationService.SendToRoleAsync(
|
||||||
|
db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
"已批准教室预约被申请人取消",
|
||||||
|
$"{reservation.ApplicantName} 已取消 " +
|
||||||
|
$"{reservation.ReservationDate:yyyy-MM-dd} 的教室预约。",
|
||||||
|
reservation.ApplicantCollegeId,
|
||||||
|
"/classroom-reservations",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Approval);
|
||||||
|
}
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||||
|
public Task<ActionResult> Approve(
|
||||||
|
Guid id,
|
||||||
|
ReviewClassroomReservationRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
ReviewAsync(id, true, request.Comment, cancellationToken);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||||
|
public Task<ActionResult> Reject(
|
||||||
|
Guid id,
|
||||||
|
ReviewClassroomReservationRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||||
|
return Task.FromResult<ActionResult>(
|
||||||
|
ValidationProblem("驳回时必须填写审核意见。"));
|
||||||
|
return ReviewAsync(id, false, request.Comment, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> ReviewAsync(
|
||||||
|
Guid id,
|
||||||
|
bool approve,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var collegeError = ReviewCollegeError();
|
||||||
|
if (collegeError is not null) return collegeError;
|
||||||
|
var reviewerCollegeId = scope.Current.CollegeId!.Value;
|
||||||
|
|
||||||
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
|
async transaction =>
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var reservation = await db.ClassroomReservations
|
||||||
|
.Include(item => item.AcademicTerm)
|
||||||
|
.Include(item => item.Classroom)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||||
|
if (reservation is null) return NotFound();
|
||||||
|
if (reservation.ApplicantCollegeId != reviewerCollegeId)
|
||||||
|
return Forbid();
|
||||||
|
if (reservation.Status != ClassroomReservationStatus.Submitted)
|
||||||
|
return ConflictProblem("该申请已处理,不能重复审核。");
|
||||||
|
|
||||||
|
if (approve)
|
||||||
|
{
|
||||||
|
if (reservation.ReservationDate < TodayInChina())
|
||||||
|
return ConflictProblem("预约日期已过,不能批准。");
|
||||||
|
var validation = await ValidateSlotAsync(
|
||||||
|
reservation.AcademicTermId,
|
||||||
|
reservation.ReservationDate,
|
||||||
|
reservation.StartPeriod,
|
||||||
|
reservation.PeriodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (validation.Error is not null) return validation.Error;
|
||||||
|
if (reservation.AttendeeCount > reservation.Classroom!.Capacity)
|
||||||
|
return ConflictProblem("申请人数已超过教室当前容量,不能批准。");
|
||||||
|
var occupiedIds =
|
||||||
|
await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||||
|
validation.Term!,
|
||||||
|
reservation.ReservationDate,
|
||||||
|
reservation.StartPeriod,
|
||||||
|
reservation.PeriodCount,
|
||||||
|
reservation.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (occupiedIds.Contains(reservation.ClassroomId))
|
||||||
|
return ConflictProblem(
|
||||||
|
"教室在该时段已被课程、考试或其他已批准预约占用。");
|
||||||
|
reservation.Status = ClassroomReservationStatus.Approved;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reservation.Status = ClassroomReservationStatus.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
reservation.ReviewedByUserId = scope.Current.UserId;
|
||||||
|
reservation.ReviewedAt = DateTime.UtcNow;
|
||||||
|
reservation.ReviewComment = Normalize(comment);
|
||||||
|
reservation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await NotificationService.SendAsync(
|
||||||
|
db,
|
||||||
|
reservation.ApplicantUserId,
|
||||||
|
approve ? "教室借用申请已通过" : "教室借用申请已驳回",
|
||||||
|
approve
|
||||||
|
? $"{reservation.ReservationDate:yyyy-MM-dd} 的教室借用申请已通过。"
|
||||||
|
: reservation.ReviewComment ?? "审核未通过。",
|
||||||
|
"/classroom-reservations",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Approval);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
},
|
||||||
|
cancellationToken,
|
||||||
|
IsolationLevel.Serializable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PagedResult<ClassroomReservationDto>> BuildPageAsync(
|
||||||
|
IQueryable<ClassroomReservation> query,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(reservation =>
|
||||||
|
reservation.Status == ClassroomReservationStatus.Submitted)
|
||||||
|
.ThenByDescending(reservation => reservation.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(reservation => new ClassroomReservationDto(
|
||||||
|
reservation.Id,
|
||||||
|
reservation.ApplicantUserId,
|
||||||
|
reservation.ApplicantName,
|
||||||
|
reservation.ApplicantCollegeId,
|
||||||
|
reservation.ApplicantCollege!.Name,
|
||||||
|
reservation.AcademicTermId,
|
||||||
|
reservation.AcademicTerm!.Name,
|
||||||
|
reservation.ClassroomId,
|
||||||
|
reservation.Classroom!.Code,
|
||||||
|
reservation.Classroom.Name,
|
||||||
|
reservation.Classroom.Building!.Name,
|
||||||
|
reservation.Classroom.Building.Campus!.Name,
|
||||||
|
reservation.ReservationDate,
|
||||||
|
reservation.StartPeriod,
|
||||||
|
reservation.PeriodCount,
|
||||||
|
reservation.AttendeeCount,
|
||||||
|
reservation.Purpose,
|
||||||
|
reservation.ContactPhone,
|
||||||
|
reservation.Notes,
|
||||||
|
reservation.Status,
|
||||||
|
reservation.ReviewComment,
|
||||||
|
reservation.ReviewedAt,
|
||||||
|
reservation.CancelledAt,
|
||||||
|
reservation.CreatedAt))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return new PagedResult<ClassroomReservationDto>(
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(
|
||||||
|
AcademicTerm? Term,
|
||||||
|
ActionResult? Error)> ValidateSlotAsync(
|
||||||
|
Guid academicTermId,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var term = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == academicTermId &&
|
||||||
|
item.IsEnabled &&
|
||||||
|
!item.IsArchived,
|
||||||
|
cancellationToken);
|
||||||
|
if (term is null) return (null, NotFound());
|
||||||
|
if (reservationDate < TodayInChina())
|
||||||
|
return (null, ValidationProblem("预约日期不能早于今天。"));
|
||||||
|
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||||
|
return (null, ValidationProblem("预约日期不在所选学期范围内。"));
|
||||||
|
var hasPublishedTimetable = await db.SchedulePlans.AsNoTracking()
|
||||||
|
.AnyAsync(plan =>
|
||||||
|
plan.AcademicTermId == term.Id &&
|
||||||
|
plan.Status == SchedulePlanStatus.Published,
|
||||||
|
cancellationToken);
|
||||||
|
if (!hasPublishedTimetable)
|
||||||
|
return (null, ConflictProblem(
|
||||||
|
"所选学期的正式课表尚未发布,暂不能提交教室预约。"));
|
||||||
|
|
||||||
|
var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray();
|
||||||
|
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.CountAsync(slot =>
|
||||||
|
slot.AcademicTermId == academicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
if (configuredPeriodCount == 0)
|
||||||
|
{
|
||||||
|
if (requestedPeriods.Any(period => period > 12))
|
||||||
|
return (null, ValidationProblem("预约范围包含不存在的节次。"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var enabledPeriods = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(slot =>
|
||||||
|
slot.AcademicTermId == academicTermId &&
|
||||||
|
slot.IsEnabled)
|
||||||
|
.WhereIn(requestedPeriods, slot => slot.PeriodNumber)
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
if (enabledPeriods != requestedPeriods.Length)
|
||||||
|
return (null, ValidationProblem("预约范围包含不存在或未启用的节次。"));
|
||||||
|
}
|
||||||
|
return (term, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ClassroomReservationTimeSlotDto>> LoadTimeSlotsAsync(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = academicTermId.HasValue
|
||||||
|
? await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(slot =>
|
||||||
|
slot.AcademicTermId == academicTermId.Value &&
|
||||||
|
slot.IsEnabled)
|
||||||
|
.OrderBy(slot => slot.PeriodNumber)
|
||||||
|
.Select(slot => new ClassroomReservationTimeSlotDto(
|
||||||
|
slot.PeriodNumber,
|
||||||
|
slot.Name,
|
||||||
|
slot.StartsAt.ToString("HH:mm"),
|
||||||
|
slot.EndsAt.ToString("HH:mm")))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [];
|
||||||
|
return result.Count > 0
|
||||||
|
? result
|
||||||
|
: Enumerable.Range(1, 12)
|
||||||
|
.Select(period => new ClassroomReservationTimeSlotDto(
|
||||||
|
period,
|
||||||
|
$"第 {period} 节",
|
||||||
|
"",
|
||||||
|
""))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApplicantResolution> ResolveApplicantAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (scope.Current.UserId == Guid.Empty)
|
||||||
|
return new(null, "无法识别当前登录账号。");
|
||||||
|
var user = await db.Users.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == scope.Current.UserId &&
|
||||||
|
item.IsEnabled,
|
||||||
|
cancellationToken);
|
||||||
|
if (user is null) return new(null, "当前账号不存在或已停用。");
|
||||||
|
|
||||||
|
var linkedCollegeIds = new List<Guid>();
|
||||||
|
if (user.CollegeId.HasValue) linkedCollegeIds.Add(user.CollegeId.Value);
|
||||||
|
var teacherCollegeId = await db.Teachers.AsNoTracking()
|
||||||
|
.Where(teacher => teacher.UserId == user.Id)
|
||||||
|
.Select(teacher => (Guid?)teacher.CollegeId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (teacherCollegeId.HasValue)
|
||||||
|
linkedCollegeIds.Add(teacherCollegeId.Value);
|
||||||
|
var studentCollegeId = await db.Students.AsNoTracking()
|
||||||
|
.Where(student => student.UserId == user.Id)
|
||||||
|
.Select(student =>
|
||||||
|
(Guid?)student.AdministrativeClass!.Major!.CollegeId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (studentCollegeId.HasValue)
|
||||||
|
linkedCollegeIds.Add(studentCollegeId.Value);
|
||||||
|
|
||||||
|
var distinctIds = linkedCollegeIds.Distinct().ToArray();
|
||||||
|
if (distinctIds.Length == 0)
|
||||||
|
return new(null, "当前账号未配置所在学院,请联系账号管理员补充学院信息。");
|
||||||
|
if (distinctIds.Length > 1)
|
||||||
|
return new(null, "账号与人员档案的学院信息不一致,请联系管理员修正后再申请。");
|
||||||
|
var college = await db.Colleges.AsNoTracking()
|
||||||
|
.Where(item => item.Id == distinctIds[0] && item.IsEnabled)
|
||||||
|
.Select(item => new { item.Id, item.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return college is null
|
||||||
|
? new(null, "当前账号所属学院不存在或已停用。")
|
||||||
|
: new(
|
||||||
|
new ApplicantContext(user, college.Id, college.Name),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult? ReviewCollegeError()
|
||||||
|
{
|
||||||
|
if (!scope.Current.IsInRole(SystemRoles.CollegeAdmin))
|
||||||
|
return Forbid();
|
||||||
|
return scope.Current.CollegeId.HasValue
|
||||||
|
? null
|
||||||
|
: ConflictProblem("学院管理员账号未配置所属学院,无法审核。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
|
Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "无法完成教室预约",
|
||||||
|
Detail = detail,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static DateOnly TodayInChina()
|
||||||
|
{
|
||||||
|
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var zone = TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
return DateOnly.FromDateTime(
|
||||||
|
TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone).DateTime);
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ApplicantContext(
|
||||||
|
ApplicationUser User,
|
||||||
|
Guid CollegeId,
|
||||||
|
string CollegeName);
|
||||||
|
|
||||||
|
private sealed record ApplicantResolution(
|
||||||
|
ApplicantContext? Context,
|
||||||
|
string? Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateClassroomReservationRequest(
|
||||||
|
Guid AcademicTermId,
|
||||||
|
Guid ClassroomId,
|
||||||
|
DateOnly ReservationDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
[Range(1, 10000)] int AttendeeCount,
|
||||||
|
[Required, StringLength(200)] string Purpose,
|
||||||
|
[Required, StringLength(30)] string ContactPhone,
|
||||||
|
[StringLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record ReviewClassroomReservationRequest(
|
||||||
|
[StringLength(500)] string? Comment);
|
||||||
|
|
||||||
|
public sealed record ClassroomReservationTimeSlotDto(
|
||||||
|
int PeriodNumber,
|
||||||
|
string Name,
|
||||||
|
string StartTime,
|
||||||
|
string EndTime);
|
||||||
|
|
||||||
|
public sealed record ClassroomReservationDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApplicantUserId,
|
||||||
|
string ApplicantName,
|
||||||
|
Guid ApplicantCollegeId,
|
||||||
|
string ApplicantCollegeName,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
string AcademicTermName,
|
||||||
|
Guid ClassroomId,
|
||||||
|
string ClassroomCode,
|
||||||
|
string ClassroomName,
|
||||||
|
string BuildingName,
|
||||||
|
string CampusName,
|
||||||
|
DateOnly ReservationDate,
|
||||||
|
int StartPeriod,
|
||||||
|
int PeriodCount,
|
||||||
|
int AttendeeCount,
|
||||||
|
string Purpose,
|
||||||
|
string ContactPhone,
|
||||||
|
string? Notes,
|
||||||
|
ClassroomReservationStatus Status,
|
||||||
|
string? ReviewComment,
|
||||||
|
DateTime? ReviewedAt,
|
||||||
|
DateTime? CancelledAt,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -3,6 +3,8 @@ using Jiaowu.Api.Domain.Academic;
|
|||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -27,6 +29,145 @@ public sealed class CourseAdjustmentsController(
|
|||||||
SystemRoles.AcademicAdmin + "," +
|
SystemRoles.AcademicAdmin + "," +
|
||||||
SystemRoles.CollegeAdmin;
|
SystemRoles.CollegeAdmin;
|
||||||
|
|
||||||
|
[HttpGet("task-options")]
|
||||||
|
[Authorize(Roles = Applicants)]
|
||||||
|
public async Task<ActionResult> GetTaskOptions(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var source = AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||||
|
if (academicTermId.HasValue)
|
||||||
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
|
||||||
|
return Ok(await source
|
||||||
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.Name,
|
||||||
|
x.AcademicTermId,
|
||||||
|
TermName = x.AcademicTerm!.Name,
|
||||||
|
CourseCode = x.Course!.Code,
|
||||||
|
CourseName = x.Course.Name,
|
||||||
|
ClassNames = x.Classes
|
||||||
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
|
.Select(item => item.AdministrativeClass!.Name)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{teachingTaskId:guid}/session-options")]
|
||||||
|
[Authorize(Roles = Applicants)]
|
||||||
|
public async Task<ActionResult> GetSessionOptions(
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Id == teachingTaskId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TaskNumber,
|
||||||
|
CourseName = x.Course!.Name,
|
||||||
|
x.AcademicTermId,
|
||||||
|
TermStartDate = x.AcademicTerm!.StartDate,
|
||||||
|
TermEndDate = x.AcademicTerm.EndDate
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (task is null) return NotFound();
|
||||||
|
|
||||||
|
var entries = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.TeachingTaskId == teachingTaskId &&
|
||||||
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published)
|
||||||
|
.OrderBy(x => x.DayOfWeek)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.DayOfWeek,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.ClassroomId,
|
||||||
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var occupied = await db.CourseAdjustments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.TeachingTaskId == teachingTaskId &&
|
||||||
|
x.SourceScheduleEntryId != null &&
|
||||||
|
x.SourceWeek != null &&
|
||||||
|
(x.Status == CourseAdjustmentStatus.Submitted ||
|
||||||
|
x.Status == CourseAdjustmentStatus.Approved))
|
||||||
|
.Select(x => new { x.SourceScheduleEntryId, x.SourceWeek })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var occupiedOccurrences = occupied
|
||||||
|
.Select(x => (x.SourceScheduleEntryId!.Value, x.SourceWeek!.Value))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var sessions = entries
|
||||||
|
.SelectMany(entry => Enumerable.Range(
|
||||||
|
entry.StartWeek,
|
||||||
|
entry.EndWeek - entry.StartWeek + 1)
|
||||||
|
.Where(week => IncludesWeek(entry.WeekPattern, week))
|
||||||
|
.Select(week => new
|
||||||
|
{
|
||||||
|
ScheduleEntryId = entry.Id,
|
||||||
|
Week = week,
|
||||||
|
Date = ResolveDate(task.TermStartDate, week, entry.DayOfWeek),
|
||||||
|
entry.DayOfWeek,
|
||||||
|
entry.StartPeriod,
|
||||||
|
entry.PeriodCount,
|
||||||
|
entry.ClassroomId,
|
||||||
|
entry.ClassroomName,
|
||||||
|
entry.BuildingName,
|
||||||
|
HasExistingAdjustment = occupiedOccurrences.Contains((entry.Id, week))
|
||||||
|
}))
|
||||||
|
.Where(x => x.Date >= task.TermStartDate && x.Date <= task.TermEndDate)
|
||||||
|
.OrderBy(x => x.Date)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var classrooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||||
|
.ThenBy(x => x.Building!.SortOrder)
|
||||||
|
.ThenBy(x => x.SortOrder)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Name,
|
||||||
|
BuildingName = x.Building!.Name,
|
||||||
|
CampusName = x.Building.Campus!.Name,
|
||||||
|
x.Capacity
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var periods = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == task.AcademicTermId && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.PeriodNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.PeriodNumber,
|
||||||
|
x.Name,
|
||||||
|
StartsAt = x.StartsAt.ToString("HH:mm"),
|
||||||
|
EndsAt = x.EndsAt.ToString("HH:mm")
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════ My adjustments ═══════════════
|
// ═══════════════ My adjustments ═══════════════
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
@@ -100,20 +241,41 @@ public sealed class CourseAdjustmentsController(
|
|||||||
var validation = await ValidateRequestAsync(request, cancellationToken);
|
var validation = await ValidateRequestAsync(request, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (validation is not null) return validation;
|
||||||
|
|
||||||
|
var source = RequiresSourceOccurrence(request.Type)
|
||||||
|
? await LoadSourceOccurrenceAsync(
|
||||||
|
request.TeachingTaskId,
|
||||||
|
request.SourceScheduleEntryId,
|
||||||
|
request.SourceWeek,
|
||||||
|
cancellationToken)
|
||||||
|
: null;
|
||||||
|
var targetDayOfWeek = request.TargetDate.HasValue
|
||||||
|
? ToIsoDayOfWeek(request.TargetDate.Value.DayOfWeek)
|
||||||
|
: request.DayOfWeek;
|
||||||
|
|
||||||
var adj = new CourseAdjustment
|
var adj = new CourseAdjustment
|
||||||
{
|
{
|
||||||
TeachingTaskId = request.TeachingTaskId,
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
Type = request.Type,
|
Type = request.Type,
|
||||||
ApplicantUserId = userId,
|
ApplicantUserId = userId,
|
||||||
Reason = request.Reason.Trim(),
|
Reason = request.Reason.Trim(),
|
||||||
|
SourceScheduleEntryId = source?.Entry.Id,
|
||||||
|
SourceWeek = source?.Week,
|
||||||
|
SourceDate = source?.Date,
|
||||||
|
SourceStartPeriod = source?.Entry.StartPeriod,
|
||||||
|
SourcePeriodCount = source?.Entry.PeriodCount,
|
||||||
|
SourceClassroomId = source?.Entry.ClassroomId,
|
||||||
TargetDate = request.TargetDate,
|
TargetDate = request.TargetDate,
|
||||||
DayOfWeek = request.DayOfWeek,
|
DayOfWeek = targetDayOfWeek,
|
||||||
StartPeriod = request.StartPeriod,
|
StartPeriod = request.StartPeriod,
|
||||||
PeriodCount = request.PeriodCount,
|
PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount,
|
||||||
ClassroomId = request.ClassroomId,
|
ClassroomId = request.ClassroomId,
|
||||||
SubstituteTeacherId = request.SubstituteTeacherId,
|
SubstituteTeacherId = request.SubstituteTeacherId,
|
||||||
CancelWeek = request.CancelWeek,
|
CancelWeek = request.Type == CourseAdjustmentType.Cancel
|
||||||
CancelDate = request.CancelDate
|
? source?.Week
|
||||||
|
: request.CancelWeek,
|
||||||
|
CancelDate = request.Type == CourseAdjustmentType.Cancel
|
||||||
|
? source?.Date
|
||||||
|
: request.CancelDate
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.Submit)
|
if (request.Submit)
|
||||||
@@ -138,7 +300,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
SystemRoles.CollegeAdmin,
|
SystemRoles.CollegeAdmin,
|
||||||
$"新的{tl}申请",
|
$"新的{tl}申请",
|
||||||
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
|
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
|
||||||
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
|
taskInfo.CollegeId, "/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +324,25 @@ public sealed class CourseAdjustmentsController(
|
|||||||
if (adj.Status != CourseAdjustmentStatus.Draft)
|
if (adj.Status != CourseAdjustmentStatus.Draft)
|
||||||
return ConflictProblem("只有草稿可以提交。");
|
return ConflictProblem("只有草稿可以提交。");
|
||||||
|
|
||||||
|
var validation = await ValidateRequestAsync(
|
||||||
|
new CourseAdjustmentRequest(
|
||||||
|
adj.TeachingTaskId,
|
||||||
|
adj.Type,
|
||||||
|
adj.Reason,
|
||||||
|
true,
|
||||||
|
adj.TargetDate,
|
||||||
|
adj.DayOfWeek,
|
||||||
|
adj.StartPeriod,
|
||||||
|
adj.PeriodCount,
|
||||||
|
adj.ClassroomId,
|
||||||
|
adj.SubstituteTeacherId,
|
||||||
|
adj.CancelWeek,
|
||||||
|
adj.CancelDate,
|
||||||
|
adj.SourceScheduleEntryId,
|
||||||
|
adj.SourceWeek),
|
||||||
|
cancellationToken);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
|
||||||
adj.Status = CourseAdjustmentStatus.Submitted;
|
adj.Status = CourseAdjustmentStatus.Submitted;
|
||||||
adj.SubmittedAt = DateTime.UtcNow;
|
adj.SubmittedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -171,12 +353,14 @@ public sealed class CourseAdjustmentsController(
|
|||||||
$"新的{TypeLabel(adj.Type)}申请",
|
$"新的{TypeLabel(adj.Type)}申请",
|
||||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
||||||
adj.TeachingTask.Course.CollegeId,
|
adj.TeachingTask.Course.CollegeId,
|
||||||
"/course-adjustments", cancellationToken);
|
"/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
await NotificationService.SendToRoleAsync(db,
|
await NotificationService.SendToRoleAsync(db,
|
||||||
SystemRoles.AcademicAdmin,
|
SystemRoles.AcademicAdmin,
|
||||||
$"新的{TypeLabel(adj.Type)}申请",
|
$"新的{TypeLabel(adj.Type)}申请",
|
||||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
||||||
null, "/course-adjustments", cancellationToken);
|
null, "/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +374,12 @@ public sealed class CourseAdjustmentsController(
|
|||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.ThenInclude(x => x!.College)
|
.ThenInclude(x => x!.College)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Classes)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.AcademicTerm)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (adj is null) return NotFound();
|
if (adj is null) return NotFound();
|
||||||
|
|
||||||
@@ -200,6 +390,12 @@ public sealed class CourseAdjustmentsController(
|
|||||||
if (adj.Status != CourseAdjustmentStatus.Submitted)
|
if (adj.Status != CourseAdjustmentStatus.Submitted)
|
||||||
return ConflictProblem("只有待审核的申请可以审批。");
|
return ConflictProblem("只有待审核的申请可以审批。");
|
||||||
|
|
||||||
|
var scheduleProblem = await ValidateApprovalScheduleAsync(
|
||||||
|
adj,
|
||||||
|
cancellationToken);
|
||||||
|
if (scheduleProblem is not null)
|
||||||
|
return ConflictProblem(scheduleProblem);
|
||||||
|
|
||||||
adj.Status = CourseAdjustmentStatus.Approved;
|
adj.Status = CourseAdjustmentStatus.Approved;
|
||||||
adj.ReviewedAt = DateTime.UtcNow;
|
adj.ReviewedAt = DateTime.UtcNow;
|
||||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||||
@@ -213,14 +409,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||||
"调停课申请已通过",
|
"调停课申请已通过",
|
||||||
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
||||||
"/course-adjustments", cancellationToken);
|
"/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
|
||||||
// Notify affected students
|
// Notify affected students
|
||||||
var studentUserIds = await db.CourseEnrollments
|
var studentUserIds = await TeachingTaskRosterQuery
|
||||||
.Where(x =>
|
.ForTask(db, adj.TeachingTaskId)
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
|
.Select(x => x.UserId)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
|
||||||
.Select(x => x.Student!.UserId)
|
|
||||||
.Where(uid => uid != null)
|
.Where(uid => uid != null)
|
||||||
.Select(uid => uid!.Value)
|
.Select(uid => uid!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -230,7 +425,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||||
"课程变动通知",
|
"课程变动通知",
|
||||||
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
||||||
"/my-timetable", cancellationToken);
|
"/my-timetable", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -242,81 +438,19 @@ public sealed class CourseAdjustmentsController(
|
|||||||
switch (adj.Type)
|
switch (adj.Type)
|
||||||
{
|
{
|
||||||
case CourseAdjustmentType.Reschedule:
|
case CourseAdjustmentType.Reschedule:
|
||||||
// Update existing schedule entries for this teaching task
|
var rescheduleSource = await LoadSourceEntryAsync(adj, ct);
|
||||||
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
|
ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value);
|
||||||
{
|
db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId));
|
||||||
var entries = await db.ScheduleEntries
|
|
||||||
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
foreach (var entry in entries)
|
|
||||||
{
|
|
||||||
entry.DayOfWeek = adj.DayOfWeek.Value;
|
|
||||||
entry.StartPeriod = adj.StartPeriod.Value;
|
|
||||||
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
|
|
||||||
}
|
|
||||||
if (adj.ClassroomId.HasValue)
|
|
||||||
{
|
|
||||||
foreach (var entry in entries)
|
|
||||||
entry.ClassroomId = adj.ClassroomId.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CourseAdjustmentType.Cancel:
|
case CourseAdjustmentType.Cancel:
|
||||||
// Cancel: remove schedule entries for the specified week
|
var cancelSource = await LoadSourceEntryAsync(adj, ct);
|
||||||
if (adj.CancelWeek.HasValue)
|
ExcludeWeek(cancelSource, adj.SourceWeek!.Value);
|
||||||
{
|
|
||||||
var entries = await db.ScheduleEntries
|
|
||||||
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
|
|
||||||
x.StartWeek <= adj.CancelWeek.Value &&
|
|
||||||
x.EndWeek >= adj.CancelWeek.Value)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
foreach (var entry in entries)
|
|
||||||
{
|
|
||||||
// Split the entry to exclude the cancelled week
|
|
||||||
if (entry.StartWeek == adj.CancelWeek.Value &&
|
|
||||||
entry.EndWeek == adj.CancelWeek.Value)
|
|
||||||
{
|
|
||||||
db.ScheduleEntries.Remove(entry);
|
|
||||||
}
|
|
||||||
else if (entry.StartWeek == adj.CancelWeek.Value)
|
|
||||||
{
|
|
||||||
entry.StartWeek = adj.CancelWeek.Value + 1;
|
|
||||||
}
|
|
||||||
else if (entry.EndWeek == adj.CancelWeek.Value)
|
|
||||||
{
|
|
||||||
entry.EndWeek = adj.CancelWeek.Value - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CourseAdjustmentType.Makeup:
|
case CourseAdjustmentType.Makeup:
|
||||||
// Makeup: add a temp schedule entry for the makeup date
|
var makeupSource = await LoadSourceEntryAsync(adj, ct);
|
||||||
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
|
db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId));
|
||||||
adj.StartPeriod.HasValue)
|
|
||||||
{
|
|
||||||
var publishedPlan = await db.SchedulePlans
|
|
||||||
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
|
|
||||||
x.Status == SchedulePlanStatus.Published)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
if (publishedPlan is not null)
|
|
||||||
{
|
|
||||||
db.ScheduleEntries.Add(new ScheduleEntry
|
|
||||||
{
|
|
||||||
SchedulePlanId = publishedPlan.Id,
|
|
||||||
TeachingTaskId = adj.TeachingTaskId,
|
|
||||||
ClassroomId = adj.ClassroomId,
|
|
||||||
DayOfWeek = adj.DayOfWeek.Value,
|
|
||||||
StartPeriod = adj.StartPeriod.Value,
|
|
||||||
PeriodCount = adj.PeriodCount ?? 2,
|
|
||||||
StartWeek = 1,
|
|
||||||
EndWeek = 1,
|
|
||||||
WeekPattern = WeekPattern.All,
|
|
||||||
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd})"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CourseAdjustmentType.Substitute:
|
case CourseAdjustmentType.Substitute:
|
||||||
@@ -383,7 +517,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
||||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||||
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
||||||
"/course-adjustments", cancellationToken);
|
"/course-adjustments", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +528,7 @@ public sealed class CourseAdjustmentsController(
|
|||||||
CourseAdjustmentRequest request,
|
CourseAdjustmentRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await db.TeachingTasks.AsNoTracking()
|
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
.Include(x => x.Course)
|
.Include(x => x.Course)
|
||||||
.FirstOrDefaultAsync(x =>
|
.FirstOrDefaultAsync(x =>
|
||||||
x.Id == request.TeachingTaskId &&
|
x.Id == request.TeachingTaskId &&
|
||||||
@@ -405,15 +540,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
{
|
{
|
||||||
case CourseAdjustmentType.Reschedule:
|
case CourseAdjustmentType.Reschedule:
|
||||||
case CourseAdjustmentType.Makeup:
|
case CourseAdjustmentType.Makeup:
|
||||||
if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7)
|
if (!request.TargetDate.HasValue)
|
||||||
return ValidationProblem("请选择有效的上课日。");
|
return ValidationProblem(
|
||||||
|
request.Type == CourseAdjustmentType.Reschedule
|
||||||
|
? "请选择调课后的日期。"
|
||||||
|
: "请选择补课日期。");
|
||||||
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
|
if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
|
||||||
return ValidationProblem("请选择起始节次。");
|
return ValidationProblem("请选择起始节次。");
|
||||||
if (!request.PeriodCount.HasValue || request.PeriodCount < 1)
|
|
||||||
return ValidationProblem("请选择持续节数。");
|
|
||||||
if (request.Type == CourseAdjustmentType.Makeup &&
|
|
||||||
!request.TargetDate.HasValue)
|
|
||||||
return ValidationProblem("补课必须指定日期。");
|
|
||||||
break;
|
break;
|
||||||
case CourseAdjustmentType.Substitute:
|
case CourseAdjustmentType.Substitute:
|
||||||
if (!request.SubstituteTeacherId.HasValue)
|
if (!request.SubstituteTeacherId.HasValue)
|
||||||
@@ -424,15 +557,241 @@ public sealed class CourseAdjustmentsController(
|
|||||||
return ValidationProblem("代课教师不存在或已离职。");
|
return ValidationProblem("代课教师不存在或已离职。");
|
||||||
break;
|
break;
|
||||||
case CourseAdjustmentType.Cancel:
|
case CourseAdjustmentType.Cancel:
|
||||||
if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue)
|
|
||||||
return ValidationProblem("停课需指定周次或日期。");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (RequiresSourceOccurrence(request.Type))
|
||||||
|
{
|
||||||
|
var source = await LoadSourceOccurrenceAsync(
|
||||||
|
request.TeachingTaskId,
|
||||||
|
request.SourceScheduleEntryId,
|
||||||
|
request.SourceWeek,
|
||||||
|
cancellationToken);
|
||||||
|
if (source is null)
|
||||||
|
return ValidationProblem("请选择当前已发布课表中的具体原课次。");
|
||||||
|
|
||||||
|
var duplicate = await db.CourseAdjustments.AsNoTracking().AnyAsync(x =>
|
||||||
|
x.SourceScheduleEntryId == source.Entry.Id &&
|
||||||
|
x.SourceWeek == source.Week &&
|
||||||
|
(x.Status == CourseAdjustmentStatus.Submitted ||
|
||||||
|
x.Status == CourseAdjustmentStatus.Approved),
|
||||||
|
cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return ConflictProblem("该课次已有待审核或已通过的调停课记录。");
|
||||||
|
|
||||||
|
if (request.TargetDate.HasValue &&
|
||||||
|
(request.TargetDate < source.Term.StartDate ||
|
||||||
|
request.TargetDate > source.Term.EndDate))
|
||||||
|
return ValidationProblem("目标日期必须在本学期起止日期内。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ClassroomId.HasValue &&
|
||||||
|
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
|
||||||
|
x.Id == request.ClassroomId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("目标教室不存在或已停用。");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ValidateApprovalScheduleAsync(
|
||||||
|
CourseAdjustment adjustment,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!RequiresSourceOccurrence(adjustment.Type)) return null;
|
||||||
|
|
||||||
|
var source = await LoadSourceOccurrenceAsync(
|
||||||
|
adjustment.TeachingTaskId,
|
||||||
|
adjustment.SourceScheduleEntryId,
|
||||||
|
adjustment.SourceWeek,
|
||||||
|
cancellationToken);
|
||||||
|
if (source is null)
|
||||||
|
return "原课次已不在当前发布课表中,请退回后重新选择。";
|
||||||
|
if (adjustment.Type == CourseAdjustmentType.Cancel) return null;
|
||||||
|
if (!adjustment.TargetDate.HasValue || !adjustment.StartPeriod.HasValue)
|
||||||
|
return "目标上课日期或节次不完整。";
|
||||||
|
|
||||||
|
var targetWeek = ResolveWeek(source.Term.StartDate, adjustment.TargetDate.Value);
|
||||||
|
var targetDay = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek);
|
||||||
|
var target = new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = adjustment.TeachingTaskId,
|
||||||
|
TeachingTask = adjustment.TeachingTask,
|
||||||
|
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
||||||
|
DayOfWeek = targetDay,
|
||||||
|
StartPeriod = adjustment.StartPeriod.Value,
|
||||||
|
PeriodCount = adjustment.SourcePeriodCount ?? source.Entry.PeriodCount,
|
||||||
|
StartWeek = targetWeek,
|
||||||
|
EndWeek = targetWeek,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
};
|
||||||
|
var possibleConflicts = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Classes)
|
||||||
|
.Where(x =>
|
||||||
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
|
x.Id != adjustment.SourceScheduleEntryId &&
|
||||||
|
x.DayOfWeek == targetDay &&
|
||||||
|
x.StartWeek <= targetWeek &&
|
||||||
|
x.EndWeek >= targetWeek &&
|
||||||
|
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
|
||||||
|
target.StartPeriod < x.StartPeriod + x.PeriodCount)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var existing in possibleConflicts.Where(x =>
|
||||||
|
IncludesWeek(x.WeekPattern, targetWeek)))
|
||||||
|
{
|
||||||
|
var reason = ScheduleConflictDetector.ConflictReason(target, existing);
|
||||||
|
if (reason is not null)
|
||||||
|
return $"目标时间存在{reason}冲突:{existing.TeachingTask!.Name}。";
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static System.Linq.Expressions.Expression<
|
private async Task<SourceOccurrence?> LoadSourceOccurrenceAsync(
|
||||||
|
Guid teachingTaskId,
|
||||||
|
Guid? scheduleEntryId,
|
||||||
|
int? week,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!scheduleEntryId.HasValue || !week.HasValue) return null;
|
||||||
|
var entry = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.AcademicTerm)
|
||||||
|
.FirstOrDefaultAsync(x =>
|
||||||
|
x.Id == scheduleEntryId.Value &&
|
||||||
|
x.TeachingTaskId == teachingTaskId &&
|
||||||
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
||||||
|
cancellationToken);
|
||||||
|
if (entry?.TeachingTask?.AcademicTerm is null ||
|
||||||
|
week < entry.StartWeek || week > entry.EndWeek ||
|
||||||
|
!IncludesWeek(entry.WeekPattern, week.Value))
|
||||||
|
return null;
|
||||||
|
var date = ResolveDate(
|
||||||
|
entry.TeachingTask.AcademicTerm.StartDate,
|
||||||
|
week.Value,
|
||||||
|
entry.DayOfWeek);
|
||||||
|
if (date < entry.TeachingTask.AcademicTerm.StartDate ||
|
||||||
|
date > entry.TeachingTask.AcademicTerm.EndDate)
|
||||||
|
return null;
|
||||||
|
return new SourceOccurrence(
|
||||||
|
entry,
|
||||||
|
entry.TeachingTask.AcademicTerm,
|
||||||
|
week.Value,
|
||||||
|
date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<ScheduleEntry> LoadSourceEntryAsync(
|
||||||
|
CourseAdjustment adjustment,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ScheduleEntries.SingleAsync(x =>
|
||||||
|
x.Id == adjustment.SourceScheduleEntryId &&
|
||||||
|
x.TeachingTaskId == adjustment.TeachingTaskId &&
|
||||||
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private void ExcludeWeek(ScheduleEntry entry, int week)
|
||||||
|
{
|
||||||
|
db.ScheduleEntries.Remove(entry);
|
||||||
|
if (entry.StartWeek <= week - 1 &&
|
||||||
|
HasIncludedWeek(entry.WeekPattern, entry.StartWeek, week - 1))
|
||||||
|
db.ScheduleEntries.Add(CloneEntry(entry, entry.StartWeek, week - 1));
|
||||||
|
if (week + 1 <= entry.EndWeek &&
|
||||||
|
HasIncludedWeek(entry.WeekPattern, week + 1, entry.EndWeek))
|
||||||
|
db.ScheduleEntries.Add(CloneEntry(entry, week + 1, entry.EndWeek));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScheduleEntry CreateTargetEntry(
|
||||||
|
CourseAdjustment adjustment,
|
||||||
|
Guid schedulePlanId)
|
||||||
|
{
|
||||||
|
var targetWeek = ResolveWeek(
|
||||||
|
adjustment.TeachingTask!.AcademicTerm!.StartDate,
|
||||||
|
adjustment.TargetDate!.Value);
|
||||||
|
return new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = schedulePlanId,
|
||||||
|
TeachingTaskId = adjustment.TeachingTaskId,
|
||||||
|
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
|
||||||
|
DayOfWeek = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek),
|
||||||
|
StartPeriod = adjustment.StartPeriod!.Value,
|
||||||
|
PeriodCount = adjustment.SourcePeriodCount!.Value,
|
||||||
|
StartWeek = targetWeek,
|
||||||
|
EndWeek = targetWeek,
|
||||||
|
WeekPattern = WeekPattern.All,
|
||||||
|
Notes = adjustment.Type == CourseAdjustmentType.Reschedule
|
||||||
|
? $"调课(原第 {adjustment.SourceWeek} 周)"
|
||||||
|
: $"补课(对应原第 {adjustment.SourceWeek} 周课次)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScheduleEntry CloneEntry(
|
||||||
|
ScheduleEntry source,
|
||||||
|
int startWeek,
|
||||||
|
int endWeek) => new()
|
||||||
|
{
|
||||||
|
SchedulePlanId = source.SchedulePlanId,
|
||||||
|
TeachingTaskId = source.TeachingTaskId,
|
||||||
|
ClassroomId = source.ClassroomId,
|
||||||
|
DayOfWeek = source.DayOfWeek,
|
||||||
|
StartPeriod = source.StartPeriod,
|
||||||
|
PeriodCount = source.PeriodCount,
|
||||||
|
StartWeek = startWeek,
|
||||||
|
EndWeek = endWeek,
|
||||||
|
WeekPattern = source.WeekPattern,
|
||||||
|
Notes = source.Notes
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool RequiresSourceOccurrence(CourseAdjustmentType type) =>
|
||||||
|
type is CourseAdjustmentType.Reschedule or
|
||||||
|
CourseAdjustmentType.Cancel or
|
||||||
|
CourseAdjustmentType.Makeup;
|
||||||
|
|
||||||
|
private static bool IncludesWeek(WeekPattern pattern, int week) =>
|
||||||
|
pattern == WeekPattern.All ||
|
||||||
|
pattern == WeekPattern.Odd && week % 2 == 1 ||
|
||||||
|
pattern == WeekPattern.Even && week % 2 == 0;
|
||||||
|
|
||||||
|
private static bool HasIncludedWeek(WeekPattern pattern, int startWeek, int endWeek) =>
|
||||||
|
Enumerable.Range(startWeek, endWeek - startWeek + 1)
|
||||||
|
.Any(week => IncludesWeek(pattern, week));
|
||||||
|
|
||||||
|
private static DateOnly ResolveDate(DateOnly termStartDate, int week, int dayOfWeek)
|
||||||
|
{
|
||||||
|
var startDay = (int)termStartDate.DayOfWeek;
|
||||||
|
var daysSinceMonday = (startDay + 6) % 7;
|
||||||
|
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
||||||
|
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveWeek(DateOnly termStartDate, DateOnly date)
|
||||||
|
{
|
||||||
|
var startDay = (int)termStartDate.DayOfWeek;
|
||||||
|
var daysSinceMonday = (startDay + 6) % 7;
|
||||||
|
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
|
||||||
|
return (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ToIsoDayOfWeek(DayOfWeek dayOfWeek) =>
|
||||||
|
dayOfWeek == System.DayOfWeek.Sunday ? 7 : (int)dayOfWeek;
|
||||||
|
|
||||||
|
private IQueryable<TeachingTask> AccessibleTeachingTasks()
|
||||||
|
{
|
||||||
|
var source = db.TeachingTasks.AsQueryable();
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.All) return source;
|
||||||
|
if (scope.Scope == DataScope.College)
|
||||||
|
return source.Where(x =>
|
||||||
|
x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||||
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
return source.Where(x =>
|
||||||
|
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
|
||||||
|
return source.Where(_ => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Linq.Expressions.Expression<
|
||||||
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
|
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -449,6 +808,17 @@ public sealed class CourseAdjustmentsController(
|
|||||||
x.Status,
|
x.Status,
|
||||||
x.Reason,
|
x.Reason,
|
||||||
x.ReviewComment,
|
x.ReviewComment,
|
||||||
|
x.SourceScheduleEntryId,
|
||||||
|
x.SourceWeek,
|
||||||
|
x.SourceDate,
|
||||||
|
x.SourceStartPeriod,
|
||||||
|
x.SourcePeriodCount,
|
||||||
|
SourceClassroomName = x.SourceClassroomId == null
|
||||||
|
? null
|
||||||
|
: db.Classrooms
|
||||||
|
.Where(room => room.Id == x.SourceClassroomId)
|
||||||
|
.Select(room => room.Building!.Name + " " + room.Name)
|
||||||
|
.FirstOrDefault(),
|
||||||
x.TargetDate,
|
x.TargetDate,
|
||||||
x.DayOfWeek,
|
x.DayOfWeek,
|
||||||
x.StartPeriod,
|
x.StartPeriod,
|
||||||
@@ -464,6 +834,12 @@ public sealed class CourseAdjustmentsController(
|
|||||||
x.CreatedAt
|
x.CreatedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private sealed record SourceOccurrence(
|
||||||
|
ScheduleEntry Entry,
|
||||||
|
AcademicTerm Term,
|
||||||
|
int Week,
|
||||||
|
DateOnly Date);
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
@@ -487,7 +863,9 @@ public sealed record CourseAdjustmentRequest(
|
|||||||
Guid? ClassroomId,
|
Guid? ClassroomId,
|
||||||
Guid? SubstituteTeacherId,
|
Guid? SubstituteTeacherId,
|
||||||
int? CancelWeek,
|
int? CancelWeek,
|
||||||
DateOnly? CancelDate);
|
DateOnly? CancelDate,
|
||||||
|
Guid? SourceScheduleEntryId = null,
|
||||||
|
int? SourceWeek = null);
|
||||||
|
|
||||||
public sealed record RejectionRequest(
|
public sealed record RejectionRequest(
|
||||||
[MaxLength(500)] string? Comment);
|
[MaxLength(500)] string? Comment);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/courses")]
|
[Route("api/courses")]
|
||||||
public sealed class CoursesController(
|
public sealed class CoursesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string WriteRoles =
|
private const string WriteRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -273,6 +275,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)
|
||||||
|
|||||||
@@ -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,18 +1,35 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
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;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Controllers;
|
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,
|
||||||
|
IAppCache appCache,
|
||||||
|
IOptions<JsonOptions> jsonOptions) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await appCache.GetOrCreateAsync(
|
||||||
|
AppCacheKeys.Dashboard,
|
||||||
|
LoadAsync,
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.Analytics],
|
||||||
|
cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var currentTerm = await db.AcademicTerms
|
var currentTerm = await db.AcademicTerms
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -20,7 +37,8 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
|||||||
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
return new
|
return JsonSerializer.SerializeToElement(
|
||||||
|
new
|
||||||
{
|
{
|
||||||
CurrentTerm = currentTerm,
|
CurrentTerm = currentTerm,
|
||||||
Counts = new
|
Counts = new
|
||||||
@@ -78,6 +96,7 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
Users = await db.Users.CountAsync(cancellationToken)
|
Users = await db.Users.CountAsync(cancellationToken)
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
jsonOptions.Value.JsonSerializerOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +79,34 @@ 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);
|
||||||
return Ok(new
|
|
||||||
|
var summary = await source
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(group => new
|
||||||
{
|
{
|
||||||
batch.Id,
|
ResultCount = group.Count(),
|
||||||
batch.Name,
|
GrantedCount = group.Count(x =>
|
||||||
batch.GraduationYear,
|
x.Conclusion == DegreeAwardConclusion.Granted),
|
||||||
batch.DegreeName,
|
NotGrantedCount = group.Count(x =>
|
||||||
batch.MinimumGradePoint,
|
x.Conclusion == DegreeAwardConclusion.NotGranted),
|
||||||
batch.Status,
|
OverrideCount = group.Count(x => x.IsOverridden)
|
||||||
batch.Notes,
|
})
|
||||||
batch.CalculatedAt,
|
.FirstOrDefaultAsync(token);
|
||||||
batch.PublishedAt,
|
|
||||||
Results = await source.OrderBy(x => x.Student!.StudentNumber)
|
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
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -99,7 +123,30 @@ public sealed class DegreeAwardsController(
|
|||||||
x.IsOverridden,
|
x.IsOverridden,
|
||||||
x.ReviewComment,
|
x.ReviewComment,
|
||||||
x.ReviewedAt
|
x.ReviewedAt
|
||||||
}).ToListAsync(token)
|
}).ToListAsync(token);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
batch.Id,
|
||||||
|
batch.Name,
|
||||||
|
batch.GraduationYear,
|
||||||
|
batch.DegreeName,
|
||||||
|
batch.MinimumGradePoint,
|
||||||
|
batch.Status,
|
||||||
|
batch.Notes,
|
||||||
|
batch.CalculatedAt,
|
||||||
|
batch.PublishedAt,
|
||||||
|
ResultCount = summary?.ResultCount ?? 0,
|
||||||
|
GrantedCount = summary?.GrantedCount ?? 0,
|
||||||
|
NotGrantedCount = summary?.NotGrantedCount ?? 0,
|
||||||
|
OverrideCount = summary?.OverrideCount ?? 0,
|
||||||
|
Results = new
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Total = total,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,25 +180,30 @@ public sealed class DegreeAwardsController(
|
|||||||
return ConflictProblem("已发布批次不能重新计算。");
|
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}"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -16,7 +17,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
public sealed class ExamsController(
|
public sealed class ExamsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
ExamArrangementService examArrangementService) : ControllerBase
|
ExamArrangementService examArrangementService,
|
||||||
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string Managers =
|
private const string Managers =
|
||||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
@@ -167,6 +169,95 @@ public sealed class ExamsController(
|
|||||||
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,
|
||||||
|
CreateExamSessionsBatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (plan.Status != ExamPlanStatus.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.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == planId)
|
||||||
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (duplicateTaskIds.Count > 0)
|
||||||
|
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。");
|
||||||
|
|
||||||
|
var enrollmentPairs = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.CourseSelectionOffering!.TeachingTaskId
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var conflictedStudentCount = enrollmentPairs
|
||||||
|
.GroupBy(x => x.StudentId)
|
||||||
|
.Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
|
||||||
|
if (conflictedStudentCount > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突,请分批选择不同时间。");
|
||||||
|
|
||||||
|
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.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession
|
||||||
|
{
|
||||||
|
ExamPlanId = planId,
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = request.ExamDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
}));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, 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(
|
||||||
@@ -235,11 +326,19 @@ public sealed class ExamsController(
|
|||||||
[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 examArrangementService.ArrangeAsync(planId, cancellationToken);
|
request ??= new ExamAutoArrangeRequest();
|
||||||
|
var result = await examArrangementService.ArrangeAsync(
|
||||||
|
planId,
|
||||||
|
request.SessionIds,
|
||||||
|
request.AssignClassrooms,
|
||||||
|
request.AssignInvigilators,
|
||||||
|
cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return ConflictProblem(result.Message);
|
return ConflictProblem(result.Message);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
return Ok(new { message = result.Message });
|
return Ok(new { message = result.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,10 +501,13 @@ public sealed class ExamsController(
|
|||||||
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 = db.CourseEnrollments
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
|
||||||
|
var students = await db.CourseEnrollments.AsNoTracking()
|
||||||
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
|
e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId)
|
||||||
.OrderBy(e => e.Student!.StudentNumber)
|
.OrderBy(e => e.Student!.StudentNumber)
|
||||||
.Select(e => new
|
.Select(e => new
|
||||||
{
|
{
|
||||||
@@ -413,9 +515,26 @@ public sealed class ExamsController(
|
|||||||
e.Student!.StudentNumber,
|
e.Student!.StudentNumber,
|
||||||
e.Student.Name,
|
e.Student.Name,
|
||||||
ClassName = e.Student.AdministrativeClass!.Name
|
ClassName = e.Student.AdministrativeClass!.Name
|
||||||
}).ToList()
|
})
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return session is null ? NotFound() : Ok(session);
|
|
||||||
|
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,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -432,7 +551,7 @@ public sealed class ExamsController(
|
|||||||
.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.ExamSessions.AsNoTracking()
|
var schedule = await db.ExamSessions.AsNoTracking()
|
||||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||||
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
||||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
@@ -453,9 +572,52 @@ public sealed class ExamsController(
|
|||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||||
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
|
x.TeachingTaskId,
|
||||||
IsExam = true
|
IsExam = true
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
if (schedule.Count == 0) return Ok(schedule);
|
||||||
|
|
||||||
|
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray();
|
||||||
|
var seatRows = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||||
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.CourseSelectionOffering!.TeachingTaskId,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var seatNumbers = seatRows
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.SelectMany(group => group.Select((row, index) => new
|
||||||
|
{
|
||||||
|
row.TeachingTaskId,
|
||||||
|
row.StudentId,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
}))
|
||||||
|
.ToDictionary(x => (x.TeachingTaskId, 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.InvigilatorNames,
|
||||||
|
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)),
|
||||||
|
x.IsExam
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (scope.IsInRole(SystemRoles.Teacher))
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
{
|
{
|
||||||
@@ -610,6 +772,7 @@ public sealed class ExamsController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(token);
|
await db.SaveChangesAsync(token);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, token);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
@@ -647,3 +810,17 @@ public sealed record CreateExamSessionRequest(
|
|||||||
[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 CreateExamSessionsBatchRequest(
|
||||||
|
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record ExamAutoArrangeRequest(
|
||||||
|
IReadOnlyCollection<Guid>? SessionIds = null,
|
||||||
|
bool AssignClassrooms = true,
|
||||||
|
bool AssignInvigilators = true);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -40,18 +42,38 @@ public sealed class GradesController(
|
|||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
GradeSheetStatus? status,
|
GradeSheetStatus? status,
|
||||||
CancellationToken cancellationToken)
|
string? keyword = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||||
var source = AccessibleTasks().AsNoTracking()
|
var source = AccessibleTasks().AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.Status == TeachingTaskStatus.Published ||
|
x.Status == TeachingTaskStatus.Published ||
|
||||||
x.Status == TeachingTaskStatus.Closed);
|
x.Status == TeachingTaskStatus.Closed);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (status.HasValue)
|
||||||
|
source = source.Where(x => db.GradeSheets.Any(sheet =>
|
||||||
|
sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.TaskNumber.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
x.Course!.Code.Contains(keyword) ||
|
||||||
|
x.Course.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
var items = await source
|
var items = await source
|
||||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
.ThenBy(x => x.TaskNumber)
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -95,10 +117,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 +153,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 +182,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
|
||||||
@@ -200,8 +227,24 @@ public sealed class GradesController(
|
|||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
x.ReviewedAt,
|
x.ReviewedAt,
|
||||||
x.PublishedAt,
|
x.PublishedAt,
|
||||||
Records = x.Records
|
x.CreatedAt,
|
||||||
|
x.UpdatedAt
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
|
||||||
|
var recordsSource = db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(record => record.GradeSheetId == id);
|
||||||
|
if (studentKeyword is not null)
|
||||||
|
recordsSource = recordsSource.Where(record =>
|
||||||
|
record.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||||
|
record.Student.Name.Contains(studentKeyword) ||
|
||||||
|
record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
|
||||||
|
var recordTotal = await recordsSource.CountAsync(cancellationToken);
|
||||||
|
var records = await recordsSource
|
||||||
.OrderBy(record => record.Student!.StudentNumber)
|
.OrderBy(record => record.Student!.StudentNumber)
|
||||||
|
.Skip((recordPage - 1) * recordPageSize)
|
||||||
|
.Take(recordPageSize)
|
||||||
.Select(record => new
|
.Select(record => new
|
||||||
{
|
{
|
||||||
record.Id,
|
record.Id,
|
||||||
@@ -224,12 +267,8 @@ public sealed class GradesController(
|
|||||||
record.ExamStatus,
|
record.ExamStatus,
|
||||||
record.Notes,
|
record.Notes,
|
||||||
record.UpdatedAt
|
record.UpdatedAt
|
||||||
}),
|
|
||||||
x.CreatedAt,
|
|
||||||
x.UpdatedAt
|
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
|
||||||
|
|
||||||
var task = await db.TeachingTasks.AsNoTracking()
|
var task = await db.TeachingTasks.AsNoTracking()
|
||||||
.Include(x => x.Teachers)
|
.Include(x => x.Teachers)
|
||||||
@@ -244,7 +283,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,
|
||||||
@@ -411,7 +479,8 @@ public sealed class GradesController(
|
|||||||
$"《{courseName}》成绩已提交,请及时审核。",
|
$"《{courseName}》成绩已提交,请及时审核。",
|
||||||
collegeId,
|
collegeId,
|
||||||
"/grades",
|
"/grades",
|
||||||
cancellationToken);
|
cancellationToken,
|
||||||
|
NotificationCategory.Grade);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +521,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 +566,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 +574,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 +586,11 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Notify enrolled students
|
// The grade sheet roster is authoritative at publication time. This also
|
||||||
var studentUserIds = await db.CourseEnrollments
|
// covers students added through approved roster corrections.
|
||||||
.Where(x =>
|
var studentUserIds = await db.GradeRecords
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
|
.Where(x => x.GradeSheetId == sheet.Id)
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
|
||||||
.Select(x => x.Student!.UserId)
|
.Select(x => x.Student!.UserId)
|
||||||
.Where(id => id != null)
|
.Where(id => id != null)
|
||||||
.Select(id => id!.Value)
|
.Select(id => id!.Value)
|
||||||
@@ -528,8 +598,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +81,35 @@ 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);
|
||||||
return Ok(new
|
|
||||||
|
var summary = await results
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(group => new
|
||||||
{
|
{
|
||||||
batch.Id,
|
ResultCount = group.Count(),
|
||||||
batch.Name,
|
EligibleCount = group.Count(x =>
|
||||||
batch.GraduationYear,
|
x.Conclusion == GraduationAuditConclusion.Eligible),
|
||||||
batch.EnrollmentYear,
|
IneligibleCount = group.Count(x =>
|
||||||
batch.Status,
|
x.Conclusion == GraduationAuditConclusion.Ineligible),
|
||||||
batch.Notes,
|
OverrideCount = group.Count(x => x.IsOverridden)
|
||||||
batch.CalculatedAt,
|
})
|
||||||
batch.PublishedAt,
|
.FirstOrDefaultAsync(token);
|
||||||
Results = await results
|
|
||||||
|
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)
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -107,7 +132,29 @@ public sealed class GraduationAuditsController(
|
|||||||
x.IsOverridden,
|
x.IsOverridden,
|
||||||
x.ReviewComment,
|
x.ReviewComment,
|
||||||
x.ReviewedAt
|
x.ReviewedAt
|
||||||
}).ToListAsync(token)
|
}).ToListAsync(token);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
batch.Id,
|
||||||
|
batch.Name,
|
||||||
|
batch.GraduationYear,
|
||||||
|
batch.EnrollmentYear,
|
||||||
|
batch.Status,
|
||||||
|
batch.Notes,
|
||||||
|
batch.CalculatedAt,
|
||||||
|
batch.PublishedAt,
|
||||||
|
ResultCount = summary?.ResultCount ?? 0,
|
||||||
|
EligibleCount = summary?.EligibleCount ?? 0,
|
||||||
|
IneligibleCount = summary?.IneligibleCount ?? 0,
|
||||||
|
OverrideCount = summary?.OverrideCount ?? 0,
|
||||||
|
Results = new
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Total = total,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +203,11 @@ public sealed class GraduationAuditsController(
|
|||||||
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
|
.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;
|
||||||
@@ -18,8 +19,7 @@ public sealed class MakeupExamsController(
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
MakeupExamEligibilityService eligibilityService,
|
MakeupExamEligibilityService eligibilityService,
|
||||||
MakeupExamArrangementService arrangementService,
|
MakeupExamArrangementService arrangementService) : ControllerBase
|
||||||
MakeupExamAutoJobQueue autoJobQueue) : ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string Managers =
|
private const string Managers =
|
||||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
@@ -224,6 +224,78 @@ 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)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
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(
|
||||||
@@ -292,9 +364,16 @@ 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();
|
||||||
|
var result = await arrangementService.ArrangeAsync(
|
||||||
|
planId,
|
||||||
|
request.SessionIds,
|
||||||
|
request.AssignClassrooms,
|
||||||
|
request.AssignInvigilators,
|
||||||
|
cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return ConflictProblem(result.Message);
|
return ConflictProblem(result.Message);
|
||||||
return Ok(new { message = result.Message });
|
return Ok(new { message = result.Message });
|
||||||
@@ -336,10 +415,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,8 +634,13 @@ 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)
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
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
|
.Select(e => new
|
||||||
{
|
{
|
||||||
e.StudentId,
|
e.StudentId,
|
||||||
@@ -563,9 +649,28 @@ public sealed class MakeupExamsController(
|
|||||||
ClassName = e.Student.AdministrativeClass!.Name,
|
ClassName = e.Student.AdministrativeClass!.Name,
|
||||||
e.Reason,
|
e.Reason,
|
||||||
e.MakeupScore
|
e.MakeupScore
|
||||||
}).ToList()
|
})
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return session is null ? NotFound() : Ok(session);
|
|
||||||
|
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 +802,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 +827,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))
|
||||||
{
|
{
|
||||||
@@ -939,6 +1086,15 @@ public sealed record CreateMakeupExamSessionRequest(
|
|||||||
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,
|
||||||
|
[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,4 @@
|
|||||||
using System.Security.Claims;
|
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.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -16,18 +16,38 @@ public sealed class NotificationsController(
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
|
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 +56,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 +99,362 @@ 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))
|
||||||
|
{
|
||||||
|
var recipientCount = await db.Users.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.IsEnabled && x.Id != scope.UserId,
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
AudienceType = MessageAudienceType.School,
|
||||||
|
AudienceName = "全校已启用账号",
|
||||||
|
RecipientCount = recipientCount,
|
||||||
|
TeachingTasks = Array.Empty<object>()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("当前账号关联的学院不存在。");
|
||||||
|
|
||||||
|
var recipientCount = await db.Users.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.IsEnabled &&
|
||||||
|
x.Id != scope.UserId &&
|
||||||
|
x.CollegeId == collegeId.Value,
|
||||||
|
cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
AudienceType = MessageAudienceType.College,
|
||||||
|
AudienceName = $"{college}全院成员",
|
||||||
|
RecipientCount = recipientCount,
|
||||||
|
TeachingTasks = Array.Empty<object>()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 (content.Length == 0)
|
||||||
|
return ValidationProblem("请填写消息正文。");
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
MessageAudienceType audienceType;
|
||||||
|
Guid? audienceId = null;
|
||||||
|
string audienceName;
|
||||||
|
List<Guid> recipientIds;
|
||||||
|
|
||||||
|
if (IsSchoolAdministrator(scope))
|
||||||
|
{
|
||||||
|
audienceType = MessageAudienceType.School;
|
||||||
|
audienceName = "全校已启用账号";
|
||||||
|
recipientIds = await db.Users.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.Id != scope.UserId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
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("当前账号关联的学院不存在。");
|
||||||
|
|
||||||
|
audienceType = MessageAudienceType.College;
|
||||||
|
audienceId = collegeId.Value;
|
||||||
|
audienceName = $"{collegeName}全院成员";
|
||||||
|
recipientIds = await db.Users.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.IsEnabled &&
|
||||||
|
x.Id != scope.UserId &&
|
||||||
|
x.CollegeId == collegeId.Value)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
};
|
||||||
|
dispatch.Notifications = recipientIds.Select(userId => new Notification
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Title = title,
|
||||||
|
Content = content,
|
||||||
|
Category = NotificationCategory.General,
|
||||||
|
LinkUrl = null
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
db.MessageDispatches.Add(dispatch);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
dispatch.Id,
|
||||||
|
dispatch.AudienceName,
|
||||||
|
dispatch.RecipientCount,
|
||||||
|
dispatch.CreatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 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(
|
||||||
|
[property: Required, StringLength(200)] string Title,
|
||||||
|
[property: Required, StringLength(1000)] string Content,
|
||||||
|
Guid? TeachingTaskId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Centralized helper to send notifications across the app.
|
/// Centralized helper to send notifications across the app.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -90,13 +466,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 +487,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,
|
||||||
var userIds = query.Where(x => x.RoleName == roleName);
|
userRole => userRole.UserId,
|
||||||
|
(user, userRole) => new
|
||||||
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
|
|
||||||
{
|
{
|
||||||
userIds = userIds.Where(x =>
|
user.Id,
|
||||||
db.Teachers.Any(t =>
|
user.IsEnabled,
|
||||||
t.UserId == x.Id && t.CollegeId == collegeId.Value));
|
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 &&
|
||||||
|
x.IsEnabled);
|
||||||
|
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
{
|
||||||
|
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken);
|
var ids = await userIds
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
foreach (var id in ids)
|
foreach (var id in ids)
|
||||||
{
|
{
|
||||||
db.Notifications.Add(new Notification
|
db.Notifications.Add(new Notification
|
||||||
@@ -132,6 +534,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 +547,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 +557,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;
|
||||||
@@ -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);
|
||||||
@@ -429,6 +434,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 +453,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)
|
||||||
|
|||||||
@@ -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 + "," +
|
||||||
@@ -142,6 +144,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)
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -15,9 +16,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 + "," +
|
||||||
@@ -241,6 +240,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 +266,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 +323,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 +345,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 },
|
||||||
|
|||||||
@@ -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 + "," +
|
||||||
@@ -34,14 +37,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 +150,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 +174,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 +258,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 +279,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 +415,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 +438,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 +574,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 +594,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 +704,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 +722,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 +927,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 +947,54 @@ 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);
|
||||||
|
if (source.Result is not null || source.Value is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Statistics cache source did not return a successful value.");
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToElement(
|
||||||
|
source.Value,
|
||||||
|
source.Value.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,
|
||||||
|
currentUserDataScope.Current.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)
|
||||||
|
|||||||
@@ -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 + "," +
|
||||||
@@ -389,6 +391,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)
|
||||||
@@ -544,6 +547,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,
|
||||||
@@ -737,6 +743,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 < x.StartPeriod + x.PeriodCount)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.ClassroomId,
|
|
||||||
x.WeekPattern,
|
|
||||||
x.StartPeriod,
|
|
||||||
x.PeriodCount
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
var occupiedIds = candidates
|
|
||||||
.Where(x =>
|
|
||||||
FreeClassroomRules.MatchesWeek(x.WeekPattern, week) &&
|
|
||||||
FreeClassroomRules.PeriodsOverlap(
|
|
||||||
startPeriod,
|
startPeriod,
|
||||||
periodCount,
|
periodCount,
|
||||||
x.StartPeriod,
|
null,
|
||||||
x.PeriodCount))
|
cancellationToken);
|
||||||
.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,7 +305,8 @@ 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
|
||||||
|
? await timetableDataService.BuildAsync(
|
||||||
TimetableResourceType.Class,
|
TimetableResourceType.Class,
|
||||||
classId,
|
classId,
|
||||||
academicTermId,
|
academicTermId,
|
||||||
@@ -277,10 +314,102 @@ public sealed class TimetablesController(
|
|||||||
false,
|
false,
|
||||||
studentId,
|
studentId,
|
||||||
student,
|
student,
|
||||||
|
cancellationToken)
|
||||||
|
: await GetPublishedTimetableAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
classId,
|
||||||
|
academicTermId,
|
||||||
cancellationToken);
|
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)))
|
||||||
|
.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)))))
|
||||||
|
.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 +424,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,42 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,43 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BackgroundJobOutboxState
|
||||||
|
{
|
||||||
|
Pending = 1,
|
||||||
|
Publishing = 2,
|
||||||
|
Published = 3,
|
||||||
|
Processing = 4,
|
||||||
|
Completed = 5
|
||||||
|
}
|
||||||
@@ -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,47 @@
|
|||||||
|
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 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,
|
||||||
|
_ => 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,296 @@
|
|||||||
|
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 missingKeys = automaticJobs
|
||||||
|
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||||
|
.Concat(publishJobs.Select(id =>
|
||||||
|
(BackgroundJobKind.SchedulePublish, id)))
|
||||||
|
.Concat(makeupJobs.Select(id =>
|
||||||
|
(BackgroundJobKind.MakeupExamAuto, 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,348 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
|
|
||||||
|
public sealed record BackgroundJobRunResult(
|
||||||
|
BackgroundJobRunOutcome Outcome,
|
||||||
|
TimeSpan RetryAfter)
|
||||||
|
{
|
||||||
|
public static BackgroundJobRunResult Completed { get; } =
|
||||||
|
new(BackgroundJobRunOutcome.Completed, TimeSpan.Zero);
|
||||||
|
|
||||||
|
public static BackgroundJobRunResult Retry(TimeSpan retryAfter) =>
|
||||||
|
new(BackgroundJobRunOutcome.Retry, retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BackgroundJobRunOutcome
|
||||||
|
{
|
||||||
|
Completed,
|
||||||
|
Retry
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BackgroundJobRunner(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IBackgroundJobTransport transport,
|
||||||
|
BackgroundJobOptions options,
|
||||||
|
BackgroundJobTelemetry telemetry,
|
||||||
|
ILogger<BackgroundJobRunner> logger)
|
||||||
|
{
|
||||||
|
public async Task<BackgroundJobRunResult> RunAsync(
|
||||||
|
BackgroundJobEnvelope message,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var startedAt = Stopwatch.GetTimestamp();
|
||||||
|
var token = Guid.NewGuid();
|
||||||
|
var claim = await TryClaimAsync(message, token, cancellationToken);
|
||||||
|
if (!claim.Claimed)
|
||||||
|
{
|
||||||
|
return claim.Completed
|
||||||
|
? BackgroundJobRunResult.Completed
|
||||||
|
: BackgroundJobRunResult.Retry(claim.RetryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.AttemptsExceeded)
|
||||||
|
{
|
||||||
|
await MarkJobRetryLimitExceededAsync(message, cancellationToken);
|
||||||
|
await MarkCompletedAsync(message.OutboxMessageId, token,
|
||||||
|
cancellationToken);
|
||||||
|
telemetry.RecordProcessing(
|
||||||
|
message.JobKind,
|
||||||
|
"retry-limit",
|
||||||
|
Stopwatch.GetElapsedTime(startedAt));
|
||||||
|
return BackgroundJobRunResult.Completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var heartbeatCancellation =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
var heartbeat = RunHeartbeatAsync(
|
||||||
|
message.OutboxMessageId,
|
||||||
|
token,
|
||||||
|
heartbeatCancellation.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
switch (message.JobKind)
|
||||||
|
{
|
||||||
|
case BackgroundJobKind.AutomaticSchedule:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<AutomaticScheduleJobProcessor>()
|
||||||
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
|
break;
|
||||||
|
case BackgroundJobKind.SchedulePublish:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<SchedulePublishJobProcessor>()
|
||||||
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
|
break;
|
||||||
|
case BackgroundJobKind.MakeupExamAuto:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<MakeupExamAutoJobProcessor>()
|
||||||
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
|
break;
|
||||||
|
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;
|
||||||
|
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,431 @@
|
|||||||
|
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
|
||||||
|
];
|
||||||
|
|
||||||
|
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",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string QueueName(
|
||||||
|
BackgroundJobOptions options,
|
||||||
|
BackgroundJobKind kind) =>
|
||||||
|
$"{options.QueuePrefix}.{RoutingKey(kind)}";
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> QueueArguments(
|
||||||
|
BackgroundJobOptions options)
|
||||||
|
{
|
||||||
|
var arguments = new Dictionary<string, object?>();
|
||||||
|
if (options.UseQuorumQueues)
|
||||||
|
arguments["x-queue-type"] = "quorum";
|
||||||
|
return arguments;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Hybrid;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
|
||||||
|
public enum AppCacheProfile
|
||||||
|
{
|
||||||
|
ReferenceData,
|
||||||
|
PublishedTimetable,
|
||||||
|
Analytics
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAppCache
|
||||||
|
{
|
||||||
|
Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
AppCacheProfile profile,
|
||||||
|
IReadOnlyCollection<string> tags,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
ValueTask RemoveByTagAsync(
|
||||||
|
string tag,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HybridAppCache(
|
||||||
|
HybridCache cache,
|
||||||
|
AppCacheOptions options,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
ILogger<HybridAppCache> logger) : IAppCache
|
||||||
|
{
|
||||||
|
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
|
||||||
|
|
||||||
|
public async Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
AppCacheProfile profile,
|
||||||
|
IReadOnlyCollection<string> tags,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Enabled)
|
||||||
|
return await factory(cancellationToken);
|
||||||
|
|
||||||
|
var sourceCompleted = false;
|
||||||
|
T? sourceValue = default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await cache.GetOrCreateAsync(
|
||||||
|
$"{prefix}:{key}",
|
||||||
|
async token =>
|
||||||
|
{
|
||||||
|
sourceValue = await factory(token);
|
||||||
|
sourceCompleted = true;
|
||||||
|
return sourceValue;
|
||||||
|
},
|
||||||
|
GetEntryOptions(profile),
|
||||||
|
tags.Select(tag => $"{prefix}:tag:{tag}"),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (RedisException exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Redis cache operation failed for {CacheKey}; using the source directly.",
|
||||||
|
key);
|
||||||
|
if (sourceCompleted)
|
||||||
|
return sourceValue!;
|
||||||
|
return await factory(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask RemoveByTagAsync(
|
||||||
|
string tag,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!options.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
$"{prefix}:tag:{tag}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (RedisException exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.",
|
||||||
|
tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) =>
|
||||||
|
profile switch
|
||||||
|
{
|
||||||
|
AppCacheProfile.ReferenceData => new HybridCacheEntryOptions
|
||||||
|
{
|
||||||
|
Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes),
|
||||||
|
LocalCacheExpiration =
|
||||||
|
TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds)
|
||||||
|
},
|
||||||
|
AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions
|
||||||
|
{
|
||||||
|
Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes),
|
||||||
|
LocalCacheExpiration =
|
||||||
|
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
|
||||||
|
},
|
||||||
|
AppCacheProfile.Analytics => new HybridCacheEntryOptions
|
||||||
|
{
|
||||||
|
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
|
||||||
|
LocalCacheExpiration =
|
||||||
|
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
|
||||||
|
},
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string BuildPrefix(string configuredPrefix, string environmentName)
|
||||||
|
{
|
||||||
|
var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix)
|
||||||
|
? "jiaowu:v1"
|
||||||
|
: configuredPrefix.Trim().Trim(':');
|
||||||
|
var environmentValue = string.Concat(
|
||||||
|
environmentName.Trim().ToLowerInvariant()
|
||||||
|
.Select(character => char.IsLetterOrDigit(character) ? character : '-'));
|
||||||
|
return $"{prefixValue}:{environmentValue}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NoOpAppCache : IAppCache
|
||||||
|
{
|
||||||
|
public static NoOpAppCache Instance { get; } = new();
|
||||||
|
|
||||||
|
private NoOpAppCache()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
AppCacheProfile profile,
|
||||||
|
IReadOnlyCollection<string> tags,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
factory(cancellationToken);
|
||||||
|
|
||||||
|
public ValueTask RemoveByTagAsync(
|
||||||
|
string tag,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AppCacheKeys
|
||||||
|
{
|
||||||
|
public const string ActivationOptions = "auth:activation-options";
|
||||||
|
public const string Dashboard = "dashboard:summary";
|
||||||
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
|
public static string BaseData(string kind) => $"base-data:{kind}";
|
||||||
|
|
||||||
|
public static string PublishedTimetable(
|
||||||
|
string resourceType,
|
||||||
|
Guid resourceId,
|
||||||
|
Guid? academicTermId) =>
|
||||||
|
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
|
||||||
|
$"{resourceType}:{resourceId:N}";
|
||||||
|
|
||||||
|
public static string Statistics(
|
||||||
|
string area,
|
||||||
|
string dataScope,
|
||||||
|
Guid? effectiveCollegeId,
|
||||||
|
params string?[] filters)
|
||||||
|
{
|
||||||
|
static string Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? "-"
|
||||||
|
: value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var filterPart = filters.Length == 0
|
||||||
|
? "all"
|
||||||
|
: string.Join(':', filters.Select(Normalize));
|
||||||
|
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||||
|
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AppCacheTags
|
||||||
|
{
|
||||||
|
public const string BaseData = "base-data";
|
||||||
|
public const string Analytics = "analytics";
|
||||||
|
public const string Timetables = "timetables";
|
||||||
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
|
public static string Timetable(Guid academicTermId) =>
|
||||||
|
$"timetable:term:{academicTermId:N}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
|
||||||
|
public sealed class AppCacheOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Cache";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public string KeyPrefix { get; set; } = "jiaowu:v1";
|
||||||
|
public int ReferenceExpirationMinutes { get; set; } = 30;
|
||||||
|
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
|
||||||
|
public int TimetableExpirationMinutes { get; set; } = 10;
|
||||||
|
public int TimetableLocalExpirationSeconds { get; set; } = 30;
|
||||||
|
public int AnalyticsExpirationMinutes { get; set; } = 3;
|
||||||
|
public int AnalyticsLocalExpirationSeconds { get; set; } = 30;
|
||||||
|
public int MaximumPayloadKilobytes { get; set; } = 2048;
|
||||||
|
}
|
||||||
@@ -14,9 +14,23 @@ public static class CourseSelectionRules
|
|||||||
round.Status == CourseSelectionRoundStatus.Open &&
|
round.Status == CourseSelectionRoundStatus.Open &&
|
||||||
nowUtc <= round.WithdrawalEndsAt;
|
nowUtc <= round.WithdrawalEndsAt;
|
||||||
|
|
||||||
|
public static bool CanPromoteWaitlist(CourseSelectionRound round, DateTime nowUtc) =>
|
||||||
|
round.Status == CourseSelectionRoundStatus.Open &&
|
||||||
|
nowUtc <= round.WithdrawalEndsAt;
|
||||||
|
|
||||||
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||||
nature == CourseNature.GeneralRequired;
|
nature == CourseNature.GeneralRequired;
|
||||||
|
|
||||||
|
public static bool IsGradeEligible(
|
||||||
|
IEnumerable<int> eligibleGrades,
|
||||||
|
int studentGrade) =>
|
||||||
|
!eligibleGrades.Any() || eligibleGrades.Contains(studentGrade);
|
||||||
|
|
||||||
|
public static bool HasReachedCourseLimit(
|
||||||
|
int? maxCourseCount,
|
||||||
|
int selectedCourseCount) =>
|
||||||
|
maxCourseCount.HasValue && selectedCourseCount >= maxCourseCount.Value;
|
||||||
|
|
||||||
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
||||||
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
||||||
|
|
||||||
@@ -68,4 +82,7 @@ public static class CourseSelectionRules
|
|||||||
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
||||||
public static int RetakeCapacity(int originalCapacity) =>
|
public static int RetakeCapacity(int originalCapacity) =>
|
||||||
(int)Math.Ceiling(originalCapacity * 1.15);
|
(int)Math.Ceiling(originalCapacity * 1.15);
|
||||||
|
|
||||||
|
public static int EffectiveCapacity(int originalCapacity, bool isRetake) =>
|
||||||
|
isRetake ? RetakeCapacity(originalCapacity) : originalCapacity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ public sealed class ExamArrangementService(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.ExamPlans
|
var plan = await db.ExamPlans
|
||||||
.Include(x => x.AcademicTerm)
|
.Include(x => x.AcademicTerm)
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
@@ -27,9 +33,23 @@ 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 ?? [])
|
||||||
|
.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 +64,8 @@ public sealed class ExamArrangementService(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>();
|
||||||
|
|
||||||
// Track occupied time slots to avoid conflicts
|
// Track occupied time slots to avoid conflicts
|
||||||
@@ -69,7 +89,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
cancellationToken);
|
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, studentCount, occupiedRooms, cancellationToken);
|
session, studentCount, occupiedRooms, cancellationToken);
|
||||||
@@ -83,20 +103,15 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
skippedRooms++;
|
unavailableRooms++;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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();
|
||||||
@@ -116,12 +131,11 @@ public sealed class ExamArrangementService(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++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +143,9 @@ public sealed class ExamArrangementService(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))}" : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -27,9 +33,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 +64,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 +86,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);
|
||||||
@@ -80,20 +100,15 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
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 +128,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 +140,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))}" : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,111 +1,10 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Channels;
|
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
public sealed class MakeupExamAutoJobQueue
|
|
||||||
{
|
|
||||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
|
||||||
new UnboundedChannelOptions
|
|
||||||
{
|
|
||||||
SingleReader = true,
|
|
||||||
SingleWriter = false
|
|
||||||
});
|
|
||||||
|
|
||||||
public void Enqueue(Guid jobId)
|
|
||||||
{
|
|
||||||
if (!_channel.Writer.TryWrite(jobId))
|
|
||||||
throw new InvalidOperationException("补考自动生成任务队列当前不可用。");
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
|
||||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class MakeupExamAutoJobWorker(
|
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
MakeupExamAutoJobQueue queue,
|
|
||||||
ILogger<MakeupExamAutoJobWorker> logger) : BackgroundService
|
|
||||||
{
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogWarning(
|
|
||||||
exception,
|
|
||||||
"Could not recover makeup exam auto jobs (table may not exist yet).");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
|
||||||
var processor = scope.ServiceProvider
|
|
||||||
.GetRequiredService<MakeupExamAutoJobProcessor>();
|
|
||||||
await processor.ProcessAsync(jobId, stoppingToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Unexpected failure while dispatching makeup exam auto job {JobId}.",
|
|
||||||
jobId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Makeup exam auto job worker is stopping.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
var jobs = await db.MakeupExamAutoJobs
|
|
||||||
.Where(x =>
|
|
||||||
x.Status == MakeupExamAutoJobStatus.Queued ||
|
|
||||||
x.Status == MakeupExamAutoJobStatus.Running)
|
|
||||||
.OrderBy(x => x.CreatedAt)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
foreach (var job in jobs)
|
|
||||||
{
|
|
||||||
job.Status = MakeupExamAutoJobStatus.Queued;
|
|
||||||
job.StartedAt = null;
|
|
||||||
job.CompletedAt = null;
|
|
||||||
job.ErrorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobs.Count > 0)
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
foreach (var job in jobs)
|
|
||||||
queue.Enqueue(job.Id);
|
|
||||||
|
|
||||||
if (jobs.Count > 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation(
|
|
||||||
"Recovered {JobCount} queued or interrupted makeup exam auto jobs.",
|
|
||||||
jobs.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class MakeupExamAutoJobProcessor(
|
public sealed class MakeupExamAutoJobProcessor(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
MakeupExamEligibilityService eligibilityService,
|
MakeupExamEligibilityService eligibilityService,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||||
|
|
||||||
|
public sealed class OfficialDocumentOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "OfficialDocuments";
|
||||||
|
|
||||||
|
public string InstitutionName { get; set; } = "明序大学";
|
||||||
|
public string IssuingOffice { get; set; } = "教务处";
|
||||||
|
public string DocumentNumberPrefix { get; set; } = "MXU";
|
||||||
|
public string? PublicBaseUrl { get; set; }
|
||||||
|
public string? FontPath { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using QRCoder;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||||
|
|
||||||
|
public interface IOfficialDocumentPdfGenerator
|
||||||
|
{
|
||||||
|
GeneratedOfficialDocument Generate(
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
string verificationUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OfficialDocumentPdfGenerator(
|
||||||
|
OfficialDocumentOptions options) : IOfficialDocumentPdfGenerator
|
||||||
|
{
|
||||||
|
private const float PageWidth = 595;
|
||||||
|
private const float PageHeight = 842;
|
||||||
|
private const float Margin = 44;
|
||||||
|
private const float ContentWidth = PageWidth - Margin * 2;
|
||||||
|
private static readonly SKColor Ink = new(31, 48, 65);
|
||||||
|
private static readonly SKColor Muted = new(91, 107, 120);
|
||||||
|
private static readonly SKColor Accent = new(26, 91, 82);
|
||||||
|
private static readonly SKColor Pale = new(236, 244, 242);
|
||||||
|
private static readonly SKColor Rule = new(208, 219, 220);
|
||||||
|
|
||||||
|
public GeneratedOfficialDocument Generate(
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
string verificationUrl)
|
||||||
|
{
|
||||||
|
using var typeface = ResolveTypeface();
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using (var document = SKDocument.CreatePdf(stream))
|
||||||
|
{
|
||||||
|
if (snapshot.Type == Domain.Academic.OfficialDocumentType.Transcript)
|
||||||
|
DrawTranscript(document, typeface, snapshot, verificationUrl);
|
||||||
|
else
|
||||||
|
DrawStatusCertificate(document, typeface, snapshot, verificationUrl);
|
||||||
|
document.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = stream.ToArray();
|
||||||
|
var hash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
|
||||||
|
return new GeneratedOfficialDocument(content, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawTranscript(
|
||||||
|
SKDocument document,
|
||||||
|
SKTypeface typeface,
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
string verificationUrl)
|
||||||
|
{
|
||||||
|
const int rowsPerPage = 19;
|
||||||
|
var pageCount = Math.Max(1, (int)Math.Ceiling(snapshot.Grades.Count / (double)rowsPerPage));
|
||||||
|
for (var pageIndex = 0; pageIndex < pageCount; pageIndex++)
|
||||||
|
{
|
||||||
|
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||||
|
DrawPageFrame(canvas);
|
||||||
|
DrawHeader(canvas, typeface, snapshot, "官方电子成绩单", "OFFICIAL ACADEMIC TRANSCRIPT");
|
||||||
|
|
||||||
|
var y = 145f;
|
||||||
|
if (pageIndex == 0)
|
||||||
|
{
|
||||||
|
y = DrawStudentSummary(canvas, typeface, snapshot.Student, y);
|
||||||
|
y = DrawTranscriptSummary(canvas, typeface, snapshot, y + 12);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, $"{snapshot.Student.Name} · {snapshot.Student.StudentNumber}",
|
||||||
|
Margin, y, 10, Muted);
|
||||||
|
y += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = snapshot.Grades.Skip(pageIndex * rowsPerPage).Take(rowsPerPage).ToArray();
|
||||||
|
y = DrawGradeTable(canvas, typeface, rows, y + 14);
|
||||||
|
|
||||||
|
if (pageIndex == pageCount - 1)
|
||||||
|
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, Math.Min(y + 18, 685));
|
||||||
|
|
||||||
|
DrawFooter(canvas, typeface, snapshot, pageIndex + 1, pageCount);
|
||||||
|
document.EndPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawStatusCertificate(
|
||||||
|
SKDocument document,
|
||||||
|
SKTypeface typeface,
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
string verificationUrl)
|
||||||
|
{
|
||||||
|
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||||
|
DrawPageFrame(canvas);
|
||||||
|
DrawHeader(canvas, typeface, snapshot, "学籍状态证明", "CERTIFICATE OF STUDENT STATUS");
|
||||||
|
|
||||||
|
var student = snapshot.Student;
|
||||||
|
var statusText = student.Status switch
|
||||||
|
{
|
||||||
|
"在读" => "现为我校在读学生",
|
||||||
|
"休学" => "现为我校注册学生,当前处于休学状态",
|
||||||
|
"已毕业" => "曾在我校就读,现已毕业",
|
||||||
|
"退学" => "曾在我校就读,现已办理退学",
|
||||||
|
_ => $"在我校的学籍状态为“{student.Status}”"
|
||||||
|
};
|
||||||
|
|
||||||
|
DrawText(canvas, typeface, "证 明", PageWidth / 2, 215, 25, Ink, SKTextAlign.Center, true);
|
||||||
|
var body = $"兹证明,{student.Name}(学号:{student.StudentNumber})," +
|
||||||
|
$"于 {student.EnrollmentDate:yyyy年M月d日} 入学," +
|
||||||
|
$"就读于{student.CollegeName}{student.MajorName}专业,行政班级为{student.ClassName}," +
|
||||||
|
$"{statusText}。";
|
||||||
|
var y = 285f;
|
||||||
|
foreach (var line in WrapText(typeface, body, 16, 448))
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, line, 74, y, 16, Ink);
|
||||||
|
y += 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(snapshot.Purpose))
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, $"用途:{snapshot.Purpose}", 74, y + 14, 11, Muted);
|
||||||
|
y += 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawText(canvas, typeface, "特此证明。", 74, y + 28, 16, Ink);
|
||||||
|
DrawText(canvas, typeface, snapshot.InstitutionName, 435, y + 100, 14, Ink,
|
||||||
|
SKTextAlign.Center, true);
|
||||||
|
DrawText(canvas, typeface, snapshot.IssuingOffice, 435, y + 126, 12, Ink,
|
||||||
|
SKTextAlign.Center);
|
||||||
|
DrawText(canvas, typeface, snapshot.IssuedAt.ToLocalTime().ToString("yyyy年M月d日"),
|
||||||
|
435, y + 150, 11, Muted, SKTextAlign.Center);
|
||||||
|
|
||||||
|
DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, 625);
|
||||||
|
DrawFooter(canvas, typeface, snapshot, 1, 1);
|
||||||
|
document.EndPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawPageFrame(SKCanvas canvas)
|
||||||
|
{
|
||||||
|
canvas.Clear(SKColors.White);
|
||||||
|
using var border = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.4f };
|
||||||
|
using var inner = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
|
||||||
|
canvas.DrawRect(22, 22, PageWidth - 44, PageHeight - 44, border);
|
||||||
|
canvas.DrawRect(27, 27, PageWidth - 54, PageHeight - 54, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawHeader(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
string title,
|
||||||
|
string englishTitle)
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, snapshot.InstitutionName, Margin, 66, 16, Accent, bold: true);
|
||||||
|
DrawText(canvas, typeface, snapshot.IssuingOffice, PageWidth - Margin, 66, 10, Muted,
|
||||||
|
SKTextAlign.Right);
|
||||||
|
DrawText(canvas, typeface, title, PageWidth / 2, 99, 23, Ink, SKTextAlign.Center, true);
|
||||||
|
DrawText(canvas, typeface, englishTitle, PageWidth / 2, 119, 8.5f, Muted,
|
||||||
|
SKTextAlign.Center);
|
||||||
|
using var rule = new SKPaint { Color = Accent, StrokeWidth = 1.2f };
|
||||||
|
canvas.DrawLine(Margin, 132, PageWidth - Margin, 132, rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float DrawStudentSummary(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
OfficialStudentSnapshot student,
|
||||||
|
float y)
|
||||||
|
{
|
||||||
|
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||||
|
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 86), 5, 5, fill);
|
||||||
|
DrawLabelValue(canvas, typeface, "姓名", student.Name, Margin + 15, y + 24);
|
||||||
|
DrawLabelValue(canvas, typeface, "学号", student.StudentNumber, 210, y + 24);
|
||||||
|
DrawLabelValue(canvas, typeface, "学籍状态", student.Status, 404, y + 24);
|
||||||
|
DrawLabelValue(canvas, typeface, "学院", student.CollegeName, Margin + 15, y + 57);
|
||||||
|
DrawLabelValue(canvas, typeface, "专业", student.MajorName, 210, y + 57);
|
||||||
|
DrawLabelValue(canvas, typeface, "班级", student.ClassName, 404, y + 57);
|
||||||
|
return y + 86;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float DrawTranscriptSummary(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
float y)
|
||||||
|
{
|
||||||
|
var values = new[]
|
||||||
|
{
|
||||||
|
("课程门数", snapshot.Grades.Count.ToString()),
|
||||||
|
("修读学分", FormatDecimal(snapshot.TotalCredits)),
|
||||||
|
("获得学分", FormatDecimal(snapshot.EarnedCredits)),
|
||||||
|
("平均绩点", snapshot.GradePointAverage?.ToString("0.00") ?? "-")
|
||||||
|
};
|
||||||
|
var width = ContentWidth / values.Length;
|
||||||
|
for (var i = 0; i < values.Length; i++)
|
||||||
|
{
|
||||||
|
var center = Margin + width * i + width / 2;
|
||||||
|
DrawText(canvas, typeface, values[i].Item1, center, y + 12, 8.5f, Muted,
|
||||||
|
SKTextAlign.Center);
|
||||||
|
DrawText(canvas, typeface, values[i].Item2, center, y + 33, 15, Accent,
|
||||||
|
SKTextAlign.Center, true);
|
||||||
|
}
|
||||||
|
return y + 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float DrawGradeTable(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
IReadOnlyList<OfficialTranscriptRow> rows,
|
||||||
|
float y)
|
||||||
|
{
|
||||||
|
var widths = new[] { 96f, 64f, 160f, 48f, 55f, 45f, 39f };
|
||||||
|
var headers = new[] { "学期", "课程代码", "课程名称", "学分", "成绩", "绩点", "状态" };
|
||||||
|
const float headerHeight = 27;
|
||||||
|
const float rowHeight = 24;
|
||||||
|
using var headerPaint = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
|
||||||
|
using var stripe = new SKPaint { Color = new SKColor(247, 249, 249), Style = SKPaintStyle.Fill };
|
||||||
|
using var rule = new SKPaint { Color = Rule, StrokeWidth = .5f };
|
||||||
|
canvas.DrawRect(Margin, y, ContentWidth, headerHeight, headerPaint);
|
||||||
|
|
||||||
|
var x = Margin;
|
||||||
|
for (var i = 0; i < headers.Length; i++)
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, headers[i], x + widths[i] / 2, y + 18, 8.5f,
|
||||||
|
SKColors.White, SKTextAlign.Center, true);
|
||||||
|
x += widths[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
|
||||||
|
{
|
||||||
|
var row = rows[rowIndex];
|
||||||
|
var rowY = y + headerHeight + rowIndex * rowHeight;
|
||||||
|
if (rowIndex % 2 == 1)
|
||||||
|
canvas.DrawRect(Margin, rowY, ContentWidth, rowHeight, stripe);
|
||||||
|
canvas.DrawLine(Margin, rowY + rowHeight, PageWidth - Margin, rowY + rowHeight, rule);
|
||||||
|
var values = new[]
|
||||||
|
{
|
||||||
|
row.AcademicTerm,
|
||||||
|
row.CourseCode,
|
||||||
|
Ellipsize(typeface, row.CourseName, 8.5f, widths[2] - 8),
|
||||||
|
FormatDecimal(row.Credits),
|
||||||
|
row.Score?.ToString("0.#") ?? "-",
|
||||||
|
row.GradePoint?.ToString("0.0#") ?? "-",
|
||||||
|
row.ExamStatus
|
||||||
|
};
|
||||||
|
x = Margin;
|
||||||
|
for (var i = 0; i < values.Length; i++)
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, values[i], x + widths[i] / 2, rowY + 16, 8.5f,
|
||||||
|
Ink, SKTextAlign.Center);
|
||||||
|
x += widths[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return y + headerHeight + rows.Count * rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawVerificationBlock(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
string verificationUrl,
|
||||||
|
float y)
|
||||||
|
{
|
||||||
|
using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||||
|
canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 84), 5, 5, fill);
|
||||||
|
DrawQrCode(canvas, verificationUrl, Margin + 10, y + 7, 70);
|
||||||
|
DrawText(canvas, typeface, "扫码验真", Margin + 94, y + 22, 11, Accent, bold: true);
|
||||||
|
DrawText(canvas, typeface, $"凭证编号:{snapshot.DocumentNumber}", Margin + 94, y + 43, 9.5f, Ink);
|
||||||
|
DrawText(canvas, typeface, "二维码指向本系统公开验真页;失效或重签后状态会实时更新。",
|
||||||
|
Margin + 94, y + 62, 8.5f, Muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawFooter(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
OfficialDocumentSnapshot snapshot,
|
||||||
|
int page,
|
||||||
|
int pageCount)
|
||||||
|
{
|
||||||
|
using var rule = new SKPaint { Color = Rule, StrokeWidth = .6f };
|
||||||
|
canvas.DrawLine(Margin, 790, PageWidth - Margin, 790, rule);
|
||||||
|
DrawText(canvas, typeface,
|
||||||
|
$"签发:{snapshot.IssuedByName} · {snapshot.IssuedAt.ToLocalTime():yyyy-MM-dd HH:mm}",
|
||||||
|
Margin, 807, 7.5f, Muted);
|
||||||
|
DrawText(canvas, typeface, $"第 {page} / {pageCount} 页", PageWidth - Margin, 807, 7.5f,
|
||||||
|
Muted, SKTextAlign.Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawLabelValue(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
string label,
|
||||||
|
string value,
|
||||||
|
float x,
|
||||||
|
float y)
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, label, x, y, 8, Muted);
|
||||||
|
DrawText(canvas, typeface, value, x + 42, y, 10, Ink, bold: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawQrCode(SKCanvas canvas, string content, float x, float y, float size)
|
||||||
|
{
|
||||||
|
using var generator = new QRCodeGenerator();
|
||||||
|
using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
|
||||||
|
var modules = data.ModuleMatrix;
|
||||||
|
const int quiet = 4;
|
||||||
|
var moduleSize = size / (modules.Count + quiet * 2);
|
||||||
|
using var paint = new SKPaint { Color = Ink, Style = SKPaintStyle.Fill, IsAntialias = false };
|
||||||
|
for (var row = 0; row < modules.Count; row++)
|
||||||
|
for (var column = 0; column < modules[row].Count; column++)
|
||||||
|
{
|
||||||
|
if (!modules[row][column]) continue;
|
||||||
|
canvas.DrawRect(
|
||||||
|
x + (column + quiet) * moduleSize,
|
||||||
|
y + (row + quiet) * moduleSize,
|
||||||
|
moduleSize + .08f,
|
||||||
|
moduleSize + .08f,
|
||||||
|
paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawText(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
string text,
|
||||||
|
float x,
|
||||||
|
float y,
|
||||||
|
float size,
|
||||||
|
SKColor color,
|
||||||
|
SKTextAlign align = SKTextAlign.Left,
|
||||||
|
bool bold = false)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size) { Embolden = bold };
|
||||||
|
using var paint = new SKPaint { Color = color, IsAntialias = true };
|
||||||
|
canvas.DrawText(text, x, y, align, font, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> WrapText(
|
||||||
|
SKTypeface typeface,
|
||||||
|
string text,
|
||||||
|
float size,
|
||||||
|
float maxWidth)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size);
|
||||||
|
var lines = new List<string>();
|
||||||
|
var current = "";
|
||||||
|
foreach (var character in text)
|
||||||
|
{
|
||||||
|
var candidate = current + character;
|
||||||
|
if (current.Length > 0 && font.MeasureText(candidate) > maxWidth)
|
||||||
|
{
|
||||||
|
var breakIndex = current.LastIndexOfAny([' ', ',', '。', ';', ':', '、']);
|
||||||
|
if (breakIndex >= current.Length / 2)
|
||||||
|
{
|
||||||
|
lines.Add(current[..(breakIndex + 1)].TrimEnd());
|
||||||
|
current = current[(breakIndex + 1)..].TrimStart() + character;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lines.Add(current);
|
||||||
|
current = character.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.Length > 0) lines.Add(current);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Ellipsize(
|
||||||
|
SKTypeface typeface,
|
||||||
|
string text,
|
||||||
|
float size,
|
||||||
|
float maxWidth)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size);
|
||||||
|
if (font.MeasureText(text) <= maxWidth) return text;
|
||||||
|
var value = text;
|
||||||
|
while (value.Length > 1 && font.MeasureText(value + "…") > maxWidth)
|
||||||
|
value = value[..^1];
|
||||||
|
return value + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
private SKTypeface ResolveTypeface()
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
options.FontPath,
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
|
||||||
|
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
|
||||||
|
};
|
||||||
|
foreach (var path in candidates.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) continue;
|
||||||
|
var typeface = SKTypeface.FromFile(path);
|
||||||
|
if (typeface is not null) return typeface;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallback = SKTypeface.FromFamilyName("Microsoft YaHei") ??
|
||||||
|
SKTypeface.FromFamilyName("Noto Sans CJK SC");
|
||||||
|
return fallback ?? throw new InvalidOperationException(
|
||||||
|
"未找到可用于官方 PDF 的中文字体。请配置 OfficialDocuments:FontPath。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDecimal(decimal value) => value.ToString("0.##");
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||||
|
|
||||||
|
public sealed class OfficialDocumentService(
|
||||||
|
AppDbContext db,
|
||||||
|
OfficialDocumentOptions options,
|
||||||
|
IOfficialDocumentPdfGenerator pdfGenerator)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public async Task<OfficialDocument> CreateAsync(
|
||||||
|
Guid studentId,
|
||||||
|
OfficialDocumentType type,
|
||||||
|
string? purpose,
|
||||||
|
Guid issuedByUserId,
|
||||||
|
string issuedByName,
|
||||||
|
string verificationBaseUrl,
|
||||||
|
Guid? reissuedFromDocumentId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.Id == studentId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentNumber,
|
||||||
|
x.Name,
|
||||||
|
x.Gender,
|
||||||
|
x.EnrollmentYear,
|
||||||
|
x.EnrollmentDate,
|
||||||
|
x.Status,
|
||||||
|
CollegeName = x.AdministrativeClass!.Major!.College!.Name,
|
||||||
|
MajorName = x.AdministrativeClass.Major.Name,
|
||||||
|
ClassName = x.AdministrativeClass.Name
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
|
?? throw new OfficialDocumentValidationException("学生档案不存在。");
|
||||||
|
|
||||||
|
var grades = type == OfficialDocumentType.Transcript
|
||||||
|
? await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == studentId &&
|
||||||
|
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
|
.OrderBy(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
|
||||||
|
.Select(x => new OfficialTranscriptRow(
|
||||||
|
x.GradeSheet!.TeachingTask!.AcademicTerm!.Name,
|
||||||
|
x.GradeSheet.TeachingTask.Course!.Code,
|
||||||
|
x.GradeSheet.TeachingTask.Course.Name,
|
||||||
|
x.GradeSheet.TeachingTask.Course.Credits,
|
||||||
|
x.TotalScore,
|
||||||
|
x.GradePoint,
|
||||||
|
GradeExamStatusLabel(x.ExamStatus)))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (type == OfficialDocumentType.Transcript && grades.Count == 0)
|
||||||
|
throw new OfficialDocumentValidationException("该学生暂无已发布成绩,不能签发官方成绩单。");
|
||||||
|
|
||||||
|
var issuedAt = DateTime.UtcNow;
|
||||||
|
var documentNumber = CreateDocumentNumber(type, issuedAt);
|
||||||
|
var verificationCode = CreateVerificationCode();
|
||||||
|
var studentSnapshot = new OfficialStudentSnapshot(
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
GenderLabel(student.Gender),
|
||||||
|
student.CollegeName,
|
||||||
|
student.MajorName,
|
||||||
|
student.ClassName,
|
||||||
|
student.EnrollmentYear,
|
||||||
|
student.EnrollmentDate,
|
||||||
|
StudentStatusLabel(student.Status));
|
||||||
|
var totalCredits = grades.Sum(x => x.Credits);
|
||||||
|
var passedGrades = grades
|
||||||
|
.Where(x => x.Score >= 60 || x.ExamStatus == "免修")
|
||||||
|
.ToArray();
|
||||||
|
var gpaGrades = grades.Where(x => x.GradePoint.HasValue).ToArray();
|
||||||
|
var gpaCredits = gpaGrades.Sum(x => x.Credits);
|
||||||
|
var gpa = gpaCredits > 0
|
||||||
|
? gpaGrades.Sum(x => x.GradePoint!.Value * x.Credits) / gpaCredits
|
||||||
|
: (decimal?)null;
|
||||||
|
var snapshot = new OfficialDocumentSnapshot(
|
||||||
|
options.InstitutionName,
|
||||||
|
options.IssuingOffice,
|
||||||
|
documentNumber,
|
||||||
|
type,
|
||||||
|
issuedAt,
|
||||||
|
issuedByName,
|
||||||
|
string.IsNullOrWhiteSpace(purpose) ? null : purpose.Trim(),
|
||||||
|
studentSnapshot,
|
||||||
|
grades,
|
||||||
|
totalCredits,
|
||||||
|
passedGrades.Sum(x => x.Credits),
|
||||||
|
gpa);
|
||||||
|
var verificationUrl = $"{verificationBaseUrl.TrimEnd('/')}/verify/{verificationCode}";
|
||||||
|
var generated = pdfGenerator.Generate(snapshot, verificationUrl);
|
||||||
|
|
||||||
|
return new OfficialDocument
|
||||||
|
{
|
||||||
|
DocumentNumber = documentNumber,
|
||||||
|
VerificationCodeHash = HashVerificationCode(verificationCode),
|
||||||
|
Type = type,
|
||||||
|
Status = OfficialDocumentStatus.Valid,
|
||||||
|
StudentId = studentId,
|
||||||
|
IssuedByUserId = issuedByUserId,
|
||||||
|
IssuedAt = issuedAt,
|
||||||
|
Purpose = snapshot.Purpose,
|
||||||
|
SnapshotJson = JsonSerializer.Serialize(snapshot, SnapshotJsonOptions),
|
||||||
|
PdfContent = generated.Content,
|
||||||
|
PdfSha256 = generated.Sha256,
|
||||||
|
ReissuedFromDocumentId = reissuedFromDocumentId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string HashVerificationCode(string verificationCode) =>
|
||||||
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(verificationCode)))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
|
||||||
|
public static OfficialDocumentSnapshot DeserializeSnapshot(string json) =>
|
||||||
|
JsonSerializer.Deserialize<OfficialDocumentSnapshot>(json, SnapshotJsonOptions)
|
||||||
|
?? throw new InvalidOperationException("官方凭证快照无法读取。");
|
||||||
|
|
||||||
|
private string CreateDocumentNumber(OfficialDocumentType type, DateTime issuedAt)
|
||||||
|
{
|
||||||
|
var typeCode = type == OfficialDocumentType.Transcript ? "TR" : "SC";
|
||||||
|
var random = Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
|
||||||
|
return $"{options.DocumentNumberPrefix}-{typeCode}-{issuedAt:yyyyMMdd}-{random}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateVerificationCode() =>
|
||||||
|
Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string GenderLabel(Gender value) => value switch
|
||||||
|
{
|
||||||
|
Gender.Male => "男",
|
||||||
|
Gender.Female => "女",
|
||||||
|
_ => "未登记"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string StudentStatusLabel(StudentStatus value) => value switch
|
||||||
|
{
|
||||||
|
StudentStatus.Active => "在读",
|
||||||
|
StudentStatus.Suspended => "休学",
|
||||||
|
StudentStatus.Graduated => "已毕业",
|
||||||
|
StudentStatus.Withdrawn => "退学",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GradeExamStatusLabel(GradeExamStatus value) => value switch
|
||||||
|
{
|
||||||
|
GradeExamStatus.Normal => "正常",
|
||||||
|
GradeExamStatus.Absent => "缺考",
|
||||||
|
GradeExamStatus.Deferred => "缓考",
|
||||||
|
GradeExamStatus.Exempt => "免修",
|
||||||
|
GradeExamStatus.Makeup => "补考",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OfficialDocumentValidationException(string message) : Exception(message);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||||
|
|
||||||
|
public sealed record OfficialDocumentSnapshot(
|
||||||
|
string InstitutionName,
|
||||||
|
string IssuingOffice,
|
||||||
|
string DocumentNumber,
|
||||||
|
OfficialDocumentType Type,
|
||||||
|
DateTime IssuedAt,
|
||||||
|
string IssuedByName,
|
||||||
|
string? Purpose,
|
||||||
|
OfficialStudentSnapshot Student,
|
||||||
|
IReadOnlyList<OfficialTranscriptRow> Grades,
|
||||||
|
decimal TotalCredits,
|
||||||
|
decimal EarnedCredits,
|
||||||
|
decimal? GradePointAverage);
|
||||||
|
|
||||||
|
public sealed record OfficialStudentSnapshot(
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
string Gender,
|
||||||
|
string CollegeName,
|
||||||
|
string MajorName,
|
||||||
|
string ClassName,
|
||||||
|
int EnrollmentYear,
|
||||||
|
DateOnly EnrollmentDate,
|
||||||
|
string Status);
|
||||||
|
|
||||||
|
public sealed record OfficialTranscriptRow(
|
||||||
|
string AcademicTerm,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
decimal Credits,
|
||||||
|
decimal? Score,
|
||||||
|
decimal? GradePoint,
|
||||||
|
string ExamStatus);
|
||||||
|
|
||||||
|
public sealed record GeneratedOfficialDocument(
|
||||||
|
byte[] Content,
|
||||||
|
string Sha256);
|
||||||
@@ -41,8 +41,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<AutomaticScheduleJob>();
|
Set<AutomaticScheduleJob>();
|
||||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||||
Set<SchedulePublishJob>();
|
Set<SchedulePublishJob>();
|
||||||
|
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||||
|
Set<ClassroomReservation>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
|
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||||
|
Set<CourseSelectionRoundGrade>();
|
||||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||||
Set<CourseSelectionOffering>();
|
Set<CourseSelectionOffering>();
|
||||||
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
||||||
@@ -75,6 +79,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
|
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
|
||||||
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
|
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
|
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
|
||||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||||
Set<StudentStatusChange>();
|
Set<StudentStatusChange>();
|
||||||
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
||||||
@@ -89,7 +94,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<GraduationClearanceItem>();
|
Set<GraduationClearanceItem>();
|
||||||
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
|
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
|
||||||
Set<GraduationClearanceRecord>();
|
Set<GraduationClearanceRecord>();
|
||||||
|
public DbSet<OfficialDocument> OfficialDocuments => Set<OfficialDocument>();
|
||||||
|
public DbSet<OfficialDocumentDownload> OfficialDocumentDownloads =>
|
||||||
|
Set<OfficialDocumentDownload>();
|
||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
||||||
|
Set<BackgroundJobOutboxMessage>();
|
||||||
|
|
||||||
protected override void ConfigureConventions(
|
protected override void ConfigureConventions(
|
||||||
ModelConfigurationBuilder configurationBuilder)
|
ModelConfigurationBuilder configurationBuilder)
|
||||||
@@ -101,6 +111,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
configurationBuilder.Properties<DateOnly>()
|
configurationBuilder.Properties<DateOnly>()
|
||||||
.HaveConversion<DateOnlyDateTimeConverter>()
|
.HaveConversion<DateOnlyDateTimeConverter>()
|
||||||
.HaveColumnType("date");
|
.HaveColumnType("date");
|
||||||
|
|
||||||
|
// Connector/NET returns MySQL TIME values as TimeSpan. Convert at the
|
||||||
|
// provider boundary so schedule time slots can still use TimeOnly.
|
||||||
|
configurationBuilder.Properties<TimeOnly>()
|
||||||
|
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||||
|
.HaveColumnType("time");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
@@ -111,6 +127,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
{
|
{
|
||||||
entity.Property(x => x.DisplayName).HasMaxLength(50);
|
entity.Property(x => x.DisplayName).HasMaxLength(50);
|
||||||
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
|
||||||
entity.HasIndex(x => x.StaffNumber);
|
entity.HasIndex(x => x.StaffNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -459,6 +476,49 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<ClassroomReservation>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.ApplicantName).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.Purpose).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.ContactPhone).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new { x.ApplicantUserId, x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ApplicantCollegeId,
|
||||||
|
x.Status,
|
||||||
|
x.ReservationDate
|
||||||
|
});
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ClassroomId,
|
||||||
|
x.ReservationDate,
|
||||||
|
x.Status,
|
||||||
|
x.StartPeriod
|
||||||
|
});
|
||||||
|
entity.HasOne(x => x.ApplicantUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApplicantUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.ApplicantCollege)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApplicantCollegeId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.AcademicTerm)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.AcademicTermId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Classroom)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.ReviewedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ReviewedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSelectionRound>(entity =>
|
builder.Entity<CourseSelectionRound>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -471,6 +531,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseSelectionRoundGrade>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.CourseSelectionRoundId, x.Grade })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasIndex(x => x.Grade);
|
||||||
|
entity.HasOne(x => x.CourseSelectionRound)
|
||||||
|
.WithMany(x => x.EligibleGrades)
|
||||||
|
.HasForeignKey(x => x.CourseSelectionRoundId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSelectionOffering>(entity =>
|
builder.Entity<CourseSelectionOffering>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
@@ -494,6 +565,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
|
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
entity.HasIndex(x => new { x.StudentId, x.Status });
|
entity.HasIndex(x => new { x.StudentId, x.Status });
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.CourseSelectionOfferingId,
|
||||||
|
x.Status,
|
||||||
|
x.WaitlistedAt
|
||||||
|
}).HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt");
|
||||||
entity.HasOne(x => x.CourseSelectionOffering)
|
entity.HasOne(x => x.CourseSelectionOffering)
|
||||||
.WithMany(x => x.Enrollments)
|
.WithMany(x => x.Enrollments)
|
||||||
.HasForeignKey(x => x.CourseSelectionOfferingId)
|
.HasForeignKey(x => x.CourseSelectionOfferingId)
|
||||||
@@ -876,6 +953,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
|
entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
|
||||||
entity.HasIndex(x => x.ApplicantUserId);
|
entity.HasIndex(x => x.ApplicantUserId);
|
||||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.SourceScheduleEntryId, x.SourceWeek });
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(x => x.Classroom).WithMany()
|
entity.HasOne(x => x.Classroom).WithMany()
|
||||||
@@ -890,7 +968,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Content).HasMaxLength(1000);
|
entity.Property(x => x.Content).HasMaxLength(1000);
|
||||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => x.MessageDispatchId);
|
||||||
entity.HasIndex(x => x.CreatedAt);
|
entity.HasIndex(x => x.CreatedAt);
|
||||||
|
entity.HasOne(x => x.MessageDispatch)
|
||||||
|
.WithMany(x => x.Notifications)
|
||||||
|
.HasForeignKey(x => x.MessageDispatchId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<MessageDispatch>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.SenderName).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.Title).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.Content).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.AudienceName).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||||
|
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<AuditLog>(entity =>
|
builder.Entity<AuditLog>(entity =>
|
||||||
@@ -901,6 +995,63 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||||
entity.HasIndex(x => x.CreatedAt);
|
entity.HasIndex(x => x.CreatedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<BackgroundJobOutboxMessage>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.LastError).HasMaxLength(2000);
|
||||||
|
entity.HasIndex(x => new { x.JobKind, x.JobId }).IsUnique();
|
||||||
|
entity.HasIndex(x => new { x.State, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.State, x.CompletedAt });
|
||||||
|
entity.HasIndex(x => x.LeaseExpiresAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<OfficialDocument>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.VerificationCodeHash).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.Purpose).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.SnapshotJson).HasColumnType("longtext");
|
||||||
|
entity.Property(x => x.PdfContent).HasColumnType("longblob");
|
||||||
|
entity.Property(x => x.PdfSha256).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.InvalidationReason).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => x.DocumentNumber).IsUnique();
|
||||||
|
entity.HasIndex(x => x.VerificationCodeHash).IsUnique();
|
||||||
|
entity.HasIndex(x => new { x.StudentId, x.IssuedAt });
|
||||||
|
entity.HasIndex(x => new { x.Status, x.IssuedAt });
|
||||||
|
entity.HasOne(x => x.Student)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.IssuedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.IssuedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.InvalidatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.InvalidatedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasIndex(x => x.ReissuedFromDocumentId).IsUnique();
|
||||||
|
entity.HasOne(x => x.ReissuedFromDocument)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ReissuedFromDocumentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<OfficialDocumentDownload>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new { x.OfficialDocumentId, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.DownloadedByUserId, x.CreatedAt });
|
||||||
|
entity.HasOne(x => x.OfficialDocument)
|
||||||
|
.WithMany(x => x.Downloads)
|
||||||
|
.HasForeignKey(x => x.OfficialDocumentId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.DownloadedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.DownloadedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -931,3 +1082,8 @@ public sealed class DateOnlyDateTimeConverter()
|
|||||||
: ValueConverter<DateOnly, DateTime>(
|
: ValueConverter<DateOnly, DateTime>(
|
||||||
date => date.ToDateTime(TimeOnly.MinValue),
|
date => date.ToDateTime(TimeOnly.MinValue),
|
||||||
value => DateOnly.FromDateTime(value));
|
value => DateOnly.FromDateTime(value));
|
||||||
|
|
||||||
|
public sealed class TimeOnlyTimeSpanConverter()
|
||||||
|
: ValueConverter<TimeOnly, TimeSpan>(
|
||||||
|
time => time.ToTimeSpan(),
|
||||||
|
value => TimeOnly.FromTimeSpan(value));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbConte
|
|||||||
?? "Server=localhost;Port=3306;Database=jiaowu;User=__design_time__;Password=__not_used__;";
|
?? "Server=localhost;Port=3306;Database=jiaowu;User=__design_time__;Password=__not_used__;";
|
||||||
|
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseMySQL(connectionString)
|
.UseMySQL(MySqlConnectionStringPolicy.ForApplication(connectionString))
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
return new AppDbContext(options);
|
return new AppDbContext(options);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
|
||||||
public sealed class DemoDataSeeder(
|
public sealed class DemoDataSeeder(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
|
IAppCache cache,
|
||||||
ILogger<DemoDataSeeder> logger)
|
ILogger<DemoDataSeeder> logger)
|
||||||
{
|
{
|
||||||
// MySql.EntityFrameworkCore 10 cannot type-map parameterized primitive
|
// MySql.EntityFrameworkCore 10 cannot type-map parameterized primitive
|
||||||
@@ -47,6 +49,10 @@ public sealed class DemoDataSeeder(
|
|||||||
await LogSummaryAsync(cancellationToken);
|
await LogSummaryAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Analytics, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureDatabaseIsEmptyAsync(CancellationToken cancellationToken)
|
private async Task EnsureDatabaseIsEmptyAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260725_25_academic_warnings";
|
"20260725_25_academic_warnings";
|
||||||
private const string AcademicTermArchivingMigration =
|
private const string AcademicTermArchivingMigration =
|
||||||
"20260726_27_academic_term_archiving";
|
"20260726_27_academic_term_archiving";
|
||||||
|
private const string CourseSelectionWaitlistMigration =
|
||||||
|
"20260726_28_course_selection_waitlist";
|
||||||
|
private const string PersonalCalendarSubscriptionMigration =
|
||||||
|
"20260726_29_personal_calendar_subscription";
|
||||||
|
private const string OfficialDocumentsMigration =
|
||||||
|
"20260726_30_official_documents";
|
||||||
|
private const string UnifiedMessageCenterMigration =
|
||||||
|
"20260726_31_unified_message_center";
|
||||||
|
private const string ClassroomReservationsMigration =
|
||||||
|
"20260726_32_classroom_reservations";
|
||||||
|
private const string BackgroundJobOutboxMigration =
|
||||||
|
"20260726_33_background_job_outbox";
|
||||||
|
private const string CourseAdjustmentOccurrencesMigration =
|
||||||
|
"20260727_34_course_adjustment_occurrences";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -266,6 +280,21 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
courseAdjustmentsExist ? [] : CourseAdjustmentsStatements,
|
courseAdjustmentsExist ? [] : CourseAdjustmentsStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var courseAdjustmentOccurrencesExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('CourseAdjustments')
|
||||||
|
WHERE name = 'SourceScheduleEntryId'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseAdjustmentOccurrencesMigration,
|
||||||
|
courseAdjustmentOccurrencesExist
|
||||||
|
? []
|
||||||
|
: CourseAdjustmentOccurrencesStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var attendanceAppealExists = await db.Database
|
var attendanceAppealExists = await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"""
|
"""
|
||||||
@@ -344,6 +373,86 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
AcademicTermArchivingMigration,
|
AcademicTermArchivingMigration,
|
||||||
academicTermArchivingExists ? [] : AcademicTermArchivingStatements,
|
academicTermArchivingExists ? [] : AcademicTermArchivingStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var courseSelectionWaitlistExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('CourseEnrollments')
|
||||||
|
WHERE name = 'WaitlistedAt'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseSelectionWaitlistMigration,
|
||||||
|
courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var personalCalendarSubscriptionExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('AspNetUsers')
|
||||||
|
WHERE name = 'CalendarSubscriptionStamp'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
PersonalCalendarSubscriptionMigration,
|
||||||
|
personalCalendarSubscriptionExists
|
||||||
|
? []
|
||||||
|
: PersonalCalendarSubscriptionStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var officialDocumentsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'OfficialDocuments'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
OfficialDocumentsMigration,
|
||||||
|
officialDocumentsExist ? [] : OfficialDocumentStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var messageDispatchesExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'MessageDispatches'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
UnifiedMessageCenterMigration,
|
||||||
|
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var classroomReservationsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ClassroomReservations'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ClassroomReservationsMigration,
|
||||||
|
classroomReservationsExist ? [] : ClassroomReservationStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var backgroundJobOutboxExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'BackgroundJobOutboxMessages'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
BackgroundJobOutboxMigration,
|
||||||
|
backgroundJobOutboxExists ? [] : BackgroundJobOutboxStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1589,6 +1698,88 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseSelectionWaitlistStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "CourseEnrollments"
|
||||||
|
ADD COLUMN "WaitlistedAt" TEXT NULL;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_CE_Offering_Status_WaitlistedAt"
|
||||||
|
ON "CourseEnrollments" ("CourseSelectionOfferingId", "Status", "WaitlistedAt");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] PersonalCalendarSubscriptionStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "AspNetUsers"
|
||||||
|
ADD COLUMN "CalendarSubscriptionStamp" TEXT NULL;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE "AspNetUsers"
|
||||||
|
ADD COLUMN "CalendarSubscriptionCreatedAt" TEXT NULL;
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] OfficialDocumentStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "OfficialDocuments" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_OfficialDocuments" PRIMARY KEY,
|
||||||
|
"DocumentNumber" TEXT NOT NULL,
|
||||||
|
"VerificationCodeHash" TEXT NOT NULL,
|
||||||
|
"Type" INTEGER NOT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"IssuedByUserId" TEXT NOT NULL,
|
||||||
|
"IssuedAt" TEXT NOT NULL,
|
||||||
|
"Purpose" TEXT NULL,
|
||||||
|
"SnapshotJson" TEXT NOT NULL,
|
||||||
|
"PdfContent" BLOB NOT NULL,
|
||||||
|
"PdfSha256" TEXT NOT NULL,
|
||||||
|
"InvalidatedAt" TEXT NULL,
|
||||||
|
"InvalidatedByUserId" TEXT NULL,
|
||||||
|
"InvalidationReason" TEXT NULL,
|
||||||
|
"ReissuedFromDocumentId" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_OfficialDocuments_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_OfficialDocuments_AspNetUsers_IssuedByUserId"
|
||||||
|
FOREIGN KEY ("IssuedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_OfficialDocuments_AspNetUsers_InvalidatedByUserId"
|
||||||
|
FOREIGN KEY ("InvalidatedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_OfficialDocuments_ReissuedFromDocumentId"
|
||||||
|
FOREIGN KEY ("ReissuedFromDocumentId") REFERENCES "OfficialDocuments" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""CREATE UNIQUE INDEX "IX_OfficialDocuments_DocumentNumber" ON "OfficialDocuments" ("DocumentNumber");""",
|
||||||
|
"""CREATE UNIQUE INDEX "IX_OfficialDocuments_VerificationCodeHash" ON "OfficialDocuments" ("VerificationCodeHash");""",
|
||||||
|
"""CREATE INDEX "IX_OfficialDocuments_StudentId_IssuedAt" ON "OfficialDocuments" ("StudentId", "IssuedAt");""",
|
||||||
|
"""CREATE INDEX "IX_OfficialDocuments_Status_IssuedAt" ON "OfficialDocuments" ("Status", "IssuedAt");""",
|
||||||
|
"""CREATE INDEX "IX_OfficialDocuments_IssuedByUserId" ON "OfficialDocuments" ("IssuedByUserId");""",
|
||||||
|
"""CREATE INDEX "IX_OfficialDocuments_InvalidatedByUserId" ON "OfficialDocuments" ("InvalidatedByUserId");""",
|
||||||
|
"""CREATE UNIQUE INDEX "IX_OfficialDocuments_ReissuedFromDocumentId" ON "OfficialDocuments" ("ReissuedFromDocumentId");""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "OfficialDocumentDownloads" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_OfficialDocumentDownloads" PRIMARY KEY,
|
||||||
|
"OfficialDocumentId" TEXT NOT NULL,
|
||||||
|
"DownloadedByUserId" TEXT NOT NULL,
|
||||||
|
"IpAddress" TEXT NULL,
|
||||||
|
"UserAgent" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_OfficialDocumentDownloads_OfficialDocuments"
|
||||||
|
FOREIGN KEY ("OfficialDocumentId") REFERENCES "OfficialDocuments" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_OfficialDocumentDownloads_AspNetUsers"
|
||||||
|
FOREIGN KEY ("DownloadedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""CREATE INDEX "IX_OfficialDocumentDownloads_OfficialDocumentId_CreatedAt" ON "OfficialDocumentDownloads" ("OfficialDocumentId", "CreatedAt");""",
|
||||||
|
"""CREATE INDEX "IX_OfficialDocumentDownloads_DownloadedByUserId_CreatedAt" ON "OfficialDocumentDownloads" ("DownloadedByUserId", "CreatedAt");"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] CourseAdjustmentsStatements =
|
private static readonly string[] CourseAdjustmentsStatements =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
@@ -1598,6 +1789,12 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"Type" INTEGER NOT NULL,
|
"Type" INTEGER NOT NULL,
|
||||||
"Status" INTEGER NOT NULL,
|
"Status" INTEGER NOT NULL,
|
||||||
"ApplicantUserId" TEXT NOT NULL,
|
"ApplicantUserId" TEXT NOT NULL,
|
||||||
|
"SourceScheduleEntryId" TEXT NULL,
|
||||||
|
"SourceWeek" INTEGER NULL,
|
||||||
|
"SourceDate" TEXT NULL,
|
||||||
|
"SourceStartPeriod" INTEGER NULL,
|
||||||
|
"SourcePeriodCount" INTEGER NULL,
|
||||||
|
"SourceClassroomId" TEXT NULL,
|
||||||
"TargetDate" TEXT NULL,
|
"TargetDate" TEXT NULL,
|
||||||
"DayOfWeek" INTEGER NULL,
|
"DayOfWeek" INTEGER NULL,
|
||||||
"StartPeriod" INTEGER NULL,
|
"StartPeriod" INTEGER NULL,
|
||||||
@@ -1643,6 +1840,17 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE INDEX "IX_Notifications_CreatedAt" ON "Notifications" ("CreatedAt");"""
|
"""CREATE INDEX "IX_Notifications_CreatedAt" ON "Notifications" ("CreatedAt");"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseAdjustmentOccurrencesStatements =
|
||||||
|
[
|
||||||
|
"""ALTER TABLE "CourseAdjustments" ADD "SourceScheduleEntryId" TEXT NULL;""",
|
||||||
|
"""ALTER TABLE "CourseAdjustments" ADD "SourceWeek" INTEGER NULL;""",
|
||||||
|
"""ALTER TABLE "CourseAdjustments" ADD "SourceDate" TEXT NULL;""",
|
||||||
|
"""ALTER TABLE "CourseAdjustments" ADD "SourceStartPeriod" INTEGER NULL;""",
|
||||||
|
"""ALTER TABLE "CourseAdjustments" ADD "SourcePeriodCount" INTEGER NULL;""",
|
||||||
|
"""ALTER TABLE "CourseAdjustments" ADD "SourceClassroomId" TEXT NULL;""",
|
||||||
|
"""CREATE INDEX "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek" ON "CourseAdjustments" ("SourceScheduleEntryId", "SourceWeek");"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] AttendanceAppealStatements =
|
private static readonly string[] AttendanceAppealStatements =
|
||||||
[
|
[
|
||||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealStatus" INTEGER NOT NULL DEFAULT 0;""",
|
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealStatus" INTEGER NOT NULL DEFAULT 0;""",
|
||||||
@@ -1747,4 +1955,139 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
|
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
|
||||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] UnifiedMessageCenterStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "MessageDispatches" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_MessageDispatches" PRIMARY KEY,
|
||||||
|
"SenderUserId" TEXT NOT NULL,
|
||||||
|
"SenderName" TEXT NOT NULL,
|
||||||
|
"Title" TEXT NOT NULL,
|
||||||
|
"Content" TEXT NOT NULL,
|
||||||
|
"Category" INTEGER NOT NULL,
|
||||||
|
"AudienceType" INTEGER NOT NULL,
|
||||||
|
"AudienceId" TEXT NULL,
|
||||||
|
"AudienceName" TEXT NOT NULL,
|
||||||
|
"RecipientCount" INTEGER NOT NULL,
|
||||||
|
"LinkUrl" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""CREATE INDEX "IX_MessageDispatches_SenderUserId_CreatedAt" ON "MessageDispatches" ("SenderUserId", "CreatedAt");""",
|
||||||
|
"""ALTER TABLE "Notifications" ADD COLUMN "Category" INTEGER NOT NULL DEFAULT 1;""",
|
||||||
|
"""ALTER TABLE "Notifications" ADD COLUMN "MessageDispatchId" TEXT NULL REFERENCES "MessageDispatches" ("Id") ON DELETE CASCADE;""",
|
||||||
|
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
|
||||||
|
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ClassroomReservationStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ClassroomReservations" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ClassroomReservations" PRIMARY KEY,
|
||||||
|
"ApplicantUserId" TEXT NOT NULL,
|
||||||
|
"ApplicantName" TEXT NOT NULL,
|
||||||
|
"ApplicantCollegeId" TEXT NOT NULL,
|
||||||
|
"AcademicTermId" TEXT NOT NULL,
|
||||||
|
"ClassroomId" TEXT NOT NULL,
|
||||||
|
"ReservationDate" TEXT NOT NULL,
|
||||||
|
"StartPeriod" INTEGER NOT NULL,
|
||||||
|
"PeriodCount" INTEGER NOT NULL,
|
||||||
|
"AttendeeCount" INTEGER NOT NULL,
|
||||||
|
"Purpose" TEXT NOT NULL,
|
||||||
|
"ContactPhone" TEXT NOT NULL,
|
||||||
|
"Notes" TEXT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"ReviewedByUserId" TEXT NULL,
|
||||||
|
"ReviewedAt" TEXT NULL,
|
||||||
|
"ReviewComment" TEXT NULL,
|
||||||
|
"CancelledAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ApplicantUserId"
|
||||||
|
FOREIGN KEY ("ApplicantUserId") REFERENCES "AspNetUsers" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_Colleges_ApplicantCollegeId"
|
||||||
|
FOREIGN KEY ("ApplicantCollegeId") REFERENCES "Colleges" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_AcademicTerms_AcademicTermId"
|
||||||
|
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_Classrooms_ClassroomId"
|
||||||
|
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId"
|
||||||
|
FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id")
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt"
|
||||||
|
ON "ClassroomReservations" ("ApplicantUserId", "Status", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationDate"
|
||||||
|
ON "ClassroomReservations" (
|
||||||
|
"ApplicantCollegeId",
|
||||||
|
"Status",
|
||||||
|
"ReservationDate"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_StartPeriod"
|
||||||
|
ON "ClassroomReservations" (
|
||||||
|
"ClassroomId",
|
||||||
|
"ReservationDate",
|
||||||
|
"Status",
|
||||||
|
"StartPeriod"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_AcademicTermId"
|
||||||
|
ON "ClassroomReservations" ("AcademicTermId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ReviewedByUserId"
|
||||||
|
ON "ClassroomReservations" ("ReviewedByUserId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] BackgroundJobOutboxStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "BackgroundJobOutboxMessages" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_BackgroundJobOutboxMessages" PRIMARY KEY,
|
||||||
|
"JobKind" INTEGER NOT NULL,
|
||||||
|
"JobId" TEXT NOT NULL,
|
||||||
|
"State" INTEGER NOT NULL,
|
||||||
|
"PublishAttempts" INTEGER NOT NULL,
|
||||||
|
"ProcessingAttempts" INTEGER NOT NULL,
|
||||||
|
"PublishedAt" TEXT NULL,
|
||||||
|
"ProcessingToken" TEXT NULL,
|
||||||
|
"LeaseExpiresAt" TEXT NULL,
|
||||||
|
"CompletedAt" TEXT NULL,
|
||||||
|
"LastError" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_BackgroundJobOutboxMessages_JobKind_JobId"
|
||||||
|
ON "BackgroundJobOutboxMessages" ("JobKind", "JobId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_BackgroundJobOutboxMessages_LeaseExpiresAt"
|
||||||
|
ON "BackgroundJobOutboxMessages" ("LeaseExpiresAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_BackgroundJobOutboxMessages_State_CreatedAt"
|
||||||
|
ON "BackgroundJobOutboxMessages" ("State", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_BackgroundJobOutboxMessages_State_CompletedAt"
|
||||||
|
ON "BackgroundJobOutboxMessages" ("State", "CompletedAt");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+4579
File diff suppressed because it is too large
Load Diff
+156
@@ -0,0 +1,156 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class OfficialDocuments : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OfficialDocuments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
DocumentNumber = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
VerificationCodeHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Type = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
IssuedByUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
IssuedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||||
|
SnapshotJson = table.Column<string>(type: "longtext", nullable: false),
|
||||||
|
PdfContent = table.Column<byte[]>(type: "longblob", nullable: false),
|
||||||
|
PdfSha256 = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||||
|
InvalidatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
InvalidatedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
InvalidationReason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
ReissuedFromDocumentId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OfficialDocuments", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OfficialDocuments_AspNetUsers_InvalidatedByUserId",
|
||||||
|
column: x => x.InvalidatedByUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OfficialDocuments_AspNetUsers_IssuedByUserId",
|
||||||
|
column: x => x.IssuedByUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OfficialDocuments_OfficialDocuments_ReissuedFromDocumentId",
|
||||||
|
column: x => x.ReissuedFromDocumentId,
|
||||||
|
principalTable: "OfficialDocuments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OfficialDocuments_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OfficialDocumentDownloads",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
OfficialDocumentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
DownloadedByUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||||
|
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OfficialDocumentDownloads", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OfficialDocumentDownloads_AspNetUsers_DownloadedByUserId",
|
||||||
|
column: x => x.DownloadedByUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OfficialDocumentDownloads_OfficialDocuments_OfficialDocument~",
|
||||||
|
column: x => x.OfficialDocumentId,
|
||||||
|
principalTable: "OfficialDocuments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocumentDownloads_DownloadedByUserId_CreatedAt",
|
||||||
|
table: "OfficialDocumentDownloads",
|
||||||
|
columns: new[] { "DownloadedByUserId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocumentDownloads_OfficialDocumentId_CreatedAt",
|
||||||
|
table: "OfficialDocumentDownloads",
|
||||||
|
columns: new[] { "OfficialDocumentId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocuments_DocumentNumber",
|
||||||
|
table: "OfficialDocuments",
|
||||||
|
column: "DocumentNumber",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocuments_InvalidatedByUserId",
|
||||||
|
table: "OfficialDocuments",
|
||||||
|
column: "InvalidatedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocuments_IssuedByUserId",
|
||||||
|
table: "OfficialDocuments",
|
||||||
|
column: "IssuedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocuments_ReissuedFromDocumentId",
|
||||||
|
table: "OfficialDocuments",
|
||||||
|
column: "ReissuedFromDocumentId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocuments_Status_IssuedAt",
|
||||||
|
table: "OfficialDocuments",
|
||||||
|
columns: new[] { "Status", "IssuedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocuments_StudentId_IssuedAt",
|
||||||
|
table: "OfficialDocuments",
|
||||||
|
columns: new[] { "StudentId", "IssuedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfficialDocuments_VerificationCodeHash",
|
||||||
|
table: "OfficialDocuments",
|
||||||
|
column: "VerificationCodeHash",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OfficialDocumentDownloads");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OfficialDocuments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260726170000_CourseSelectionWaitlist")]
|
||||||
|
public partial class CourseSelectionWaitlist : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @waitlisted_column_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `INFORMATION_SCHEMA`.`COLUMNS`
|
||||||
|
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||||
|
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||||
|
AND `COLUMN_NAME` = 'WaitlistedAt'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @add_waitlisted_column_sql = IF(
|
||||||
|
@waitlisted_column_exists = 0,
|
||||||
|
'ALTER TABLE `CourseEnrollments` ADD COLUMN `WaitlistedAt` datetime(6) NULL',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"PREPARE add_waitlisted_column_stmt FROM @add_waitlisted_column_sql;");
|
||||||
|
migrationBuilder.Sql("EXECUTE add_waitlisted_column_stmt;");
|
||||||
|
migrationBuilder.Sql("DEALLOCATE PREPARE add_waitlisted_column_stmt;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @waitlist_index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `INFORMATION_SCHEMA`.`STATISTICS`
|
||||||
|
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||||
|
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||||
|
AND `INDEX_NAME` = 'IX_CE_Offering_Status_WaitlistedAt'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @add_waitlist_index_sql = IF(
|
||||||
|
@waitlist_index_exists = 0,
|
||||||
|
'CREATE INDEX `IX_CE_Offering_Status_WaitlistedAt` ON `CourseEnrollments` (`CourseSelectionOfferingId`, `Status`, `WaitlistedAt`)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"PREPARE add_waitlist_index_stmt FROM @add_waitlist_index_sql;");
|
||||||
|
migrationBuilder.Sql("EXECUTE add_waitlist_index_stmt;");
|
||||||
|
migrationBuilder.Sql("DEALLOCATE PREPARE add_waitlist_index_stmt;");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @waitlist_index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `INFORMATION_SCHEMA`.`STATISTICS`
|
||||||
|
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||||
|
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||||
|
AND `INDEX_NAME` = 'IX_CE_Offering_Status_WaitlistedAt'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @drop_waitlist_index_sql = IF(
|
||||||
|
@waitlist_index_exists > 0,
|
||||||
|
'DROP INDEX `IX_CE_Offering_Status_WaitlistedAt` ON `CourseEnrollments`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"PREPARE drop_waitlist_index_stmt FROM @drop_waitlist_index_sql;");
|
||||||
|
migrationBuilder.Sql("EXECUTE drop_waitlist_index_stmt;");
|
||||||
|
migrationBuilder.Sql("DEALLOCATE PREPARE drop_waitlist_index_stmt;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @waitlisted_column_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `INFORMATION_SCHEMA`.`COLUMNS`
|
||||||
|
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||||
|
AND `TABLE_NAME` = 'CourseEnrollments'
|
||||||
|
AND `COLUMN_NAME` = 'WaitlistedAt'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
SET @drop_waitlisted_column_sql = IF(
|
||||||
|
@waitlisted_column_exists > 0,
|
||||||
|
'ALTER TABLE `CourseEnrollments` DROP COLUMN `WaitlistedAt`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"PREPARE drop_waitlisted_column_stmt FROM @drop_waitlisted_column_sql;");
|
||||||
|
migrationBuilder.Sql("EXECUTE drop_waitlisted_column_stmt;");
|
||||||
|
migrationBuilder.Sql("DEALLOCATE PREPARE drop_waitlisted_column_stmt;");
|
||||||
|
}
|
||||||
|
}
|
||||||
+4391
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseSelectionGradeLimits : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MaxCourseCount",
|
||||||
|
table: "CourseSelectionRounds",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseSelectionRoundGrades",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CourseSelectionRoundId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Grade = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CourseSelectionRoundGrades", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CourseSelectionRoundGrades_CourseSelectionRounds_CourseSelec~",
|
||||||
|
column: x => x.CourseSelectionRoundId,
|
||||||
|
principalTable: "CourseSelectionRounds",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade",
|
||||||
|
table: "CourseSelectionRoundGrades",
|
||||||
|
columns: new[] { "CourseSelectionRoundId", "Grade" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseSelectionRoundGrades_Grade",
|
||||||
|
table: "CourseSelectionRoundGrades",
|
||||||
|
column: "Grade");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseSelectionRoundGrades");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MaxCourseCount",
|
||||||
|
table: "CourseSelectionRounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4398
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class PersonalCalendarSubscription : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CalendarSubscriptionCreatedAt",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CalendarSubscriptionStamp",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "varchar(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CalendarSubscriptionCreatedAt",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CalendarSubscriptionStamp",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4662
File diff suppressed because it is too large
Load Diff
+102
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UnifiedMessageCenter : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Category",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "MessageDispatchId",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MessageDispatches",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
SenderUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
SenderName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||||
|
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
|
||||||
|
Category = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AudienceType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AudienceId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
AudienceName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||||
|
RecipientCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LinkUrl = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_MessageDispatches", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_MessageDispatchId",
|
||||||
|
table: "Notifications",
|
||||||
|
column: "MessageDispatchId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "UserId", "Category", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_MessageDispatches_SenderUserId_CreatedAt",
|
||||||
|
table: "MessageDispatches",
|
||||||
|
columns: new[] { "SenderUserId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
|
||||||
|
table: "Notifications",
|
||||||
|
column: "MessageDispatchId",
|
||||||
|
principalTable: "MessageDispatches",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "MessageDispatches");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_MessageDispatchId",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Category",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MessageDispatchId",
|
||||||
|
table: "Notifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4790
File diff suppressed because it is too large
Load Diff
+108
@@ -0,0 +1,108 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ClassroomReservations : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClassroomReservations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ApplicantName = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
ApplicantCollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ReservationDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AttendeeCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||||
|
ContactPhone = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClassroomReservations", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_AcademicTerms_AcademicTermId",
|
||||||
|
column: x => x.AcademicTermId,
|
||||||
|
principalTable: "AcademicTerms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_AspNetUsers_ApplicantUserId",
|
||||||
|
column: x => x.ApplicantUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId",
|
||||||
|
column: x => x.ReviewedByUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_Classrooms_ClassroomId",
|
||||||
|
column: x => x.ClassroomId,
|
||||||
|
principalTable: "Classrooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_Colleges_ApplicantCollegeId",
|
||||||
|
column: x => x.ApplicantCollegeId,
|
||||||
|
principalTable: "Colleges",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_AcademicTermId",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
column: "AcademicTermId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationD~",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
columns: new[] { "ApplicantCollegeId", "Status", "ReservationDate" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
columns: new[] { "ApplicantUserId", "Status", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_Sta~",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
columns: new[] { "ClassroomId", "ReservationDate", "Status", "StartPeriod" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ReviewedByUserId",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
column: "ReviewedByUserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClassroomReservations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4847
File diff suppressed because it is too large
Load Diff
+67
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class BackgroundJobOutbox : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "BackgroundJobOutboxMessages",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
JobKind = table.Column<int>(type: "int", nullable: false),
|
||||||
|
JobId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
State = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PublishAttempts = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ProcessingAttempts = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ProcessingToken = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
LeaseExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
LastError = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_BackgroundJobOutboxMessages", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BackgroundJobOutboxMessages_JobKind_JobId",
|
||||||
|
table: "BackgroundJobOutboxMessages",
|
||||||
|
columns: new[] { "JobKind", "JobId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BackgroundJobOutboxMessages_LeaseExpiresAt",
|
||||||
|
table: "BackgroundJobOutboxMessages",
|
||||||
|
column: "LeaseExpiresAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BackgroundJobOutboxMessages_State_CreatedAt",
|
||||||
|
table: "BackgroundJobOutboxMessages",
|
||||||
|
columns: new[] { "State", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BackgroundJobOutboxMessages_State_CompletedAt",
|
||||||
|
table: "BackgroundJobOutboxMessages",
|
||||||
|
columns: new[] { "State", "CompletedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "BackgroundJobOutboxMessages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4867
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseAdjustmentOccurrences : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "SourceClassroomId",
|
||||||
|
table: "CourseAdjustments",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "SourceDate",
|
||||||
|
table: "CourseAdjustments",
|
||||||
|
type: "date",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SourcePeriodCount",
|
||||||
|
table: "CourseAdjustments",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "SourceScheduleEntryId",
|
||||||
|
table: "CourseAdjustments",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SourceStartPeriod",
|
||||||
|
table: "CourseAdjustments",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SourceWeek",
|
||||||
|
table: "CourseAdjustments",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek",
|
||||||
|
table: "CourseAdjustments",
|
||||||
|
columns: new[] { "SourceScheduleEntryId", "SourceWeek" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek",
|
||||||
|
table: "CourseAdjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceClassroomId",
|
||||||
|
table: "CourseAdjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceDate",
|
||||||
|
table: "CourseAdjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourcePeriodCount",
|
||||||
|
table: "CourseAdjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceScheduleEntryId",
|
||||||
|
table: "CourseAdjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceStartPeriod",
|
||||||
|
table: "CourseAdjustments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceWeek",
|
||||||
|
table: "CourseAdjustments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+541
-15
@@ -40,13 +40,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly>("EndDate")
|
b.Property<DateTime>("EndDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<bool>("IsCurrent")
|
b.Property<bool>("IsArchived")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<bool>("IsArchived")
|
b.Property<bool>("IsCurrent")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<bool>("IsEnabled")
|
b.Property<bool>("IsEnabled")
|
||||||
@@ -63,7 +63,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateOnly>("StartDate")
|
b.Property<DateTime>("StartDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
@@ -74,10 +74,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.HasIndex("Code")
|
b.HasIndex("Code")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("IsCurrent");
|
|
||||||
|
|
||||||
b.HasIndex("IsArchived");
|
b.HasIndex("IsArchived");
|
||||||
|
|
||||||
|
b.HasIndex("IsCurrent");
|
||||||
|
|
||||||
b.HasIndex("IsEnabled", "SortOrder");
|
b.HasIndex("IsEnabled", "SortOrder");
|
||||||
|
|
||||||
b.ToTable("AcademicTerms");
|
b.ToTable("AcademicTerms");
|
||||||
@@ -465,6 +465,92 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("Classrooms");
|
b.ToTable("Classrooms");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("AcademicTermId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApplicantCollegeId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ApplicantName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApplicantUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("AttendeeCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ClassroomId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPhone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("PeriodCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Purpose")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReservationDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("ReviewComment")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReviewedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReviewedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("StartPeriod")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AcademicTermId");
|
||||||
|
|
||||||
|
b.HasIndex("ReviewedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate");
|
||||||
|
|
||||||
|
b.HasIndex("ApplicantUserId", "Status", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod");
|
||||||
|
|
||||||
|
b.ToTable("ClassroomReservations");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -595,7 +681,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid>("ApplicantUserId")
|
b.Property<Guid>("ApplicantUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateOnly?>("CancelDate")
|
b.Property<DateTime?>("CancelDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<int?>("CancelWeek")
|
b.Property<int?>("CancelWeek")
|
||||||
@@ -628,6 +714,24 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid?>("ReviewedByUserId")
|
b.Property<Guid?>("ReviewedByUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SourceClassroomId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SourceDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<int?>("SourcePeriodCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SourceScheduleEntryId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int?>("SourceStartPeriod")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("SourceWeek")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("StartPeriod")
|
b.Property<int?>("StartPeriod")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -640,7 +744,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid?>("SubstituteTeacherId")
|
b.Property<Guid?>("SubstituteTeacherId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateOnly?>("TargetDate")
|
b.Property<DateTime?>("TargetDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<Guid>("TeachingTaskId")
|
b.Property<Guid>("TeachingTaskId")
|
||||||
@@ -660,6 +764,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("SubstituteTeacherId");
|
b.HasIndex("SubstituteTeacherId");
|
||||||
|
|
||||||
|
b.HasIndex("SourceScheduleEntryId", "SourceWeek");
|
||||||
|
|
||||||
b.HasIndex("Status", "CreatedAt");
|
b.HasIndex("Status", "CreatedAt");
|
||||||
|
|
||||||
b.HasIndex("TeachingTaskId", "Status");
|
b.HasIndex("TeachingTaskId", "Status");
|
||||||
@@ -732,6 +838,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("WaitlistedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateTime?>("WithdrawnAt")
|
b.Property<DateTime?>("WithdrawnAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
@@ -742,6 +851,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("StudentId", "Status");
|
b.HasIndex("StudentId", "Status");
|
||||||
|
|
||||||
|
b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt")
|
||||||
|
.HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt");
|
||||||
|
|
||||||
b.ToTable("CourseEnrollments");
|
b.ToTable("CourseEnrollments");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -849,6 +961,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("EndsAt")
|
b.Property<DateTime>("EndsAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxCourseCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal>("MaxCredits")
|
b.Property<decimal>("MaxCredits")
|
||||||
.HasPrecision(6, 1)
|
.HasPrecision(6, 1)
|
||||||
.HasColumnType("decimal(6,1)");
|
.HasColumnType("decimal(6,1)");
|
||||||
@@ -881,6 +996,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("CourseSelectionRounds");
|
b.ToTable("CourseSelectionRounds");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CourseSelectionRoundId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("CourseSelectionRoundId", "Grade")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CourseSelectionRoundGrades");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1402,7 +1545,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("EndsAt")
|
b.Property<DateTime>("EndsAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly>("ExamDate")
|
b.Property<DateTime>("ExamDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<Guid>("ExamPlanId")
|
b.Property<Guid>("ExamPlanId")
|
||||||
@@ -2112,7 +2255,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("EndsAt")
|
b.Property<DateTime>("EndsAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly>("ExamDate")
|
b.Property<DateTime>("ExamDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<Guid>("MakeupExamPlanId")
|
b.Property<Guid>("MakeupExamPlanId")
|
||||||
@@ -2173,12 +2316,73 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("MakeupExamSessionInvigilators");
|
b.ToTable("MakeupExamSessionInvigilators");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AudienceId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("AudienceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<int>("AudienceType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Category")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("LinkUrl")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("varchar(300)");
|
||||||
|
|
||||||
|
b.Property<int>("RecipientCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SenderName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SenderUserId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("MessageDispatches");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("Category")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("Content")
|
b.Property<string>("Content")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
@@ -2194,6 +2398,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
.HasColumnType("varchar(300)");
|
.HasColumnType("varchar(300)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("MessageDispatchId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -2209,11 +2416,139 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("MessageDispatchId");
|
||||||
|
|
||||||
b.HasIndex("UserId", "IsRead");
|
b.HasIndex("UserId", "IsRead");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Category", "CreatedAt");
|
||||||
|
|
||||||
b.ToTable("Notifications");
|
b.ToTable("Notifications");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("InvalidatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InvalidatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("InvalidationReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("IssuedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("IssuedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PdfContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longblob");
|
||||||
|
|
||||||
|
b.Property<string>("PdfSha256")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Purpose")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReissuedFromDocumentId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("SnapshotJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("StudentId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("VerificationCodeHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DocumentNumber")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("InvalidatedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("IssuedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("ReissuedFromDocumentId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("VerificationCodeHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status", "IssuedAt");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId", "IssuedAt");
|
||||||
|
|
||||||
|
b.ToTable("OfficialDocuments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("DownloadedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OfficialDocumentId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DownloadedByUserId", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("OfficialDocumentId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("OfficialDocumentDownloads");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2386,7 +2721,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<TimeOnly>("EndsAt")
|
b.Property<TimeSpan>("EndsAt")
|
||||||
.HasColumnType("time");
|
.HasColumnType("time");
|
||||||
|
|
||||||
b.Property<bool>("IsEnabled")
|
b.Property<bool>("IsEnabled")
|
||||||
@@ -2400,7 +2735,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("PeriodNumber")
|
b.Property<int>("PeriodNumber")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<TimeOnly>("StartsAt")
|
b.Property<TimeSpan>("StartsAt")
|
||||||
.HasColumnType("time");
|
.HasColumnType("time");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
@@ -2426,14 +2761,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateOnly?>("DateOfBirth")
|
b.Property<DateTime?>("DateOfBirth")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("varchar(100)");
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
b.Property<DateOnly>("EnrollmentDate")
|
b.Property<DateTime>("EnrollmentDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<int>("EnrollmentYear")
|
b.Property<int>("EnrollmentYear")
|
||||||
@@ -2554,7 +2889,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("Gender")
|
b.Property<int>("Gender")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateOnly?>("HireDate")
|
b.Property<DateTime?>("HireDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<bool>("IsExternal")
|
b.Property<bool>("IsExternal")
|
||||||
@@ -2979,6 +3314,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("AccessFailedCount")
|
b.Property<int>("AccessFailedCount")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CalendarSubscriptionCreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("CalendarSubscriptionStamp")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
b.Property<Guid?>("CollegeId")
|
b.Property<Guid?>("CollegeId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -3101,6 +3443,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("AuditLogs");
|
b.ToTable("AuditLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.System.BackgroundJobOutboxMessage", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("JobId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("JobKind")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("varchar(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LeaseExpiresAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("ProcessingAttempts")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProcessingToken")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("PublishAttempts")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PublishedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("State")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LeaseExpiresAt");
|
||||||
|
|
||||||
|
b.HasIndex("JobKind", "JobId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("State", "CompletedAt");
|
||||||
|
|
||||||
|
b.HasIndex("State", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("BackgroundJobOutboxMessages");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3286,6 +3685,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Building");
|
b.Navigation("Building");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AcademicTermId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.College", "ApplicantCollege")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApplicantCollegeId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ApplicantUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApplicantUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ClassroomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ReviewedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ReviewedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("AcademicTerm");
|
||||||
|
|
||||||
|
b.Navigation("ApplicantCollege");
|
||||||
|
|
||||||
|
b.Navigation("ApplicantUser");
|
||||||
|
|
||||||
|
b.Navigation("Classroom");
|
||||||
|
|
||||||
|
b.Navigation("ReviewedByUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
||||||
@@ -3407,6 +3848,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("AcademicTerm");
|
b.Navigation("AcademicTerm");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
|
||||||
|
.WithMany("EligibleGrades")
|
||||||
|
.HasForeignKey("CourseSelectionRoundId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CourseSelectionRound");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse")
|
||||||
@@ -3897,6 +4349,68 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Teacher");
|
b.Navigation("Teacher");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.MessageDispatch", "MessageDispatch")
|
||||||
|
.WithMany("Notifications")
|
||||||
|
.HasForeignKey("MessageDispatchId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("MessageDispatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InvalidatedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "IssuedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("IssuedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "ReissuedFromDocument")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ReissuedFromDocumentId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("InvalidatedByUser");
|
||||||
|
|
||||||
|
b.Navigation("IssuedByUser");
|
||||||
|
|
||||||
|
b.Navigation("ReissuedFromDocument");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "DownloadedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DownloadedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "OfficialDocument")
|
||||||
|
.WithMany("Downloads")
|
||||||
|
.HasForeignKey("OfficialDocumentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("DownloadedByUser");
|
||||||
|
|
||||||
|
b.Navigation("OfficialDocument");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||||
@@ -4227,6 +4741,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("EligibleGrades");
|
||||||
|
|
||||||
b.Navigation("Offerings");
|
b.Navigation("Offerings");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4316,6 +4832,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Invigilators");
|
b.Navigation("Invigilators");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Downloads");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Entries");
|
b.Navigation("Entries");
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public static class MySqlConnectionStringPolicy
|
||||||
|
{
|
||||||
|
public static string ForApplication(string connectionString)
|
||||||
|
{
|
||||||
|
var builder = new MySqlConnectionStringBuilder(connectionString)
|
||||||
|
{
|
||||||
|
AllowUserVariables = true
|
||||||
|
};
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,104 +1,11 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
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.Scheduling;
|
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
|
||||||
public sealed class AutomaticScheduleJobQueue
|
|
||||||
{
|
|
||||||
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 AutomaticScheduleJobWorker(
|
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
AutomaticScheduleJobQueue queue,
|
|
||||||
ILogger<AutomaticScheduleJobWorker> logger) : BackgroundService
|
|
||||||
{
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
|
||||||
var processor = scope.ServiceProvider
|
|
||||||
.GetRequiredService<AutomaticScheduleJobProcessor>();
|
|
||||||
await processor.ProcessAsync(jobId, stoppingToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Unexpected failure while dispatching automatic schedule job {JobId}.",
|
|
||||||
jobId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Automatic schedule 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.AutomaticScheduleJobs
|
|
||||||
.Where(x =>
|
|
||||||
x.Status == AutomaticScheduleJobStatus.Queued ||
|
|
||||||
x.Status == AutomaticScheduleJobStatus.Running)
|
|
||||||
.OrderBy(x => x.CreatedAt)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
foreach (var job in jobs)
|
|
||||||
{
|
|
||||||
job.Status = AutomaticScheduleJobStatus.Queued;
|
|
||||||
job.ActiveSchedulePlanId = job.SchedulePlanId;
|
|
||||||
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 automatic schedule jobs.",
|
|
||||||
jobs.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class AutomaticScheduleJobProcessor(
|
public sealed class AutomaticScheduleJobProcessor(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
AutomaticScheduleGenerator generator,
|
AutomaticScheduleGenerator generator,
|
||||||
|
|||||||
@@ -1,108 +1,15 @@
|
|||||||
using System.Threading.Channels;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
|
||||||
public sealed class SchedulePublishJobQueue
|
|
||||||
{
|
|
||||||
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 SchedulePublishJobWorker(
|
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
SchedulePublishJobQueue queue,
|
|
||||||
ILogger<SchedulePublishJobWorker> logger) : BackgroundService
|
|
||||||
{
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
|
||||||
var processor = scope.ServiceProvider
|
|
||||||
.GetRequiredService<SchedulePublishJobProcessor>();
|
|
||||||
await processor.ProcessAsync(jobId, stoppingToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Unexpected failure while dispatching schedule publish job {JobId}.",
|
|
||||||
jobId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Schedule publish 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.SchedulePublishJobs
|
|
||||||
.Where(x =>
|
|
||||||
x.Status == SchedulePublishJobStatus.Queued ||
|
|
||||||
x.Status == SchedulePublishJobStatus.Running)
|
|
||||||
.OrderBy(x => x.CreatedAt)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
foreach (var job in jobs)
|
|
||||||
{
|
|
||||||
job.Status = SchedulePublishJobStatus.Queued;
|
|
||||||
job.ActiveAcademicTermId = job.AcademicTermId;
|
|
||||||
job.CompletedSteps = 0;
|
|
||||||
job.CurrentStep = "等待后台检查";
|
|
||||||
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 schedule publish jobs.",
|
|
||||||
jobs.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class SchedulePublishJobProcessor(
|
public sealed class SchedulePublishJobProcessor(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
SchedulePlanPublisher publisher,
|
SchedulePlanPublisher publisher,
|
||||||
|
IAppCache cache,
|
||||||
ILogger<SchedulePublishJobProcessor> logger)
|
ILogger<SchedulePublishJobProcessor> logger)
|
||||||
{
|
{
|
||||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
@@ -177,6 +84,7 @@ public sealed class SchedulePublishJobProcessor(
|
|||||||
},
|
},
|
||||||
stoppingToken);
|
stoppingToken);
|
||||||
|
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@@ -75,11 +75,11 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
|||||||
{
|
{
|
||||||
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
|
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
|
||||||
if (rule.NotifyStudent && si.UserId.HasValue)
|
if (rule.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 (rule.NotifyCounselor)
|
if (rule.NotifyCounselor)
|
||||||
{
|
{
|
||||||
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||||
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
|
||||||
|
public static class TeachingTaskRosterQuery
|
||||||
|
{
|
||||||
|
public static IQueryable<Student> ForTask(AppDbContext db, Guid teachingTaskId) =>
|
||||||
|
db.Students.Where(student =>
|
||||||
|
(student.Status == StudentStatus.Active &&
|
||||||
|
db.TeachingTaskClasses.Any(assignment =>
|
||||||
|
assignment.TeachingTaskId == teachingTaskId &&
|
||||||
|
assignment.AdministrativeClassId == student.AdministrativeClassId)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == student.Id &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
|
||||||
|
public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<HashSet<Guid>> GetOccupiedClassroomIdsAsync(
|
||||||
|
AcademicTerm term,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
Guid? excludedReservationId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var occupiedIds = new HashSet<Guid>();
|
||||||
|
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
|
||||||
|
|
||||||
|
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(entry =>
|
||||||
|
entry.ClassroomId.HasValue &&
|
||||||
|
entry.SchedulePlan!.AcademicTermId == term.Id &&
|
||||||
|
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||||
|
entry.DayOfWeek == dayOfWeek &&
|
||||||
|
entry.StartWeek <= week &&
|
||||||
|
entry.EndWeek >= week &&
|
||||||
|
entry.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < entry.StartPeriod + entry.PeriodCount)
|
||||||
|
.Select(entry => new
|
||||||
|
{
|
||||||
|
entry.ClassroomId,
|
||||||
|
entry.WeekPattern,
|
||||||
|
entry.StartPeriod,
|
||||||
|
entry.PeriodCount
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var entry in scheduleEntries.Where(entry =>
|
||||||
|
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
|
||||||
|
FreeClassroomRules.PeriodsOverlap(
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
entry.StartPeriod,
|
||||||
|
entry.PeriodCount)))
|
||||||
|
{
|
||||||
|
occupiedIds.Add(entry.ClassroomId!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var examRoomIds = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(session =>
|
||||||
|
session.ClassroomId.HasValue &&
|
||||||
|
session.ExamPlan!.AcademicTermId == term.Id &&
|
||||||
|
session.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||||
|
session.ExamDate == reservationDate &&
|
||||||
|
session.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||||
|
.Select(session => session.ClassroomId!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(examRoomIds);
|
||||||
|
|
||||||
|
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(session =>
|
||||||
|
session.ClassroomId.HasValue &&
|
||||||
|
session.MakeupExamPlan!.AcademicTermId == term.Id &&
|
||||||
|
session.MakeupExamPlan.Status == MakeupExamPlanStatus.Published &&
|
||||||
|
session.ExamDate == reservationDate &&
|
||||||
|
session.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||||
|
.Select(session => session.ClassroomId!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(makeupExamRoomIds);
|
||||||
|
|
||||||
|
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
||||||
|
.Where(reservation =>
|
||||||
|
reservation.AcademicTermId == term.Id &&
|
||||||
|
reservation.Status == ClassroomReservationStatus.Approved &&
|
||||||
|
reservation.ReservationDate == reservationDate &&
|
||||||
|
reservation.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod <
|
||||||
|
reservation.StartPeriod + reservation.PeriodCount);
|
||||||
|
if (excludedReservationId.HasValue)
|
||||||
|
reservationQuery = reservationQuery.Where(reservation =>
|
||||||
|
reservation.Id != excludedReservationId.Value);
|
||||||
|
var reservationRoomIds = await reservationQuery
|
||||||
|
.Select(reservation => reservation.ClassroomId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(reservationRoomIds);
|
||||||
|
return occupiedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (int Week, int DayOfWeek) ResolveTeachingWeek(
|
||||||
|
AcademicTerm term,
|
||||||
|
DateOnly date)
|
||||||
|
{
|
||||||
|
var termStartDay = (int)term.StartDate.DayOfWeek;
|
||||||
|
var daysSinceMonday = (termStartDay + 6) % 7;
|
||||||
|
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
|
||||||
|
var week = (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
|
||||||
|
var dayOfWeek = ((int)date.DayOfWeek + 6) % 7 + 1;
|
||||||
|
return (week, dayOfWeek);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
|
||||||
|
public sealed class PersonalCalendarService(
|
||||||
|
AppDbContext db,
|
||||||
|
TimetableDataService timetableDataService,
|
||||||
|
IOptions<JwtOptions> jwtOptions)
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan ChinaOffset = TimeSpan.FromHours(8);
|
||||||
|
private readonly byte[] _signingKey = SHA256.HashData(
|
||||||
|
Encoding.UTF8.GetBytes($"{jwtOptions.Value.Key}|personal-calendar"));
|
||||||
|
|
||||||
|
public static string CreateStamp() =>
|
||||||
|
Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
public string CreateAccessToken(ApplicationUser user)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||||
|
throw new InvalidOperationException("个人日历订阅尚未启用。");
|
||||||
|
|
||||||
|
var payload = Encoding.UTF8.GetBytes(
|
||||||
|
$"{user.Id:N}:{user.CalendarSubscriptionStamp}");
|
||||||
|
using var hmac = new HMACSHA256(_signingKey);
|
||||||
|
return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAccessTokenValid(ApplicationUser user, string accessToken)
|
||||||
|
{
|
||||||
|
if (!user.IsEnabled || string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
byte[] provided;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
provided = WebEncoders.Base64UrlDecode(accessToken);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expected = WebEncoders.Base64UrlDecode(CreateAccessToken(user));
|
||||||
|
return provided.Length == expected.Length &&
|
||||||
|
CryptographicOperations.FixedTimeEquals(provided, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PersonalCalendarResult?> BuildAsync(
|
||||||
|
ApplicationUser user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == user.Id && x.Status == StudentStatus.Active)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.AdministrativeClassId,
|
||||||
|
x.StudentNumber,
|
||||||
|
x.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
var teacher = student is null
|
||||||
|
? await db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == user.Id && x.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => new { x.Id, x.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
if (student is null && teacher is null) return null;
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.Add(ChinaOffset));
|
||||||
|
var earliestDate = today.AddMonths(-2);
|
||||||
|
var latestDate = today.AddYears(1);
|
||||||
|
var termIds = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.IsEnabled &&
|
||||||
|
x.EndDate >= earliestDate &&
|
||||||
|
x.StartDate <= latestDate)
|
||||||
|
.OrderBy(x => x.StartDate)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var calendarName = $"明序教务 · {user.DisplayName}个人教学日历";
|
||||||
|
var builder = new IcsBuilder(calendarName);
|
||||||
|
foreach (var termId in termIds)
|
||||||
|
{
|
||||||
|
var timetable = student is not null
|
||||||
|
? await timetableDataService.BuildAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
student.AdministrativeClassId,
|
||||||
|
termId,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
student.Id,
|
||||||
|
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||||
|
cancellationToken)
|
||||||
|
: await timetableDataService.BuildAsync(
|
||||||
|
TimetableResourceType.Teacher,
|
||||||
|
teacher!.Id,
|
||||||
|
termId,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cancellationToken);
|
||||||
|
if (timetable is null) continue;
|
||||||
|
|
||||||
|
AddScheduledCourses(builder, timetable);
|
||||||
|
AddExamEntries(builder, timetable, teacher is not null);
|
||||||
|
AddFlexibleCourseReminders(builder, timetable);
|
||||||
|
}
|
||||||
|
|
||||||
|
var makeupSessions = await LoadMakeupSessionsAsync(
|
||||||
|
student?.Id,
|
||||||
|
teacher?.Id,
|
||||||
|
earliestDate,
|
||||||
|
latestDate,
|
||||||
|
cancellationToken);
|
||||||
|
foreach (var session in makeupSessions)
|
||||||
|
{
|
||||||
|
builder.AddTimedEvent(
|
||||||
|
$"makeup-{session.Id:N}@jiaowu",
|
||||||
|
teacher is null
|
||||||
|
? $"[补考] {session.CourseName}"
|
||||||
|
: $"[补考监考] {session.CourseName}",
|
||||||
|
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.StartsAt)),
|
||||||
|
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.EndsAt)),
|
||||||
|
JoinLocation(
|
||||||
|
session.CampusName,
|
||||||
|
session.BuildingName,
|
||||||
|
session.ClassroomName),
|
||||||
|
JoinDescription(
|
||||||
|
session.PlanName,
|
||||||
|
session.TaskName,
|
||||||
|
session.Notes),
|
||||||
|
"补考",
|
||||||
|
session.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PersonalCalendarResult(
|
||||||
|
builder.Build(),
|
||||||
|
calendarName,
|
||||||
|
builder.EventCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddScheduledCourses(IcsBuilder builder, TimetableData timetable)
|
||||||
|
{
|
||||||
|
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
var termMonday = StartOfWeek(timetable.Term.StartDate);
|
||||||
|
foreach (var entry in timetable.Entries)
|
||||||
|
{
|
||||||
|
if (!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||||
|
!slots.TryGetValue(
|
||||||
|
entry.StartPeriod + entry.PeriodCount - 1,
|
||||||
|
out var endSlot))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
|
||||||
|
{
|
||||||
|
if (!OccursInWeek(entry.WeekPattern, week)) continue;
|
||||||
|
var date = termMonday.AddDays((week - 1) * 7 + entry.DayOfWeek - 1);
|
||||||
|
if (date < timetable.Term.StartDate || date > timetable.Term.EndDate)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
builder.AddTimedEvent(
|
||||||
|
$"course-{entry.Id:N}-{date:yyyyMMdd}@jiaowu",
|
||||||
|
entry.CourseName,
|
||||||
|
ToUtc(date, startSlot.StartsAt),
|
||||||
|
ToUtc(date, endSlot.EndsAt),
|
||||||
|
JoinLocation(
|
||||||
|
entry.CampusName,
|
||||||
|
entry.BuildingName,
|
||||||
|
entry.ClassroomName),
|
||||||
|
JoinDescription(
|
||||||
|
entry.TaskName,
|
||||||
|
$"教师:{string.Join('、', entry.TeacherNames)}",
|
||||||
|
$"教学班:{string.Join('、', entry.ClassNames)}",
|
||||||
|
entry.Notes),
|
||||||
|
"课程",
|
||||||
|
entry.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddExamEntries(
|
||||||
|
IcsBuilder builder,
|
||||||
|
TimetableData timetable,
|
||||||
|
bool isTeacher)
|
||||||
|
{
|
||||||
|
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
foreach (var entry in timetable.ExamEntries)
|
||||||
|
{
|
||||||
|
if (entry.ExamDate is not DateOnly examDate ||
|
||||||
|
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||||
|
!slots.TryGetValue(
|
||||||
|
entry.StartPeriod + entry.PeriodCount - 1,
|
||||||
|
out var endSlot))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
builder.AddTimedEvent(
|
||||||
|
$"exam-{entry.Id:N}@jiaowu",
|
||||||
|
isTeacher
|
||||||
|
? $"[监考] {entry.CourseName}"
|
||||||
|
: $"[考试] {entry.CourseName}",
|
||||||
|
ToUtc(examDate, startSlot.StartsAt),
|
||||||
|
ToUtc(examDate, endSlot.EndsAt),
|
||||||
|
JoinLocation(
|
||||||
|
entry.CampusName,
|
||||||
|
entry.BuildingName,
|
||||||
|
entry.ClassroomName),
|
||||||
|
JoinDescription(
|
||||||
|
entry.ExamPlanName,
|
||||||
|
entry.TaskName,
|
||||||
|
string.Join('、', entry.TeacherNames),
|
||||||
|
entry.Notes),
|
||||||
|
"考试",
|
||||||
|
entry.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddFlexibleCourseReminders(
|
||||||
|
IcsBuilder builder,
|
||||||
|
TimetableData timetable)
|
||||||
|
{
|
||||||
|
foreach (var course in timetable.FlexibleCourses)
|
||||||
|
{
|
||||||
|
builder.AddAllDayEvent(
|
||||||
|
$"flexible-{course.Id:N}-{timetable.Term.Id:N}@jiaowu",
|
||||||
|
$"[灵活安排] {course.CourseName}",
|
||||||
|
timetable.Term.StartDate,
|
||||||
|
JoinDescription(
|
||||||
|
$"{course.StartWeek}—{course.EndWeek} 周",
|
||||||
|
$"每周 {course.WeeklyHours} 学时",
|
||||||
|
$"教师:{string.Join('、', course.TeacherNames)}",
|
||||||
|
course.Notes),
|
||||||
|
"灵活课程",
|
||||||
|
course.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
|
||||||
|
Guid? studentId,
|
||||||
|
Guid? teacherId,
|
||||||
|
DateOnly earliestDate,
|
||||||
|
DateOnly latestDate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!studentId.HasValue && !teacherId.HasValue) return [];
|
||||||
|
var source = db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||||
|
x.ExamDate >= earliestDate &&
|
||||||
|
x.ExamDate <= latestDate);
|
||||||
|
source = studentId.HasValue
|
||||||
|
? source.Where(x => x.Enrollments.Any(e => e.StudentId == studentId.Value))
|
||||||
|
: source.Where(x => x.Invigilators.Any(i => i.TeacherId == teacherId!.Value));
|
||||||
|
|
||||||
|
return await source
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartsAt)
|
||||||
|
.Select(x => new PersonalMakeupSession(
|
||||||
|
x.Id,
|
||||||
|
x.MakeupExamPlan!.Name,
|
||||||
|
x.TeachingTask!.Name,
|
||||||
|
x.TeachingTask.Course!.Name,
|
||||||
|
x.ExamDate,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
x.Classroom == null ? null : x.Classroom.Name,
|
||||||
|
x.Classroom == null ? null : x.Classroom.Building!.Name,
|
||||||
|
x.Classroom == null ? null : x.Classroom.Building!.Campus!.Name,
|
||||||
|
x.Notes,
|
||||||
|
x.UpdatedAt))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly StartOfWeek(DateOnly date)
|
||||||
|
{
|
||||||
|
var day = date.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)date.DayOfWeek;
|
||||||
|
return date.AddDays(1 - day);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool OccursInWeek(WeekPattern pattern, int week) => pattern switch
|
||||||
|
{
|
||||||
|
WeekPattern.Odd => week % 2 == 1,
|
||||||
|
WeekPattern.Even => week % 2 == 0,
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DateTime ToUtc(DateOnly date, TimeOnly time)
|
||||||
|
{
|
||||||
|
var local = date.ToDateTime(time, DateTimeKind.Unspecified);
|
||||||
|
return new DateTimeOffset(local, ChinaOffset).UtcDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string JoinLocation(params string?[] parts) =>
|
||||||
|
string.Join(" · ", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
|
||||||
|
private static string JoinDescription(params string?[] parts) =>
|
||||||
|
string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
|
||||||
|
private sealed record PersonalMakeupSession(
|
||||||
|
Guid Id,
|
||||||
|
string PlanName,
|
||||||
|
string TaskName,
|
||||||
|
string CourseName,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
DateTime StartsAt,
|
||||||
|
DateTime EndsAt,
|
||||||
|
string? ClassroomName,
|
||||||
|
string? BuildingName,
|
||||||
|
string? CampusName,
|
||||||
|
string? Notes,
|
||||||
|
DateTime UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PersonalCalendarResult(
|
||||||
|
byte[] Content,
|
||||||
|
string Name,
|
||||||
|
int EventCount);
|
||||||
|
|
||||||
|
internal sealed class IcsBuilder
|
||||||
|
{
|
||||||
|
private static readonly UTF8Encoding Utf8 = new(false);
|
||||||
|
private readonly StringBuilder _content = new();
|
||||||
|
private bool _built;
|
||||||
|
|
||||||
|
public IcsBuilder(string name)
|
||||||
|
{
|
||||||
|
AddLine("BEGIN:VCALENDAR");
|
||||||
|
AddLine("VERSION:2.0");
|
||||||
|
AddLine("PRODID:-//Mingxu Jiaowu//Personal Teaching Calendar//ZH-CN");
|
||||||
|
AddLine("CALSCALE:GREGORIAN");
|
||||||
|
AddLine("METHOD:PUBLISH");
|
||||||
|
AddLine($"X-WR-CALNAME:{Escape(name)}");
|
||||||
|
AddLine("X-WR-TIMEZONE:Asia/Shanghai");
|
||||||
|
AddLine("REFRESH-INTERVAL;VALUE=DURATION:PT1H");
|
||||||
|
AddLine("X-PUBLISHED-TTL:PT1H");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int EventCount { get; private set; }
|
||||||
|
|
||||||
|
public void AddTimedEvent(
|
||||||
|
string uid,
|
||||||
|
string summary,
|
||||||
|
DateTime startsAtUtc,
|
||||||
|
DateTime endsAtUtc,
|
||||||
|
string location,
|
||||||
|
string description,
|
||||||
|
string category,
|
||||||
|
DateTime updatedAt)
|
||||||
|
{
|
||||||
|
AddLine("BEGIN:VEVENT");
|
||||||
|
AddLine($"UID:{uid}");
|
||||||
|
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||||
|
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||||
|
AddLine($"DTSTART:{FormatUtc(startsAtUtc)}");
|
||||||
|
AddLine($"DTEND:{FormatUtc(endsAtUtc)}");
|
||||||
|
AddLine($"SUMMARY:{Escape(summary)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(location))
|
||||||
|
AddLine($"LOCATION:{Escape(location)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
|
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||||
|
AddLine($"CATEGORIES:{Escape(category)}");
|
||||||
|
AddLine("STATUS:CONFIRMED");
|
||||||
|
AddLine("END:VEVENT");
|
||||||
|
EventCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddAllDayEvent(
|
||||||
|
string uid,
|
||||||
|
string summary,
|
||||||
|
DateOnly date,
|
||||||
|
string description,
|
||||||
|
string category,
|
||||||
|
DateTime updatedAt)
|
||||||
|
{
|
||||||
|
AddLine("BEGIN:VEVENT");
|
||||||
|
AddLine($"UID:{uid}");
|
||||||
|
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||||
|
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||||
|
AddLine($"DTSTART;VALUE=DATE:{date:yyyyMMdd}");
|
||||||
|
AddLine($"DTEND;VALUE=DATE:{date.AddDays(1):yyyyMMdd}");
|
||||||
|
AddLine($"SUMMARY:{Escape(summary)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
|
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||||
|
AddLine($"CATEGORIES:{Escape(category)}");
|
||||||
|
AddLine("STATUS:CONFIRMED");
|
||||||
|
AddLine("TRANSP:TRANSPARENT");
|
||||||
|
AddLine("END:VEVENT");
|
||||||
|
EventCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] Build()
|
||||||
|
{
|
||||||
|
if (!_built)
|
||||||
|
{
|
||||||
|
AddLine("END:VCALENDAR");
|
||||||
|
_built = true;
|
||||||
|
}
|
||||||
|
return Utf8.GetBytes(_content.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLine(string line)
|
||||||
|
{
|
||||||
|
var current = new StringBuilder();
|
||||||
|
var byteCount = 0;
|
||||||
|
foreach (var rune in line.EnumerateRunes())
|
||||||
|
{
|
||||||
|
var value = rune.ToString();
|
||||||
|
var runeBytes = Utf8.GetByteCount(value);
|
||||||
|
if (byteCount > 0 && byteCount + runeBytes > 74)
|
||||||
|
{
|
||||||
|
_content.Append(current).Append("\r\n ");
|
||||||
|
current.Clear();
|
||||||
|
byteCount = 1;
|
||||||
|
}
|
||||||
|
current.Append(value);
|
||||||
|
byteCount += runeBytes;
|
||||||
|
}
|
||||||
|
_content.Append(current).Append("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string value) => value
|
||||||
|
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
||||||
|
.Replace("\n", "\\n", StringComparison.Ordinal)
|
||||||
|
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||||
|
.Replace(",", "\\,", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static string FormatUtc(DateTime value) =>
|
||||||
|
value.ToUniversalTime().ToString(
|
||||||
|
"yyyyMMdd'T'HHmmss'Z'",
|
||||||
|
CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static DateTime NormalizeUpdatedAt(DateTime value) =>
|
||||||
|
value == default
|
||||||
|
? new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||||
|
: value.Kind == DateTimeKind.Unspecified
|
||||||
|
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||||
|
: value.ToUniversalTime();
|
||||||
|
}
|
||||||
@@ -99,7 +99,11 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeekPattern,
|
x.WeekPattern,
|
||||||
x.Notes))
|
x.Notes,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
x.UpdatedAt))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +294,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
.Select(item => item.Teacher!.Name),
|
.Select(item => item.Teacher!.Name),
|
||||||
x.Classes.Select(item => item.AdministrativeClass!.Name),
|
x.Classes.Select(item => item.AdministrativeClass!.Name),
|
||||||
x.Notes))
|
x.Notes,
|
||||||
|
x.UpdatedAt))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +362,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
x.ExamPlan!.Name,
|
x.ExamPlan!.Name,
|
||||||
InvigilatorNames = x.Invigilators
|
InvigilatorNames = x.Invigilators
|
||||||
.Select(i => i.Teacher!.Name),
|
.Select(i => i.Teacher!.Name),
|
||||||
x.Notes
|
x.Notes,
|
||||||
|
x.UpdatedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -385,7 +391,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
x.Notes,
|
x.Notes,
|
||||||
true,
|
true,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.ExamDate);
|
x.ExamDate,
|
||||||
|
x.UpdatedAt);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,7 +475,8 @@ public sealed record TimetableEntryDto(
|
|||||||
string? Notes,
|
string? Notes,
|
||||||
bool IsExam = false,
|
bool IsExam = false,
|
||||||
string? ExamPlanName = null,
|
string? ExamPlanName = null,
|
||||||
DateOnly? ExamDate = null);
|
DateOnly? ExamDate = null,
|
||||||
|
DateTime UpdatedAt = default);
|
||||||
|
|
||||||
public sealed record FlexibleCourseDto(
|
public sealed record FlexibleCourseDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -483,4 +491,5 @@ public sealed record FlexibleCourseDto(
|
|||||||
int WeeklyHours,
|
int WeeklyHours,
|
||||||
IEnumerable<string> TeacherNames,
|
IEnumerable<string> TeacherNames,
|
||||||
IEnumerable<string> ClassNames,
|
IEnumerable<string> ClassNames,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
DateTime UpdatedAt = default);
|
||||||
|
|||||||
@@ -23,12 +23,18 @@
|
|||||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
|
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
|
||||||
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||||
|
<PackageReference Include="SkiaSharp" Version="3.119.2" />
|
||||||
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.2" />
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
+203
-7
@@ -1,10 +1,13 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
using Jiaowu.Api.Infrastructure.Configuration;
|
using Jiaowu.Api.Infrastructure.Configuration;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Middleware;
|
using Jiaowu.Api.Infrastructure.Middleware;
|
||||||
|
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
using Jiaowu.Api.Infrastructure.Timetables;
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
@@ -12,6 +15,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
@@ -61,6 +65,26 @@ if (seedDemoData && builder.Environment.IsDevelopment())
|
|||||||
var databaseOptions = builder.Configuration
|
var databaseOptions = builder.Configuration
|
||||||
.GetSection(DatabaseOptions.SectionName)
|
.GetSection(DatabaseOptions.SectionName)
|
||||||
.Get<DatabaseOptions>() ?? new DatabaseOptions();
|
.Get<DatabaseOptions>() ?? new DatabaseOptions();
|
||||||
|
var cacheOptions = builder.Configuration
|
||||||
|
.GetSection(AppCacheOptions.SectionName)
|
||||||
|
.Get<AppCacheOptions>() ?? new AppCacheOptions();
|
||||||
|
var officialDocumentOptions = builder.Configuration
|
||||||
|
.GetSection(OfficialDocumentOptions.SectionName)
|
||||||
|
.Get<OfficialDocumentOptions>() ?? new OfficialDocumentOptions();
|
||||||
|
var backgroundJobOptions = builder.Configuration
|
||||||
|
.GetSection(BackgroundJobOptions.SectionName)
|
||||||
|
.Get<BackgroundJobOptions>() ?? new BackgroundJobOptions();
|
||||||
|
var rabbitMqOptions = builder.Configuration
|
||||||
|
.GetSection(RabbitMqOptions.SectionName)
|
||||||
|
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
|
||||||
|
string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) ||
|
||||||
|
string.IsNullOrWhiteSpace(officialDocumentOptions.DocumentNumberPrefix))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"OfficialDocuments 的 InstitutionName、IssuingOffice 和 DocumentNumberPrefix 不能为空。");
|
||||||
|
}
|
||||||
|
|
||||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) &&
|
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) &&
|
||||||
!builder.Environment.IsDevelopment())
|
!builder.Environment.IsDevelopment())
|
||||||
@@ -82,7 +106,74 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
|||||||
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||||
|
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||||
|
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||||
|
cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
|
cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
|
cacheOptions.AnalyticsLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
|
cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 ||
|
||||||
|
cacheOptions.ReferenceLocalExpirationSeconds >
|
||||||
|
cacheOptions.ReferenceExpirationMinutes * 60 ||
|
||||||
|
cacheOptions.TimetableLocalExpirationSeconds >
|
||||||
|
cacheOptions.TimetableExpirationMinutes * 60 ||
|
||||||
|
cacheOptions.AnalyticsLocalExpirationSeconds >
|
||||||
|
cacheOptions.AnalyticsExpirationMinutes * 60 ||
|
||||||
|
string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) ||
|
||||||
|
cacheOptions.KeyPrefix.Length > 100)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cache 缓存时间或 MaximumPayloadKilobytes 超出允许范围。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backgroundJobOptions.Transport.Equals(
|
||||||
|
"InMemory",
|
||||||
|
StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!backgroundJobOptions.UsesRabbitMq)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"BackgroundJobs:Transport 仅支持 InMemory 或 RabbitMq。");
|
||||||
|
}
|
||||||
|
if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
||||||
|
backgroundJobOptions.LeaseSeconds is < 30 or > 3600 ||
|
||||||
|
backgroundJobOptions.PrefetchCount is < 1 or > 100 ||
|
||||||
|
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
|
||||||
|
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
|
||||||
|
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
|
||||||
|
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||||
|
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||||
|
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||||
|
backgroundJobOptions.CleanupBatchSize is < 10 or > 5000 ||
|
||||||
|
string.IsNullOrWhiteSpace(backgroundJobOptions.Exchange) ||
|
||||||
|
string.IsNullOrWhiteSpace(backgroundJobOptions.QueuePrefix))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("BackgroundJobs 配置超出允许范围。");
|
||||||
|
}
|
||||||
|
if (backgroundJobOptions.UsesRabbitMq &&
|
||||||
|
(string.IsNullOrWhiteSpace(rabbitMqOptions.HostName) ||
|
||||||
|
rabbitMqOptions.Port is < 1 or > 65535 ||
|
||||||
|
string.IsNullOrWhiteSpace(rabbitMqOptions.UserName) ||
|
||||||
|
string.IsNullOrWhiteSpace(rabbitMqOptions.Password) ||
|
||||||
|
string.IsNullOrWhiteSpace(rabbitMqOptions.VirtualHost)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RabbitMq 连接配置不完整。");
|
||||||
|
}
|
||||||
|
if (backgroundJobOptions.UsesRabbitMq &&
|
||||||
|
!builder.Environment.IsDevelopment() &&
|
||||||
|
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
rabbitMqOptions.Password == "guest"))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"生产环境启用 RabbitMQ 时不能使用默认 guest 凭据。");
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddSingleton(databaseOptions);
|
builder.Services.AddSingleton(databaseOptions);
|
||||||
|
builder.Services.AddSingleton(cacheOptions);
|
||||||
|
builder.Services.AddSingleton(officialDocumentOptions);
|
||||||
|
builder.Services.AddSingleton(backgroundJobOptions);
|
||||||
|
builder.Services.AddSingleton(rabbitMqOptions);
|
||||||
|
builder.Services.Configure<OfficialDocumentOptions>(
|
||||||
|
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||||
builder.Services.AddDbContextPool<AppDbContext>(options =>
|
builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||||
{
|
{
|
||||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -115,7 +206,9 @@ builder.Services.AddDbContextPool<AppDbContext>(options =>
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。");
|
"缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。");
|
||||||
}
|
}
|
||||||
options.UseMySQL(connectionString, mySqlOptions =>
|
options.UseMySQL(
|
||||||
|
MySqlConnectionStringPolicy.ForApplication(connectionString),
|
||||||
|
mySqlOptions =>
|
||||||
{
|
{
|
||||||
mySqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds);
|
mySqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds);
|
||||||
mySqlOptions.EnableRetryOnFailure(
|
mySqlOptions.EnableRetryOnFailure(
|
||||||
@@ -125,6 +218,19 @@ builder.Services.AddDbContextPool<AppDbContext>(options =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
||||||
|
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||||
|
{
|
||||||
|
builder.Services.AddStackExchangeRedisCache(options =>
|
||||||
|
options.Configuration = redisConnectionString);
|
||||||
|
}
|
||||||
|
builder.Services.AddHybridCache(options =>
|
||||||
|
{
|
||||||
|
options.MaximumKeyLength = 512;
|
||||||
|
options.MaximumPayloadBytes = cacheOptions.MaximumPayloadKilobytes * 1024;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<IAppCache, HybridAppCache>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddIdentityCore<ApplicationUser>(options =>
|
.AddIdentityCore<ApplicationUser>(options =>
|
||||||
{
|
{
|
||||||
@@ -159,20 +265,36 @@ builder.Services.AddScoped<DemoDataSeeder>();
|
|||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
builder.Services.AddScoped<TimetableDataService>();
|
builder.Services.AddScoped<TimetableDataService>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
|
builder.Services.AddScoped<PersonalCalendarService>();
|
||||||
|
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
|
||||||
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
|
||||||
builder.Services.AddScoped<SchedulePlanPublisher>();
|
builder.Services.AddScoped<SchedulePlanPublisher>();
|
||||||
builder.Services.AddScoped<SchedulePublishJobProcessor>();
|
builder.Services.AddScoped<SchedulePublishJobProcessor>();
|
||||||
builder.Services.AddSingleton<SchedulePublishJobQueue>();
|
|
||||||
builder.Services.AddHostedService<SchedulePublishJobWorker>();
|
|
||||||
builder.Services.AddHostedService<WarningCheckWorker>();
|
builder.Services.AddHostedService<WarningCheckWorker>();
|
||||||
builder.Services.AddScoped<ExamArrangementService>();
|
builder.Services.AddScoped<ExamArrangementService>();
|
||||||
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
||||||
builder.Services.AddScoped<MakeupExamArrangementService>();
|
builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||||
builder.Services.AddSingleton<MakeupExamAutoJobQueue>();
|
|
||||||
builder.Services.AddHostedService<MakeupExamAutoJobWorker>();
|
|
||||||
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||||
|
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
|
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||||
|
builder.Services.AddSingleton<BackgroundJobRunner>();
|
||||||
|
if (backgroundJobOptions.UsesRabbitMq)
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<
|
||||||
|
IBackgroundJobTransport,
|
||||||
|
RabbitMqBackgroundJobTransport>();
|
||||||
|
builder.Services.AddHostedService<RabbitMqBackgroundJobWorker>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<InMemoryBackgroundJobTransport>();
|
||||||
|
builder.Services.AddSingleton<IBackgroundJobTransport>(services =>
|
||||||
|
services.GetRequiredService<InMemoryBackgroundJobTransport>());
|
||||||
|
builder.Services.AddHostedService<InMemoryBackgroundJobWorker>();
|
||||||
|
}
|
||||||
|
builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
|
||||||
|
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
|
||||||
|
builder.Services.AddScoped<OfficialDocumentService>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
@@ -205,6 +327,16 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
AutoReplenishment = true
|
AutoReplenishment = true
|
||||||
}));
|
}));
|
||||||
|
options.AddPolicy("official-verification", context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 30,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
@@ -320,6 +452,8 @@ app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
|
|||||||
.AllowAnonymous();
|
.AllowAnonymous();
|
||||||
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
|
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||||
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
|
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||||
|
app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous();
|
||||||
|
app.MapGet("/health/messaging", CheckMessagingHealthAsync).AllowAnonymous();
|
||||||
app.MapFallback(async context =>
|
app.MapFallback(async context =>
|
||||||
{
|
{
|
||||||
if (context.Request.Path.StartsWithSegments("/api") ||
|
if (context.Request.Path.StartsWithSegments("/api") ||
|
||||||
@@ -383,4 +517,66 @@ static async Task<IResult> CheckDatabaseHealthAsync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async Task<IResult> CheckCacheHealthAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var distributedCache = services.GetService<IDistributedCache>();
|
||||||
|
if (distributedCache is null)
|
||||||
|
return Results.Ok(new { Status = "disabled", Backend = "memory" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await distributedCache.GetAsync(
|
||||||
|
"jiaowu:health:probe",
|
||||||
|
cancellationToken);
|
||||||
|
return Results.Ok(new { Status = "healthy", Backend = "redis" });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { Status = "unhealthy", Backend = "redis" },
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<IResult> CheckMessagingHealthAsync(
|
||||||
|
IBackgroundJobTransport transport,
|
||||||
|
BackgroundJobMonitoringService monitoring,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var healthy = await transport.CheckHealthAsync(cancellationToken);
|
||||||
|
var backend = transport.IsDurable ? "rabbitmq" : "memory";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var backlog = await monitoring.GetSnapshotAsync(cancellationToken);
|
||||||
|
return healthy
|
||||||
|
? Results.Ok(new
|
||||||
|
{
|
||||||
|
Status = "healthy",
|
||||||
|
Backend = backend,
|
||||||
|
Backlog = backlog
|
||||||
|
})
|
||||||
|
: Results.Json(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Status = "unhealthy",
|
||||||
|
Backend = backend,
|
||||||
|
Backlog = backlog
|
||||||
|
},
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Status = "unhealthy",
|
||||||
|
Backend = backend,
|
||||||
|
Backlog = (object?)null
|
||||||
|
},
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Jiaowu.Api.Tests")]
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"SQLite": "Data Source=data/jiaowu-dev.sqlite"
|
"SQLite": "Data Source=data/jiaowu-dev.sqlite"
|
||||||
},
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "jiaowu-development-secret-key-change-before-production",
|
"Key": "jiaowu-development-secret-key-change-before-production",
|
||||||
"ExpireMinutes": 480
|
"ExpireMinutes": 480
|
||||||
|
|||||||
@@ -5,7 +5,44 @@
|
|||||||
"CommandTimeoutSeconds": 30
|
"CommandTimeoutSeconds": 30
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"MySql": ""
|
"MySql": "",
|
||||||
|
"Redis": ""
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Enabled": true,
|
||||||
|
"KeyPrefix": "jiaowu:v1",
|
||||||
|
"ReferenceExpirationMinutes": 30,
|
||||||
|
"ReferenceLocalExpirationSeconds": 120,
|
||||||
|
"TimetableExpirationMinutes": 10,
|
||||||
|
"TimetableLocalExpirationSeconds": 30,
|
||||||
|
"AnalyticsExpirationMinutes": 3,
|
||||||
|
"AnalyticsLocalExpirationSeconds": 30,
|
||||||
|
"MaximumPayloadKilobytes": 2048
|
||||||
|
},
|
||||||
|
"BackgroundJobs": {
|
||||||
|
"Transport": "InMemory",
|
||||||
|
"PollIntervalMilliseconds": 500,
|
||||||
|
"LeaseSeconds": 120,
|
||||||
|
"PrefetchCount": 1,
|
||||||
|
"AutomaticScheduleConcurrency": 1,
|
||||||
|
"SchedulePublishConcurrency": 1,
|
||||||
|
"MakeupExamAutoConcurrency": 1,
|
||||||
|
"Exchange": "jiaowu.background-jobs",
|
||||||
|
"QueuePrefix": "jiaowu.background-jobs",
|
||||||
|
"UseQuorumQueues": true,
|
||||||
|
"ProcessingAttemptLimit": 5,
|
||||||
|
"MaintenanceIntervalSeconds": 60,
|
||||||
|
"CompletedRetentionDays": 14,
|
||||||
|
"CleanupBatchSize": 500
|
||||||
|
},
|
||||||
|
"RabbitMq": {
|
||||||
|
"HostName": "localhost",
|
||||||
|
"Port": 5672,
|
||||||
|
"UserName": "guest",
|
||||||
|
"Password": "guest",
|
||||||
|
"VirtualHost": "/",
|
||||||
|
"UseTls": false,
|
||||||
|
"TlsServerName": ""
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "Jiaowu.Api",
|
"Issuer": "Jiaowu.Api",
|
||||||
@@ -16,6 +53,13 @@
|
|||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": []
|
"Origins": []
|
||||||
},
|
},
|
||||||
|
"OfficialDocuments": {
|
||||||
|
"InstitutionName": "明序大学",
|
||||||
|
"IssuingOffice": "教务处",
|
||||||
|
"DocumentNumberPrefix": "MXU",
|
||||||
|
"PublicBaseUrl": "",
|
||||||
|
"FontPath": ""
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AppCacheTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Statistics_keys_isolate_data_scope_college_and_filters()
|
||||||
|
{
|
||||||
|
var firstCollege = Guid.NewGuid();
|
||||||
|
var secondCollege = Guid.NewGuid();
|
||||||
|
var term = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
var first = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"College",
|
||||||
|
firstCollege,
|
||||||
|
term,
|
||||||
|
"-");
|
||||||
|
var otherCollege = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"College",
|
||||||
|
secondCollege,
|
||||||
|
term,
|
||||||
|
"-");
|
||||||
|
var otherScope = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"All",
|
||||||
|
firstCollege,
|
||||||
|
term,
|
||||||
|
"-");
|
||||||
|
var otherFilter = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"College",
|
||||||
|
firstCollege,
|
||||||
|
Guid.NewGuid().ToString("N"),
|
||||||
|
"-");
|
||||||
|
|
||||||
|
Assert.NotEqual(first, otherCollege);
|
||||||
|
Assert.NotEqual(first, otherScope);
|
||||||
|
Assert.NotEqual(first, otherFilter);
|
||||||
|
Assert.Equal(
|
||||||
|
first,
|
||||||
|
AppCacheKeys.Statistics(
|
||||||
|
" GRADES ",
|
||||||
|
"COLLEGE",
|
||||||
|
firstCollege,
|
||||||
|
term,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hybrid_cache_reuses_value_and_tag_invalidation_reloads_source()
|
||||||
|
{
|
||||||
|
await using var provider = CreateProvider(enabled: true);
|
||||||
|
var cache = provider.GetRequiredService<IAppCache>();
|
||||||
|
var sourceCalls = 0;
|
||||||
|
var key = $"test:{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
Task<int> Load(CancellationToken _)
|
||||||
|
{
|
||||||
|
sourceCalls++;
|
||||||
|
return Task.FromResult(sourceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = await cache.GetOrCreateAsync(
|
||||||
|
key,
|
||||||
|
Load,
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
CancellationToken.None);
|
||||||
|
var second = await cache.GetOrCreateAsync(
|
||||||
|
key,
|
||||||
|
Load,
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, first);
|
||||||
|
Assert.Equal(1, second);
|
||||||
|
Assert.Equal(1, sourceCalls);
|
||||||
|
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.BaseData);
|
||||||
|
var afterInvalidation = await cache.GetOrCreateAsync(
|
||||||
|
key,
|
||||||
|
Load,
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[AppCacheTags.BaseData],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, afterInvalidation);
|
||||||
|
Assert.Equal(2, sourceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disabled_cache_always_uses_source()
|
||||||
|
{
|
||||||
|
await using var provider = CreateProvider(enabled: false);
|
||||||
|
var cache = provider.GetRequiredService<IAppCache>();
|
||||||
|
var sourceCalls = 0;
|
||||||
|
|
||||||
|
Task<int> Load(CancellationToken _)
|
||||||
|
{
|
||||||
|
sourceCalls++;
|
||||||
|
return Task.FromResult(sourceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = await cache.GetOrCreateAsync(
|
||||||
|
"disabled",
|
||||||
|
Load,
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[],
|
||||||
|
CancellationToken.None);
|
||||||
|
var second = await cache.GetOrCreateAsync(
|
||||||
|
"disabled",
|
||||||
|
Load,
|
||||||
|
AppCacheProfile.ReferenceData,
|
||||||
|
[],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, first);
|
||||||
|
Assert.Equal(2, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Timetable_data_round_trips_through_distributed_cache()
|
||||||
|
{
|
||||||
|
IDistributedCache distributedCache = new SharedDistributedCache(
|
||||||
|
new MemoryDistributedCache(
|
||||||
|
Options.Create(new MemoryDistributedCacheOptions())));
|
||||||
|
var keyPrefix = $"tests:{Guid.NewGuid():N}";
|
||||||
|
await using var writer = CreateProvider(true, distributedCache, keyPrefix);
|
||||||
|
var source = CreateTimetableData();
|
||||||
|
|
||||||
|
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
||||||
|
"timetable",
|
||||||
|
_ => Task.FromResult(source),
|
||||||
|
AppCacheProfile.PublishedTimetable,
|
||||||
|
[AppCacheTags.Timetables],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
await using var reader = CreateProvider(true, distributedCache, keyPrefix);
|
||||||
|
var sourceCalled = false;
|
||||||
|
var result = await reader.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
||||||
|
"timetable",
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
sourceCalled = true;
|
||||||
|
return Task.FromResult(source);
|
||||||
|
},
|
||||||
|
AppCacheProfile.PublishedTimetable,
|
||||||
|
[AppCacheTags.Timetables],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(sourceCalled);
|
||||||
|
Assert.Equal(source.Term.Id, result.Term.Id);
|
||||||
|
Assert.Equal(source.Plan!.Id, result.Plan!.Id);
|
||||||
|
Assert.Equal("缓存测试课程", result.Entries.Single().CourseName);
|
||||||
|
Assert.Equal(new TimeOnly(8, 45), result.Slots.Single().EndsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analytics_json_round_trips_through_distributed_cache()
|
||||||
|
{
|
||||||
|
IDistributedCache distributedCache = new SharedDistributedCache(
|
||||||
|
new MemoryDistributedCache(
|
||||||
|
Options.Create(new MemoryDistributedCacheOptions())));
|
||||||
|
var keyPrefix = $"tests:{Guid.NewGuid():N}";
|
||||||
|
var source = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
totals = new { totalCourses = 3 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await using (var writer = CreateProvider(
|
||||||
|
true,
|
||||||
|
distributedCache,
|
||||||
|
keyPrefix))
|
||||||
|
{
|
||||||
|
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
||||||
|
"statistics:courses",
|
||||||
|
_ => Task.FromResult(source),
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.Analytics],
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var reader = CreateProvider(
|
||||||
|
true,
|
||||||
|
distributedCache,
|
||||||
|
keyPrefix);
|
||||||
|
var sourceCalled = false;
|
||||||
|
var result = await reader.GetRequiredService<IAppCache>()
|
||||||
|
.GetOrCreateAsync(
|
||||||
|
"statistics:courses",
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
sourceCalled = true;
|
||||||
|
return Task.FromResult(source);
|
||||||
|
},
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.Analytics],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(sourceCalled);
|
||||||
|
Assert.Equal(
|
||||||
|
3,
|
||||||
|
result.GetProperty("totals")
|
||||||
|
.GetProperty("totalCourses")
|
||||||
|
.GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider CreateProvider(
|
||||||
|
bool enabled,
|
||||||
|
IDistributedCache? distributedCache = null,
|
||||||
|
string? keyPrefix = null)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddHybridCache();
|
||||||
|
if (distributedCache is not null)
|
||||||
|
services.AddSingleton(distributedCache);
|
||||||
|
services.AddSingleton(new AppCacheOptions
|
||||||
|
{
|
||||||
|
Enabled = enabled,
|
||||||
|
KeyPrefix = keyPrefix ?? $"tests:{Guid.NewGuid():N}"
|
||||||
|
});
|
||||||
|
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment());
|
||||||
|
services.AddSingleton<IAppCache, HybridAppCache>();
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimetableData CreateTimetableData()
|
||||||
|
{
|
||||||
|
var termId = Guid.NewGuid();
|
||||||
|
var planId = Guid.NewGuid();
|
||||||
|
var taskId = Guid.NewGuid();
|
||||||
|
return new TimetableData(
|
||||||
|
new TimetableTermDto(
|
||||||
|
termId,
|
||||||
|
"2026-2027 学年第一学期",
|
||||||
|
"2026-2027",
|
||||||
|
TermSeason.Autumn,
|
||||||
|
new DateOnly(2026, 9, 1),
|
||||||
|
new DateOnly(2027, 1, 20),
|
||||||
|
true),
|
||||||
|
new TimetableSubjectDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"SE202601",
|
||||||
|
"软件工程 2026 级 1 班",
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
2026,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"软件工程",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"计算机学院",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new TimetablePlanDto(
|
||||||
|
planId,
|
||||||
|
"正式课表",
|
||||||
|
"V1",
|
||||||
|
SchedulePlanStatus.Published,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow),
|
||||||
|
[
|
||||||
|
new TimetableSlotDto(
|
||||||
|
1,
|
||||||
|
"第 1 节",
|
||||||
|
new TimeOnly(8, 0),
|
||||||
|
new TimeOnly(8, 45))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
new TimetableEntryDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
taskId,
|
||||||
|
"TASK-001",
|
||||||
|
"缓存测试教学班",
|
||||||
|
"CACHE-01",
|
||||||
|
"缓存测试课程",
|
||||||
|
["测试教师"],
|
||||||
|
["软件工程 2026 级 1 班"],
|
||||||
|
"第一教学楼 101",
|
||||||
|
"第一教学楼",
|
||||||
|
"主校区",
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
16,
|
||||||
|
WeekPattern.All,
|
||||||
|
null)
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestHostEnvironment : IHostEnvironment
|
||||||
|
{
|
||||||
|
public string EnvironmentName { get; set; } = Environments.Development;
|
||||||
|
public string ApplicationName { get; set; } = nameof(AppCacheTests);
|
||||||
|
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||||
|
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SharedDistributedCache(IDistributedCache inner)
|
||||||
|
: IDistributedCache
|
||||||
|
{
|
||||||
|
public byte[]? Get(string key) => inner.Get(key);
|
||||||
|
|
||||||
|
public Task<byte[]?> GetAsync(
|
||||||
|
string key,
|
||||||
|
CancellationToken token = default) =>
|
||||||
|
inner.GetAsync(key, token);
|
||||||
|
|
||||||
|
public void Refresh(string key) => inner.Refresh(key);
|
||||||
|
|
||||||
|
public Task RefreshAsync(
|
||||||
|
string key,
|
||||||
|
CancellationToken token = default) =>
|
||||||
|
inner.RefreshAsync(key, token);
|
||||||
|
|
||||||
|
public void Remove(string key) => inner.Remove(key);
|
||||||
|
|
||||||
|
public Task RemoveAsync(
|
||||||
|
string key,
|
||||||
|
CancellationToken token = default) =>
|
||||||
|
inner.RemoveAsync(key, token);
|
||||||
|
|
||||||
|
public void Set(
|
||||||
|
string key,
|
||||||
|
byte[] value,
|
||||||
|
DistributedCacheEntryOptions options) =>
|
||||||
|
inner.Set(key, value, options);
|
||||||
|
|
||||||
|
public Task SetAsync(
|
||||||
|
string key,
|
||||||
|
byte[] value,
|
||||||
|
DistributedCacheEntryOptions options,
|
||||||
|
CancellationToken token = default) =>
|
||||||
|
inner.SetAsync(key, value, options, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ApprovalsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
fixture.CurrentTerm.IsCurrent = false;
|
||||||
|
fixture.Db.AcademicTerms.Add(new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-2",
|
||||||
|
Name = "2026—2027 学年第二学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Spring,
|
||||||
|
StartDate = new DateOnly(2027, 2, 20),
|
||||||
|
EndDate = new DateOnly(2027, 7, 1),
|
||||||
|
IsCurrent = true
|
||||||
|
});
|
||||||
|
fixture.Db.SchedulePlans.Add(new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.CurrentTerm.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "v1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
Entries =
|
||||||
|
[
|
||||||
|
new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.GetMyEnrolledCourses(
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
|
var options = Assert.IsAssignableFrom<
|
||||||
|
IEnumerable<StudentApprovalCourseOption>>(ok.Value).ToList();
|
||||||
|
var option = Assert.Single(options);
|
||||||
|
Assert.Equal(fixture.CurrentTask.Id, option.TeachingTaskId);
|
||||||
|
Assert.Equal(fixture.CurrentCourse.Id, option.CourseId);
|
||||||
|
Assert.True(option.HasPublishedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Exemption_RejectsTeachingTaskOutsideStudentsCurrentCourses()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
var unrelatedTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2026-1-OTHER-01",
|
||||||
|
Name = "其他教学班",
|
||||||
|
AcademicTermId = fixture.CurrentTerm.Id,
|
||||||
|
CourseId = fixture.SubstituteCourse.Id,
|
||||||
|
Capacity = 30,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
fixture.Db.TeachingTasks.Add(unrelatedTask);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.ApplyExemption(
|
||||||
|
new ExemptionRequest(unrelatedTask.Id, "申请原因"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
Assert.Empty(fixture.Db.CourseExemptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Substitution_AcceptsCurrentCourseAndPublishedPassedCourse()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
var historicalTerm = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2025-2",
|
||||||
|
Name = "2025—2026 学年第二学期",
|
||||||
|
AcademicYear = "2025-2026",
|
||||||
|
Season = TermSeason.Spring,
|
||||||
|
StartDate = new DateOnly(2026, 2, 20),
|
||||||
|
EndDate = new DateOnly(2026, 7, 1)
|
||||||
|
};
|
||||||
|
var historicalTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2025-2-PASS-01",
|
||||||
|
Name = "已通过课程教学班",
|
||||||
|
AcademicTermId = historicalTerm.Id,
|
||||||
|
CourseId = fixture.SubstituteCourse.Id,
|
||||||
|
Capacity = 30,
|
||||||
|
Status = TeachingTaskStatus.Closed
|
||||||
|
};
|
||||||
|
var sheet = new GradeSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = historicalTask.Id,
|
||||||
|
Status = GradeSheetStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow.AddDays(-30),
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new GradeRecord
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TotalScore = 86,
|
||||||
|
GradePoint = 3.6m
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(historicalTerm, historicalTask, sheet);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.ApplySubstitution(
|
||||||
|
new SubstitutionRequest(
|
||||||
|
fixture.CurrentCourse.Id,
|
||||||
|
fixture.SubstituteCourse.Id,
|
||||||
|
"课程内容相近"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
var substitution = Assert.Single(fixture.Db.CourseSubstitutions);
|
||||||
|
Assert.Equal(fixture.CurrentCourse.Id, substitution.OriginalCourseId);
|
||||||
|
Assert.Equal(
|
||||||
|
fixture.SubstituteCourse.Id,
|
||||||
|
substitution.SubstituteCourseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ApprovalFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private ApprovalFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
Student student,
|
||||||
|
AcademicTerm currentTerm,
|
||||||
|
Course currentCourse,
|
||||||
|
Course substituteCourse,
|
||||||
|
TeachingTask currentTask,
|
||||||
|
ApprovalsController controller)
|
||||||
|
{
|
||||||
|
Connection = connection;
|
||||||
|
Db = db;
|
||||||
|
Student = student;
|
||||||
|
CurrentTerm = currentTerm;
|
||||||
|
CurrentCourse = currentCourse;
|
||||||
|
SubstituteCourse = substituteCourse;
|
||||||
|
CurrentTask = currentTask;
|
||||||
|
Controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Connection { get; }
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public Student Student { get; }
|
||||||
|
public AcademicTerm CurrentTerm { get; }
|
||||||
|
public Course CurrentCourse { get; }
|
||||||
|
public Course SubstituteCourse { get; }
|
||||||
|
public TeachingTask CurrentTask { get; }
|
||||||
|
public ApprovalsController Controller { get; }
|
||||||
|
|
||||||
|
public static async Task<ApprovalFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "080901",
|
||||||
|
Name = "计算机科学与技术",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = "工学学士"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "CS2026-01",
|
||||||
|
Name = "计科 2026-1 班",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = "202601001",
|
||||||
|
NormalizedUserName = "202601001",
|
||||||
|
DisplayName = "测试学生"
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "202601001",
|
||||||
|
Name = "测试学生",
|
||||||
|
UserId = user.Id,
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
};
|
||||||
|
var currentTerm = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-1",
|
||||||
|
Name = "2026—2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 20),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var currentCourse = CreateCourse(college.Id, "CS101", "程序设计基础");
|
||||||
|
var substituteCourse = CreateCourse(college.Id, "CS102", "程序设计进阶");
|
||||||
|
var currentTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2026-1-CS101-01",
|
||||||
|
Name = "程序设计基础教学班",
|
||||||
|
AcademicTermId = currentTerm.Id,
|
||||||
|
CourseId = currentCourse.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = administrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
college,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
user,
|
||||||
|
student,
|
||||||
|
currentTerm,
|
||||||
|
currentCourse,
|
||||||
|
substituteCourse,
|
||||||
|
currentTask);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ApprovalsController(
|
||||||
|
db,
|
||||||
|
new StudentDataScope(user.Id));
|
||||||
|
return new ApprovalFixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
student,
|
||||||
|
currentTerm,
|
||||||
|
currentCourse,
|
||||||
|
substituteCourse,
|
||||||
|
currentTask,
|
||||||
|
controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await Connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Course CreateCourse(
|
||||||
|
Guid collegeId,
|
||||||
|
string code,
|
||||||
|
string name) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
CollegeId = collegeId,
|
||||||
|
Credits = 3,
|
||||||
|
TotalHours = 48,
|
||||||
|
LectureHours = 32,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
userId,
|
||||||
|
"测试学生",
|
||||||
|
null,
|
||||||
|
DataScope.Self,
|
||||||
|
new HashSet<string>([SystemRoles.Student]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
|||||||
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.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -103,7 +104,11 @@ public sealed class AuthControllerTests
|
|||||||
var userManager = scope.ServiceProvider
|
var userManager = scope.ServiceProvider
|
||||||
.GetRequiredService<UserManager<ApplicationUser>>();
|
.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
||||||
var controller = new AuthController(db, userManager, new StubTokenService());
|
var controller = new AuthController(
|
||||||
|
db,
|
||||||
|
userManager,
|
||||||
|
new StubTokenService(),
|
||||||
|
NoOpAppCache.Instance);
|
||||||
var request = new StudentActivationRequest(
|
var request = new StudentActivationRequest(
|
||||||
student.Name,
|
student.Name,
|
||||||
student.StudentNumber,
|
student.StudentNumber,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
.Options;
|
.Options;
|
||||||
await using var db = new AppDbContext(options);
|
await using var db = new AppDbContext(options);
|
||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"DROP TABLE \"BackgroundJobOutboxMessages\"");
|
||||||
|
|
||||||
var migrator = new DevelopmentSqliteMigrator(
|
var migrator = new DevelopmentSqliteMigrator(
|
||||||
db,
|
db,
|
||||||
@@ -27,6 +29,15 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
|
|
||||||
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
||||||
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
|
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
|
||||||
|
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
|
||||||
|
Assert.True(await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_index_list('BackgroundJobOutboxMessages')
|
||||||
|
WHERE name = 'IX_BackgroundJobOutboxMessages_State_CompletedAt'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0));
|
||||||
Assert.True(await db.Database
|
Assert.True(await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class BackgroundJobOutboxTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task In_memory_transport_keeps_job_types_on_independent_channels()
|
||||||
|
{
|
||||||
|
var transport = new InMemoryBackgroundJobTransport();
|
||||||
|
var automatic = new BackgroundJobEnvelope(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
BackgroundJobKind.AutomaticSchedule,
|
||||||
|
Guid.NewGuid());
|
||||||
|
var publish = new BackgroundJobEnvelope(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
BackgroundJobKind.SchedulePublish,
|
||||||
|
Guid.NewGuid());
|
||||||
|
await transport.PublishAsync(automatic, CancellationToken.None);
|
||||||
|
await transport.PublishAsync(publish, CancellationToken.None);
|
||||||
|
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||||
|
await using var automaticReader = transport
|
||||||
|
.ReadAllAsync(BackgroundJobKind.AutomaticSchedule, timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token);
|
||||||
|
await using var publishReader = transport
|
||||||
|
.ReadAllAsync(BackgroundJobKind.SchedulePublish, timeout.Token)
|
||||||
|
.GetAsyncEnumerator(timeout.Token);
|
||||||
|
|
||||||
|
Assert.True(await automaticReader.MoveNextAsync());
|
||||||
|
Assert.Equal(automatic, automaticReader.Current);
|
||||||
|
Assert.True(await publishReader.MoveNextAsync());
|
||||||
|
Assert.Equal(publish, publishReader.Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publisher_recovers_pending_message_and_cleans_old_completion()
|
||||||
|
{
|
||||||
|
var databasePath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"jiaowu-outbox-{Guid.NewGuid():N}.sqlite");
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
options.UseSqlite($"Data Source={databasePath};Pooling=False"));
|
||||||
|
var options = new BackgroundJobOptions
|
||||||
|
{
|
||||||
|
PollIntervalMilliseconds = 100,
|
||||||
|
CompletedRetentionDays = 1,
|
||||||
|
CleanupBatchSize = 10
|
||||||
|
};
|
||||||
|
services.AddSingleton(options);
|
||||||
|
services.AddSingleton<InMemoryBackgroundJobTransport>();
|
||||||
|
services.AddSingleton<IBackgroundJobTransport>(provider =>
|
||||||
|
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
|
||||||
|
services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
|
services.AddSingleton<BackgroundJobOutboxPublisher>();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid jobId;
|
||||||
|
Guid oldCompletedOutboxId;
|
||||||
|
await using (var scope = provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "RECOVERY",
|
||||||
|
Name = "补投测试学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "补投测试排课",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
var job = new AutomaticScheduleJob
|
||||||
|
{
|
||||||
|
SchedulePlan = plan,
|
||||||
|
ActiveSchedulePlanId = plan.Id
|
||||||
|
};
|
||||||
|
var oldCompletedOutbox = BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.SchedulePublish,
|
||||||
|
Guid.NewGuid());
|
||||||
|
oldCompletedOutbox.State = BackgroundJobOutboxState.Completed;
|
||||||
|
oldCompletedOutbox.CompletedAt = DateTime.UtcNow.AddDays(-2);
|
||||||
|
oldCompletedOutboxId = oldCompletedOutbox.Id;
|
||||||
|
jobId = job.Id;
|
||||||
|
db.AddRange(term, plan, job, oldCompletedOutbox);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var publisher = provider.GetRequiredService<BackgroundJobOutboxPublisher>();
|
||||||
|
await publisher.StartAsync(CancellationToken.None);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var published = false;
|
||||||
|
for (var attempt = 0; attempt < 50 && !published; attempt++)
|
||||||
|
{
|
||||||
|
await Task.Delay(50);
|
||||||
|
await using var scope = provider.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
published = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||||
|
.AnyAsync(x =>
|
||||||
|
x.JobId == jobId &&
|
||||||
|
x.JobKind == BackgroundJobKind.AutomaticSchedule &&
|
||||||
|
x.State == BackgroundJobOutboxState.Published) &&
|
||||||
|
!await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.Id == oldCompletedOutboxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(published);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await publisher.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await provider.DisposeAsync();
|
||||||
|
File.Delete(databasePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Monitoring_snapshot_reports_backlog_and_expired_leases()
|
||||||
|
{
|
||||||
|
var databasePath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"jiaowu-monitoring-{Guid.NewGuid():N}.sqlite");
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite($"Data Source={databasePath};Pooling=False")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var pending = BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.AutomaticSchedule,
|
||||||
|
Guid.NewGuid());
|
||||||
|
pending.CreatedAt = DateTime.UtcNow.AddMinutes(-2);
|
||||||
|
var processing = BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.MakeupExamAuto,
|
||||||
|
Guid.NewGuid());
|
||||||
|
processing.State = BackgroundJobOutboxState.Processing;
|
||||||
|
processing.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(-1);
|
||||||
|
db.AddRange(pending, processing);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var snapshot = await new BackgroundJobMonitoringService(db)
|
||||||
|
.GetSnapshotAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, snapshot.Pending);
|
||||||
|
Assert.Equal(1, snapshot.Processing);
|
||||||
|
Assert.Equal(1, snapshot.ExpiredLeases);
|
||||||
|
Assert.NotNull(snapshot.OldestUnfinishedAt);
|
||||||
|
Assert.True(snapshot.OldestUnfinishedAgeSeconds >= 100);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(databasePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Runner_stops_after_retry_limit_and_ignores_duplicate_delivery()
|
||||||
|
{
|
||||||
|
var databasePath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"jiaowu-runner-{Guid.NewGuid():N}.sqlite");
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
options.UseSqlite($"Data Source={databasePath};Pooling=False"));
|
||||||
|
services.AddScoped<Jiaowu.Api.Infrastructure.Scheduling.AutomaticScheduleGenerator>();
|
||||||
|
services.AddScoped<Jiaowu.Api.Infrastructure.Scheduling.AutomaticScheduleJobProcessor>();
|
||||||
|
services.AddSingleton(new BackgroundJobOptions());
|
||||||
|
services.AddSingleton<InMemoryBackgroundJobTransport>();
|
||||||
|
services.AddSingleton<IBackgroundJobTransport>(provider =>
|
||||||
|
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
|
||||||
|
services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
|
services.AddSingleton<BackgroundJobRunner>();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BackgroundJobEnvelope envelope;
|
||||||
|
await using (var scope = provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "OUTBOX",
|
||||||
|
Name = "消息测试学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "消息测试排课",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
var job = new AutomaticScheduleJob
|
||||||
|
{
|
||||||
|
SchedulePlan = plan,
|
||||||
|
ActiveSchedulePlanId = plan.Id,
|
||||||
|
Status = AutomaticScheduleJobStatus.Queued
|
||||||
|
};
|
||||||
|
var outbox = BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.AutomaticSchedule,
|
||||||
|
job.Id);
|
||||||
|
outbox.State = BackgroundJobOutboxState.Published;
|
||||||
|
outbox.ProcessingAttempts = 5;
|
||||||
|
db.AddRange(term, plan, job, outbox);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
envelope = new BackgroundJobEnvelope(
|
||||||
|
outbox.Id,
|
||||||
|
outbox.JobKind,
|
||||||
|
outbox.JobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var runner = provider.GetRequiredService<BackgroundJobRunner>();
|
||||||
|
var first = await runner.RunAsync(envelope, CancellationToken.None);
|
||||||
|
var duplicate = await runner.RunAsync(envelope, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(BackgroundJobRunOutcome.Completed, first.Outcome);
|
||||||
|
Assert.Equal(BackgroundJobRunOutcome.Completed, duplicate.Outcome);
|
||||||
|
await using var assertScope = provider.CreateAsyncScope();
|
||||||
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var persistedOutbox =
|
||||||
|
await assertDb.BackgroundJobOutboxMessages.SingleAsync();
|
||||||
|
Assert.Equal(BackgroundJobOutboxState.Completed, persistedOutbox.State);
|
||||||
|
Assert.NotNull(persistedOutbox.CompletedAt);
|
||||||
|
Assert.Null(persistedOutbox.ProcessingToken);
|
||||||
|
Assert.Null(persistedOutbox.LeaseExpiresAt);
|
||||||
|
Assert.Equal(6, persistedOutbox.ProcessingAttempts);
|
||||||
|
var persistedJob = await assertDb.AutomaticScheduleJobs.SingleAsync();
|
||||||
|
Assert.Equal(AutomaticScheduleJobStatus.Failed, persistedJob.Status);
|
||||||
|
Assert.Null(persistedJob.ActiveSchedulePlanId);
|
||||||
|
Assert.Contains("超过 5 次", persistedJob.ErrorMessage);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await provider.DisposeAsync();
|
||||||
|
File.Delete(databasePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
@@ -21,7 +22,7 @@ public sealed class BaseDataControllerTests : IAsyncDisposable
|
|||||||
.Options;
|
.Options;
|
||||||
db = new AppDbContext(options);
|
db = new AppDbContext(options);
|
||||||
db.Database.EnsureCreated();
|
db.Database.EnsureCreated();
|
||||||
controller = new BaseDataController(db);
|
controller = new BaseDataController(db, NoOpAppCache.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
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.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ClassroomReservationsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_UsesApplicantsCollegeAndRejectsPublishedClassConflict()
|
||||||
|
{
|
||||||
|
await using var fixture = await ReservationFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ApplicantScope);
|
||||||
|
var conflictedRequest = fixture.Request(
|
||||||
|
fixture.ScheduledClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
1,
|
||||||
|
2);
|
||||||
|
|
||||||
|
var conflicted = await controller.Create(
|
||||||
|
conflictedRequest,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(conflicted);
|
||||||
|
Assert.Empty(fixture.Db.ClassroomReservations);
|
||||||
|
|
||||||
|
var created = await controller.Create(
|
||||||
|
fixture.Request(
|
||||||
|
fixture.AvailableClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
1,
|
||||||
|
2),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(created);
|
||||||
|
var reservation = Assert.Single(fixture.Db.ClassroomReservations);
|
||||||
|
Assert.Equal(fixture.Applicant.Id, reservation.ApplicantUserId);
|
||||||
|
Assert.Equal(fixture.CollegeA.Id, reservation.ApplicantCollegeId);
|
||||||
|
Assert.Equal(ClassroomReservationStatus.Submitted, reservation.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Review_OnlyAllowsApplicantCollegeAndApprovesAvailableRoom()
|
||||||
|
{
|
||||||
|
await using var fixture = await ReservationFixture.CreateAsync();
|
||||||
|
var reservation = fixture.AddSubmittedReservation(
|
||||||
|
fixture.AvailableClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
3,
|
||||||
|
2);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var wrongCollege = await fixture.Controller(fixture.CollegeBReviewerScope)
|
||||||
|
.Approve(
|
||||||
|
reservation.Id,
|
||||||
|
new ReviewClassroomReservationRequest(""),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ForbidResult>(wrongCollege);
|
||||||
|
Assert.Equal(
|
||||||
|
ClassroomReservationStatus.Submitted,
|
||||||
|
reservation.Status);
|
||||||
|
|
||||||
|
var approved = await fixture.Controller(fixture.CollegeAReviewerScope)
|
||||||
|
.Approve(
|
||||||
|
reservation.Id,
|
||||||
|
new ReviewClassroomReservationRequest("同意"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(approved);
|
||||||
|
var stored = await fixture.Db.ClassroomReservations
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleAsync(item => item.Id == reservation.Id);
|
||||||
|
Assert.Equal(ClassroomReservationStatus.Approved, stored.Status);
|
||||||
|
Assert.Equal(fixture.CollegeAReviewer.Id, stored.ReviewedByUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_RechecksApprovedReservationConflict()
|
||||||
|
{
|
||||||
|
await using var fixture = await ReservationFixture.CreateAsync();
|
||||||
|
fixture.Db.ClassroomReservations.Add(new ClassroomReservation
|
||||||
|
{
|
||||||
|
ApplicantUserId = fixture.Applicant.Id,
|
||||||
|
ApplicantName = fixture.Applicant.DisplayName,
|
||||||
|
ApplicantCollegeId = fixture.CollegeA.Id,
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
ClassroomId = fixture.AvailableClassroom.Id,
|
||||||
|
ReservationDate = fixture.ReservationDate,
|
||||||
|
StartPeriod = 5,
|
||||||
|
PeriodCount = 2,
|
||||||
|
AttendeeCount = 20,
|
||||||
|
Purpose = "已批准活动",
|
||||||
|
ContactPhone = "13800000000",
|
||||||
|
Status = ClassroomReservationStatus.Approved
|
||||||
|
});
|
||||||
|
var competing = fixture.AddSubmittedReservation(
|
||||||
|
fixture.AvailableClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
6,
|
||||||
|
2);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller(fixture.CollegeAReviewerScope)
|
||||||
|
.Approve(
|
||||||
|
competing.Id,
|
||||||
|
new ReviewClassroomReservationRequest(null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
var stored = await fixture.Db.ClassroomReservations
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleAsync(item => item.Id == competing.Id);
|
||||||
|
Assert.Equal(ClassroomReservationStatus.Submitted, stored.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReservationFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private ReservationFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
College collegeA,
|
||||||
|
ApplicationUser applicant,
|
||||||
|
ApplicationUser collegeAReviewer,
|
||||||
|
AcademicTerm term,
|
||||||
|
Classroom scheduledClassroom,
|
||||||
|
Classroom availableClassroom,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
ICurrentUserDataScope applicantScope,
|
||||||
|
ICurrentUserDataScope collegeAReviewerScope,
|
||||||
|
ICurrentUserDataScope collegeBReviewerScope)
|
||||||
|
{
|
||||||
|
Connection = connection;
|
||||||
|
Db = db;
|
||||||
|
CollegeA = collegeA;
|
||||||
|
Applicant = applicant;
|
||||||
|
CollegeAReviewer = collegeAReviewer;
|
||||||
|
Term = term;
|
||||||
|
ScheduledClassroom = scheduledClassroom;
|
||||||
|
AvailableClassroom = availableClassroom;
|
||||||
|
ReservationDate = reservationDate;
|
||||||
|
ApplicantScope = applicantScope;
|
||||||
|
CollegeAReviewerScope = collegeAReviewerScope;
|
||||||
|
CollegeBReviewerScope = collegeBReviewerScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Connection { get; }
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public College CollegeA { get; }
|
||||||
|
public ApplicationUser Applicant { get; }
|
||||||
|
public ApplicationUser CollegeAReviewer { get; }
|
||||||
|
public AcademicTerm Term { get; }
|
||||||
|
public Classroom ScheduledClassroom { get; }
|
||||||
|
public Classroom AvailableClassroom { get; }
|
||||||
|
public DateOnly ReservationDate { get; }
|
||||||
|
public ICurrentUserDataScope ApplicantScope { get; }
|
||||||
|
public ICurrentUserDataScope CollegeAReviewerScope { get; }
|
||||||
|
public ICurrentUserDataScope CollegeBReviewerScope { get; }
|
||||||
|
|
||||||
|
public static async Task<ReservationFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "A",
|
||||||
|
Name = "明德楼",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var scheduledClassroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "A101",
|
||||||
|
Name = "A101",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 60
|
||||||
|
};
|
||||||
|
var availableClassroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "A102",
|
||||||
|
Name = "A102",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 80
|
||||||
|
};
|
||||||
|
var collegeA = new College
|
||||||
|
{
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机学院"
|
||||||
|
};
|
||||||
|
var collegeB = new College
|
||||||
|
{
|
||||||
|
Code = "EE",
|
||||||
|
Name = "电子工程学院"
|
||||||
|
};
|
||||||
|
var applicant = User("student", "测试申请人", collegeA.Id);
|
||||||
|
var collegeAReviewer = User(
|
||||||
|
"college-a",
|
||||||
|
"计算机学院审核人",
|
||||||
|
collegeA.Id);
|
||||||
|
var collegeBReviewer = User(
|
||||||
|
"college-b",
|
||||||
|
"电子工程学院审核人",
|
||||||
|
collegeB.Id);
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2099-1",
|
||||||
|
Name = "2099—2100 学年第一学期",
|
||||||
|
AcademicYear = "2099-2100",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2099, 9, 1),
|
||||||
|
EndDate = new DateOnly(2100, 1, 31),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
CollegeId = collegeA.Id,
|
||||||
|
Credits = 3,
|
||||||
|
TotalHours = 48,
|
||||||
|
LectureHours = 32,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
var teachingTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2099-1-CS101-01",
|
||||||
|
Name = "程序设计基础教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var reservationDate = term.StartDate;
|
||||||
|
var (_, dayOfWeek) =
|
||||||
|
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
|
||||||
|
term,
|
||||||
|
reservationDate);
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "v1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
Entries =
|
||||||
|
[
|
||||||
|
new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = teachingTask.Id,
|
||||||
|
ClassroomId = scheduledClassroom.Id,
|
||||||
|
DayOfWeek = dayOfWeek,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 18,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
scheduledClassroom,
|
||||||
|
availableClassroom,
|
||||||
|
collegeA,
|
||||||
|
collegeB,
|
||||||
|
applicant,
|
||||||
|
collegeAReviewer,
|
||||||
|
collegeBReviewer,
|
||||||
|
term,
|
||||||
|
course,
|
||||||
|
teachingTask,
|
||||||
|
plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ReservationFixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
collegeA,
|
||||||
|
applicant,
|
||||||
|
collegeAReviewer,
|
||||||
|
term,
|
||||||
|
scheduledClassroom,
|
||||||
|
availableClassroom,
|
||||||
|
reservationDate,
|
||||||
|
Scope(applicant, SystemRoles.Student),
|
||||||
|
Scope(collegeAReviewer, SystemRoles.CollegeAdmin),
|
||||||
|
Scope(collegeBReviewer, SystemRoles.CollegeAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassroomReservationsController Controller(
|
||||||
|
ICurrentUserDataScope currentScope) =>
|
||||||
|
new(
|
||||||
|
Db,
|
||||||
|
currentScope,
|
||||||
|
new ClassroomReservationAvailabilityService(Db));
|
||||||
|
|
||||||
|
public CreateClassroomReservationRequest Request(
|
||||||
|
Guid classroomId,
|
||||||
|
DateOnly date,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount) =>
|
||||||
|
new(
|
||||||
|
Term.Id,
|
||||||
|
classroomId,
|
||||||
|
date,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
30,
|
||||||
|
"学院学术活动",
|
||||||
|
"13800000000",
|
||||||
|
null);
|
||||||
|
|
||||||
|
public ClassroomReservation AddSubmittedReservation(
|
||||||
|
Guid classroomId,
|
||||||
|
DateOnly date,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount)
|
||||||
|
{
|
||||||
|
var reservation = new ClassroomReservation
|
||||||
|
{
|
||||||
|
ApplicantUserId = Applicant.Id,
|
||||||
|
ApplicantName = Applicant.DisplayName,
|
||||||
|
ApplicantCollegeId = CollegeA.Id,
|
||||||
|
AcademicTermId = Term.Id,
|
||||||
|
ClassroomId = classroomId,
|
||||||
|
ReservationDate = date,
|
||||||
|
StartPeriod = startPeriod,
|
||||||
|
PeriodCount = periodCount,
|
||||||
|
AttendeeCount = 20,
|
||||||
|
Purpose = "学院交流活动",
|
||||||
|
ContactPhone = "13800000000"
|
||||||
|
};
|
||||||
|
Db.ClassroomReservations.Add(reservation);
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await Connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationUser User(
|
||||||
|
string userName,
|
||||||
|
string displayName,
|
||||||
|
Guid collegeId) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = userName,
|
||||||
|
NormalizedUserName = userName.ToUpperInvariant(),
|
||||||
|
DisplayName = displayName,
|
||||||
|
CollegeId = collegeId,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ICurrentUserDataScope Scope(
|
||||||
|
ApplicationUser user,
|
||||||
|
string role) =>
|
||||||
|
new FixedScope(new CurrentUserScope(
|
||||||
|
user.Id,
|
||||||
|
user.DisplayName,
|
||||||
|
user.CollegeId,
|
||||||
|
role == SystemRoles.CollegeAdmin
|
||||||
|
? DataScope.College
|
||||||
|
: DataScope.Self,
|
||||||
|
new HashSet<string>([role])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedScope(CurrentUserScope current)
|
||||||
|
: ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class CourseAdjustmentsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Reschedule_MovesOnlyTheSelectedOccurrence()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var teacherController = new CourseAdjustmentsController(
|
||||||
|
fixture.Db,
|
||||||
|
new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher));
|
||||||
|
var targetDate = new DateOnly(2026, 9, 24);
|
||||||
|
|
||||||
|
var created = await teacherController.Create(
|
||||||
|
new CourseAdjustmentRequest(
|
||||||
|
fixture.Task.Id,
|
||||||
|
CourseAdjustmentType.Reschedule,
|
||||||
|
"参加学院教研活动",
|
||||||
|
true,
|
||||||
|
targetDate,
|
||||||
|
null,
|
||||||
|
3,
|
||||||
|
null,
|
||||||
|
fixture.Classroom.Id,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
fixture.Entry.Id,
|
||||||
|
3),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<CreatedResult>(created);
|
||||||
|
|
||||||
|
var adjustment = await fixture.Db.CourseAdjustments.SingleAsync();
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 16), adjustment.SourceDate);
|
||||||
|
Assert.Equal(1, adjustment.SourceStartPeriod);
|
||||||
|
Assert.Equal(2, adjustment.SourcePeriodCount);
|
||||||
|
|
||||||
|
var adminController = new CourseAdjustmentsController(
|
||||||
|
fixture.Db,
|
||||||
|
new TestScope(Guid.NewGuid(), DataScope.All, SystemRoles.SuperAdmin));
|
||||||
|
var approved = await adminController.Approve(
|
||||||
|
adjustment.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<NoContentResult>(approved);
|
||||||
|
|
||||||
|
var entries = await fixture.Db.ScheduleEntries
|
||||||
|
.OrderBy(x => x.StartWeek)
|
||||||
|
.ThenBy(x => x.DayOfWeek)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Contains(entries, x =>
|
||||||
|
x.DayOfWeek == 3 && x.StartWeek == 1 && x.EndWeek == 2);
|
||||||
|
Assert.Contains(entries, x =>
|
||||||
|
x.DayOfWeek == 3 && x.StartWeek == 4 && x.EndWeek == 5);
|
||||||
|
Assert.Contains(entries, x =>
|
||||||
|
x.DayOfWeek == 4 && x.StartWeek == 4 && x.EndWeek == 4 &&
|
||||||
|
x.StartPeriod == 3 && x.PeriodCount == 2);
|
||||||
|
Assert.DoesNotContain(entries, x =>
|
||||||
|
x.DayOfWeek == 3 && x.StartWeek <= 3 && x.EndWeek >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_RemovesOnlyTheSelectedMiddleWeek()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var teacherController = new CourseAdjustmentsController(
|
||||||
|
fixture.Db,
|
||||||
|
new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher));
|
||||||
|
await teacherController.Create(
|
||||||
|
new CourseAdjustmentRequest(
|
||||||
|
fixture.Task.Id,
|
||||||
|
CourseAdjustmentType.Cancel,
|
||||||
|
"参加培训",
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
fixture.Entry.Id,
|
||||||
|
3),
|
||||||
|
CancellationToken.None);
|
||||||
|
var adjustment = await fixture.Db.CourseAdjustments.SingleAsync();
|
||||||
|
|
||||||
|
var adminController = new CourseAdjustmentsController(
|
||||||
|
fixture.Db,
|
||||||
|
new TestScope(Guid.NewGuid(), DataScope.All, SystemRoles.SuperAdmin));
|
||||||
|
var approved = await adminController.Approve(
|
||||||
|
adjustment.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<NoContentResult>(approved);
|
||||||
|
|
||||||
|
var entries = await fixture.Db.ScheduleEntries
|
||||||
|
.OrderBy(x => x.StartWeek)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
Assert.Contains(entries, x => x.StartWeek == 1 && x.EndWeek == 2);
|
||||||
|
Assert.Contains(entries, x => x.StartWeek == 4 && x.EndWeek == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Fixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection connection;
|
||||||
|
|
||||||
|
private Fixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
Guid teacherUserId,
|
||||||
|
TeachingTask task,
|
||||||
|
ScheduleEntry entry,
|
||||||
|
Classroom classroom)
|
||||||
|
{
|
||||||
|
this.connection = connection;
|
||||||
|
Db = db;
|
||||||
|
TeacherUserId = teacherUserId;
|
||||||
|
Task = task;
|
||||||
|
Entry = entry;
|
||||||
|
Classroom = classroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public Guid TeacherUserId { get; }
|
||||||
|
public TeachingTask Task { get; }
|
||||||
|
public ScheduleEntry Entry { get; }
|
||||||
|
public Classroom Classroom { get; }
|
||||||
|
|
||||||
|
public static async Task<Fixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var teacherUserId = Guid.NewGuid();
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = teacherUserId,
|
||||||
|
UserName = "T001",
|
||||||
|
NormalizedUserName = "T001",
|
||||||
|
DisplayName = "张老师"
|
||||||
|
};
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var college = new College
|
||||||
|
{
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机学院",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "A",
|
||||||
|
Name = "教学楼 A",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "A101",
|
||||||
|
Name = "A101",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 60
|
||||||
|
};
|
||||||
|
var teacher = new Teacher
|
||||||
|
{
|
||||||
|
TeacherNumber = "T001",
|
||||||
|
Name = "张老师",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
UserId = teacherUserId
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 4,
|
||||||
|
TotalHours = 64,
|
||||||
|
LectureHours = 48,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-1",
|
||||||
|
Name = "2026—2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 20)
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2026-1-CS101-01",
|
||||||
|
Name = "程序设计基础教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Teachers =
|
||||||
|
[
|
||||||
|
new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "1.0",
|
||||||
|
Status = SchedulePlanStatus.Published
|
||||||
|
};
|
||||||
|
var entry = new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = plan.Id,
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
ClassroomId = classroom.Id,
|
||||||
|
DayOfWeek = 3,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 5,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
user,
|
||||||
|
campus,
|
||||||
|
college,
|
||||||
|
building,
|
||||||
|
classroom,
|
||||||
|
teacher,
|
||||||
|
course,
|
||||||
|
term,
|
||||||
|
task,
|
||||||
|
plan,
|
||||||
|
entry);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new Fixture(connection, db, teacherUserId, task, entry, classroom);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestScope(
|
||||||
|
Guid userId,
|
||||||
|
DataScope scope,
|
||||||
|
string role) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
userId,
|
||||||
|
"测试用户",
|
||||||
|
null,
|
||||||
|
scope,
|
||||||
|
new HashSet<string>([role]));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user