Compare commits
78
Commits
codex/shiyan
...
v2.4.0
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
|
||||
CLICKHOUSE_USER=jiaowu_analytics
|
||||
CLICKHOUSE_PASSWORD=
|
||||
|
||||
RABBITMQ_USER=jiaowu
|
||||
RABBITMQ_PASSWORD=
|
||||
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||
|
||||
+23
-3
@@ -2,6 +2,13 @@
|
||||
ASPNETCORE_ENVIRONMENT=Production
|
||||
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__ApplyMigrationsOnStartup=false
|
||||
Database__CommandTimeoutSeconds=30
|
||||
@@ -18,6 +25,7 @@ BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
BackgroundJobs__ExamArrangementConcurrency=1
|
||||
BackgroundJobs__CourseGradeStatisticsRefreshConcurrency=1
|
||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
# RabbitMq__Port=5671
|
||||
# RabbitMq__UserName=jiaowu
|
||||
@@ -36,15 +44,15 @@ Cache__AnalyticsExpirationMinutes=3
|
||||
Cache__AnalyticsLocalExpirationSeconds=30
|
||||
Cache__MaximumPayloadKilobytes=2048
|
||||
|
||||
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
|
||||
# 应用日志建立接口与慢 SQL 基线;日志系统可按 DurationMs、RequestId、QueryName 聚合。
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__LogAllApiRequests=true
|
||||
Observability__SlowRequestThresholdMilliseconds=1000
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
||||
Observability__IncludeSqlText=false
|
||||
Observability__MaximumSqlTextLength=2000
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
||||
|
||||
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||
PerformanceReporting__Enabled=false
|
||||
@@ -54,6 +62,18 @@ PerformanceReporting__Enabled=false
|
||||
PerformanceReporting__CacheSeconds=30
|
||||
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__BackupWarningHours=24
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build self-contained platform packages
|
||||
- name: Build framework-dependent platform packages
|
||||
shell: bash
|
||||
run: |
|
||||
bash ./scripts/publish-platform-packages.sh \
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- Windows x64:`.zip`
|
||||
- Linux x64 / ARM64:`.tar.gz`
|
||||
- `SHA256SUMS`:发布包校验值
|
||||
- 自包含程序包无需预装 .NET 或 ASP.NET Core Runtime
|
||||
- 二进制程序包不包含 .NET 或 ASP.NET Core Runtime;请先安装匹配版本的 ASP.NET Core Runtime
|
||||
|
||||
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
|
||||
WORKDIR /source
|
||||
COPY versions.props ./
|
||||
COPY web/package.json web/package-lock.json ./web/
|
||||
RUN npm --prefix web ci
|
||||
COPY web/ ./web/
|
||||
@@ -11,6 +12,7 @@ FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
ARG TARGETARCH
|
||||
WORKDIR /source
|
||||
COPY global.json ./
|
||||
COPY versions.props ./
|
||||
COPY .env.example ./
|
||||
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
|
||||
RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH"
|
||||
|
||||
@@ -366,39 +366,36 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
|
||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||
|
||||
### OpenTelemetry 与慢查询定位
|
||||
### 慢接口与慢查询基线
|
||||
|
||||
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
||||
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
||||
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
||||
仍然有效。
|
||||
应用不依赖 OpenTelemetry。启用 `Observability` 后,会为每个 `/api` 请求写入结构化的
|
||||
方法、路径、终结点、状态码、耗时和请求号;超过阈值或返回 5xx 的请求会提升为 Warning。
|
||||
EF Core 数据库命令超过阈值时同样写入查询名称、SQL 模板哈希、数据库类型、耗时和相同的
|
||||
请求号。用日志平台按 `DurationMs` 聚合即可得到真实的 P50/P95/P99 和慢接口排行。
|
||||
|
||||
```text
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__LogAllApiRequests=true
|
||||
Observability__SlowRequestThresholdMilliseconds=1000
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
```
|
||||
|
||||
数据库指标包括 `jiaowu.db.command.duration`、`jiaowu.db.command.slow` 和
|
||||
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
|
||||
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
|
||||
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
|
||||
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
|
||||
为关键 EF 查询添加 `TagWith("模块.查询名")` 后,慢 SQL 日志会直接显示稳定名称;无标签
|
||||
查询只显示操作类型和 SQL 模板哈希。默认 `Observability__IncludeSqlText=false`,不会把
|
||||
SQL 模板、参数值或连接串写入日志。仅在受控诊断窗口内临时启用 SQL 模板记录,并限制日志
|
||||
访问权限与保留时间。
|
||||
|
||||
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||
应用侧请求号用于关联接口和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
||||
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
||||
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
||||
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
||||
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
||||
|
||||
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
||||
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
||||
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
||||
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
||||
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
||||
如部署环境另行提供 Prometheus 兼容指标源,超级管理员仍可在“组织与权限 → 运维与审计 →
|
||||
系统性能”查看其汇总数据。该页面只读查询外部指标源,浏览器不会接触其地址或令牌;结果
|
||||
默认缓存 30 秒。应用本身不会再通过 OpenTelemetry 向该指标源写入数据。
|
||||
|
||||
```text
|
||||
PerformanceReporting__Enabled=true
|
||||
@@ -415,6 +412,22 @@ Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置
|
||||
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||
`*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
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
@@ -455,7 +468,7 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
||||
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
||||
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
||||
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
||||
可接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||
可由现有日志平台或运行时指标采集器汇总。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
||||
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||
Cache__Enabled: "true"
|
||||
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__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||
@@ -47,6 +52,25 @@ x-json-logging: &json-logging
|
||||
max-file: "3"
|
||||
|
||||
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:
|
||||
image: rabbitmq:4.2-management-alpine
|
||||
restart: unless-stopped
|
||||
@@ -135,6 +159,8 @@ services:
|
||||
condition: service_started
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
@@ -165,5 +191,6 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
clickhouse-data:
|
||||
rabbitmq-data:
|
||||
backup-data:
|
||||
|
||||
@@ -39,7 +39,7 @@ for runtime in "${runtimes[@]}"; do
|
||||
dotnet publish "$project" \
|
||||
--configuration Release \
|
||||
--runtime "$runtime" \
|
||||
--self-contained true \
|
||||
--self-contained false \
|
||||
--output "$package_root" \
|
||||
-p:BuildFrontendOnPublish=false \
|
||||
-p:Version="$version" \
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
@@ -23,43 +24,73 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
// ═══════════════ Aggregated pending ═══════════════
|
||||
[HttpGet("pending")]
|
||||
[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)
|
||||
.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))
|
||||
.ToListAsync(ct));
|
||||
total += await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||
|
||||
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))
|
||||
.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)
|
||||
.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))
|
||||
.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)
|
||||
.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))
|
||||
.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)
|
||||
.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))
|
||||
.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)
|
||||
.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))
|
||||
.ToListAsync(ct));
|
||||
total += await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted).CountAsync(ct);
|
||||
|
||||
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))
|
||||
.ToListAsync(ct));
|
||||
total += await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted).CountAsync(ct);
|
||||
|
||||
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))
|
||||
.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 ═══════════════
|
||||
@@ -251,13 +282,6 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
// Auto-apply: update grade record
|
||||
gm.GradeRecord!.TotalScore = 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 NotificationService.SendAsync(
|
||||
db,
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -33,14 +34,32 @@ public sealed class AttendanceController(
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> GetMyTasks(
|
||||
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()
|
||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||
if (academicTermId.HasValue)
|
||||
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
|
||||
.OrderBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -63,22 +82,44 @@ public sealed class AttendanceController(
|
||||
sheet.TeachingTaskId == x.Id)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
return Ok(new PagedResult<object>(result, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("sheets")]
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> GetSheets(
|
||||
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()
|
||||
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
var sheets = await db.AttendanceSheets.AsNoTracking()
|
||||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||
var source = db.AttendanceSheets.AsNoTracking()
|
||||
.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)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -99,7 +140,7 @@ public sealed class AttendanceController(
|
||||
TotalCount = x.Records.Count
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(sheets);
|
||||
return Ok(new PagedResult<object>(sheets, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("sheets")]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
@@ -232,8 +233,49 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
}
|
||||
|
||||
[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(
|
||||
AppCacheKeys.BaseData("classes"),
|
||||
token => db.AdministrativeClasses.AsNoTracking()
|
||||
@@ -474,8 +516,54 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
}
|
||||
|
||||
[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(
|
||||
AppCacheKeys.BaseData("classrooms"),
|
||||
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 Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -175,8 +177,13 @@ public sealed class CourseAdjustmentsController(
|
||||
public async Task<ActionResult> GetMine(
|
||||
Guid? academicTermId,
|
||||
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 source = db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x => x.ApplicantUserId == userId);
|
||||
@@ -186,10 +193,15 @@ public sealed class CourseAdjustmentsController(
|
||||
if (status.HasValue)
|
||||
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)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(AdjustmentProjection())
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
// ═══════════════ Pending reviews ═══════════════
|
||||
@@ -198,8 +210,13 @@ public sealed class CourseAdjustmentsController(
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> GetPendingReviews(
|
||||
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 source = db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
|
||||
@@ -210,10 +227,15 @@ public sealed class CourseAdjustmentsController(
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||
|
||||
return Ok(await source
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.SubmittedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(AdjustmentProjection())
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
// ═══════════════ Detail ═══════════════
|
||||
@@ -286,6 +308,8 @@ public sealed class CourseAdjustmentsController(
|
||||
|
||||
db.CourseAdjustments.Add(adj);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await new PublishedTimetableProjectionService(db)
|
||||
.RebuildPublishedPlansForTaskAsync(adj.TeachingTaskId, cancellationToken);
|
||||
|
||||
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")]
|
||||
[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()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
@@ -428,11 +442,64 @@ public sealed class CourseSelectionsController(
|
||||
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
|
||||
return Forbid();
|
||||
|
||||
var students = await db.CourseEnrollments.AsNoTracking()
|
||||
var enrolledSource = db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
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)
|
||||
.Skip((studentPage - 1) * studentPageSize)
|
||||
.Take(studentPageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -445,12 +512,11 @@ public sealed class CourseSelectionsController(
|
||||
x.EnrolledAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var waitlistedRows = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.CourseSelectionOfferingId == id &&
|
||||
x.Status == CourseEnrollmentStatus.Waitlisted)
|
||||
var waitlistedRows = await waitlistSource
|
||||
.OrderBy(x => x.WaitlistedAt)
|
||||
.ThenBy(x => x.CreatedAt)
|
||||
.Skip((waitlistPage - 1) * waitlistPageSize)
|
||||
.Take(waitlistPageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -474,7 +540,7 @@ public sealed class CourseSelectionsController(
|
||||
item.MajorName,
|
||||
item.Grade,
|
||||
item.WaitlistedAt,
|
||||
Position = index + 1
|
||||
Position = (waitlistPage - 1) * waitlistPageSize + index + 1
|
||||
})
|
||||
.ToList();
|
||||
return Ok(new
|
||||
@@ -487,10 +553,24 @@ public sealed class CourseSelectionsController(
|
||||
offering.CourseName,
|
||||
offering.CourseNature,
|
||||
offering.Capacity,
|
||||
EnrolledCount = students.Count,
|
||||
EnrolledCount = enrolledCount,
|
||||
Students = students,
|
||||
WaitlistedCount = waitlist.Count,
|
||||
StudentPage = studentPage,
|
||||
StudentPageSize = studentPageSize,
|
||||
StudentTotal = enrolledTotal,
|
||||
WaitlistedCount = waitlistedCount,
|
||||
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 =
|
||||
offering.RoundStatus == CourseSelectionRoundStatus.Open &&
|
||||
(scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
@@ -1011,7 +1091,11 @@ public sealed class CourseSelectionsController(
|
||||
x.TeachingTask!.Status == TeachingTaskStatus.Published &&
|
||||
(x.IsOpenToAll ||
|
||||
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)
|
||||
.Select(x => new StudentOfferingDto(
|
||||
x.Id,
|
||||
|
||||
@@ -432,6 +432,48 @@ public sealed class CurriculumPlansController(
|
||||
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}")]
|
||||
public async Task<ActionResult> UpdateCourse(
|
||||
Guid planId,
|
||||
@@ -586,3 +628,8 @@ public sealed record CurriculumCourseRequest(
|
||||
[Range(1, 20)] int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
[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,
|
||||
counts,
|
||||
pending,
|
||||
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
|
||||
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(
|
||||
CurrentUserScope scope,
|
||||
Guid? restrictedCollegeId,
|
||||
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
|
||||
DashboardTerm? CurrentTerm,
|
||||
DashboardCounts Counts,
|
||||
DashboardPending Pending,
|
||||
DashboardGreeting Greeting,
|
||||
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(
|
||||
string Level,
|
||||
string Title,
|
||||
|
||||
@@ -391,81 +391,90 @@ public sealed class EvaluationsController(
|
||||
if (taskIds.Count == 0)
|
||||
return Ok(new { Tasks = Array.Empty<object>() });
|
||||
|
||||
var setups = await db.EvaluationSetups.AsNoTracking()
|
||||
.Where(x => x.Status == EvaluationSetupStatus.Closed)
|
||||
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
var taskSummaries = await db.EvaluationRecords.AsNoTracking()
|
||||
.WhereIn(taskIds, record => record.TeachingTaskId)
|
||||
.Where(record => record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed)
|
||||
.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);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var setup in setups)
|
||||
{
|
||||
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
|
||||
.Where(r => r.EvaluationSetupId == setup.Id)
|
||||
.WhereIn(taskIds, r => r.TeachingTaskId)
|
||||
.Select(r => r.TeachingTaskId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var taskId in setupTaskIds)
|
||||
var dimensionSummaries = await (
|
||||
from score in db.EvaluationScores.AsNoTracking()
|
||||
join record in db.EvaluationRecords.AsNoTracking()
|
||||
on score.EvaluationRecordId equals record.Id
|
||||
join dimension in db.EvaluationDimensions.AsNoTracking()
|
||||
on score.EvaluationDimensionId equals dimension.Id
|
||||
where taskIds.Contains(record.TeachingTaskId) &&
|
||||
record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed
|
||||
group score by new
|
||||
{
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.Id == taskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
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
|
||||
});
|
||||
record.EvaluationSetupId,
|
||||
record.TeachingTaskId,
|
||||
dimension.Id,
|
||||
dimension.Name,
|
||||
dimension.MaxScore,
|
||||
dimension.SortOrder
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -594,3 +603,27 @@ public sealed record SubmitEvaluationRequest(
|
||||
public sealed record EvaluationScoreRequest(
|
||||
Guid DimensionId,
|
||||
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.Data;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
@@ -38,8 +41,15 @@ public sealed class ExperimentGradesController(
|
||||
public async Task<ActionResult> GetManagement(
|
||||
Guid? academicTermId,
|
||||
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()
|
||||
.Where(x =>
|
||||
x.Status == ExperimentProjectStatus.Published ||
|
||||
@@ -51,10 +61,43 @@ public sealed class ExperimentGradesController(
|
||||
source = source.Where(x =>
|
||||
x.GradeSheet != null &&
|
||||
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
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
var total = await source.Select(x => x.TeachingTaskId)
|
||||
.Distinct()
|
||||
.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)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -62,6 +105,7 @@ public sealed class ExperimentGradesController(
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
x.ScheduleWeek,
|
||||
ProjectStatus = x.Status,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
@@ -74,6 +118,14 @@ public sealed class ExperimentGradesController(
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ScheduleEntry = x.ScheduleEntry == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
x.ScheduleEntry.DayOfWeek,
|
||||
x.ScheduleEntry.StartPeriod,
|
||||
x.ScheduleEntry.PeriodCount
|
||||
},
|
||||
Sheet = x.GradeSheet == null
|
||||
? null
|
||||
: new
|
||||
@@ -96,7 +148,40 @@ public sealed class ExperimentGradesController(
|
||||
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")]
|
||||
@@ -107,7 +192,7 @@ public sealed class ExperimentGradesController(
|
||||
if (student is null)
|
||||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
|
||||
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
|
||||
var results = await db.ExperimentGradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.ExperimentGradeSheet!.Status ==
|
||||
@@ -126,12 +211,19 @@ public sealed class ExperimentGradesController(
|
||||
ProjectName =
|
||||
x.ExperimentGradeSheet.ExperimentProject.Name,
|
||||
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
|
||||
.TeachingTask!.Course!.Code,
|
||||
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.Course.Name,
|
||||
TermName = x.ExperimentGradeSheet.ExperimentProject
|
||||
.TeachingTask.AcademicTerm!.Name,
|
||||
x.ExperimentGradeSheet.ContributionWeight,
|
||||
x.ExperimentGradeSheet.PassScore,
|
||||
x.ParticipationStatus,
|
||||
x.TotalScore,
|
||||
@@ -156,7 +248,40 @@ public sealed class ExperimentGradesController(
|
||||
}),
|
||||
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")]
|
||||
@@ -244,6 +369,15 @@ public sealed class ExperimentGradesController(
|
||||
ProjectCode = x.ExperimentProject!.Code,
|
||||
ProjectName = x.ExperimentProject.Name,
|
||||
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.TeachingTask!.TaskNumber,
|
||||
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||
@@ -285,6 +419,14 @@ public sealed class ExperimentGradesController(
|
||||
|
||||
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
||||
.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)
|
||||
recordsSource = recordsSource.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||
@@ -378,7 +520,7 @@ public sealed class ExperimentGradesController(
|
||||
sheet.CreatedAt,
|
||||
sheet.UpdatedAt
|
||||
},
|
||||
CanEdit = CanEdit(task) &&
|
||||
CanEdit = (CanEdit(task) || isBoundInstructor) &&
|
||||
sheet.Status is
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned,
|
||||
@@ -450,13 +592,18 @@ public sealed class ExperimentGradesController(
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ExperimentSession)
|
||||
.ThenInclude(x => x!.Instructors)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!) &&
|
||||
!CanEditBoundSessionRecords(sheet, request.Records)) return Forbid();
|
||||
if (sheet.Status is not (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
@@ -502,6 +649,166 @@ public sealed class ExperimentGradesController(
|
||||
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")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> SyncParticipants(
|
||||
@@ -713,7 +1020,15 @@ public sealed class ExperimentGradesController(
|
||||
|
||||
sheet.Status = ExperimentGradeSheetStatus.Published;
|
||||
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
|
||||
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
||||
.Select(x => x.Student!.UserId)
|
||||
@@ -747,9 +1062,29 @@ public sealed class ExperimentGradesController(
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.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);
|
||||
}
|
||||
|
||||
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(
|
||||
ExperimentProject project,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -816,6 +1151,15 @@ public sealed class ExperimentGradesController(
|
||||
|
||||
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);
|
||||
return db.ExperimentProjects.Where(x =>
|
||||
taskIds.Contains(x.TeachingTaskId));
|
||||
@@ -838,6 +1182,22 @@ public sealed class ExperimentGradesController(
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
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) =>
|
||||
task.Teachers.Any(x =>
|
||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||
@@ -890,6 +1250,93 @@ public sealed class ExperimentGradesController(
|
||||
private static bool ValidScore(decimal? score) =>
|
||||
!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) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Experiments;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -31,12 +33,21 @@ public sealed class ExperimentsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetOptions(
|
||||
Guid? academicTermId,
|
||||
Guid? offeringCollegeId,
|
||||
string? courseKeyword,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||
if (academicTermId.HasValue)
|
||||
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()
|
||||
.Where(x => x.IsEnabled);
|
||||
@@ -76,6 +87,7 @@ public sealed class ExperimentsController(
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
x.CourseId,
|
||||
x.SchedulingMode,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermStartDate = x.AcademicTerm.StartDate,
|
||||
TermEndDate = x.AcademicTerm.EndDate,
|
||||
@@ -90,6 +102,13 @@ public sealed class ExperimentsController(
|
||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||
.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),
|
||||
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||
@@ -99,6 +118,11 @@ public sealed class ExperimentsController(
|
||||
.Contains(x.TeachingTaskId))
|
||||
.Where(x => !academicTermId.HasValue ||
|
||||
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)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ThenBy(x => x.DayOfWeek)
|
||||
@@ -116,10 +140,14 @@ public sealed class ExperimentsController(
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
ClassNames = x.TeachingTask!.Classes
|
||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||
.Select(item => item.AdministrativeClass!.Name),
|
||||
ClassroomName = x.Classroom!.Name,
|
||||
BuildingName = x.Classroom.Building!.Name,
|
||||
CampusName = x.Classroom.Building.Campus!.Name
|
||||
})
|
||||
.Take(500)
|
||||
.ToListAsync(cancellationToken),
|
||||
Classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
@@ -136,6 +164,19 @@ public sealed class ExperimentsController(
|
||||
,x.TeachingVenueNature
|
||||
})
|
||||
.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
|
||||
});
|
||||
}
|
||||
@@ -146,9 +187,21 @@ public sealed class ExperimentsController(
|
||||
Guid? academicTermId,
|
||||
ExperimentArrangementMode? arrangementMode,
|
||||
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 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)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||
@@ -158,7 +211,47 @@ public sealed class ExperimentsController(
|
||||
if (status.HasValue)
|
||||
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)
|
||||
.ThenBy(x => x.StartDate)
|
||||
.ThenBy(x => x.Code)
|
||||
@@ -167,6 +260,7 @@ public sealed class ExperimentsController(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.ScheduleEntryId,
|
||||
x.ScheduleWeek,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
@@ -174,6 +268,8 @@ public sealed class ExperimentsController(
|
||||
x.Requirements,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.SelectionStartsAt,
|
||||
x.SelectionEndsAt,
|
||||
x.Status,
|
||||
x.PublishedAt,
|
||||
x.ClosedAt,
|
||||
@@ -197,6 +293,7 @@ public sealed class ExperimentsController(
|
||||
x.ScheduleEntry.StartWeek,
|
||||
x.ScheduleEntry.EndWeek,
|
||||
x.ScheduleEntry.WeekPattern,
|
||||
ProjectWeek = x.ScheduleWeek,
|
||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||
@@ -218,9 +315,19 @@ public sealed class ExperimentsController(
|
||||
ClassroomName = item.Classroom!.Name,
|
||||
BuildingName = item.Classroom.Building!.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")]
|
||||
@@ -254,10 +361,13 @@ public sealed class ExperimentsController(
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
x.ScheduleEntryId,
|
||||
x.ScheduleWeek,
|
||||
x.Description,
|
||||
x.Requirements,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.SelectionStartsAt,
|
||||
x.SelectionEndsAt,
|
||||
x.Status,
|
||||
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||
@@ -275,6 +385,7 @@ public sealed class ExperimentsController(
|
||||
x.ScheduleEntry.StartWeek,
|
||||
x.ScheduleEntry.EndWeek,
|
||||
x.ScheduleEntry.WeekPattern,
|
||||
ProjectWeek = x.ScheduleWeek,
|
||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||
@@ -296,6 +407,9 @@ public sealed class ExperimentsController(
|
||||
ClassroomName = item.Classroom!.Name,
|
||||
BuildingName = item.Classroom.Building!.Name,
|
||||
CampusName = item.Classroom.Building.Campus!.Name
|
||||
,InstructorNames = item.Instructors
|
||||
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
|
||||
.Select(instructor => instructor.Teacher!.Name)
|
||||
}),
|
||||
MyBooking = x.Bookings
|
||||
.Where(item =>
|
||||
@@ -350,7 +464,9 @@ public sealed class ExperimentsController(
|
||||
Description = Normalize(request.Description),
|
||||
Requirements = Normalize(request.Requirements),
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate
|
||||
EndDate = request.EndDate,
|
||||
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
||||
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
||||
};
|
||||
db.ExperimentProjects.Add(project);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -363,8 +479,6 @@ public sealed class ExperimentsController(
|
||||
ExperimentProjectBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ArrangementMode == ExperimentArrangementMode.Centralized)
|
||||
return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。");
|
||||
var taskIds = request.TeachingTaskIds
|
||||
.Where(x => x != Guid.Empty)
|
||||
.Distinct()
|
||||
@@ -384,21 +498,72 @@ public sealed class ExperimentsController(
|
||||
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
||||
|
||||
var first = tasks[0];
|
||||
if (tasks.Any(x =>
|
||||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
|
||||
x.AcademicTermId != first.AcademicTermId ||
|
||||
x.CourseId != first.CourseId))
|
||||
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||||
|
||||
var code = request.Code.Trim();
|
||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Where(x => x.Code == code)
|
||||
.Select(x => x.TeachingTask!.TaskNumber)
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (conflictingTaskNumbers.Count > 0)
|
||||
return ConflictProblem(
|
||||
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
||||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||||
{
|
||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Where(x => x.Code == code)
|
||||
.Select(x => x.TeachingTask!.TaskNumber)
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
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);
|
||||
foreach (var task in tasks)
|
||||
@@ -407,17 +572,65 @@ public sealed class ExperimentsController(
|
||||
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||||
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,
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
ArrangementMode = request.ArrangementMode,
|
||||
Description = Normalize(request.Description),
|
||||
Requirements = Normalize(request.Requirements),
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate
|
||||
});
|
||||
var scheduleEntry = occurrence.Entry;
|
||||
var legacy = scheduleEntry is null ? null : legacyProjects
|
||||
.SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
|
||||
if (legacy is not null)
|
||||
{
|
||||
var firstWeek = scheduledOccurrences
|
||||
.Where(item => item.Entry.Id == scheduleEntry!.Id)
|
||||
.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);
|
||||
@@ -470,6 +683,8 @@ public sealed class ExperimentsController(
|
||||
project.Requirements = Normalize(request.Requirements);
|
||||
project.StartDate = request.StartDate;
|
||||
project.EndDate = request.EndDate;
|
||||
project.SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt);
|
||||
project.SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
@@ -491,6 +706,87 @@ public sealed class ExperimentsController(
|
||||
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")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> PublishProject(
|
||||
@@ -499,6 +795,7 @@ public sealed class ExperimentsController(
|
||||
{
|
||||
var project = await ScopedProjects()
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Instructors)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
@@ -518,35 +815,59 @@ public sealed class ExperimentsController(
|
||||
(x.SessionDate < project.StartDate ||
|
||||
x.SessionDate > project.EndDate)))
|
||||
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.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(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);
|
||||
}
|
||||
await NotifyProjectPublishedAsync(project, cancellationToken);
|
||||
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")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CloseProject(
|
||||
@@ -600,6 +921,10 @@ public sealed class ExperimentsController(
|
||||
cancellationToken),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
session.Instructors = request.InstructorTeacherIds!
|
||||
.Distinct()
|
||||
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||
.ToList();
|
||||
db.ExperimentSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -635,16 +960,14 @@ public sealed class ExperimentsController(
|
||||
if (request.Items.Count > 100)
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("单次最多安排 100 条实验场次。"));
|
||||
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
|
||||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
|
||||
request.Items.Count)
|
||||
if (request.Items.Any(x => x.ProjectId == Guid.Empty))
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
|
||||
ValidationProblem("实验项目不能为空。"));
|
||||
|
||||
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
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()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
@@ -688,6 +1011,10 @@ public sealed class ExperimentsController(
|
||||
cancellationToken),
|
||||
Notes = Normalize(item.Notes)
|
||||
};
|
||||
session.Instructors = item.InstructorTeacherIds!
|
||||
.Distinct()
|
||||
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||
.ToList();
|
||||
db.ExperimentSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
createdSessions.Add((project, session));
|
||||
@@ -731,12 +1058,11 @@ public sealed class ExperimentsController(
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Include(x => x.Instructors)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null ||
|
||||
!await ScopedProjects().AnyAsync(
|
||||
x => x.Id == session.ExperimentProjectId,
|
||||
cancellationToken))
|
||||
return NotFound();
|
||||
if (session is null) return NotFound();
|
||||
if (!CanManageSession(session)) return Forbid();
|
||||
if (session.Status == ExperimentSessionStatus.Cancelled)
|
||||
return NoContent();
|
||||
|
||||
@@ -798,12 +1124,11 @@ public sealed class ExperimentsController(
|
||||
{
|
||||
var session = await db.ExperimentSessions.AsNoTracking()
|
||||
.Include(x => x.ExperimentProject)
|
||||
.Include(x => x.Instructors)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null ||
|
||||
!await ScopedProjects().AnyAsync(
|
||||
x => x.Id == session.ExperimentProjectId,
|
||||
cancellationToken))
|
||||
return NotFound();
|
||||
if (session is null) return NotFound();
|
||||
if (!CanManageSession(session)) return Forbid();
|
||||
|
||||
if (session.ExperimentProject!.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized)
|
||||
@@ -866,6 +1191,12 @@ public sealed class ExperimentsController(
|
||||
if (session.ExperimentProject.ArrangementMode !=
|
||||
ExperimentArrangementMode.SelfScheduled)
|
||||
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
|
||||
.TaskIdsForStudent(db, student.Id)
|
||||
.ContainsAsync(
|
||||
@@ -971,11 +1302,34 @@ public sealed class ExperimentsController(
|
||||
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(
|
||||
ExperimentProject project,
|
||||
ExperimentSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var instructorProblem = await ValidateInstructorTeacherIdsAsync(
|
||||
project, request.InstructorTeacherIds, cancellationToken);
|
||||
if (instructorProblem is not null) return instructorProblem;
|
||||
if (request.SessionDate < project.StartDate ||
|
||||
request.SessionDate > project.EndDate)
|
||||
return "实验场次日期必须在项目开放日期范围内。";
|
||||
@@ -1203,6 +1557,26 @@ public sealed class ExperimentsController(
|
||||
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(
|
||||
CancellationToken cancellationToken) =>
|
||||
db.Students.FirstOrDefaultAsync(x =>
|
||||
@@ -1234,6 +1608,47 @@ public sealed class ExperimentsController(
|
||||
.Distinct()
|
||||
.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(
|
||||
ExperimentProjectRequest request,
|
||||
AcademicTerm term)
|
||||
@@ -1246,6 +1661,12 @@ public sealed class ExperimentsController(
|
||||
return "实验安排方式无效。";
|
||||
if (request.StartDate > request.EndDate)
|
||||
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 ||
|
||||
request.EndDate > term.EndDate)
|
||||
return "实验项目日期必须在所属学期起止日期内。";
|
||||
@@ -1279,6 +1700,21 @@ public sealed class ExperimentsController(
|
||||
private static string? Normalize(string? value) =>
|
||||
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) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
@@ -1297,7 +1733,14 @@ public sealed record ExperimentProjectRequest(
|
||||
[MaxLength(1000)] string? Requirements,
|
||||
DateOnly StartDate,
|
||||
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(
|
||||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||
@@ -1307,7 +1750,11 @@ public sealed record ExperimentProjectBatchRequest(
|
||||
[MaxLength(1000)] string? Description,
|
||||
[MaxLength(1000)] string? Requirements,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate)
|
||||
DateOnly EndDate,
|
||||
IReadOnlyList<string>? Names = null,
|
||||
bool GenerateSequentialNames = false,
|
||||
DateTime? SelectionStartsAt = null,
|
||||
DateTime? SelectionEndsAt = null)
|
||||
{
|
||||
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||||
new(
|
||||
@@ -1318,16 +1765,27 @@ public sealed record ExperimentProjectBatchRequest(
|
||||
Description,
|
||||
Requirements,
|
||||
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(
|
||||
Guid ClassroomId,
|
||||
DateOnly SessionDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 30)] int PeriodCount,
|
||||
[Range(1, 10000)] int Capacity,
|
||||
[MaxLength(500)] string? Notes);
|
||||
[MaxLength(500)] string? Notes,
|
||||
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
|
||||
|
||||
public sealed record ExperimentSessionBatchRequest(
|
||||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||
@@ -1339,7 +1797,8 @@ public sealed record ExperimentSessionBatchItem(
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 30)] int PeriodCount,
|
||||
[Range(1, 10000)] int Capacity,
|
||||
[MaxLength(500)] string? Notes)
|
||||
[MaxLength(500)] string? Notes,
|
||||
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null)
|
||||
{
|
||||
public ExperimentSessionRequest ToSessionRequest() =>
|
||||
new(
|
||||
@@ -1348,7 +1807,8 @@ public sealed record ExperimentSessionBatchItem(
|
||||
StartPeriod,
|
||||
PeriodCount,
|
||||
Capacity,
|
||||
Notes);
|
||||
Notes,
|
||||
InstructorTeacherIds);
|
||||
}
|
||||
|
||||
public sealed record ExperimentPeriodOption(
|
||||
|
||||
@@ -25,11 +25,77 @@ public sealed class GradeAnalyticsController(
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Leader + "," +
|
||||
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")]
|
||||
public async Task<ActionResult> GetTeachingClasses(
|
||||
Guid? academicTermId,
|
||||
string? keyword,
|
||||
Guid? collegeId = null,
|
||||
string? teacherKeyword = null,
|
||||
bool? riskOnly = null,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -42,12 +108,23 @@ public sealed class GradeAnalyticsController(
|
||||
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||
if (academicTermId.HasValue)
|
||||
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)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||
x.TeachingTask.Name.Contains(keyword) ||
|
||||
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||
x.TeachingTask.Course.Name.Contains(keyword));
|
||||
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 items = await source
|
||||
@@ -526,3 +603,8 @@ public sealed class GradeAnalyticsController(
|
||||
IReadOnlyList<HistoricalComparison> History,
|
||||
ComparisonDelta? UniversityDelta);
|
||||
}
|
||||
|
||||
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
|
||||
bool IsEnabled,
|
||||
int IntervalSeconds,
|
||||
int BatchSize);
|
||||
|
||||
@@ -151,6 +151,7 @@ public sealed class GradesController(
|
||||
|
||||
var task = await AccessibleTasks()
|
||||
.Include(x => x.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
|
||||
@@ -508,7 +509,6 @@ public sealed class GradesController(
|
||||
}
|
||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
@@ -668,7 +668,6 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// 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 headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||
|
||||
var rows = sheet.Records.Select(record =>
|
||||
{
|
||||
@@ -724,8 +723,8 @@ public sealed class GradesController(
|
||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||
values.Add(score);
|
||||
}
|
||||
values.Add(null);
|
||||
values.Add(record.FinalScore);
|
||||
values.Add(null);
|
||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||||
@@ -746,9 +745,9 @@ public sealed class GradesController(
|
||||
|
||||
var regularColumn = 4;
|
||||
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||
var totalColumn = regularColumn + itemColumns.Length + 1;
|
||||
var finalColumn = totalColumn + 1;
|
||||
var statusColumn = finalColumn + 1;
|
||||
var finalColumn = regularColumn + itemColumns.Length + 1;
|
||||
var totalColumn = finalColumn + 1;
|
||||
var statusColumn = totalColumn + 1;
|
||||
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||
(itemColumns[index], item.Weight)));
|
||||
@@ -820,7 +819,7 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try
|
||||
@@ -918,7 +917,6 @@ public sealed class GradesController(
|
||||
if (errors.Count > 0)
|
||||
return ImportValidationProblem(errors);
|
||||
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { updated, total = rows.Count });
|
||||
}
|
||||
@@ -1161,7 +1159,6 @@ public sealed class GradesController(
|
||||
{
|
||||
try
|
||||
{
|
||||
QueueCourseStatisticsRefresh(id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
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) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
|
||||
@@ -737,6 +737,7 @@ public sealed class MakeupExamsController(
|
||||
.Include(x => x.Enrollments)
|
||||
.Include(x => x.TeachingTask!)
|
||||
.ThenInclude(x => x.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class NotificationsController(
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -32,15 +33,24 @@ public sealed class OfficialDocumentsController(
|
||||
OfficialDocumentType? type,
|
||||
OfficialDocumentStatus? status,
|
||||
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();
|
||||
if (type.HasValue) source = source.Where(x => x.Type == type);
|
||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
|
||||
|
||||
return Ok(await source
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.IssuedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -62,15 +72,21 @@ public sealed class OfficialDocumentsController(
|
||||
DownloadCount = x.Downloads.Count,
|
||||
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("students/options")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> StudentOptions(
|
||||
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();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
@@ -79,7 +95,10 @@ public sealed class OfficialDocumentsController(
|
||||
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
|
||||
{
|
||||
x.Id,
|
||||
@@ -90,7 +109,8 @@ public sealed class OfficialDocumentsController(
|
||||
MajorName = x.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -207,14 +227,24 @@ public sealed class OfficialDocumentsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> DownloadHistory(
|
||||
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))
|
||||
return NotFound();
|
||||
|
||||
return Ok(await db.OfficialDocumentDownloads.AsNoTracking()
|
||||
var source = db.OfficialDocumentDownloads.AsNoTracking()
|
||||
.Where(x => x.OfficialDocumentId == id)
|
||||
.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
|
||||
{
|
||||
x.Id,
|
||||
@@ -224,7 +254,8 @@ public sealed class OfficialDocumentsController(
|
||||
x.IpAddress,
|
||||
x.UserAgent
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/invalidate")]
|
||||
|
||||
@@ -330,7 +330,7 @@ public sealed class OperationsController(
|
||||
{
|
||||
var ids = pageItems.Select(x => x.Id).ToArray();
|
||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.JobId))
|
||||
.WhereIn(ids, x => x.JobId)
|
||||
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
||||
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
||||
cancellationToken);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -20,10 +21,38 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
|
||||
[HttpGet("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
||||
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
||||
public async Task<ActionResult> GetBatches(
|
||||
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 })
|
||||
.ToListAsync(ct));
|
||||
.ToListAsync(ct);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("batches")]
|
||||
[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);
|
||||
if (batch is null) return NotFound();
|
||||
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
||||
return result is null ? Ok(new { updated = request.Results.Count }) : result;
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}/template")]
|
||||
@@ -153,7 +182,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||
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);
|
||||
}
|
||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||
@@ -166,7 +195,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
batch.Status = OtherExamBatchStatus.Draft;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
batch.Results = inputs.Select(x =>
|
||||
var replacementResults = inputs.Select(x =>
|
||||
{
|
||||
var student = students[x.StudentNumber.Trim()];
|
||||
return new OtherExamResult
|
||||
@@ -180,6 +209,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
Notes = Normalize(x.Notes)
|
||||
};
|
||||
}).ToList();
|
||||
db.OtherExamResults.AddRange(replacementResults);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return null;
|
||||
@@ -209,13 +239,20 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||
_ => 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 到满分之间。",
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
||||
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
||||
_ => null
|
||||
};
|
||||
if (Normalize(notes)?.Length > 500) return "备注不能超过 500 个字符。";
|
||||
return b.MetricKind switch
|
||||
{
|
||||
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)
|
||||
{
|
||||
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()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedExperimentClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
return Ok(tasks.Select(task =>
|
||||
{
|
||||
@@ -132,11 +133,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
: constraint?.RequiresClassroom ?? true,
|
||||
constraint?.RequiredCampusId,
|
||||
constraint?.RequiredBuildingId,
|
||||
constraint?.ExperimentRequiredCampusId,
|
||||
constraint?.ExperimentRequiredBuildingId,
|
||||
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
|
||||
constraint?.EarliestPeriod,
|
||||
constraint?.LatestPeriod,
|
||||
AllowedClassroomIds = constraint?.AllowedClassrooms
|
||||
.Select(x => x.ClassroomId) ?? [],
|
||||
AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
|
||||
.Select(x => x.ClassroomId) ?? [],
|
||||
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
|
||||
};
|
||||
}));
|
||||
@@ -168,6 +173,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
{
|
||||
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedExperimentClassrooms)
|
||||
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
||||
if (flexibleConstraint is not null)
|
||||
{
|
||||
@@ -195,6 +201,22 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
cancellationToken))
|
||||
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()
|
||||
.Where(x => x.IsEnabled)
|
||||
.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))
|
||||
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
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedExperimentClassrooms)
|
||||
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
|
||||
if (constraint is null)
|
||||
{
|
||||
@@ -223,6 +260,12 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
constraint.RequiredBuildingId = request.RequiresClassroom
|
||||
? request.RequiredBuildingId
|
||||
: null;
|
||||
constraint.ExperimentRequiredCampusId = request.RequiresClassroom
|
||||
? request.ExperimentRequiredCampusId
|
||||
: null;
|
||||
constraint.ExperimentRequiredBuildingId = request.RequiresClassroom
|
||||
? request.ExperimentRequiredBuildingId
|
||||
: null;
|
||||
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
|
||||
? null
|
||||
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
||||
@@ -230,10 +273,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
constraint.LatestPeriod = request.LatestPeriod;
|
||||
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
|
||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
|
||||
constraint.AllowedClassrooms = request.RequiresClassroom
|
||||
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
|
||||
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
|
||||
: [];
|
||||
constraint.AllowedExperimentClassrooms = request.RequiresClassroom
|
||||
? allowedExperimentRoomIds.Select(classroomId =>
|
||||
new TeachingTaskAllowedExperimentClassroom { ClassroomId = classroomId }).ToList()
|
||||
: [];
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
@@ -256,21 +304,26 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
!Enum.IsDefined(request.SchedulingMode.Value))
|
||||
return ValidationProblem("授课方式无效。");
|
||||
if (!request.SchedulingMode.HasValue &&
|
||||
!request.RequiresClassroom.HasValue &&
|
||||
request.AllowedDayOfWeeks is null &&
|
||||
!request.UpdateClassroomScope &&
|
||||
!request.UpdatePeriodRange &&
|
||||
!request.RequiresClassroom.HasValue &&
|
||||
request.AllowedDayOfWeeks is null &&
|
||||
!request.UpdateClassroomScope &&
|
||||
!request.UpdateExperimentClassroomScope &&
|
||||
!request.AllowedExperimentVenueNatures.HasValue &&
|
||||
!request.UpdatePeriodRange &&
|
||||
!request.EarliestPeriod.HasValue &&
|
||||
!request.LatestPeriod.HasValue)
|
||||
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
||||
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
|
||||
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
|
||||
if (request.UpdateExperimentClassroomScope && request.RequiresClassroom == false)
|
||||
return ValidationProblem("批量指定实验场地时,场地要求不能设置为不占用教室。");
|
||||
|
||||
var tasks = await db.TeachingTasks
|
||||
.Where(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.WhereIn(taskIds, x => x.Id)
|
||||
.Include(x => x.Course)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tasks.Count != taskIds.Length)
|
||||
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
|
||||
@@ -281,9 +334,16 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
(request.SchedulingMode ?? task.SchedulingMode) ==
|
||||
TeachingTaskSchedulingMode.Flexible))
|
||||
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
|
||||
if (request.UpdateExperimentClassroomScope && tasks.Any(task =>
|
||||
(request.SchedulingMode ?? task.SchedulingMode) ==
|
||||
TeachingTaskSchedulingMode.Flexible))
|
||||
return ConflictProblem("非排时课程不能指定实验场地,请先将当前筛选结果限定为正常排课课程。");
|
||||
|
||||
Building? building = null;
|
||||
List<Classroom> allowedRooms = [];
|
||||
var experimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
|
||||
Building? experimentBuilding = null;
|
||||
List<Classroom> allowedExperimentRooms = [];
|
||||
if (request.UpdateClassroomScope)
|
||||
{
|
||||
if (request.RequiredBuildingId.HasValue)
|
||||
@@ -320,10 +380,42 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
x.Building!.CampusId != request.RequiredCampusId))
|
||||
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
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedExperimentClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
@@ -342,6 +434,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
request.RequiresClassroom.HasValue ||
|
||||
request.AllowedDayOfWeeks is not null ||
|
||||
request.UpdateClassroomScope ||
|
||||
request.UpdateExperimentClassroomScope ||
|
||||
request.AllowedExperimentVenueNatures.HasValue ||
|
||||
request.UpdatePeriodRange;
|
||||
if (!changesConstraint) continue;
|
||||
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
|
||||
@@ -357,7 +451,10 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
constraint.RequiredCampusId = null;
|
||||
constraint.RequiredBuildingId = null;
|
||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
|
||||
constraint.AllowedExperimentClassrooms);
|
||||
constraint.AllowedClassrooms = [];
|
||||
constraint.AllowedExperimentClassrooms = [];
|
||||
}
|
||||
}
|
||||
if (request.AllowedDayOfWeeks is not null)
|
||||
@@ -379,6 +476,17 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
ClassroomId = room.Id
|
||||
}).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)
|
||||
{
|
||||
constraint.EarliestPeriod = request.EarliestPeriod;
|
||||
@@ -402,11 +510,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
constraint.RequiresClassroom = false;
|
||||
constraint.RequiredCampusId = null;
|
||||
constraint.RequiredBuildingId = null;
|
||||
constraint.ExperimentRequiredCampusId = null;
|
||||
constraint.ExperimentRequiredBuildingId = null;
|
||||
constraint.AllowedDayOfWeeks = null;
|
||||
constraint.EarliestPeriod = null;
|
||||
constraint.LatestPeriod = null;
|
||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
|
||||
constraint.AllowedClassrooms = [];
|
||||
constraint.AllowedExperimentClassrooms = [];
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
@@ -441,7 +553,10 @@ public sealed record TeachingTaskScheduleConstraintRequest(
|
||||
IReadOnlyList<int> AllowedDayOfWeeks,
|
||||
[Range(1, 30)] int? EarliestPeriod,
|
||||
[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(
|
||||
Guid AcademicTermId,
|
||||
@@ -455,4 +570,9 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
||||
IReadOnlyList<Guid>? AllowedClassroomIds,
|
||||
bool UpdatePeriodRange,
|
||||
[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));
|
||||
}
|
||||
|
||||
[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}")]
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
||||
GetAutomaticScheduleJob(
|
||||
@@ -552,6 +571,7 @@ public sealed class SchedulesController(
|
||||
|
||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedExperimentClassrooms)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.TeachingTaskId == request.TeachingTaskId,
|
||||
cancellationToken);
|
||||
@@ -580,20 +600,26 @@ public sealed class SchedulesController(
|
||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||
!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
||||
return ValidationProblem(
|
||||
$"实验课必须安排在具有实验教学性质的场地;“{classroom.Name}”未标注实验室、实训室、机房或语音室性质。 ");
|
||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||
if (request.Kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiredCampusId is Guid campusId &&
|
||||
classroom.Building!.CampusId != campusId)
|
||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||
if (constraint?.RequiredBuildingId is Guid buildingId &&
|
||||
if (request.Kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiredBuildingId is Guid buildingId &&
|
||||
classroom.BuildingId != buildingId)
|
||||
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
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet() ?? [];
|
||||
if (allowedClassroomIds.Count > 0 &&
|
||||
if (request.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
|
||||
!allowedClassroomIds.Contains(classroom.Id))
|
||||
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
|
||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||
@@ -601,6 +627,13 @@ public sealed class SchedulesController(
|
||||
allowedNatures != 0 &&
|
||||
(classroom.TeachingVenueNature & allowedNatures) == 0)
|
||||
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 =>
|
||||
x.AdministrativeClass!.Students.Count(student =>
|
||||
|
||||
@@ -17,17 +17,53 @@ public sealed class StudentStatusChangesController(
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[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();
|
||||
return Ok(await source.OrderByDescending(x => x.SubmittedAt).Select(x => new
|
||||
{
|
||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
CollegeName = 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));
|
||||
var userScope = currentUserDataScope.Current;
|
||||
var total = await source.CountAsync(token);
|
||||
var actionableTotal = await source.CountAsync(change =>
|
||||
(change.State == StudentStatusChangeState.Submitted &&
|
||||
userScope.IsInRole(SystemRoles.Counselor)) ||
|
||||
(change.State == StudentStatusChangeState.CounselorApproved &&
|
||||
userScope.IsInRole(SystemRoles.CollegeAdmin)) ||
|
||||
(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")]
|
||||
@@ -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(
|
||||
StudentStatusChangeType Type,
|
||||
[Required, MinLength(10), MaxLength(1000)] string Reason);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -46,17 +47,26 @@ public sealed class TeacherCourseApplicationsController(
|
||||
[Authorize(Roles = SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMine(
|
||||
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);
|
||||
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
||||
var source = db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x => x.TeacherId == teacher.Id);
|
||||
if (academicTermId.HasValue)
|
||||
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)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -72,7 +82,8 @@ public sealed class TeacherCourseApplicationsController(
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("mine")]
|
||||
@@ -144,15 +155,24 @@ public sealed class TeacherCourseApplicationsController(
|
||||
public async Task<ActionResult> GetReviews(
|
||||
Guid? academicTermId,
|
||||
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();
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
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)
|
||||
.ThenByDescending(x => x.SubmittedAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -172,7 +192,8 @@ public sealed class TeacherCourseApplicationsController(
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("assignment-options")]
|
||||
|
||||
@@ -171,6 +171,95 @@ public sealed class TimetableManagementController(
|
||||
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(
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
@@ -205,8 +294,22 @@ public sealed class TimetableManagementController(
|
||||
value = value.Replace(character, '-');
|
||||
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]
|
||||
[Route("api/timetables")]
|
||||
public sealed class FreeClassroomsController(
|
||||
|
||||
@@ -45,6 +45,30 @@ public sealed class TimetablesController(
|
||||
CancellationToken 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}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetTeacherTimetable(
|
||||
@@ -76,6 +100,21 @@ public sealed class TimetablesController(
|
||||
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")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> ExportClassTimetable(
|
||||
@@ -92,6 +131,21 @@ public sealed class TimetablesController(
|
||||
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")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyTimetable(
|
||||
@@ -203,6 +257,39 @@ public sealed class TimetablesController(
|
||||
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")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||
@@ -426,6 +513,13 @@ public sealed class TimetablesController(
|
||||
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)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -19,10 +20,54 @@ public sealed class UsersController(
|
||||
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
||||
{
|
||||
[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)
|
||||
.ThenBy(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -36,26 +81,34 @@ public sealed class UsersController(
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var user in users)
|
||||
{
|
||||
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
|
||||
result.Add(new
|
||||
{
|
||||
user.Id,
|
||||
user.UserName,
|
||||
user.DisplayName,
|
||||
user.StaffNumber,
|
||||
user.CollegeId,
|
||||
user.IsEnabled,
|
||||
user.LastLoginAt,
|
||||
user.CreatedAt,
|
||||
Roles = identityUser is null
|
||||
? []
|
||||
: await userManager.GetRolesAsync(identityUser)
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
var userIds = users.Select(x => x.Id).ToArray();
|
||||
var roleRows = await (
|
||||
from userRole in db.UserRoles.AsNoTracking()
|
||||
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
where userIds.Contains(userRole.UserId)
|
||||
select new { userRole.UserId, RoleName = role.Name! })
|
||||
.ToListAsync(cancellationToken);
|
||||
var rolesByUser = roleRows
|
||||
.GroupBy(x => x.UserId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => (IReadOnlyCollection<string>)group
|
||||
.Select(x => x.RoleName)
|
||||
.OrderBy(x => x)
|
||||
.ToArray());
|
||||
|
||||
var items = users.Select(user => new UserListItem(
|
||||
user.Id,
|
||||
user.UserName ?? string.Empty,
|
||||
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")]
|
||||
@@ -281,6 +334,16 @@ public sealed record CreateUserRequest(
|
||||
[MinLength(1)] string[] Roles);
|
||||
|
||||
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(
|
||||
[MaxLength(30)] string? StaffNumber,
|
||||
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 Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -21,32 +22,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
||||
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));
|
||||
|
||||
[HttpPut("rules")]
|
||||
[Authorize(Roles = Managers)]
|
||||
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);
|
||||
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)
|
||||
{
|
||||
db.WarningRules.Add(new WarningRule
|
||||
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
|
||||
if (entity is null)
|
||||
{
|
||||
AcademicTermId = academicTermId,
|
||||
Type = r.Type,
|
||||
Name = r.Name.Trim(),
|
||||
Threshold = r.Threshold,
|
||||
IsEnabled = r.IsEnabled,
|
||||
NotifyStudent = r.NotifyStudent,
|
||||
NotifyCounselor = r.NotifyCounselor,
|
||||
Description = r.Description?.Trim(),
|
||||
AutoCheckEnabled = r.AutoCheckEnabled,
|
||||
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
||||
CheckHour = r.CheckHour,
|
||||
CheckMinute = r.CheckMinute
|
||||
});
|
||||
entity = new WarningRule
|
||||
{
|
||||
AcademicTermId = academicTermId,
|
||||
Type = r.Type,
|
||||
Name = r.Name.Trim()
|
||||
};
|
||||
db.WarningRules.Add(entity);
|
||||
}
|
||||
|
||||
entity.Name = r.Name.Trim();
|
||||
entity.Threshold = r.Threshold;
|
||||
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);
|
||||
return NoContent();
|
||||
@@ -87,17 +101,23 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
db.WarningRecords.AddRange(generated);
|
||||
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)
|
||||
{
|
||||
var si = studentInfos.First(s => s.Id == w.StudentId);
|
||||
var r = rules.First(r => r.Type == w.Type);
|
||||
var si = studentsById[w.StudentId];
|
||||
var r = rulesByType[w.Type];
|
||||
if (r.NotifyStudent && si.UserId.HasValue)
|
||||
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);
|
||||
if (counselorId != default)
|
||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||
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")]
|
||||
[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();
|
||||
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 (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 ═══════════
|
||||
@@ -131,7 +180,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -154,71 +203,90 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
// ═══════════ Detection logic ═══════════
|
||||
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
{
|
||||
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);
|
||||
if (failed >= rule.Threshold)
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
var existingStudentIds = await db.WarningRecords.AsNoTracking()
|
||||
.Where(record => record.AcademicTermId == termId && record.Type == rule.Type)
|
||||
.Select(record => record.StudentId)
|
||||
.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;
|
||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
StudentId = item.StudentId,
|
||||
Type = rule.Type,
|
||||
TriggerValue = item.Failed,
|
||||
Detail = $"不及格课程 {item.Failed} 门,达到预警阈值 {rule.Threshold} 门。",
|
||||
AcademicTermId = termId
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
{
|
||||
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);
|
||||
if (grades.Count == 0) continue;
|
||||
var gpa = grades.Average(x => x.GradePoint!.Value);
|
||||
if (gpa < rule.Threshold)
|
||||
{
|
||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue;
|
||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||
var gpaByStudent = await db.GradeRecords.AsNoTracking()
|
||||
.Where(record => studentIds.Contains(record.StudentId) &&
|
||||
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
|
||||
record.GradeSheet.Status == GradeSheetStatus.Published &&
|
||||
record.GradePoint != null)
|
||||
.GroupBy(record => record.StudentId)
|
||||
.Select(group => new { StudentId = group.Key, Gpa = group.Average(record => record.GradePoint!.Value) })
|
||||
.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();
|
||||
}
|
||||
|
||||
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
{
|
||||
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);
|
||||
if (absent >= rule.Threshold)
|
||||
{
|
||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue;
|
||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||
var absencesByStudent = await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(record => studentIds.Contains(record.StudentId) && record.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && record.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (record.Status == AttendanceStatus.Absent || record.Status == AttendanceStatus.Late))
|
||||
.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))
|
||||
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Absent, Detail = $"缺勤/迟到 {item.Absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
{
|
||||
var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
||||
if (total >= rule.Threshold)
|
||||
{
|
||||
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;
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
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);
|
||||
return failedByStudent.Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"累计不及格 {item.Failed} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }).ToList();
|
||||
}
|
||||
|
||||
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 ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", 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 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 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 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
|
||||
{
|
||||
Draft = 1,
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class ExamArrangementJob : EntityBase
|
||||
public Guid PlanId { get; set; }
|
||||
public Guid? ActivePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public string? ProjectIdsJson { get; set; }
|
||||
public string? SessionIdsJson { get; set; }
|
||||
public bool AssignClassrooms { get; set; }
|
||||
public bool AssignInvigilators { get; set; }
|
||||
@@ -138,6 +139,7 @@ public sealed class ExamPublishJob : EntityBase
|
||||
public Guid PlanId { get; set; }
|
||||
public Guid? ActivePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public string? ProjectIdsJson { get; set; }
|
||||
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
@@ -148,7 +150,8 @@ public sealed class ExamPublishJob : EntityBase
|
||||
public enum ExamPublishJobKind
|
||||
{
|
||||
FormalExam = 1,
|
||||
MakeupExam = 2
|
||||
MakeupExam = 2,
|
||||
ExperimentProjects = 3
|
||||
}
|
||||
|
||||
public enum ExamPublishJobStatus
|
||||
|
||||
@@ -9,6 +9,8 @@ public sealed class ExperimentProject : EntityBase
|
||||
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||
public Guid? ScheduleEntryId { get; set; }
|
||||
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
|
||||
public int? ScheduleWeek { get; set; }
|
||||
public required string Code { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||
@@ -16,6 +18,9 @@ public sealed class ExperimentProject : EntityBase
|
||||
public string? Requirements { get; set; }
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
// 自主预约项目可单独控制学生可选场次的时间窗口(UTC 时刻)。
|
||||
public DateTime? SelectionStartsAt { get; set; }
|
||||
public DateTime? SelectionEndsAt { get; set; }
|
||||
public ExperimentProjectStatus Status { get; set; } =
|
||||
ExperimentProjectStatus.Draft;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
@@ -41,6 +46,15 @@ public sealed class ExperimentSession : EntityBase
|
||||
ExperimentSessionStatus.Scheduled;
|
||||
public DateTime? CancelledAt { 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
|
||||
|
||||
@@ -51,6 +51,22 @@ public sealed class ExperimentGradeRecord : EntityBase
|
||||
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 Guid ExperimentGradeRecordId { get; set; }
|
||||
|
||||
@@ -52,11 +52,16 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
|
||||
public Campus? RequiredCampus { get; set; }
|
||||
public Guid? RequiredBuildingId { 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 int? EarliestPeriod { get; set; }
|
||||
public int? LatestPeriod { get; set; }
|
||||
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
|
||||
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
|
||||
public ICollection<TeachingTaskAllowedExperimentClassroom> AllowedExperimentClassrooms { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class TeachingTaskAllowedClassroom
|
||||
@@ -67,6 +72,31 @@ public sealed class TeachingTaskAllowedClassroom
|
||||
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 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.System;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
@@ -39,6 +42,9 @@ public sealed class ExamPublishJobProcessor(
|
||||
case ExamPublishJobKind.MakeupExam:
|
||||
await PublishMakeupExamAsync(job, stoppingToken);
|
||||
break;
|
||||
case ExamPublishJobKind.ExperimentProjects:
|
||||
await PublishExperimentProjectsAsync(job, stoppingToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"不支持的考试发布类型:{job.Kind}。");
|
||||
@@ -248,6 +254,67 @@ public sealed class ExamPublishJobProcessor(
|
||||
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)
|
||||
{
|
||||
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 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(
|
||||
AppDbContext db,
|
||||
Guid teachingTaskId,
|
||||
IReadOnlyCollection<Guid> studentIds,
|
||||
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 =>
|
||||
x.Status ==
|
||||
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
||||
@@ -28,40 +99,27 @@ public static class ExperimentGradeAggregationService
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var scores = new Dictionary<Guid, decimal?>();
|
||||
foreach (var studentId in studentIds.Distinct())
|
||||
private static decimal? CalculateStudentScore(
|
||||
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;
|
||||
decimal totalWeight = 0;
|
||||
var complete = sheets.Count > 0;
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var score = sheet.Scores.FirstOrDefault(x =>
|
||||
x.StudentId == studentId);
|
||||
if (score?.TotalScore is not decimal totalScore)
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||
totalWeight += sheet.ContributionWeight;
|
||||
}
|
||||
scores[studentId] = complete && totalWeight > 0
|
||||
? Math.Round(
|
||||
weightedTotal / totalWeight,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
var score = sheet.Scores.FirstOrDefault(x =>
|
||||
x.StudentId == studentId);
|
||||
if (score?.TotalScore is not decimal totalScore) return null;
|
||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||
totalWeight += sheet.ContributionWeight;
|
||||
}
|
||||
|
||||
return new ExperimentGradeAggregateResult(
|
||||
sheets.Count,
|
||||
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ContributionWeight)).ToList(),
|
||||
scores);
|
||||
return totalWeight > 0
|
||||
? Math.Round(
|
||||
weightedTotal / totalWeight,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
}
|
||||
|
||||
private sealed record PublishedExperimentSheet(
|
||||
|
||||
@@ -51,7 +51,8 @@ public static class GradeAnalysisWordReportGenerator
|
||||
DateTime generatedAt,
|
||||
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!;
|
||||
|
||||
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 fill = new SKPaint { Color = color, IsAntialias = true };
|
||||
using var path = new SKPath();
|
||||
path.MoveTo(points[0]);
|
||||
foreach (var point in points.Skip(1)) path.LineTo(point);
|
||||
using var builder = new SKPathBuilder();
|
||||
builder.MoveTo(points[0]);
|
||||
foreach (var point in points.Skip(1)) builder.LineTo(point);
|
||||
using var path = builder.Detach();
|
||||
canvas.DrawPath(path, paint);
|
||||
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.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
@@ -9,31 +7,10 @@ namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
ObservabilityOptions options,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||
: DbCommandInterceptor
|
||||
{
|
||||
public const string ActivitySourceName = "Jiaowu.Api.Database";
|
||||
public const string MeterName = "Jiaowu.Api.Database";
|
||||
|
||||
private static readonly ActivitySource ActivitySource =
|
||||
new(ActivitySourceName);
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
private static readonly Histogram<double> CommandDuration =
|
||||
Meter.CreateHistogram<double>(
|
||||
"jiaowu.db.command.duration",
|
||||
"ms",
|
||||
"EF Core database command duration");
|
||||
private static readonly Counter<long> SlowCommandCount =
|
||||
Meter.CreateCounter<long>(
|
||||
"jiaowu.db.command.slow",
|
||||
"{command}",
|
||||
"EF Core commands exceeding the configured slow-query threshold");
|
||||
private static readonly Counter<long> FailedCommandCount =
|
||||
Meter.CreateCounter<long>(
|
||||
"jiaowu.db.command.failed",
|
||||
"{command}",
|
||||
"Failed EF Core database commands");
|
||||
|
||||
public override DbDataReader ReaderExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
@@ -138,82 +115,41 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
var queryName = GetQueryName(command.CommandText);
|
||||
var statementHash = GetStatementHash(command.CommandText);
|
||||
var provider = GetProviderName(command);
|
||||
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "db.system.name", provider },
|
||||
{ "db.operation.name", commandKind },
|
||||
{ "db.query.name", queryName }
|
||||
};
|
||||
if (errorType is not null)
|
||||
tags.Add("error.type", errorType);
|
||||
|
||||
var requestId = httpContextAccessor.HttpContext?.TraceIdentifier ?? "background";
|
||||
var durationMilliseconds = duration.TotalMilliseconds;
|
||||
CommandDuration.Record(durationMilliseconds, tags);
|
||||
if (errorType is not null)
|
||||
FailedCommandCount.Add(1, tags);
|
||||
|
||||
using var activity = ActivitySource.StartActivity(
|
||||
ActivityKind.Client,
|
||||
Activity.Current?.Context ?? default,
|
||||
startTime: DateTimeOffset.UtcNow - duration,
|
||||
name: queryName);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("db.system.name", provider);
|
||||
activity.SetTag("db.operation.name", commandKind);
|
||||
activity.SetTag("db.query.name", queryName);
|
||||
activity.SetTag("db.statement.hash", statementHash);
|
||||
activity.SetTag(
|
||||
"db.namespace",
|
||||
EmptyToNull(command.Connection?.Database));
|
||||
if (options.IncludeSqlText)
|
||||
{
|
||||
activity.SetTag(
|
||||
"db.query.text",
|
||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||
}
|
||||
if (errorType is not null)
|
||||
{
|
||||
activity.SetTag("error.type", errorType);
|
||||
activity.SetStatus(ActivityStatusCode.Error, errorType);
|
||||
}
|
||||
activity.SetEndTime(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (errorType is not null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Database command failed after {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"error {ErrorType}, trace {TraceId}).",
|
||||
"error {ErrorType}, request {RequestId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
errorType,
|
||||
traceId);
|
||||
requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||
return;
|
||||
|
||||
SlowCommandCount.Add(1, tags);
|
||||
if (options.IncludeSqlText)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}). " +
|
||||
"request {RequestId}). " +
|
||||
"SQL template: {SqlTemplate}",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId,
|
||||
requestId,
|
||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||
}
|
||||
else
|
||||
@@ -221,13 +157,13 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}).",
|
||||
"request {RequestId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId);
|
||||
requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,9 +207,6 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
return "other_sql";
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
private static string Truncate(string value, int maximumLength) =>
|
||||
value.Length <= maximumLength
|
||||
? value
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class ObservabilityOptions
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
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 bool IncludeSqlText { get; set; }
|
||||
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<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
||||
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<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
|
||||
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
|
||||
@@ -33,11 +35,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<TeacherCourseApplication>();
|
||||
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
|
||||
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
||||
public DbSet<PublishedScheduleOccurrence> PublishedScheduleOccurrences => Set<PublishedScheduleOccurrence>();
|
||||
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
|
||||
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
|
||||
Set<TeachingTaskScheduleConstraint>();
|
||||
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
|
||||
Set<TeachingTaskAllowedClassroom>();
|
||||
public DbSet<TeachingTaskAllowedExperimentClassroom> TeachingTaskAllowedExperimentClassrooms =>
|
||||
Set<TeachingTaskAllowedExperimentClassroom>();
|
||||
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
|
||||
Set<AutomaticScheduleJob>();
|
||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||
@@ -46,6 +51,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<ClassroomReservation>();
|
||||
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||
public DbSet<ExperimentSessionInstructor> ExperimentSessionInstructors =>
|
||||
Set<ExperimentSessionInstructor>();
|
||||
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
||||
Set<ExperimentGradeSheet>();
|
||||
@@ -53,6 +60,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<ExperimentGradeItem>();
|
||||
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
|
||||
Set<ExperimentGradeRecord>();
|
||||
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
|
||||
Set<ExperimentCourseGrade>();
|
||||
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
|
||||
Set<ExperimentGradeItemScore>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
@@ -139,6 +148,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<AppUpdateRelease>();
|
||||
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
||||
Set<SystemFeatureSetting>();
|
||||
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
|
||||
Set<CourseGradeStatisticsRefreshSetting>();
|
||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
@@ -157,6 +168,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
configurationBuilder.Properties<TimeOnly>()
|
||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||
.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)
|
||||
@@ -511,6 +530,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.RequiredBuildingId)
|
||||
.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 =>
|
||||
@@ -530,6 +557,57 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.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 =>
|
||||
{
|
||||
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.Description).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.HasOne(x => x.TeachingTask).WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId)
|
||||
@@ -720,6 +809,34 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.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 =>
|
||||
{
|
||||
entity.HasKey(x => new
|
||||
@@ -994,6 +1111,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
});
|
||||
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.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
@@ -1020,6 +1141,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<ExamRoomAssignment>(entity =>
|
||||
{
|
||||
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 })
|
||||
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||
entity.HasIndex(x => new
|
||||
@@ -1108,6 +1231,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
});
|
||||
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.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
||||
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.Content).HasColumnType("longtext");
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
||||
// Each inbox query starts with its recipient. Keep the selected
|
||||
// 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.CreatedAt);
|
||||
entity.HasOne(x => x.MessageDispatch)
|
||||
@@ -1463,6 +1592,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
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 =>
|
||||
{
|
||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||
@@ -1545,3 +1680,15 @@ public sealed class TimeOnlyTimeSpanConverter()
|
||||
: ValueConverter<TimeOnly, TimeSpan>(
|
||||
time => time.ToTimeSpan(),
|
||||
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";
|
||||
private const string SwaggerDocumentationSettingMigration =
|
||||
"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)
|
||||
{
|
||||
@@ -671,6 +685,14 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
TeachingTaskGradeAnalyticsMigration,
|
||||
TeachingTaskGradeAnalyticsStatements,
|
||||
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
|
||||
.SqlQueryRaw<int>(
|
||||
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
||||
@@ -679,6 +701,63 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
SwaggerDocumentationSettingMigration,
|
||||
swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements,
|
||||
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(
|
||||
@@ -2940,4 +3019,192 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1749,6 +1848,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("ProcessedSessions")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ProjectIdsJson")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -1851,6 +1953,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid>("PlanId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("ProjectIdsJson")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -2158,6 +2263,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2370,6 +2517,15 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid?>("ScheduleEntryId")
|
||||
.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")
|
||||
.HasColumnType("date");
|
||||
|
||||
@@ -2386,11 +2542,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("ScheduleEntryId");
|
||||
|
||||
b.HasIndex("TeachingTaskId", "Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status", "StartDate", "EndDate");
|
||||
|
||||
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ExperimentProjects");
|
||||
});
|
||||
|
||||
@@ -2446,6 +2602,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -3267,9 +3451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -3499,6 +3685,66 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -4102,6 +4348,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("TeachingTaskId")
|
||||
@@ -4257,6 +4518,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int?>("EarliestPeriod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("ExperimentRequiredBuildingId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("ExperimentRequiredCampusId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int?>("LatestPeriod")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4277,6 +4544,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExperimentRequiredBuildingId");
|
||||
|
||||
b.HasIndex("ExperimentRequiredCampusId");
|
||||
|
||||
b.HasIndex("RequiredBuildingId");
|
||||
|
||||
b.HasIndex("RequiredCampusId");
|
||||
@@ -5167,6 +5438,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||
@@ -5591,6 +5881,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")
|
||||
@@ -5695,6 +6004,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
||||
@@ -6021,6 +6349,32 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||
@@ -6198,6 +6552,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
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")
|
||||
.WithMany()
|
||||
.HasForeignKey("RequiredBuildingId")
|
||||
@@ -6277,6 +6660,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ExperimentRequiredBuilding");
|
||||
|
||||
b.Navigation("ExperimentRequiredCampus");
|
||||
|
||||
b.Navigation("RequiredBuilding");
|
||||
|
||||
b.Navigation("RequiredCampus");
|
||||
@@ -6406,6 +6793,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("RequiredByCourses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
|
||||
{
|
||||
b.Navigation("Courses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
|
||||
{
|
||||
b.Navigation("Enrollments");
|
||||
@@ -6504,6 +6896,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
||||
{
|
||||
b.Navigation("Bookings");
|
||||
|
||||
b.Navigation("Instructors");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Navigation("AllowedClassrooms");
|
||||
|
||||
b.Navigation("AllowedExperimentClassrooms");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedExperimentClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
@@ -253,8 +254,15 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
x.TeachingTaskId == task.Id && 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 experimentGeneralClassroomPenalty =
|
||||
kind == ScheduleEntryKind.Experiment &&
|
||||
constraint?.AllowedExperimentVenueNatures == 0 &&
|
||||
room?.TeachingVenueNature == TeachingVenueNature.GeneralClassroom
|
||||
? 100_000
|
||||
: 0;
|
||||
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
|
||||
roomWaste / 10 + startWeek;
|
||||
roomWaste / 10 + startWeek +
|
||||
experimentGeneralClassroomPenalty;
|
||||
candidates.Add((proposed, score));
|
||||
}
|
||||
}
|
||||
@@ -279,6 +287,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
var allowedRoomIds = constraint?.AllowedClassrooms
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet() ?? [];
|
||||
var allowedExperimentRoomIds = constraint?.AllowedExperimentClassrooms
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet() ?? [];
|
||||
var minimumCapacity = Math.Max(
|
||||
task.Capacity,
|
||||
task.Classes.Sum(x =>
|
||||
@@ -286,16 +297,25 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
student.Status == StudentStatus.Active) ?? 0));
|
||||
return classrooms.Where(room =>
|
||||
room.Capacity >= minimumCapacity &&
|
||||
(constraint?.RequiredCampusId is not Guid requiredCampusId ||
|
||||
(kind == ScheduleEntryKind.Experiment ||
|
||||
constraint?.RequiredCampusId is not Guid requiredCampusId ||
|
||||
room.Building!.CampusId == requiredCampusId) &&
|
||||
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
||||
(kind == ScheduleEntryKind.Experiment ||
|
||||
constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
||||
room.BuildingId == requiredBuildingId) &&
|
||||
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
|
||||
(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 ||
|
||||
constraint.AllowedExperimentVenueNatures == 0 ||
|
||||
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0))
|
||||
constraint.AllowedExperimentVenueNatures == 0 ||
|
||||
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0) &&
|
||||
(kind != ScheduleEntryKind.Experiment || allowedExperimentRoomIds.Count == 0 ||
|
||||
allowedExperimentRoomIds.Contains(room.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -73,6 +74,8 @@ public sealed class SchedulePublishJobProcessor(
|
||||
foreach (var oldPlan in previous)
|
||||
oldPlan.Status = SchedulePlanStatus.Archived;
|
||||
|
||||
await new PublishedTimetableProjectionService(db)
|
||||
.RebuildAsync(publishPlan, stoppingToken);
|
||||
publishPlan.Status = SchedulePlanStatus.Published;
|
||||
publishPlan.PublishedAt = DateTime.UtcNow;
|
||||
publishJob.Status = SchedulePublishJobStatus.Succeeded;
|
||||
@@ -167,6 +170,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.Include(x => x.AllowedExperimentClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
|
||||
foreach (var entry in plan.Entries)
|
||||
@@ -270,21 +274,40 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
{
|
||||
if (classroom is null || !classroom.IsEnabled)
|
||||
Fail(entry, "所选教室不存在或已停用");
|
||||
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||
!IsExperimentRoom(classroom.RoomType))
|
||||
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
|
||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||
if (entry.Kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiredCampusId is Guid campusId &&
|
||||
classroom.Building!.CampusId != campusId)
|
||||
Fail(entry, "所选教室不在指定校区");
|
||||
if (constraint?.RequiredBuildingId is Guid buildingId &&
|
||||
if (entry.Kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiredBuildingId is Guid buildingId &&
|
||||
classroom.BuildingId != buildingId)
|
||||
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
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet() ?? [];
|
||||
if (allowedClassroomIds.Count > 0 &&
|
||||
if (entry.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
|
||||
!allowedClassroomIds.Contains(classroom.Id))
|
||||
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 =>
|
||||
@@ -305,12 +328,6 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
.Select(int.Parse)
|
||||
.ToHashSet();
|
||||
|
||||
private static bool IsExperimentRoom(string roomType) =>
|
||||
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
[DoesNotReturn]
|
||||
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
|
||||
{
|
||||
private static readonly TimeZoneInfo ChinaTimeZone = ResolveChinaTimeZone();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -25,22 +27,32 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
||||
|
||||
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var dow = (int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek;
|
||||
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var chinaNow = TimeZoneInfo.ConvertTime(
|
||||
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
|
||||
.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))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinute)
|
||||
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinuteUtc)
|
||||
continue;
|
||||
|
||||
rule.LastCheckAt = now;
|
||||
rule.LastCheckAt = nowUtc;
|
||||
await db.SaveChangesAsync(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);
|
||||
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 (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
|
||||
|
||||
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(entry =>
|
||||
entry.ClassroomId.HasValue &&
|
||||
entry.SchedulePlan!.AcademicTermId == term.Id &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.DayOfWeek == dayOfWeek &&
|
||||
entry.StartWeek <= week &&
|
||||
entry.EndWeek >= week &&
|
||||
entry.StartPeriod < startPeriod + periodCount &&
|
||||
startPeriod < entry.StartPeriod + entry.PeriodCount)
|
||||
.Select(entry => new
|
||||
{
|
||||
entry.ClassroomId,
|
||||
entry.WeekPattern,
|
||||
entry.StartPeriod,
|
||||
entry.PeriodCount
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var entry in scheduleEntries.Where(entry =>
|
||||
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
|
||||
FreeClassroomRules.PeriodsOverlap(
|
||||
startPeriod,
|
||||
periodCount,
|
||||
entry.StartPeriod,
|
||||
entry.PeriodCount)))
|
||||
var hasProjection = await db.PublishedScheduleOccurrences.AsNoTracking()
|
||||
.AnyAsync(entry => entry.AcademicTermId == term.Id, cancellationToken);
|
||||
if (hasProjection)
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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.Range("A1:H1").Merge();
|
||||
@@ -153,9 +179,17 @@ public static class TimetableExcelExporter
|
||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
||||
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) =>
|
||||
|
||||
@@ -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 Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<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="RabbitMQ.Client" Version="7.2.2" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||
|
||||
+61
-34
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
@@ -6,6 +7,7 @@ using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Configuration;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Analytics;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Middleware;
|
||||
@@ -19,15 +21,13 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
@@ -101,12 +101,30 @@ var observabilityOptions = builder.Configuration
|
||||
var performanceReportingOptions = builder.Configuration
|
||||
.GetSection(PerformanceReportingOptions.SectionName)
|
||||
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||
var clickHouseAnalyticsOptions = builder.Configuration
|
||||
.GetSection(ClickHouseAnalyticsOptions.SectionName)
|
||||
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
|
||||
var rabbitMqOptions = builder.Configuration
|
||||
.GetSection(RabbitMqOptions.SectionName)
|
||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||
var ssoOptions = builder.Configuration
|
||||
.GetSection(SsoOptions.SectionName)
|
||||
.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 &&
|
||||
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
||||
@@ -158,6 +176,7 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
||||
observabilityOptions.ServiceName.Length > 100 ||
|
||||
observabilityOptions.SlowRequestThresholdMilliseconds is < 1 or > 60000 ||
|
||||
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
||||
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
||||
{
|
||||
@@ -187,15 +206,6 @@ if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
|
||||
"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 ||
|
||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||
@@ -252,6 +262,19 @@ if (backgroundJobOptions.UsesRabbitMq &&
|
||||
{
|
||||
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 &&
|
||||
!builder.Environment.IsDevelopment() &&
|
||||
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -282,14 +305,21 @@ builder.Services.AddSingleton(backgroundJobOptions);
|
||||
builder.Services.AddSingleton(operationsOptions);
|
||||
builder.Services.AddSingleton(observabilityOptions);
|
||||
builder.Services.AddSingleton(performanceReportingOptions);
|
||||
builder.Services.AddSingleton(clickHouseAnalyticsOptions);
|
||||
builder.Services.AddSingleton(rabbitMqOptions);
|
||||
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||
{
|
||||
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||
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.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||
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");
|
||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
@@ -422,6 +430,7 @@ builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<DemoDataSeeder>();
|
||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||
builder.Services.AddScoped<TimetableDataService>();
|
||||
builder.Services.AddScoped<PublishedTimetableProjectionService>();
|
||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||
builder.Services.AddScoped<PersonalCalendarService>();
|
||||
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
|
||||
@@ -437,6 +446,10 @@ builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||
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.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
@@ -526,6 +539,18 @@ if (ssoOptions.Enabled)
|
||||
});
|
||||
}
|
||||
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 =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
@@ -640,6 +665,7 @@ builder.Services.AddSwaggerGen(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseResponseCompression();
|
||||
app.Use(async (context, next) =>
|
||||
@@ -695,6 +721,7 @@ app.UseStaticFiles(new StaticFileOptions
|
||||
});
|
||||
app.UseCors("Web");
|
||||
app.UseRateLimiter();
|
||||
app.UseMiddleware<SlowRequestLoggingMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<AuditMiddleware>();
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"Observability": {
|
||||
"Enabled": true,
|
||||
"ServiceName": "jiaowu-api",
|
||||
"LogAllApiRequests": true,
|
||||
"SlowRequestThresholdMilliseconds": 1000,
|
||||
"SlowQueryThresholdMilliseconds": 500,
|
||||
"IncludeSqlText": false,
|
||||
"MaximumSqlTextLength": 2000
|
||||
@@ -39,6 +41,17 @@
|
||||
"SlowDatabaseMetric": "jiaowu_db_command_slow_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": {
|
||||
"BackupDirectory": "data/backups",
|
||||
"BackupWarningHours": 24,
|
||||
@@ -94,6 +107,12 @@
|
||||
"FrontendBaseUrl": "",
|
||||
"CallbackUrl": ""
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"TrustedProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"capacitor://localhost",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -11,6 +12,40 @@ namespace Jiaowu.Api.Tests;
|
||||
|
||||
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]
|
||||
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||
{
|
||||
@@ -298,4 +333,14 @@ public sealed class ApprovalsControllerTests
|
||||
DataScope.Self,
|
||||
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));
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
@@ -358,6 +431,14 @@ public sealed class CourseSelectionsControllerTests
|
||||
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(
|
||||
string number,
|
||||
string name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -40,7 +40,7 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ef_command_records_duration_metric()
|
||||
public async Task Ef_command_executes_with_logging_interceptor()
|
||||
{
|
||||
await using var connection =
|
||||
new SqliteConnection("Data Source=:memory:");
|
||||
@@ -52,25 +52,9 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||
await using (var setupDb = new AppDbContext(setupOptions))
|
||||
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(
|
||||
new ObservabilityOptions(),
|
||||
new HttpContextAccessor(),
|
||||
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
||||
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
@@ -82,7 +66,6 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||
.TagWith("Observability.Tests.TermCount")
|
||||
.CountAsync();
|
||||
|
||||
Assert.NotNull(recordedDuration);
|
||||
Assert.True(recordedDuration >= 0);
|
||||
Assert.Equal(0, await db.AcademicTerms.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -11,6 +14,294 @@ namespace Jiaowu.Api.Tests;
|
||||
|
||||
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]
|
||||
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
|
||||
{
|
||||
@@ -108,7 +399,17 @@ public sealed class ExperimentGradesControllerTests
|
||||
var studentResult = await fixture
|
||||
.ExperimentGrades(fixture.StudentScope)
|
||||
.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
|
||||
{
|
||||
@@ -176,6 +477,83 @@ public sealed class ExperimentGradesControllerTests
|
||||
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]
|
||||
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
|
||||
{
|
||||
@@ -260,6 +638,7 @@ public sealed class ExperimentGradesControllerTests
|
||||
Student student,
|
||||
Classroom classroom,
|
||||
ICurrentUserDataScope adminScope,
|
||||
ICurrentUserDataScope teacherScope,
|
||||
ICurrentUserDataScope studentScope)
|
||||
{
|
||||
Connection = connection;
|
||||
@@ -269,6 +648,7 @@ public sealed class ExperimentGradesControllerTests
|
||||
Student = student;
|
||||
Classroom = classroom;
|
||||
AdminScope = adminScope;
|
||||
TeacherScope = teacherScope;
|
||||
StudentScope = studentScope;
|
||||
}
|
||||
|
||||
@@ -279,6 +659,7 @@ public sealed class ExperimentGradesControllerTests
|
||||
public Student Student { get; }
|
||||
public Classroom Classroom { get; }
|
||||
public ICurrentUserDataScope AdminScope { get; }
|
||||
public ICurrentUserDataScope TeacherScope { get; }
|
||||
public ICurrentUserDataScope StudentScope { get; }
|
||||
|
||||
public static async Task<ExperimentGradeFixture> CreateAsync()
|
||||
@@ -410,6 +791,7 @@ public sealed class ExperimentGradesControllerTests
|
||||
student,
|
||||
classroom,
|
||||
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
|
||||
Scope(teacherUser, SystemRoles.Teacher, 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]
|
||||
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
||||
{
|
||||
@@ -61,7 +161,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null),
|
||||
null,
|
||||
[fixture.Teacher.Id]),
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[1],
|
||||
fixture.Classroom.Id,
|
||||
@@ -69,7 +170,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null)
|
||||
null,
|
||||
[fixture.Teacher.Id])
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -102,7 +204,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null),
|
||||
null,
|
||||
[fixture.Teacher.Id]),
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[1],
|
||||
fixture.SecondClassroom.Id,
|
||||
@@ -110,7 +213,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null)
|
||||
null,
|
||||
[fixture.Teacher.Id])
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -118,6 +222,65 @@ public sealed class ExperimentsControllerTests
|
||||
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]
|
||||
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
|
||||
{
|
||||
@@ -146,6 +309,35 @@ public sealed class ExperimentsControllerTests
|
||||
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]
|
||||
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||
{
|
||||
@@ -380,6 +572,7 @@ public sealed class ExperimentsControllerTests
|
||||
AppDbContext db,
|
||||
AcademicTerm term,
|
||||
TeachingTask task,
|
||||
Teacher teacher,
|
||||
Classroom classroom,
|
||||
Classroom secondClassroom,
|
||||
ScheduleEntry scheduleEntry,
|
||||
@@ -390,6 +583,7 @@ public sealed class ExperimentsControllerTests
|
||||
Db = db;
|
||||
Term = term;
|
||||
Task = task;
|
||||
Teacher = teacher;
|
||||
Classroom = classroom;
|
||||
SecondClassroom = secondClassroom;
|
||||
ScheduleEntry = scheduleEntry;
|
||||
@@ -401,6 +595,7 @@ public sealed class ExperimentsControllerTests
|
||||
public AppDbContext Db { get; }
|
||||
public AcademicTerm Term { get; }
|
||||
public TeachingTask Task { get; }
|
||||
public Teacher Teacher { get; }
|
||||
public Classroom Classroom { get; }
|
||||
public Classroom SecondClassroom { get; }
|
||||
public ScheduleEntry ScheduleEntry { get; }
|
||||
@@ -471,7 +666,8 @@ public sealed class ExperimentsControllerTests
|
||||
{
|
||||
TeacherNumber = "T2099",
|
||||
Name = "实验教师",
|
||||
CollegeId = college.Id
|
||||
CollegeId = college.Id,
|
||||
UserId = manager.Id
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
@@ -526,7 +722,15 @@ public sealed class ExperimentsControllerTests
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 40,
|
||||
Status = TeachingTaskStatus.Published
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
]
|
||||
};
|
||||
db.AddRange(
|
||||
manager,
|
||||
@@ -583,6 +787,7 @@ public sealed class ExperimentsControllerTests
|
||||
db,
|
||||
term,
|
||||
task,
|
||||
teacher,
|
||||
classroom,
|
||||
secondClassroom,
|
||||
scheduleEntry,
|
||||
@@ -615,7 +820,13 @@ public sealed class ExperimentsControllerTests
|
||||
"携带校园卡。",
|
||||
Term.StartDate,
|
||||
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(
|
||||
ExperimentArrangementMode mode) =>
|
||||
@@ -642,7 +853,8 @@ public sealed class ExperimentsControllerTests
|
||||
startPeriod,
|
||||
periodCount,
|
||||
capacity,
|
||||
null);
|
||||
null,
|
||||
[Teacher.Id]);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
@@ -83,8 +84,12 @@ public sealed class GradesPaginationTests
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = tasks[0].Id,
|
||||
RegularWeight = 30,
|
||||
FinalWeight = 70,
|
||||
RegularWeight = 20,
|
||||
FinalWeight = 60,
|
||||
Items =
|
||||
[
|
||||
new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 }
|
||||
],
|
||||
Records = students.Select(student => new GradeRecord
|
||||
{
|
||||
StudentId = student.Id
|
||||
@@ -129,6 +134,18 @@ public sealed class GradesPaginationTests
|
||||
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
|
||||
.GetValue(secondDetail.Value)!;
|
||||
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) =>
|
||||
|
||||
@@ -109,6 +109,34 @@ public sealed class MySqlMigrationTests
|
||||
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]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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