Compare commits
78
Commits
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_ROOT_PASSWORD=
|
MYSQL_ROOT_PASSWORD=
|
||||||
|
|
||||||
|
CLICKHOUSE_USER=jiaowu_analytics
|
||||||
|
CLICKHOUSE_PASSWORD=
|
||||||
|
|
||||||
RABBITMQ_USER=jiaowu
|
RABBITMQ_USER=jiaowu
|
||||||
RABBITMQ_PASSWORD=
|
RABBITMQ_PASSWORD=
|
||||||
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||||
|
|||||||
+23
-3
@@ -2,6 +2,13 @@
|
|||||||
ASPNETCORE_ENVIRONMENT=Production
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
ASPNETCORE_URLS=http://0.0.0.0:8080
|
ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||||
|
|
||||||
|
# 反向代理必须在转发请求时设置 X-Forwarded-For 和 X-Forwarded-Proto。
|
||||||
|
# 仅填写实际直接连接 API 的代理 IP;多个代理依次使用 __0、__1……。
|
||||||
|
# 使用 Docker 时通常是宿主机/代理容器在 Docker 网络中的 IP,而非访客 IP。
|
||||||
|
# 默认仅信任 127.0.0.1 和 ::1。
|
||||||
|
# ReverseProxy__TrustedProxies__0=127.0.0.1
|
||||||
|
# ReverseProxy__TrustedProxies__1=::1
|
||||||
|
|
||||||
Database__Provider=MySql
|
Database__Provider=MySql
|
||||||
Database__ApplyMigrationsOnStartup=false
|
Database__ApplyMigrationsOnStartup=false
|
||||||
Database__CommandTimeoutSeconds=30
|
Database__CommandTimeoutSeconds=30
|
||||||
@@ -18,6 +25,7 @@ BackgroundJobs__AutomaticScheduleConcurrency=1
|
|||||||
BackgroundJobs__SchedulePublishConcurrency=1
|
BackgroundJobs__SchedulePublishConcurrency=1
|
||||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||||
BackgroundJobs__ExamArrangementConcurrency=1
|
BackgroundJobs__ExamArrangementConcurrency=1
|
||||||
|
BackgroundJobs__CourseGradeStatisticsRefreshConcurrency=1
|
||||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||||
# RabbitMq__Port=5671
|
# RabbitMq__Port=5671
|
||||||
# RabbitMq__UserName=jiaowu
|
# RabbitMq__UserName=jiaowu
|
||||||
@@ -36,15 +44,15 @@ Cache__AnalyticsExpirationMinutes=3
|
|||||||
Cache__AnalyticsLocalExpirationSeconds=30
|
Cache__AnalyticsLocalExpirationSeconds=30
|
||||||
Cache__MaximumPayloadKilobytes=2048
|
Cache__MaximumPayloadKilobytes=2048
|
||||||
|
|
||||||
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
|
# 应用日志建立接口与慢 SQL 基线;日志系统可按 DurationMs、RequestId、QueryName 聚合。
|
||||||
Observability__Enabled=true
|
Observability__Enabled=true
|
||||||
Observability__ServiceName=jiaowu-api
|
Observability__ServiceName=jiaowu-api
|
||||||
|
Observability__LogAllApiRequests=true
|
||||||
|
Observability__SlowRequestThresholdMilliseconds=1000
|
||||||
Observability__SlowQueryThresholdMilliseconds=500
|
Observability__SlowQueryThresholdMilliseconds=500
|
||||||
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
||||||
Observability__IncludeSqlText=false
|
Observability__IncludeSqlText=false
|
||||||
Observability__MaximumSqlTextLength=2000
|
Observability__MaximumSqlTextLength=2000
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
|
||||||
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
|
||||||
|
|
||||||
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||||
PerformanceReporting__Enabled=false
|
PerformanceReporting__Enabled=false
|
||||||
@@ -54,6 +62,18 @@ PerformanceReporting__Enabled=false
|
|||||||
PerformanceReporting__CacheSeconds=30
|
PerformanceReporting__CacheSeconds=30
|
||||||
PerformanceReporting__TimeoutSeconds=10
|
PerformanceReporting__TimeoutSeconds=10
|
||||||
|
|
||||||
|
# ClickHouse 仅作为异步分析读模型,不参与教务事务写入。启用前请为应用创建
|
||||||
|
# 仅能操作该分析库的独立账号,并通过 TLS 或受信任的内网访问。
|
||||||
|
ClickHouseAnalytics__Enabled=false
|
||||||
|
# ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
|
||||||
|
# ClickHouseAnalytics__Database=jiaowu_analytics
|
||||||
|
# ClickHouseAnalytics__UserName=jiaowu_analytics
|
||||||
|
# ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||||
|
ClickHouseAnalytics__CreateSchemaOnStartup=true
|
||||||
|
ClickHouseAnalytics__SyncIntervalSeconds=60
|
||||||
|
ClickHouseAnalytics__SourceLookbackDays=90
|
||||||
|
ClickHouseAnalytics__BatchSize=1000
|
||||||
|
|
||||||
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
||||||
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
||||||
Operations__BackupWarningHours=24
|
Operations__BackupWarningHours=24
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build self-contained platform packages
|
- name: Build framework-dependent platform packages
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
bash ./scripts/publish-platform-packages.sh \
|
bash ./scripts/publish-platform-packages.sh \
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
- Windows x64:`.zip`
|
- Windows x64:`.zip`
|
||||||
- Linux x64 / ARM64:`.tar.gz`
|
- Linux x64 / ARM64:`.tar.gz`
|
||||||
- `SHA256SUMS`:发布包校验值
|
- `SHA256SUMS`:发布包校验值
|
||||||
- 自包含程序包无需预装 .NET 或 ASP.NET Core Runtime
|
- 二进制程序包不包含 .NET 或 ASP.NET Core Runtime;请先安装匹配版本的 ASP.NET Core Runtime
|
||||||
|
|
||||||
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
|
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
|
||||||
|
|
||||||
|
|||||||
Submodule
+1
Submodule Academic-Affairs-System.wiki added at fe88e71716
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
|
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
|
||||||
WORKDIR /source
|
WORKDIR /source
|
||||||
|
COPY versions.props ./
|
||||||
COPY web/package.json web/package-lock.json ./web/
|
COPY web/package.json web/package-lock.json ./web/
|
||||||
RUN npm --prefix web ci
|
RUN npm --prefix web ci
|
||||||
COPY web/ ./web/
|
COPY web/ ./web/
|
||||||
@@ -11,6 +12,7 @@ FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
WORKDIR /source
|
WORKDIR /source
|
||||||
COPY global.json ./
|
COPY global.json ./
|
||||||
|
COPY versions.props ./
|
||||||
COPY .env.example ./
|
COPY .env.example ./
|
||||||
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
|
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
|
||||||
RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH"
|
RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH"
|
||||||
|
|||||||
@@ -366,39 +366,36 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
|
|||||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||||
|
|
||||||
### OpenTelemetry 与慢查询定位
|
### 慢接口与慢查询基线
|
||||||
|
|
||||||
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
应用不依赖 OpenTelemetry。启用 `Observability` 后,会为每个 `/api` 请求写入结构化的
|
||||||
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
方法、路径、终结点、状态码、耗时和请求号;超过阈值或返回 5xx 的请求会提升为 Warning。
|
||||||
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
EF Core 数据库命令超过阈值时同样写入查询名称、SQL 模板哈希、数据库类型、耗时和相同的
|
||||||
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
请求号。用日志平台按 `DurationMs` 聚合即可得到真实的 P50/P95/P99 和慢接口排行。
|
||||||
仍然有效。
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Observability__Enabled=true
|
Observability__Enabled=true
|
||||||
Observability__ServiceName=jiaowu-api
|
Observability__ServiceName=jiaowu-api
|
||||||
|
Observability__LogAllApiRequests=true
|
||||||
|
Observability__SlowRequestThresholdMilliseconds=1000
|
||||||
Observability__SlowQueryThresholdMilliseconds=500
|
Observability__SlowQueryThresholdMilliseconds=500
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
|
||||||
```
|
```
|
||||||
|
|
||||||
数据库指标包括 `jiaowu.db.command.duration`、`jiaowu.db.command.slow` 和
|
为关键 EF 查询添加 `TagWith("模块.查询名")` 后,慢 SQL 日志会直接显示稳定名称;无标签
|
||||||
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
|
查询只显示操作类型和 SQL 模板哈希。默认 `Observability__IncludeSqlText=false`,不会把
|
||||||
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
|
SQL 模板、参数值或连接串写入日志。仅在受控诊断窗口内临时启用 SQL 模板记录,并限制日志
|
||||||
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
|
访问权限与保留时间。
|
||||||
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
|
|
||||||
|
|
||||||
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
应用侧请求号用于关联接口和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||||
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
||||||
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
||||||
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
||||||
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
||||||
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
||||||
|
|
||||||
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
如部署环境另行提供 Prometheus 兼容指标源,超级管理员仍可在“组织与权限 → 运维与审计 →
|
||||||
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
系统性能”查看其汇总数据。该页面只读查询外部指标源,浏览器不会接触其地址或令牌;结果
|
||||||
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
默认缓存 30 秒。应用本身不会再通过 OpenTelemetry 向该指标源写入数据。
|
||||||
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
|
||||||
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PerformanceReporting__Enabled=true
|
PerformanceReporting__Enabled=true
|
||||||
@@ -415,6 +412,22 @@ Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置
|
|||||||
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||||
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
||||||
|
|
||||||
|
### ClickHouse 分析读模型
|
||||||
|
|
||||||
|
ClickHouse 仅用于考勤、操作审计和成绩趋势的多维聚合,MySQL 仍是所有教务业务的唯一写入源。默认关闭;启用后,后台工作器以可重试的滚动窗口投影 MySQL 当前事实到 `ReplacingMergeTree` 表,重复投递不会改变读结果。
|
||||||
|
|
||||||
|
生产环境请为分析库创建独立账号,并限制其只能访问 `ClickHouseAnalytics__Database`。推荐通过 HTTPS 或内网连接:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ClickHouseAnalytics__Enabled=true
|
||||||
|
ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
|
||||||
|
ClickHouseAnalytics__Database=jiaowu_analytics
|
||||||
|
ClickHouseAnalytics__UserName=jiaowu_analytics
|
||||||
|
ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
分析概览通过 `GET /api/clickhouse-analytics/overview` 提供;学院管理员只能读取本学院的考勤和成绩趋势,跨学院的操作审计仅对全校数据范围角色开放。ClickHouse 暂时不可用时,业务写入不会失败,工作器会在下一个周期重试。
|
||||||
|
|
||||||
### 后台任务与 RabbitMQ
|
### 后台任务与 RabbitMQ
|
||||||
|
|
||||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||||
@@ -455,7 +468,7 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
|||||||
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
||||||
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
||||||
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
||||||
可接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
可由现有日志平台或运行时指标采集器汇总。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||||
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
||||||
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ x-jiaowu-environment: &jiaowu-environment
|
|||||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||||
Cache__Enabled: "true"
|
Cache__Enabled: "true"
|
||||||
Cache__KeyPrefix: "jiaowu:v1"
|
Cache__KeyPrefix: "jiaowu:v1"
|
||||||
|
ClickHouseAnalytics__Enabled: "true"
|
||||||
|
ClickHouseAnalytics__Endpoint: "http://clickhouse:8123"
|
||||||
|
ClickHouseAnalytics__Database: "jiaowu_analytics"
|
||||||
|
ClickHouseAnalytics__UserName: "${CLICKHOUSE_USER:-jiaowu_analytics}"
|
||||||
|
ClickHouseAnalytics__Password: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
|
||||||
BackgroundJobs__Transport: RabbitMq
|
BackgroundJobs__Transport: RabbitMq
|
||||||
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||||
@@ -47,6 +52,25 @@ x-json-logging: &json-logging
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:25.8-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_DB: jiaowu_analytics
|
||||||
|
CLICKHOUSE_USER: "${CLICKHOUSE_USER:-jiaowu_analytics}"
|
||||||
|
CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- wget -qO- http://localhost:8123/ping | grep -q Ok
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
volumes:
|
||||||
|
- clickhouse-data:/var/lib/clickhouse
|
||||||
|
logging: *json-logging
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:4.2-management-alpine
|
image: rabbitmq:4.2-management-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -135,6 +159,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
clickhouse:
|
||||||
|
condition: service_healthy
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
ports:
|
ports:
|
||||||
@@ -165,5 +191,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
clickhouse-data:
|
||||||
rabbitmq-data:
|
rabbitmq-data:
|
||||||
backup-data:
|
backup-data:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ for runtime in "${runtimes[@]}"; do
|
|||||||
dotnet publish "$project" \
|
dotnet publish "$project" \
|
||||||
--configuration Release \
|
--configuration Release \
|
||||||
--runtime "$runtime" \
|
--runtime "$runtime" \
|
||||||
--self-contained true \
|
--self-contained false \
|
||||||
--output "$package_root" \
|
--output "$package_root" \
|
||||||
-p:BuildFrontendOnPublish=false \
|
-p:BuildFrontendOnPublish=false \
|
||||||
-p:Version="$version" \
|
-p:Version="$version" \
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
@@ -23,43 +24,73 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
// ═══════════════ Aggregated pending ═══════════════
|
// ═══════════════ Aggregated pending ═══════════════
|
||||||
[HttpGet("pending")]
|
[HttpGet("pending")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetPending(CancellationToken ct)
|
public async Task<ActionResult<PagedResult<ApprovalItem>>> GetPending(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var items = new List<ApprovalItem>();
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("page 必须大于 0,pageSize 必须在 1 到 100 之间。");
|
||||||
|
|
||||||
|
var take = checked(page * pageSize);
|
||||||
|
var total = 0;
|
||||||
|
var items = new List<ApprovalItem>(take * 8);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
|
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
|
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
|
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
|
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
|
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
|
||||||
|
.OrderByDescending(x => x.AppealSubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending).CountAsync(ct);
|
||||||
|
|
||||||
return Ok(items.OrderByDescending(x => x.Time).ToList());
|
var pageItems = items
|
||||||
|
.OrderByDescending(x => x.Time)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToArray();
|
||||||
|
return Ok(new PagedResult<ApprovalItem>(pageItems, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Course Exemption ═══════════════
|
// ═══════════════ Course Exemption ═══════════════
|
||||||
@@ -251,13 +282,6 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
// Auto-apply: update grade record
|
// Auto-apply: update grade record
|
||||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||||
var statisticsJob = new CourseGradeStatisticsRefreshJob
|
|
||||||
{
|
|
||||||
GradeSheetId = gm.GradeRecord.GradeSheetId
|
|
||||||
};
|
|
||||||
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
|
|
||||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
|
||||||
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await NotificationService.SendAsync(
|
await NotificationService.SendAsync(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
|
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;
|
||||||
@@ -33,14 +34,32 @@ public sealed class AttendanceController(
|
|||||||
[Authorize(Roles = AttendanceRoles)]
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> GetMyTasks(
|
public async Task<ActionResult> GetMyTasks(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
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 tasks = AccessibleTasks().AsNoTracking()
|
var tasks = AccessibleTasks().AsNoTracking()
|
||||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
tasks = tasks.Where(x =>
|
||||||
|
x.TaskNumber.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
x.Course!.Code.Contains(keyword) ||
|
||||||
|
x.Course.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var total = await tasks.CountAsync(cancellationToken);
|
||||||
var result = await tasks
|
var result = await tasks
|
||||||
.OrderBy(x => x.Course!.Code)
|
.OrderBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -63,22 +82,44 @@ public sealed class AttendanceController(
|
|||||||
sheet.TeachingTaskId == x.Id)
|
sheet.TeachingTaskId == x.Id)
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return Ok(result);
|
return Ok(new PagedResult<object>(result, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sheets")]
|
[HttpGet("sheets")]
|
||||||
[Authorize(Roles = AttendanceRoles)]
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
Guid teachingTaskId,
|
Guid teachingTaskId,
|
||||||
CancellationToken cancellationToken)
|
string? keyword = null,
|
||||||
|
AttendanceSheetStatus? status = null,
|
||||||
|
AttendanceCheckInMethod? checkInMethod = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||||
var task = await AccessibleTasks().AsNoTracking()
|
var task = await AccessibleTasks().AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||||
if (task is null) return NotFound();
|
if (task is null) return NotFound();
|
||||||
|
|
||||||
var sheets = await db.AttendanceSheets.AsNoTracking()
|
var source = db.AttendanceSheets.AsNoTracking()
|
||||||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
.Where(x => x.TeachingTaskId == teachingTaskId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x => x.Name.Contains(keyword) || x.Notes!.Contains(keyword));
|
||||||
|
}
|
||||||
|
if (status.HasValue)
|
||||||
|
source = source.Where(x => x.Status == status.Value);
|
||||||
|
if (checkInMethod.HasValue)
|
||||||
|
source = source.Where(x => x.CheckInMethod == checkInMethod.Value);
|
||||||
|
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var sheets = await source
|
||||||
.OrderByDescending(x => x.AttendanceDate)
|
.OrderByDescending(x => x.AttendanceDate)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -99,7 +140,7 @@ public sealed class AttendanceController(
|
|||||||
TotalCount = x.Records.Count
|
TotalCount = x.Records.Count
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return Ok(sheets);
|
return Ok(new PagedResult<object>(sheets, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets")]
|
[HttpPost("sheets")]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
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;
|
||||||
@@ -232,8 +233,49 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classes")]
|
[HttpGet("classes")]
|
||||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken)
|
public async Task<ActionResult<object>> GetClasses(
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
string? keyword,
|
||||||
|
Guid? collegeId,
|
||||||
|
Guid? majorId,
|
||||||
|
int? grade,
|
||||||
|
bool? isEnabled,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (page.HasValue || pageSize.HasValue)
|
||||||
|
{
|
||||||
|
if (page is < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
|
var source = db.AdministrativeClasses.AsNoTracking();
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
source = source.Where(x => x.Major!.CollegeId == collegeId.Value);
|
||||||
|
if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId.Value);
|
||||||
|
if (grade.HasValue) source = source.Where(x => x.Grade == grade.Value);
|
||||||
|
if (isEnabled.HasValue) source = source.Where(x => x.IsEnabled == isEnabled.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.Code.Contains(keyword) || x.Name.Contains(keyword) ||
|
||||||
|
x.Major!.Name.Contains(keyword) ||
|
||||||
|
x.Major.College!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||||
|
.Skip((page!.Value - 1) * pageSize!.Value)
|
||||||
|
.Take(pageSize.Value)
|
||||||
|
.Select(x => new AdministrativeClassListItem(
|
||||||
|
x.Id, x.Code, x.Name, x.MajorId, x.Major!.Name,
|
||||||
|
x.Major.College!.Name, x.Grade, x.CounselorUserId,
|
||||||
|
x.CounselorName, x.IsEnabled, x.SortOrder))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<AdministrativeClassListItem>(
|
||||||
|
items, total, page.Value, pageSize.Value));
|
||||||
|
}
|
||||||
|
|
||||||
var result = await cache.GetOrCreateAsync(
|
var result = await cache.GetOrCreateAsync(
|
||||||
AppCacheKeys.BaseData("classes"),
|
AppCacheKeys.BaseData("classes"),
|
||||||
token => db.AdministrativeClasses.AsNoTracking()
|
token => db.AdministrativeClasses.AsNoTracking()
|
||||||
@@ -474,8 +516,54 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classrooms")]
|
[HttpGet("classrooms")]
|
||||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
|
public async Task<ActionResult<object>> GetClassrooms(
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
string? keyword,
|
||||||
|
Guid? campusId,
|
||||||
|
Guid? buildingId,
|
||||||
|
int? minimumCapacity,
|
||||||
|
TeachingVenueNature? teachingVenueNature,
|
||||||
|
bool? isEnabled,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (page.HasValue || pageSize.HasValue)
|
||||||
|
{
|
||||||
|
if (page is < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
|
var source = db.Classrooms.AsNoTracking();
|
||||||
|
if (campusId.HasValue)
|
||||||
|
source = source.Where(x => x.Building!.CampusId == campusId.Value);
|
||||||
|
if (buildingId.HasValue) source = source.Where(x => x.BuildingId == buildingId.Value);
|
||||||
|
if (minimumCapacity.HasValue)
|
||||||
|
source = source.Where(x => x.Capacity >= minimumCapacity.Value);
|
||||||
|
if (teachingVenueNature.HasValue)
|
||||||
|
source = source.Where(x => (x.TeachingVenueNature & teachingVenueNature.Value) != 0);
|
||||||
|
if (isEnabled.HasValue) source = source.Where(x => x.IsEnabled == isEnabled.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.Code.Contains(keyword) || x.Name.Contains(keyword) ||
|
||||||
|
x.Building!.Name.Contains(keyword) ||
|
||||||
|
x.Building.Campus!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderBy(x => x.Building!.Campus!.SortOrder).ThenBy(x => x.Code)
|
||||||
|
.Skip((page!.Value - 1) * pageSize!.Value)
|
||||||
|
.Take(pageSize.Value)
|
||||||
|
.Select(x => new ClassroomListItem(
|
||||||
|
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
|
||||||
|
x.Building.Name, x.Building.Campus!.Name, x.Capacity,
|
||||||
|
x.RoomType, x.TeachingVenueNature, x.Equipment,
|
||||||
|
x.IsEnabled, x.SortOrder))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<ClassroomListItem>(
|
||||||
|
items, total, page.Value, pageSize.Value));
|
||||||
|
}
|
||||||
|
|
||||||
var result = await cache.GetOrCreateAsync(
|
var result = await cache.GetOrCreateAsync(
|
||||||
AppCacheKeys.BaseData("classrooms"),
|
AppCacheKeys.BaseData("classrooms"),
|
||||||
token => db.Classrooms.AsNoTracking()
|
token => db.Classrooms.AsNoTracking()
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = AnalyticsUsers)]
|
||||||
|
[Route("api/clickhouse-analytics")]
|
||||||
|
public sealed class ClickHouseAnalyticsController(
|
||||||
|
ClickHouseAnalyticsClient client,
|
||||||
|
ClickHouseAnalyticsOptions options,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string AnalyticsUsers =
|
||||||
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin + "," + SystemRoles.Leader;
|
||||||
|
|
||||||
|
[HttpGet("status")]
|
||||||
|
public ActionResult GetStatus() => Ok(new
|
||||||
|
{
|
||||||
|
options.Enabled,
|
||||||
|
options.SyncIntervalSeconds,
|
||||||
|
options.SourceLookbackDays,
|
||||||
|
options.BatchSize,
|
||||||
|
options.Database
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("overview")]
|
||||||
|
public async Task<ActionResult> GetOverview(
|
||||||
|
DateOnly? from,
|
||||||
|
DateOnly? to,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Enabled)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "ClickHouse 分析未启用",
|
||||||
|
Detail = "请先配置 ClickHouseAnalytics 并启动分析库。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var start = from ?? end.AddDays(-29);
|
||||||
|
if (start > end || end.DayNumber - start.DayNumber > 366)
|
||||||
|
return ValidationProblem("分析时间范围应为 1 到 366 天,且开始日期不能晚于结束日期。");
|
||||||
|
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var collegeFilter = scope.RestrictedCollegeId is { } collegeId
|
||||||
|
? $" AND collegeId = toUUID('{collegeId:D}')"
|
||||||
|
: string.Empty;
|
||||||
|
var dateFilter = $"attendanceDate >= toDate('{start:yyyy-MM-dd}') AND attendanceDate <= toDate('{end:yyyy-MM-dd}')";
|
||||||
|
|
||||||
|
var attendance = await client.QueryAsync($"""
|
||||||
|
SELECT attendanceDate, count() AS total, countIf(status = 1) AS present,
|
||||||
|
countIf(status = 2) AS absent, countIf(status = 3) AS late
|
||||||
|
FROM {options.Database}.attendanceRecords FINAL
|
||||||
|
WHERE {dateFilter}{collegeFilter}
|
||||||
|
GROUP BY attendanceDate ORDER BY attendanceDate
|
||||||
|
""", cancellationToken);
|
||||||
|
var grades = await client.QueryAsync($"""
|
||||||
|
SELECT academicTermId, academicTermName,
|
||||||
|
totalStudentCount AS studentCount,
|
||||||
|
round(totalWeightedScore / nullIf(totalStudentCount, 0), 2) AS averageScore,
|
||||||
|
round(totalPassedCount / nullIf(totalStudentCount, 0), 4) AS passRate
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
SELECT academicTermId, any(academicTermName) AS academicTermName,
|
||||||
|
sum(studentCount) AS totalStudentCount,
|
||||||
|
sum(averageScore * studentCount) AS totalWeightedScore,
|
||||||
|
sum(passedCount) AS totalPassedCount
|
||||||
|
FROM {options.Database}.gradeStatistics FINAL
|
||||||
|
WHERE 1 = 1{collegeFilter}
|
||||||
|
GROUP BY academicTermId
|
||||||
|
) AS gradeTotals
|
||||||
|
ORDER BY academicTermName
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Audit data has no college dimension, so it is never exposed to a
|
||||||
|
// college-scoped administrator.
|
||||||
|
var audit = scope.RestrictedCollegeId is null
|
||||||
|
? await client.QueryAsync($"""
|
||||||
|
SELECT toDate(occurredAt) AS date, count() AS total,
|
||||||
|
countIf(statusCode >= 400) AS failed
|
||||||
|
FROM {options.Database}.auditEvents FINAL
|
||||||
|
WHERE occurredAt >= toDateTime('{start:yyyy-MM-dd}')
|
||||||
|
AND occurredAt < toDateTime('{end.AddDays(1):yyyy-MM-dd}')
|
||||||
|
GROUP BY date ORDER BY date
|
||||||
|
""", cancellationToken)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return Ok(new { Start = start, End = end, Attendance = attendance, Grades = grades, Audit = audit });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
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.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -175,8 +177,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
public async Task<ActionResult> GetMine(
|
public async Task<ActionResult> GetMine(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CourseAdjustmentStatus? status,
|
CourseAdjustmentStatus? status,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var userId = currentUserDataScope.Current.UserId;
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
var source = db.CourseAdjustments.AsNoTracking()
|
var source = db.CourseAdjustments.AsNoTracking()
|
||||||
.Where(x => x.ApplicantUserId == userId);
|
.Where(x => x.ApplicantUserId == userId);
|
||||||
@@ -186,10 +193,15 @@ public sealed class CourseAdjustmentsController(
|
|||||||
if (status.HasValue)
|
if (status.HasValue)
|
||||||
source = source.Where(x => x.Status == status);
|
source = source.Where(x => x.Status == status);
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(AdjustmentProjection())
|
.Select(AdjustmentProjection())
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Pending reviews ═══════════════
|
// ═══════════════ Pending reviews ═══════════════
|
||||||
@@ -198,8 +210,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
[Authorize(Roles = Reviewers)]
|
[Authorize(Roles = Reviewers)]
|
||||||
public async Task<ActionResult> GetPendingReviews(
|
public async Task<ActionResult> GetPendingReviews(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var scope = currentUserDataScope.Current;
|
var scope = currentUserDataScope.Current;
|
||||||
var source = db.CourseAdjustments.AsNoTracking()
|
var source = db.CourseAdjustments.AsNoTracking()
|
||||||
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
|
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
|
||||||
@@ -210,10 +227,15 @@ public sealed class CourseAdjustmentsController(
|
|||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.SubmittedAt)
|
.OrderByDescending(x => x.SubmittedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(AdjustmentProjection())
|
.Select(AdjustmentProjection())
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Detail ═══════════════
|
// ═══════════════ Detail ═══════════════
|
||||||
@@ -286,6 +308,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
|
|
||||||
db.CourseAdjustments.Add(adj);
|
db.CourseAdjustments.Add(adj);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await new PublishedTimetableProjectionService(db)
|
||||||
|
.RebuildPublishedPlansForTaskAsync(adj.TeachingTaskId, cancellationToken);
|
||||||
|
|
||||||
if (request.Submit)
|
if (request.Submit)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = ReadRoles)]
|
||||||
|
[Route("api/course-groups")]
|
||||||
|
public sealed class CourseGroupsController(AppDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string ReadRoles =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin;
|
||||||
|
private const string ManageRoles =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult> GetAll(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var groups = await db.CourseGroups.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.Description,
|
||||||
|
CourseCount = x.Courses.Count,
|
||||||
|
Courses = x.Courses.OrderBy(item => item.Course!.Code).Select(item => new
|
||||||
|
{
|
||||||
|
item.Id,
|
||||||
|
item.CourseId,
|
||||||
|
CourseCode = item.Course!.Code,
|
||||||
|
CourseName = item.Course.Name,
|
||||||
|
item.Course.Credits,
|
||||||
|
item.Course.TotalHours,
|
||||||
|
item.Course.Nature
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = ManageRoles)]
|
||||||
|
public async Task<ActionResult> Create(
|
||||||
|
CourseGroupRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var group = new CourseGroup
|
||||||
|
{
|
||||||
|
Code = request.Code.Trim(),
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Description = Normalize(request.Description)
|
||||||
|
};
|
||||||
|
db.CourseGroups.Add(group);
|
||||||
|
return await SaveCreatedAsync(group.Id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize(Roles = ManageRoles)]
|
||||||
|
public async Task<ActionResult> Update(
|
||||||
|
Guid id,
|
||||||
|
CourseGroupRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (group is null) return NotFound();
|
||||||
|
group.Code = request.Code.Trim();
|
||||||
|
group.Name = request.Name.Trim();
|
||||||
|
group.Description = Normalize(request.Description);
|
||||||
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[Authorize(Roles = ManageRoles)]
|
||||||
|
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (group is null) return NotFound();
|
||||||
|
db.CourseGroups.Remove(group);
|
||||||
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/courses")]
|
||||||
|
[Authorize(Roles = ManageRoles)]
|
||||||
|
public async Task<ActionResult> AddCourse(
|
||||||
|
Guid id,
|
||||||
|
CourseGroupCourseRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.CourseGroups.AnyAsync(x => x.Id == id, cancellationToken)) return NotFound();
|
||||||
|
if (!await db.Courses.AnyAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken))
|
||||||
|
return ValidationProblem("所选课程不存在或已停用。");
|
||||||
|
db.CourseGroupCourses.Add(new CourseGroupCourse { CourseGroupId = id, CourseId = request.CourseId });
|
||||||
|
return await SaveCreatedAsync(id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/courses/{courseId:guid}")]
|
||||||
|
[Authorize(Roles = ManageRoles)]
|
||||||
|
public async Task<ActionResult> RemoveCourse(
|
||||||
|
Guid id,
|
||||||
|
Guid courseId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var item = await db.CourseGroupCourses.FirstOrDefaultAsync(
|
||||||
|
x => x.CourseGroupId == id && x.CourseId == courseId,
|
||||||
|
cancellationToken);
|
||||||
|
if (item is null) return NotFound();
|
||||||
|
db.CourseGroupCourses.Remove(item);
|
||||||
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Created(string.Empty, new { id });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("课程组编码或组内课程重复。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("课程组编码或组内课程重复。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CourseGroupRequest(
|
||||||
|
[Required, MaxLength(30)] string Code,
|
||||||
|
[Required, MaxLength(100)] string Name,
|
||||||
|
[MaxLength(500)] string? Description);
|
||||||
|
|
||||||
|
public sealed record CourseGroupCourseRequest(Guid CourseId);
|
||||||
@@ -399,8 +399,22 @@ public sealed class CourseSelectionsController(
|
|||||||
|
|
||||||
[HttpGet("offerings/{id:guid}/roster")]
|
[HttpGet("offerings/{id:guid}/roster")]
|
||||||
[Authorize(Roles = RosterReaders)]
|
[Authorize(Roles = RosterReaders)]
|
||||||
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> GetRoster(
|
||||||
|
Guid id,
|
||||||
|
string? keyword = null,
|
||||||
|
int? grade = null,
|
||||||
|
Guid? majorId = null,
|
||||||
|
Guid? administrativeClassId = null,
|
||||||
|
int studentPage = 1,
|
||||||
|
int studentPageSize = 20,
|
||||||
|
int waitlistPage = 1,
|
||||||
|
int waitlistPageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
studentPage = Math.Max(1, studentPage);
|
||||||
|
studentPageSize = Math.Clamp(studentPageSize, 10, 100);
|
||||||
|
waitlistPage = Math.Max(1, waitlistPage);
|
||||||
|
waitlistPageSize = Math.Clamp(waitlistPageSize, 10, 100);
|
||||||
var offering = await db.CourseSelectionOfferings.AsNoTracking()
|
var offering = await db.CourseSelectionOfferings.AsNoTracking()
|
||||||
.Where(x => x.Id == id)
|
.Where(x => x.Id == id)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
@@ -428,11 +442,64 @@ public sealed class CourseSelectionsController(
|
|||||||
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
|
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
var students = await db.CourseEnrollments.AsNoTracking()
|
var enrolledSource = db.CourseEnrollments.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.CourseSelectionOfferingId == id &&
|
x.CourseSelectionOfferingId == id &&
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
x.Status == CourseEnrollmentStatus.Enrolled);
|
||||||
|
var waitlistSource = db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Waitlisted);
|
||||||
|
var enrolledCount = await enrolledSource.CountAsync(cancellationToken);
|
||||||
|
var waitlistedCount = await waitlistSource.CountAsync(cancellationToken);
|
||||||
|
var rosterSource = db.CourseEnrollments.AsNoTracking().Where(x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
(x.Status == CourseEnrollmentStatus.Enrolled ||
|
||||||
|
x.Status == CourseEnrollmentStatus.Waitlisted));
|
||||||
|
var filterOptions = await rosterSource
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
Grade = x.Student!.AdministrativeClass!.Grade,
|
||||||
|
MajorId = x.Student.AdministrativeClass.MajorId,
|
||||||
|
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||||
|
ClassId = x.Student.AdministrativeClassId,
|
||||||
|
ClassName = x.Student.AdministrativeClass.Name
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (grade.HasValue)
|
||||||
|
{
|
||||||
|
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
|
||||||
|
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
|
||||||
|
}
|
||||||
|
if (majorId.HasValue)
|
||||||
|
{
|
||||||
|
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
|
||||||
|
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
|
||||||
|
}
|
||||||
|
if (administrativeClassId.HasValue)
|
||||||
|
{
|
||||||
|
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
|
||||||
|
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
enrolledSource = enrolledSource.Where(x =>
|
||||||
|
x.Student!.StudentNumber.Contains(keyword) ||
|
||||||
|
x.Student.Name.Contains(keyword) ||
|
||||||
|
x.Student.AdministrativeClass!.Name.Contains(keyword));
|
||||||
|
waitlistSource = waitlistSource.Where(x =>
|
||||||
|
x.Student!.StudentNumber.Contains(keyword) ||
|
||||||
|
x.Student.Name.Contains(keyword) ||
|
||||||
|
x.Student.AdministrativeClass!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var enrolledTotal = await enrolledSource.CountAsync(cancellationToken);
|
||||||
|
var waitlistedTotal = await waitlistSource.CountAsync(cancellationToken);
|
||||||
|
var students = await enrolledSource
|
||||||
.OrderBy(x => x.Student!.StudentNumber)
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Skip((studentPage - 1) * studentPageSize)
|
||||||
|
.Take(studentPageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -445,12 +512,11 @@ public sealed class CourseSelectionsController(
|
|||||||
x.EnrolledAt
|
x.EnrolledAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var waitlistedRows = await db.CourseEnrollments.AsNoTracking()
|
var waitlistedRows = await waitlistSource
|
||||||
.Where(x =>
|
|
||||||
x.CourseSelectionOfferingId == id &&
|
|
||||||
x.Status == CourseEnrollmentStatus.Waitlisted)
|
|
||||||
.OrderBy(x => x.WaitlistedAt)
|
.OrderBy(x => x.WaitlistedAt)
|
||||||
.ThenBy(x => x.CreatedAt)
|
.ThenBy(x => x.CreatedAt)
|
||||||
|
.Skip((waitlistPage - 1) * waitlistPageSize)
|
||||||
|
.Take(waitlistPageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -474,7 +540,7 @@ public sealed class CourseSelectionsController(
|
|||||||
item.MajorName,
|
item.MajorName,
|
||||||
item.Grade,
|
item.Grade,
|
||||||
item.WaitlistedAt,
|
item.WaitlistedAt,
|
||||||
Position = index + 1
|
Position = (waitlistPage - 1) * waitlistPageSize + index + 1
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -487,10 +553,24 @@ public sealed class CourseSelectionsController(
|
|||||||
offering.CourseName,
|
offering.CourseName,
|
||||||
offering.CourseNature,
|
offering.CourseNature,
|
||||||
offering.Capacity,
|
offering.Capacity,
|
||||||
EnrolledCount = students.Count,
|
EnrolledCount = enrolledCount,
|
||||||
Students = students,
|
Students = students,
|
||||||
WaitlistedCount = waitlist.Count,
|
StudentPage = studentPage,
|
||||||
|
StudentPageSize = studentPageSize,
|
||||||
|
StudentTotal = enrolledTotal,
|
||||||
|
WaitlistedCount = waitlistedCount,
|
||||||
Waitlist = waitlist,
|
Waitlist = waitlist,
|
||||||
|
WaitlistPage = waitlistPage,
|
||||||
|
WaitlistPageSize = waitlistPageSize,
|
||||||
|
WaitlistTotal = waitlistedTotal,
|
||||||
|
FilterOptions = new
|
||||||
|
{
|
||||||
|
Grades = filterOptions.Select(x => x.Grade).Distinct().OrderBy(x => x),
|
||||||
|
Majors = filterOptions.Select(x => new { x.MajorId, x.MajorName }).Distinct()
|
||||||
|
.OrderBy(x => x.MajorName),
|
||||||
|
Classes = filterOptions.Select(x => new { x.ClassId, x.ClassName, x.MajorId })
|
||||||
|
.Distinct().OrderBy(x => x.ClassName)
|
||||||
|
},
|
||||||
CanManageWaitlist =
|
CanManageWaitlist =
|
||||||
offering.RoundStatus == CourseSelectionRoundStatus.Open &&
|
offering.RoundStatus == CourseSelectionRoundStatus.Open &&
|
||||||
(scope.IsInRole(SystemRoles.SuperAdmin) ||
|
(scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
@@ -1011,7 +1091,11 @@ public sealed class CourseSelectionsController(
|
|||||||
x.TeachingTask!.Status == TeachingTaskStatus.Published &&
|
x.TeachingTask!.Status == TeachingTaskStatus.Published &&
|
||||||
(x.IsOpenToAll ||
|
(x.IsOpenToAll ||
|
||||||
x.TeachingTask.Classes.Any(item =>
|
x.TeachingTask.Classes.Any(item =>
|
||||||
item.AdministrativeClassId == student.AdministrativeClassId)))
|
item.AdministrativeClassId == student.AdministrativeClassId) ||
|
||||||
|
x.Enrollments.Any(item =>
|
||||||
|
item.StudentId == student.Id &&
|
||||||
|
(item.Status == CourseEnrollmentStatus.Enrolled ||
|
||||||
|
item.Status == CourseEnrollmentStatus.Waitlisted))))
|
||||||
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
||||||
.Select(x => new StudentOfferingDto(
|
.Select(x => new StudentOfferingDto(
|
||||||
x.Id,
|
x.Id,
|
||||||
|
|||||||
@@ -432,6 +432,48 @@ public sealed class CurriculumPlansController(
|
|||||||
return await SaveCreatedAsync(item.Id, cancellationToken);
|
return await SaveCreatedAsync(item.Id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{planId:guid}/modules/{moduleId:guid}/course-groups/{groupId:guid}")]
|
||||||
|
public async Task<ActionResult> AddCourseGroup(
|
||||||
|
Guid planId,
|
||||||
|
Guid moduleId,
|
||||||
|
Guid groupId,
|
||||||
|
CurriculumCourseGroupImportRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await ModifiablePlanAsync(planId, cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
|
||||||
|
return ValidationProblem("建议学期超出了该专业学制。");
|
||||||
|
if (!await db.CurriculumModules.AnyAsync(
|
||||||
|
x => x.Id == moduleId && x.CurriculumPlanId == planId,
|
||||||
|
cancellationToken))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var courseIds = await db.CourseGroupCourses.AsNoTracking()
|
||||||
|
.Where(x => x.CourseGroupId == groupId && x.Course!.IsEnabled)
|
||||||
|
.Select(x => x.CourseId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (courseIds.Count == 0)
|
||||||
|
return ValidationProblem("课程组不存在,或其中没有可用课程。");
|
||||||
|
var existingCourseIds = await db.CurriculumCourses.AsNoTracking()
|
||||||
|
.Where(x => x.CurriculumModule!.CurriculumPlanId == planId &&
|
||||||
|
courseIds.Contains(x.CourseId))
|
||||||
|
.Select(x => x.CourseId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (existingCourseIds.Count > 0)
|
||||||
|
return ConflictProblem("课程组中有课程已存在于该培养方案,请先移除重复课程后再导入。");
|
||||||
|
|
||||||
|
db.CurriculumCourses.AddRange(courseIds.Select(courseId => new CurriculumCourse
|
||||||
|
{
|
||||||
|
CurriculumModuleId = moduleId,
|
||||||
|
CourseId = courseId,
|
||||||
|
RecommendedSemester = request.RecommendedSemester,
|
||||||
|
Type = request.Type,
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
}));
|
||||||
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
|
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
|
||||||
public async Task<ActionResult> UpdateCourse(
|
public async Task<ActionResult> UpdateCourse(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
@@ -586,3 +628,8 @@ public sealed record CurriculumCourseRequest(
|
|||||||
[Range(1, 20)] int RecommendedSemester,
|
[Range(1, 20)] int RecommendedSemester,
|
||||||
CurriculumCourseType Type,
|
CurriculumCourseType Type,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record CurriculumCourseGroupImportRequest(
|
||||||
|
[Range(1, 20)] int RecommendedSemester,
|
||||||
|
CurriculumCourseType Type,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|||||||
@@ -135,9 +135,179 @@ public sealed class DashboardController(
|
|||||||
currentTerm,
|
currentTerm,
|
||||||
counts,
|
counts,
|
||||||
pending,
|
pending,
|
||||||
|
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
|
||||||
DateTime.UtcNow));
|
DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("greeting")]
|
||||||
|
public async Task<ActionResult<DashboardGreeting>> GetGreeting(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentTermId = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(x => x.IsCurrent)
|
||||||
|
.Select(x => (Guid?)x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(await BuildGreetingAsync(
|
||||||
|
currentUserDataScope.Current,
|
||||||
|
currentTermId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DashboardGreeting> BuildGreetingAsync(
|
||||||
|
CurrentUserScope scope,
|
||||||
|
Guid? currentTermId,
|
||||||
|
DashboardCounts? counts,
|
||||||
|
DashboardPending? pending,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var name = string.IsNullOrWhiteSpace(scope.DisplayName) ? "" : $"{scope.DisplayName},";
|
||||||
|
var greeting = GetTimeGreeting();
|
||||||
|
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.Leader) ||
|
||||||
|
scope.IsInRole(SystemRoles.Counselor);
|
||||||
|
|
||||||
|
if (!isManager && scope.IsInRole(SystemRoles.Student))
|
||||||
|
{
|
||||||
|
var studentId = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == scope.UserId)
|
||||||
|
.Select(x => (Guid?)x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!studentId.HasValue)
|
||||||
|
return new DashboardGreeting("Student", $"{name}{greeting}", "绑定学籍后,将为你生成课程与成绩学习概览。", "学习节奏", "关联学生档案后,可从课程安排、成绩和考试中生成学习状态摘要。", []);
|
||||||
|
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.Id == studentId.Value)
|
||||||
|
.Select(x => new { x.AdministrativeClassId })
|
||||||
|
.FirstAsync(cancellationToken);
|
||||||
|
var currentTasks = db.TeachingTasks.AsNoTracking().Where(task =>
|
||||||
|
currentTermId.HasValue &&
|
||||||
|
task.AcademicTermId == currentTermId.Value &&
|
||||||
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == studentId.Value &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)));
|
||||||
|
var taskWorkload = await currentTasks.Select(task => new
|
||||||
|
{
|
||||||
|
task.Id,
|
||||||
|
task.CourseId,
|
||||||
|
Credits = task.Course!.Credits,
|
||||||
|
IsClassAssigned = task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId)
|
||||||
|
}).ToListAsync(cancellationToken);
|
||||||
|
var currentCourses = taskWorkload
|
||||||
|
.GroupBy(x => x.CourseId)
|
||||||
|
.Select(x => x.First())
|
||||||
|
.ToList();
|
||||||
|
var courseCount = currentCourses.Count;
|
||||||
|
var courseCredits = currentCourses.Sum(x => x.Credits);
|
||||||
|
var classAssignedCount = taskWorkload.Count(x => x.IsClassAssigned);
|
||||||
|
var selfSelectedCount = taskWorkload.Count(x => !x.IsClassAssigned);
|
||||||
|
var publishedGrades = db.GradeRecords.AsNoTracking().Where(x =>
|
||||||
|
x.StudentId == studentId.Value &&
|
||||||
|
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
currentTermId.HasValue &&
|
||||||
|
x.GradeSheet.TeachingTask!.AcademicTermId == currentTermId.Value);
|
||||||
|
var gradeCount = await publishedGrades.CountAsync(cancellationToken);
|
||||||
|
var average = await publishedGrades
|
||||||
|
.Where(x => x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue)
|
||||||
|
.AverageAsync(x => (decimal?)x.TotalScore, cancellationToken);
|
||||||
|
var failed = await publishedGrades.CountAsync(x =>
|
||||||
|
x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue && x.TotalScore < 60,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var subtitle = failed > 0
|
||||||
|
? $"已发布成绩中有 {failed} 门课程需要重点关注,建议优先查看课程反馈。"
|
||||||
|
: courseCount > 0
|
||||||
|
? $"本学期已有 {courseCount} 门课程、{courseCredits:0.#} 学分进入你的学习安排。"
|
||||||
|
: "本学期暂未发现为你安排或确认选课的课程,可先查看培养方案和选课安排。";
|
||||||
|
var narrative = courseCount == 0
|
||||||
|
? "你的当前学习安排尚未形成:系统还没有找到行政班已安排课程或已确认选课。"
|
||||||
|
: failed > 0
|
||||||
|
? $"本学期已形成 {courseCount} 门课程安排,其中 {classAssignedCount} 个教学班来自行政班安排;已发布成绩中有 {failed} 门需要重点关注。"
|
||||||
|
: gradeCount > 0
|
||||||
|
? $"本学期有 {courseCount} 门课程进入学习安排,已发布 {gradeCount} 门成绩,当前没有不及格记录。"
|
||||||
|
: $"本学期有 {courseCount} 门课程进入学习安排,包含 {classAssignedCount} 个行政班教学班和 {selfSelectedCount} 个自主选课教学班,成绩发布后会在这里更新。";
|
||||||
|
return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", narrative,
|
||||||
|
[
|
||||||
|
new DashboardGreetingInsight("本学期课程", $"{courseCount} 门", $"共 {courseCredits:0.#} 学分", "calm"),
|
||||||
|
new DashboardGreetingInsight("已发布成绩", $"{gradeCount} 门", average.HasValue ? $"平均分 {average.Value:0.0}" : "等待成绩发布", "calm"),
|
||||||
|
new DashboardGreetingInsight("重点关注", $"{failed} 门", failed > 0 ? "建议尽早安排复习与答疑" : "当前无不及格记录", failed > 0 ? "attention" : "positive")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isManager && scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
{
|
||||||
|
var teacherId = await db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == scope.UserId && x.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => (Guid?)x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!teacherId.HasValue)
|
||||||
|
return new DashboardGreeting("Teacher", $"{name}{greeting}", "绑定教师档案后,将为你生成本学期教学负荷概览。", "教学节奏", "关联教师档案后,可从教学班、授课学时和成绩进度生成今日工作摘要。", []);
|
||||||
|
|
||||||
|
var tasks = db.TeachingTasks.AsNoTracking().Where(x =>
|
||||||
|
currentTermId.HasValue && x.AcademicTermId == currentTermId.Value &&
|
||||||
|
x.Teachers.Any(t => t.TeacherId == teacherId.Value));
|
||||||
|
var teachingClasses = await tasks.CountAsync(cancellationToken);
|
||||||
|
var estimatedHours = await tasks.SumAsync(
|
||||||
|
x => (int?)(x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), cancellationToken) ?? 0;
|
||||||
|
var gradeSheets = db.GradeSheets.AsNoTracking().Where(x =>
|
||||||
|
x.TeachingTask!.Teachers.Any(t => t.TeacherId == teacherId.Value) &&
|
||||||
|
currentTermId.HasValue && x.TeachingTask.AcademicTermId == currentTermId.Value);
|
||||||
|
var pendingGrades = await gradeSheets.CountAsync(x =>
|
||||||
|
x.Status == GradeSheetStatus.Draft ||
|
||||||
|
x.Status == GradeSheetStatus.Returned, cancellationToken);
|
||||||
|
var submittedGrades = await gradeSheets.CountAsync(x => x.Status == GradeSheetStatus.Submitted, cancellationToken);
|
||||||
|
var subtitle = pendingGrades > 0
|
||||||
|
? $"有 {pendingGrades} 张成绩登记册尚待提交,完成后可进入审核流程。"
|
||||||
|
: teachingClasses > 0
|
||||||
|
? $"本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时。"
|
||||||
|
: "本学期暂未分配教学班,请留意教学任务安排。";
|
||||||
|
var narrative = pendingGrades > 0
|
||||||
|
? $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时;有 {pendingGrades} 张成绩登记册等待提交。"
|
||||||
|
: $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时,目前没有待提交的成绩登记册。";
|
||||||
|
return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", narrative,
|
||||||
|
[
|
||||||
|
new DashboardGreetingInsight("教学班", $"{teachingClasses} 个", $"预计 {estimatedHours} 学时", "calm"),
|
||||||
|
new DashboardGreetingInsight("待提交成绩", $"{pendingGrades} 张", pendingGrades > 0 ? "请在截止日前完成登记" : "当前无需提交", pendingGrades > 0 ? "attention" : "positive"),
|
||||||
|
new DashboardGreetingInsight("审核中成绩", $"{submittedGrades} 张", submittedGrades > 0 ? "等待审核结果" : "暂无审核中登记册", "calm")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionable = pending is null ? 0 : pending.TeacherApplications + pending.GradeSheets +
|
||||||
|
pending.CourseAdjustments + pending.StudentStatusChanges + pending.GradeModifications +
|
||||||
|
pending.ClassroomReservations + pending.GeneralApprovals;
|
||||||
|
var taskCount = counts?.TeachingTasks ?? 0;
|
||||||
|
var scheduledCount = counts?.ScheduledTeachingTasks ?? 0;
|
||||||
|
var subtitleForManager = actionable > 0
|
||||||
|
? $"当前有 {actionable} 项待办需要跟进,优先处理时效性审核事项。"
|
||||||
|
: taskCount > 0
|
||||||
|
? $"本学期 {taskCount} 个教学班正在运行,当前没有积压待办。"
|
||||||
|
: "当前学期运行数据已就绪,可从教学任务开始推进。";
|
||||||
|
var managerNarrative = actionable > 0
|
||||||
|
? $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表;{actionable} 项待办正等待处理。"
|
||||||
|
: $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表,暂未发现需要你处理的积压事项。";
|
||||||
|
return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", managerNarrative,
|
||||||
|
[
|
||||||
|
new DashboardGreetingInsight("当前待办", $"{actionable} 项", actionable > 0 ? "优先处理可操作事项" : "暂无积压", actionable > 0 ? "attention" : "positive"),
|
||||||
|
new DashboardGreetingInsight("本学期教学班", $"{taskCount} 个", "教学运行规模", "calm"),
|
||||||
|
new DashboardGreetingInsight("已进入课表", $"{scheduledCount} 个", taskCount > 0 ? $"覆盖 {Math.Round(scheduledCount * 100d / taskCount)}% 教学班" : "等待教学任务发布", "calm")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTimeGreeting()
|
||||||
|
{
|
||||||
|
var hour = DateTime.UtcNow.AddHours(8).Hour;
|
||||||
|
return hour < 11 ? "早上好" : hour < 14 ? "中午好" : hour < 18 ? "下午好" : "晚上好";
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<DashboardPending> LoadPendingAsync(
|
private async Task<DashboardPending> LoadPendingAsync(
|
||||||
CurrentUserScope scope,
|
CurrentUserScope scope,
|
||||||
Guid? restrictedCollegeId,
|
Guid? restrictedCollegeId,
|
||||||
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
|
|||||||
DashboardTerm? CurrentTerm,
|
DashboardTerm? CurrentTerm,
|
||||||
DashboardCounts Counts,
|
DashboardCounts Counts,
|
||||||
DashboardPending Pending,
|
DashboardPending Pending,
|
||||||
|
DashboardGreeting Greeting,
|
||||||
DateTime GeneratedAt);
|
DateTime GeneratedAt);
|
||||||
|
|
||||||
|
public sealed record DashboardGreeting(
|
||||||
|
string Role,
|
||||||
|
string Title,
|
||||||
|
string Subtitle,
|
||||||
|
string Label,
|
||||||
|
string Narrative,
|
||||||
|
IReadOnlyList<DashboardGreetingInsight> Insights);
|
||||||
|
|
||||||
|
public sealed record DashboardGreetingInsight(
|
||||||
|
string Label,
|
||||||
|
string Value,
|
||||||
|
string Hint,
|
||||||
|
string Tone);
|
||||||
|
|
||||||
public sealed record DashboardAudience(
|
public sealed record DashboardAudience(
|
||||||
string Level,
|
string Level,
|
||||||
string Title,
|
string Title,
|
||||||
|
|||||||
@@ -391,81 +391,90 @@ public sealed class EvaluationsController(
|
|||||||
if (taskIds.Count == 0)
|
if (taskIds.Count == 0)
|
||||||
return Ok(new { Tasks = Array.Empty<object>() });
|
return Ok(new { Tasks = Array.Empty<object>() });
|
||||||
|
|
||||||
var setups = await db.EvaluationSetups.AsNoTracking()
|
var taskSummaries = await db.EvaluationRecords.AsNoTracking()
|
||||||
.Where(x => x.Status == EvaluationSetupStatus.Closed)
|
.WhereIn(taskIds, record => record.TeachingTaskId)
|
||||||
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
|
.Where(record => record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed)
|
||||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
.GroupBy(record => new
|
||||||
|
{
|
||||||
|
record.EvaluationSetupId,
|
||||||
|
SetupName = record.EvaluationSetup!.Name,
|
||||||
|
SetupStartDate = record.EvaluationSetup.AcademicTerm!.StartDate,
|
||||||
|
record.TeachingTaskId,
|
||||||
|
TaskNumber = record.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = record.TeachingTask.Name,
|
||||||
|
record.TeachingTask.AcademicTermId,
|
||||||
|
CourseCode = record.TeachingTask.Course!.Code,
|
||||||
|
CourseName = record.TeachingTask.Course.Name
|
||||||
|
})
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
group.Key,
|
||||||
|
StudentCount = group.Count(),
|
||||||
|
AverageTotal = group.Average(record =>
|
||||||
|
record.Scores.Sum(score => score.Score))
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Key.SetupStartDate)
|
||||||
|
.ThenBy(x => x.Key.CourseCode)
|
||||||
|
.ThenBy(x => x.Key.TaskNumber)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var result = new List<object>();
|
var dimensionSummaries = await (
|
||||||
foreach (var setup in setups)
|
from score in db.EvaluationScores.AsNoTracking()
|
||||||
{
|
join record in db.EvaluationRecords.AsNoTracking()
|
||||||
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
|
on score.EvaluationRecordId equals record.Id
|
||||||
.Where(r => r.EvaluationSetupId == setup.Id)
|
join dimension in db.EvaluationDimensions.AsNoTracking()
|
||||||
.WhereIn(taskIds, r => r.TeachingTaskId)
|
on score.EvaluationDimensionId equals dimension.Id
|
||||||
.Select(r => r.TeachingTaskId)
|
where taskIds.Contains(record.TeachingTaskId) &&
|
||||||
.Distinct()
|
record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed
|
||||||
.ToListAsync(cancellationToken);
|
group score by new
|
||||||
|
|
||||||
foreach (var taskId in setupTaskIds)
|
|
||||||
{
|
{
|
||||||
var task = await db.TeachingTasks.AsNoTracking()
|
record.EvaluationSetupId,
|
||||||
.Where(x => x.Id == taskId)
|
record.TeachingTaskId,
|
||||||
.Select(x => new
|
dimension.Id,
|
||||||
{
|
dimension.Name,
|
||||||
x.Id,
|
dimension.MaxScore,
|
||||||
x.TaskNumber,
|
dimension.SortOrder
|
||||||
x.Name,
|
|
||||||
x.AcademicTermId,
|
|
||||||
CourseCode = x.Course!.Code,
|
|
||||||
CourseName = x.Course.Name
|
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
if (task is null) continue;
|
|
||||||
|
|
||||||
var records = await db.EvaluationRecords.AsNoTracking()
|
|
||||||
.Where(r => r.EvaluationSetupId == setup.Id &&
|
|
||||||
r.TeachingTaskId == taskId)
|
|
||||||
.Include(r => r.Scores)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (records.Count == 0) continue;
|
|
||||||
|
|
||||||
var dimensionResults = setup.Dimensions.Select(dim =>
|
|
||||||
{
|
|
||||||
var scores = records
|
|
||||||
.SelectMany(r => r.Scores)
|
|
||||||
.Where(s => s.EvaluationDimensionId == dim.Id)
|
|
||||||
.Select(s => (double)s.Score)
|
|
||||||
.ToList();
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
DimensionId = dim.Id,
|
|
||||||
DimensionName = dim.Name,
|
|
||||||
MaxScore = dim.MaxScore,
|
|
||||||
AverageScore = scores.Count > 0
|
|
||||||
? Math.Round(scores.Average(), 1) : 0,
|
|
||||||
TotalScore = scores.Sum(),
|
|
||||||
Count = scores.Count
|
|
||||||
};
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
var totalAvg = records
|
|
||||||
.Select(r => (double)r.Scores.Sum(s => s.Score))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
result.Add(new
|
|
||||||
{
|
|
||||||
SetupId = setup.Id,
|
|
||||||
setup.Name,
|
|
||||||
Task = task,
|
|
||||||
StudentCount = records.Count,
|
|
||||||
AverageTotal = totalAvg.Count > 0
|
|
||||||
? Math.Round(totalAvg.Average(), 1) : 0,
|
|
||||||
Dimensions = dimensionResults
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
into scoreGroup
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
scoreGroup.Key,
|
||||||
|
AverageScore = scoreGroup.Average(score => score.Score),
|
||||||
|
TotalScore = scoreGroup.Sum(score => score.Score),
|
||||||
|
Count = scoreGroup.Count()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var dimensionsByTask = dimensionSummaries
|
||||||
|
.GroupBy(item => (item.Key.EvaluationSetupId, item.Key.TeachingTaskId))
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => (IReadOnlyCollection<TeacherEvaluationDimensionResult>)group
|
||||||
|
.OrderBy(item => item.Key.SortOrder)
|
||||||
|
.Select(item => new TeacherEvaluationDimensionResult(
|
||||||
|
item.Key.Id,
|
||||||
|
item.Key.Name,
|
||||||
|
item.Key.MaxScore,
|
||||||
|
Math.Round((double)item.AverageScore, 1),
|
||||||
|
item.TotalScore,
|
||||||
|
item.Count))
|
||||||
|
.ToArray());
|
||||||
|
var result = taskSummaries.Select(item => new TeacherEvaluationTaskResult(
|
||||||
|
item.Key.EvaluationSetupId,
|
||||||
|
item.Key.SetupName,
|
||||||
|
new TeacherEvaluationTask(
|
||||||
|
item.Key.TeachingTaskId,
|
||||||
|
item.Key.TaskNumber,
|
||||||
|
item.Key.TaskName,
|
||||||
|
item.Key.AcademicTermId,
|
||||||
|
item.Key.CourseCode,
|
||||||
|
item.Key.CourseName),
|
||||||
|
item.StudentCount,
|
||||||
|
Math.Round((double)item.AverageTotal, 1),
|
||||||
|
dimensionsByTask.GetValueOrDefault(
|
||||||
|
(item.Key.EvaluationSetupId, item.Key.TeachingTaskId),
|
||||||
|
Array.Empty<TeacherEvaluationDimensionResult>())))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
return Ok(new { Tasks = result });
|
return Ok(new { Tasks = result });
|
||||||
}
|
}
|
||||||
@@ -594,3 +603,27 @@ public sealed record SubmitEvaluationRequest(
|
|||||||
public sealed record EvaluationScoreRequest(
|
public sealed record EvaluationScoreRequest(
|
||||||
Guid DimensionId,
|
Guid DimensionId,
|
||||||
int Score);
|
int Score);
|
||||||
|
|
||||||
|
public sealed record TeacherEvaluationTaskResult(
|
||||||
|
Guid SetupId,
|
||||||
|
string SetupName,
|
||||||
|
TeacherEvaluationTask Task,
|
||||||
|
int StudentCount,
|
||||||
|
double AverageTotal,
|
||||||
|
IReadOnlyCollection<TeacherEvaluationDimensionResult> Dimensions);
|
||||||
|
|
||||||
|
public sealed record TeacherEvaluationTask(
|
||||||
|
Guid Id,
|
||||||
|
string TaskNumber,
|
||||||
|
string Name,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName);
|
||||||
|
|
||||||
|
public sealed record TeacherEvaluationDimensionResult(
|
||||||
|
Guid DimensionId,
|
||||||
|
string DimensionName,
|
||||||
|
int MaxScore,
|
||||||
|
double AverageScore,
|
||||||
|
decimal TotalScore,
|
||||||
|
int Count);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
|
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.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 Jiaowu.Api.Infrastructure.Teaching;
|
||||||
@@ -38,8 +41,15 @@ public sealed class ExperimentGradesController(
|
|||||||
public async Task<ActionResult> GetManagement(
|
public async Task<ActionResult> GetManagement(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
ExperimentGradeSheetStatus? status,
|
ExperimentGradeSheetStatus? status,
|
||||||
CancellationToken cancellationToken)
|
Guid? collegeId = null,
|
||||||
|
string? keyword = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||||
|
keyword = Normalize(keyword);
|
||||||
var source = ScopedProjects().AsNoTracking()
|
var source = ScopedProjects().AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.Status == ExperimentProjectStatus.Published ||
|
x.Status == ExperimentProjectStatus.Published ||
|
||||||
@@ -51,10 +61,43 @@ public sealed class ExperimentGradesController(
|
|||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.GradeSheet != null &&
|
x.GradeSheet != null &&
|
||||||
x.GradeSheet.Status == status.Value);
|
x.GradeSheet.Status == status.Value);
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.TeachingTask!.Course!.CollegeId == collegeId.Value);
|
||||||
|
if (keyword is not null)
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.Code.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Name.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Course.Name.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Teachers.Any(item =>
|
||||||
|
item.Teacher!.TeacherNumber.Contains(keyword) ||
|
||||||
|
item.Teacher.Name.Contains(keyword)));
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.Select(x => x.TeachingTaskId)
|
||||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
.Distinct()
|
||||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
.CountAsync(cancellationToken);
|
||||||
|
var taskIds = await source
|
||||||
|
.GroupBy(x => new
|
||||||
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
|
StartDate = x.TeachingTask!.AcademicTerm!.StartDate,
|
||||||
|
x.TeachingTask.TaskNumber
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Key.StartDate)
|
||||||
|
.ThenBy(x => x.Key.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => x.Key.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var projects = await source
|
||||||
|
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.OrderBy(x => x.ScheduleWeek ?? int.MaxValue)
|
||||||
|
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.DayOfWeek)
|
||||||
|
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.StartPeriod)
|
||||||
|
.ThenBy(x => x.StartDate)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -62,6 +105,7 @@ public sealed class ExperimentGradesController(
|
|||||||
x.Code,
|
x.Code,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
|
x.ScheduleWeek,
|
||||||
ProjectStatus = x.Status,
|
ProjectStatus = x.Status,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
@@ -74,6 +118,14 @@ public sealed class ExperimentGradesController(
|
|||||||
TeacherNames = x.TeachingTask.Teachers
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
.Select(item => item.Teacher!.Name),
|
.Select(item => item.Teacher!.Name),
|
||||||
|
ScheduleEntry = x.ScheduleEntry == null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
x.ScheduleEntry.DayOfWeek,
|
||||||
|
x.ScheduleEntry.StartPeriod,
|
||||||
|
x.ScheduleEntry.PeriodCount
|
||||||
|
},
|
||||||
Sheet = x.GradeSheet == null
|
Sheet = x.GradeSheet == null
|
||||||
? null
|
? null
|
||||||
: new
|
: new
|
||||||
@@ -96,7 +148,40 @@ public sealed class ExperimentGradesController(
|
|||||||
x.GradeSheet.PublishedAt
|
x.GradeSheet.PublishedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
var order = taskIds.Select((id, index) => new { id, index })
|
||||||
|
.ToDictionary(x => x.id, x => x.index);
|
||||||
|
var items = projects.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.OrderBy(group => order[group.Key])
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
TeachingTaskId = group.Key,
|
||||||
|
first.TaskNumber,
|
||||||
|
first.TaskName,
|
||||||
|
first.AcademicTermId,
|
||||||
|
first.TermName,
|
||||||
|
first.CourseCode,
|
||||||
|
first.CourseName,
|
||||||
|
first.CollegeName,
|
||||||
|
first.TeacherNames,
|
||||||
|
ProjectCount = group.Count(),
|
||||||
|
SheetCount = group.Count(project => project.Sheet != null),
|
||||||
|
ScoredCount = group.Sum(project => project.Sheet?.ScoredCount ?? 0),
|
||||||
|
StudentCount = group.Sum(project => project.Sheet?.StudentCount ?? 0),
|
||||||
|
Projects = group.ToList()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Total = total,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
@@ -107,7 +192,7 @@ public sealed class ExperimentGradesController(
|
|||||||
if (student is null)
|
if (student is null)
|
||||||
return ConflictProblem("当前账号未关联有效学生档案。");
|
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||||
|
|
||||||
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
|
var results = await db.ExperimentGradeRecords.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.StudentId == student.Id &&
|
x.StudentId == student.Id &&
|
||||||
x.ExperimentGradeSheet!.Status ==
|
x.ExperimentGradeSheet!.Status ==
|
||||||
@@ -126,12 +211,19 @@ public sealed class ExperimentGradesController(
|
|||||||
ProjectName =
|
ProjectName =
|
||||||
x.ExperimentGradeSheet.ExperimentProject.Name,
|
x.ExperimentGradeSheet.ExperimentProject.Name,
|
||||||
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
|
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
|
||||||
|
TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTaskId,
|
||||||
|
AcademicTermId = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTask!.AcademicTermId,
|
||||||
|
TaskNumber = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTask.TaskNumber,
|
||||||
CourseCode = x.ExperimentGradeSheet.ExperimentProject
|
CourseCode = x.ExperimentGradeSheet.ExperimentProject
|
||||||
.TeachingTask!.Course!.Code,
|
.TeachingTask!.Course!.Code,
|
||||||
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
||||||
.TeachingTask.Course.Name,
|
.TeachingTask.Course.Name,
|
||||||
TermName = x.ExperimentGradeSheet.ExperimentProject
|
TermName = x.ExperimentGradeSheet.ExperimentProject
|
||||||
.TeachingTask.AcademicTerm!.Name,
|
.TeachingTask.AcademicTerm!.Name,
|
||||||
|
x.ExperimentGradeSheet.ContributionWeight,
|
||||||
x.ExperimentGradeSheet.PassScore,
|
x.ExperimentGradeSheet.PassScore,
|
||||||
x.ParticipationStatus,
|
x.ParticipationStatus,
|
||||||
x.TotalScore,
|
x.TotalScore,
|
||||||
@@ -156,7 +248,40 @@ public sealed class ExperimentGradesController(
|
|||||||
}),
|
}),
|
||||||
x.ExperimentGradeSheet.PublishedAt
|
x.ExperimentGradeSheet.PublishedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray();
|
||||||
|
var aggregates = await db.ExperimentCourseGrades.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||||
|
var courses = results
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
aggregates.TryGetValue(group.Key, out var aggregate);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
TeachingTaskId = group.Key,
|
||||||
|
first.AcademicTermId,
|
||||||
|
first.TermName,
|
||||||
|
first.TaskNumber,
|
||||||
|
first.CourseCode,
|
||||||
|
first.CourseName,
|
||||||
|
ExperimentCourseScore = aggregate?.WeightedAverageScore,
|
||||||
|
PublishedProjectCount =
|
||||||
|
aggregate?.PublishedProjectCount ?? group.Count(),
|
||||||
|
TotalWeight = aggregate?.TotalWeight ??
|
||||||
|
group.Sum(x => x.ContributionWeight),
|
||||||
|
RefreshedAt = aggregate?.RefreshedAt,
|
||||||
|
Projects = group.OrderBy(x => x.ProjectCode).ToList()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Projects.Max(project => project.PublishedAt))
|
||||||
|
.ThenBy(x => x.CourseCode)
|
||||||
|
.ToList();
|
||||||
|
return Ok(courses);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets")]
|
[HttpPost("sheets")]
|
||||||
@@ -244,6 +369,15 @@ public sealed class ExperimentGradesController(
|
|||||||
ProjectCode = x.ExperimentProject!.Code,
|
ProjectCode = x.ExperimentProject!.Code,
|
||||||
ProjectName = x.ExperimentProject.Name,
|
ProjectName = x.ExperimentProject.Name,
|
||||||
x.ExperimentProject.ArrangementMode,
|
x.ExperimentProject.ArrangementMode,
|
||||||
|
x.ExperimentProject.ScheduleWeek,
|
||||||
|
ScheduleEntry = x.ExperimentProject.ScheduleEntry == null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
x.ExperimentProject.ScheduleEntry.DayOfWeek,
|
||||||
|
x.ExperimentProject.ScheduleEntry.StartPeriod,
|
||||||
|
x.ExperimentProject.ScheduleEntry.PeriodCount
|
||||||
|
},
|
||||||
x.ExperimentProject.TeachingTaskId,
|
x.ExperimentProject.TeachingTaskId,
|
||||||
x.ExperimentProject.TeachingTask!.TaskNumber,
|
x.ExperimentProject.TeachingTask!.TaskNumber,
|
||||||
TaskName = x.ExperimentProject.TeachingTask.Name,
|
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||||
@@ -285,6 +419,14 @@ public sealed class ExperimentGradesController(
|
|||||||
|
|
||||||
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
||||||
.Where(x => x.ExperimentGradeSheetId == id);
|
.Where(x => x.ExperimentGradeSheetId == id);
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var isBoundInstructor = scope.Scope == DataScope.Self &&
|
||||||
|
scope.IsInRole(SystemRoles.Teacher) &&
|
||||||
|
sheet.ArrangementMode == ExperimentArrangementMode.SelfScheduled;
|
||||||
|
if (isBoundInstructor)
|
||||||
|
recordsSource = recordsSource.Where(x =>
|
||||||
|
x.ExperimentSession != null && x.ExperimentSession.Instructors.Any(
|
||||||
|
instructor => instructor.Teacher!.UserId == scope.UserId));
|
||||||
if (studentKeyword is not null)
|
if (studentKeyword is not null)
|
||||||
recordsSource = recordsSource.Where(x =>
|
recordsSource = recordsSource.Where(x =>
|
||||||
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||||
@@ -378,7 +520,7 @@ public sealed class ExperimentGradesController(
|
|||||||
sheet.CreatedAt,
|
sheet.CreatedAt,
|
||||||
sheet.UpdatedAt
|
sheet.UpdatedAt
|
||||||
},
|
},
|
||||||
CanEdit = CanEdit(task) &&
|
CanEdit = (CanEdit(task) || isBoundInstructor) &&
|
||||||
sheet.Status is
|
sheet.Status is
|
||||||
ExperimentGradeSheetStatus.Draft or
|
ExperimentGradeSheetStatus.Draft or
|
||||||
ExperimentGradeSheetStatus.Returned,
|
ExperimentGradeSheetStatus.Returned,
|
||||||
@@ -450,13 +592,18 @@ public sealed class ExperimentGradesController(
|
|||||||
.Include(x => x.Items)
|
.Include(x => x.Items)
|
||||||
.Include(x => x.Records)
|
.Include(x => x.Records)
|
||||||
.ThenInclude(x => x.ItemScores)
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ExperimentSession)
|
||||||
|
.ThenInclude(x => x!.Instructors)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
.ThenInclude(x => x.Teacher)
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!) &&
|
||||||
|
!CanEditBoundSessionRecords(sheet, request.Records)) return Forbid();
|
||||||
if (sheet.Status is not (
|
if (sheet.Status is not (
|
||||||
ExperimentGradeSheetStatus.Draft or
|
ExperimentGradeSheetStatus.Draft or
|
||||||
ExperimentGradeSheetStatus.Returned))
|
ExperimentGradeSheetStatus.Returned))
|
||||||
@@ -502,6 +649,166 @@ public sealed class ExperimentGradesController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/template")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<IActionResult> DownloadTemplate(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
|
||||||
|
var headers = ExperimentImportHeaders(sheet.Items);
|
||||||
|
var rows = sheet.Records
|
||||||
|
.OrderBy(record => record.Student!.StudentNumber)
|
||||||
|
.Select(record =>
|
||||||
|
{
|
||||||
|
var values = new List<object?>
|
||||||
|
{
|
||||||
|
record.Student!.StudentNumber,
|
||||||
|
record.Student.Name,
|
||||||
|
record.Student.AdministrativeClass!.Name,
|
||||||
|
ParticipationLabel(record.ParticipationStatus)
|
||||||
|
};
|
||||||
|
foreach (var item in sheet.Items.OrderBy(item => item.SortOrder))
|
||||||
|
values.Add(record.ItemScores.FirstOrDefault(score =>
|
||||||
|
score.ExperimentGradeItemId == item.Id)?.Score);
|
||||||
|
values.Add(record.SafetyViolation);
|
||||||
|
values.Add(record.AttemptNumber);
|
||||||
|
values.Add(null);
|
||||||
|
values.Add(record.TeacherComment);
|
||||||
|
return (IReadOnlyList<object?>)values;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var itemCount = sheet.Items.Count;
|
||||||
|
var totalColumn = 7 + itemCount;
|
||||||
|
var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
|
||||||
|
var instructions = new List<string>
|
||||||
|
{
|
||||||
|
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。",
|
||||||
|
"参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。",
|
||||||
|
"评分项填写 0—100 的数值,留空表示暂未录入。",
|
||||||
|
"安全违规填写 是 或 否;实验次数填写 1—20。",
|
||||||
|
"实验总评(自动计算)仅供 Excel 预览;上传时系统会按评分项、参与状态和安全违规重新计算。",
|
||||||
|
$"本实验共 {itemCount} 个评分项:{string.Join("、", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。"
|
||||||
|
};
|
||||||
|
var bytes = ExcelWorkbookHelper.Create(
|
||||||
|
"实验成绩导入", headers, rows, instructions,
|
||||||
|
(worksheet, rowNumber) =>
|
||||||
|
{
|
||||||
|
var itemReferences = Enumerable.Range(5, itemCount)
|
||||||
|
.Select(column => $"{ColumnLetter(column)}{rowNumber}")
|
||||||
|
.ToArray();
|
||||||
|
var weightedExpression = string.Join("+", sheet.Items
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.Select((item, index) =>
|
||||||
|
$"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
|
||||||
|
var statusReference = $"D{rowNumber}";
|
||||||
|
var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}";
|
||||||
|
worksheet.Cell(rowNumber, totalColumn).FormulaA1 =
|
||||||
|
$"=IF(OR({statusReference}=\"缺席\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))";
|
||||||
|
var totalCell = worksheet.Cell(rowNumber, totalColumn);
|
||||||
|
totalCell.Style.NumberFormat.Format = "0.0";
|
||||||
|
totalCell.Style.Font.Bold = true;
|
||||||
|
totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
|
||||||
|
foreach (var column in scoreColumns)
|
||||||
|
{
|
||||||
|
var format = worksheet.Range(rowNumber, column, rowNumber, column)
|
||||||
|
.AddConditionalFormat().WhenLessThan(60);
|
||||||
|
format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
|
||||||
|
format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||||
|
$"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/import")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<ActionResult> ImportRecords(
|
||||||
|
Guid id,
|
||||||
|
IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("只有录入中或已退回实验成绩单可以导入成绩。");
|
||||||
|
|
||||||
|
var headers = ExperimentImportHeaders(sheet.Items);
|
||||||
|
IReadOnlyList<ExcelRow> rows;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = await ExcelWorkbookHelper.ReadAsync(file,
|
||||||
|
headers.Where(header => header != "实验总评(自动计算)").ToArray(),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException exception)
|
||||||
|
{
|
||||||
|
return ValidationProblem(exception.Message);
|
||||||
|
}
|
||||||
|
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。");
|
||||||
|
|
||||||
|
var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var items = sheet.Items.OrderBy(item => item.SortOrder).ToList();
|
||||||
|
var errors = new List<string>();
|
||||||
|
var updated = 0;
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var studentNumber = row["学号"];
|
||||||
|
if (string.IsNullOrWhiteSpace(studentNumber))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!records.TryGetValue(studentNumber, out var record))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本实验成绩单中。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:{participationError}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TryParseYesNo(row["安全违规"], out var safetyViolation))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“安全违规”请填写是或否。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20)
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“实验次数”请填写 1—20 的整数。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var scores = new List<decimal?>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors);
|
||||||
|
if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break;
|
||||||
|
scores.Add(score);
|
||||||
|
}
|
||||||
|
if (scores.Count != items.Count) continue;
|
||||||
|
|
||||||
|
record.ParticipationStatus = participationStatus;
|
||||||
|
record.SafetyViolation = safetyViolation;
|
||||||
|
record.AttemptNumber = attemptNumber;
|
||||||
|
record.TeacherComment = Normalize(row["教师评语"]);
|
||||||
|
var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId);
|
||||||
|
for (var index = 0; index < items.Count; index++)
|
||||||
|
scoreMap[items[index].Id].Score = scores[index];
|
||||||
|
Recalculate(sheet, record);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new { Updated = updated, Total = rows.Count });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/sync-participants")]
|
[HttpPost("sheets/{id:guid}/sync-participants")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> SyncParticipants(
|
public async Task<ActionResult> SyncParticipants(
|
||||||
@@ -713,7 +1020,15 @@ public sealed class ExperimentGradesController(
|
|||||||
|
|
||||||
sheet.Status = ExperimentGradeSheetStatus.Published;
|
sheet.Status = ExperimentGradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.ExecuteInRetriableTransactionAsync(async transaction =>
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
|
||||||
|
db,
|
||||||
|
sheet.ExperimentProject!.TeachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}, cancellationToken, IsolationLevel.Serializable);
|
||||||
var userIds = await db.ExperimentGradeRecords
|
var userIds = await db.ExperimentGradeRecords
|
||||||
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
||||||
.Select(x => x.Student!.UserId)
|
.Select(x => x.Student!.UserId)
|
||||||
@@ -747,9 +1062,29 @@ public sealed class ExperimentGradesController(
|
|||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ExperimentGradeSheet?> LoadEditableSheetAsync(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ScopedSheets()
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.Student)
|
||||||
|
.ThenInclude(x => x!.AdministrativeClass)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
|
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
|
||||||
ExperimentProject project,
|
ExperimentProject project,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -816,6 +1151,15 @@ public sealed class ExperimentGradesController(
|
|||||||
|
|
||||||
private IQueryable<ExperimentProject> ScopedProjects()
|
private IQueryable<ExperimentProject> ScopedProjects()
|
||||||
{
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
return db.ExperimentProjects.Where(x =>
|
||||||
|
(x.ArrangementMode == ExperimentArrangementMode.Centralized &&
|
||||||
|
x.TeachingTask!.Teachers.Any(item =>
|
||||||
|
item.Teacher!.UserId == scope.UserId)) ||
|
||||||
|
(x.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
x.Sessions.Any(session => session.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher!.UserId == scope.UserId))));
|
||||||
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
||||||
return db.ExperimentProjects.Where(x =>
|
return db.ExperimentProjects.Where(x =>
|
||||||
taskIds.Contains(x.TeachingTaskId));
|
taskIds.Contains(x.TeachingTaskId));
|
||||||
@@ -838,6 +1182,22 @@ public sealed class ExperimentGradesController(
|
|||||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
IsAssignedTeacher(task);
|
IsAssignedTeacher(task);
|
||||||
|
|
||||||
|
private bool CanEditBoundSessionRecords(
|
||||||
|
ExperimentGradeSheet sheet,
|
||||||
|
IReadOnlyCollection<ExperimentGradeRecordRequest> requestedRecords)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled ||
|
||||||
|
scope.Scope != DataScope.Self || !scope.IsInRole(SystemRoles.Teacher) ||
|
||||||
|
requestedRecords.Count == 0)
|
||||||
|
return false;
|
||||||
|
var requestedIds = requestedRecords.Select(x => x.Id).ToHashSet();
|
||||||
|
return requestedIds.All(recordId => sheet.Records.Any(record =>
|
||||||
|
record.Id == recordId &&
|
||||||
|
record.ExperimentSession?.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher?.UserId == scope.UserId) == true));
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsAssignedTeacher(TeachingTask task) =>
|
private bool IsAssignedTeacher(TeachingTask task) =>
|
||||||
task.Teachers.Any(x =>
|
task.Teachers.Any(x =>
|
||||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||||
@@ -890,6 +1250,93 @@ public sealed class ExperimentGradesController(
|
|||||||
private static bool ValidScore(decimal? score) =>
|
private static bool ValidScore(decimal? score) =>
|
||||||
!score.HasValue || score.Value is >= 0 and <= 100;
|
!score.HasValue || score.Value is >= 0 and <= 100;
|
||||||
|
|
||||||
|
private static List<string> ExperimentImportHeaders(
|
||||||
|
IEnumerable<ExperimentGradeItem> items)
|
||||||
|
{
|
||||||
|
var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
|
||||||
|
headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name));
|
||||||
|
headers.AddRange(["安全违规", "实验次数", "实验总评(自动计算)", "教师评语"]);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParticipationLabel(
|
||||||
|
ExperimentParticipationStatus status) => status switch
|
||||||
|
{
|
||||||
|
ExperimentParticipationStatus.Pending => "待登记",
|
||||||
|
ExperimentParticipationStatus.Completed => "已完成",
|
||||||
|
ExperimentParticipationStatus.Absent => "缺席",
|
||||||
|
ExperimentParticipationStatus.Excused => "请假",
|
||||||
|
ExperimentParticipationStatus.Makeup => "补做",
|
||||||
|
ExperimentParticipationStatus.Exempt => "免做",
|
||||||
|
_ => "待登记"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool TryParseParticipationStatus(
|
||||||
|
ExcelRow row,
|
||||||
|
out ExperimentParticipationStatus status,
|
||||||
|
out string error)
|
||||||
|
{
|
||||||
|
error = string.Empty;
|
||||||
|
switch (row["参与状态"])
|
||||||
|
{
|
||||||
|
case "待登记": status = ExperimentParticipationStatus.Pending; return true;
|
||||||
|
case "已完成": status = ExperimentParticipationStatus.Completed; return true;
|
||||||
|
case "缺席": status = ExperimentParticipationStatus.Absent; return true;
|
||||||
|
case "请假": status = ExperimentParticipationStatus.Excused; return true;
|
||||||
|
case "补做": status = ExperimentParticipationStatus.Makeup; return true;
|
||||||
|
case "免做": status = ExperimentParticipationStatus.Exempt; return true;
|
||||||
|
default:
|
||||||
|
status = default;
|
||||||
|
error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseYesNo(string value, out bool result)
|
||||||
|
{
|
||||||
|
if (value == "是") { result = true; return true; }
|
||||||
|
if (value == "否") { result = false; return true; }
|
||||||
|
result = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ParseOptionalDecimal(
|
||||||
|
ExcelRow row,
|
||||||
|
string header,
|
||||||
|
decimal minimum,
|
||||||
|
decimal maximum,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
var value = row[header];
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
if (decimal.TryParse(value, NumberStyles.Number,
|
||||||
|
CultureInfo.InvariantCulture, out var result) &&
|
||||||
|
result >= minimum && result <= maximum)
|
||||||
|
return result;
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||||
|
{
|
||||||
|
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
||||||
|
if (errors.Count > 50)
|
||||||
|
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
|
||||||
|
return ValidationProblem(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ColumnLetter(int column)
|
||||||
|
{
|
||||||
|
var result = string.Empty;
|
||||||
|
while (column > 0)
|
||||||
|
{
|
||||||
|
column--;
|
||||||
|
result = (char)('A' + column % 26) + result;
|
||||||
|
column /= 26;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
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.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Experiments;
|
using Jiaowu.Api.Infrastructure.Experiments;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -31,12 +33,21 @@ public sealed class ExperimentsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetOptions(
|
public async Task<ActionResult> GetOptions(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
|
Guid? offeringCollegeId,
|
||||||
|
string? courseKeyword,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
||||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (offeringCollegeId.HasValue)
|
||||||
|
tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId);
|
||||||
|
var normalizedCourseKeyword = Normalize(courseKeyword);
|
||||||
|
if (normalizedCourseKeyword is not null)
|
||||||
|
tasks = tasks.Where(x =>
|
||||||
|
x.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||||||
|
x.Course.Name.Contains(normalizedCourseKeyword));
|
||||||
|
|
||||||
var periods = db.ScheduleTimeSlots.AsNoTracking()
|
var periods = db.ScheduleTimeSlots.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled);
|
.Where(x => x.IsEnabled);
|
||||||
@@ -76,6 +87,7 @@ public sealed class ExperimentsController(
|
|||||||
x.Name,
|
x.Name,
|
||||||
x.AcademicTermId,
|
x.AcademicTermId,
|
||||||
x.CourseId,
|
x.CourseId,
|
||||||
|
x.SchedulingMode,
|
||||||
TermName = x.AcademicTerm!.Name,
|
TermName = x.AcademicTerm!.Name,
|
||||||
TermStartDate = x.AcademicTerm.StartDate,
|
TermStartDate = x.AcademicTerm.StartDate,
|
||||||
TermEndDate = x.AcademicTerm.EndDate,
|
TermEndDate = x.AcademicTerm.EndDate,
|
||||||
@@ -90,6 +102,13 @@ public sealed class ExperimentsController(
|
|||||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
.Select(item => item.AdministrativeClass!.Name)
|
.Select(item => item.AdministrativeClass!.Name)
|
||||||
})
|
})
|
||||||
|
.Take(200)
|
||||||
|
.ToListAsync(cancellationToken),
|
||||||
|
Colleges = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value)
|
||||||
|
.Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name })
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x.CollegeName)
|
||||||
.ToListAsync(cancellationToken),
|
.ToListAsync(cancellationToken),
|
||||||
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
@@ -99,6 +118,11 @@ public sealed class ExperimentsController(
|
|||||||
.Contains(x.TeachingTaskId))
|
.Contains(x.TeachingTaskId))
|
||||||
.Where(x => !academicTermId.HasValue ||
|
.Where(x => !academicTermId.HasValue ||
|
||||||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
|
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
|
||||||
|
.Where(x => !offeringCollegeId.HasValue ||
|
||||||
|
x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value)
|
||||||
|
.Where(x => normalizedCourseKeyword == null ||
|
||||||
|
x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||||||
|
x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword))
|
||||||
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
||||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||||
.ThenBy(x => x.DayOfWeek)
|
.ThenBy(x => x.DayOfWeek)
|
||||||
@@ -116,10 +140,14 @@ public sealed class ExperimentsController(
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeekPattern,
|
x.WeekPattern,
|
||||||
|
ClassNames = x.TeachingTask!.Classes
|
||||||
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
|
.Select(item => item.AdministrativeClass!.Name),
|
||||||
ClassroomName = x.Classroom!.Name,
|
ClassroomName = x.Classroom!.Name,
|
||||||
BuildingName = x.Classroom.Building!.Name,
|
BuildingName = x.Classroom.Building!.Name,
|
||||||
CampusName = x.Classroom.Building.Campus!.Name
|
CampusName = x.Classroom.Building.Campus!.Name
|
||||||
})
|
})
|
||||||
|
.Take(500)
|
||||||
.ToListAsync(cancellationToken),
|
.ToListAsync(cancellationToken),
|
||||||
Classrooms = await db.Classrooms.AsNoTracking()
|
Classrooms = await db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled)
|
||||||
@@ -136,6 +164,19 @@ public sealed class ExperimentsController(
|
|||||||
,x.TeachingVenueNature
|
,x.TeachingVenueNature
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken),
|
.ToListAsync(cancellationToken),
|
||||||
|
Teachers = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.SelectMany(x => x.Teachers)
|
||||||
|
.Where(x => x.Teacher != null && x.Teacher.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeacherId,
|
||||||
|
TeacherNumber = x.Teacher!.TeacherNumber,
|
||||||
|
TeacherName = x.Teacher.Name
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x.TeacherNumber)
|
||||||
|
.ToListAsync(cancellationToken),
|
||||||
Periods = periodItems
|
Periods = periodItems
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -146,9 +187,21 @@ public sealed class ExperimentsController(
|
|||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
ExperimentArrangementMode? arrangementMode,
|
ExperimentArrangementMode? arrangementMode,
|
||||||
ExperimentProjectStatus? status,
|
ExperimentProjectStatus? status,
|
||||||
CancellationToken cancellationToken)
|
Guid? offeringCollegeId,
|
||||||
|
string? courseKeyword,
|
||||||
|
string? classKeyword,
|
||||||
|
string? teacherKeyword,
|
||||||
|
string? taskKeyword,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20)
|
||||||
{
|
{
|
||||||
var source = ScopedProjects().AsNoTracking();
|
var source = ScopedProjects().AsNoTracking();
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
source = source.Where(x => x.Sessions.Any(session =>
|
||||||
|
session.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher!.UserId == scope.UserId)));
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||||
@@ -158,7 +211,47 @@ public sealed class ExperimentsController(
|
|||||||
if (status.HasValue)
|
if (status.HasValue)
|
||||||
source = source.Where(x => x.Status == status);
|
source = source.Where(x => x.Status == status);
|
||||||
|
|
||||||
return Ok(await source
|
var normalizedCourseKeyword = Normalize(courseKeyword);
|
||||||
|
var normalizedClassKeyword = Normalize(classKeyword);
|
||||||
|
var normalizedTeacherKeyword = Normalize(teacherKeyword);
|
||||||
|
var normalizedTaskKeyword = Normalize(taskKeyword);
|
||||||
|
var taskSource = AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id));
|
||||||
|
if (academicTermId.HasValue)
|
||||||
|
taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (offeringCollegeId.HasValue)
|
||||||
|
taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId);
|
||||||
|
if (normalizedCourseKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x =>
|
||||||
|
x.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||||||
|
x.Course.Name.Contains(normalizedCourseKeyword));
|
||||||
|
if (normalizedClassKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x => x.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) ||
|
||||||
|
item.AdministrativeClass.Name.Contains(normalizedClassKeyword)));
|
||||||
|
if (normalizedTeacherKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x => x.Teachers.Any(item =>
|
||||||
|
item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) ||
|
||||||
|
item.Teacher.Name.Contains(normalizedTeacherKeyword)));
|
||||||
|
if (normalizedTaskKeyword is not null)
|
||||||
|
taskSource = taskSource.Where(x =>
|
||||||
|
x.TaskNumber.Contains(normalizedTaskKeyword) ||
|
||||||
|
x.Name.Contains(normalizedTaskKeyword));
|
||||||
|
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
|
var total = await taskSource.CountAsync(cancellationToken);
|
||||||
|
var taskIds = await taskSource
|
||||||
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var items = await source
|
||||||
|
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||||
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
||||||
.ThenBy(x => x.StartDate)
|
.ThenBy(x => x.StartDate)
|
||||||
.ThenBy(x => x.Code)
|
.ThenBy(x => x.Code)
|
||||||
@@ -167,6 +260,7 @@ public sealed class ExperimentsController(
|
|||||||
x.Id,
|
x.Id,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.ScheduleEntryId,
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek,
|
||||||
x.Code,
|
x.Code,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
@@ -174,6 +268,8 @@ public sealed class ExperimentsController(
|
|||||||
x.Requirements,
|
x.Requirements,
|
||||||
x.StartDate,
|
x.StartDate,
|
||||||
x.EndDate,
|
x.EndDate,
|
||||||
|
x.SelectionStartsAt,
|
||||||
|
x.SelectionEndsAt,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.PublishedAt,
|
x.PublishedAt,
|
||||||
x.ClosedAt,
|
x.ClosedAt,
|
||||||
@@ -197,6 +293,7 @@ public sealed class ExperimentsController(
|
|||||||
x.ScheduleEntry.StartWeek,
|
x.ScheduleEntry.StartWeek,
|
||||||
x.ScheduleEntry.EndWeek,
|
x.ScheduleEntry.EndWeek,
|
||||||
x.ScheduleEntry.WeekPattern,
|
x.ScheduleEntry.WeekPattern,
|
||||||
|
ProjectWeek = x.ScheduleWeek,
|
||||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||||
@@ -218,9 +315,19 @@ public sealed class ExperimentsController(
|
|||||||
ClassroomName = item.Classroom!.Name,
|
ClassroomName = item.Classroom!.Name,
|
||||||
BuildingName = item.Classroom.Building!.Name,
|
BuildingName = item.Classroom.Building!.Name,
|
||||||
CampusName = item.Classroom.Building.Campus!.Name
|
CampusName = item.Classroom.Building.Campus!.Name
|
||||||
|
,InstructorNames = item.Instructors
|
||||||
|
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
|
||||||
|
.Select(instructor => instructor.Teacher!.Name)
|
||||||
|
,InstructorTeacherIds = item.Instructors
|
||||||
|
.Select(instructor => instructor.TeacherId)
|
||||||
|
,CanManage = scope.Scope != DataScope.Self ||
|
||||||
|
item.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher!.UserId == scope.UserId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("student")]
|
[HttpGet("student")]
|
||||||
@@ -254,10 +361,13 @@ public sealed class ExperimentsController(
|
|||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
x.ScheduleEntryId,
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek,
|
||||||
x.Description,
|
x.Description,
|
||||||
x.Requirements,
|
x.Requirements,
|
||||||
x.StartDate,
|
x.StartDate,
|
||||||
x.EndDate,
|
x.EndDate,
|
||||||
|
x.SelectionStartsAt,
|
||||||
|
x.SelectionEndsAt,
|
||||||
x.Status,
|
x.Status,
|
||||||
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
||||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||||
@@ -275,6 +385,7 @@ public sealed class ExperimentsController(
|
|||||||
x.ScheduleEntry.StartWeek,
|
x.ScheduleEntry.StartWeek,
|
||||||
x.ScheduleEntry.EndWeek,
|
x.ScheduleEntry.EndWeek,
|
||||||
x.ScheduleEntry.WeekPattern,
|
x.ScheduleEntry.WeekPattern,
|
||||||
|
ProjectWeek = x.ScheduleWeek,
|
||||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||||
@@ -296,6 +407,9 @@ public sealed class ExperimentsController(
|
|||||||
ClassroomName = item.Classroom!.Name,
|
ClassroomName = item.Classroom!.Name,
|
||||||
BuildingName = item.Classroom.Building!.Name,
|
BuildingName = item.Classroom.Building!.Name,
|
||||||
CampusName = item.Classroom.Building.Campus!.Name
|
CampusName = item.Classroom.Building.Campus!.Name
|
||||||
|
,InstructorNames = item.Instructors
|
||||||
|
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
|
||||||
|
.Select(instructor => instructor.Teacher!.Name)
|
||||||
}),
|
}),
|
||||||
MyBooking = x.Bookings
|
MyBooking = x.Bookings
|
||||||
.Where(item =>
|
.Where(item =>
|
||||||
@@ -350,7 +464,9 @@ public sealed class ExperimentsController(
|
|||||||
Description = Normalize(request.Description),
|
Description = Normalize(request.Description),
|
||||||
Requirements = Normalize(request.Requirements),
|
Requirements = Normalize(request.Requirements),
|
||||||
StartDate = request.StartDate,
|
StartDate = request.StartDate,
|
||||||
EndDate = request.EndDate
|
EndDate = request.EndDate,
|
||||||
|
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
||||||
|
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
||||||
};
|
};
|
||||||
db.ExperimentProjects.Add(project);
|
db.ExperimentProjects.Add(project);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -363,8 +479,6 @@ public sealed class ExperimentsController(
|
|||||||
ExperimentProjectBatchRequest request,
|
ExperimentProjectBatchRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.ArrangementMode == ExperimentArrangementMode.Centralized)
|
|
||||||
return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。");
|
|
||||||
var taskIds = request.TeachingTaskIds
|
var taskIds = request.TeachingTaskIds
|
||||||
.Where(x => x != Guid.Empty)
|
.Where(x => x != Guid.Empty)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -384,21 +498,72 @@ public sealed class ExperimentsController(
|
|||||||
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
||||||
|
|
||||||
var first = tasks[0];
|
var first = tasks[0];
|
||||||
if (tasks.Any(x =>
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
|
||||||
x.AcademicTermId != first.AcademicTermId ||
|
x.AcademicTermId != first.AcademicTermId ||
|
||||||
x.CourseId != first.CourseId))
|
x.CourseId != first.CourseId))
|
||||||
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||||||
|
|
||||||
var code = request.Code.Trim();
|
var code = request.Code.Trim();
|
||||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
{
|
||||||
.Where(x => x.Code == code)
|
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||||
.Select(x => x.TeachingTask!.TaskNumber)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.OrderBy(x => x)
|
.Where(x => x.Code == code)
|
||||||
.ToListAsync(cancellationToken);
|
.Select(x => x.TeachingTask!.TaskNumber)
|
||||||
if (conflictingTaskNumbers.Count > 0)
|
.OrderBy(x => x)
|
||||||
return ConflictProblem(
|
.ToListAsync(cancellationToken);
|
||||||
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
if (conflictingTaskNumbers.Count > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
|
x.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
x.ClassroomId.HasValue &&
|
||||||
|
taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.ThenBy(x => x.DayOfWeek)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [];
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.Centralized &&
|
||||||
|
tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id)))
|
||||||
|
return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课,请先完成课表安排。");
|
||||||
|
|
||||||
|
var scheduledOccurrences = scheduledEntries
|
||||||
|
.SelectMany(entry => Enumerable.Range(
|
||||||
|
entry.StartWeek,
|
||||||
|
entry.EndWeek - entry.StartWeek + 1)
|
||||||
|
.Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week))
|
||||||
|
.Select(week => (Entry: entry, Week: week)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var legacyProjects = scheduledEntries.Count > 0
|
||||||
|
? await db.ExperimentProjects
|
||||||
|
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
|
||||||
|
x.ScheduleWeek == null &&
|
||||||
|
taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [];
|
||||||
|
if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft))
|
||||||
|
return ConflictProblem("存在旧版已发布实验项目,不能自动拆分为每周项目;请先关闭后重新设置。");
|
||||||
|
|
||||||
|
if (scheduledOccurrences.Count > 0)
|
||||||
|
{
|
||||||
|
var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList();
|
||||||
|
var existingOccurrences = await db.ExperimentProjects.AsNoTracking()
|
||||||
|
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
|
||||||
|
x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value))
|
||||||
|
.Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingKeys = existingOccurrences
|
||||||
|
.Select(x => (x.ScheduleEntryId, x.ScheduleWeek))
|
||||||
|
.ToHashSet();
|
||||||
|
if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week))))
|
||||||
|
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
|
||||||
|
}
|
||||||
|
|
||||||
var projects = new List<ExperimentProject>(tasks.Count);
|
var projects = new List<ExperimentProject>(tasks.Count);
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
@@ -407,17 +572,65 @@ public sealed class ExperimentsController(
|
|||||||
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||||||
if (problem is not null) return ValidationProblem(problem);
|
if (problem is not null) return ValidationProblem(problem);
|
||||||
|
|
||||||
projects.Add(new ExperimentProject
|
IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences =
|
||||||
|
request.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? scheduledOccurrences
|
||||||
|
.Where(item => item.Entry.TeachingTaskId == task.Id)
|
||||||
|
.Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week))
|
||||||
|
: [(null, null)];
|
||||||
|
foreach (var occurrence in taskOccurrences)
|
||||||
{
|
{
|
||||||
TeachingTaskId = task.Id,
|
var scheduleEntry = occurrence.Entry;
|
||||||
Code = code,
|
var legacy = scheduleEntry is null ? null : legacyProjects
|
||||||
Name = request.Name.Trim(),
|
.SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
|
||||||
ArrangementMode = request.ArrangementMode,
|
if (legacy is not null)
|
||||||
Description = Normalize(request.Description),
|
{
|
||||||
Requirements = Normalize(request.Requirements),
|
var firstWeek = scheduledOccurrences
|
||||||
StartDate = request.StartDate,
|
.Where(item => item.Entry.Id == scheduleEntry!.Id)
|
||||||
EndDate = request.EndDate
|
.Min(item => item.Week);
|
||||||
});
|
if (occurrence.Week == firstWeek)
|
||||||
|
{
|
||||||
|
legacy.ScheduleWeek = occurrence.Week;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projects.Add(new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
ScheduleEntryId = occurrence.Entry?.Id,
|
||||||
|
ScheduleWeek = occurrence.Week,
|
||||||
|
Code = code,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
ArrangementMode = request.ArrangementMode,
|
||||||
|
Description = Normalize(request.Description),
|
||||||
|
Requirements = Normalize(request.Requirements),
|
||||||
|
StartDate = request.StartDate,
|
||||||
|
EndDate = request.EndDate,
|
||||||
|
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
||||||
|
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var names = NormalizeBatchProjectNames(request.Names);
|
||||||
|
if (request.GenerateSequentialNames)
|
||||||
|
{
|
||||||
|
if (names is not null)
|
||||||
|
return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。");
|
||||||
|
var width = Math.Max(2, projects.Count.ToString().Length);
|
||||||
|
if (request.Name.Trim().Length + width + 1 > 120)
|
||||||
|
return ValidationProblem("项目名称前缀过长,无法追加顺序号。");
|
||||||
|
for (var index = 0; index < projects.Count; index++)
|
||||||
|
projects[index].Name = $"{request.Name.Trim()} {(index + 1).ToString($"D{width}")}";
|
||||||
|
}
|
||||||
|
else if (names is not null)
|
||||||
|
{
|
||||||
|
if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
|
||||||
|
return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。");
|
||||||
|
if (names.Count != projects.Count)
|
||||||
|
return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。");
|
||||||
|
for (var index = 0; index < projects.Count; index++)
|
||||||
|
projects[index].Name = names[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
db.ExperimentProjects.AddRange(projects);
|
db.ExperimentProjects.AddRange(projects);
|
||||||
@@ -470,6 +683,8 @@ public sealed class ExperimentsController(
|
|||||||
project.Requirements = Normalize(request.Requirements);
|
project.Requirements = Normalize(request.Requirements);
|
||||||
project.StartDate = request.StartDate;
|
project.StartDate = request.StartDate;
|
||||||
project.EndDate = request.EndDate;
|
project.EndDate = request.EndDate;
|
||||||
|
project.SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt);
|
||||||
|
project.SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -491,6 +706,87 @@ public sealed class ExperimentsController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/published-details")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CorrectPublishedProjectDetails(
|
||||||
|
Guid id,
|
||||||
|
PublishedExperimentProjectCorrectionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var project = await ScopedProjects()
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (project is null) return NotFound();
|
||||||
|
if (project.Status != ExperimentProjectStatus.Published)
|
||||||
|
return ConflictProblem("只有已发布实验项目可以修正教学内容。");
|
||||||
|
if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid();
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
return ValidationProblem("请填写实验项目名称。");
|
||||||
|
|
||||||
|
var changed = project.Name != request.Name.Trim() ||
|
||||||
|
project.Description != Normalize(request.Description) ||
|
||||||
|
project.Requirements != Normalize(request.Requirements);
|
||||||
|
project.Name = request.Name.Trim();
|
||||||
|
project.Description = Normalize(request.Description);
|
||||||
|
project.Requirements = Normalize(request.Requirements);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
if (changed)
|
||||||
|
await NotifyProjectCorrectionAsync(project, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("batch/names")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> UpdateProjectNames(
|
||||||
|
ExperimentProjectBatchNamesRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||||||
|
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||||||
|
|
||||||
|
var names = NormalizeBatchProjectNames(request.Names);
|
||||||
|
if (names is null || names.Count != ids.Count || names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
|
||||||
|
return ValidationProblem("请为每个已选实验项目填写一个名称,每行一个且数量必须一致。");
|
||||||
|
|
||||||
|
var projects = await ScopedProjects()
|
||||||
|
.Where(x => ids.Contains(x.Id))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (projects.Count != ids.Count) return NotFound();
|
||||||
|
if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
|
||||||
|
return ConflictProblem("批量修改名称只能包含草稿实验项目。");
|
||||||
|
|
||||||
|
var projectsById = projects.ToDictionary(x => x.Id);
|
||||||
|
for (var index = 0; index < ids.Count; index++)
|
||||||
|
projectsById[ids[index]].Name = names[index];
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> DeleteProjects(
|
||||||
|
ExperimentProjectBulkRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||||||
|
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||||||
|
var projects = await ScopedProjects()
|
||||||
|
.Where(x => ids.Contains(x.Id))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (projects.Count != ids.Count) return NotFound();
|
||||||
|
if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
|
||||||
|
return ConflictProblem("批量删除只能包含草稿实验项目。");
|
||||||
|
|
||||||
|
db.ExperimentProjects.RemoveRange(projects);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/publish")]
|
[HttpPost("{id:guid}/publish")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> PublishProject(
|
public async Task<ActionResult> PublishProject(
|
||||||
@@ -499,6 +795,7 @@ public sealed class ExperimentsController(
|
|||||||
{
|
{
|
||||||
var project = await ScopedProjects()
|
var project = await ScopedProjects()
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.Instructors)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
@@ -518,35 +815,59 @@ public sealed class ExperimentsController(
|
|||||||
(x.SessionDate < project.StartDate ||
|
(x.SessionDate < project.StartDate ||
|
||||||
x.SessionDate > project.EndDate)))
|
x.SessionDate > project.EndDate)))
|
||||||
return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
|
return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
(project.SelectionStartsAt is null || project.SelectionEndsAt is null ||
|
||||||
|
project.SelectionStartsAt >= project.SelectionEndsAt))
|
||||||
|
return ConflictProblem("请先设置有效的开始选课和截至选课时间。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
x.Instructors.Count == 0))
|
||||||
|
return ConflictProblem("请为每个有效实验场次至少指定一位指导老师。");
|
||||||
|
|
||||||
project.Status = ExperimentProjectStatus.Published;
|
project.Status = ExperimentProjectStatus.Published;
|
||||||
project.PublishedAt = DateTime.UtcNow;
|
project.PublishedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotifyProjectPublishedAsync(project, cancellationToken);
|
||||||
var userIds = await TeachingTaskRosterQuery
|
|
||||||
.ForTask(db, project.TeachingTaskId)
|
|
||||||
.Where(x => x.UserId.HasValue)
|
|
||||||
.Select(x => x.UserId!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
if (userIds.Count > 0)
|
|
||||||
{
|
|
||||||
var mode = project.ArrangementMode ==
|
|
||||||
ExperimentArrangementMode.Centralized
|
|
||||||
? "集中安排"
|
|
||||||
: "自行预约";
|
|
||||||
await NotificationService.SendToUserIdsAsync(
|
|
||||||
db,
|
|
||||||
userIds,
|
|
||||||
"实验项目已发布",
|
|
||||||
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
|
||||||
"/experiments",
|
|
||||||
cancellationToken,
|
|
||||||
NotificationCategory.Schedule);
|
|
||||||
}
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("batch/publish")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> PublishProjects(
|
||||||
|
ExperimentProjectBulkRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||||||
|
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||||||
|
if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count)
|
||||||
|
return NotFound();
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var job = new ExamPublishJob
|
||||||
|
{
|
||||||
|
Kind = ExamPublishJobKind.ExperimentProjects,
|
||||||
|
PlanId = ids[0],
|
||||||
|
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||||
|
ProjectIdsJson = JsonSerializer.Serialize(ids),
|
||||||
|
CurrentStep = "等待后台校验"
|
||||||
|
};
|
||||||
|
db.ExamPublishJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.ExamPublish, job.Id));
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("batch/publish-jobs/{jobId:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetPublishJob(Guid jobId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs.AsNoTracking().FirstOrDefaultAsync(x =>
|
||||||
|
x.Id == jobId && x.Kind == ExamPublishJobKind.ExperimentProjects,
|
||||||
|
cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
return Ok(new { job.Id, job.Status, job.CurrentStep, job.ErrorMessage, job.StartedAt, job.CompletedAt });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/close")]
|
[HttpPost("{id:guid}/close")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> CloseProject(
|
public async Task<ActionResult> CloseProject(
|
||||||
@@ -600,6 +921,10 @@ public sealed class ExperimentsController(
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
|
session.Instructors = request.InstructorTeacherIds!
|
||||||
|
.Distinct()
|
||||||
|
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||||
|
.ToList();
|
||||||
db.ExperimentSessions.Add(session);
|
db.ExperimentSessions.Add(session);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -635,16 +960,14 @@ public sealed class ExperimentsController(
|
|||||||
if (request.Items.Count > 100)
|
if (request.Items.Count > 100)
|
||||||
return Task.FromResult<ActionResult>(
|
return Task.FromResult<ActionResult>(
|
||||||
ValidationProblem("单次最多安排 100 条实验场次。"));
|
ValidationProblem("单次最多安排 100 条实验场次。"));
|
||||||
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
|
if (request.Items.Any(x => x.ProjectId == Guid.Empty))
|
||||||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
|
|
||||||
request.Items.Count)
|
|
||||||
return Task.FromResult<ActionResult>(
|
return Task.FromResult<ActionResult>(
|
||||||
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
|
ValidationProblem("实验项目不能为空。"));
|
||||||
|
|
||||||
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
async transaction =>
|
async transaction =>
|
||||||
{
|
{
|
||||||
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
|
var projectIds = request.Items.Select(x => x.ProjectId).Distinct().ToList();
|
||||||
var projects = await ScopedProjects()
|
var projects = await ScopedProjects()
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.AcademicTerm)
|
.ThenInclude(x => x!.AcademicTerm)
|
||||||
@@ -688,6 +1011,10 @@ public sealed class ExperimentsController(
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
Notes = Normalize(item.Notes)
|
Notes = Normalize(item.Notes)
|
||||||
};
|
};
|
||||||
|
session.Instructors = item.InstructorTeacherIds!
|
||||||
|
.Distinct()
|
||||||
|
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||||
|
.ToList();
|
||||||
db.ExperimentSessions.Add(session);
|
db.ExperimentSessions.Add(session);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
createdSessions.Add((project, session));
|
createdSessions.Add((project, session));
|
||||||
@@ -731,12 +1058,11 @@ public sealed class ExperimentsController(
|
|||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.Instructors)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (session is null ||
|
if (session is null) return NotFound();
|
||||||
!await ScopedProjects().AnyAsync(
|
if (!CanManageSession(session)) return Forbid();
|
||||||
x => x.Id == session.ExperimentProjectId,
|
|
||||||
cancellationToken))
|
|
||||||
return NotFound();
|
|
||||||
if (session.Status == ExperimentSessionStatus.Cancelled)
|
if (session.Status == ExperimentSessionStatus.Cancelled)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
||||||
@@ -798,12 +1124,11 @@ public sealed class ExperimentsController(
|
|||||||
{
|
{
|
||||||
var session = await db.ExperimentSessions.AsNoTracking()
|
var session = await db.ExperimentSessions.AsNoTracking()
|
||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
|
.Include(x => x.Instructors)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (session is null ||
|
if (session is null) return NotFound();
|
||||||
!await ScopedProjects().AnyAsync(
|
if (!CanManageSession(session)) return Forbid();
|
||||||
x => x.Id == session.ExperimentProjectId,
|
|
||||||
cancellationToken))
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
if (session.ExperimentProject!.ArrangementMode ==
|
if (session.ExperimentProject!.ArrangementMode ==
|
||||||
ExperimentArrangementMode.Centralized)
|
ExperimentArrangementMode.Centralized)
|
||||||
@@ -866,6 +1191,12 @@ public sealed class ExperimentsController(
|
|||||||
if (session.ExperimentProject.ArrangementMode !=
|
if (session.ExperimentProject.ArrangementMode !=
|
||||||
ExperimentArrangementMode.SelfScheduled)
|
ExperimentArrangementMode.SelfScheduled)
|
||||||
return ConflictProblem("集中安排实验无需学生预约。");
|
return ConflictProblem("集中安排实验无需学生预约。");
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (session.ExperimentProject.SelectionStartsAt is null ||
|
||||||
|
session.ExperimentProject.SelectionEndsAt is null ||
|
||||||
|
now < session.ExperimentProject.SelectionStartsAt ||
|
||||||
|
now > session.ExperimentProject.SelectionEndsAt)
|
||||||
|
return ConflictProblem("当前不在该实验项目的选课时间范围内。");
|
||||||
if (!await TeachingTaskRosterQuery
|
if (!await TeachingTaskRosterQuery
|
||||||
.TaskIdsForStudent(db, student.Id)
|
.TaskIdsForStudent(db, student.Id)
|
||||||
.ContainsAsync(
|
.ContainsAsync(
|
||||||
@@ -971,11 +1302,34 @@ public sealed class ExperimentsController(
|
|||||||
IsolationLevel.Serializable);
|
IsolationLevel.Serializable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ValidateInstructorTeacherIdsAsync(
|
||||||
|
ExperimentProject project,
|
||||||
|
IReadOnlyList<Guid>? instructorTeacherIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = instructorTeacherIds?.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
|
||||||
|
if (ids.Count == 0) return "请至少选择一位指导老师。";
|
||||||
|
if (ids.Count > 20) return "单个实验场次最多选择 20 位指导老师。";
|
||||||
|
var validCount = await db.TeachingTaskTeachers.AsNoTracking()
|
||||||
|
.Where(x => x.TeachingTaskId == project.TeachingTaskId &&
|
||||||
|
ids.Contains(x.TeacherId) &&
|
||||||
|
x.Teacher!.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
return validCount == ids.Count
|
||||||
|
? null
|
||||||
|
: "指导老师必须是该教学任务的在职任课教师。";
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string?> ValidateSessionAsync(
|
private async Task<string?> ValidateSessionAsync(
|
||||||
ExperimentProject project,
|
ExperimentProject project,
|
||||||
ExperimentSessionRequest request,
|
ExperimentSessionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var instructorProblem = await ValidateInstructorTeacherIdsAsync(
|
||||||
|
project, request.InstructorTeacherIds, cancellationToken);
|
||||||
|
if (instructorProblem is not null) return instructorProblem;
|
||||||
if (request.SessionDate < project.StartDate ||
|
if (request.SessionDate < project.StartDate ||
|
||||||
request.SessionDate > project.EndDate)
|
request.SessionDate > project.EndDate)
|
||||||
return "实验场次日期必须在项目开放日期范围内。";
|
return "实验场次日期必须在项目开放日期范围内。";
|
||||||
@@ -1203,6 +1557,26 @@ public sealed class ExperimentsController(
|
|||||||
taskIds.Contains(x.TeachingTaskId));
|
taskIds.Contains(x.TeachingTaskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanManageSession(ExperimentSession session)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope is DataScope.All or DataScope.College)
|
||||||
|
return true;
|
||||||
|
return scope.IsInRole(SystemRoles.Teacher) &&
|
||||||
|
session.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher?.UserId == scope.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanCorrectPublishedProject(TeachingTask task)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
return scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||||
|
task.Teachers.Any(item =>
|
||||||
|
item.Teacher?.UserId == scope.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
private Task<Student?> CurrentStudentAsync(
|
private Task<Student?> CurrentStudentAsync(
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
db.Students.FirstOrDefaultAsync(x =>
|
db.Students.FirstOrDefaultAsync(x =>
|
||||||
@@ -1234,6 +1608,47 @@ public sealed class ExperimentsController(
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
private async Task NotifyProjectPublishedAsync(
|
||||||
|
ExperimentProject project,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
|
||||||
|
if (userIds.Count == 0) return;
|
||||||
|
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? "集中安排"
|
||||||
|
: "自行预约";
|
||||||
|
await NotificationService.SendToUserIdsAsync(
|
||||||
|
db,
|
||||||
|
userIds,
|
||||||
|
"实验项目已发布",
|
||||||
|
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
||||||
|
"/experiments",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NotifyProjectCorrectionAsync(
|
||||||
|
ExperimentProject project,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
|
||||||
|
if (userIds.Count == 0) return;
|
||||||
|
await NotificationService.SendToUserIdsAsync(
|
||||||
|
db,
|
||||||
|
userIds,
|
||||||
|
"实验项目内容已更新",
|
||||||
|
$"《{project.TeachingTask!.Course!.Name}》的“{project.Name}”教学内容或要求已修正,请重新查看。",
|
||||||
|
"/experiments",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
|
||||||
|
{
|
||||||
|
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
||||||
|
return ids is { Count: > 0 and <= 100 } ? ids : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ValidateProjectRequest(
|
private static string? ValidateProjectRequest(
|
||||||
ExperimentProjectRequest request,
|
ExperimentProjectRequest request,
|
||||||
AcademicTerm term)
|
AcademicTerm term)
|
||||||
@@ -1246,6 +1661,12 @@ public sealed class ExperimentsController(
|
|||||||
return "实验安排方式无效。";
|
return "实验安排方式无效。";
|
||||||
if (request.StartDate > request.EndDate)
|
if (request.StartDate > request.EndDate)
|
||||||
return "项目开始日期不能晚于结束日期。";
|
return "项目开始日期不能晚于结束日期。";
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
request.SelectionStartsAt.HasValue != request.SelectionEndsAt.HasValue)
|
||||||
|
return "开始选课和截至选课时间需要同时填写。";
|
||||||
|
if (request.SelectionStartsAt.HasValue && request.SelectionEndsAt.HasValue &&
|
||||||
|
request.SelectionStartsAt >= request.SelectionEndsAt)
|
||||||
|
return "开始选课时间必须早于截至选课时间。";
|
||||||
if (request.StartDate < term.StartDate ||
|
if (request.StartDate < term.StartDate ||
|
||||||
request.EndDate > term.EndDate)
|
request.EndDate > term.EndDate)
|
||||||
return "实验项目日期必须在所属学期起止日期内。";
|
return "实验项目日期必须在所属学期起止日期内。";
|
||||||
@@ -1279,6 +1700,21 @@ public sealed class ExperimentsController(
|
|||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static DateTime? NormalizeSelectionTime(DateTime? value)
|
||||||
|
{
|
||||||
|
if (!value.HasValue) return null;
|
||||||
|
if (value.Value.Kind == DateTimeKind.Utc) return value.Value;
|
||||||
|
var china = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
|
||||||
|
return TimeZoneInfo.ConvertTimeToUtc(
|
||||||
|
DateTime.SpecifyKind(value.Value, DateTimeKind.Unspecified), china);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? names)
|
||||||
|
{
|
||||||
|
if (names is null || names.Count == 0) return null;
|
||||||
|
return names.Select(name => name.Trim()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
@@ -1297,7 +1733,14 @@ public sealed record ExperimentProjectRequest(
|
|||||||
[MaxLength(1000)] string? Requirements,
|
[MaxLength(1000)] string? Requirements,
|
||||||
DateOnly StartDate,
|
DateOnly StartDate,
|
||||||
DateOnly EndDate,
|
DateOnly EndDate,
|
||||||
Guid? ScheduleEntryId = null);
|
Guid? ScheduleEntryId = null,
|
||||||
|
DateTime? SelectionStartsAt = null,
|
||||||
|
DateTime? SelectionEndsAt = null);
|
||||||
|
|
||||||
|
public sealed record PublishedExperimentProjectCorrectionRequest(
|
||||||
|
[Required, MaxLength(120)] string Name,
|
||||||
|
[MaxLength(1000)] string? Description,
|
||||||
|
[MaxLength(1000)] string? Requirements);
|
||||||
|
|
||||||
public sealed record ExperimentProjectBatchRequest(
|
public sealed record ExperimentProjectBatchRequest(
|
||||||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||||
@@ -1307,7 +1750,11 @@ public sealed record ExperimentProjectBatchRequest(
|
|||||||
[MaxLength(1000)] string? Description,
|
[MaxLength(1000)] string? Description,
|
||||||
[MaxLength(1000)] string? Requirements,
|
[MaxLength(1000)] string? Requirements,
|
||||||
DateOnly StartDate,
|
DateOnly StartDate,
|
||||||
DateOnly EndDate)
|
DateOnly EndDate,
|
||||||
|
IReadOnlyList<string>? Names = null,
|
||||||
|
bool GenerateSequentialNames = false,
|
||||||
|
DateTime? SelectionStartsAt = null,
|
||||||
|
DateTime? SelectionEndsAt = null)
|
||||||
{
|
{
|
||||||
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||||||
new(
|
new(
|
||||||
@@ -1318,16 +1765,27 @@ public sealed record ExperimentProjectBatchRequest(
|
|||||||
Description,
|
Description,
|
||||||
Requirements,
|
Requirements,
|
||||||
StartDate,
|
StartDate,
|
||||||
EndDate);
|
EndDate,
|
||||||
|
null,
|
||||||
|
SelectionStartsAt,
|
||||||
|
SelectionEndsAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record ExperimentProjectBulkRequest(
|
||||||
|
[Required] IReadOnlyList<Guid> ProjectIds);
|
||||||
|
|
||||||
|
public sealed record ExperimentProjectBatchNamesRequest(
|
||||||
|
[Required] IReadOnlyList<Guid> ProjectIds,
|
||||||
|
[Required] IReadOnlyList<string> Names);
|
||||||
|
|
||||||
public sealed record ExperimentSessionRequest(
|
public sealed record ExperimentSessionRequest(
|
||||||
Guid ClassroomId,
|
Guid ClassroomId,
|
||||||
DateOnly SessionDate,
|
DateOnly SessionDate,
|
||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 30)] int PeriodCount,
|
[Range(1, 30)] int PeriodCount,
|
||||||
[Range(1, 10000)] int Capacity,
|
[Range(1, 10000)] int Capacity,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes,
|
||||||
|
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
|
||||||
|
|
||||||
public sealed record ExperimentSessionBatchRequest(
|
public sealed record ExperimentSessionBatchRequest(
|
||||||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||||
@@ -1339,7 +1797,8 @@ public sealed record ExperimentSessionBatchItem(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 30)] int PeriodCount,
|
[Range(1, 30)] int PeriodCount,
|
||||||
[Range(1, 10000)] int Capacity,
|
[Range(1, 10000)] int Capacity,
|
||||||
[MaxLength(500)] string? Notes)
|
[MaxLength(500)] string? Notes,
|
||||||
|
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null)
|
||||||
{
|
{
|
||||||
public ExperimentSessionRequest ToSessionRequest() =>
|
public ExperimentSessionRequest ToSessionRequest() =>
|
||||||
new(
|
new(
|
||||||
@@ -1348,7 +1807,8 @@ public sealed record ExperimentSessionBatchItem(
|
|||||||
StartPeriod,
|
StartPeriod,
|
||||||
PeriodCount,
|
PeriodCount,
|
||||||
Capacity,
|
Capacity,
|
||||||
Notes);
|
Notes,
|
||||||
|
InstructorTeacherIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ExperimentPeriodOption(
|
public sealed record ExperimentPeriodOption(
|
||||||
|
|||||||
@@ -25,11 +25,77 @@ public sealed class GradeAnalyticsController(
|
|||||||
SystemRoles.CollegeAdmin + "," +
|
SystemRoles.CollegeAdmin + "," +
|
||||||
SystemRoles.Leader + "," +
|
SystemRoles.Leader + "," +
|
||||||
SystemRoles.Teacher;
|
SystemRoles.Teacher;
|
||||||
|
private const string ScheduleManagers =
|
||||||
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
[HttpGet("refresh-schedule")]
|
||||||
|
[Authorize(Roles = ScheduleManagers)]
|
||||||
|
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
var defaults = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
|
||||||
|
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
|
||||||
|
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
|
||||||
|
var lastRunAt = setting?.LastRunAt;
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
IsEnabled = enabled,
|
||||||
|
IntervalSeconds = intervalSeconds,
|
||||||
|
BatchSize = batchSize,
|
||||||
|
LastRunAt = lastRunAt,
|
||||||
|
NextRunAt = enabled && lastRunAt.HasValue
|
||||||
|
? lastRunAt.Value.AddSeconds(intervalSeconds)
|
||||||
|
: null as DateTime?
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("refresh-schedule")]
|
||||||
|
[Authorize(Roles = ScheduleManagers)]
|
||||||
|
public async Task<ActionResult> SaveRefreshSchedule(
|
||||||
|
SaveGradeStatisticsRefreshScheduleRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.IntervalSeconds is < 10 or > 86400)
|
||||||
|
return BadRequest(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "刷新间隔应在 10 秒到 24 小时之间。",
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
});
|
||||||
|
if (request.BatchSize is < 1 or > 5000)
|
||||||
|
return BadRequest(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "单次刷新批量应在 1 到 5000 之间。",
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
});
|
||||||
|
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
setting.IsEnabled = request.IsEnabled;
|
||||||
|
setting.IntervalSeconds = request.IntervalSeconds;
|
||||||
|
setting.BatchSize = request.BatchSize;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("teaching-classes")]
|
[HttpGet("teaching-classes")]
|
||||||
public async Task<ActionResult> GetTeachingClasses(
|
public async Task<ActionResult> GetTeachingClasses(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
string? keyword,
|
string? keyword,
|
||||||
|
Guid? collegeId = null,
|
||||||
|
string? teacherKeyword = null,
|
||||||
|
bool? riskOnly = null,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -42,12 +108,23 @@ public sealed class GradeAnalyticsController(
|
|||||||
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value);
|
||||||
if (keyword is not null)
|
if (keyword is not null)
|
||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||||
x.TeachingTask.Name.Contains(keyword) ||
|
x.TeachingTask.Name.Contains(keyword) ||
|
||||||
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||||
x.TeachingTask.Course.Name.Contains(keyword));
|
x.TeachingTask.Course.Name.Contains(keyword));
|
||||||
|
if (!string.IsNullOrWhiteSpace(teacherKeyword))
|
||||||
|
{
|
||||||
|
teacherKeyword = teacherKeyword.Trim();
|
||||||
|
source = source.Where(x => x.TeachingTask!.Teachers.Any(item =>
|
||||||
|
item.Teacher!.Name.Contains(teacherKeyword) ||
|
||||||
|
item.Teacher.TeacherNumber.Contains(teacherKeyword)));
|
||||||
|
}
|
||||||
|
if (riskOnly == true)
|
||||||
|
source = source.Where(x => x.PassRate < 60 || x.AverageScore < 60);
|
||||||
|
|
||||||
var total = await source.CountAsync(cancellationToken);
|
var total = await source.CountAsync(cancellationToken);
|
||||||
var items = await source
|
var items = await source
|
||||||
@@ -526,3 +603,8 @@ public sealed class GradeAnalyticsController(
|
|||||||
IReadOnlyList<HistoricalComparison> History,
|
IReadOnlyList<HistoricalComparison> History,
|
||||||
ComparisonDelta? UniversityDelta);
|
ComparisonDelta? UniversityDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
|
||||||
|
bool IsEnabled,
|
||||||
|
int IntervalSeconds,
|
||||||
|
int BatchSize);
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ public sealed class GradesController(
|
|||||||
|
|
||||||
var task = await AccessibleTasks()
|
var task = await AccessibleTasks()
|
||||||
.Include(x => x.Teachers)
|
.Include(x => x.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||||
if (task is null) return NotFound();
|
if (task is null) return NotFound();
|
||||||
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
|
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
|
||||||
@@ -508,7 +509,6 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||||
QueueCourseStatisticsRefresh(sheet.Id);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -668,7 +668,6 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
QueueCourseStatisticsRefresh(sheet.Id);
|
|
||||||
|
|
||||||
// The grade sheet roster is authoritative at publication time. This also
|
// The grade sheet roster is authoritative at publication time. This also
|
||||||
// covers students added through approved roster corrections.
|
// covers students added through approved roster corrections.
|
||||||
@@ -707,7 +706,7 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||||
|
|
||||||
var rows = sheet.Records.Select(record =>
|
var rows = sheet.Records.Select(record =>
|
||||||
{
|
{
|
||||||
@@ -724,8 +723,8 @@ public sealed class GradesController(
|
|||||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||||
values.Add(score);
|
values.Add(score);
|
||||||
}
|
}
|
||||||
values.Add(null);
|
|
||||||
values.Add(record.FinalScore);
|
values.Add(record.FinalScore);
|
||||||
|
values.Add(null);
|
||||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||||
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||||||
@@ -746,9 +745,9 @@ public sealed class GradesController(
|
|||||||
|
|
||||||
var regularColumn = 4;
|
var regularColumn = 4;
|
||||||
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||||
var totalColumn = regularColumn + itemColumns.Length + 1;
|
var finalColumn = regularColumn + itemColumns.Length + 1;
|
||||||
var finalColumn = totalColumn + 1;
|
var totalColumn = finalColumn + 1;
|
||||||
var statusColumn = finalColumn + 1;
|
var statusColumn = totalColumn + 1;
|
||||||
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||||
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||||
(itemColumns[index], item.Weight)));
|
(itemColumns[index], item.Weight)));
|
||||||
@@ -820,7 +819,7 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||||
|
|
||||||
IReadOnlyList<ExcelRow> rows;
|
IReadOnlyList<ExcelRow> rows;
|
||||||
try
|
try
|
||||||
@@ -918,7 +917,6 @@ public sealed class GradesController(
|
|||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
return ImportValidationProblem(errors);
|
return ImportValidationProblem(errors);
|
||||||
|
|
||||||
QueueCourseStatisticsRefresh(sheet.Id);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new { updated, total = rows.Count });
|
return Ok(new { updated, total = rows.Count });
|
||||||
}
|
}
|
||||||
@@ -1161,7 +1159,6 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
QueueCourseStatisticsRefresh(id);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
@@ -1171,14 +1168,6 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
|
||||||
{
|
|
||||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
|
||||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
|
||||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
|
||||||
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -737,6 +737,7 @@ public sealed class MakeupExamsController(
|
|||||||
.Include(x => x.Enrollments)
|
.Include(x => x.Enrollments)
|
||||||
.Include(x => x.TeachingTask!)
|
.Include(x => x.TeachingTask!)
|
||||||
.ThenInclude(x => x.Teachers)
|
.ThenInclude(x => x.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (session is null) return NotFound();
|
if (session is null) return NotFound();
|
||||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public sealed class NotificationsController(
|
|||||||
var total = await source.CountAsync(cancellationToken);
|
var total = await source.CountAsync(cancellationToken);
|
||||||
var items = await source
|
var items = await source
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
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;
|
||||||
@@ -32,15 +33,24 @@ public sealed class OfficialDocumentsController(
|
|||||||
OfficialDocumentType? type,
|
OfficialDocumentType? type,
|
||||||
OfficialDocumentStatus? status,
|
OfficialDocumentStatus? status,
|
||||||
Guid? studentId,
|
Guid? studentId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = AccessibleDocuments().AsNoTracking();
|
var source = AccessibleDocuments().AsNoTracking();
|
||||||
if (type.HasValue) source = source.Where(x => x.Type == type);
|
if (type.HasValue) source = source.Where(x => x.Type == type);
|
||||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||||
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
|
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.IssuedAt)
|
.OrderByDescending(x => x.IssuedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -62,15 +72,21 @@ public sealed class OfficialDocumentsController(
|
|||||||
DownloadCount = x.Downloads.Count,
|
DownloadCount = x.Downloads.Count,
|
||||||
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("students/options")]
|
[HttpGet("students/options")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> StudentOptions(
|
public async Task<ActionResult> StudentOptions(
|
||||||
string? keyword,
|
string? keyword,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 30)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = AccessibleStudents().AsNoTracking();
|
var source = AccessibleStudents().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
@@ -79,7 +95,10 @@ public sealed class OfficialDocumentsController(
|
|||||||
x.StudentNumber.Contains(value) || x.Name.Contains(value));
|
x.StudentNumber.Contains(value) || x.Name.Contains(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(await source.OrderBy(x => x.StudentNumber)
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source.OrderBy(x => x.StudentNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -90,7 +109,8 @@ public sealed class OfficialDocumentsController(
|
|||||||
MajorName = x.AdministrativeClass.Major!.Name,
|
MajorName = x.AdministrativeClass.Major!.Name,
|
||||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -207,14 +227,24 @@ public sealed class OfficialDocumentsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> DownloadHistory(
|
public async Task<ActionResult> DownloadHistory(
|
||||||
Guid id,
|
Guid id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
|
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
return Ok(await db.OfficialDocumentDownloads.AsNoTracking()
|
var source = db.OfficialDocumentDownloads.AsNoTracking()
|
||||||
.Where(x => x.OfficialDocumentId == id)
|
.Where(x => x.OfficialDocumentId == id)
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id);
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -224,7 +254,8 @@ public sealed class OfficialDocumentsController(
|
|||||||
x.IpAddress,
|
x.IpAddress,
|
||||||
x.UserAgent
|
x.UserAgent
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/invalidate")]
|
[HttpPost("{id:guid}/invalidate")]
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ public sealed class OperationsController(
|
|||||||
{
|
{
|
||||||
var ids = pageItems.Select(x => x.Id).ToArray();
|
var ids = pageItems.Select(x => x.Id).ToArray();
|
||||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||||
.Where(x => ids.Contains(x.JobId))
|
.WhereIn(ids, x => x.JobId)
|
||||||
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
||||||
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -20,10 +21,38 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
|
|
||||||
[HttpGet("batches")]
|
[HttpGet("batches")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
public async Task<ActionResult> GetBatches(
|
||||||
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
string? keyword = null,
|
||||||
|
OtherExamBatchStatus? status = null,
|
||||||
|
OtherExamMetricKind? metricKind = null,
|
||||||
|
DateOnly? examDateFrom = null,
|
||||||
|
DateOnly? examDateTo = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
|
var source = db.OtherExamBatches.AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
(x.ExamCode != null && x.ExamCode.Contains(keyword)) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
(x.Organizer != null && x.Organizer.Contains(keyword)));
|
||||||
|
}
|
||||||
|
if (status.HasValue) source = source.Where(x => x.Status == status.Value);
|
||||||
|
if (metricKind.HasValue) source = source.Where(x => x.MetricKind == metricKind.Value);
|
||||||
|
if (examDateFrom.HasValue) source = source.Where(x => x.ExamDate >= examDateFrom.Value);
|
||||||
|
if (examDateTo.HasValue) source = source.Where(x => x.ExamDate <= examDateTo.Value);
|
||||||
|
var total = await source.CountAsync(ct);
|
||||||
|
var items = await source.OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("batches")]
|
[HttpPost("batches")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
@@ -74,7 +103,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (batch is null) return NotFound();
|
if (batch is null) return NotFound();
|
||||||
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||||
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
return result is null ? Ok(new { updated = request.Results.Count }) : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("batches/{id:guid}/template")]
|
[HttpGet("batches/{id:guid}/template")]
|
||||||
@@ -153,7 +182,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||||
foreach (var item in inputs)
|
foreach (var item in inputs)
|
||||||
{
|
{
|
||||||
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
|
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed, item.Notes);
|
||||||
if (error is not null) return ValidationProblem(error);
|
if (error is not null) return ValidationProblem(error);
|
||||||
}
|
}
|
||||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||||
@@ -166,7 +195,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
batch.Status = OtherExamBatchStatus.Draft;
|
batch.Status = OtherExamBatchStatus.Draft;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
batch.Results = inputs.Select(x =>
|
var replacementResults = inputs.Select(x =>
|
||||||
{
|
{
|
||||||
var student = students[x.StudentNumber.Trim()];
|
var student = students[x.StudentNumber.Trim()];
|
||||||
return new OtherExamResult
|
return new OtherExamResult
|
||||||
@@ -180,6 +209,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
Notes = Normalize(x.Notes)
|
Notes = Normalize(x.Notes)
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
db.OtherExamResults.AddRange(replacementResults);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
return null;
|
return null;
|
||||||
@@ -209,13 +239,20 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
|
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass, string? notes)
|
||||||
{
|
{
|
||||||
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
if (Normalize(notes)?.Length > 500) return "备注不能超过 500 个字符。";
|
||||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
return b.MetricKind switch
|
||||||
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
{
|
||||||
_ => null
|
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
||||||
};
|
OtherExamMetricKind.Score when decimal.Round(score.Value, 2) != score.Value => "分数最多保留两位小数。",
|
||||||
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
||||||
|
OtherExamMetricKind.Level when Normalize(level)!.Length > 50 => "等级不能超过 50 个字符。",
|
||||||
|
OtherExamMetricKind.Level when !(b.LevelOptions ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Contains(Normalize(level)!, StringComparer.Ordinal) => "等级必须从该考试场次配置的等级选项中选择。",
|
||||||
|
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
||||||
{
|
{
|
||||||
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.Include(x => x.AllowedExperimentClassrooms)
|
||||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||||
return Ok(tasks.Select(task =>
|
return Ok(tasks.Select(task =>
|
||||||
{
|
{
|
||||||
@@ -132,11 +133,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
: constraint?.RequiresClassroom ?? true,
|
: constraint?.RequiresClassroom ?? true,
|
||||||
constraint?.RequiredCampusId,
|
constraint?.RequiredCampusId,
|
||||||
constraint?.RequiredBuildingId,
|
constraint?.RequiredBuildingId,
|
||||||
|
constraint?.ExperimentRequiredCampusId,
|
||||||
|
constraint?.ExperimentRequiredBuildingId,
|
||||||
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
|
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
|
||||||
constraint?.EarliestPeriod,
|
constraint?.EarliestPeriod,
|
||||||
constraint?.LatestPeriod,
|
constraint?.LatestPeriod,
|
||||||
AllowedClassroomIds = constraint?.AllowedClassrooms
|
AllowedClassroomIds = constraint?.AllowedClassrooms
|
||||||
.Select(x => x.ClassroomId) ?? [],
|
.Select(x => x.ClassroomId) ?? [],
|
||||||
|
AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
|
||||||
|
.Select(x => x.ClassroomId) ?? [],
|
||||||
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
|
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
@@ -168,6 +173,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
{
|
{
|
||||||
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
|
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.Include(x => x.AllowedExperimentClassrooms)
|
||||||
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
||||||
if (flexibleConstraint is not null)
|
if (flexibleConstraint is not null)
|
||||||
{
|
{
|
||||||
@@ -195,6 +201,22 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
cancellationToken))
|
cancellationToken))
|
||||||
return ValidationProblem("指定校区不存在或已停用。");
|
return ValidationProblem("指定校区不存在或已停用。");
|
||||||
|
|
||||||
|
Building? experimentBuilding = null;
|
||||||
|
if (request.ExperimentRequiredBuildingId.HasValue)
|
||||||
|
{
|
||||||
|
experimentBuilding = await db.Buildings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
|
||||||
|
cancellationToken);
|
||||||
|
if (experimentBuilding is null) return ValidationProblem("指定实验教学楼不存在或已停用。");
|
||||||
|
if (request.ExperimentRequiredCampusId.HasValue &&
|
||||||
|
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
|
||||||
|
return ValidationProblem("指定实验教学楼不属于所选校区。");
|
||||||
|
}
|
||||||
|
if (request.ExperimentRequiredCampusId.HasValue &&
|
||||||
|
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("指定实验校区不存在或已停用。");
|
||||||
|
|
||||||
var allowedRooms = await db.Classrooms.AsNoTracking()
|
var allowedRooms = await db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled)
|
||||||
.WhereIn(request.AllowedClassroomIds, x => x.Id)
|
.WhereIn(request.AllowedClassroomIds, x => x.Id)
|
||||||
@@ -208,8 +230,23 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
|
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
|
||||||
return ValidationProblem("指定教室必须位于所选校区。");
|
return ValidationProblem("指定教室必须位于所选校区。");
|
||||||
|
|
||||||
|
var allowedExperimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
|
||||||
|
var allowedExperimentRooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.WhereIn(allowedExperimentRoomIds, x => x.Id)
|
||||||
|
.Include(x => x.Building)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (allowedExperimentRooms.Count != allowedExperimentRoomIds.Length)
|
||||||
|
return ValidationProblem("部分指定实验场地不存在或已停用。");
|
||||||
|
if (experimentBuilding is not null && allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
|
||||||
|
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
|
||||||
|
if (request.ExperimentRequiredCampusId.HasValue &&
|
||||||
|
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
|
||||||
|
return ValidationProblem("指定实验场地必须位于所选实验校区。");
|
||||||
|
|
||||||
var constraint = await db.TeachingTaskScheduleConstraints
|
var constraint = await db.TeachingTaskScheduleConstraints
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.Include(x => x.AllowedExperimentClassrooms)
|
||||||
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
||||||
if (constraint is null)
|
if (constraint is null)
|
||||||
{
|
{
|
||||||
@@ -223,6 +260,12 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
constraint.RequiredBuildingId = request.RequiresClassroom
|
constraint.RequiredBuildingId = request.RequiresClassroom
|
||||||
? request.RequiredBuildingId
|
? request.RequiredBuildingId
|
||||||
: null;
|
: null;
|
||||||
|
constraint.ExperimentRequiredCampusId = request.RequiresClassroom
|
||||||
|
? request.ExperimentRequiredCampusId
|
||||||
|
: null;
|
||||||
|
constraint.ExperimentRequiredBuildingId = request.RequiresClassroom
|
||||||
|
? request.ExperimentRequiredBuildingId
|
||||||
|
: null;
|
||||||
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
|
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
|
||||||
? null
|
? null
|
||||||
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
||||||
@@ -230,10 +273,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
constraint.LatestPeriod = request.LatestPeriod;
|
constraint.LatestPeriod = request.LatestPeriod;
|
||||||
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
|
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
|
||||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||||
|
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
|
||||||
constraint.AllowedClassrooms = request.RequiresClassroom
|
constraint.AllowedClassrooms = request.RequiresClassroom
|
||||||
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
|
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
|
||||||
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
|
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
|
||||||
: [];
|
: [];
|
||||||
|
constraint.AllowedExperimentClassrooms = request.RequiresClassroom
|
||||||
|
? allowedExperimentRoomIds.Select(classroomId =>
|
||||||
|
new TeachingTaskAllowedExperimentClassroom { ClassroomId = classroomId }).ToList()
|
||||||
|
: [];
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -256,21 +304,26 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
!Enum.IsDefined(request.SchedulingMode.Value))
|
!Enum.IsDefined(request.SchedulingMode.Value))
|
||||||
return ValidationProblem("授课方式无效。");
|
return ValidationProblem("授课方式无效。");
|
||||||
if (!request.SchedulingMode.HasValue &&
|
if (!request.SchedulingMode.HasValue &&
|
||||||
!request.RequiresClassroom.HasValue &&
|
!request.RequiresClassroom.HasValue &&
|
||||||
request.AllowedDayOfWeeks is null &&
|
request.AllowedDayOfWeeks is null &&
|
||||||
!request.UpdateClassroomScope &&
|
!request.UpdateClassroomScope &&
|
||||||
!request.UpdatePeriodRange &&
|
!request.UpdateExperimentClassroomScope &&
|
||||||
|
!request.AllowedExperimentVenueNatures.HasValue &&
|
||||||
|
!request.UpdatePeriodRange &&
|
||||||
!request.EarliestPeriod.HasValue &&
|
!request.EarliestPeriod.HasValue &&
|
||||||
!request.LatestPeriod.HasValue)
|
!request.LatestPeriod.HasValue)
|
||||||
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
||||||
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
|
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
|
||||||
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
|
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
|
||||||
|
if (request.UpdateExperimentClassroomScope && request.RequiresClassroom == false)
|
||||||
|
return ValidationProblem("批量指定实验场地时,场地要求不能设置为不占用教室。");
|
||||||
|
|
||||||
var tasks = await db.TeachingTasks
|
var tasks = await db.TeachingTasks
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.AcademicTermId == request.AcademicTermId &&
|
x.AcademicTermId == request.AcademicTermId &&
|
||||||
x.Status == TeachingTaskStatus.Published)
|
x.Status == TeachingTaskStatus.Published)
|
||||||
.WhereIn(taskIds, x => x.Id)
|
.WhereIn(taskIds, x => x.Id)
|
||||||
|
.Include(x => x.Course)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (tasks.Count != taskIds.Length)
|
if (tasks.Count != taskIds.Length)
|
||||||
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
|
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
|
||||||
@@ -281,9 +334,16 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
(request.SchedulingMode ?? task.SchedulingMode) ==
|
(request.SchedulingMode ?? task.SchedulingMode) ==
|
||||||
TeachingTaskSchedulingMode.Flexible))
|
TeachingTaskSchedulingMode.Flexible))
|
||||||
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
|
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
|
||||||
|
if (request.UpdateExperimentClassroomScope && tasks.Any(task =>
|
||||||
|
(request.SchedulingMode ?? task.SchedulingMode) ==
|
||||||
|
TeachingTaskSchedulingMode.Flexible))
|
||||||
|
return ConflictProblem("非排时课程不能指定实验场地,请先将当前筛选结果限定为正常排课课程。");
|
||||||
|
|
||||||
Building? building = null;
|
Building? building = null;
|
||||||
List<Classroom> allowedRooms = [];
|
List<Classroom> allowedRooms = [];
|
||||||
|
var experimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
|
||||||
|
Building? experimentBuilding = null;
|
||||||
|
List<Classroom> allowedExperimentRooms = [];
|
||||||
if (request.UpdateClassroomScope)
|
if (request.UpdateClassroomScope)
|
||||||
{
|
{
|
||||||
if (request.RequiredBuildingId.HasValue)
|
if (request.RequiredBuildingId.HasValue)
|
||||||
@@ -320,10 +380,42 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
x.Building!.CampusId != request.RequiredCampusId))
|
x.Building!.CampusId != request.RequiredCampusId))
|
||||||
return ValidationProblem("指定教室必须位于所选校区。");
|
return ValidationProblem("指定教室必须位于所选校区。");
|
||||||
}
|
}
|
||||||
|
if (request.UpdateExperimentClassroomScope)
|
||||||
|
{
|
||||||
|
if (request.ExperimentRequiredBuildingId.HasValue)
|
||||||
|
{
|
||||||
|
experimentBuilding = await db.Buildings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
|
||||||
|
cancellationToken);
|
||||||
|
if (experimentBuilding is null)
|
||||||
|
return ValidationProblem("指定实验教学楼不存在或已停用。");
|
||||||
|
if (request.ExperimentRequiredCampusId.HasValue &&
|
||||||
|
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
|
||||||
|
return ValidationProblem("指定实验教学楼不属于所选实验校区。");
|
||||||
|
}
|
||||||
|
if (request.ExperimentRequiredCampusId.HasValue &&
|
||||||
|
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("指定实验校区不存在或已停用。");
|
||||||
|
allowedExperimentRooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.WhereIn(experimentRoomIds, x => x.Id)
|
||||||
|
.Include(x => x.Building)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (allowedExperimentRooms.Count != experimentRoomIds.Length)
|
||||||
|
return ValidationProblem("部分指定实验场地不存在或已停用。");
|
||||||
|
if (experimentBuilding is not null &&
|
||||||
|
allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
|
||||||
|
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
|
||||||
|
if (request.ExperimentRequiredCampusId.HasValue &&
|
||||||
|
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
|
||||||
|
return ValidationProblem("指定实验场地必须位于所选实验校区。");
|
||||||
|
}
|
||||||
|
|
||||||
var constraints = await db.TeachingTaskScheduleConstraints
|
var constraints = await db.TeachingTaskScheduleConstraints
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.Include(x => x.AllowedExperimentClassrooms)
|
||||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
{
|
{
|
||||||
@@ -342,6 +434,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
request.RequiresClassroom.HasValue ||
|
request.RequiresClassroom.HasValue ||
|
||||||
request.AllowedDayOfWeeks is not null ||
|
request.AllowedDayOfWeeks is not null ||
|
||||||
request.UpdateClassroomScope ||
|
request.UpdateClassroomScope ||
|
||||||
|
request.UpdateExperimentClassroomScope ||
|
||||||
|
request.AllowedExperimentVenueNatures.HasValue ||
|
||||||
request.UpdatePeriodRange;
|
request.UpdatePeriodRange;
|
||||||
if (!changesConstraint) continue;
|
if (!changesConstraint) continue;
|
||||||
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
|
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
|
||||||
@@ -357,7 +451,10 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
constraint.RequiredCampusId = null;
|
constraint.RequiredCampusId = null;
|
||||||
constraint.RequiredBuildingId = null;
|
constraint.RequiredBuildingId = null;
|
||||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||||
|
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
|
||||||
|
constraint.AllowedExperimentClassrooms);
|
||||||
constraint.AllowedClassrooms = [];
|
constraint.AllowedClassrooms = [];
|
||||||
|
constraint.AllowedExperimentClassrooms = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (request.AllowedDayOfWeeks is not null)
|
if (request.AllowedDayOfWeeks is not null)
|
||||||
@@ -379,6 +476,17 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
ClassroomId = room.Id
|
ClassroomId = room.Id
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
if (task.Course?.PracticeHours > 0 && request.AllowedExperimentVenueNatures.HasValue)
|
||||||
|
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures.Value;
|
||||||
|
if (task.Course?.PracticeHours > 0 && request.UpdateExperimentClassroomScope)
|
||||||
|
{
|
||||||
|
constraint.ExperimentRequiredCampusId = request.ExperimentRequiredCampusId;
|
||||||
|
constraint.ExperimentRequiredBuildingId = request.ExperimentRequiredBuildingId;
|
||||||
|
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
|
||||||
|
constraint.AllowedExperimentClassrooms);
|
||||||
|
constraint.AllowedExperimentClassrooms = allowedExperimentRooms.Select(room =>
|
||||||
|
new TeachingTaskAllowedExperimentClassroom { ClassroomId = room.Id }).ToList();
|
||||||
|
}
|
||||||
if (request.UpdatePeriodRange)
|
if (request.UpdatePeriodRange)
|
||||||
{
|
{
|
||||||
constraint.EarliestPeriod = request.EarliestPeriod;
|
constraint.EarliestPeriod = request.EarliestPeriod;
|
||||||
@@ -402,11 +510,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
constraint.RequiresClassroom = false;
|
constraint.RequiresClassroom = false;
|
||||||
constraint.RequiredCampusId = null;
|
constraint.RequiredCampusId = null;
|
||||||
constraint.RequiredBuildingId = null;
|
constraint.RequiredBuildingId = null;
|
||||||
|
constraint.ExperimentRequiredCampusId = null;
|
||||||
|
constraint.ExperimentRequiredBuildingId = null;
|
||||||
constraint.AllowedDayOfWeeks = null;
|
constraint.AllowedDayOfWeeks = null;
|
||||||
constraint.EarliestPeriod = null;
|
constraint.EarliestPeriod = null;
|
||||||
constraint.LatestPeriod = null;
|
constraint.LatestPeriod = null;
|
||||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||||
|
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
|
||||||
constraint.AllowedClassrooms = [];
|
constraint.AllowedClassrooms = [];
|
||||||
|
constraint.AllowedExperimentClassrooms = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
@@ -441,7 +553,10 @@ public sealed record TeachingTaskScheduleConstraintRequest(
|
|||||||
IReadOnlyList<int> AllowedDayOfWeeks,
|
IReadOnlyList<int> AllowedDayOfWeeks,
|
||||||
[Range(1, 30)] int? EarliestPeriod,
|
[Range(1, 30)] int? EarliestPeriod,
|
||||||
[Range(1, 30)] int? LatestPeriod,
|
[Range(1, 30)] int? LatestPeriod,
|
||||||
TeachingVenueNature AllowedExperimentVenueNatures = 0);
|
TeachingVenueNature AllowedExperimentVenueNatures = 0,
|
||||||
|
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
|
||||||
|
Guid? ExperimentRequiredCampusId = null,
|
||||||
|
Guid? ExperimentRequiredBuildingId = null);
|
||||||
|
|
||||||
public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
||||||
Guid AcademicTermId,
|
Guid AcademicTermId,
|
||||||
@@ -455,4 +570,9 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
|||||||
IReadOnlyList<Guid>? AllowedClassroomIds,
|
IReadOnlyList<Guid>? AllowedClassroomIds,
|
||||||
bool UpdatePeriodRange,
|
bool UpdatePeriodRange,
|
||||||
[Range(1, 30)] int? EarliestPeriod,
|
[Range(1, 30)] int? EarliestPeriod,
|
||||||
[Range(1, 30)] int? LatestPeriod);
|
[Range(1, 30)] int? LatestPeriod,
|
||||||
|
bool UpdateExperimentClassroomScope = false,
|
||||||
|
TeachingVenueNature? AllowedExperimentVenueNatures = null,
|
||||||
|
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
|
||||||
|
Guid? ExperimentRequiredCampusId = null,
|
||||||
|
Guid? ExperimentRequiredBuildingId = null);
|
||||||
|
|||||||
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
|
|||||||
ToResponse(job));
|
ToResponse(job));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("plans/{planId:guid}/preflight")]
|
||||||
|
public async Task<ActionResult> Preflight(Guid planId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
var tasks = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published &&
|
||||||
|
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||||
|
.Select(x => new { x.Id, x.Name, CourseName = x.Course!.Name })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var scheduled = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x => x.SchedulePlanId == planId)
|
||||||
|
.Select(x => x.TeachingTaskId).Distinct().ToListAsync(cancellationToken);
|
||||||
|
var missing = tasks.Where(x => !scheduled.Contains(x.Id))
|
||||||
|
.Select(x => $"《{x.CourseName}》{x.Name}").Take(20).ToList();
|
||||||
|
return Ok(new { totalTasks = tasks.Count, scheduledTasks = scheduled.Count, unscheduledTasks = missing.Count, messages = missing });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
|
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
|
||||||
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
||||||
GetAutomaticScheduleJob(
|
GetAutomaticScheduleJob(
|
||||||
@@ -552,6 +571,7 @@ public sealed class SchedulesController(
|
|||||||
|
|
||||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.Include(x => x.AllowedExperimentClassrooms)
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
x => x.TeachingTaskId == request.TeachingTaskId,
|
x => x.TeachingTaskId == request.TeachingTaskId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -580,20 +600,26 @@ public sealed class SchedulesController(
|
|||||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
if (request.Kind != ScheduleEntryKind.Experiment &&
|
||||||
!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
constraint?.RequiredCampusId is Guid campusId &&
|
||||||
return ValidationProblem(
|
|
||||||
$"实验课必须安排在具有实验教学性质的场地;“{classroom.Name}”未标注实验室、实训室、机房或语音室性质。 ");
|
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||||
if (constraint?.RequiredBuildingId is Guid buildingId &&
|
if (request.Kind != ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.RequiredBuildingId is Guid buildingId &&
|
||||||
classroom.BuildingId != buildingId)
|
classroom.BuildingId != buildingId)
|
||||||
return ValidationProblem("所选教室不在该课程指定的教学楼。");
|
return ValidationProblem("所选教室不在该课程指定的教学楼。");
|
||||||
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
|
||||||
|
classroom.Building!.CampusId != experimentCampusId)
|
||||||
|
return ValidationProblem("所选场地不在该实验课指定的校区。");
|
||||||
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
|
||||||
|
classroom.BuildingId != experimentBuildingId)
|
||||||
|
return ValidationProblem("所选场地不在该实验课指定的教学楼。");
|
||||||
var allowedClassroomIds = constraint?.AllowedClassrooms
|
var allowedClassroomIds = constraint?.AllowedClassrooms
|
||||||
.Select(x => x.ClassroomId)
|
.Select(x => x.ClassroomId)
|
||||||
.ToHashSet() ?? [];
|
.ToHashSet() ?? [];
|
||||||
if (allowedClassroomIds.Count > 0 &&
|
if (request.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
|
||||||
!allowedClassroomIds.Contains(classroom.Id))
|
!allowedClassroomIds.Contains(classroom.Id))
|
||||||
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
|
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
|
||||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
@@ -601,6 +627,13 @@ public sealed class SchedulesController(
|
|||||||
allowedNatures != 0 &&
|
allowedNatures != 0 &&
|
||||||
(classroom.TeachingVenueNature & allowedNatures) == 0)
|
(classroom.TeachingVenueNature & allowedNatures) == 0)
|
||||||
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
|
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
|
||||||
|
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
|
||||||
|
.Select(x => x.ClassroomId)
|
||||||
|
.ToHashSet() ?? [];
|
||||||
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
allowedExperimentClassroomIds.Count > 0 &&
|
||||||
|
!allowedExperimentClassroomIds.Contains(classroom.Id))
|
||||||
|
return ValidationProblem("所选场地不在该实验课指定的场地范围内。");
|
||||||
}
|
}
|
||||||
var studentCount = task.Classes.Sum(x =>
|
var studentCount = task.Classes.Sum(x =>
|
||||||
x.AdministrativeClass!.Students.Count(student =>
|
x.AdministrativeClass!.Students.Count(student =>
|
||||||
|
|||||||
@@ -17,17 +17,53 @@ public sealed class StudentStatusChangesController(
|
|||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult> Get(CancellationToken token)
|
public async Task<ActionResult<StudentStatusChangePage>> Get(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken token = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = ScopedChanges().AsNoTracking();
|
var source = ScopedChanges().AsNoTracking();
|
||||||
return Ok(await source.OrderByDescending(x => x.SubmittedAt).Select(x => new
|
var userScope = currentUserDataScope.Current;
|
||||||
{
|
var total = await source.CountAsync(token);
|
||||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
var actionableTotal = await source.CountAsync(change =>
|
||||||
ClassName = x.Student.AdministrativeClass!.Name,
|
(change.State == StudentStatusChangeState.Submitted &&
|
||||||
CollegeName = x.Student.AdministrativeClass.Major!.College!.Name,
|
userScope.IsInRole(SystemRoles.Counselor)) ||
|
||||||
x.Type, x.OriginalStatus, x.TargetStatus, x.Reason, x.State,
|
(change.State == StudentStatusChangeState.CounselorApproved &&
|
||||||
x.ReviewComment, x.SubmittedAt, x.ReviewedAt, x.ApprovedAt
|
userScope.IsInRole(SystemRoles.CollegeAdmin)) ||
|
||||||
}).ToListAsync(token));
|
(change.State == StudentStatusChangeState.CollegeApproved &&
|
||||||
|
(userScope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||||
|
userScope.IsInRole(SystemRoles.SuperAdmin))), token);
|
||||||
|
var finishedTotal = await source.CountAsync(change =>
|
||||||
|
change.State == StudentStatusChangeState.Approved ||
|
||||||
|
change.State == StudentStatusChangeState.Rejected ||
|
||||||
|
change.State == StudentStatusChangeState.Cancelled, token);
|
||||||
|
var items = await source
|
||||||
|
.OrderByDescending(x => x.SubmittedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new StudentStatusChangeListItem(
|
||||||
|
x.Id,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber,
|
||||||
|
x.Student.Name,
|
||||||
|
x.Student.AdministrativeClass!.Name,
|
||||||
|
x.Student.AdministrativeClass.Major!.College!.Name,
|
||||||
|
x.Type,
|
||||||
|
x.OriginalStatus,
|
||||||
|
x.TargetStatus,
|
||||||
|
x.Reason,
|
||||||
|
x.State,
|
||||||
|
x.ReviewComment,
|
||||||
|
x.SubmittedAt,
|
||||||
|
x.ReviewedAt,
|
||||||
|
x.ApprovedAt))
|
||||||
|
.ToListAsync(token);
|
||||||
|
return Ok(new StudentStatusChangePage(
|
||||||
|
items, total, page, pageSize, actionableTotal, finishedTotal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("options")]
|
[HttpGet("options")]
|
||||||
@@ -199,6 +235,31 @@ public sealed class StudentStatusChangesController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record StudentStatusChangePage(
|
||||||
|
IReadOnlyCollection<StudentStatusChangeListItem> Items,
|
||||||
|
int Total,
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
int ActionableTotal,
|
||||||
|
int FinishedTotal);
|
||||||
|
|
||||||
|
public sealed record StudentStatusChangeListItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
string ClassName,
|
||||||
|
string CollegeName,
|
||||||
|
StudentStatusChangeType Type,
|
||||||
|
StudentStatus OriginalStatus,
|
||||||
|
StudentStatus TargetStatus,
|
||||||
|
string Reason,
|
||||||
|
StudentStatusChangeState State,
|
||||||
|
string? ReviewComment,
|
||||||
|
DateTime SubmittedAt,
|
||||||
|
DateTime? ReviewedAt,
|
||||||
|
DateTime? ApprovedAt);
|
||||||
|
|
||||||
public sealed record StudentStatusChangeRequest(
|
public sealed record StudentStatusChangeRequest(
|
||||||
StudentStatusChangeType Type,
|
StudentStatusChangeType Type,
|
||||||
[Required, MinLength(10), MaxLength(1000)] string Reason);
|
[Required, MinLength(10), MaxLength(1000)] string Reason);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
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;
|
||||||
@@ -46,17 +47,26 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
[Authorize(Roles = SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMine(
|
public async Task<ActionResult> GetMine(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var teacher = await CurrentTeacherAsync(cancellationToken);
|
var teacher = await CurrentTeacherAsync(cancellationToken);
|
||||||
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
||||||
var source = db.TeacherCourseApplications.AsNoTracking()
|
var source = db.TeacherCourseApplications.AsNoTracking()
|
||||||
.Where(x => x.TeacherId == teacher.Id);
|
.Where(x => x.TeacherId == teacher.Id);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
.ThenBy(x => x.Course!.Code)
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -72,7 +82,8 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
x.ReviewedAt
|
x.ReviewedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("mine")]
|
[HttpPost("mine")]
|
||||||
@@ -144,15 +155,24 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
public async Task<ActionResult> GetReviews(
|
public async Task<ActionResult> GetReviews(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
TeacherCourseApplicationStatus? status,
|
TeacherCourseApplicationStatus? status,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = ScopedApplications().AsNoTracking();
|
var source = ScopedApplications().AsNoTracking();
|
||||||
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 => x.Status == status);
|
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderBy(x => x.Status)
|
.OrderBy(x => x.Status)
|
||||||
.ThenByDescending(x => x.SubmittedAt)
|
.ThenByDescending(x => x.SubmittedAt)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -172,7 +192,8 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
x.ReviewedAt
|
x.ReviewedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("assignment-options")]
|
[HttpGet("assignment-options")]
|
||||||
|
|||||||
@@ -171,6 +171,95 @@ public sealed class TimetableManagementController(
|
|||||||
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("export/batch.xlsx")]
|
||||||
|
public async Task<ActionResult> ExportBatch(
|
||||||
|
TimetableBatchExportRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var resourceIds = request.ResourceIds.Distinct().ToArray();
|
||||||
|
if (resourceIds.Length is 0 or > 100)
|
||||||
|
return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var timetables = new List<TimetableData>(resourceIds.Length);
|
||||||
|
foreach (var resourceId in resourceIds)
|
||||||
|
{
|
||||||
|
var result = await LoadAuthorizedAsync(
|
||||||
|
request.ResourceType,
|
||||||
|
resourceId,
|
||||||
|
request.AcademicTermId,
|
||||||
|
request.SchedulePlanId,
|
||||||
|
cancellationToken);
|
||||||
|
if (result.Result is null) return result.Error!;
|
||||||
|
timetables.Add(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = TimetableExcelExporter.Create(timetables);
|
||||||
|
var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.xlsx";
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export.pdf")]
|
||||||
|
public async Task<ActionResult> ExportPdf(
|
||||||
|
TimetableResourceType resourceType,
|
||||||
|
Guid resourceId,
|
||||||
|
Guid academicTermId,
|
||||||
|
Guid? schedulePlanId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await LoadAuthorizedAsync(
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
academicTermId,
|
||||||
|
schedulePlanId,
|
||||||
|
cancellationToken);
|
||||||
|
if (result.Result is null) return result.Error!;
|
||||||
|
return PdfFile(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("export/batch.pdf")]
|
||||||
|
public async Task<ActionResult> ExportBatchPdf(
|
||||||
|
TimetableBatchExportRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var resourceIds = request.ResourceIds.Distinct().ToArray();
|
||||||
|
if (resourceIds.Length is 0 or > 100)
|
||||||
|
return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var timetables = new List<TimetableData>(resourceIds.Length);
|
||||||
|
foreach (var resourceId in resourceIds)
|
||||||
|
{
|
||||||
|
var result = await LoadAuthorizedAsync(
|
||||||
|
request.ResourceType,
|
||||||
|
resourceId,
|
||||||
|
request.AcademicTermId,
|
||||||
|
request.SchedulePlanId,
|
||||||
|
cancellationToken);
|
||||||
|
if (result.Result is null) return result.Error!;
|
||||||
|
timetables.Add(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.pdf";
|
||||||
|
return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("display-links/export.xlsx")]
|
||||||
|
public async Task<ActionResult> ExportDisplayLinks(VenueDisplayLinkExportRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var classroomIds = request.ClassroomIds.Distinct().Take(200).ToArray();
|
||||||
|
var buildingIds = request.BuildingIds.Distinct().Take(100).ToArray();
|
||||||
|
if (classroomIds.Length + buildingIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一个教室或教学楼。", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
var classrooms = await db.Classrooms.AsNoTracking().WhereIn(classroomIds, x => x.Id)
|
||||||
|
.OrderBy(x => x.Building!.Name).ThenBy(x => x.Code)
|
||||||
|
.Select(x => new object?[] { "教室当天课程", x.Building!.Name + " · " + x.Name, "/venue-display/classroom/" + x.Id })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var buildings = await db.Buildings.AsNoTracking().WhereIn(buildingIds, x => x.Id).OrderBy(x => x.Name)
|
||||||
|
.Select(x => new object?[] { "教学楼空余教室", x.Name, "/venue-display/building/" + x.Id })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var bytes = ExcelWorkbookHelper.Create("展牌链接", ["展牌类型", "场地", "相对链接"], classrooms.Concat(buildings).ToList());
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType, "场地信息展牌链接.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync(
|
private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync(
|
||||||
TimetableResourceType resourceType,
|
TimetableResourceType resourceType,
|
||||||
Guid resourceId,
|
Guid resourceId,
|
||||||
@@ -205,8 +294,22 @@ public sealed class TimetableManagementController(
|
|||||||
value = value.Replace(character, '-');
|
value = value.Replace(character, '-');
|
||||||
return value.Trim();
|
return value.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ActionResult PdfFile(TimetableData timetable) =>
|
||||||
|
File(
|
||||||
|
TimetablePdfExporter.Create(timetable),
|
||||||
|
"application/pdf",
|
||||||
|
$"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record TimetableBatchExportRequest(
|
||||||
|
TimetableResourceType ResourceType,
|
||||||
|
[param: Required, MinLength(1), MaxLength(100)] IReadOnlyCollection<Guid> ResourceIds,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
Guid? SchedulePlanId);
|
||||||
|
|
||||||
|
public sealed record VenueDisplayLinkExportRequest(IReadOnlyCollection<Guid> ClassroomIds, IReadOnlyCollection<Guid> BuildingIds);
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class FreeClassroomsController(
|
public sealed class FreeClassroomsController(
|
||||||
|
|||||||
@@ -45,6 +45,30 @@ public sealed class TimetablesController(
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
|
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
|
||||||
|
|
||||||
|
[HttpGet("my-class")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMyClass(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var administrativeClassId = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => (Guid?)x.AdministrativeClassId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!administrativeClassId.HasValue)
|
||||||
|
{
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "档案未关联",
|
||||||
|
Detail = "当前登录账号没有关联学生档案,请联系教务管理员。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { AdministrativeClassId = administrativeClassId.Value });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("teachers/{teacherId:guid}")]
|
[HttpGet("teachers/{teacherId:guid}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult> GetTeacherTimetable(
|
public async Task<ActionResult> GetTeacherTimetable(
|
||||||
@@ -76,6 +100,21 @@ public sealed class TimetablesController(
|
|||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("teachers/{teacherId:guid}/export.pdf")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> ExportTeacherTimetablePdf(
|
||||||
|
Guid teacherId,
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await GetPublishedTimetableAsync(
|
||||||
|
TimetableResourceType.Teacher,
|
||||||
|
teacherId,
|
||||||
|
academicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
return result is null ? NotFound() : PdfFile(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("classes/{classId:guid}/export.xlsx")]
|
[HttpGet("classes/{classId:guid}/export.xlsx")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult> ExportClassTimetable(
|
public async Task<ActionResult> ExportClassTimetable(
|
||||||
@@ -92,6 +131,21 @@ public sealed class TimetablesController(
|
|||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("classes/{classId:guid}/export.pdf")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> ExportClassTimetablePdf(
|
||||||
|
Guid classId,
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await GetPublishedTimetableAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
classId,
|
||||||
|
academicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
return result is null ? NotFound() : PdfFile(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMyTimetable(
|
public async Task<ActionResult> GetMyTimetable(
|
||||||
@@ -203,6 +257,39 @@ public sealed class TimetablesController(
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine/export.pdf")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
|
public async Task<ActionResult> ExportMyTimetablePdf(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => new { x.Id, x.AdministrativeClassId, x.StudentNumber, x.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (student is not null)
|
||||||
|
{
|
||||||
|
var result = await timetableDataService.BuildAsync(
|
||||||
|
TimetableResourceType.Class, student.AdministrativeClassId, academicTermId, null,
|
||||||
|
false, student.Id, new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||||
|
cancellationToken);
|
||||||
|
return result is null ? NotFound() : PdfFile(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var teacher = await db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => new { x.Id })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (teacher is null) return NotFound();
|
||||||
|
var teacherResult = await timetableDataService.BuildAsync(
|
||||||
|
TimetableResourceType.Teacher, teacher.Id, academicTermId, null, false, null, null,
|
||||||
|
cancellationToken);
|
||||||
|
return teacherResult is null ? NotFound() : PdfFile(teacherResult);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("mine/calendar-subscription")]
|
[HttpGet("mine/calendar-subscription")]
|
||||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||||
@@ -426,6 +513,13 @@ public sealed class TimetablesController(
|
|||||||
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ActionResult PdfFile(TimetableData result)
|
||||||
|
{
|
||||||
|
var fileName =
|
||||||
|
$"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.pdf";
|
||||||
|
return File(TimetablePdfExporter.Create(result), "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
private static string SafeFileName(string value)
|
private static string SafeFileName(string value)
|
||||||
{
|
{
|
||||||
foreach (var character in Path.GetInvalidFileNameChars())
|
foreach (var character in Path.GetInvalidFileNameChars())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
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.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -19,10 +20,54 @@ public sealed class UsersController(
|
|||||||
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<object>> GetUsers(CancellationToken cancellationToken)
|
public async Task<ActionResult<PagedResult<UserListItem>>> GetUsers(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? keyword = null,
|
||||||
|
string? roleName = null,
|
||||||
|
Guid? collegeId = null,
|
||||||
|
bool? isEnabled = null,
|
||||||
|
bool? hasLoggedIn = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var users = await userManager.Users.AsNoTracking()
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
|
var query = userManager.Users.AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
query = query.Where(user =>
|
||||||
|
user.UserName!.Contains(keyword) ||
|
||||||
|
user.DisplayName.Contains(keyword) ||
|
||||||
|
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
|
||||||
|
(from userRole in db.UserRoles
|
||||||
|
join role in db.Roles on userRole.RoleId equals role.Id
|
||||||
|
where userRole.UserId == user.Id && role.Name!.Contains(keyword)
|
||||||
|
select role.Id).Any());
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(roleName))
|
||||||
|
{
|
||||||
|
roleName = roleName.Trim();
|
||||||
|
query = query.Where(user =>
|
||||||
|
(from userRole in db.UserRoles
|
||||||
|
join candidateRole in db.Roles on userRole.RoleId equals candidateRole.Id
|
||||||
|
where userRole.UserId == user.Id && candidateRole.Name == roleName
|
||||||
|
select userRole.RoleId).Any());
|
||||||
|
}
|
||||||
|
if (collegeId.HasValue) query = query.Where(user => user.CollegeId == collegeId.Value);
|
||||||
|
if (isEnabled.HasValue) query = query.Where(user => user.IsEnabled == isEnabled.Value);
|
||||||
|
if (hasLoggedIn.HasValue)
|
||||||
|
query = hasLoggedIn.Value
|
||||||
|
? query.Where(user => user.LastLoginAt != null)
|
||||||
|
: query.Where(user => user.LastLoginAt == null);
|
||||||
|
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var users = await query
|
||||||
.OrderBy(x => x.UserName)
|
.OrderBy(x => x.UserName)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -36,26 +81,34 @@ public sealed class UsersController(
|
|||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var result = new List<object>();
|
var userIds = users.Select(x => x.Id).ToArray();
|
||||||
foreach (var user in users)
|
var roleRows = await (
|
||||||
{
|
from userRole in db.UserRoles.AsNoTracking()
|
||||||
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
|
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||||
result.Add(new
|
where userIds.Contains(userRole.UserId)
|
||||||
{
|
select new { userRole.UserId, RoleName = role.Name! })
|
||||||
user.Id,
|
.ToListAsync(cancellationToken);
|
||||||
user.UserName,
|
var rolesByUser = roleRows
|
||||||
user.DisplayName,
|
.GroupBy(x => x.UserId)
|
||||||
user.StaffNumber,
|
.ToDictionary(
|
||||||
user.CollegeId,
|
group => group.Key,
|
||||||
user.IsEnabled,
|
group => (IReadOnlyCollection<string>)group
|
||||||
user.LastLoginAt,
|
.Select(x => x.RoleName)
|
||||||
user.CreatedAt,
|
.OrderBy(x => x)
|
||||||
Roles = identityUser is null
|
.ToArray());
|
||||||
? []
|
|
||||||
: await userManager.GetRolesAsync(identityUser)
|
var items = users.Select(user => new UserListItem(
|
||||||
});
|
user.Id,
|
||||||
}
|
user.UserName ?? string.Empty,
|
||||||
return Ok(result);
|
user.DisplayName,
|
||||||
|
user.StaffNumber,
|
||||||
|
user.CollegeId,
|
||||||
|
user.IsEnabled,
|
||||||
|
user.LastLoginAt,
|
||||||
|
user.CreatedAt,
|
||||||
|
rolesByUser.GetValueOrDefault(user.Id, Array.Empty<string>())))
|
||||||
|
.ToArray();
|
||||||
|
return Ok(new PagedResult<UserListItem>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("roles")]
|
[HttpGet("roles")]
|
||||||
@@ -281,6 +334,16 @@ public sealed record CreateUserRequest(
|
|||||||
[MinLength(1)] string[] Roles);
|
[MinLength(1)] string[] Roles);
|
||||||
|
|
||||||
public sealed record SetUserStatusRequest(bool IsEnabled);
|
public sealed record SetUserStatusRequest(bool IsEnabled);
|
||||||
|
public sealed record UserListItem(
|
||||||
|
Guid Id,
|
||||||
|
string UserName,
|
||||||
|
string DisplayName,
|
||||||
|
string? StaffNumber,
|
||||||
|
Guid? CollegeId,
|
||||||
|
bool IsEnabled,
|
||||||
|
DateTime? LastLoginAt,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
IReadOnlyCollection<string> Roles);
|
||||||
public sealed record SetRolesRequest(
|
public sealed record SetRolesRequest(
|
||||||
[MaxLength(30)] string? StaffNumber,
|
[MaxLength(30)] string? StaffNumber,
|
||||||
Guid? CollegeId,
|
Guid? CollegeId,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
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]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/venue-displays")]
|
||||||
|
public sealed class VenueDisplaysController(
|
||||||
|
AppDbContext db,
|
||||||
|
TimetableDataService timetableDataService,
|
||||||
|
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("classrooms/{classroomId:guid}")]
|
||||||
|
public async Task<ActionResult> Classroom(Guid classroomId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var term = await CurrentTermAsync(cancellationToken);
|
||||||
|
var classroom = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.Id == classroomId && x.IsEnabled && x.Building!.IsEnabled)
|
||||||
|
.Select(x => new { x.Id, x.Name, x.Code, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (classroom is null) return NotFound();
|
||||||
|
if (term is null) return Ok(new { Classroom = classroom, Today = DateOnly.FromDateTime(DateTime.Now), Entries = Array.Empty<object>() });
|
||||||
|
|
||||||
|
var sheet = await timetableDataService.BuildAsync(TimetableResourceType.Classroom, classroomId,
|
||||||
|
term.Id, null, false, null, null, cancellationToken);
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var (week, day) = ClassroomReservationAvailabilityService.ResolveTeachingWeek(term, today);
|
||||||
|
var entries = sheet?.Entries.Where(x => x.DayOfWeek == day && x.StartWeek <= week && x.EndWeek >= week &&
|
||||||
|
(x.WeekPattern == WeekPattern.All || x.WeekPattern == WeekPattern.Odd && week % 2 == 1 || x.WeekPattern == WeekPattern.Even && week % 2 == 0))
|
||||||
|
.Select(x => new { x.CourseName, x.TeacherNames, x.ClassNames, x.StartPeriod, x.PeriodCount }) ?? [];
|
||||||
|
return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("buildings/{buildingId:guid}")]
|
||||||
|
public async Task<ActionResult> Building(Guid buildingId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var term = await CurrentTermAsync(cancellationToken);
|
||||||
|
var building = await db.Buildings.AsNoTracking().Where(x => x.Id == buildingId && x.IsEnabled)
|
||||||
|
.Select(x => new { x.Id, x.Name, x.Code, CampusName = x.Campus!.Name }).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (building is null) return NotFound();
|
||||||
|
if (term is null) return Ok(new { Building = building, Today = DateOnly.FromDateTime(DateTime.Now), Slots = Array.Empty<object>(), Rooms = Array.Empty<object>() });
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var slots = await db.ScheduleTimeSlots.AsNoTracking().Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.PeriodNumber).Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }).ToListAsync(cancellationToken);
|
||||||
|
var rooms = await db.Classrooms.AsNoTracking().Where(x => x.BuildingId == buildingId && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.Code).Select(x => new { x.Id, x.Code, x.Name, x.Capacity }).ToListAsync(cancellationToken);
|
||||||
|
var occupied = new Dictionary<int, HashSet<Guid>>();
|
||||||
|
foreach (var slot in slots)
|
||||||
|
occupied[slot.PeriodNumber] = await availabilityService.GetOccupiedClassroomIdsAsync(term, today, slot.PeriodNumber, 1, null, cancellationToken);
|
||||||
|
return Ok(new { Building = building, Term = term.Name, Today = today, Slots = slots, Rooms = rooms.Select(room => new { room.Code, room.Name, room.Capacity, FreePeriods = slots.Where(slot => !occupied[slot.PeriodNumber].Contains(room.Id)).Select(slot => slot.PeriodNumber) }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<AcademicTerm?> CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.IsCurrent).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
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;
|
||||||
@@ -21,32 +22,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
||||||
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
||||||
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
.OrderBy(x => x.Type).Select(x => new { x.Id, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
|
||||||
[HttpPut("rules")]
|
[HttpPut("rules")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
|
||||||
|
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
|
||||||
|
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
|
||||||
|
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
|
||||||
|
|
||||||
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
||||||
db.WarningRules.RemoveRange(existing);
|
var incomingTypes = rules.Select(x => x.Type).ToHashSet();
|
||||||
|
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
|
||||||
foreach (var r in rules)
|
foreach (var r in rules)
|
||||||
{
|
{
|
||||||
db.WarningRules.Add(new WarningRule
|
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
|
||||||
|
if (entity is null)
|
||||||
{
|
{
|
||||||
AcademicTermId = academicTermId,
|
entity = new WarningRule
|
||||||
Type = r.Type,
|
{
|
||||||
Name = r.Name.Trim(),
|
AcademicTermId = academicTermId,
|
||||||
Threshold = r.Threshold,
|
Type = r.Type,
|
||||||
IsEnabled = r.IsEnabled,
|
Name = r.Name.Trim()
|
||||||
NotifyStudent = r.NotifyStudent,
|
};
|
||||||
NotifyCounselor = r.NotifyCounselor,
|
db.WarningRules.Add(entity);
|
||||||
Description = r.Description?.Trim(),
|
}
|
||||||
AutoCheckEnabled = r.AutoCheckEnabled,
|
|
||||||
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
entity.Name = r.Name.Trim();
|
||||||
CheckHour = r.CheckHour,
|
entity.Threshold = r.Threshold;
|
||||||
CheckMinute = r.CheckMinute
|
entity.IsEnabled = r.IsEnabled;
|
||||||
});
|
entity.NotifyStudent = r.NotifyStudent;
|
||||||
|
entity.NotifyCounselor = r.NotifyCounselor;
|
||||||
|
entity.Description = r.Description?.Trim();
|
||||||
|
entity.AutoCheckEnabled = r.AutoCheckEnabled;
|
||||||
|
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
|
||||||
|
entity.CheckHour = r.CheckHour;
|
||||||
|
entity.CheckMinute = r.CheckMinute;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -87,17 +101,23 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
db.WarningRecords.AddRange(generated);
|
db.WarningRecords.AddRange(generated);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var studentsById = studentInfos.ToDictionary(student => student.Id);
|
||||||
|
var rulesByType = rules.ToDictionary(rule => rule.Type);
|
||||||
|
var classIds = studentInfos.Select(student => student.ClassId).Distinct().ToArray();
|
||||||
|
var counselorsByClass = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.WhereIn(classIds, item => item.Id)
|
||||||
|
.Where(item => item.CounselorUserId != null)
|
||||||
|
.Select(item => new { item.Id, CounselorUserId = item.CounselorUserId!.Value })
|
||||||
|
.ToDictionaryAsync(item => item.Id, item => item.CounselorUserId, ct);
|
||||||
foreach (var w in generated)
|
foreach (var w in generated)
|
||||||
{
|
{
|
||||||
var si = studentInfos.First(s => s.Id == w.StudentId);
|
var si = studentsById[w.StudentId];
|
||||||
var r = rules.First(r => r.Type == w.Type);
|
var r = rulesByType[w.Type];
|
||||||
if (r.NotifyStudent && si.UserId.HasValue)
|
if (r.NotifyStudent && si.UserId.HasValue)
|
||||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
||||||
if (r.NotifyCounselor)
|
if (r.NotifyCounselor && counselorsByClass.TryGetValue(si.ClassId, out var counselorId))
|
||||||
{
|
{
|
||||||
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||||
if (counselorId != default)
|
|
||||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,8 +127,16 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
|
|
||||||
[HttpGet("records")]
|
[HttpGet("records")]
|
||||||
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
||||||
public async Task<ActionResult> GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct)
|
public async Task<ActionResult<PagedResult<WarningRecordListItem>>> GetRecords(
|
||||||
|
Guid? academicTermId,
|
||||||
|
WarningType? type,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
||||||
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
||||||
{
|
{
|
||||||
@@ -120,7 +148,28 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
}
|
}
|
||||||
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||||
if (type.HasValue) q = q.Where(x => x.Type == type);
|
if (type.HasValue) q = q.Where(x => x.Type == type);
|
||||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new WarningRecordListItem(
|
||||||
|
x.Id,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.Name,
|
||||||
|
x.Student.StudentNumber,
|
||||||
|
x.Student.AdministrativeClass!.Name,
|
||||||
|
(int)x.Type,
|
||||||
|
(int)x.Status,
|
||||||
|
x.TriggerValue,
|
||||||
|
x.Detail,
|
||||||
|
x.AcknowledgedAt,
|
||||||
|
x.AcknowledgeComment,
|
||||||
|
x.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return Ok(new PagedResult<WarningRecordListItem>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ Student ═══════════
|
// ═══════════ Student ═══════════
|
||||||
@@ -131,7 +180,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
||||||
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
.Select(x => new { x.Id, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,71 +203,90 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
// ═══════════ Detection logic ═══════════
|
// ═══════════ Detection logic ═══════════
|
||||||
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await db.WarningRecords.AsNoTracking()
|
||||||
{
|
.Where(record => record.AcademicTermId == termId && record.Type == rule.Type)
|
||||||
var failed = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
.Select(record => record.StudentId)
|
||||||
if (failed >= rule.Threshold)
|
.ToHashSetAsync(ct);
|
||||||
|
var failedByStudent = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(record => studentIds.Contains(record.StudentId) &&
|
||||||
|
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
|
||||||
|
record.GradeSheet.Status == GradeSheetStatus.Published &&
|
||||||
|
record.TotalScore < 60)
|
||||||
|
.GroupBy(record => record.StudentId)
|
||||||
|
.Select(group => new { StudentId = group.Key, Failed = group.Count() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return failedByStudent
|
||||||
|
.Where(item => item.Failed >= rule.Threshold &&
|
||||||
|
!existingStudentIds.Contains(item.StudentId))
|
||||||
|
.Select(item => new WarningRecord
|
||||||
{
|
{
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.FailedCredits, ct)) continue;
|
StudentId = item.StudentId,
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
Type = rule.Type,
|
||||||
}
|
TriggerValue = item.Failed,
|
||||||
}
|
Detail = $"不及格课程 {item.Failed} 门,达到预警阈值 {rule.Threshold} 门。",
|
||||||
return records;
|
AcademicTermId = termId
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||||
{
|
var gpaByStudent = await db.GradeRecords.AsNoTracking()
|
||||||
var grades = await db.GradeRecords.Where(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct);
|
.Where(record => studentIds.Contains(record.StudentId) &&
|
||||||
if (grades.Count == 0) continue;
|
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
|
||||||
var gpa = grades.Average(x => x.GradePoint!.Value);
|
record.GradeSheet.Status == GradeSheetStatus.Published &&
|
||||||
if (gpa < rule.Threshold)
|
record.GradePoint != null)
|
||||||
{
|
.GroupBy(record => record.StudentId)
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue;
|
.Select(group => new { StudentId = group.Key, Gpa = group.Average(record => record.GradePoint!.Value) })
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId });
|
.ToListAsync(ct);
|
||||||
}
|
return gpaByStudent.Where(item => item.Gpa < rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||||
}
|
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = Math.Round(item.Gpa, 2), Detail = $"平均绩点 {item.Gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId }).ToList();
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||||
{
|
var absencesByStudent = await db.AttendanceRecords.AsNoTracking()
|
||||||
var absent = await db.AttendanceRecords.CountAsync(x => x.StudentId == s.Id && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct);
|
.Where(record => studentIds.Contains(record.StudentId) && record.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && record.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (record.Status == AttendanceStatus.Absent || record.Status == AttendanceStatus.Late))
|
||||||
if (absent >= rule.Threshold)
|
.GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Absent = group.Count() }).ToListAsync(ct);
|
||||||
{
|
return absencesByStudent.Where(item => item.Absent >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue;
|
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Absent, Detail = $"缺勤/迟到 {item.Absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }).ToList();
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||||
{
|
var failedByStudent = await db.GradeRecords.AsNoTracking().Where(record => studentIds.Contains(record.StudentId) && record.GradeSheet!.Status == GradeSheetStatus.Published && record.TotalScore < 60).GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Failed = group.Count() }).ToListAsync(ct);
|
||||||
var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
return failedByStudent.Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||||
if (total >= rule.Threshold)
|
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"累计不及格 {item.Failed} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }).ToList();
|
||||||
{
|
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.GraduationDelay, ct)) continue;
|
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = total, Detail = $"累计不及格 {total} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task<HashSet<Guid>> ExistingWarningStudentIdsAsync(WarningType type, Guid termId, CancellationToken ct) =>
|
||||||
|
db.WarningRecords.AsNoTracking().Where(record => record.AcademicTermId == termId && record.Type == type).Select(record => record.StudentId).ToHashSetAsync(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<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 });
|
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 });
|
||||||
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 });
|
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
||||||
|
public sealed record WarningRecordListItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentName,
|
||||||
|
string StudentNumber,
|
||||||
|
string ClassName,
|
||||||
|
int Type,
|
||||||
|
int Status,
|
||||||
|
decimal TriggerValue,
|
||||||
|
string Detail,
|
||||||
|
DateTime? AcknowledgedAt,
|
||||||
|
string? AcknowledgeComment,
|
||||||
|
DateTime CreatedAt);
|
||||||
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
|
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
|
||||||
public sealed record AckBody([MaxLength(300)] string? Comment);
|
public sealed record AckBody([MaxLength(300)] string? Comment);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshSetting : EntityBase
|
||||||
|
{
|
||||||
|
public const string DefaultKey = "default";
|
||||||
|
|
||||||
|
public string Key { get; set; } = DefaultKey;
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public int IntervalSeconds { get; set; } = 300;
|
||||||
|
public int BatchSize { get; set; } = 100;
|
||||||
|
public DateTime? LastRunAt { get; set; }
|
||||||
|
}
|
||||||
@@ -38,6 +38,22 @@ public sealed class CurriculumCourse : EntityBase
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGroup : EntityBase
|
||||||
|
{
|
||||||
|
public required string Code { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public ICollection<CourseGroupCourse> Courses { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGroupCourse : EntityBase
|
||||||
|
{
|
||||||
|
public Guid CourseGroupId { get; set; }
|
||||||
|
public CourseGroup? CourseGroup { get; set; }
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Course? Course { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum CurriculumPlanStatus
|
public enum CurriculumPlanStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ExamArrangementJob : EntityBase
|
|||||||
public Guid PlanId { get; set; }
|
public Guid PlanId { get; set; }
|
||||||
public Guid? ActivePlanId { get; set; }
|
public Guid? ActivePlanId { get; set; }
|
||||||
public Guid? RequestedByUserId { get; set; }
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? ProjectIdsJson { get; set; }
|
||||||
public string? SessionIdsJson { get; set; }
|
public string? SessionIdsJson { get; set; }
|
||||||
public bool AssignClassrooms { get; set; }
|
public bool AssignClassrooms { get; set; }
|
||||||
public bool AssignInvigilators { get; set; }
|
public bool AssignInvigilators { get; set; }
|
||||||
@@ -138,6 +139,7 @@ public sealed class ExamPublishJob : EntityBase
|
|||||||
public Guid PlanId { get; set; }
|
public Guid PlanId { get; set; }
|
||||||
public Guid? ActivePlanId { get; set; }
|
public Guid? ActivePlanId { get; set; }
|
||||||
public Guid? RequestedByUserId { get; set; }
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? ProjectIdsJson { get; set; }
|
||||||
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||||
public string? CurrentStep { get; set; }
|
public string? CurrentStep { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
@@ -148,7 +150,8 @@ public sealed class ExamPublishJob : EntityBase
|
|||||||
public enum ExamPublishJobKind
|
public enum ExamPublishJobKind
|
||||||
{
|
{
|
||||||
FormalExam = 1,
|
FormalExam = 1,
|
||||||
MakeupExam = 2
|
MakeupExam = 2,
|
||||||
|
ExperimentProjects = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ExamPublishJobStatus
|
public enum ExamPublishJobStatus
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public sealed class ExperimentProject : EntityBase
|
|||||||
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||||
public Guid? ScheduleEntryId { get; set; }
|
public Guid? ScheduleEntryId { get; set; }
|
||||||
public ScheduleEntry? ScheduleEntry { get; set; }
|
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||||
|
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
|
||||||
|
public int? ScheduleWeek { get; set; }
|
||||||
public required string Code { get; set; }
|
public required string Code { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||||
@@ -16,6 +18,9 @@ public sealed class ExperimentProject : EntityBase
|
|||||||
public string? Requirements { get; set; }
|
public string? Requirements { get; set; }
|
||||||
public DateOnly StartDate { get; set; }
|
public DateOnly StartDate { get; set; }
|
||||||
public DateOnly EndDate { get; set; }
|
public DateOnly EndDate { get; set; }
|
||||||
|
// 自主预约项目可单独控制学生可选场次的时间窗口(UTC 时刻)。
|
||||||
|
public DateTime? SelectionStartsAt { get; set; }
|
||||||
|
public DateTime? SelectionEndsAt { get; set; }
|
||||||
public ExperimentProjectStatus Status { get; set; } =
|
public ExperimentProjectStatus Status { get; set; } =
|
||||||
ExperimentProjectStatus.Draft;
|
ExperimentProjectStatus.Draft;
|
||||||
public DateTime? PublishedAt { get; set; }
|
public DateTime? PublishedAt { get; set; }
|
||||||
@@ -41,6 +46,15 @@ public sealed class ExperimentSession : EntityBase
|
|||||||
ExperimentSessionStatus.Scheduled;
|
ExperimentSessionStatus.Scheduled;
|
||||||
public DateTime? CancelledAt { get; set; }
|
public DateTime? CancelledAt { get; set; }
|
||||||
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
public ICollection<ExperimentSessionInstructor> Instructors { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentSessionInstructor : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentSessionId { get; set; }
|
||||||
|
public ExperimentSession? ExperimentSession { get; set; }
|
||||||
|
public Guid TeacherId { get; set; }
|
||||||
|
public Teacher? Teacher { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ExperimentBooking : EntityBase
|
public sealed class ExperimentBooking : EntityBase
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ public sealed class ExperimentGradeRecord : EntityBase
|
|||||||
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A student's persisted experiment-part score for one teaching task.
|
||||||
|
/// The score is the weighted average of every published experiment project.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExperimentCourseGrade : EntityBase
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public decimal? WeightedAverageScore { get; set; }
|
||||||
|
public decimal TotalWeight { get; set; }
|
||||||
|
public int PublishedProjectCount { get; set; }
|
||||||
|
public DateTime RefreshedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ExperimentGradeItemScore
|
public sealed class ExperimentGradeItemScore
|
||||||
{
|
{
|
||||||
public Guid ExperimentGradeRecordId { get; set; }
|
public Guid ExperimentGradeRecordId { get; set; }
|
||||||
|
|||||||
@@ -52,11 +52,16 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
|
|||||||
public Campus? RequiredCampus { get; set; }
|
public Campus? RequiredCampus { get; set; }
|
||||||
public Guid? RequiredBuildingId { get; set; }
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
public Building? RequiredBuilding { get; set; }
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public Guid? ExperimentRequiredCampusId { get; set; }
|
||||||
|
public Campus? ExperimentRequiredCampus { get; set; }
|
||||||
|
public Guid? ExperimentRequiredBuildingId { get; set; }
|
||||||
|
public Building? ExperimentRequiredBuilding { get; set; }
|
||||||
public string? AllowedDayOfWeeks { get; set; }
|
public string? AllowedDayOfWeeks { get; set; }
|
||||||
public int? EarliestPeriod { get; set; }
|
public int? EarliestPeriod { get; set; }
|
||||||
public int? LatestPeriod { get; set; }
|
public int? LatestPeriod { get; set; }
|
||||||
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
|
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
|
||||||
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
|
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
|
||||||
|
public ICollection<TeachingTaskAllowedExperimentClassroom> AllowedExperimentClassrooms { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TeachingTaskAllowedClassroom
|
public sealed class TeachingTaskAllowedClassroom
|
||||||
@@ -67,6 +72,31 @@ public sealed class TeachingTaskAllowedClassroom
|
|||||||
public Classroom? Classroom { get; set; }
|
public Classroom? Classroom { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class PublishedScheduleOccurrence : EntityBase
|
||||||
|
{
|
||||||
|
public Guid SchedulePlanId { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public Guid ScheduleEntryId { get; set; }
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public Guid? ClassroomId { get; set; }
|
||||||
|
public int Week { get; set; }
|
||||||
|
public int DayOfWeek { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; }
|
||||||
|
public ScheduleEntryKind Kind { get; set; }
|
||||||
|
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TeachingTaskAllowedExperimentClassroom
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskScheduleConstraintId { get; set; }
|
||||||
|
public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; }
|
||||||
|
public Guid ClassroomId { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class AutomaticScheduleJob : EntityBase
|
public sealed class AutomaticScheduleJob : EntityBase
|
||||||
{
|
{
|
||||||
public Guid SchedulePlanId { get; set; }
|
public Guid SchedulePlanId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
|
||||||
|
public sealed class ClickHouseAnalyticsClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ClickHouseAnalyticsOptions options)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public bool IsEnabled => options.Enabled;
|
||||||
|
|
||||||
|
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ExecuteAsync($"CREATE DATABASE IF NOT EXISTS {options.Database}", cancellationToken);
|
||||||
|
|
||||||
|
await ExecuteAsync($"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {options.Database}.auditEvents
|
||||||
|
(
|
||||||
|
id UUID,
|
||||||
|
occurredAt DateTime64(3, 'UTC'),
|
||||||
|
userId Nullable(UUID),
|
||||||
|
method LowCardinality(String),
|
||||||
|
path String,
|
||||||
|
statusCode UInt16,
|
||||||
|
ipAddress Nullable(String),
|
||||||
|
projectedAt DateTime64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(projectedAt)
|
||||||
|
PARTITION BY toYYYYMM(occurredAt)
|
||||||
|
ORDER BY (id)
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
await ExecuteAsync($"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {options.Database}.attendanceRecords
|
||||||
|
(
|
||||||
|
attendanceSheetId UUID,
|
||||||
|
studentId UUID,
|
||||||
|
attendanceDate Date,
|
||||||
|
teachingTaskId UUID,
|
||||||
|
academicTermId UUID,
|
||||||
|
collegeId UUID,
|
||||||
|
status UInt8,
|
||||||
|
checkInAt Nullable(DateTime64(3, 'UTC')),
|
||||||
|
checkedInMethod Nullable(UInt8),
|
||||||
|
appealStatus UInt8,
|
||||||
|
projectedAt DateTime64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(projectedAt)
|
||||||
|
PARTITION BY toYYYYMM(attendanceDate)
|
||||||
|
ORDER BY (attendanceSheetId, studentId)
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
await ExecuteAsync($"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {options.Database}.gradeStatistics
|
||||||
|
(
|
||||||
|
gradeSheetId UUID,
|
||||||
|
teachingTaskId UUID,
|
||||||
|
courseId UUID,
|
||||||
|
academicTermId UUID,
|
||||||
|
collegeId UUID,
|
||||||
|
academicTermName LowCardinality(String),
|
||||||
|
studentCount UInt32,
|
||||||
|
passedCount UInt32,
|
||||||
|
excellentCount UInt32,
|
||||||
|
averageScore Decimal(8, 2),
|
||||||
|
passRate Decimal(8, 4),
|
||||||
|
excellentRate Decimal(8, 4),
|
||||||
|
calculatedAt DateTime64(3, 'UTC'),
|
||||||
|
projectedAt DateTime64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(projectedAt)
|
||||||
|
PARTITION BY toYYYYMM(calculatedAt)
|
||||||
|
ORDER BY (gradeSheetId)
|
||||||
|
""", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InsertAsync<T>(string table, IReadOnlyCollection<T> rows, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return;
|
||||||
|
var payload = JsonSerializer.Serialize(rows, JsonOptions);
|
||||||
|
await ExecuteAsync(
|
||||||
|
$"INSERT INTO {options.Database}.{table} FORMAT JSONEachRow\n{ToJsonLines(payload)}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonElement[]> QueryAsync(string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(sql + " FORMAT JSON", cancellationToken);
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"ClickHouse 查询失败 ({(int)response.StatusCode}):{content}");
|
||||||
|
using var document = JsonDocument.Parse(content);
|
||||||
|
return document.RootElement.GetProperty("data")
|
||||||
|
.EnumerateArray().Select(x => x.Clone()).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteAsync(string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(sql, cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode) return;
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
throw new HttpRequestException($"ClickHouse 写入失败 ({(int)response.StatusCode}):{content}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<HttpResponseMessage> SendAsync(string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "")
|
||||||
|
{
|
||||||
|
Content = new StringContent(sql, Encoding.UTF8, "text/plain")
|
||||||
|
};
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(
|
||||||
|
$"{options.UserName}:{options.Password}"));
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
return httpClient.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToJsonLines(string json)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
return string.Join('\n', document.RootElement.EnumerateArray().Select(x => x.GetRawText()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
|
||||||
|
public sealed partial class ClickHouseAnalyticsOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "ClickHouseAnalytics";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string Endpoint { get; set; } = "http://localhost:8123";
|
||||||
|
public string Database { get; set; } = "jiaowu_analytics";
|
||||||
|
public string UserName { get; set; } = "jiaowu_analytics";
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
public bool CreateSchemaOnStartup { get; set; } = true;
|
||||||
|
public int SyncIntervalSeconds { get; set; } = 60;
|
||||||
|
public int SourceLookbackDays { get; set; } = 90;
|
||||||
|
public int BatchSize { get; set; } = 1000;
|
||||||
|
|
||||||
|
[GeneratedRegex("^[a-zA-Z_][a-zA-Z0-9_]{0,62}$")]
|
||||||
|
private static partial Regex IdentifierPattern();
|
||||||
|
|
||||||
|
public bool HasValidIdentifiers() =>
|
||||||
|
IdentifierPattern().IsMatch(Database);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects MySQL facts to ClickHouse. The projection is deliberately
|
||||||
|
/// best-effort: business writes never depend on an analytics database.
|
||||||
|
/// ReplacingMergeTree plus FINAL reads make repeated lookback batches safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClickHouseAnalyticsProjectionWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ClickHouseAnalyticsClient client,
|
||||||
|
ClickHouseAnalyticsOptions options,
|
||||||
|
ILogger<ClickHouseAnalyticsProjectionWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!options.Enabled) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (options.CreateSchemaOnStartup)
|
||||||
|
await client.EnsureSchemaAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "ClickHouse 分析表初始化失败,将在下一轮重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(options.SyncIntervalSeconds));
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProjectAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "ClickHouse 分析投影失败,将在下一轮重试。");
|
||||||
|
}
|
||||||
|
} while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProjectAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var projectedAt = DateTime.UtcNow;
|
||||||
|
var from = projectedAt.AddDays(-options.SourceLookbackDays);
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var auditCount = await ProjectAuditsAsync(db, from, projectedAt, cancellationToken);
|
||||||
|
var attendanceCount = await ProjectAttendanceAsync(db, from.Date, projectedAt, cancellationToken);
|
||||||
|
var gradeCount = await ProjectGradesAsync(db, projectedAt, cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"ClickHouse 分析投影完成:审计 {AuditCount},考勤 {AttendanceCount},成绩 {GradeCount}。",
|
||||||
|
auditCount, attendanceCount, gradeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ProjectAuditsAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cursorAt = from;
|
||||||
|
var cursorId = Guid.Empty;
|
||||||
|
var count = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var rows = await db.AuditLogs.AsNoTracking()
|
||||||
|
.Where(x => x.CreatedAt > cursorAt || x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) > 0)
|
||||||
|
.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(options.BatchSize)
|
||||||
|
.Select(x => new { x.Id, OccurredAt = x.CreatedAt, x.UserId, x.Method, x.Path, x.StatusCode, x.IpAddress, ProjectedAt = projectedAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (rows.Count == 0) return count;
|
||||||
|
await client.InsertAsync("auditEvents", rows, cancellationToken);
|
||||||
|
count += rows.Count;
|
||||||
|
cursorAt = rows[^1].OccurredAt;
|
||||||
|
cursorId = rows[^1].Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ProjectAttendanceAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cursorDate = from;
|
||||||
|
var cursorSheetId = Guid.Empty;
|
||||||
|
var cursorStudentId = Guid.Empty;
|
||||||
|
var count = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var rows = await db.AttendanceRecords.AsNoTracking()
|
||||||
|
.Where(x => x.AttendanceSheet!.AttendanceDate > cursorDate ||
|
||||||
|
x.AttendanceSheet.AttendanceDate == cursorDate &&
|
||||||
|
(x.AttendanceSheetId.CompareTo(cursorSheetId) > 0 ||
|
||||||
|
x.AttendanceSheetId == cursorSheetId && x.StudentId.CompareTo(cursorStudentId) > 0))
|
||||||
|
.OrderBy(x => x.AttendanceSheet!.AttendanceDate).ThenBy(x => x.AttendanceSheetId).ThenBy(x => x.StudentId).Take(options.BatchSize)
|
||||||
|
.Select(x => new { x.AttendanceSheetId, x.StudentId, AttendanceDate = x.AttendanceSheet!.AttendanceDate, TeachingTaskId = x.AttendanceSheet.TeachingTaskId, AcademicTermId = x.AttendanceSheet.TeachingTask!.AcademicTermId, CollegeId = x.AttendanceSheet.TeachingTask.Course!.CollegeId, x.Status, x.CheckInAt, x.CheckedInMethod, x.AppealStatus, ProjectedAt = projectedAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (rows.Count == 0) return count;
|
||||||
|
await client.InsertAsync("attendanceRecords", rows, cancellationToken);
|
||||||
|
count += rows.Count;
|
||||||
|
cursorDate = rows[^1].AttendanceDate;
|
||||||
|
cursorSheetId = rows[^1].AttendanceSheetId;
|
||||||
|
cursorStudentId = rows[^1].StudentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ProjectGradesAsync(AppDbContext db, DateTime projectedAt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cursorAt = DateTime.MinValue;
|
||||||
|
var cursorSheetId = Guid.Empty;
|
||||||
|
var count = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var rows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CalculatedAt > cursorAt || x.CalculatedAt == cursorAt && x.GradeSheetId.CompareTo(cursorSheetId) > 0)
|
||||||
|
.OrderBy(x => x.CalculatedAt).ThenBy(x => x.GradeSheetId).Take(options.BatchSize)
|
||||||
|
.Select(x => new { x.GradeSheetId, x.TeachingTaskId, x.CourseId, x.AcademicTermId, CollegeId = x.TeachingTask!.Course!.CollegeId, AcademicTermName = x.TeachingTask.AcademicTerm!.Name, x.StudentCount, x.PassedCount, x.ExcellentCount, x.AverageScore, x.PassRate, x.ExcellentRate, x.CalculatedAt, ProjectedAt = projectedAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (rows.Count == 0) return count;
|
||||||
|
await client.InsertAsync("gradeStatistics", rows, cancellationToken);
|
||||||
|
count += rows.Count;
|
||||||
|
cursorAt = rows[^1].CalculatedAt;
|
||||||
|
cursorSheetId = rows[^1].GradeSheetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
@@ -39,6 +42,9 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
case ExamPublishJobKind.MakeupExam:
|
case ExamPublishJobKind.MakeupExam:
|
||||||
await PublishMakeupExamAsync(job, stoppingToken);
|
await PublishMakeupExamAsync(job, stoppingToken);
|
||||||
break;
|
break;
|
||||||
|
case ExamPublishJobKind.ExperimentProjects:
|
||||||
|
await PublishExperimentProjectsAsync(job, stoppingToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"不支持的考试发布类型:{job.Kind}。");
|
$"不支持的考试发布类型:{job.Kind}。");
|
||||||
@@ -248,6 +254,67 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ids = JsonSerializer.Deserialize<List<Guid>>(job.ProjectIdsJson ?? "[]")?
|
||||||
|
.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
|
||||||
|
if (ids.Count is 0 or > 100)
|
||||||
|
throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。");
|
||||||
|
|
||||||
|
var projects = await db.ExperimentProjects
|
||||||
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.Instructors)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Where(x => ids.Contains(x.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (projects.Count != ids.Count)
|
||||||
|
throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。");
|
||||||
|
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
if (project.Status != ExperimentProjectStatus.Draft)
|
||||||
|
throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。");
|
||||||
|
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||||
|
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
|
||||||
|
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
|
||||||
|
if (!hasSchedule)
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。");
|
||||||
|
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
(project.SelectionStartsAt is null || project.SelectionEndsAt is null ||
|
||||||
|
project.SelectionStartsAt >= project.SelectionEndsAt))
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”未设置有效的选课时间范围。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
x.Instructors.Count == 0))
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”存在未指定指导老师的实验场次。");
|
||||||
|
}
|
||||||
|
|
||||||
|
job.CurrentStep = "正在发布实验项目";
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
var publishedAt = DateTime.UtcNow;
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
project.Status = ExperimentProjectStatus.Published;
|
||||||
|
project.PublishedAt = publishedAt;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId)
|
||||||
|
.Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct);
|
||||||
|
if (userIds.Count == 0) continue;
|
||||||
|
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约";
|
||||||
|
await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布",
|
||||||
|
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
||||||
|
"/experiments", ct, NotificationCategory.Schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
{
|
{
|
||||||
db.ChangeTracker.Clear();
|
db.ChangeTracker.Clear();
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repairs missing or stale materialized grade statistics on a database-
|
||||||
|
/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
|
||||||
|
/// worker batches changes made during bulk imports.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatisticsRefreshWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Database-configured course grade statistics scheduler started.");
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), timeProvider);
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var scheduler = scope.ServiceProvider
|
||||||
|
.GetRequiredService<CourseGradeStatisticsRefreshScheduler>();
|
||||||
|
var queued = await scheduler.EnqueueDueAsync(
|
||||||
|
timeProvider.GetUtcNow().UtcDateTime,
|
||||||
|
stoppingToken);
|
||||||
|
if (queued > 0)
|
||||||
|
logger.LogInformation(
|
||||||
|
"Scheduled course grade statistics scan queued {Count} refresh jobs.",
|
||||||
|
queued);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Scheduled course grade statistics scan failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await timer.WaitForNextTickAsync(stoppingToken)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshScheduler(
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<CourseGradeStatisticsRefreshScheduler> logger)
|
||||||
|
{
|
||||||
|
public async Task<int> EnqueueDueAsync(
|
||||||
|
DateTime utcNow,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ExecuteWithLeaseAsync(async () =>
|
||||||
|
{
|
||||||
|
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||||
|
cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = new CourseGradeStatisticsRefreshSetting();
|
||||||
|
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
var interval = TimeSpan.FromSeconds(
|
||||||
|
Math.Clamp(setting.IntervalSeconds, 10, 86400));
|
||||||
|
if (!setting.IsEnabled ||
|
||||||
|
setting.LastRunAt.HasValue && utcNow < setting.LastRunAt.Value + interval)
|
||||||
|
{
|
||||||
|
if (db.Entry(setting).State == EntityState.Added)
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setting.LastRunAt = utcNow;
|
||||||
|
var queued = await EnqueueStaleCoreAsync(setting.BatchSize, cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return queued;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<int> EnqueueStaleAsync(
|
||||||
|
int batchSize,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ExecuteWithLeaseAsync(async () =>
|
||||||
|
{
|
||||||
|
var queued = await EnqueueStaleCoreAsync(batchSize, cancellationToken);
|
||||||
|
if (queued > 0) await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return queued;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<int> ExecuteWithLeaseAsync(
|
||||||
|
Func<Task<int>> action,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var usesMySqlLease = db.Database.ProviderName?.Contains(
|
||||||
|
"MySql",
|
||||||
|
StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
if (usesMySqlLease && !await TryAcquireMySqlLeaseAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
logger.LogDebug("Another instance owns the grade statistics refresh lease.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await action();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (usesMySqlLease)
|
||||||
|
await ReleaseMySqlLeaseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> EnqueueStaleCoreAsync(
|
||||||
|
int batchSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
batchSize = Math.Clamp(batchSize, 1, 5000);
|
||||||
|
var activeTargets = await (
|
||||||
|
from job in db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||||
|
join sheet in db.GradeSheets.AsNoTracking()
|
||||||
|
on job.GradeSheetId equals sheet.Id
|
||||||
|
where job.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||||
|
job.Status == CourseGradeStatisticsRefreshJobStatus.Running
|
||||||
|
select new CourseTermTarget(
|
||||||
|
sheet.TeachingTask!.CourseId,
|
||||||
|
sheet.TeachingTask.AcademicTermId))
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var active = activeTargets.ToHashSet();
|
||||||
|
|
||||||
|
var rows = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(sheet =>
|
||||||
|
sheet.Status == GradeSheetStatus.Published &&
|
||||||
|
sheet.Records.Any(record => record.TotalScore != null))
|
||||||
|
.Select(sheet => new RefreshCandidate(
|
||||||
|
sheet.Id,
|
||||||
|
sheet.TeachingTask!.CourseId,
|
||||||
|
sheet.TeachingTask.AcademicTermId,
|
||||||
|
sheet.UpdatedAt,
|
||||||
|
sheet.Records
|
||||||
|
.Where(record => record.TotalScore != null)
|
||||||
|
.Max(record => record.UpdatedAt),
|
||||||
|
db.TeachingTaskGradeStatistics
|
||||||
|
.Where(statistic => statistic.GradeSheetId == sheet.Id)
|
||||||
|
.Select(statistic => (DateTime?)statistic.CalculatedAt)
|
||||||
|
.FirstOrDefault()))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var stale = rows
|
||||||
|
.Where(row =>
|
||||||
|
row.CalculatedAt is null ||
|
||||||
|
row.SheetUpdatedAt > row.CalculatedAt ||
|
||||||
|
row.RecordsUpdatedAt > row.CalculatedAt)
|
||||||
|
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
|
||||||
|
.Where(group => !active.Contains(group.Key))
|
||||||
|
.Select(group => group
|
||||||
|
.OrderByDescending(row => row.RecordsUpdatedAt)
|
||||||
|
.ThenByDescending(row => row.SheetUpdatedAt)
|
||||||
|
.First())
|
||||||
|
.Take(batchSize)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var candidate in stale)
|
||||||
|
{
|
||||||
|
var job = new CourseGradeStatisticsRefreshJob
|
||||||
|
{
|
||||||
|
GradeSheetId = candidate.GradeSheetId
|
||||||
|
};
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||||
|
job.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stale.Length == 0) return 0;
|
||||||
|
logger.LogDebug(
|
||||||
|
"Queued {Count} stale course grade statistics targets.",
|
||||||
|
stale.Length);
|
||||||
|
return stale.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryAcquireMySqlLeaseAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await db.Database.OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT GET_LOCK('jiaowu:grade-statistics-refresh', 0);";
|
||||||
|
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
return Convert.ToInt32(result) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReleaseMySqlLeaseAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT RELEASE_LOCK('jiaowu:grade-statistics-refresh');";
|
||||||
|
await command.ExecuteScalarAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to release grade statistics refresh lease.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RefreshCandidate(
|
||||||
|
Guid GradeSheetId,
|
||||||
|
Guid CourseId,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
DateTime SheetUpdatedAt,
|
||||||
|
DateTime RecordsUpdatedAt,
|
||||||
|
DateTime? CalculatedAt);
|
||||||
|
|
||||||
|
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
|
||||||
|
}
|
||||||
@@ -5,13 +5,84 @@ namespace Jiaowu.Api.Infrastructure.Grades;
|
|||||||
|
|
||||||
public static class ExperimentGradeAggregationService
|
public static class ExperimentGradeAggregationService
|
||||||
{
|
{
|
||||||
|
public static async Task RefreshTeachingTaskAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheets = await LoadPublishedSheetsAsync(
|
||||||
|
db,
|
||||||
|
teachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
var existing = await db.ExperimentCourseGrades
|
||||||
|
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||||
|
.ToDictionaryAsync(x => x.StudentId, cancellationToken);
|
||||||
|
var studentIds = sheets
|
||||||
|
.SelectMany(x => x.Scores.Select(score => score.StudentId))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var refreshedAt = DateTime.UtcNow;
|
||||||
|
var totalWeight = sheets.Sum(x => x.ContributionWeight);
|
||||||
|
|
||||||
|
foreach (var studentId in studentIds)
|
||||||
|
{
|
||||||
|
var score = CalculateStudentScore(sheets, studentId);
|
||||||
|
if (!existing.Remove(studentId, out var aggregate))
|
||||||
|
{
|
||||||
|
aggregate = new Domain.Academic.ExperimentCourseGrade
|
||||||
|
{
|
||||||
|
TeachingTaskId = teachingTaskId,
|
||||||
|
StudentId = studentId
|
||||||
|
};
|
||||||
|
db.ExperimentCourseGrades.Add(aggregate);
|
||||||
|
}
|
||||||
|
aggregate.WeightedAverageScore = score;
|
||||||
|
aggregate.TotalWeight = totalWeight;
|
||||||
|
aggregate.PublishedProjectCount = sheets.Count;
|
||||||
|
aggregate.RefreshedAt = refreshedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ExperimentCourseGrades.RemoveRange(existing.Values);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
|
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
Guid teachingTaskId,
|
Guid teachingTaskId,
|
||||||
IReadOnlyCollection<Guid> studentIds,
|
IReadOnlyCollection<Guid> studentIds,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
|
var sheets = await LoadPublishedSheetsAsync(
|
||||||
|
db,
|
||||||
|
teachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
var requestedStudentIds = studentIds.Distinct().ToArray();
|
||||||
|
var persistedScores = await db.ExperimentCourseGrades.AsNoTracking()
|
||||||
|
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||||
|
.WhereIn(requestedStudentIds, x => x.StudentId)
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
x => x.StudentId,
|
||||||
|
x => x.WeightedAverageScore,
|
||||||
|
cancellationToken);
|
||||||
|
var scores = requestedStudentIds.ToDictionary(
|
||||||
|
studentId => studentId,
|
||||||
|
studentId => persistedScores.GetValueOrDefault(studentId));
|
||||||
|
|
||||||
|
return new ExperimentGradeAggregateResult(
|
||||||
|
sheets.Count,
|
||||||
|
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.ContributionWeight)).ToList(),
|
||||||
|
scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<List<PublishedExperimentSheet>> LoadPublishedSheetsAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ExperimentGradeSheets.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.Status ==
|
x.Status ==
|
||||||
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
||||||
@@ -28,40 +99,27 @@ public static class ExperimentGradeAggregationService
|
|||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var scores = new Dictionary<Guid, decimal?>();
|
private static decimal? CalculateStudentScore(
|
||||||
foreach (var studentId in studentIds.Distinct())
|
IReadOnlyCollection<PublishedExperimentSheet> sheets,
|
||||||
|
Guid studentId)
|
||||||
|
{
|
||||||
|
decimal weightedTotal = 0;
|
||||||
|
decimal totalWeight = 0;
|
||||||
|
if (sheets.Count == 0) return null;
|
||||||
|
foreach (var sheet in sheets)
|
||||||
{
|
{
|
||||||
decimal weightedTotal = 0;
|
var score = sheet.Scores.FirstOrDefault(x =>
|
||||||
decimal totalWeight = 0;
|
x.StudentId == studentId);
|
||||||
var complete = sheets.Count > 0;
|
if (score?.TotalScore is not decimal totalScore) return null;
|
||||||
foreach (var sheet in sheets)
|
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||||
{
|
totalWeight += sheet.ContributionWeight;
|
||||||
var score = sheet.Scores.FirstOrDefault(x =>
|
|
||||||
x.StudentId == studentId);
|
|
||||||
if (score?.TotalScore is not decimal totalScore)
|
|
||||||
{
|
|
||||||
complete = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
|
||||||
totalWeight += sheet.ContributionWeight;
|
|
||||||
}
|
|
||||||
scores[studentId] = complete && totalWeight > 0
|
|
||||||
? Math.Round(
|
|
||||||
weightedTotal / totalWeight,
|
|
||||||
1,
|
|
||||||
MidpointRounding.AwayFromZero)
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
return totalWeight > 0
|
||||||
return new ExperimentGradeAggregateResult(
|
? Math.Round(
|
||||||
sheets.Count,
|
weightedTotal / totalWeight,
|
||||||
sheets.Select(x => new ExperimentGradeAggregateProject(
|
1,
|
||||||
x.Id,
|
MidpointRounding.AwayFromZero)
|
||||||
x.Code,
|
: null;
|
||||||
x.Name,
|
|
||||||
x.ContributionWeight)).ToList(),
|
|
||||||
scores);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record PublishedExperimentSheet(
|
private sealed record PublishedExperimentSheet(
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public static class GradeAnalysisWordReportGenerator
|
|||||||
DateTime generatedAt,
|
DateTime generatedAt,
|
||||||
HeaderFooterIds headerFooterIds)
|
HeaderFooterIds headerFooterIds)
|
||||||
{
|
{
|
||||||
var body = mainPart.Document.Body!;
|
var body = mainPart.Document?.Body
|
||||||
|
?? throw new InvalidOperationException("The report document body has not been initialized.");
|
||||||
var summary = report.Summary!;
|
var summary = report.Summary!;
|
||||||
|
|
||||||
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
|
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
|
||||||
@@ -467,9 +468,10 @@ public static class GradeAnalysisWordReportGenerator
|
|||||||
{
|
{
|
||||||
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
||||||
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
||||||
using var path = new SKPath();
|
using var builder = new SKPathBuilder();
|
||||||
path.MoveTo(points[0]);
|
builder.MoveTo(points[0]);
|
||||||
foreach (var point in points.Skip(1)) path.LineTo(point);
|
foreach (var point in points.Skip(1)) builder.LineTo(point);
|
||||||
|
using var path = builder.Detach();
|
||||||
canvas.DrawPath(path, paint);
|
canvas.DrawPath(path, paint);
|
||||||
for (var i = 0; i < points.Length; i++)
|
for (var i = 0; i < points.Length; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Middleware;
|
||||||
|
|
||||||
|
public sealed class SlowRequestLoggingMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ObservabilityOptions options,
|
||||||
|
ILogger<SlowRequestLoggingMiddleware> logger)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (!options.Enabled ||
|
||||||
|
!context.Request.Path.StartsWithSegments("/api"))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startedAt = Stopwatch.GetTimestamp();
|
||||||
|
await next(context);
|
||||||
|
|
||||||
|
var durationMilliseconds = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||||
|
var endpoint = context.GetEndpoint()?.DisplayName ?? context.Request.Path.Value ?? "/api";
|
||||||
|
if (durationMilliseconds >= options.SlowRequestThresholdMilliseconds ||
|
||||||
|
context.Response.StatusCode >= StatusCodes.Status500InternalServerError)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Slow API request completed: {Method} {Path} ({Endpoint}) returned {StatusCode} in {DurationMs:F1} ms, request {RequestId}.",
|
||||||
|
context.Request.Method,
|
||||||
|
context.Request.Path.Value,
|
||||||
|
endpoint,
|
||||||
|
context.Response.StatusCode,
|
||||||
|
durationMilliseconds,
|
||||||
|
context.TraceIdentifier);
|
||||||
|
}
|
||||||
|
else if (options.LogAllApiRequests)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"API request completed: {Method} {Path} ({Endpoint}) returned {StatusCode} in {DurationMs:F1} ms, request {RequestId}.",
|
||||||
|
context.Request.Method,
|
||||||
|
context.Request.Path.Value,
|
||||||
|
endpoint,
|
||||||
|
context.Response.StatusCode,
|
||||||
|
durationMilliseconds,
|
||||||
|
context.TraceIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Diagnostics.Metrics;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
@@ -9,31 +7,10 @@ namespace Jiaowu.Api.Infrastructure.Observability;
|
|||||||
|
|
||||||
public sealed class DatabaseCommandTelemetryInterceptor(
|
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||||
ObservabilityOptions options,
|
ObservabilityOptions options,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||||
: DbCommandInterceptor
|
: DbCommandInterceptor
|
||||||
{
|
{
|
||||||
public const string ActivitySourceName = "Jiaowu.Api.Database";
|
|
||||||
public const string MeterName = "Jiaowu.Api.Database";
|
|
||||||
|
|
||||||
private static readonly ActivitySource ActivitySource =
|
|
||||||
new(ActivitySourceName);
|
|
||||||
private static readonly Meter Meter = new(MeterName);
|
|
||||||
private static readonly Histogram<double> CommandDuration =
|
|
||||||
Meter.CreateHistogram<double>(
|
|
||||||
"jiaowu.db.command.duration",
|
|
||||||
"ms",
|
|
||||||
"EF Core database command duration");
|
|
||||||
private static readonly Counter<long> SlowCommandCount =
|
|
||||||
Meter.CreateCounter<long>(
|
|
||||||
"jiaowu.db.command.slow",
|
|
||||||
"{command}",
|
|
||||||
"EF Core commands exceeding the configured slow-query threshold");
|
|
||||||
private static readonly Counter<long> FailedCommandCount =
|
|
||||||
Meter.CreateCounter<long>(
|
|
||||||
"jiaowu.db.command.failed",
|
|
||||||
"{command}",
|
|
||||||
"Failed EF Core database commands");
|
|
||||||
|
|
||||||
public override DbDataReader ReaderExecuted(
|
public override DbDataReader ReaderExecuted(
|
||||||
DbCommand command,
|
DbCommand command,
|
||||||
CommandExecutedEventData eventData,
|
CommandExecutedEventData eventData,
|
||||||
@@ -138,82 +115,41 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
|||||||
var queryName = GetQueryName(command.CommandText);
|
var queryName = GetQueryName(command.CommandText);
|
||||||
var statementHash = GetStatementHash(command.CommandText);
|
var statementHash = GetStatementHash(command.CommandText);
|
||||||
var provider = GetProviderName(command);
|
var provider = GetProviderName(command);
|
||||||
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
var requestId = httpContextAccessor.HttpContext?.TraceIdentifier ?? "background";
|
||||||
var tags = new TagList
|
|
||||||
{
|
|
||||||
{ "db.system.name", provider },
|
|
||||||
{ "db.operation.name", commandKind },
|
|
||||||
{ "db.query.name", queryName }
|
|
||||||
};
|
|
||||||
if (errorType is not null)
|
|
||||||
tags.Add("error.type", errorType);
|
|
||||||
|
|
||||||
var durationMilliseconds = duration.TotalMilliseconds;
|
var durationMilliseconds = duration.TotalMilliseconds;
|
||||||
CommandDuration.Record(durationMilliseconds, tags);
|
|
||||||
if (errorType is not null)
|
|
||||||
FailedCommandCount.Add(1, tags);
|
|
||||||
|
|
||||||
using var activity = ActivitySource.StartActivity(
|
|
||||||
ActivityKind.Client,
|
|
||||||
Activity.Current?.Context ?? default,
|
|
||||||
startTime: DateTimeOffset.UtcNow - duration,
|
|
||||||
name: queryName);
|
|
||||||
if (activity is not null)
|
|
||||||
{
|
|
||||||
activity.SetTag("db.system.name", provider);
|
|
||||||
activity.SetTag("db.operation.name", commandKind);
|
|
||||||
activity.SetTag("db.query.name", queryName);
|
|
||||||
activity.SetTag("db.statement.hash", statementHash);
|
|
||||||
activity.SetTag(
|
|
||||||
"db.namespace",
|
|
||||||
EmptyToNull(command.Connection?.Database));
|
|
||||||
if (options.IncludeSqlText)
|
|
||||||
{
|
|
||||||
activity.SetTag(
|
|
||||||
"db.query.text",
|
|
||||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
|
||||||
}
|
|
||||||
if (errorType is not null)
|
|
||||||
{
|
|
||||||
activity.SetTag("error.type", errorType);
|
|
||||||
activity.SetStatus(ActivityStatusCode.Error, errorType);
|
|
||||||
}
|
|
||||||
activity.SetEndTime(DateTime.UtcNow);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorType is not null)
|
if (errorType is not null)
|
||||||
{
|
{
|
||||||
logger.LogError(
|
logger.LogError(
|
||||||
"Database command failed after {DurationMs:F1} ms: " +
|
"Database command failed after {DurationMs:F1} ms: " +
|
||||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
"error {ErrorType}, trace {TraceId}).",
|
"error {ErrorType}, request {RequestId}).",
|
||||||
durationMilliseconds,
|
durationMilliseconds,
|
||||||
queryName,
|
queryName,
|
||||||
commandKind,
|
commandKind,
|
||||||
provider,
|
provider,
|
||||||
statementHash,
|
statementHash,
|
||||||
errorType,
|
errorType,
|
||||||
traceId);
|
requestId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
SlowCommandCount.Add(1, tags);
|
|
||||||
if (options.IncludeSqlText)
|
if (options.IncludeSqlText)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Slow database command took {DurationMs:F1} ms: " +
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
"trace {TraceId}). " +
|
"request {RequestId}). " +
|
||||||
"SQL template: {SqlTemplate}",
|
"SQL template: {SqlTemplate}",
|
||||||
durationMilliseconds,
|
durationMilliseconds,
|
||||||
queryName,
|
queryName,
|
||||||
commandKind,
|
commandKind,
|
||||||
provider,
|
provider,
|
||||||
statementHash,
|
statementHash,
|
||||||
traceId,
|
requestId,
|
||||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -221,13 +157,13 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
|||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Slow database command took {DurationMs:F1} ms: " +
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
"trace {TraceId}).",
|
"request {RequestId}).",
|
||||||
durationMilliseconds,
|
durationMilliseconds,
|
||||||
queryName,
|
queryName,
|
||||||
commandKind,
|
commandKind,
|
||||||
provider,
|
provider,
|
||||||
statementHash,
|
statementHash,
|
||||||
traceId);
|
requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +207,6 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
|||||||
return "other_sql";
|
return "other_sql";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? EmptyToNull(string? value) =>
|
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
|
||||||
|
|
||||||
private static string Truncate(string value, int maximumLength) =>
|
private static string Truncate(string value, int maximumLength) =>
|
||||||
value.Length <= maximumLength
|
value.Length <= maximumLength
|
||||||
? value
|
? value
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public sealed class ObservabilityOptions
|
|||||||
|
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
public string ServiceName { get; set; } = "jiaowu-api";
|
public string ServiceName { get; set; } = "jiaowu-api";
|
||||||
|
public bool LogAllApiRequests { get; set; } = true;
|
||||||
|
public int SlowRequestThresholdMilliseconds { get; set; } = 1000;
|
||||||
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
||||||
public bool IncludeSqlText { get; set; }
|
public bool IncludeSqlText { get; set; }
|
||||||
public int MaximumSqlTextLength { get; set; } = 2000;
|
public int MaximumSqlTextLength { get; set; } = 2000;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
||||||
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
||||||
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
|
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
|
||||||
|
public DbSet<CourseGroup> CourseGroups => Set<CourseGroup>();
|
||||||
|
public DbSet<CourseGroupCourse> CourseGroupCourses => Set<CourseGroupCourse>();
|
||||||
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
|
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
|
||||||
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
|
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
|
||||||
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
|
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
|
||||||
@@ -33,11 +35,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<TeacherCourseApplication>();
|
Set<TeacherCourseApplication>();
|
||||||
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
|
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
|
||||||
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
||||||
|
public DbSet<PublishedScheduleOccurrence> PublishedScheduleOccurrences => Set<PublishedScheduleOccurrence>();
|
||||||
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
|
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
|
||||||
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
|
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
|
||||||
Set<TeachingTaskScheduleConstraint>();
|
Set<TeachingTaskScheduleConstraint>();
|
||||||
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
|
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
|
||||||
Set<TeachingTaskAllowedClassroom>();
|
Set<TeachingTaskAllowedClassroom>();
|
||||||
|
public DbSet<TeachingTaskAllowedExperimentClassroom> TeachingTaskAllowedExperimentClassrooms =>
|
||||||
|
Set<TeachingTaskAllowedExperimentClassroom>();
|
||||||
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
|
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
|
||||||
Set<AutomaticScheduleJob>();
|
Set<AutomaticScheduleJob>();
|
||||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||||
@@ -46,6 +51,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<ClassroomReservation>();
|
Set<ClassroomReservation>();
|
||||||
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||||
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||||
|
public DbSet<ExperimentSessionInstructor> ExperimentSessionInstructors =>
|
||||||
|
Set<ExperimentSessionInstructor>();
|
||||||
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||||
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
||||||
Set<ExperimentGradeSheet>();
|
Set<ExperimentGradeSheet>();
|
||||||
@@ -53,6 +60,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<ExperimentGradeItem>();
|
Set<ExperimentGradeItem>();
|
||||||
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
|
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
|
||||||
Set<ExperimentGradeRecord>();
|
Set<ExperimentGradeRecord>();
|
||||||
|
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
|
||||||
|
Set<ExperimentCourseGrade>();
|
||||||
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
|
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
|
||||||
Set<ExperimentGradeItemScore>();
|
Set<ExperimentGradeItemScore>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
@@ -139,6 +148,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<AppUpdateRelease>();
|
Set<AppUpdateRelease>();
|
||||||
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
||||||
Set<SystemFeatureSetting>();
|
Set<SystemFeatureSetting>();
|
||||||
|
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
|
||||||
|
Set<CourseGradeStatisticsRefreshSetting>();
|
||||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||||
|
|
||||||
protected override void ConfigureConventions(
|
protected override void ConfigureConventions(
|
||||||
@@ -157,6 +168,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
configurationBuilder.Properties<TimeOnly>()
|
configurationBuilder.Properties<TimeOnly>()
|
||||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||||
.HaveColumnType("time");
|
.HaveColumnType("time");
|
||||||
|
|
||||||
|
// MySQL DATETIME has no offset or DateTimeKind. All system timestamps
|
||||||
|
// are persisted as UTC, so restore that contract when materializing
|
||||||
|
// them. System.Text.Json will then emit the trailing "Z", allowing
|
||||||
|
// browsers to convert timestamps to the viewer's local time correctly.
|
||||||
|
configurationBuilder.Properties<DateTime>()
|
||||||
|
.HaveConversion<UtcDateTimeConverter>()
|
||||||
|
.HaveColumnType("datetime(6)");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
@@ -511,6 +530,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.RequiredBuildingId)
|
.HasForeignKey(x => x.RequiredBuildingId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.ExperimentRequiredCampus)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ExperimentRequiredCampusId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.ExperimentRequiredBuilding)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ExperimentRequiredBuildingId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<TeachingTaskAllowedClassroom>(entity =>
|
builder.Entity<TeachingTaskAllowedClassroom>(entity =>
|
||||||
@@ -530,6 +557,57 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseGroup>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Code).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.Description).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => x.Code).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseGroupCourse>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.CourseGroupId, x.CourseId }).IsUnique();
|
||||||
|
entity.HasOne(x => x.CourseGroup)
|
||||||
|
.WithMany(x => x.Courses)
|
||||||
|
.HasForeignKey(x => x.CourseGroupId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Course)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.CourseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<PublishedScheduleOccurrence>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.AcademicTermId, x.TeachingTaskId, x.Week });
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.AcademicTermId,
|
||||||
|
x.Week,
|
||||||
|
x.DayOfWeek,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.ClassroomId
|
||||||
|
});
|
||||||
|
entity.HasIndex(x => new { x.SchedulePlanId, x.ClassroomId, x.Week, x.DayOfWeek, x.StartPeriod });
|
||||||
|
entity.HasIndex(x => new { x.ScheduleEntryId, x.Week }).IsUnique();
|
||||||
|
entity.HasOne(x => x.ScheduleEntry).WithMany().HasForeignKey(x => x.ScheduleEntryId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.TeachingTask).WithMany().HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Classroom).WithMany().HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<TeachingTaskAllowedExperimentClassroom>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
|
||||||
|
entity.HasOne(x => x.TeachingTaskScheduleConstraint)
|
||||||
|
.WithMany(x => x.AllowedExperimentClassrooms)
|
||||||
|
.HasForeignKey(x => x.TeachingTaskScheduleConstraintId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Classroom)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<AutomaticScheduleJob>(entity =>
|
builder.Entity<AutomaticScheduleJob>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||||
@@ -614,7 +692,18 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||||
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||||
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
|
entity.Property(x => x.SelectionStartsAt).HasConversion<UtcDateTimeConverter>();
|
||||||
|
entity.Property(x => x.SelectionEndsAt).HasConversion<UtcDateTimeConverter>();
|
||||||
|
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
|
||||||
|
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.Code,
|
||||||
|
x.ScheduleEntryId,
|
||||||
|
x.ScheduleWeek
|
||||||
|
})
|
||||||
|
.IsUnique();
|
||||||
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
.HasForeignKey(x => x.TeachingTaskId)
|
.HasForeignKey(x => x.TeachingTaskId)
|
||||||
@@ -720,6 +809,34 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentSessionInstructor>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.ExperimentSessionId, x.TeacherId })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasIndex(x => x.TeacherId);
|
||||||
|
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Instructors)
|
||||||
|
.HasForeignKey(x => x.ExperimentSessionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeacherId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentCourseGrade>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.TotalWeight).HasPrecision(8, 1);
|
||||||
|
entity.HasIndex(x => new { x.TeachingTaskId, x.StudentId })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasIndex(x => x.StudentId);
|
||||||
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeachingTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<ExperimentGradeItemScore>(entity =>
|
builder.Entity<ExperimentGradeItemScore>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(x => new
|
entity.HasKey(x => new
|
||||||
@@ -994,6 +1111,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
});
|
});
|
||||||
builder.Entity<ExamSession>(entity =>
|
builder.Entity<ExamSession>(entity =>
|
||||||
{
|
{
|
||||||
|
// Exam slot times are China-local wall-clock times, not instants.
|
||||||
|
// Keep their existing API representation offset-free.
|
||||||
|
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
|
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
|
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
|
||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
@@ -1020,6 +1141,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
builder.Entity<ExamRoomAssignment>(entity =>
|
builder.Entity<ExamRoomAssignment>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("ExamRooms");
|
entity.ToTable("ExamRooms");
|
||||||
|
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
|
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
||||||
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||||
entity.HasIndex(x => new
|
entity.HasIndex(x => new
|
||||||
@@ -1108,6 +1231,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
});
|
});
|
||||||
builder.Entity<MakeupExamSession>(entity =>
|
builder.Entity<MakeupExamSession>(entity =>
|
||||||
{
|
{
|
||||||
|
// Makeup-exam slot times follow the same wall-clock convention.
|
||||||
|
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
|
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
@@ -1383,8 +1509,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Title).HasMaxLength(200);
|
entity.Property(x => x.Title).HasMaxLength(200);
|
||||||
entity.Property(x => x.Content).HasColumnType("longtext");
|
entity.Property(x => x.Content).HasColumnType("longtext");
|
||||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
// Each inbox query starts with its recipient. Keep the selected
|
||||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
// sort fields in the index so large inboxes do not need a filesort.
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.CreatedAt, x.Id });
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.IsRead, x.CreatedAt, x.Id });
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt, x.Id });
|
||||||
entity.HasIndex(x => x.MessageDispatchId);
|
entity.HasIndex(x => x.MessageDispatchId);
|
||||||
entity.HasIndex(x => x.CreatedAt);
|
entity.HasIndex(x => x.CreatedAt);
|
||||||
entity.HasOne(x => x.MessageDispatch)
|
entity.HasOne(x => x.MessageDispatch)
|
||||||
@@ -1463,6 +1592,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.Key).IsUnique();
|
entity.HasIndex(x => x.Key).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<CourseGradeStatisticsRefreshSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Key).HasMaxLength(50);
|
||||||
|
entity.HasIndex(x => x.Key).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<OfficialDocument>(entity =>
|
builder.Entity<OfficialDocument>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||||
@@ -1545,3 +1680,15 @@ public sealed class TimeOnlyTimeSpanConverter()
|
|||||||
: ValueConverter<TimeOnly, TimeSpan>(
|
: ValueConverter<TimeOnly, TimeSpan>(
|
||||||
time => time.ToTimeSpan(),
|
time => time.ToTimeSpan(),
|
||||||
value => TimeOnly.FromTimeSpan(value));
|
value => TimeOnly.FromTimeSpan(value));
|
||||||
|
|
||||||
|
public sealed class UtcDateTimeConverter()
|
||||||
|
: ValueConverter<DateTime, DateTime>(
|
||||||
|
value => value.Kind == DateTimeKind.Local
|
||||||
|
? value.ToUniversalTime()
|
||||||
|
: DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||||
|
value => DateTime.SpecifyKind(value, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
public sealed class UnspecifiedDateTimeConverter()
|
||||||
|
: ValueConverter<DateTime, DateTime>(
|
||||||
|
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified),
|
||||||
|
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified));
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260809_48_teaching_task_grade_analytics";
|
"20260809_48_teaching_task_grade_analytics";
|
||||||
private const string SwaggerDocumentationSettingMigration =
|
private const string SwaggerDocumentationSettingMigration =
|
||||||
"20260809_49_swagger_documentation_setting";
|
"20260809_49_swagger_documentation_setting";
|
||||||
|
private const string ExperimentClassroomConstraintsMigration =
|
||||||
|
"20260809_50_experiment_classroom_constraints";
|
||||||
|
private const string SeparateExperimentClassroomScopeMigration =
|
||||||
|
"20260809_51_separate_experiment_classroom_scope";
|
||||||
|
private const string ReusableCourseGroupsMigration =
|
||||||
|
"20260809_52_reusable_course_groups";
|
||||||
|
private const string CourseGradeStatisticsRefreshSettingsMigration =
|
||||||
|
"20260809_53_course_grade_statistics_refresh_settings";
|
||||||
|
private const string ExperimentCourseGradesMigration =
|
||||||
|
"20260809_54_experiment_course_grades";
|
||||||
|
private const string NotificationInboxIndexesMigration =
|
||||||
|
"20260810_55_notification_inbox_indexes";
|
||||||
|
private const string SelfScheduledExperimentSelectionMigration =
|
||||||
|
"20260811_56_self_scheduled_experiment_selection";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -671,6 +685,14 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
TeachingTaskGradeAnalyticsMigration,
|
TeachingTaskGradeAnalyticsMigration,
|
||||||
TeachingTaskGradeAnalyticsStatements,
|
TeachingTaskGradeAnalyticsStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var gradeRefreshSettingsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGradeStatisticsRefreshSettings'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseGradeStatisticsRefreshSettingsMigration,
|
||||||
|
gradeRefreshSettingsExist ? [] : CourseGradeStatisticsRefreshSettingsStatements,
|
||||||
|
cancellationToken);
|
||||||
var swaggerSettingsExist = await db.Database
|
var swaggerSettingsExist = await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
||||||
@@ -679,6 +701,63 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
SwaggerDocumentationSettingMigration,
|
SwaggerDocumentationSettingMigration,
|
||||||
swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements,
|
swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var experimentClassroomConstraintsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'TeachingTaskAllowedExperimentClassrooms'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExperimentClassroomConstraintsMigration,
|
||||||
|
experimentClassroomConstraintsExist ? [] : ExperimentClassroomConstraintStatements,
|
||||||
|
cancellationToken);
|
||||||
|
var experimentScopeColumns = (await db.Database.SqlQueryRaw<string>(
|
||||||
|
"SELECT name AS \"Value\" FROM pragma_table_info('TeachingTaskScheduleConstraints')")
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
SeparateExperimentClassroomScopeMigration,
|
||||||
|
experimentScopeColumns.Contains("ExperimentRequiredCampusId")
|
||||||
|
? []
|
||||||
|
: SeparateExperimentClassroomScopeStatements,
|
||||||
|
cancellationToken);
|
||||||
|
var courseGroupsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGroups'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ReusableCourseGroupsMigration,
|
||||||
|
courseGroupsExist ? [] : ReusableCourseGroupsStatements,
|
||||||
|
cancellationToken);
|
||||||
|
var experimentCourseGradesExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExperimentCourseGrades'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExperimentCourseGradesMigration,
|
||||||
|
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
|
||||||
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
NotificationInboxIndexesMigration,
|
||||||
|
NotificationInboxIndexesStatements,
|
||||||
|
cancellationToken);
|
||||||
|
var experimentProjectColumns = (await db.Database.SqlQueryRaw<string>(
|
||||||
|
"SELECT name AS \"Value\" FROM pragma_table_info('ExperimentProjects')")
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var experimentSessionInstructorsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExperimentSessionInstructors'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
var selfScheduledExperimentSelectionStatements = new List<string>();
|
||||||
|
if (!experimentProjectColumns.Contains("SelectionStartsAt"))
|
||||||
|
selfScheduledExperimentSelectionStatements.Add(SelfScheduledExperimentSelectionStatements[0]);
|
||||||
|
if (!experimentProjectColumns.Contains("SelectionEndsAt"))
|
||||||
|
selfScheduledExperimentSelectionStatements.Add(SelfScheduledExperimentSelectionStatements[1]);
|
||||||
|
if (!experimentSessionInstructorsExist)
|
||||||
|
selfScheduledExperimentSelectionStatements.AddRange(SelfScheduledExperimentSelectionStatements.Skip(2));
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
SelfScheduledExperimentSelectionMigration,
|
||||||
|
selfScheduledExperimentSelectionStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2940,4 +3019,192 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "SystemFeatureSettings" ("Key");
|
ON "SystemFeatureSettings" ("Key");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseGradeStatisticsRefreshSettingsStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshSettings" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshSettings" PRIMARY KEY, "Key" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "IntervalSeconds" INTEGER NOT NULL, "BatchSize" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||||
|
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] NotificationInboxIndexesStatements =
|
||||||
|
[
|
||||||
|
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_IsRead";""",
|
||||||
|
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_Category_CreatedAt";""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_CreatedAt_Id" ON "Notifications" ("UserId", "CreatedAt", "Id");""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_IsRead_CreatedAt_Id" ON "Notifications" ("UserId", "IsRead", "CreatedAt", "Id");""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_Category_CreatedAt_Id" ON "Notifications" ("UserId", "Category", "CreatedAt", "Id");"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExperimentCourseGradesStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentCourseGrades" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentCourseGrades" PRIMARY KEY,
|
||||||
|
"TeachingTaskId" TEXT NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"WeightedAverageScore" TEXT NULL,
|
||||||
|
"TotalWeight" TEXT NOT NULL,
|
||||||
|
"PublishedProjectCount" INTEGER NOT NULL,
|
||||||
|
"RefreshedAt" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId"
|
||||||
|
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentCourseGrades_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""CREATE INDEX "IX_ExperimentCourseGrades_StudentId" ON "ExperimentCourseGrades" ("StudentId");""",
|
||||||
|
"""CREATE UNIQUE INDEX "IX_ExperimentCourseGrades_TeachingTaskId_StudentId" ON "ExperimentCourseGrades" ("TeachingTaskId", "StudentId");""",
|
||||||
|
"""
|
||||||
|
INSERT INTO "ExperimentCourseGrades"
|
||||||
|
("Id", "TeachingTaskId", "StudentId", "WeightedAverageScore",
|
||||||
|
"TotalWeight", "PublishedProjectCount", "RefreshedAt",
|
||||||
|
"CreatedAt", "UpdatedAt")
|
||||||
|
SELECT
|
||||||
|
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' ||
|
||||||
|
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||||
|
substr('89ab', abs(random()) % 4 + 1, 1) ||
|
||||||
|
substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))),
|
||||||
|
project."TeachingTaskId",
|
||||||
|
record."StudentId",
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM "ExperimentGradeSheets" AS all_sheet
|
||||||
|
INNER JOIN "ExperimentProjects" AS all_project
|
||||||
|
ON all_project."Id" = all_sheet."ExperimentProjectId"
|
||||||
|
WHERE all_sheet."Status" = 4
|
||||||
|
AND all_project."TeachingTaskId" = project."TeachingTaskId")
|
||||||
|
AND SUM(CASE WHEN record."TotalScore" IS NULL THEN 1 ELSE 0 END) = 0
|
||||||
|
THEN ROUND(
|
||||||
|
SUM(record."TotalScore" * sheet."ContributionWeight") /
|
||||||
|
SUM(sheet."ContributionWeight"), 1)
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(SUM(all_sheet."ContributionWeight"), 0)
|
||||||
|
FROM "ExperimentGradeSheets" AS all_sheet
|
||||||
|
INNER JOIN "ExperimentProjects" AS all_project
|
||||||
|
ON all_project."Id" = all_sheet."ExperimentProjectId"
|
||||||
|
WHERE all_sheet."Status" = 4
|
||||||
|
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM "ExperimentGradeSheets" AS all_sheet
|
||||||
|
INNER JOIN "ExperimentProjects" AS all_project
|
||||||
|
ON all_project."Id" = all_sheet."ExperimentProjectId"
|
||||||
|
WHERE all_sheet."Status" = 4
|
||||||
|
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
|
||||||
|
strftime('%Y-%m-%d %H:%M:%f', 'now'),
|
||||||
|
strftime('%Y-%m-%d %H:%M:%f', 'now'),
|
||||||
|
strftime('%Y-%m-%d %H:%M:%f', 'now')
|
||||||
|
FROM "ExperimentGradeRecords" AS record
|
||||||
|
INNER JOIN "ExperimentGradeSheets" AS sheet
|
||||||
|
ON sheet."Id" = record."ExperimentGradeSheetId"
|
||||||
|
INNER JOIN "ExperimentProjects" AS project
|
||||||
|
ON project."Id" = sheet."ExperimentProjectId"
|
||||||
|
WHERE sheet."Status" = 4
|
||||||
|
GROUP BY project."TeachingTaskId", record."StudentId";
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExperimentClassroomConstraintStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "TeachingTaskAllowedExperimentClassrooms" (
|
||||||
|
"TeachingTaskScheduleConstraintId" TEXT NOT NULL,
|
||||||
|
"ClassroomId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "PK_TeachingTaskAllowedExperimentClassrooms"
|
||||||
|
PRIMARY KEY ("TeachingTaskScheduleConstraintId", "ClassroomId"),
|
||||||
|
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Constraints"
|
||||||
|
FOREIGN KEY ("TeachingTaskScheduleConstraintId")
|
||||||
|
REFERENCES "TeachingTaskScheduleConstraints" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Classrooms"
|
||||||
|
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_TeachingTaskAllowedExperimentClassrooms_ClassroomId"
|
||||||
|
ON "TeachingTaskAllowedExperimentClassrooms" ("ClassroomId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] SeparateExperimentClassroomScopeStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "TeachingTaskScheduleConstraints"
|
||||||
|
ADD COLUMN "ExperimentRequiredCampusId" TEXT NULL;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE "TeachingTaskScheduleConstraints"
|
||||||
|
ADD COLUMN "ExperimentRequiredBuildingId" TEXT NULL;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId"
|
||||||
|
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredCampusId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId"
|
||||||
|
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredBuildingId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] SelfScheduledExperimentSelectionStatements =
|
||||||
|
[
|
||||||
|
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionStartsAt\" TEXT NULL;",
|
||||||
|
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionEndsAt\" TEXT NULL;",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentSessionInstructors" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessionInstructors" PRIMARY KEY,
|
||||||
|
"ExperimentSessionId" TEXT NOT NULL,
|
||||||
|
"TeacherId" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentSessionInstructors_ExperimentSessions_ExperimentSessionId"
|
||||||
|
FOREIGN KEY ("ExperimentSessionId") REFERENCES "ExperimentSessions" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentSessionInstructors_Teachers_TeacherId"
|
||||||
|
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"CREATE UNIQUE INDEX \"IX_ExperimentSessionInstructors_ExperimentSessionId_TeacherId\" ON \"ExperimentSessionInstructors\" (\"ExperimentSessionId\", \"TeacherId\");",
|
||||||
|
"CREATE INDEX \"IX_ExperimentSessionInstructors_TeacherId\" ON \"ExperimentSessionInstructors\" (\"TeacherId\");"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ReusableCourseGroupsStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "CourseGroups" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroups" PRIMARY KEY,
|
||||||
|
"Code" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"Description" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_CourseGroups_Code" ON "CourseGroups" ("Code");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "CourseGroupCourses" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroupCourses" PRIMARY KEY,
|
||||||
|
"CourseGroupId" TEXT NOT NULL,
|
||||||
|
"CourseId" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_CourseGroupCourses_CourseGroups_CourseGroupId"
|
||||||
|
FOREIGN KEY ("CourseGroupId") REFERENCES "CourseGroups" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_CourseGroupCourses_Courses_CourseId"
|
||||||
|
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_CourseGroupCourses_CourseGroupId_CourseId"
|
||||||
|
ON "CourseGroupCourses" ("CourseGroupId", "CourseId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_CourseGroupCourses_CourseId" ON "CourseGroupCourses" ("CourseId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+6631
File diff suppressed because it is too large
Load Diff
+52
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExperimentClassroomConstraints : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeachingTaskAllowedExperimentClassrooms",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TeachingTaskScheduleConstraintId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeachingTaskAllowedExperimentClassrooms", x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeachingTaskAllowedExperimentClassrooms_Classrooms_Classroom~",
|
||||||
|
column: x => x.ClassroomId,
|
||||||
|
principalTable: "Classrooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeachingTaskAllowedExperimentClassrooms_TeachingTaskSchedule~",
|
||||||
|
column: x => x.TeachingTaskScheduleConstraintId,
|
||||||
|
principalTable: "TeachingTaskScheduleConstraints",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeachingTaskAllowedExperimentClassrooms_ClassroomId",
|
||||||
|
table: "TeachingTaskAllowedExperimentClassrooms",
|
||||||
|
column: "ClassroomId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeachingTaskAllowedExperimentClassrooms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6655
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SeparateExperimentClassroomScope : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ExperimentRequiredBuildingId",
|
||||||
|
table: "TeachingTaskScheduleConstraints",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ExperimentRequiredCampusId",
|
||||||
|
table: "TeachingTaskScheduleConstraints",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId",
|
||||||
|
table: "TeachingTaskScheduleConstraints",
|
||||||
|
column: "ExperimentRequiredBuildingId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId",
|
||||||
|
table: "TeachingTaskScheduleConstraints",
|
||||||
|
column: "ExperimentRequiredCampusId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_TeachingTaskScheduleConstraints_Buildings_ExperimentRequired~",
|
||||||
|
table: "TeachingTaskScheduleConstraints",
|
||||||
|
column: "ExperimentRequiredBuildingId",
|
||||||
|
principalTable: "Buildings",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_TeachingTaskScheduleConstraints_Campuses_ExperimentRequiredC~",
|
||||||
|
table: "TeachingTaskScheduleConstraints",
|
||||||
|
column: "ExperimentRequiredCampusId",
|
||||||
|
principalTable: "Campuses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_TeachingTaskScheduleConstraints_Buildings_ExperimentRequired~",
|
||||||
|
table: "TeachingTaskScheduleConstraints");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_TeachingTaskScheduleConstraints_Campuses_ExperimentRequiredC~",
|
||||||
|
table: "TeachingTaskScheduleConstraints");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId",
|
||||||
|
table: "TeachingTaskScheduleConstraints");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId",
|
||||||
|
table: "TeachingTaskScheduleConstraints");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExperimentRequiredBuildingId",
|
||||||
|
table: "TeachingTaskScheduleConstraints");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExperimentRequiredCampusId",
|
||||||
|
table: "TeachingTaskScheduleConstraints");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6739
File diff suppressed because it is too large
Load Diff
+90
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class PublishedTimetableOccurrences : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PublishedScheduleOccurrences",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ScheduleEntryId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
Week = table.Column<int>(type: "int", nullable: false),
|
||||||
|
DayOfWeek = table.Column<int>(type: "int", nullable: false),
|
||||||
|
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Kind = 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_PublishedScheduleOccurrences", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PublishedScheduleOccurrences_Classrooms_ClassroomId",
|
||||||
|
column: x => x.ClassroomId,
|
||||||
|
principalTable: "Classrooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PublishedScheduleOccurrences_ScheduleEntries_ScheduleEntryId",
|
||||||
|
column: x => x.ScheduleEntryId,
|
||||||
|
principalTable: "ScheduleEntries",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PublishedScheduleOccurrences_TeachingTasks_TeachingTaskId",
|
||||||
|
column: x => x.TeachingTaskId,
|
||||||
|
principalTable: "TeachingTasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PublishedScheduleOccurrences_AcademicTermId_TeachingTaskId_W~",
|
||||||
|
table: "PublishedScheduleOccurrences",
|
||||||
|
columns: new[] { "AcademicTermId", "TeachingTaskId", "Week" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PublishedScheduleOccurrences_ClassroomId",
|
||||||
|
table: "PublishedScheduleOccurrences",
|
||||||
|
column: "ClassroomId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PublishedScheduleOccurrences_ScheduleEntryId_Week",
|
||||||
|
table: "PublishedScheduleOccurrences",
|
||||||
|
columns: new[] { "ScheduleEntryId", "Week" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PublishedScheduleOccurrences_SchedulePlanId_ClassroomId_Week~",
|
||||||
|
table: "PublishedScheduleOccurrences",
|
||||||
|
columns: new[] { "SchedulePlanId", "ClassroomId", "Week", "DayOfWeek", "StartPeriod" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PublishedScheduleOccurrences_TeachingTaskId",
|
||||||
|
table: "PublishedScheduleOccurrences",
|
||||||
|
column: "TeachingTaskId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PublishedScheduleOccurrences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6741
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class OptimizePublishedTimetableOccurrenceLookup : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PublishedScheduleOccurrences_AcademicTermId_Week_DayOfWeek_S~",
|
||||||
|
table: "PublishedScheduleOccurrences",
|
||||||
|
columns: new[] { "AcademicTermId", "Week", "DayOfWeek", "StartPeriod", "ClassroomId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_PublishedScheduleOccurrences_AcademicTermId_Week_DayOfWeek_S~",
|
||||||
|
table: "PublishedScheduleOccurrences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6827
File diff suppressed because it is too large
Load Diff
+87
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReusableCourseGroups : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Description = 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_CourseGroups", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseGroupCourses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CourseGroupId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CourseGroupCourses", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CourseGroupCourses_CourseGroups_CourseGroupId",
|
||||||
|
column: x => x.CourseGroupId,
|
||||||
|
principalTable: "CourseGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CourseGroupCourses_Courses_CourseId",
|
||||||
|
column: x => x.CourseId,
|
||||||
|
principalTable: "Courses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGroupCourses_CourseGroupId_CourseId",
|
||||||
|
table: "CourseGroupCourses",
|
||||||
|
columns: new[] { "CourseGroupId", "CourseId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGroupCourses_CourseId",
|
||||||
|
table: "CourseGroupCourses",
|
||||||
|
column: "CourseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGroups_Code",
|
||||||
|
table: "CourseGroups",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseGroupCourses");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseGroups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260809112000_AllowAllScheduledExperimentLessons")]
|
||||||
|
partial class AllowAllScheduledExperimentLessons
|
||||||
|
{
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
public partial class AllowAllScheduledExperimentLessons : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// MySQL 可能将旧唯一索引用于外键支撑。先提供同列的普通索引,
|
||||||
|
// 再替换业务唯一索引,避免线上迁移因外键依赖而中断。
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code" });
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260809114000_SplitCentralizedExperimentProjectsByWeek")]
|
||||||
|
partial class SplitCentralizedExperimentProjectsByWeek
|
||||||
|
{
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||||
|
|
||||||
|
public partial class SplitCentralizedExperimentProjectsByWeek : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ExecuteWhenMissing(
|
||||||
|
migrationBuilder,
|
||||||
|
"COLUMNS",
|
||||||
|
"COLUMN_NAME = 'ScheduleWeek'",
|
||||||
|
"ALTER TABLE `ExperimentProjects` ADD COLUMN `ScheduleWeek` int NULL");
|
||||||
|
|
||||||
|
ExecuteWhenPresent(
|
||||||
|
migrationBuilder,
|
||||||
|
"STATISTICS",
|
||||||
|
"INDEX_NAME = 'IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId'",
|
||||||
|
"DROP INDEX `IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId` ON `ExperimentProjects`");
|
||||||
|
|
||||||
|
ExecuteWhenMissing(
|
||||||
|
migrationBuilder,
|
||||||
|
"STATISTICS",
|
||||||
|
"INDEX_NAME = 'IX_ExpProj_Task_Code_Entry_Week'",
|
||||||
|
"CREATE UNIQUE INDEX `IX_ExpProj_Task_Code_Entry_Week` ON `ExperimentProjects` (`TeachingTaskId`, `Code`, `ScheduleEntryId`, `ScheduleWeek`)");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExpProj_Task_Code_Entry_Week",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ScheduleWeek",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExecuteWhenMissing(
|
||||||
|
MigrationBuilder migrationBuilder,
|
||||||
|
string informationSchemaTable,
|
||||||
|
string condition,
|
||||||
|
string command)
|
||||||
|
{
|
||||||
|
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "= 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExecuteWhenPresent(
|
||||||
|
MigrationBuilder migrationBuilder,
|
||||||
|
string informationSchemaTable,
|
||||||
|
string condition,
|
||||||
|
string command)
|
||||||
|
{
|
||||||
|
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "> 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExecuteConditionally(
|
||||||
|
MigrationBuilder migrationBuilder,
|
||||||
|
string informationSchemaTable,
|
||||||
|
string condition,
|
||||||
|
string command,
|
||||||
|
string comparison)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql($"SET @jiaowu_exists = (SELECT COUNT(*) FROM `information_schema`.`{informationSchemaTable}` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'ExperimentProjects' AND {condition})");
|
||||||
|
migrationBuilder.Sql($"SET @jiaowu_sql = IF(@jiaowu_exists {comparison}, '{command}', 'SELECT 1')");
|
||||||
|
migrationBuilder.Sql("PREPARE jiaowu_migration_statement FROM @jiaowu_sql");
|
||||||
|
migrationBuilder.Sql("EXECUTE jiaowu_migration_statement");
|
||||||
|
migrationBuilder.Sql("DEALLOCATE PREPARE jiaowu_migration_statement");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
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("20260809121000_ExperimentPublishJobPayload")]
|
||||||
|
public partial class ExperimentPublishJobPayload : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamPublishJobs",
|
||||||
|
type: "longtext",
|
||||||
|
maxLength: 5000,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamPublishJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
+6870
File diff suppressed because it is too large
Load Diff
+48
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseGradeStatisticsRefreshSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
IntervalSeconds = table.Column<int>(type: "int", nullable: false),
|
||||||
|
BatchSize = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LastRunAt = 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_CourseGradeStatisticsRefreshSettings", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseGradeStatisticsRefreshSettings_Key",
|
||||||
|
table: "CourseGradeStatisticsRefreshSettings",
|
||||||
|
column: "Key",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseGradeStatisticsRefreshSettings");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6873
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExamArrangementJobPayload : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamArrangementJobs",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamArrangementJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6934
File diff suppressed because it is too large
Load Diff
+113
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class PersistExperimentCourseGrades : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentCourseGrades",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
WeightedAverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||||
|
TotalWeight = table.Column<decimal>(type: "decimal(8,1)", precision: 8, scale: 1, nullable: false),
|
||||||
|
PublishedProjectCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
RefreshedAt = table.Column<DateTime>(type: "datetime(6)", 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_ExperimentCourseGrades", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentCourseGrades_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId",
|
||||||
|
column: x => x.TeachingTaskId,
|
||||||
|
principalTable: "TeachingTasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentCourseGrades_StudentId",
|
||||||
|
table: "ExperimentCourseGrades",
|
||||||
|
column: "StudentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentCourseGrades_TeachingTaskId_StudentId",
|
||||||
|
table: "ExperimentCourseGrades",
|
||||||
|
columns: new[] { "TeachingTaskId", "StudentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO `ExperimentCourseGrades`
|
||||||
|
(`Id`, `TeachingTaskId`, `StudentId`, `WeightedAverageScore`,
|
||||||
|
`TotalWeight`, `PublishedProjectCount`, `RefreshedAt`,
|
||||||
|
`CreatedAt`, `UpdatedAt`)
|
||||||
|
SELECT
|
||||||
|
UUID(),
|
||||||
|
project.`TeachingTaskId`,
|
||||||
|
record.`StudentId`,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `ExperimentGradeSheets` AS all_sheet
|
||||||
|
INNER JOIN `ExperimentProjects` AS all_project
|
||||||
|
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
|
||||||
|
WHERE all_sheet.`Status` = 4
|
||||||
|
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`)
|
||||||
|
AND SUM(CASE WHEN record.`TotalScore` IS NULL THEN 1 ELSE 0 END) = 0
|
||||||
|
THEN ROUND(
|
||||||
|
SUM(record.`TotalScore` * sheet.`ContributionWeight`) /
|
||||||
|
SUM(sheet.`ContributionWeight`), 1)
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(SUM(all_sheet.`ContributionWeight`), 0)
|
||||||
|
FROM `ExperimentGradeSheets` AS all_sheet
|
||||||
|
INNER JOIN `ExperimentProjects` AS all_project
|
||||||
|
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
|
||||||
|
WHERE all_sheet.`Status` = 4
|
||||||
|
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `ExperimentGradeSheets` AS all_sheet
|
||||||
|
INNER JOIN `ExperimentProjects` AS all_project
|
||||||
|
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
|
||||||
|
WHERE all_sheet.`Status` = 4
|
||||||
|
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
|
||||||
|
UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)
|
||||||
|
FROM `ExperimentGradeRecords` AS record
|
||||||
|
INNER JOIN `ExperimentGradeSheets` AS sheet
|
||||||
|
ON sheet.`Id` = record.`ExperimentGradeSheetId`
|
||||||
|
INNER JOIN `ExperimentProjects` AS project
|
||||||
|
ON project.`Id` = sheet.`ExperimentProjectId`
|
||||||
|
WHERE sheet.`Status` = 4
|
||||||
|
GROUP BY project.`TeachingTaskId`, record.`StudentId`;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentCourseGrades");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6936
File diff suppressed because it is too large
Load Diff
+63
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class OptimizeNotificationInboxIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_UserId_IsRead",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_UserId_Category_CreatedAt_Id",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "UserId", "Category", "CreatedAt", "Id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_UserId_CreatedAt_Id",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "UserId", "CreatedAt", "Id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_UserId_IsRead_CreatedAt_Id",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "UserId", "IsRead", "CreatedAt", "Id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_UserId_Category_CreatedAt_Id",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_UserId_CreatedAt_Id",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Notifications_UserId_IsRead_CreatedAt_Id",
|
||||||
|
table: "Notifications");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "UserId", "Category", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_UserId_IsRead",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "UserId", "IsRead" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6991
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSelfScheduledExperimentSelectionWindowsAndInstructors : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "SelectionEndsAt",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "SelectionStartsAt",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentSessionInstructors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentSessionInstructors", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentSessionInstructors_ExperimentSessions_ExperimentSe~",
|
||||||
|
column: x => x.ExperimentSessionId,
|
||||||
|
principalTable: "ExperimentSessions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentSessionInstructors_Teachers_TeacherId",
|
||||||
|
column: x => x.TeacherId,
|
||||||
|
principalTable: "Teachers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentSessionInstructors_ExperimentSessionId_TeacherId",
|
||||||
|
table: "ExperimentSessionInstructors",
|
||||||
|
columns: new[] { "ExperimentSessionId", "TeacherId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentSessionInstructors_TeacherId",
|
||||||
|
table: "ExperimentSessionInstructors",
|
||||||
|
column: "TeacherId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentSessionInstructors");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SelectionEndsAt",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SelectionStartsAt",
|
||||||
|
table: "ExperimentProjects");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+401
-5
@@ -1098,6 +1098,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("BatchSize")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("IntervalSeconds")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastRunAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CourseGradeStatisticsRefreshSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CourseGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroupCourse", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CourseGroupId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CourseId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CourseId");
|
||||||
|
|
||||||
|
b.HasIndex("CourseGroupId", "CourseId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CourseGroupCourses");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1749,6 +1848,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("ProcessedSessions")
|
b.Property<int>("ProcessedSessions")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectIdsJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<Guid?>("RequestedByUserId")
|
b.Property<Guid?>("RequestedByUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -1851,6 +1953,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid>("PlanId")
|
b.Property<Guid>("PlanId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectIdsJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<Guid?>("RequestedByUserId")
|
b.Property<Guid?>("RequestedByUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -2158,6 +2263,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("ExperimentBookings");
|
b.ToTable("ExperimentBookings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("PublishedProjectCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RefreshedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("StudentId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeachingTaskId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalWeight")
|
||||||
|
.HasPrecision(8, 1)
|
||||||
|
.HasColumnType("decimal(8,1)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("WeightedAverageScore")
|
||||||
|
.HasPrecision(5, 1)
|
||||||
|
.HasColumnType("decimal(5,1)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.HasIndex("TeachingTaskId", "StudentId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ExperimentCourseGrades");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2370,6 +2517,15 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid?>("ScheduleEntryId")
|
b.Property<Guid?>("ScheduleEntryId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int?>("ScheduleWeek")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SelectionEndsAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SelectionStartsAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateTime>("StartDate")
|
b.Property<DateTime>("StartDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
@@ -2386,11 +2542,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("ScheduleEntryId");
|
b.HasIndex("ScheduleEntryId");
|
||||||
|
|
||||||
b.HasIndex("TeachingTaskId", "Code")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Status", "StartDate", "EndDate");
|
b.HasIndex("Status", "StartDate", "EndDate");
|
||||||
|
|
||||||
|
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("ExperimentProjects");
|
b.ToTable("ExperimentProjects");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2446,6 +2602,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("ExperimentSessions");
|
b.ToTable("ExperimentSessions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSessionInstructor", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ExperimentSessionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeacherId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeacherId");
|
||||||
|
|
||||||
|
b.HasIndex("ExperimentSessionId", "TeacherId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ExperimentSessionInstructors");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -3267,9 +3451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("MessageDispatchId");
|
b.HasIndex("MessageDispatchId");
|
||||||
|
|
||||||
b.HasIndex("UserId", "IsRead");
|
b.HasIndex("UserId", "CreatedAt", "Id");
|
||||||
|
|
||||||
b.HasIndex("UserId", "Category", "CreatedAt");
|
b.HasIndex("UserId", "Category", "CreatedAt", "Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "IsRead", "CreatedAt", "Id");
|
||||||
|
|
||||||
b.ToTable("Notifications");
|
b.ToTable("Notifications");
|
||||||
});
|
});
|
||||||
@@ -3499,6 +3685,66 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("OtherExamResults");
|
b.ToTable("OtherExamResults");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.PublishedScheduleOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("AcademicTermId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ClassroomId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("DayOfWeek")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("PeriodCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("ScheduleEntryId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SchedulePlanId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("StartPeriod")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeachingTaskId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Week")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClassroomId");
|
||||||
|
|
||||||
|
b.HasIndex("TeachingTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("ScheduleEntryId", "Week")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("AcademicTermId", "TeachingTaskId", "Week");
|
||||||
|
|
||||||
|
b.HasIndex("AcademicTermId", "Week", "DayOfWeek", "StartPeriod", "ClassroomId");
|
||||||
|
|
||||||
|
b.HasIndex("SchedulePlanId", "ClassroomId", "Week", "DayOfWeek", "StartPeriod");
|
||||||
|
|
||||||
|
b.ToTable("PublishedScheduleOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -4102,6 +4348,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("TeachingTaskAllowedClassrooms");
|
b.ToTable("TeachingTaskAllowedClassrooms");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedExperimentClassroom", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("TeachingTaskScheduleConstraintId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ClassroomId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId");
|
||||||
|
|
||||||
|
b.HasIndex("ClassroomId");
|
||||||
|
|
||||||
|
b.ToTable("TeachingTaskAllowedExperimentClassrooms");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("TeachingTaskId")
|
b.Property<Guid>("TeachingTaskId")
|
||||||
@@ -4257,6 +4518,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int?>("EarliestPeriod")
|
b.Property<int?>("EarliestPeriod")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ExperimentRequiredBuildingId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ExperimentRequiredCampusId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<int?>("LatestPeriod")
|
b.Property<int?>("LatestPeriod")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4277,6 +4544,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExperimentRequiredBuildingId");
|
||||||
|
|
||||||
|
b.HasIndex("ExperimentRequiredCampusId");
|
||||||
|
|
||||||
b.HasIndex("RequiredBuildingId");
|
b.HasIndex("RequiredBuildingId");
|
||||||
|
|
||||||
b.HasIndex("RequiredCampusId");
|
b.HasIndex("RequiredCampusId");
|
||||||
@@ -5167,6 +5438,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroupCourse", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.CourseGroup", "CourseGroup")
|
||||||
|
.WithMany("Courses")
|
||||||
|
.HasForeignKey("CourseGroupId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CourseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Course");
|
||||||
|
|
||||||
|
b.Navigation("CourseGroup");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||||
@@ -5591,6 +5881,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Student");
|
b.Navigation("Student");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeachingTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
|
||||||
|
b.Navigation("TeachingTask");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")
|
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")
|
||||||
@@ -5695,6 +6004,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("ExperimentProject");
|
b.Navigation("ExperimentProject");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSessionInstructor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentSession", "ExperimentSession")
|
||||||
|
.WithMany("Instructors")
|
||||||
|
.HasForeignKey("ExperimentSessionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeacherId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ExperimentSession");
|
||||||
|
|
||||||
|
b.Navigation("Teacher");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
||||||
@@ -6021,6 +6349,32 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Student");
|
b.Navigation("Student");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.PublishedScheduleOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ClassroomId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.ScheduleEntry", "ScheduleEntry")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ScheduleEntryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeachingTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Classroom");
|
||||||
|
|
||||||
|
b.Navigation("ScheduleEntry");
|
||||||
|
|
||||||
|
b.Navigation("TeachingTask");
|
||||||
|
});
|
||||||
|
|
||||||
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")
|
||||||
@@ -6198,6 +6552,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("TeachingTaskScheduleConstraint");
|
b.Navigation("TeachingTaskScheduleConstraint");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedExperimentClassroom", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ClassroomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint")
|
||||||
|
.WithMany("AllowedExperimentClassrooms")
|
||||||
|
.HasForeignKey("TeachingTaskScheduleConstraintId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Classroom");
|
||||||
|
|
||||||
|
b.Navigation("TeachingTaskScheduleConstraint");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
|
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
|
||||||
@@ -6261,6 +6634,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "ExperimentRequiredBuilding")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExperimentRequiredBuildingId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "ExperimentRequiredCampus")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ExperimentRequiredCampusId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("RequiredBuildingId")
|
.HasForeignKey("RequiredBuildingId")
|
||||||
@@ -6277,6 +6660,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ExperimentRequiredBuilding");
|
||||||
|
|
||||||
|
b.Navigation("ExperimentRequiredCampus");
|
||||||
|
|
||||||
b.Navigation("RequiredBuilding");
|
b.Navigation("RequiredBuilding");
|
||||||
|
|
||||||
b.Navigation("RequiredCampus");
|
b.Navigation("RequiredCampus");
|
||||||
@@ -6406,6 +6793,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("RequiredByCourses");
|
b.Navigation("RequiredByCourses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Courses");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Enrollments");
|
b.Navigation("Enrollments");
|
||||||
@@ -6504,6 +6896,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Bookings");
|
b.Navigation("Bookings");
|
||||||
|
|
||||||
|
b.Navigation("Instructors");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
@@ -6585,6 +6979,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("AllowedClassrooms");
|
b.Navigation("AllowedClassrooms");
|
||||||
|
|
||||||
|
b.Navigation("AllowedExperimentClassrooms");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.Include(x => x.AllowedExperimentClassrooms)
|
||||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||||
var classrooms = await db.Classrooms.AsNoTracking()
|
var classrooms = await db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled)
|
||||||
@@ -253,8 +254,15 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
|
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
|
||||||
var dayLoad = entries.Count(x => x.DayOfWeek == day);
|
var dayLoad = entries.Count(x => x.DayOfWeek == day);
|
||||||
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
|
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
|
||||||
|
var experimentGeneralClassroomPenalty =
|
||||||
|
kind == ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.AllowedExperimentVenueNatures == 0 &&
|
||||||
|
room?.TeachingVenueNature == TeachingVenueNature.GeneralClassroom
|
||||||
|
? 100_000
|
||||||
|
: 0;
|
||||||
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
|
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
|
||||||
roomWaste / 10 + startWeek;
|
roomWaste / 10 + startWeek +
|
||||||
|
experimentGeneralClassroomPenalty;
|
||||||
candidates.Add((proposed, score));
|
candidates.Add((proposed, score));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +287,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
var allowedRoomIds = constraint?.AllowedClassrooms
|
var allowedRoomIds = constraint?.AllowedClassrooms
|
||||||
.Select(x => x.ClassroomId)
|
.Select(x => x.ClassroomId)
|
||||||
.ToHashSet() ?? [];
|
.ToHashSet() ?? [];
|
||||||
|
var allowedExperimentRoomIds = constraint?.AllowedExperimentClassrooms
|
||||||
|
.Select(x => x.ClassroomId)
|
||||||
|
.ToHashSet() ?? [];
|
||||||
var minimumCapacity = Math.Max(
|
var minimumCapacity = Math.Max(
|
||||||
task.Capacity,
|
task.Capacity,
|
||||||
task.Classes.Sum(x =>
|
task.Classes.Sum(x =>
|
||||||
@@ -286,16 +297,25 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
student.Status == StudentStatus.Active) ?? 0));
|
student.Status == StudentStatus.Active) ?? 0));
|
||||||
return classrooms.Where(room =>
|
return classrooms.Where(room =>
|
||||||
room.Capacity >= minimumCapacity &&
|
room.Capacity >= minimumCapacity &&
|
||||||
(constraint?.RequiredCampusId is not Guid requiredCampusId ||
|
(kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiredCampusId is not Guid requiredCampusId ||
|
||||||
room.Building!.CampusId == requiredCampusId) &&
|
room.Building!.CampusId == requiredCampusId) &&
|
||||||
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
(kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
||||||
room.BuildingId == requiredBuildingId) &&
|
room.BuildingId == requiredBuildingId) &&
|
||||||
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
|
|
||||||
(kind != ScheduleEntryKind.Experiment ||
|
(kind != ScheduleEntryKind.Experiment ||
|
||||||
TeachingVenueNatureRules.SupportsExperiment(room.TeachingVenueNature)) &&
|
constraint?.ExperimentRequiredCampusId is not Guid experimentCampusId ||
|
||||||
|
room.Building!.CampusId == experimentCampusId) &&
|
||||||
|
(kind != ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.ExperimentRequiredBuildingId is not Guid experimentBuildingId ||
|
||||||
|
room.BuildingId == experimentBuildingId) &&
|
||||||
|
(kind == ScheduleEntryKind.Experiment ||
|
||||||
|
allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
|
||||||
(kind != ScheduleEntryKind.Experiment || constraint is null ||
|
(kind != ScheduleEntryKind.Experiment || constraint is null ||
|
||||||
constraint.AllowedExperimentVenueNatures == 0 ||
|
constraint.AllowedExperimentVenueNatures == 0 ||
|
||||||
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0))
|
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0) &&
|
||||||
|
(kind != ScheduleEntryKind.Experiment || allowedExperimentRoomIds.Count == 0 ||
|
||||||
|
allowedExperimentRoomIds.Contains(room.Id)))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -73,6 +74,8 @@ public sealed class SchedulePublishJobProcessor(
|
|||||||
foreach (var oldPlan in previous)
|
foreach (var oldPlan in previous)
|
||||||
oldPlan.Status = SchedulePlanStatus.Archived;
|
oldPlan.Status = SchedulePlanStatus.Archived;
|
||||||
|
|
||||||
|
await new PublishedTimetableProjectionService(db)
|
||||||
|
.RebuildAsync(publishPlan, stoppingToken);
|
||||||
publishPlan.Status = SchedulePlanStatus.Published;
|
publishPlan.Status = SchedulePlanStatus.Published;
|
||||||
publishPlan.PublishedAt = DateTime.UtcNow;
|
publishPlan.PublishedAt = DateTime.UtcNow;
|
||||||
publishJob.Status = SchedulePublishJobStatus.Succeeded;
|
publishJob.Status = SchedulePublishJobStatus.Succeeded;
|
||||||
@@ -167,6 +170,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.Include(x => x.AllowedExperimentClassrooms)
|
||||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||||
|
|
||||||
foreach (var entry in plan.Entries)
|
foreach (var entry in plan.Entries)
|
||||||
@@ -270,21 +274,40 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
{
|
{
|
||||||
if (classroom is null || !classroom.IsEnabled)
|
if (classroom is null || !classroom.IsEnabled)
|
||||||
Fail(entry, "所选教室不存在或已停用");
|
Fail(entry, "所选教室不存在或已停用");
|
||||||
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
if (entry.Kind != ScheduleEntryKind.Experiment &&
|
||||||
!IsExperimentRoom(classroom.RoomType))
|
constraint?.RequiredCampusId is Guid campusId &&
|
||||||
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
|
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
Fail(entry, "所选教室不在指定校区");
|
Fail(entry, "所选教室不在指定校区");
|
||||||
if (constraint?.RequiredBuildingId is Guid buildingId &&
|
if (entry.Kind != ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.RequiredBuildingId is Guid buildingId &&
|
||||||
classroom.BuildingId != buildingId)
|
classroom.BuildingId != buildingId)
|
||||||
Fail(entry, "所选教室不在指定教学楼");
|
Fail(entry, "所选教室不在指定教学楼");
|
||||||
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
|
||||||
|
classroom.Building!.CampusId != experimentCampusId)
|
||||||
|
Fail(entry, "所选场地不在实验课指定校区");
|
||||||
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
|
||||||
|
classroom.BuildingId != experimentBuildingId)
|
||||||
|
Fail(entry, "所选场地不在实验课指定教学楼");
|
||||||
var allowedClassroomIds = constraint?.AllowedClassrooms
|
var allowedClassroomIds = constraint?.AllowedClassrooms
|
||||||
.Select(x => x.ClassroomId)
|
.Select(x => x.ClassroomId)
|
||||||
.ToHashSet() ?? [];
|
.ToHashSet() ?? [];
|
||||||
if (allowedClassroomIds.Count > 0 &&
|
if (entry.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
|
||||||
!allowedClassroomIds.Contains(classroom.Id))
|
!allowedClassroomIds.Contains(classroom.Id))
|
||||||
Fail(entry, "所选教室不在指定教室范围内");
|
Fail(entry, "所选教室不在指定教室范围内");
|
||||||
|
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
|
||||||
|
.Select(x => x.ClassroomId)
|
||||||
|
.ToHashSet() ?? [];
|
||||||
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
allowedExperimentClassroomIds.Count > 0 &&
|
||||||
|
!allowedExperimentClassroomIds.Contains(classroom.Id))
|
||||||
|
Fail(entry, "所选场地不在实验课指定场地范围内");
|
||||||
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
|
||||||
|
allowedNatures != 0 &&
|
||||||
|
(classroom.TeachingVenueNature & allowedNatures) == 0)
|
||||||
|
Fail(entry, "所选场地不在实验课允许的场地性质范围内");
|
||||||
}
|
}
|
||||||
|
|
||||||
var studentCount = task.Classes.Sum(x =>
|
var studentCount = task.Classes.Sum(x =>
|
||||||
@@ -305,12 +328,6 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
private static bool IsExperimentRoom(string roomType) =>
|
|
||||||
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
[DoesNotReturn]
|
[DoesNotReturn]
|
||||||
private static void Fail(ScheduleEntry entry, string message)
|
private static void Fail(ScheduleEntry entry, string message)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace Jiaowu.Api.Infrastructure.Scheduling;
|
|||||||
|
|
||||||
public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService
|
public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
|
private static readonly TimeZoneInfo ChinaTimeZone = ResolveChinaTimeZone();
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
@@ -25,22 +27,32 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
|||||||
|
|
||||||
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
|
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var nowUtc = DateTime.UtcNow;
|
||||||
var dow = (int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek;
|
var chinaNow = TimeZoneInfo.ConvertTime(
|
||||||
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
|
new DateTimeOffset(nowUtc),
|
||||||
|
ChinaTimeZone);
|
||||||
|
var dow = (int)chinaNow.DayOfWeek == 0 ? 7 : (int)chinaNow.DayOfWeek;
|
||||||
|
var currentMinuteUtc = new DateTime(
|
||||||
|
nowUtc.Year,
|
||||||
|
nowUtc.Month,
|
||||||
|
nowUtc.Day,
|
||||||
|
nowUtc.Hour,
|
||||||
|
nowUtc.Minute,
|
||||||
|
0,
|
||||||
|
DateTimeKind.Utc);
|
||||||
|
|
||||||
var rules = await db.WarningRules
|
var rules = await db.WarningRules
|
||||||
.Where(r => r.IsEnabled && r.AutoCheckEnabled &&
|
.Where(r => r.IsEnabled && r.AutoCheckEnabled &&
|
||||||
r.CheckHour == now.Hour && r.CheckMinute == now.Minute &&
|
r.CheckHour == chinaNow.Hour && r.CheckMinute == chinaNow.Minute &&
|
||||||
(r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow))
|
(r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var rule in rules)
|
foreach (var rule in rules)
|
||||||
{
|
{
|
||||||
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinute)
|
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinuteUtc)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
rule.LastCheckAt = now;
|
rule.LastCheckAt = nowUtc;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct);
|
var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct);
|
||||||
@@ -100,4 +112,23 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
|||||||
var gpa = grades.Average(x => x.GradePoint!.Value);
|
var gpa = grades.Average(x => x.GradePoint!.Value);
|
||||||
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
|
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo ResolveChinaTimeZone()
|
||||||
|
{
|
||||||
|
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("服务器未提供中国标准时间时区定义。");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-26
@@ -17,33 +17,34 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
|||||||
var occupiedIds = new HashSet<Guid>();
|
var occupiedIds = new HashSet<Guid>();
|
||||||
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
|
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
|
||||||
|
|
||||||
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
var hasProjection = await db.PublishedScheduleOccurrences.AsNoTracking()
|
||||||
.Where(entry =>
|
.AnyAsync(entry => entry.AcademicTermId == term.Id, cancellationToken);
|
||||||
entry.ClassroomId.HasValue &&
|
if (hasProjection)
|
||||||
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 projectedRoomIds = await db.PublishedScheduleOccurrences.AsNoTracking()
|
||||||
|
.Where(entry => entry.AcademicTermId == term.Id && entry.Week == week &&
|
||||||
|
entry.DayOfWeek == dayOfWeek && entry.ClassroomId.HasValue &&
|
||||||
|
entry.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < entry.StartPeriod + entry.PeriodCount)
|
||||||
|
.Select(entry => entry.ClassroomId!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(projectedRoomIds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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()
|
var examRoomIds = await db.ExamSessions.AsNoTracking()
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
|
||||||
|
public sealed class PublishedTimetableProjectionService(AppDbContext db)
|
||||||
|
{
|
||||||
|
private const int WriteBatchSize = 2_000;
|
||||||
|
public async Task RebuildPublishedPlansForTaskAsync(Guid teachingTaskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plans = await db.SchedulePlans
|
||||||
|
.Where(plan => plan.Status == SchedulePlanStatus.Published &&
|
||||||
|
plan.Entries.Any(entry => entry.TeachingTaskId == teachingTaskId))
|
||||||
|
.Include(plan => plan.Entries)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var plan in plans)
|
||||||
|
await RebuildAsync(plan, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RebuildAsync(SchedulePlan plan, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await db.PublishedScheduleOccurrences
|
||||||
|
.Where(x => x.SchedulePlanId == plan.Id)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rows = new List<PublishedScheduleOccurrence>(WriteBatchSize);
|
||||||
|
foreach (var entry in plan.Entries)
|
||||||
|
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
|
||||||
|
{
|
||||||
|
if (entry.WeekPattern == WeekPattern.Odd && week % 2 == 0 ||
|
||||||
|
entry.WeekPattern == WeekPattern.Even && week % 2 != 0) continue;
|
||||||
|
rows.Add(new PublishedScheduleOccurrence
|
||||||
|
{
|
||||||
|
SchedulePlanId = plan.Id,
|
||||||
|
AcademicTermId = plan.AcademicTermId,
|
||||||
|
ScheduleEntryId = entry.Id,
|
||||||
|
TeachingTaskId = entry.TeachingTaskId,
|
||||||
|
ClassroomId = entry.ClassroomId,
|
||||||
|
Week = week,
|
||||||
|
DayOfWeek = entry.DayOfWeek,
|
||||||
|
StartPeriod = entry.StartPeriod,
|
||||||
|
PeriodCount = entry.PeriodCount,
|
||||||
|
Kind = entry.Kind
|
||||||
|
});
|
||||||
|
if (rows.Count == WriteBatchSize)
|
||||||
|
await WriteBatchAsync(rows, cancellationToken);
|
||||||
|
}
|
||||||
|
if (rows.Count > 0)
|
||||||
|
await WriteBatchAsync(rows, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteBatchAsync(
|
||||||
|
List<PublishedScheduleOccurrence> rows,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
db.PublishedScheduleOccurrences.AddRange(rows);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
foreach (var row in rows)
|
||||||
|
db.Entry(row).State = EntityState.Detached;
|
||||||
|
rows.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,33 @@ public static class TimetableExcelExporter
|
|||||||
public static byte[] Create(TimetableData timetable)
|
public static byte[] Create(TimetableData timetable)
|
||||||
{
|
{
|
||||||
using var workbook = new XLWorkbook();
|
using var workbook = new XLWorkbook();
|
||||||
var sheet = workbook.Worksheets.Add("课表");
|
CreateWorksheet(workbook, timetable, "课表");
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Create(IReadOnlyCollection<TimetableData> timetables)
|
||||||
|
{
|
||||||
|
if (timetables.Count == 0)
|
||||||
|
throw new ArgumentException("至少需要一张课表。", nameof(timetables));
|
||||||
|
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
var index = 1;
|
||||||
|
foreach (var timetable in timetables)
|
||||||
|
CreateWorksheet(workbook, timetable, WorksheetName(timetable.Subject.Name, index++));
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateWorksheet(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
TimetableData timetable,
|
||||||
|
string worksheetName)
|
||||||
|
{
|
||||||
|
var sheet = workbook.Worksheets.Add(worksheetName);
|
||||||
sheet.Style.Font.FontName = "Microsoft YaHei";
|
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||||
|
|
||||||
sheet.Range("A1:H1").Merge();
|
sheet.Range("A1:H1").Merge();
|
||||||
@@ -153,9 +179,17 @@ public static class TimetableExcelExporter
|
|||||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||||
sheet.PageSetup.FitToPages(1, 0);
|
sheet.PageSetup.FitToPages(1, 0);
|
||||||
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
||||||
using var stream = new MemoryStream();
|
}
|
||||||
workbook.SaveAs(stream);
|
|
||||||
return stream.ToArray();
|
private static string WorksheetName(string subjectName, int index)
|
||||||
|
{
|
||||||
|
var suffix = $"-{index}";
|
||||||
|
var name = string.Concat(subjectName.Select(character =>
|
||||||
|
"[]:*?/\\".Contains(character) ? '-' : character)).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) name = "课表";
|
||||||
|
return name.Length > 31 - suffix.Length
|
||||||
|
? name[..(31 - suffix.Length)] + suffix
|
||||||
|
: name + suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EntryText(TimetableEntryDto entry) =>
|
private static string EntryText(TimetableEntryDto entry) =>
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
|
||||||
|
public static class TimetablePdfExporter
|
||||||
|
{
|
||||||
|
private const float PageWidth = 842;
|
||||||
|
private const float PageHeight = 595;
|
||||||
|
private const float Margin = 24;
|
||||||
|
private static readonly SKColor Ink = new(27, 53, 74);
|
||||||
|
private static readonly SKColor Muted = new(92, 111, 126);
|
||||||
|
private static readonly SKColor Accent = new(32, 105, 99);
|
||||||
|
private static readonly SKColor Rule = new(206, 220, 226);
|
||||||
|
private static readonly SKColor Pale = new(241, 247, 247);
|
||||||
|
|
||||||
|
public static byte[] Create(TimetableData timetable) => Create([timetable]);
|
||||||
|
|
||||||
|
public static byte[] Create(IReadOnlyCollection<TimetableData> timetables)
|
||||||
|
{
|
||||||
|
if (timetables.Count == 0)
|
||||||
|
throw new ArgumentException("至少需要一张课表。", nameof(timetables));
|
||||||
|
|
||||||
|
using var typeface = ResolveTypeface();
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var document = SKDocument.CreatePdf(stream);
|
||||||
|
var page = 0;
|
||||||
|
foreach (var timetable in timetables)
|
||||||
|
{
|
||||||
|
var periods = Periods(timetable);
|
||||||
|
foreach (var periodPage in periods.Chunk(9))
|
||||||
|
DrawPage(document, typeface, timetable, periodPage, ++page);
|
||||||
|
}
|
||||||
|
document.Close();
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawPage(
|
||||||
|
SKDocument document,
|
||||||
|
SKTypeface typeface,
|
||||||
|
TimetableData timetable,
|
||||||
|
IReadOnlyList<int> periods,
|
||||||
|
int page)
|
||||||
|
{
|
||||||
|
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||||
|
canvas.Clear(SKColors.White);
|
||||||
|
using var frame = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.1f };
|
||||||
|
canvas.DrawRect(14, 14, PageWidth - 28, PageHeight - 28, frame);
|
||||||
|
|
||||||
|
DrawText(canvas, typeface, timetable.Subject.Name, Margin, 53, 19, Ink, bold: true);
|
||||||
|
DrawText(canvas, typeface, "课程表", PageWidth - Margin, 53, 11, Accent, SKTextAlign.Right, true);
|
||||||
|
DrawText(canvas, typeface,
|
||||||
|
$"{timetable.Subject.Code} · {timetable.Term.Name}" +
|
||||||
|
(timetable.Plan is null ? "" : $" · {timetable.Plan.Version} · {PlanStatus(timetable.Plan.Status)}"),
|
||||||
|
Margin, 74, 9, Muted);
|
||||||
|
DrawText(canvas, typeface,
|
||||||
|
$"导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}",
|
||||||
|
PageWidth - Margin, 74, 8, Muted, SKTextAlign.Right);
|
||||||
|
using var titleRule = new SKPaint { Color = Rule, StrokeWidth = .8f };
|
||||||
|
canvas.DrawLine(Margin, 86, PageWidth - Margin, 86, titleRule);
|
||||||
|
|
||||||
|
var widths = new[] { 84f, 101f, 101f, 101f, 101f, 101f, 101f, 101f };
|
||||||
|
var headers = new[] { "节次 / 时间", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" };
|
||||||
|
const float headerHeight = 24;
|
||||||
|
const float rowHeight = 46;
|
||||||
|
var y = 103f;
|
||||||
|
using var header = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
|
||||||
|
using var grid = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
|
||||||
|
canvas.DrawRect(Margin, y, widths.Sum(), headerHeight, header);
|
||||||
|
var x = Margin;
|
||||||
|
for (var column = 0; column < headers.Length; column++)
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, headers[column], x + widths[column] / 2, y + 16, 8.5f,
|
||||||
|
SKColors.White, SKTextAlign.Center, true);
|
||||||
|
x += widths[column];
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries).ToArray();
|
||||||
|
for (var rowIndex = 0; rowIndex < periods.Count; rowIndex++)
|
||||||
|
{
|
||||||
|
var period = periods[rowIndex];
|
||||||
|
var rowY = y + headerHeight + rowIndex * rowHeight;
|
||||||
|
using var leftFill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||||
|
canvas.DrawRect(Margin, rowY, widths[0], rowHeight, leftFill);
|
||||||
|
var slot = timetable.Slots.FirstOrDefault(item => item.PeriodNumber == period);
|
||||||
|
DrawText(canvas, typeface, $"第 {period} 节", Margin + widths[0] / 2, rowY + 18, 8.5f,
|
||||||
|
Ink, SKTextAlign.Center, true);
|
||||||
|
if (slot is not null)
|
||||||
|
DrawText(canvas, typeface, $"{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}",
|
||||||
|
Margin + widths[0] / 2, rowY + 32, 7, Muted, SKTextAlign.Center);
|
||||||
|
|
||||||
|
x = Margin + widths[0];
|
||||||
|
for (var day = 1; day <= 7; day++)
|
||||||
|
{
|
||||||
|
var cellEntries = entries.Where(item =>
|
||||||
|
item.DayOfWeek == day &&
|
||||||
|
item.StartPeriod <= period &&
|
||||||
|
item.StartPeriod + item.PeriodCount - 1 >= period).ToArray();
|
||||||
|
if (cellEntries.Length > 0)
|
||||||
|
{
|
||||||
|
using var fill = new SKPaint
|
||||||
|
{
|
||||||
|
Color = cellEntries.Any(item => item.IsExam)
|
||||||
|
? new SKColor(255, 243, 230)
|
||||||
|
: cellEntries.Any(item => item.IsExperiment)
|
||||||
|
? new SKColor(232, 246, 241)
|
||||||
|
: new SKColor(234, 245, 244),
|
||||||
|
Style = SKPaintStyle.Fill
|
||||||
|
};
|
||||||
|
canvas.DrawRect(x, rowY, widths[day], rowHeight, fill);
|
||||||
|
DrawCellText(canvas, typeface, cellEntries, x, rowY, widths[day], rowHeight);
|
||||||
|
}
|
||||||
|
x += widths[day];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableHeight = headerHeight + periods.Count * rowHeight;
|
||||||
|
canvas.DrawRect(Margin, y, widths.Sum(), tableHeight, grid);
|
||||||
|
x = Margin;
|
||||||
|
for (var column = 0; column < widths.Length - 1; column++)
|
||||||
|
{
|
||||||
|
x += widths[column];
|
||||||
|
canvas.DrawLine(x, y, x, y + tableHeight, grid);
|
||||||
|
}
|
||||||
|
for (var row = 0; row < periods.Count; row++)
|
||||||
|
canvas.DrawLine(Margin, y + headerHeight + row * rowHeight, Margin + widths.Sum(),
|
||||||
|
y + headerHeight + row * rowHeight, grid);
|
||||||
|
|
||||||
|
var footerY = PageHeight - 31;
|
||||||
|
canvas.DrawLine(Margin, footerY - 12, PageWidth - Margin, footerY - 12, titleRule);
|
||||||
|
DrawText(canvas, typeface, "明序教务 · 课表导出", Margin, footerY, 7.5f, Muted);
|
||||||
|
DrawText(canvas, typeface, $"第 {page} 页", PageWidth - Margin, footerY, 7.5f, Muted,
|
||||||
|
SKTextAlign.Right);
|
||||||
|
document.EndPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawCellText(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
IReadOnlyList<TimetableEntryDto> entries,
|
||||||
|
float x,
|
||||||
|
float y,
|
||||||
|
float width,
|
||||||
|
float height)
|
||||||
|
{
|
||||||
|
var text = string.Join("\n", entries.Select(EntryText));
|
||||||
|
var lines = WrapText(typeface, text, 6.6f, width - 8).Take(5).ToArray();
|
||||||
|
for (var index = 0; index < lines.Length; index++)
|
||||||
|
DrawText(canvas, typeface, lines[index], x + 4, y + 10 + index * 8, 6.6f, Ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<int> Periods(TimetableData timetable)
|
||||||
|
{
|
||||||
|
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries);
|
||||||
|
var periods = timetable.Slots.Select(item => item.PeriodNumber)
|
||||||
|
.Concat(entries.SelectMany(item => Enumerable.Range(item.StartPeriod, item.PeriodCount)))
|
||||||
|
.Distinct().Order().ToArray();
|
||||||
|
return periods.Length == 0 ? Enumerable.Range(1, 8).ToArray() : periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EntryText(TimetableEntryDto entry) =>
|
||||||
|
$"{(entry.IsExam ? "【考试】" : entry.IsExperiment ? "【实验】" : "")}{entry.CourseName} · {string.Join('、', entry.TeacherNames)}" +
|
||||||
|
$" · {string.Join(" ", new[] { entry.BuildingName, entry.ClassroomName }.Where(item => !string.IsNullOrWhiteSpace(item)))}";
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> WrapText(SKTypeface typeface, string text, float size, float width)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size);
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var paragraph in text.Split('\n'))
|
||||||
|
{
|
||||||
|
var current = "";
|
||||||
|
foreach (var character in paragraph)
|
||||||
|
{
|
||||||
|
var candidate = current + character;
|
||||||
|
if (current.Length > 0 && font.MeasureText(candidate) > width)
|
||||||
|
{
|
||||||
|
lines.Add(current);
|
||||||
|
current = character.ToString();
|
||||||
|
}
|
||||||
|
else current = candidate;
|
||||||
|
}
|
||||||
|
if (current.Length > 0) lines.Add(current);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 SKTypeface ResolveTypeface()
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (File.Exists(path) && SKTypeface.FromFile(path) is { } typeface)
|
||||||
|
return typeface;
|
||||||
|
}
|
||||||
|
return SKTypeface.FromFamilyName("Microsoft YaHei") ??
|
||||||
|
SKTypeface.FromFamilyName("Noto Sans CJK SC") ??
|
||||||
|
throw new InvalidOperationException("未找到可用于课表 PDF 的中文字体。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch
|
||||||
|
{
|
||||||
|
Domain.Academic.SchedulePlanStatus.Draft => "草稿",
|
||||||
|
Domain.Academic.SchedulePlanStatus.Published => "已发布",
|
||||||
|
_ => "已归档"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,11 +47,6 @@
|
|||||||
</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.9" />
|
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.2" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.2" />
|
||||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||||
|
|||||||
+61
-34
@@ -1,4 +1,5 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Net;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
@@ -6,6 +7,7 @@ using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
|||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Configuration;
|
using Jiaowu.Api.Infrastructure.Configuration;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Analytics;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
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;
|
||||||
@@ -19,15 +21,13 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
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.Extensions.Caching.Distributed;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using OpenTelemetry.Metrics;
|
|
||||||
using OpenTelemetry.Resources;
|
|
||||||
using OpenTelemetry.Trace;
|
|
||||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
@@ -101,12 +101,30 @@ var observabilityOptions = builder.Configuration
|
|||||||
var performanceReportingOptions = builder.Configuration
|
var performanceReportingOptions = builder.Configuration
|
||||||
.GetSection(PerformanceReportingOptions.SectionName)
|
.GetSection(PerformanceReportingOptions.SectionName)
|
||||||
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||||
|
var clickHouseAnalyticsOptions = builder.Configuration
|
||||||
|
.GetSection(ClickHouseAnalyticsOptions.SectionName)
|
||||||
|
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
|
||||||
var rabbitMqOptions = builder.Configuration
|
var rabbitMqOptions = builder.Configuration
|
||||||
.GetSection(RabbitMqOptions.SectionName)
|
.GetSection(RabbitMqOptions.SectionName)
|
||||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||||
var ssoOptions = builder.Configuration
|
var ssoOptions = builder.Configuration
|
||||||
.GetSection(SsoOptions.SectionName)
|
.GetSection(SsoOptions.SectionName)
|
||||||
.Get<SsoOptions>() ?? new SsoOptions();
|
.Get<SsoOptions>() ?? new SsoOptions();
|
||||||
|
var trustedProxyAddresses = builder.Configuration
|
||||||
|
.GetSection("ReverseProxy:TrustedProxies")
|
||||||
|
.Get<string[]>() ?? [];
|
||||||
|
var trustedProxies = trustedProxyAddresses
|
||||||
|
.Select(value =>
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(value, out var address))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"ReverseProxy:TrustedProxies contains an invalid IP address: '{value}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
if (ssoOptions.Enabled &&
|
if (ssoOptions.Enabled &&
|
||||||
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
||||||
@@ -158,6 +176,7 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
||||||
observabilityOptions.ServiceName.Length > 100 ||
|
observabilityOptions.ServiceName.Length > 100 ||
|
||||||
|
observabilityOptions.SlowRequestThresholdMilliseconds is < 1 or > 60000 ||
|
||||||
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
||||||
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
||||||
{
|
{
|
||||||
@@ -187,15 +206,6 @@ if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
|
|||||||
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
|
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
|
||||||
}
|
}
|
||||||
|
|
||||||
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
|
|
||||||
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
|
|
||||||
parsedOtlpEndpoint.Scheme is not ("http" or "https")))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT 必须是有效的 HTTP 或 HTTPS 绝对地址。");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||||
@@ -252,6 +262,19 @@ if (backgroundJobOptions.UsesRabbitMq &&
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException("RabbitMq 连接配置不完整。");
|
throw new InvalidOperationException("RabbitMq 连接配置不完整。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clickHouseAnalyticsOptions.Enabled &&
|
||||||
|
(!Uri.TryCreate(clickHouseAnalyticsOptions.Endpoint, UriKind.Absolute, out var clickHouseEndpoint) ||
|
||||||
|
clickHouseEndpoint.Scheme is not ("http" or "https") ||
|
||||||
|
!clickHouseAnalyticsOptions.HasValidIdentifiers() ||
|
||||||
|
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.UserName) ||
|
||||||
|
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.Password) ||
|
||||||
|
clickHouseAnalyticsOptions.SyncIntervalSeconds is < 10 or > 86400 ||
|
||||||
|
clickHouseAnalyticsOptions.SourceLookbackDays is < 1 or > 3650 ||
|
||||||
|
clickHouseAnalyticsOptions.BatchSize is < 1 or > 10000))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ClickHouseAnalytics 配置无效。");
|
||||||
|
}
|
||||||
if (backgroundJobOptions.UsesRabbitMq &&
|
if (backgroundJobOptions.UsesRabbitMq &&
|
||||||
!builder.Environment.IsDevelopment() &&
|
!builder.Environment.IsDevelopment() &&
|
||||||
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
||||||
@@ -282,14 +305,21 @@ builder.Services.AddSingleton(backgroundJobOptions);
|
|||||||
builder.Services.AddSingleton(operationsOptions);
|
builder.Services.AddSingleton(operationsOptions);
|
||||||
builder.Services.AddSingleton(observabilityOptions);
|
builder.Services.AddSingleton(observabilityOptions);
|
||||||
builder.Services.AddSingleton(performanceReportingOptions);
|
builder.Services.AddSingleton(performanceReportingOptions);
|
||||||
|
builder.Services.AddSingleton(clickHouseAnalyticsOptions);
|
||||||
builder.Services.AddSingleton(rabbitMqOptions);
|
builder.Services.AddSingleton(rabbitMqOptions);
|
||||||
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||||
{
|
{
|
||||||
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||||
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
|
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
|
||||||
});
|
});
|
||||||
|
builder.Services.AddHttpClient<ClickHouseAnalyticsClient>((_, client) =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(clickHouseAnalyticsOptions.Endpoint.TrimEnd('/') + "/");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
});
|
||||||
builder.Services.Configure<OfficialDocumentOptions>(
|
builder.Services.Configure<OfficialDocumentOptions>(
|
||||||
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||||
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
||||||
@@ -339,28 +369,6 @@ builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (observabilityOptions.Enabled &&
|
|
||||||
!string.IsNullOrWhiteSpace(otlpEndpoint))
|
|
||||||
{
|
|
||||||
builder.Services
|
|
||||||
.AddOpenTelemetry()
|
|
||||||
.ConfigureResource(resource =>
|
|
||||||
resource.AddService(observabilityOptions.ServiceName))
|
|
||||||
.WithMetrics(metrics => metrics
|
|
||||||
.AddAspNetCoreInstrumentation()
|
|
||||||
.AddHttpClientInstrumentation()
|
|
||||||
.AddRuntimeInstrumentation()
|
|
||||||
.AddMeter(DatabaseCommandTelemetryInterceptor.MeterName))
|
|
||||||
.WithTracing(tracing => tracing
|
|
||||||
.AddAspNetCoreInstrumentation(options =>
|
|
||||||
options.Filter = context =>
|
|
||||||
!context.Request.Path.StartsWithSegments("/health/live"))
|
|
||||||
.AddHttpClientInstrumentation()
|
|
||||||
.AddSource(DatabaseCommandTelemetryInterceptor.ActivitySourceName))
|
|
||||||
.WithMetrics(metrics => metrics.AddOtlpExporter())
|
|
||||||
.WithTracing(tracing => tracing.AddOtlpExporter());
|
|
||||||
}
|
|
||||||
|
|
||||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
||||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||||
{
|
{
|
||||||
@@ -422,6 +430,7 @@ builder.Services.AddScoped<DatabaseInitializer>();
|
|||||||
builder.Services.AddScoped<DemoDataSeeder>();
|
builder.Services.AddScoped<DemoDataSeeder>();
|
||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
builder.Services.AddScoped<TimetableDataService>();
|
builder.Services.AddScoped<TimetableDataService>();
|
||||||
|
builder.Services.AddScoped<PublishedTimetableProjectionService>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
builder.Services.AddScoped<PersonalCalendarService>();
|
builder.Services.AddScoped<PersonalCalendarService>();
|
||||||
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
|
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
|
||||||
@@ -437,6 +446,10 @@ builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
|||||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||||
|
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
||||||
|
builder.Services.AddHostedService<ClickHouseAnalyticsProjectionWorker>();
|
||||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||||
builder.Services.AddScoped<OperationalHealthService>();
|
builder.Services.AddScoped<OperationalHealthService>();
|
||||||
@@ -526,6 +539,18 @@ if (ssoOptions.Enabled)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders =
|
||||||
|
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.ForwardLimit = 1;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
foreach (var proxy in trustedProxies)
|
||||||
|
{
|
||||||
|
options.KnownProxies.Add(proxy);
|
||||||
|
}
|
||||||
|
});
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
@@ -640,6 +665,7 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseForwardedHeaders();
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
app.UseResponseCompression();
|
app.UseResponseCompression();
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
@@ -695,6 +721,7 @@ app.UseStaticFiles(new StaticFileOptions
|
|||||||
});
|
});
|
||||||
app.UseCors("Web");
|
app.UseCors("Web");
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
app.UseMiddleware<SlowRequestLoggingMiddleware>();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<AuditMiddleware>();
|
app.UseMiddleware<AuditMiddleware>();
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"Observability": {
|
"Observability": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ServiceName": "jiaowu-api",
|
"ServiceName": "jiaowu-api",
|
||||||
|
"LogAllApiRequests": true,
|
||||||
|
"SlowRequestThresholdMilliseconds": 1000,
|
||||||
"SlowQueryThresholdMilliseconds": 500,
|
"SlowQueryThresholdMilliseconds": 500,
|
||||||
"IncludeSqlText": false,
|
"IncludeSqlText": false,
|
||||||
"MaximumSqlTextLength": 2000
|
"MaximumSqlTextLength": 2000
|
||||||
@@ -39,6 +41,17 @@
|
|||||||
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
|
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
|
||||||
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
|
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
|
||||||
},
|
},
|
||||||
|
"ClickHouseAnalytics": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Endpoint": "http://localhost:8123",
|
||||||
|
"Database": "jiaowu_analytics",
|
||||||
|
"UserName": "jiaowu_analytics",
|
||||||
|
"Password": "",
|
||||||
|
"CreateSchemaOnStartup": true,
|
||||||
|
"SyncIntervalSeconds": 60,
|
||||||
|
"SourceLookbackDays": 90,
|
||||||
|
"BatchSize": 1000
|
||||||
|
},
|
||||||
"Operations": {
|
"Operations": {
|
||||||
"BackupDirectory": "data/backups",
|
"BackupDirectory": "data/backups",
|
||||||
"BackupWarningHours": 24,
|
"BackupWarningHours": 24,
|
||||||
@@ -94,6 +107,12 @@
|
|||||||
"FrontendBaseUrl": "",
|
"FrontendBaseUrl": "",
|
||||||
"CallbackUrl": ""
|
"CallbackUrl": ""
|
||||||
},
|
},
|
||||||
|
"ReverseProxy": {
|
||||||
|
"TrustedProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": [
|
"Origins": [
|
||||||
"capacitor://localhost",
|
"capacitor://localhost",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
|
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;
|
||||||
@@ -11,6 +12,40 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class ApprovalsControllerTests
|
public sealed class ApprovalsControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Pending_returns_a_single_server_page_with_total()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
fixture.Db.AddRange(
|
||||||
|
new DeferredExam
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
Reason = "较早提交",
|
||||||
|
SubmittedAt = DateTime.UtcNow.AddMinutes(-2)
|
||||||
|
},
|
||||||
|
new CourseExemption
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
Reason = "较晚提交",
|
||||||
|
SubmittedAt = DateTime.UtcNow.AddMinutes(-1)
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ApprovalsController(
|
||||||
|
fixture.Db,
|
||||||
|
new ManagerDataScope());
|
||||||
|
var result = await controller.GetPending(2, 1, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var page = Assert.IsType<PagedResult<ApprovalItem>>(ok.Value);
|
||||||
|
Assert.Equal(2, page.Total);
|
||||||
|
Assert.Equal(2, page.Page);
|
||||||
|
var item = Assert.Single(page.Items);
|
||||||
|
Assert.Equal("较早提交", item.Desc);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||||
{
|
{
|
||||||
@@ -298,4 +333,14 @@ public sealed class ApprovalsControllerTests
|
|||||||
DataScope.Self,
|
DataScope.Self,
|
||||||
new HashSet<string>([SystemRoles.Student]));
|
new HashSet<string>([SystemRoles.Student]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ManagerDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Jiaowu.Api.Contracts;
|
||||||
|
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 AttendancePaginationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task TasksAndSheets_AreFilteredAndReturnedByPage()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var db = new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
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 tasks = Enumerable.Range(1, 11).Select(number => new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"2026-1-CS101-{number:00}", Name = $"程序设计教学班 {number}",
|
||||||
|
AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
}).ToList();
|
||||||
|
var sheets = Enumerable.Range(1, 11).Select(number => new AttendanceSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = tasks[0].Id,
|
||||||
|
Name = number == 11 ? "期末扫码点名" : $"第 {number} 周点名",
|
||||||
|
AttendanceDate = new DateTime(2026, 9, number),
|
||||||
|
Status = number == 11 ? AttendanceSheetStatus.Submitted : AttendanceSheetStatus.Draft,
|
||||||
|
CheckInMethod = number == 11 ? AttendanceCheckInMethod.QrCode : AttendanceCheckInMethod.Manual
|
||||||
|
}).ToList();
|
||||||
|
db.AddRange(college, course, term);
|
||||||
|
db.AddRange(tasks);
|
||||||
|
db.AddRange(sheets);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new AttendanceController(db, new AllScope());
|
||||||
|
var taskResult = Assert.IsType<OkObjectResult>(await controller.GetMyTasks(
|
||||||
|
term.Id, "程序设计", 1, 10, CancellationToken.None));
|
||||||
|
var taskPage = Assert.IsType<PagedResult<object>>(taskResult.Value);
|
||||||
|
Assert.Equal(11, taskPage.Total);
|
||||||
|
Assert.Equal(10, taskPage.Items.Count);
|
||||||
|
|
||||||
|
var sheetResult = Assert.IsType<OkObjectResult>(await controller.GetSheets(
|
||||||
|
tasks[0].Id, "期末", AttendanceSheetStatus.Submitted,
|
||||||
|
AttendanceCheckInMethod.QrCode, 1, 10, CancellationToken.None));
|
||||||
|
var sheetPage = Assert.IsType<PagedResult<object>>(sheetResult.Value);
|
||||||
|
Assert.Equal(1, sheetPage.Total);
|
||||||
|
Assert.Single(sheetPage.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AllScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(), "测试管理员", null, DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshSchedulerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueDueAsync_UsesPersistentScheduleAndHonorsInterval()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||||
|
db,
|
||||||
|
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||||
|
var now = new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
await scheduler.EnqueueDueAsync(now, CancellationToken.None);
|
||||||
|
var setting = Assert.Single(await db.CourseGradeStatisticsRefreshSettings.ToListAsync());
|
||||||
|
Assert.True(setting.IsEnabled);
|
||||||
|
Assert.Equal(now, setting.LastRunAt);
|
||||||
|
|
||||||
|
await scheduler.EnqueueDueAsync(now.AddMinutes(1), CancellationToken.None);
|
||||||
|
Assert.Equal(now, setting.LastRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueStaleAsync_GroupsByCourseTerm_AndSkipsActiveTarget()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "SCHEDULE", Name = "定时任务学院" };
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-M",
|
||||||
|
Name = "定时任务专业",
|
||||||
|
College = college,
|
||||||
|
DegreeType = "本科"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-C",
|
||||||
|
Name = "定时任务一班",
|
||||||
|
Major = major,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "SCHEDULE-001",
|
||||||
|
Name = "定时任务学生",
|
||||||
|
AdministrativeClass = administrativeClass,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "SCHEDULE-COURSE",
|
||||||
|
Name = "定时任务课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-A",
|
||||||
|
Name = "2026-2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var firstTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "SCHEDULE-T1",
|
||||||
|
Name = "定时任务教学班一",
|
||||||
|
Course = course,
|
||||||
|
AcademicTerm = term,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var secondTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "SCHEDULE-T2",
|
||||||
|
Name = "定时任务教学班二",
|
||||||
|
Course = course,
|
||||||
|
AcademicTerm = term,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var firstSheet = PublishedSheet(firstTask, student, 80m);
|
||||||
|
var secondSheet = PublishedSheet(secondTask, student, 90m);
|
||||||
|
db.GradeSheets.AddRange(firstSheet, secondSheet);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||||
|
db,
|
||||||
|
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||||
|
|
||||||
|
var queued = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||||
|
var queuedAgain = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, queued);
|
||||||
|
Assert.Equal(0, queuedAgain);
|
||||||
|
var job = Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||||
|
var outbox = Assert.Single(await db.BackgroundJobOutboxMessages.ToListAsync());
|
||||||
|
Assert.Equal(BackgroundJobKind.CourseGradeStatisticsRefresh, outbox.JobKind);
|
||||||
|
|
||||||
|
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||||
|
db.TeachingTaskGradeStatistics.AddRange(
|
||||||
|
FreshStatistic(firstSheet, course, term),
|
||||||
|
FreshStatistic(secondSheet, course, term));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var queuedAfterFreshStatistics = await scheduler.EnqueueStaleAsync(
|
||||||
|
100,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, queuedAfterFreshStatistics);
|
||||||
|
Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GradeSheet PublishedSheet(
|
||||||
|
TeachingTask task,
|
||||||
|
Student student,
|
||||||
|
decimal score)
|
||||||
|
{
|
||||||
|
var sheet = new GradeSheet
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
Status = GradeSheetStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
sheet.Records.Add(new GradeRecord
|
||||||
|
{
|
||||||
|
GradeSheet = sheet,
|
||||||
|
Student = student,
|
||||||
|
TotalScore = score,
|
||||||
|
GradePoint = 3m
|
||||||
|
});
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingTaskGradeStatistic FreshStatistic(
|
||||||
|
GradeSheet sheet,
|
||||||
|
Course course,
|
||||||
|
AcademicTerm term) => new()
|
||||||
|
{
|
||||||
|
GradeSheetId = sheet.Id,
|
||||||
|
TeachingTaskId = sheet.TeachingTaskId,
|
||||||
|
CourseId = course.Id,
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
StudentCount = 1,
|
||||||
|
PassedCount = 1,
|
||||||
|
HighestScore = 80m,
|
||||||
|
AverageScore = 80m,
|
||||||
|
MedianScore = 80m,
|
||||||
|
LowestScore = 80m,
|
||||||
|
PassRate = 100m,
|
||||||
|
CalculatedAt = DateTime.UtcNow.AddMinutes(1)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -205,6 +205,79 @@ public sealed class CourseSelectionsControllerTests
|
|||||||
x.Grade == 2026));
|
x.Grade == 2026));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Student_options_include_an_administrator_assigned_offering_outside_the_students_class_scope()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var data = await SeedFullOfferingAsync(db);
|
||||||
|
|
||||||
|
var originalClass = await db.AdministrativeClasses.SingleAsync();
|
||||||
|
var otherClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "CS2026-02",
|
||||||
|
Name = "计科 2026-2 班",
|
||||||
|
MajorId = originalClass.MajorId,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var taskId = await db.CourseSelectionOfferings
|
||||||
|
.Where(x => x.Id == data.OfferingId)
|
||||||
|
.Select(x => x.TeachingTaskId)
|
||||||
|
.SingleAsync();
|
||||||
|
var task = await db.TeachingTasks
|
||||||
|
.Include(x => x.Classes)
|
||||||
|
.SingleAsync(x => x.Id == taskId);
|
||||||
|
task.Classes.Clear();
|
||||||
|
task.Classes.Add(new TeachingTaskClass { AdministrativeClassId = otherClass.Id });
|
||||||
|
task.SchedulingMode = TeachingTaskSchedulingMode.Standard;
|
||||||
|
var termId = await db.CourseSelectionRounds
|
||||||
|
.Where(x => x.Id == data.RoundId)
|
||||||
|
.Select(x => x.AcademicTermId)
|
||||||
|
.SingleAsync();
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = termId,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "V1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
db.AddRange(otherClass, plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ScheduleEntries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = plan.Id,
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var controller = new CourseSelectionsController(
|
||||||
|
db,
|
||||||
|
new StudentDataScope(data.EnrolledUserId));
|
||||||
|
var result = Assert.IsType<OkObjectResult>(await controller.GetStudentOptions(
|
||||||
|
data.RoundId,
|
||||||
|
CancellationToken.None));
|
||||||
|
var offerings = ReadProperty<IEnumerable<StudentOfferingDto>>(
|
||||||
|
result.Value, "Offerings");
|
||||||
|
var assignedOffering = Assert.Single(offerings);
|
||||||
|
|
||||||
|
Assert.Equal(data.OfferingId, assignedOffering.Id);
|
||||||
|
Assert.Equal(CourseEnrollmentStatus.Enrolled, assignedOffering.EnrollmentStatus);
|
||||||
|
Assert.Single(assignedOffering.Schedules);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
|
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
@@ -358,6 +431,14 @@ public sealed class CourseSelectionsControllerTests
|
|||||||
return Assert.IsType<int>(property.GetValue(value));
|
return Assert.IsType<int>(property.GetValue(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static T ReadProperty<T>(object? value, string propertyName)
|
||||||
|
{
|
||||||
|
Assert.NotNull(value);
|
||||||
|
var property = value.GetType().GetProperty(propertyName);
|
||||||
|
Assert.NotNull(property);
|
||||||
|
return Assert.IsAssignableFrom<T>(property.GetValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
private static Student CreateStudent(
|
private static Student CreateStudent(
|
||||||
string number,
|
string number,
|
||||||
string name,
|
string name,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Diagnostics.Metrics;
|
|
||||||
using Jiaowu.Api.Infrastructure.Observability;
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -40,7 +40,7 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Ef_command_records_duration_metric()
|
public async Task Ef_command_executes_with_logging_interceptor()
|
||||||
{
|
{
|
||||||
await using var connection =
|
await using var connection =
|
||||||
new SqliteConnection("Data Source=:memory:");
|
new SqliteConnection("Data Source=:memory:");
|
||||||
@@ -52,25 +52,9 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
|||||||
await using (var setupDb = new AppDbContext(setupOptions))
|
await using (var setupDb = new AppDbContext(setupOptions))
|
||||||
await setupDb.Database.EnsureCreatedAsync();
|
await setupDb.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
double? recordedDuration = null;
|
|
||||||
using var listener = new MeterListener
|
|
||||||
{
|
|
||||||
InstrumentPublished = (instrument, meterListener) =>
|
|
||||||
{
|
|
||||||
if (instrument.Meter.Name ==
|
|
||||||
DatabaseCommandTelemetryInterceptor.MeterName &&
|
|
||||||
instrument.Name == "jiaowu.db.command.duration")
|
|
||||||
{
|
|
||||||
meterListener.EnableMeasurementEvents(instrument);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
listener.SetMeasurementEventCallback<double>(
|
|
||||||
(_, measurement, _, _) => recordedDuration = measurement);
|
|
||||||
listener.Start();
|
|
||||||
|
|
||||||
var interceptor = new DatabaseCommandTelemetryInterceptor(
|
var interceptor = new DatabaseCommandTelemetryInterceptor(
|
||||||
new ObservabilityOptions(),
|
new ObservabilityOptions(),
|
||||||
|
new HttpContextAccessor(),
|
||||||
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
||||||
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseSqlite(connection)
|
.UseSqlite(connection)
|
||||||
@@ -82,7 +66,6 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
|||||||
.TagWith("Observability.Tests.TermCount")
|
.TagWith("Observability.Tests.TermCount")
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
|
|
||||||
Assert.NotNull(recordedDuration);
|
Assert.Equal(0, await db.AcademicTerms.CountAsync());
|
||||||
Assert.True(recordedDuration >= 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ 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.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -11,6 +14,294 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class ExperimentGradesControllerTests
|
public sealed class ExperimentGradesControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignedTeacher_CanSubmitExperimentGradeSheet()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var project = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-TEACHER",
|
||||||
|
Name = "教师提交实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
fixture.Db.ExperimentProjects.Add(project);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var admin = fixture.ExperimentGrades(fixture.AdminScope);
|
||||||
|
await admin.CreateSheet(
|
||||||
|
new ExperimentGradeSheetRequest(
|
||||||
|
project.Id,
|
||||||
|
1,
|
||||||
|
60,
|
||||||
|
[new ExperimentGradeItemRequest(
|
||||||
|
"操作",
|
||||||
|
ExperimentGradeItemKind.Operation,
|
||||||
|
100)]),
|
||||||
|
CancellationToken.None);
|
||||||
|
var sheet = await fixture.Db.ExperimentGradeSheets
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.SingleAsync();
|
||||||
|
var record = Assert.Single(sheet.Records);
|
||||||
|
var item = Assert.Single(sheet.Items);
|
||||||
|
|
||||||
|
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
|
||||||
|
Assert.IsType<NoContentResult>(await teacher.UpdateRecords(
|
||||||
|
sheet.Id,
|
||||||
|
new ExperimentGradeRecordsRequest(
|
||||||
|
[
|
||||||
|
new ExperimentGradeRecordRequest(
|
||||||
|
record.Id,
|
||||||
|
ExperimentParticipationStatus.Completed,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
[new ExperimentGradeItemScoreRequest(item.Id, 85, null)])
|
||||||
|
]),
|
||||||
|
CancellationToken.None));
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
Assert.IsType<NoContentResult>(await teacher.Submit(
|
||||||
|
sheet.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Teacher_CanDownloadAndImportExperimentGradeTemplate()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var project = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-EXCEL",
|
||||||
|
Name = "Excel 实验成绩",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
fixture.Db.ExperimentProjects.Add(project);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var admin = fixture.ExperimentGrades(fixture.AdminScope);
|
||||||
|
await admin.CreateSheet(new ExperimentGradeSheetRequest(
|
||||||
|
project.Id, 1, 60,
|
||||||
|
[new ExperimentGradeItemRequest("操作", ExperimentGradeItemKind.Operation, 100)]),
|
||||||
|
CancellationToken.None);
|
||||||
|
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
|
||||||
|
var template = Assert.IsType<FileContentResult>(await teacher.DownloadTemplate(
|
||||||
|
sheet.Id, CancellationToken.None));
|
||||||
|
Assert.Equal("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
template.ContentType);
|
||||||
|
await using var templateStream = new MemoryStream(template.FileContents);
|
||||||
|
await using var stream = new MemoryStream();
|
||||||
|
using (var workbook = new XLWorkbook(templateStream))
|
||||||
|
{
|
||||||
|
var worksheet = workbook.Worksheet("实验成绩导入");
|
||||||
|
worksheet.Cell("D2").Value = "已完成";
|
||||||
|
worksheet.Cell("E2").Value = 85;
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
}
|
||||||
|
stream.Position = 0;
|
||||||
|
var file = new FormFile(stream, 0, stream.Length, "file", "实验成绩导入模板.xlsx");
|
||||||
|
Assert.IsType<OkObjectResult>(await teacher.ImportRecords(
|
||||||
|
sheet.Id, file, CancellationToken.None));
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
Assert.Equal(85m, await fixture.Db.ExperimentGradeRecords
|
||||||
|
.Select(record => record.TotalScore)
|
||||||
|
.SingleAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var collegeId = await fixture.Db.Courses
|
||||||
|
.Where(x => x.Id == fixture.Task.CourseId)
|
||||||
|
.Select(x => x.CollegeId)
|
||||||
|
.SingleAsync();
|
||||||
|
for (var index = 1; index <= 12; index++)
|
||||||
|
{
|
||||||
|
fixture.Db.ExperimentProjects.Add(new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = $"LAB-{index:00}",
|
||||||
|
Name = $"分页实验 {index:00}",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var otherCollege = new College { Code = "OTHER", Name = "其他学院" };
|
||||||
|
var otherCourse = new Course
|
||||||
|
{
|
||||||
|
CollegeId = otherCollege.Id,
|
||||||
|
Code = "OTHER-LAB",
|
||||||
|
Name = "其他学院实验",
|
||||||
|
Credits = 1,
|
||||||
|
TotalHours = 16,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.Practice,
|
||||||
|
AssessmentMethod = AssessmentMethod.Assessment
|
||||||
|
};
|
||||||
|
var otherTask = new TeachingTask
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
CourseId = otherCourse.Id,
|
||||||
|
TaskNumber = "OTHER-LAB-01",
|
||||||
|
Name = "其他学院实验班",
|
||||||
|
Capacity = 20,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var otherProject = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = otherTask.Id,
|
||||||
|
Code = "FOREIGN-LAB",
|
||||||
|
Name = "不可见实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(otherCollege, otherCourse, otherTask, otherProject);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var collegeScope = new FixedScope(new CurrentUserScope(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"学院管理员",
|
||||||
|
collegeId,
|
||||||
|
DataScope.College,
|
||||||
|
new HashSet<string>([SystemRoles.CollegeAdmin])));
|
||||||
|
var controller = fixture.ExperimentGrades(collegeScope);
|
||||||
|
var page = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Equal(1, Property<int>(page.Value, "Total"));
|
||||||
|
var course = Assert.Single(Property<System.Collections.IEnumerable>(
|
||||||
|
page.Value, "Items").Cast<object>());
|
||||||
|
Assert.Equal(12, Property<System.Collections.IEnumerable>(
|
||||||
|
course, "Projects").Cast<object>().Count());
|
||||||
|
|
||||||
|
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"LAB-12",
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Equal(1, Property<int>(filtered.Value, "Total"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManagementList_OrdersProjectsByScheduledWeekAndPeriod()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
Name = "实验课表",
|
||||||
|
Version = "v1",
|
||||||
|
Status = SchedulePlanStatus.Published
|
||||||
|
};
|
||||||
|
var laterEntry = new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = plan.Id,
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ClassroomId = fixture.Classroom.Id,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
DayOfWeek = 4,
|
||||||
|
StartPeriod = 5,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 5,
|
||||||
|
EndWeek = 5,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
};
|
||||||
|
var earlierEntry = new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = plan.Id,
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ClassroomId = fixture.Classroom.Id,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
DayOfWeek = 2,
|
||||||
|
StartPeriod = 3,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 2,
|
||||||
|
EndWeek = 2,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(plan, laterEntry, earlierEntry);
|
||||||
|
fixture.Db.AddRange(
|
||||||
|
new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ScheduleEntryId = laterEntry.Id,
|
||||||
|
ScheduleWeek = 5,
|
||||||
|
Code = "LAB-A",
|
||||||
|
Name = "后面的实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published
|
||||||
|
},
|
||||||
|
new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ScheduleEntryId = earlierEntry.Id,
|
||||||
|
ScheduleWeek = 2,
|
||||||
|
Code = "LAB-Z",
|
||||||
|
Name = "前面的实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(await fixture.ExperimentGrades(
|
||||||
|
fixture.AdminScope).GetManagement(
|
||||||
|
fixture.Term.Id, null, null, null, 1, 10, CancellationToken.None));
|
||||||
|
var course = Assert.Single(Property<System.Collections.IEnumerable>(
|
||||||
|
result.Value, "Items").Cast<object>());
|
||||||
|
var projects = Property<System.Collections.IEnumerable>(course, "Projects")
|
||||||
|
.Cast<object>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Equal(["前面的实验", "后面的实验"],
|
||||||
|
projects.Select(project => Property<string>(project, "Name")));
|
||||||
|
var schedule = Property<object>(projects[0], "ScheduleEntry");
|
||||||
|
Assert.Equal(2, Property<int>(projects[0], "ScheduleWeek"));
|
||||||
|
Assert.Equal(2, Property<int>(schedule, "DayOfWeek"));
|
||||||
|
Assert.Equal(3, Property<int>(schedule, "StartPeriod"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Property<T>(object? value, string name) =>
|
||||||
|
Assert.IsAssignableFrom<T>(
|
||||||
|
value!.GetType().GetProperty(name)!.GetValue(value));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
|
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
|
||||||
{
|
{
|
||||||
@@ -108,7 +399,17 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
var studentResult = await fixture
|
var studentResult = await fixture
|
||||||
.ExperimentGrades(fixture.StudentScope)
|
.ExperimentGrades(fixture.StudentScope)
|
||||||
.GetMine(CancellationToken.None);
|
.GetMine(CancellationToken.None);
|
||||||
Assert.IsType<OkObjectResult>(studentResult);
|
var studentResultOk = Assert.IsType<OkObjectResult>(studentResult);
|
||||||
|
var studentCourse = Assert.Single(
|
||||||
|
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
|
||||||
|
studentResultOk.Value).Cast<object>());
|
||||||
|
Assert.Equal(85m, Property<decimal>(
|
||||||
|
studentCourse,
|
||||||
|
"ExperimentCourseScore"));
|
||||||
|
var persistedExperimentCourseGrade = await fixture.Db
|
||||||
|
.ExperimentCourseGrades.SingleAsync();
|
||||||
|
Assert.Equal(85m, persistedExperimentCourseGrade.WeightedAverageScore);
|
||||||
|
Assert.Equal(1, persistedExperimentCourseGrade.PublishedProjectCount);
|
||||||
|
|
||||||
var courseSheet = new GradeSheet
|
var courseSheet = new GradeSheet
|
||||||
{
|
{
|
||||||
@@ -176,6 +477,83 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
Assert.IsType<ConflictObjectResult>(manualOverride);
|
Assert.IsType<ConflictObjectResult>(manualOverride);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExperimentCourseGrade_IsPersistedAsWeightedAverage()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var firstProject = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-W1",
|
||||||
|
Name = "低权重实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
GradeSheet = new ExperimentGradeSheet
|
||||||
|
{
|
||||||
|
ContributionWeight = 1,
|
||||||
|
Status = ExperimentGradeSheetStatus.Published,
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new ExperimentGradeRecord
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
ParticipationStatus = ExperimentParticipationStatus.Completed,
|
||||||
|
TotalScore = 80,
|
||||||
|
IsPassed = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var secondProject = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-W2",
|
||||||
|
Name = "高权重实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
GradeSheet = new ExperimentGradeSheet
|
||||||
|
{
|
||||||
|
ContributionWeight = 3,
|
||||||
|
Status = ExperimentGradeSheetStatus.Published,
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new ExperimentGradeRecord
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
ParticipationStatus = ExperimentParticipationStatus.Completed,
|
||||||
|
TotalScore = 90,
|
||||||
|
IsPassed = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(firstProject, secondProject);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
|
||||||
|
fixture.Db,
|
||||||
|
fixture.Task.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var persisted = await fixture.Db.ExperimentCourseGrades.SingleAsync();
|
||||||
|
Assert.Equal(87.5m, persisted.WeightedAverageScore);
|
||||||
|
Assert.Equal(4m, persisted.TotalWeight);
|
||||||
|
Assert.Equal(2, persisted.PublishedProjectCount);
|
||||||
|
|
||||||
|
firstProject.GradeSheet!.Records.Single().TotalScore = 0;
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
var referenced = await ExperimentGradeAggregationService.CalculateAsync(
|
||||||
|
fixture.Db,
|
||||||
|
fixture.Task.Id,
|
||||||
|
[fixture.Student.Id],
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(87.5m, referenced.Scores[fixture.Student.Id]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
|
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
|
||||||
{
|
{
|
||||||
@@ -260,6 +638,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
Student student,
|
Student student,
|
||||||
Classroom classroom,
|
Classroom classroom,
|
||||||
ICurrentUserDataScope adminScope,
|
ICurrentUserDataScope adminScope,
|
||||||
|
ICurrentUserDataScope teacherScope,
|
||||||
ICurrentUserDataScope studentScope)
|
ICurrentUserDataScope studentScope)
|
||||||
{
|
{
|
||||||
Connection = connection;
|
Connection = connection;
|
||||||
@@ -269,6 +648,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
Student = student;
|
Student = student;
|
||||||
Classroom = classroom;
|
Classroom = classroom;
|
||||||
AdminScope = adminScope;
|
AdminScope = adminScope;
|
||||||
|
TeacherScope = teacherScope;
|
||||||
StudentScope = studentScope;
|
StudentScope = studentScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +659,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
public Student Student { get; }
|
public Student Student { get; }
|
||||||
public Classroom Classroom { get; }
|
public Classroom Classroom { get; }
|
||||||
public ICurrentUserDataScope AdminScope { get; }
|
public ICurrentUserDataScope AdminScope { get; }
|
||||||
|
public ICurrentUserDataScope TeacherScope { get; }
|
||||||
public ICurrentUserDataScope StudentScope { get; }
|
public ICurrentUserDataScope StudentScope { get; }
|
||||||
|
|
||||||
public static async Task<ExperimentGradeFixture> CreateAsync()
|
public static async Task<ExperimentGradeFixture> CreateAsync()
|
||||||
@@ -410,6 +791,7 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
student,
|
student,
|
||||||
classroom,
|
classroom,
|
||||||
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
|
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
|
||||||
|
Scope(teacherUser, SystemRoles.Teacher, DataScope.Self),
|
||||||
Scope(studentUser, SystemRoles.Student, DataScope.Self));
|
Scope(studentUser, SystemRoles.Student, DataScope.Self));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,106 @@ public sealed class ExperimentsControllerTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchProjects_AssignsPastedNamesAndSequentialNamesInGenerationOrder()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
|
||||||
|
var pasted = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with
|
||||||
|
{
|
||||||
|
Names = ["实验一", "实验二"]
|
||||||
|
};
|
||||||
|
Assert.IsType<CreatedResult>(await controller.CreateProjects(pasted, CancellationToken.None));
|
||||||
|
Assert.Equal(["实验一", "实验二"], await fixture.Db.ExperimentProjects
|
||||||
|
.OrderBy(x => x.TeachingTask!.TaskNumber)
|
||||||
|
.Select(x => x.Name)
|
||||||
|
.ToListAsync());
|
||||||
|
|
||||||
|
fixture.Db.ExperimentProjects.RemoveRange(fixture.Db.ExperimentProjects);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
var sequential = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with
|
||||||
|
{
|
||||||
|
Name = "数据库实验",
|
||||||
|
GenerateSequentialNames = true
|
||||||
|
};
|
||||||
|
Assert.IsType<CreatedResult>(await controller.CreateProjects(sequential, CancellationToken.None));
|
||||||
|
Assert.Equal(["数据库实验 01", "数据库实验 02"], await fixture.Db.ExperimentProjects
|
||||||
|
.OrderBy(x => x.TeachingTask!.TaskNumber)
|
||||||
|
.Select(x => x.Name)
|
||||||
|
.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchProjectNames_UpdatesOnlySelectedDraftProjectsInRequestOrder()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await controller.CreateProjects(
|
||||||
|
fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var ids = await fixture.Db.ExperimentProjects
|
||||||
|
.OrderByDescending(x => x.TeachingTask!.TaskNumber)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = await controller.UpdateProjectNames(
|
||||||
|
new ExperimentProjectBatchNamesRequest(ids, ["第二个", "第一个"]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
var projectsById = await fixture.Db.ExperimentProjects.ToDictionaryAsync(x => x.Id, x => x.Name);
|
||||||
|
Assert.Equal("第二个", projectsById[ids[0]]);
|
||||||
|
Assert.Equal("第一个", projectsById[ids[1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralizedBatchProjects_CreatesOneProjectForEveryScheduledExperimentLesson()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
fixture.Db.ScheduleEntries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = fixture.ScheduleEntry.SchedulePlanId,
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
ClassroomId = fixture.SecondClassroom.Id,
|
||||||
|
DayOfWeek = 3,
|
||||||
|
StartPeriod = 5,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 8,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller(fixture.ManagerScope).CreateProjects(
|
||||||
|
new ExperimentProjectBatchRequest(
|
||||||
|
[fixture.Task.Id],
|
||||||
|
"LAB-ALL",
|
||||||
|
"全部课表实验",
|
||||||
|
ExperimentArrangementMode.Centralized,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
fixture.Term.StartDate,
|
||||||
|
fixture.Term.StartDate.AddDays(14)),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
var projects = await fixture.Db.ExperimentProjects
|
||||||
|
.Where(x => x.Code == "LAB-ALL")
|
||||||
|
.OrderBy(x => x.ScheduleEntryId)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(16, projects.Count);
|
||||||
|
Assert.All(projects, project =>
|
||||||
|
{
|
||||||
|
Assert.Equal(fixture.Task.Id, project.TeachingTaskId);
|
||||||
|
Assert.Equal(ExperimentArrangementMode.Centralized, project.ArrangementMode);
|
||||||
|
Assert.NotNull(project.ScheduleEntryId);
|
||||||
|
});
|
||||||
|
Assert.Equal(2, projects.Select(x => x.ScheduleEntryId).Distinct().Count());
|
||||||
|
Assert.Equal(8, projects.Select(x => x.ScheduleWeek).Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
||||||
{
|
{
|
||||||
@@ -61,7 +161,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null),
|
null,
|
||||||
|
[fixture.Teacher.Id]),
|
||||||
new ExperimentSessionBatchItem(
|
new ExperimentSessionBatchItem(
|
||||||
projectIds[1],
|
projectIds[1],
|
||||||
fixture.Classroom.Id,
|
fixture.Classroom.Id,
|
||||||
@@ -69,7 +170,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null)
|
null,
|
||||||
|
[fixture.Teacher.Id])
|
||||||
]),
|
]),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -102,7 +204,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null),
|
null,
|
||||||
|
[fixture.Teacher.Id]),
|
||||||
new ExperimentSessionBatchItem(
|
new ExperimentSessionBatchItem(
|
||||||
projectIds[1],
|
projectIds[1],
|
||||||
fixture.SecondClassroom.Id,
|
fixture.SecondClassroom.Id,
|
||||||
@@ -110,7 +213,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null)
|
null,
|
||||||
|
[fixture.Teacher.Id])
|
||||||
]),
|
]),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -118,6 +222,65 @@ public sealed class ExperimentsControllerTests
|
|||||||
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelfScheduledProject_AllowsMultipleCandidateSessionsAndRequiresSelectionWindow()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
var request = fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled) with
|
||||||
|
{
|
||||||
|
SelectionStartsAt = DateTime.UtcNow.AddHours(1),
|
||||||
|
SelectionEndsAt = DateTime.UtcNow.AddDays(1)
|
||||||
|
};
|
||||||
|
await manager.CreateProject(request, CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
|
||||||
|
var result = await manager.CreateSessions(
|
||||||
|
new ExperimentSessionBatchRequest(
|
||||||
|
[
|
||||||
|
new ExperimentSessionBatchItem(project.Id, fixture.Classroom.Id,
|
||||||
|
fixture.Term.StartDate, 1, 2, 20, null, [fixture.Teacher.Id]),
|
||||||
|
new ExperimentSessionBatchItem(project.Id, fixture.SecondClassroom.Id,
|
||||||
|
fixture.Term.StartDate, 4, 2, 20, null, [fixture.Teacher.Id])
|
||||||
|
]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
||||||
|
Assert.Equal(2, await fixture.Db.ExperimentSessionInstructors.CountAsync());
|
||||||
|
Assert.IsType<NoContentResult>(await manager.PublishProject(project.Id, CancellationToken.None));
|
||||||
|
var session = await fixture.Db.ExperimentSessions.OrderBy(x => x.StartPeriod).FirstAsync();
|
||||||
|
Assert.IsType<ConflictObjectResult>(
|
||||||
|
await fixture.Controller(fixture.StudentScope).Book(session.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoundInstructor_CanCancelOwnSession()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
Assert.IsType<CreatedResult>(await manager.CreateSession(
|
||||||
|
project.Id, fixture.SessionRequest(1, 2, 20), CancellationToken.None));
|
||||||
|
var session = await fixture.Db.ExperimentSessions.SingleAsync();
|
||||||
|
Assert.IsType<NoContentResult>(await manager.PublishProject(project.Id, CancellationToken.None));
|
||||||
|
|
||||||
|
var instructorScope = new FixedScope(new CurrentUserScope(
|
||||||
|
fixture.Teacher.UserId!.Value,
|
||||||
|
fixture.Teacher.Name,
|
||||||
|
null,
|
||||||
|
DataScope.Self,
|
||||||
|
new HashSet<string>([SystemRoles.Teacher])));
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(await fixture.Controller(instructorScope)
|
||||||
|
.CancelSession(session.Id, CancellationToken.None));
|
||||||
|
Assert.Equal(ExperimentSessionStatus.Cancelled,
|
||||||
|
(await fixture.Db.ExperimentSessions.SingleAsync()).Status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
|
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
|
||||||
{
|
{
|
||||||
@@ -146,6 +309,35 @@ public sealed class ExperimentsControllerTests
|
|||||||
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishedProject_AllowsTeachingDetailCorrectionWithoutChangingSchedule()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await controller.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
await controller.PublishProject(project.Id, CancellationToken.None);
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(await controller.CorrectPublishedProjectDetails(
|
||||||
|
project.Id,
|
||||||
|
new PublishedExperimentProjectCorrectionRequest(
|
||||||
|
"修正后的实验名称",
|
||||||
|
"按本班教学计划调整实验步骤。",
|
||||||
|
"请提前完成环境检查。"),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
var corrected = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
Assert.Equal("修正后的实验名称", corrected.Name);
|
||||||
|
Assert.Equal("按本班教学计划调整实验步骤。", corrected.Description);
|
||||||
|
Assert.Equal("请提前完成环境检查。", corrected.Requirements);
|
||||||
|
Assert.Equal("LAB-C", corrected.Code);
|
||||||
|
Assert.Equal(fixture.ScheduleEntry.Id, corrected.ScheduleEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||||
{
|
{
|
||||||
@@ -380,6 +572,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
AcademicTerm term,
|
AcademicTerm term,
|
||||||
TeachingTask task,
|
TeachingTask task,
|
||||||
|
Teacher teacher,
|
||||||
Classroom classroom,
|
Classroom classroom,
|
||||||
Classroom secondClassroom,
|
Classroom secondClassroom,
|
||||||
ScheduleEntry scheduleEntry,
|
ScheduleEntry scheduleEntry,
|
||||||
@@ -390,6 +583,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
Db = db;
|
Db = db;
|
||||||
Term = term;
|
Term = term;
|
||||||
Task = task;
|
Task = task;
|
||||||
|
Teacher = teacher;
|
||||||
Classroom = classroom;
|
Classroom = classroom;
|
||||||
SecondClassroom = secondClassroom;
|
SecondClassroom = secondClassroom;
|
||||||
ScheduleEntry = scheduleEntry;
|
ScheduleEntry = scheduleEntry;
|
||||||
@@ -401,6 +595,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
public AppDbContext Db { get; }
|
public AppDbContext Db { get; }
|
||||||
public AcademicTerm Term { get; }
|
public AcademicTerm Term { get; }
|
||||||
public TeachingTask Task { get; }
|
public TeachingTask Task { get; }
|
||||||
|
public Teacher Teacher { get; }
|
||||||
public Classroom Classroom { get; }
|
public Classroom Classroom { get; }
|
||||||
public Classroom SecondClassroom { get; }
|
public Classroom SecondClassroom { get; }
|
||||||
public ScheduleEntry ScheduleEntry { get; }
|
public ScheduleEntry ScheduleEntry { get; }
|
||||||
@@ -471,7 +666,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
{
|
{
|
||||||
TeacherNumber = "T2099",
|
TeacherNumber = "T2099",
|
||||||
Name = "实验教师",
|
Name = "实验教师",
|
||||||
CollegeId = college.Id
|
CollegeId = college.Id,
|
||||||
|
UserId = manager.Id
|
||||||
};
|
};
|
||||||
var term = new AcademicTerm
|
var term = new AcademicTerm
|
||||||
{
|
{
|
||||||
@@ -526,7 +722,15 @@ public sealed class ExperimentsControllerTests
|
|||||||
AcademicTermId = term.Id,
|
AcademicTermId = term.Id,
|
||||||
CourseId = course.Id,
|
CourseId = course.Id,
|
||||||
Capacity = 40,
|
Capacity = 40,
|
||||||
Status = TeachingTaskStatus.Published
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Teachers =
|
||||||
|
[
|
||||||
|
new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
db.AddRange(
|
db.AddRange(
|
||||||
manager,
|
manager,
|
||||||
@@ -583,6 +787,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
db,
|
db,
|
||||||
term,
|
term,
|
||||||
task,
|
task,
|
||||||
|
teacher,
|
||||||
classroom,
|
classroom,
|
||||||
secondClassroom,
|
secondClassroom,
|
||||||
scheduleEntry,
|
scheduleEntry,
|
||||||
@@ -615,7 +820,13 @@ public sealed class ExperimentsControllerTests
|
|||||||
"携带校园卡。",
|
"携带校园卡。",
|
||||||
Term.StartDate,
|
Term.StartDate,
|
||||||
Term.StartDate.AddDays(14),
|
Term.StartDate.AddDays(14),
|
||||||
mode == ExperimentArrangementMode.Centralized ? ScheduleEntry.Id : null);
|
mode == ExperimentArrangementMode.Centralized ? ScheduleEntry.Id : null,
|
||||||
|
mode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? new DateTime(2020, 1, 1, 8, 0, 0)
|
||||||
|
: null,
|
||||||
|
mode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? new DateTime(2100, 1, 1, 18, 0, 0)
|
||||||
|
: null);
|
||||||
|
|
||||||
public ExperimentProjectBatchRequest BatchProjectRequest(
|
public ExperimentProjectBatchRequest BatchProjectRequest(
|
||||||
ExperimentArrangementMode mode) =>
|
ExperimentArrangementMode mode) =>
|
||||||
@@ -642,7 +853,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
startPeriod,
|
startPeriod,
|
||||||
periodCount,
|
periodCount,
|
||||||
capacity,
|
capacity,
|
||||||
null);
|
null,
|
||||||
|
[Teacher.Id]);
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
@@ -83,8 +84,12 @@ public sealed class GradesPaginationTests
|
|||||||
var sheet = new GradeSheet
|
var sheet = new GradeSheet
|
||||||
{
|
{
|
||||||
TeachingTaskId = tasks[0].Id,
|
TeachingTaskId = tasks[0].Id,
|
||||||
RegularWeight = 30,
|
RegularWeight = 20,
|
||||||
FinalWeight = 70,
|
FinalWeight = 60,
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 }
|
||||||
|
],
|
||||||
Records = students.Select(student => new GradeRecord
|
Records = students.Select(student => new GradeRecord
|
||||||
{
|
{
|
||||||
StudentId = student.Id
|
StudentId = student.Id
|
||||||
@@ -129,6 +134,18 @@ public sealed class GradesPaginationTests
|
|||||||
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
|
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
|
||||||
.GetValue(secondDetail.Value)!;
|
.GetValue(secondDetail.Value)!;
|
||||||
Assert.Single(ReadItems(secondSheet, "Records"));
|
Assert.Single(ReadItems(secondSheet, "Records"));
|
||||||
|
|
||||||
|
var templateResult = Assert.IsType<FileContentResult>(
|
||||||
|
await controller.DownloadTemplate(sheet.Id, CancellationToken.None));
|
||||||
|
using var stream = new MemoryStream(templateResult.FileContents);
|
||||||
|
using var workbook = new XLWorkbook(stream);
|
||||||
|
var worksheet = workbook.Worksheet("成绩导入");
|
||||||
|
Assert.Equal(
|
||||||
|
["学号", "姓名", "班级", "平时成绩", "实验", "期末成绩", "总分(自动计算)", "考试状态", "备注"],
|
||||||
|
worksheet.Row(1).CellsUsed().Select(cell => cell.GetString()));
|
||||||
|
Assert.Contains("D2*20/100", worksheet.Cell("G2").FormulaA1);
|
||||||
|
Assert.Contains("E2*20/100", worksheet.Cell("G2").FormulaA1);
|
||||||
|
Assert.Contains("F2*60/100", worksheet.Cell("G2").FormulaA1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ReadInt(object value, string property) =>
|
private static int ReadInt(object value, string property) =>
|
||||||
|
|||||||
@@ -109,6 +109,34 @@ public sealed class MySqlMigrationTests
|
|||||||
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MySql_experiment_course_grade_lookup_uses_provider_safe_predicates()
|
||||||
|
{
|
||||||
|
using var db = new AppDbContext(CreateMySqlOptions());
|
||||||
|
var teachingTaskId = Guid.Parse(
|
||||||
|
"33333333-3333-3333-3333-333333333333");
|
||||||
|
var studentIds = new[]
|
||||||
|
{
|
||||||
|
Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||||
|
Guid.Parse("22222222-2222-2222-2222-222222222222")
|
||||||
|
};
|
||||||
|
|
||||||
|
var sql = db.ExperimentCourseGrades
|
||||||
|
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||||
|
.WhereIn(studentIds, x => x.StudentId)
|
||||||
|
.ToQueryString();
|
||||||
|
|
||||||
|
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(
|
||||||
|
teachingTaskId.ToString(),
|
||||||
|
sql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.All(studentIds, studentId => Assert.Contains(
|
||||||
|
studentId.ToString(),
|
||||||
|
sql,
|
||||||
|
StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MySql_attendance_device_hash_query_uses_provider_safe_predicates()
|
public void MySql_attendance_device_hash_query_uses_provider_safe_predicates()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -234,6 +234,53 @@ public sealed class NotificationsControllerTests
|
|||||||
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
|
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mark_all_read_only_updates_the_current_users_unread_notifications()
|
||||||
|
{
|
||||||
|
await using var fixture = await NotificationFixture.CreateAsync();
|
||||||
|
fixture.Db.Notifications.AddRange(
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
UserId = fixture.Sender.Id,
|
||||||
|
Title = "当前用户未读",
|
||||||
|
Content = "应被标为已读。"
|
||||||
|
},
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
UserId = fixture.Sender.Id,
|
||||||
|
Title = "当前用户已读",
|
||||||
|
Content = "应保持已读。",
|
||||||
|
IsRead = true
|
||||||
|
},
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
UserId = fixture.CollegeRecipient.Id,
|
||||||
|
Title = "其他用户未读",
|
||||||
|
Content = "不应被修改。"
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new NotificationsController(
|
||||||
|
fixture.Db,
|
||||||
|
new TestDataScope(
|
||||||
|
fixture.Sender.Id,
|
||||||
|
fixture.FirstCollege.Id,
|
||||||
|
SystemRoles.AcademicAdmin));
|
||||||
|
|
||||||
|
var result = await controller.MarkAllRead(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
var notifications = await fixture.Db.Notifications.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Title)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.All(
|
||||||
|
notifications.Where(x => x.UserId == fixture.Sender.Id),
|
||||||
|
notification => Assert.True(notification.IsRead));
|
||||||
|
Assert.False(Assert.Single(
|
||||||
|
notifications,
|
||||||
|
x => x.UserId == fixture.CollegeRecipient.Id).IsRead);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Publishing_course_grades_automatically_notifies_roster_students()
|
public async Task Publishing_course_grades_automatically_notifies_roster_students()
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user