Compare commits
92
Commits
cc04270c2a
..
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,6 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -22,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 ═══════════════
|
||||
|
||||
@@ -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()
|
||||
@@ -491,6 +579,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
x.Building.Campus!.Name,
|
||||
x.Capacity,
|
||||
x.RoomType,
|
||||
x.TeachingVenueNature,
|
||||
x.Equipment,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
@@ -557,6 +646,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
BuildingId = request.BuildingId,
|
||||
Capacity = request.Capacity,
|
||||
RoomType = request.RoomType.Trim(),
|
||||
TeachingVenueNature = request.TeachingVenueNature == 0
|
||||
? TeachingVenueNature.GeneralClassroom
|
||||
: request.TeachingVenueNature,
|
||||
Equipment = request.Equipment?.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
IsEnabled = request.IsEnabled
|
||||
@@ -577,6 +669,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
entity.BuildingId = request.BuildingId;
|
||||
entity.Capacity = request.Capacity;
|
||||
entity.RoomType = request.RoomType.Trim();
|
||||
entity.TeachingVenueNature = request.TeachingVenueNature == 0
|
||||
? TeachingVenueNature.GeneralClassroom
|
||||
: request.TeachingVenueNature;
|
||||
entity.Equipment = request.Equipment?.Trim();
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
@@ -728,6 +823,7 @@ public sealed record ClassroomRequest(
|
||||
Guid BuildingId,
|
||||
[Range(1, 1000)] int Capacity,
|
||||
[Required, MaxLength(40)] string RoomType,
|
||||
TeachingVenueNature TeachingVenueNature,
|
||||
[MaxLength(300)] string? Equipment)
|
||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||
|
||||
@@ -784,6 +880,7 @@ public sealed record ClassroomListItem(
|
||||
string CampusName,
|
||||
int Capacity,
|
||||
string RoomType,
|
||||
TeachingVenueNature TeachingVenueNature,
|
||||
string? Equipment,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
||||
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
||||
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
|
||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "教学场地性质", "设备", "排序", "状态"],
|
||||
["course-categories"] = ["编码", "名称", "排序", "状态"]
|
||||
};
|
||||
|
||||
@@ -70,7 +70,10 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try
|
||||
{
|
||||
rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken);
|
||||
var requiredHeaders = kind.Equals("classrooms", StringComparison.OrdinalIgnoreCase)
|
||||
? headers.Where(x => x != "教学场地性质").ToArray()
|
||||
: headers;
|
||||
rows = await ExcelWorkbookHelper.ReadAsync(file, requiredHeaders, cancellationToken);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
@@ -165,6 +168,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
"classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building)
|
||||
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
||||
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
||||
VenueNatureName(x.TeachingVenueNature),
|
||||
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
||||
"course-categories" => (await db.CourseCategories.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
@@ -473,8 +477,9 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
var buildingCode = Required(row, "所属教学楼编码", errors);
|
||||
var capacity = ParseInt(row, "容量", 1, 1000, errors);
|
||||
var roomType = Required(row, "教室类型", errors);
|
||||
var venueNature = ParseVenueNature(row, roomType, errors);
|
||||
if (code is null || name is null || buildingCode is null ||
|
||||
capacity is null || roomType is null) continue;
|
||||
capacity is null || roomType is null || venueNature is null) continue;
|
||||
if (!buildings.TryGetValue(buildingCode, out var building))
|
||||
{
|
||||
errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。");
|
||||
@@ -488,7 +493,8 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
Code = code,
|
||||
Name = name,
|
||||
BuildingId = building.Id,
|
||||
RoomType = roomType
|
||||
RoomType = roomType,
|
||||
TeachingVenueNature = venueNature.Value
|
||||
};
|
||||
db.Classrooms.Add(entity);
|
||||
existing[code] = entity;
|
||||
@@ -499,6 +505,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
entity.BuildingId = building.Id;
|
||||
entity.Capacity = capacity.Value;
|
||||
entity.RoomType = roomType;
|
||||
entity.TeachingVenueNature = venueNature.Value;
|
||||
entity.Equipment = Optional(row, "设备");
|
||||
}
|
||||
return new(created, updated, rows.Count);
|
||||
@@ -597,6 +604,57 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TeachingVenueNature? ParseVenueNature(
|
||||
ExcelRow row,
|
||||
string? roomType,
|
||||
List<string> errors)
|
||||
{
|
||||
var value = Optional(row, "教学场地性质");
|
||||
if (value is null) return InferVenueNature(roomType ?? string.Empty);
|
||||
var result = (TeachingVenueNature)0;
|
||||
foreach (var part in value.Split(['、', ',', ',', ';', ';'],
|
||||
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
result |= part switch
|
||||
{
|
||||
"普通教室" => TeachingVenueNature.GeneralClassroom,
|
||||
"实验室" => TeachingVenueNature.Laboratory,
|
||||
"实训室" => TeachingVenueNature.TrainingRoom,
|
||||
"计算机机房" or "机房" => TeachingVenueNature.ComputerLab,
|
||||
"语音室" => TeachingVenueNature.LanguageLab,
|
||||
"体育场地" => TeachingVenueNature.SportsVenue,
|
||||
"艺术场地" => TeachingVenueNature.ArtsVenue,
|
||||
_ => (TeachingVenueNature)0
|
||||
};
|
||||
if (part is not ("普通教室" or "实验室" or "实训室" or "计算机机房" or "机房" or "语音室" or "体育场地" or "艺术场地"))
|
||||
errors.Add($"第 {row.RowNumber} 行:“教学场地性质”包含不支持的值“{part}”。");
|
||||
}
|
||||
return result == 0 ? null : result;
|
||||
}
|
||||
|
||||
private static TeachingVenueNature InferVenueNature(string roomType) =>
|
||||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.Laboratory | TeachingVenueNature.ComputerLab
|
||||
: roomType.Contains("语音", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.Laboratory | TeachingVenueNature.LanguageLab
|
||||
: roomType.Contains("实训", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.TrainingRoom
|
||||
: roomType.Contains("实验", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.Laboratory
|
||||
: TeachingVenueNature.GeneralClassroom;
|
||||
|
||||
private static string VenueNatureName(TeachingVenueNature value) => string.Join("、",
|
||||
new[]
|
||||
{
|
||||
(TeachingVenueNature.GeneralClassroom, "普通教室"),
|
||||
(TeachingVenueNature.Laboratory, "实验室"),
|
||||
(TeachingVenueNature.TrainingRoom, "实训室"),
|
||||
(TeachingVenueNature.ComputerLab, "计算机机房"),
|
||||
(TeachingVenueNature.LanguageLab, "语音室"),
|
||||
(TeachingVenueNature.SportsVenue, "体育场地"),
|
||||
(TeachingVenueNature.ArtsVenue, "艺术场地")
|
||||
}.Where(x => (value & x.Item1) != 0).Select(x => x.Item2));
|
||||
|
||||
private static bool ParseBoolean(
|
||||
ExcelRow row, string header, bool defaultValue, List<string> errors)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var setup in setups)
|
||||
var taskSummaries = await db.EvaluationRecords.AsNoTracking()
|
||||
.WhereIn(taskIds, record => record.TeachingTaskId)
|
||||
.Where(record => record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed)
|
||||
.GroupBy(record => new
|
||||
{
|
||||
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 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
|
||||
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
|
||||
})
|
||||
.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)
|
||||
.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);
|
||||
|
||||
if (records.Count == 0) continue;
|
||||
|
||||
var dimensionResults = setup.Dimensions.Select(dim =>
|
||||
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 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.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();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,610 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = AnalyticsUsers)]
|
||||
[Route("api/grade-analytics")]
|
||||
public sealed class GradeAnalyticsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache? cache = null) : ControllerBase
|
||||
{
|
||||
private const string AnalyticsUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Leader + "," +
|
||||
SystemRoles.Teacher;
|
||||
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)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||
|
||||
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (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
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||
x.AcademicTermId,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name),
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.CalculatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||
}
|
||||
|
||||
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
|
||||
public async Task<ActionResult> GetTeachingClassAnalysis(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
|
||||
.Select(x => new AnalysisTarget(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
x.TeachingTask.Course!.CollegeId,
|
||||
x.TeachingTask.TaskNumber,
|
||||
x.TeachingTask.Name,
|
||||
x.TeachingTask.Course.Code,
|
||||
x.TeachingTask.Course.Name,
|
||||
x.TeachingTask.AcademicTerm!.Name))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
|
||||
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
|
||||
token => BuildReportAsync(sheet, token),
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics],
|
||||
cancellationToken);
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
[HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")]
|
||||
public async Task<ActionResult> ExportTeachingClassAnalysisReport(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
|
||||
.Select(x => new AnalysisTarget(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
x.TeachingTask.Course!.CollegeId,
|
||||
x.TeachingTask.TaskNumber,
|
||||
x.TeachingTask.Name,
|
||||
x.TeachingTask.Course.Code,
|
||||
x.TeachingTask.Course.Name,
|
||||
x.TeachingTask.AcademicTerm!.Name))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
|
||||
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
|
||||
token => BuildReportAsync(sheet, token),
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics],
|
||||
cancellationToken);
|
||||
if (report.IsRefreshing || report.Summary is null)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "成绩统计尚未生成",
|
||||
Detail = "请先重新计算当前教学班,待统计完成后再导出。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
|
||||
var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx";
|
||||
return File(
|
||||
content,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileName);
|
||||
}
|
||||
|
||||
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
|
||||
public async Task<ActionResult> RefreshTeachingClassAnalysis(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await db.GradeSheets.AsNoTracking()
|
||||
.AnyAsync(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
|
||||
cancellationToken);
|
||||
if (!exists) return NotFound();
|
||||
|
||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||
job.Id));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Accepted(new { job.Id });
|
||||
}
|
||||
|
||||
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
|
||||
AnalysisTarget target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||
.Select(x => new TeachingClassMetrics(
|
||||
x.StudentCount,
|
||||
x.PassedCount,
|
||||
x.ExcellentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.CalculatedAt,
|
||||
x.ScoreBands.OrderBy(band => band.SortOrder)
|
||||
.Select(band => new ScoreBand(
|
||||
band.Label,
|
||||
band.LowerBound,
|
||||
band.UpperBound,
|
||||
band.StudentCount))
|
||||
.ToArray()))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (statistic is null)
|
||||
return new TeachingClassAnalysisReport(
|
||||
true,
|
||||
target.GradeSheetId,
|
||||
target.TeachingTaskId,
|
||||
target.TaskNumber,
|
||||
target.TaskName,
|
||||
target.CourseCode,
|
||||
target.CourseName,
|
||||
target.TermName,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
|
||||
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.OrderByDescending(x => x.AverageScore)
|
||||
.Select(x => new
|
||||
{
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name).ToArray(),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name).ToArray(),
|
||||
x.StudentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var peers = peerRows.Select(x => new TeachingClassComparison(
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TaskNumber,
|
||||
x.TaskName,
|
||||
string.Join("、", x.TeacherNames),
|
||||
string.Join("、", x.ClassNames),
|
||||
x.StudentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.GradeSheetId == target.GradeSheetId)).ToArray();
|
||||
|
||||
var classProfiles = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||
.Select(x => new ClassProfile(
|
||||
x.Student!.AdministrativeClassId,
|
||||
x.Student.AdministrativeClass!.Name,
|
||||
x.Student.AdministrativeClass.MajorId,
|
||||
x.Student.AdministrativeClass.Major!.Name,
|
||||
x.Student.AdministrativeClass.Major.CollegeId,
|
||||
x.Student.AdministrativeClass.Major.College!.Name))
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
|
||||
|
||||
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.TeachingTask!.Teachers.Any(link =>
|
||||
selectedTeacherIds.Contains(link.TeacherId)))
|
||||
.Select(x => new
|
||||
{
|
||||
x.AcademicTermId,
|
||||
TermName = x.TeachingTask!.AcademicTerm!.Name,
|
||||
x.TeachingTask.AcademicTerm.StartDate,
|
||||
x.StudentCount,
|
||||
x.PassedCount,
|
||||
x.ExcellentCount,
|
||||
x.AverageScore
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.Scope == CourseGradeStatisticScope.University)
|
||||
.Select(x => new
|
||||
{
|
||||
x.AcademicTermId,
|
||||
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||
.Select(term => term.Name).First(),
|
||||
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||
.Select(term => term.StartDate).First(),
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
ExcellentRate = x.StudentCount == 0 ? 0m :
|
||||
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var teacherByTerm = historicalTaskRows
|
||||
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
|
||||
.ToDictionary(group => group.Key.AcademicTermId, group =>
|
||||
{
|
||||
var count = group.Sum(x => x.StudentCount);
|
||||
return new HistoricalSeriesValue(
|
||||
count,
|
||||
count == 0 ? 0m : Math.Round(
|
||||
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
|
||||
count == 0 ? 0m : Math.Round(
|
||||
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
|
||||
count == 0 ? 0m : Math.Round(
|
||||
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
|
||||
});
|
||||
var history = courseHistory
|
||||
.OrderBy(x => x.StartDate)
|
||||
.Select(x => new HistoricalComparison(
|
||||
x.AcademicTermId,
|
||||
x.TermName,
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
|
||||
.ToArray();
|
||||
|
||||
var university = scopeStatistics.FirstOrDefault(x =>
|
||||
x.Scope == CourseGradeStatisticScope.University);
|
||||
return new TeachingClassAnalysisReport(
|
||||
false,
|
||||
target.GradeSheetId,
|
||||
target.TeachingTaskId,
|
||||
target.TaskNumber,
|
||||
target.TaskName,
|
||||
target.CourseCode,
|
||||
target.CourseName,
|
||||
target.TermName,
|
||||
statistic,
|
||||
peers,
|
||||
benchmarks,
|
||||
history,
|
||||
university is null ? null : new ComparisonDelta(
|
||||
Math.Round(statistic.AverageScore - university.AverageScore, 1),
|
||||
Math.Round(statistic.PassRate - university.PassRate, 2),
|
||||
university.AverageScore,
|
||||
university.PassRate));
|
||||
}
|
||||
|
||||
private static ScopeBenchmark[] BuildBenchmarks(
|
||||
IEnumerable<ClassProfile> classProfiles,
|
||||
IReadOnlyCollection<CourseGradeStatistic> statistics)
|
||||
{
|
||||
var profiles = classProfiles.ToArray();
|
||||
var rows = new List<ScopeBenchmark>();
|
||||
foreach (var profile in profiles)
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
|
||||
profile.ClassId, "行政班", profile.ClassName);
|
||||
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
|
||||
profile.MajorId, "专业", profile.MajorName);
|
||||
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
|
||||
profile.CollegeId, "学院", profile.CollegeName);
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
|
||||
null, "全校", "全校同课程");
|
||||
return rows.ToArray();
|
||||
}
|
||||
|
||||
private static void AddBenchmark(
|
||||
ICollection<ScopeBenchmark> target,
|
||||
IEnumerable<CourseGradeStatistic> source,
|
||||
CourseGradeStatisticScope scope,
|
||||
Guid? entityId,
|
||||
string scopeLabel,
|
||||
string name)
|
||||
{
|
||||
var item = source.FirstOrDefault(x =>
|
||||
x.Scope == scope && x.ScopeEntityId == entityId);
|
||||
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
|
||||
target.Add(new ScopeBenchmark(
|
||||
scopeLabel,
|
||||
name,
|
||||
item.StudentCount,
|
||||
item.HighestScore,
|
||||
item.AverageScore,
|
||||
item.LowestScore,
|
||||
item.PassRate));
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> VisibleTeachingTasks()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
if (scope.Scope == DataScope.All) return source;
|
||||
if (scope.Scope == DataScope.College)
|
||||
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
return source.Where(x =>
|
||||
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
|
||||
return source.Where(_ => false);
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character));
|
||||
}
|
||||
|
||||
private sealed record AnalysisTarget(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
Guid CourseId,
|
||||
Guid AcademicTermId,
|
||||
Guid CourseCollegeId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TermName);
|
||||
|
||||
private sealed record ClassProfile(
|
||||
Guid ClassId,
|
||||
string ClassName,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
Guid CollegeId,
|
||||
string CollegeName);
|
||||
|
||||
public sealed record ScoreBand(
|
||||
string Label,
|
||||
decimal LowerBound,
|
||||
decimal? UpperBound,
|
||||
int StudentCount);
|
||||
|
||||
public sealed record TeachingClassMetrics(
|
||||
int StudentCount,
|
||||
int PassedCount,
|
||||
int ExcellentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal MedianScore,
|
||||
decimal LowestScore,
|
||||
decimal StandardDeviation,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate,
|
||||
DateTime CalculatedAt,
|
||||
IReadOnlyList<ScoreBand> ScoreBands);
|
||||
|
||||
public sealed record TeachingClassComparison(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string TeacherNames,
|
||||
string ClassNames,
|
||||
int StudentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal MedianScore,
|
||||
decimal LowestScore,
|
||||
decimal StandardDeviation,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record ScopeBenchmark(
|
||||
string Scope,
|
||||
string Name,
|
||||
int StudentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal LowestScore,
|
||||
decimal PassRate);
|
||||
|
||||
public sealed record HistoricalSeriesValue(
|
||||
int StudentCount,
|
||||
decimal AverageScore,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate);
|
||||
|
||||
public sealed record HistoricalComparison(
|
||||
Guid AcademicTermId,
|
||||
string TermName,
|
||||
int CourseStudentCount,
|
||||
decimal CourseAverageScore,
|
||||
decimal CoursePassRate,
|
||||
decimal CourseExcellentRate,
|
||||
HistoricalSeriesValue? Instructor);
|
||||
|
||||
public sealed record ComparisonDelta(
|
||||
decimal AverageScoreDifference,
|
||||
decimal PassRateDifference,
|
||||
decimal UniversityAverageScore,
|
||||
decimal UniversityPassRate);
|
||||
|
||||
public sealed record TeachingClassAnalysisReport(
|
||||
bool IsRefreshing,
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TermName,
|
||||
TeachingClassMetrics? Summary,
|
||||
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
|
||||
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
|
||||
IReadOnlyList<HistoricalComparison> History,
|
||||
ComparisonDelta? UniversityDelta);
|
||||
}
|
||||
|
||||
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
|
||||
bool IsEnabled,
|
||||
int IntervalSeconds,
|
||||
int BatchSize);
|
||||
@@ -3,7 +3,9 @@ using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/grades")]
|
||||
public sealed class GradesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache? cache = null) : ControllerBase
|
||||
{
|
||||
private const string SheetUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -37,6 +40,9 @@ public sealed class GradesController(
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin;
|
||||
|
||||
private const string StatisticsUsers =
|
||||
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
|
||||
|
||||
[HttpGet("sheets")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheets(
|
||||
@@ -145,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))
|
||||
@@ -699,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 =>
|
||||
{
|
||||
@@ -717,6 +724,7 @@ public sealed class GradesController(
|
||||
values.Add(score);
|
||||
}
|
||||
values.Add(record.FinalScore);
|
||||
values.Add(null);
|
||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||||
@@ -729,13 +737,56 @@ public sealed class GradesController(
|
||||
{
|
||||
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
||||
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
||||
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
|
||||
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
||||
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
||||
"导入后会自动重新计算总评成绩和绩点。"
|
||||
};
|
||||
|
||||
var regularColumn = 4;
|
||||
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||
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)));
|
||||
weightedColumns.Add((finalColumn, sheet.FinalWeight));
|
||||
var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
|
||||
var scoreColumns = weightedColumns.Select(x => x.Column)
|
||||
.Append(totalColumn)
|
||||
.ToArray();
|
||||
|
||||
var bytes = ExcelWorkbookHelper.Create(
|
||||
"成绩导入", headers, rows, instructions);
|
||||
"成绩导入", headers, rows, instructions,
|
||||
(worksheet, rowNumber) =>
|
||||
{
|
||||
var componentReferences = requiredColumns
|
||||
.Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
|
||||
.ToArray();
|
||||
var weightedExpression = string.Join("+", weightedColumns.Select(x =>
|
||||
$"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
|
||||
var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
|
||||
var formula =
|
||||
$"=IF(OR({statusReference}=\"缺考\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
|
||||
var cell = worksheet.Cell(rowNumber, totalColumn);
|
||||
cell.FormulaA1 = formula;
|
||||
cell.Style.NumberFormat.Format = "0.0";
|
||||
cell.Style.Font.Bold = true;
|
||||
cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
|
||||
|
||||
foreach (var scoreColumn in scoreColumns)
|
||||
{
|
||||
var conditionalFormat = worksheet
|
||||
.Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
|
||||
.AddConditionalFormat();
|
||||
var failingScoreFormat = conditionalFormat.WhenLessThan(60);
|
||||
failingScoreFormat.Fill.BackgroundColor =
|
||||
ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
|
||||
failingScoreFormat.Font.FontColor =
|
||||
ClosedXML.Excel.XLColor.FromHtml("#B42318");
|
||||
}
|
||||
});
|
||||
var taskName = sheet.TeachingTask!.Name;
|
||||
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||
$"成绩导入模板-{taskName}.xlsx");
|
||||
@@ -768,13 +819,15 @@ 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
|
||||
{
|
||||
rows = await ExcelWorkbookHelper.ReadAsync(
|
||||
file, headers, cancellationToken);
|
||||
file,
|
||||
headers.Where(x => x != "总分(自动计算)").ToArray(),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
@@ -810,7 +863,7 @@ public sealed class GradesController(
|
||||
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||
|
||||
// Parse final score
|
||||
// Parse final score. The formula-driven total column is intentionally ignored.
|
||||
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||
|
||||
@@ -902,6 +955,7 @@ public sealed class GradesController(
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
GradeSheetId = x.GradeSheetId,
|
||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTaskId,
|
||||
@@ -918,6 +972,121 @@ public sealed class GradesController(
|
||||
return Ok(new { Student = student, Records = records });
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}/statistics")]
|
||||
[Authorize(Roles = StatisticsUsers)]
|
||||
public async Task<ActionResult> GetCourseStatistics(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Status,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
CourseName = x.TeachingTask.Course!.Name,
|
||||
CourseCode = x.TeachingTask.Course.Code,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
Guid? classId = null;
|
||||
Guid? majorId = null;
|
||||
Guid? collegeId = null;
|
||||
if (scope.IsInRole(SystemRoles.Student))
|
||||
{
|
||||
if (sheet.Status != GradeSheetStatus.Published)
|
||||
return NotFound();
|
||||
var student = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Student!.AdministrativeClassId,
|
||||
MajorId = x.Student.AdministrativeClass!.MajorId,
|
||||
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (student is null) return Forbid();
|
||||
classId = student.AdministrativeClassId;
|
||||
majorId = student.MajorId;
|
||||
collegeId = student.CollegeId;
|
||||
}
|
||||
else if (scope.Scope == DataScope.College)
|
||||
{
|
||||
collegeId = scope.RestrictedCollegeId;
|
||||
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
|
||||
await db.Courses.Where(x => x.Id == sheet.CourseId)
|
||||
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
|
||||
return Forbid();
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.CounselorUserId == scope.UserId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (allowedClassIds.Count == 0) return Forbid();
|
||||
// A counselor sees their classes plus the matching major/college
|
||||
// benchmarks, never an unrelated class-level statistic.
|
||||
classId = allowedClassIds.First();
|
||||
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
|
||||
.Select(x => x.MajorId).FirstAsync(cancellationToken);
|
||||
collegeId = await db.Majors.Where(x => x.Id == majorId)
|
||||
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
|
||||
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
|
||||
{
|
||||
var source = db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == sheet.CourseId &&
|
||||
x.AcademicTermId == sheet.AcademicTermId);
|
||||
return await source.Select(x => new
|
||||
{
|
||||
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
|
||||
x.Below60Count, x.From60To69Count, x.From70To79Count,
|
||||
x.From80To89Count, x.From90To100Count,
|
||||
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
|
||||
x.CalculatedAt
|
||||
}).ToListAsync(token);
|
||||
}, AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics], cancellationToken);
|
||||
|
||||
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
|
||||
{
|
||||
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
|
||||
x.ScopeEntityId == entityId);
|
||||
return item is null ? null : new
|
||||
{
|
||||
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
|
||||
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
|
||||
item.CalculatedAt,
|
||||
Distribution = new[]
|
||||
{
|
||||
new { Range = "0–59", Count = item.Below60Count },
|
||||
new { Range = "60–69", Count = item.From60To69Count },
|
||||
new { Range = "70–79", Count = item.From70To79Count },
|
||||
new { Range = "80–89", Count = item.From80To89Count },
|
||||
new { Range = "90–100", Count = item.From90To100Count }
|
||||
}
|
||||
};
|
||||
}
|
||||
return Ok(new
|
||||
{
|
||||
sheet.CourseName, sheet.CourseCode, sheet.TermName,
|
||||
IsRefreshing = !statistics.Any(),
|
||||
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
|
||||
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
|
||||
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
|
||||
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
|
||||
? Find(CourseGradeStatisticScope.University, null) : null
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> AccessibleTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
@@ -1041,6 +1210,18 @@ public sealed class GradesController(
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string ColumnLetter(int column)
|
||||
{
|
||||
var result = string.Empty;
|
||||
while (column > 0)
|
||||
{
|
||||
column--;
|
||||
result = (char)('A' + column % 26) + result;
|
||||
column /= 26;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record GradeSheetRequest(
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -79,6 +79,38 @@ public sealed class OperationsController(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await healthService.CheckAsync(cancellationToken));
|
||||
|
||||
[HttpGet("swagger")]
|
||||
public async Task<ActionResult<SwaggerDocumentationSettings>> GetSwaggerSettings(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(new SwaggerDocumentationSettings(await IsSwaggerEnabledAsync(cancellationToken)));
|
||||
|
||||
[HttpPut("swagger")]
|
||||
public async Task<ActionResult<SwaggerDocumentationSettings>> UpdateSwaggerSettings(
|
||||
UpdateSwaggerDocumentationSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var setting = await db.SystemFeatureSettings.SingleOrDefaultAsync(
|
||||
x => x.Key == SystemFeatureKeys.SwaggerDocumentation,
|
||||
cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new SystemFeatureSetting
|
||||
{
|
||||
Key = SystemFeatureKeys.SwaggerDocumentation,
|
||||
IsEnabled = request.IsEnabled
|
||||
};
|
||||
db.SystemFeatureSettings.Add(setting);
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.IsEnabled = request.IsEnabled;
|
||||
setting.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new SwaggerDocumentationSettings(setting.IsEnabled));
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
|
||||
[FromQuery] int page = 1,
|
||||
@@ -298,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);
|
||||
@@ -523,6 +555,12 @@ public sealed class OperationsController(
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<bool> IsSwaggerEnabledAsync(CancellationToken cancellationToken) =>
|
||||
await db.SystemFeatureSettings.AsNoTracking()
|
||||
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
|
||||
.Select(x => (bool?)x.IsEnabled)
|
||||
.SingleOrDefaultAsync(cancellationToken) ?? false;
|
||||
|
||||
private ActionResult? ValidatePaging(int page, int pageSize)
|
||||
{
|
||||
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
|
||||
@@ -604,3 +642,7 @@ public sealed record OperationsSummary(
|
||||
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
|
||||
|
||||
public sealed record RestoreDrillRequest([Required] string Confirmation);
|
||||
|
||||
public sealed record SwaggerDocumentationSettings(bool IsEnabled);
|
||||
|
||||
public sealed record UpdateSwaggerDocumentationSettings(bool IsEnabled);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
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;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/other-exams")]
|
||||
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
||||
{
|
||||
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
|
||||
[HttpGet("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatches(
|
||||
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);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
|
||||
{
|
||||
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
|
||||
var name = Normalize(request.Name);
|
||||
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
|
||||
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
|
||||
if (error is not null) return ValidationProblem(error);
|
||||
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
|
||||
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
|
||||
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
|
||||
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
|
||||
db.OtherExamBatches.Add(batch);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { batch.Id });
|
||||
}
|
||||
|
||||
[HttpGet("students/lookup")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
|
||||
{
|
||||
var number = Normalize(studentNumber);
|
||||
if (number is null) return ValidationProblem("请输入学号。");
|
||||
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
|
||||
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
|
||||
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
|
||||
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
|
||||
if (batch is null) return NotFound();
|
||||
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
|
||||
return Ok(new { Batch = batch, Results = results });
|
||||
}
|
||||
|
||||
[HttpPut("batches/{id:guid}/results")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||
return result is null ? Ok(new { updated = request.Results.Count }) : result;
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}/template")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
var headers = HeadersFor(batch);
|
||||
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
|
||||
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
|
||||
}
|
||||
|
||||
[HttpPost("batches/{id:guid}/import")]
|
||||
[Authorize(Roles = Managers)]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
|
||||
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
|
||||
var inputs = new List<OtherExamResultRequest>();
|
||||
var errors = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var number = row["学号"].Trim();
|
||||
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
|
||||
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
|
||||
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
|
||||
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
|
||||
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
|
||||
}
|
||||
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||
var result = await ReplaceResultsAsync(batch, inputs, ct);
|
||||
return result ?? Ok(new { updated = inputs.Count });
|
||||
}
|
||||
|
||||
[HttpPost("batches/{id:guid}/publish")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
|
||||
batch.Status = OtherExamBatchStatus.Published;
|
||||
batch.PublicationCount++;
|
||||
batch.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { batch.PublicationCount, batch.PublishedAt });
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> Mine(CancellationToken ct)
|
||||
{
|
||||
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
|
||||
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
|
||||
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
|
||||
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
|
||||
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
|
||||
return Ok(new { Best = best, History = history });
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
|
||||
{
|
||||
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
|
||||
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
|
||||
var studentRows = await db.Students
|
||||
.Where(x => numbers.Contains(x.StudentNumber))
|
||||
.ToListAsync(ct);
|
||||
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
|
||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||
foreach (var item in inputs)
|
||||
{
|
||||
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed, item.Notes);
|
||||
if (error is not null) return ValidationProblem(error);
|
||||
}
|
||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||
var beforeCount = await db.OtherExamResults.AsNoTracking()
|
||||
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
|
||||
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
|
||||
{
|
||||
db.OtherExamResults.RemoveRange(batch.Results);
|
||||
batch.Status = OtherExamBatchStatus.Draft;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var replacementResults = inputs.Select(x =>
|
||||
{
|
||||
var student = students[x.StudentNumber.Trim()];
|
||||
return new OtherExamResult
|
||||
{
|
||||
OtherExamBatchId = batch.Id,
|
||||
StudentId = student.Id,
|
||||
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
|
||||
Score = x.Score,
|
||||
Level = Normalize(x.Level),
|
||||
IsPassed = x.IsPassed,
|
||||
Notes = Normalize(x.Notes)
|
||||
};
|
||||
}).ToList();
|
||||
db.OtherExamResults.AddRange(replacementResults);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return null;
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
|
||||
{
|
||||
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
|
||||
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
|
||||
_ => ["学号", "是否合格", "备注"]
|
||||
};
|
||||
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
|
||||
{
|
||||
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
|
||||
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
|
||||
}
|
||||
private static bool? ParsePass(string value, int row, List<string> errors)
|
||||
{
|
||||
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
|
||||
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
|
||||
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
|
||||
}
|
||||
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
|
||||
{
|
||||
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||
_ => null
|
||||
};
|
||||
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass, string? notes)
|
||||
{
|
||||
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);
|
||||
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
|
||||
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
var index = Array.IndexOf(levels, level ?? "");
|
||||
return index >= 0 ? levels.Length - index : -1;
|
||||
}
|
||||
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||
{
|
||||
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
|
||||
}
|
||||
|
||||
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
|
||||
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
|
||||
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
|
||||
@@ -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,16 @@ 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) ?? []
|
||||
.Select(x => x.ClassroomId) ?? [],
|
||||
AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
|
||||
.Select(x => x.ClassroomId) ?? [],
|
||||
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
|
||||
};
|
||||
}));
|
||||
}
|
||||
@@ -167,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)
|
||||
{
|
||||
@@ -194,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)
|
||||
@@ -207,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)
|
||||
{
|
||||
@@ -222,16 +260,28 @@ 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());
|
||||
constraint.EarliestPeriod = request.EarliestPeriod;
|
||||
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();
|
||||
}
|
||||
@@ -257,18 +307,23 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
!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("部分教学任务不存在、未发布或不属于当前学期。");
|
||||
@@ -279,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)
|
||||
@@ -318,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.AllowedExperimentClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
@@ -340,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 };
|
||||
@@ -355,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)
|
||||
@@ -377,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;
|
||||
@@ -400,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) =>
|
||||
@@ -438,7 +552,11 @@ public sealed record TeachingTaskScheduleConstraintRequest(
|
||||
IReadOnlyList<Guid> AllowedClassroomIds,
|
||||
IReadOnlyList<int> AllowedDayOfWeeks,
|
||||
[Range(1, 30)] int? EarliestPeriod,
|
||||
[Range(1, 30)] int? LatestPeriod);
|
||||
[Range(1, 30)] int? LatestPeriod,
|
||||
TeachingVenueNature AllowedExperimentVenueNatures = 0,
|
||||
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
|
||||
Guid? ExperimentRequiredCampusId = null,
|
||||
Guid? ExperimentRequiredBuildingId = null);
|
||||
|
||||
public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
||||
Guid AcademicTermId,
|
||||
@@ -452,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,22 +600,40 @@ public sealed class SchedulesController(
|
||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||
!IsExperimentRoom(classroom.RoomType))
|
||||
return ValidationProblem(
|
||||
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
|
||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||
if (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 &&
|
||||
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
|
||||
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 =>
|
||||
@@ -655,11 +693,6 @@ public sealed class SchedulesController(
|
||||
.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);
|
||||
|
||||
private async Task<ActionResult> SaveAsync(
|
||||
Guid id,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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,
|
||||
user.UserName ?? string.Empty,
|
||||
user.DisplayName,
|
||||
user.StaffNumber,
|
||||
user.CollegeId,
|
||||
user.IsEnabled,
|
||||
user.LastLoginAt,
|
||||
user.CreatedAt,
|
||||
Roles = identityUser is null
|
||||
? []
|
||||
: await userManager.GetRolesAsync(identityUser)
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
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)
|
||||
{
|
||||
entity = new WarningRule
|
||||
{
|
||||
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
|
||||
});
|
||||
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,16 +101,22 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -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 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
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
|
||||
@@ -6,6 +6,11 @@ public sealed class ExperimentProject : EntityBase
|
||||
{
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||
public Guid? ScheduleEntryId { get; set; }
|
||||
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
|
||||
public int? ScheduleWeek { get; set; }
|
||||
public required string Code { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||
@@ -13,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; }
|
||||
@@ -38,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; }
|
||||
|
||||
@@ -54,6 +54,99 @@ public sealed class GradeItemScore
|
||||
public decimal? Score { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted course-result aggregate. One course/term is materialized at each
|
||||
/// organizational level so the result-analysis page never aggregates raw
|
||||
/// grade records on request.
|
||||
/// </summary>
|
||||
public sealed class CourseGradeStatistic : EntityBase
|
||||
{
|
||||
public Guid CourseId { get; set; }
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public CourseGradeStatisticScope Scope { get; set; }
|
||||
public Guid? ScopeEntityId { get; set; }
|
||||
public int StudentCount { get; set; }
|
||||
public int PassedCount { get; set; }
|
||||
public int Below60Count { get; set; }
|
||||
public int From60To69Count { get; set; }
|
||||
public int From70To79Count { get; set; }
|
||||
public int From80To89Count { get; set; }
|
||||
public int From90To100Count { get; set; }
|
||||
public decimal HighestScore { get; set; }
|
||||
public decimal AverageScore { get; set; }
|
||||
public decimal LowestScore { get; set; }
|
||||
public decimal PassRate { get; set; }
|
||||
public DateTime CalculatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialized analysis for one published teaching class. Course/term
|
||||
/// organizational benchmarks stay in <see cref="CourseGradeStatistic"/>;
|
||||
/// this table is the grain used for peer-class and historical comparisons.
|
||||
/// </summary>
|
||||
public sealed class TeachingTaskGradeStatistic : EntityBase
|
||||
{
|
||||
public Guid GradeSheetId { get; set; }
|
||||
public GradeSheet? GradeSheet { get; set; }
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
public Guid CourseId { get; set; }
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public int StudentCount { get; set; }
|
||||
public int PassedCount { get; set; }
|
||||
public int ExcellentCount { get; set; }
|
||||
public decimal HighestScore { get; set; }
|
||||
public decimal AverageScore { get; set; }
|
||||
public decimal MedianScore { get; set; }
|
||||
public decimal LowestScore { get; set; }
|
||||
public decimal StandardDeviation { get; set; }
|
||||
public decimal PassRate { get; set; }
|
||||
public decimal ExcellentRate { get; set; }
|
||||
public DateTime CalculatedAt { get; set; }
|
||||
public ICollection<TeachingTaskGradeScoreBand> ScoreBands { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flexible score-band rows are kept separately so future band definitions do
|
||||
/// not require widening the teaching-class summary table.
|
||||
/// </summary>
|
||||
public sealed class TeachingTaskGradeScoreBand : EntityBase
|
||||
{
|
||||
public Guid TeachingTaskGradeStatisticId { get; set; }
|
||||
public TeachingTaskGradeStatistic? TeachingTaskGradeStatistic { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public decimal LowerBound { get; set; }
|
||||
public decimal? UpperBound { get; set; }
|
||||
public int StudentCount { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public enum CourseGradeStatisticScope
|
||||
{
|
||||
AdministrativeClass = 1,
|
||||
Major = 2,
|
||||
College = 3,
|
||||
University = 4
|
||||
}
|
||||
|
||||
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
|
||||
{
|
||||
public Guid GradeSheetId { get; set; }
|
||||
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
|
||||
CourseGradeStatisticsRefreshJobStatus.Queued;
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public enum CourseGradeStatisticsRefreshJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
public enum GradeSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
|
||||
@@ -46,9 +46,35 @@ public sealed class Classroom : CatalogEntity
|
||||
public Building? Building { get; set; }
|
||||
public int Capacity { get; set; }
|
||||
public string RoomType { get; set; } = "普通教室";
|
||||
public TeachingVenueNature TeachingVenueNature { get; set; } =
|
||||
TeachingVenueNature.GeneralClassroom;
|
||||
public string? Equipment { get; set; }
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum TeachingVenueNature
|
||||
{
|
||||
GeneralClassroom = 1,
|
||||
Laboratory = 2,
|
||||
TrainingRoom = 4,
|
||||
ComputerLab = 8,
|
||||
LanguageLab = 16,
|
||||
SportsVenue = 32,
|
||||
ArtsVenue = 64
|
||||
}
|
||||
|
||||
public static class TeachingVenueNatureRules
|
||||
{
|
||||
public const TeachingVenueNature ExperimentTeaching =
|
||||
TeachingVenueNature.Laboratory |
|
||||
TeachingVenueNature.TrainingRoom |
|
||||
TeachingVenueNature.ComputerLab |
|
||||
TeachingVenueNature.LanguageLab;
|
||||
|
||||
public static bool SupportsExperiment(TeachingVenueNature value) =>
|
||||
(value & ExperimentTeaching) != 0;
|
||||
}
|
||||
|
||||
public sealed class AcademicTerm : CatalogEntity
|
||||
{
|
||||
public required string AcademicYear { get; set; }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class OtherExamBatch : EntityBase
|
||||
{
|
||||
public string? ExamCode { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Organizer { get; set; }
|
||||
public DateOnly ExamDate { get; set; }
|
||||
public OtherExamMetricKind MetricKind { get; set; }
|
||||
public decimal? MaxScore { get; set; }
|
||||
public string? LevelOptions { get; set; }
|
||||
public OtherExamBatchStatus Status { get; set; } = OtherExamBatchStatus.Draft;
|
||||
public int PublicationCount { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<OtherExamResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class OtherExamResult : EntityBase
|
||||
{
|
||||
public Guid OtherExamBatchId { get; set; }
|
||||
public OtherExamBatch? OtherExamBatch { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public int AttemptNumber { get; set; } = 1;
|
||||
public decimal? Score { get; set; }
|
||||
public string? Level { get; set; }
|
||||
public bool? IsPassed { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public enum OtherExamMetricKind
|
||||
{
|
||||
PassFail = 1,
|
||||
Level = 2,
|
||||
Score = 3
|
||||
}
|
||||
|
||||
public enum OtherExamBatchStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Published = 2
|
||||
}
|
||||
@@ -52,10 +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
|
||||
@@ -66,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; }
|
||||
|
||||
@@ -33,7 +33,8 @@ public enum BackgroundJobKind
|
||||
MakeupExamAuto = 3,
|
||||
ExamArrangement = 4,
|
||||
ExamSignInExport = 5,
|
||||
ExamPublish = 6
|
||||
ExamPublish = 6,
|
||||
CourseGradeStatisticsRefresh = 7
|
||||
}
|
||||
|
||||
public enum BackgroundJobOutboxState
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.System;
|
||||
|
||||
public static class SystemFeatureKeys
|
||||
{
|
||||
public const string SwaggerDocumentation = "SwaggerDocumentation";
|
||||
}
|
||||
|
||||
public sealed class SystemFeatureSetting : EntityBase
|
||||
{
|
||||
public required string Key { get; set; }
|
||||
public bool IsEnabled { 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public sealed class BackgroundJobOptions
|
||||
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||
public int ExamPublishConcurrency { get; set; } = 1;
|
||||
public int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
|
||||
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||
public bool UseQuorumQueues { get; set; } = true;
|
||||
@@ -35,6 +36,8 @@ public sealed class BackgroundJobOptions
|
||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh =>
|
||||
CourseGradeStatisticsRefreshConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,6 +130,15 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missingKeys = automaticJobs
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
@@ -143,6 +152,8 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
(BackgroundJobKind.ExamSignInExport, id)))
|
||||
.Concat(publishJobs2.Select(id =>
|
||||
(BackgroundJobKind.ExamPublish, id)))
|
||||
.Concat(gradeStatisticsJobs.Select(id =>
|
||||
(BackgroundJobKind.CourseGradeStatisticsRefresh, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -100,6 +101,11 @@ public sealed class BackgroundJobRunner(
|
||||
.GetRequiredService<ExamPublishJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
@@ -308,6 +314,19 @@ public sealed class BackgroundJobRunner(
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||
await db.CourseGradeStatisticsRefreshJobs
|
||||
.Where(x => x.Id == message.JobId &&
|
||||
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
|
||||
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.Status,
|
||||
CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(message.JobKind),
|
||||
|
||||
@@ -319,7 +319,8 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
BackgroundJobKind.ExamSignInExport,
|
||||
BackgroundJobKind.ExamPublish
|
||||
BackgroundJobKind.ExamPublish,
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
@@ -418,6 +419,7 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||
BackgroundJobKind.ExamPublish => "exam.publish",
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
|
||||
|
||||
@@ -182,12 +182,19 @@ public static class AppCacheKeys
|
||||
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||
}
|
||||
|
||||
public static string CourseGradeStatistics(Guid gradeSheetId) =>
|
||||
$"grade-statistics:sheet:{gradeSheetId:N}";
|
||||
|
||||
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
|
||||
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
|
||||
}
|
||||
|
||||
public static class AppCacheTags
|
||||
{
|
||||
public const string BaseData = "base-data";
|
||||
public const string Analytics = "analytics";
|
||||
public const string CourseGradeStatistics = "grade-statistics";
|
||||
public const string Timetables = "timetables";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper
|
||||
string sheetName,
|
||||
IReadOnlyList<string> headers,
|
||||
IEnumerable<IReadOnlyList<object?>> rows,
|
||||
IReadOnlyList<string>? instructions = null)
|
||||
IReadOnlyList<string>? instructions = null,
|
||||
Action<IXLWorksheet, int>? configureRow = null)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var sheet = workbook.Worksheets.Add(sheetName);
|
||||
@@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper
|
||||
{
|
||||
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
|
||||
}
|
||||
configureRow?.Invoke(sheet, rowNumber);
|
||||
rowNumber++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a course/term's denormalized result statistics. The operation is
|
||||
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
|
||||
/// </summary>
|
||||
public sealed class CourseGradeStatisticsRefreshJobProcessor(
|
||||
AppDbContext db,
|
||||
IAppCache cache,
|
||||
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.CourseGradeStatisticsRefreshJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
|
||||
return;
|
||||
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
job.ErrorMessage = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var sheetData = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == job.GradeSheetId)
|
||||
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheetData is null)
|
||||
{
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
|
||||
|
||||
try
|
||||
{
|
||||
// Statistics shown to students are based only on formally published
|
||||
// scores. This prevents an unfinished class from exposing data.
|
||||
var scores = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.TotalScore != null &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
|
||||
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
|
||||
.Select(x => new ScoreRow(
|
||||
x.GradeSheetId,
|
||||
x.GradeSheet!.TeachingTaskId,
|
||||
x.TotalScore!.Value,
|
||||
x.Student!.AdministrativeClassId,
|
||||
x.Student.AdministrativeClass!.MajorId,
|
||||
x.Student.AdministrativeClass.Major!.CollegeId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var rebuilt = new List<CourseGradeStatistic>();
|
||||
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
|
||||
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
|
||||
AddStatistics(CourseGradeStatisticScope.Major,
|
||||
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
|
||||
AddStatistics(CourseGradeStatisticScope.College,
|
||||
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
|
||||
AddUniversityStatistic(scores, rebuilt, target, now);
|
||||
|
||||
var rebuiltTeachingTasks = scores
|
||||
.GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId })
|
||||
.Select(group => CreateTeachingTaskStatistic(
|
||||
group.Key.GradeSheetId,
|
||||
group.Key.TeachingTaskId,
|
||||
group.Select(x => x.Score),
|
||||
target,
|
||||
now))
|
||||
.ToList();
|
||||
|
||||
await db.CourseGradeStatistics
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
if (rebuilt.Count > 0)
|
||||
db.CourseGradeStatistics.AddRange(rebuilt);
|
||||
|
||||
var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (oldTeachingTaskStatisticIds.Count > 0)
|
||||
{
|
||||
await db.TeachingTaskGradeScoreBands
|
||||
.Where(x => oldTeachingTaskStatisticIds.Contains(
|
||||
x.TeachingTaskGradeStatisticId))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await db.TeachingTaskGradeStatistics
|
||||
.Where(x => oldTeachingTaskStatisticIds.Contains(x.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
if (rebuiltTeachingTasks.Count > 0)
|
||||
db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks);
|
||||
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||
job.CompletedAt = now;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
|
||||
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
|
||||
exception.GetBaseException().Message.Length)];
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddStatistics(
|
||||
CourseGradeStatisticScope scope,
|
||||
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
|
||||
ICollection<CourseGradeStatistic> target,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
foreach (var group in groups)
|
||||
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
|
||||
}
|
||||
|
||||
private static void AddUniversityStatistic(
|
||||
IReadOnlyCollection<ScoreRow> scores,
|
||||
ICollection<CourseGradeStatistic> target,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
if (scores.Count > 0)
|
||||
target.Add(Create(CourseGradeStatisticScope.University, null,
|
||||
scores.Select(x => x.Score), targetInfo, calculatedAt));
|
||||
}
|
||||
|
||||
private static CourseGradeStatistic Create(
|
||||
CourseGradeStatisticScope scope,
|
||||
Guid? scopeEntityId,
|
||||
IEnumerable<decimal> source,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
var scores = source.ToArray();
|
||||
var passed = scores.Count(x => x >= 60m);
|
||||
return new CourseGradeStatistic
|
||||
{
|
||||
CourseId = targetInfo.CourseId,
|
||||
AcademicTermId = targetInfo.AcademicTermId,
|
||||
Scope = scope,
|
||||
ScopeEntityId = scopeEntityId,
|
||||
StudentCount = scores.Length,
|
||||
PassedCount = passed,
|
||||
Below60Count = scores.Count(x => x < 60m),
|
||||
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
|
||||
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
|
||||
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
|
||||
From90To100Count = scores.Count(x => x >= 90m),
|
||||
HighestScore = scores.Max(),
|
||||
AverageScore = Math.Round(scores.Average(), 1),
|
||||
LowestScore = scores.Min(),
|
||||
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
|
||||
CalculatedAt = calculatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic(
|
||||
Guid gradeSheetId,
|
||||
Guid teachingTaskId,
|
||||
IEnumerable<decimal> source,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
var scores = source.OrderBy(x => x).ToArray();
|
||||
var passed = scores.Count(x => x >= 60m);
|
||||
var excellent = scores.Count(x => x >= 90m);
|
||||
var average = scores.Average();
|
||||
var middle = scores.Length / 2;
|
||||
var median = scores.Length % 2 == 0
|
||||
? (scores[middle - 1] + scores[middle]) / 2m
|
||||
: scores[middle];
|
||||
var variance = scores.Average(x =>
|
||||
(double)((x - average) * (x - average)));
|
||||
|
||||
var statistic = new TeachingTaskGradeStatistic
|
||||
{
|
||||
GradeSheetId = gradeSheetId,
|
||||
TeachingTaskId = teachingTaskId,
|
||||
CourseId = targetInfo.CourseId,
|
||||
AcademicTermId = targetInfo.AcademicTermId,
|
||||
StudentCount = scores.Length,
|
||||
PassedCount = passed,
|
||||
ExcellentCount = excellent,
|
||||
HighestScore = scores.Max(),
|
||||
AverageScore = Math.Round(average, 1),
|
||||
MedianScore = Math.Round(median, 1),
|
||||
LowestScore = scores.Min(),
|
||||
StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2),
|
||||
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
|
||||
ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2),
|
||||
CalculatedAt = calculatedAt
|
||||
};
|
||||
statistic.ScoreBands =
|
||||
[
|
||||
CreateBand(statistic.Id, "0–59", 0m, 60m,
|
||||
scores.Count(x => x < 60m), 0),
|
||||
CreateBand(statistic.Id, "60–69", 60m, 70m,
|
||||
scores.Count(x => x >= 60m && x < 70m), 1),
|
||||
CreateBand(statistic.Id, "70–79", 70m, 80m,
|
||||
scores.Count(x => x >= 70m && x < 80m), 2),
|
||||
CreateBand(statistic.Id, "80–89", 80m, 90m,
|
||||
scores.Count(x => x >= 80m && x < 90m), 3),
|
||||
CreateBand(statistic.Id, "90–100", 90m, null,
|
||||
scores.Count(x => x >= 90m), 4)
|
||||
];
|
||||
return statistic;
|
||||
}
|
||||
|
||||
private static TeachingTaskGradeScoreBand CreateBand(
|
||||
Guid statisticId,
|
||||
string label,
|
||||
decimal lowerBound,
|
||||
decimal? upperBound,
|
||||
int count,
|
||||
int sortOrder) => new()
|
||||
{
|
||||
TeachingTaskGradeStatisticId = statisticId,
|
||||
Label = label,
|
||||
LowerBound = lowerBound,
|
||||
UpperBound = upperBound,
|
||||
StudentCount = count,
|
||||
SortOrder = sortOrder
|
||||
};
|
||||
|
||||
private sealed record ScoreRow(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
decimal Score,
|
||||
Guid ClassId,
|
||||
Guid MajorId,
|
||||
Guid CollegeId);
|
||||
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
|
||||
}
|
||||
@@ -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,25 +99,22 @@ 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;
|
||||
var complete = sheets.Count > 0;
|
||||
if (sheets.Count == 0) return null;
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var score = sheet.Scores.FirstOrDefault(x =>
|
||||
x.StudentId == studentId);
|
||||
if (score?.TotalScore is not decimal totalScore)
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
if (score?.TotalScore is not decimal totalScore) return null;
|
||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||
totalWeight += sheet.ContributionWeight;
|
||||
}
|
||||
scores[studentId] = complete && totalWeight > 0
|
||||
return totalWeight > 0
|
||||
? Math.Round(
|
||||
weightedTotal / totalWeight,
|
||||
1,
|
||||
@@ -54,16 +122,6 @@ public static class ExperimentGradeAggregationService
|
||||
: null;
|
||||
}
|
||||
|
||||
return new ExperimentGradeAggregateResult(
|
||||
sheets.Count,
|
||||
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ContributionWeight)).ToList(),
|
||||
scores);
|
||||
}
|
||||
|
||||
private sealed record PublishedExperimentSheet(
|
||||
Guid Id,
|
||||
string Code,
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using SkiaSharp;
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
|
||||
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
|
||||
using W = DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
public static class GradeAnalysisWordReportGenerator
|
||||
{
|
||||
private const string Blue = "2E74B5";
|
||||
private const string DarkBlue = "1F4D78";
|
||||
private const string Ink = "263238";
|
||||
private const string Muted = "68707A";
|
||||
private const string LightFill = "F2F4F7";
|
||||
private const int ContentWidth = 9360;
|
||||
|
||||
public static byte[] Generate(
|
||||
GradeAnalyticsController.TeachingClassAnalysisReport report,
|
||||
DateTime generatedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report.Summary);
|
||||
using var stream = new MemoryStream();
|
||||
using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true))
|
||||
{
|
||||
document.PackageProperties.Title = $"{report.CourseName}成绩分析报告";
|
||||
document.PackageProperties.Subject = "教学班成绩统计与对比分析";
|
||||
document.PackageProperties.Creator = "教务管理系统";
|
||||
document.PackageProperties.Created = generatedAt;
|
||||
|
||||
var mainPart = document.AddMainDocumentPart();
|
||||
mainPart.Document = new Document(new Body());
|
||||
var settingsPart = mainPart.AddNewPart<DocumentSettingsPart>();
|
||||
settingsPart.Settings = new Settings(new EvenAndOddHeaders());
|
||||
settingsPart.Settings.Save();
|
||||
AddStyles(mainPart);
|
||||
var headerFooterIds = AddHeaderAndFooter(mainPart);
|
||||
BuildBody(mainPart, report, generatedAt, headerFooterIds);
|
||||
mainPart.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void BuildBody(
|
||||
MainDocumentPart mainPart,
|
||||
GradeAnalyticsController.TeachingClassAnalysisReport report,
|
||||
DateTime generatedAt,
|
||||
HeaderFooterIds headerFooterIds)
|
||||
{
|
||||
var body = mainPart.Document?.Body
|
||||
?? throw new InvalidOperationException("The report document body has not been initialized.");
|
||||
var summary = report.Summary!;
|
||||
|
||||
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
|
||||
body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220));
|
||||
body.Append(MetadataTable([
|
||||
("课程", $"{report.CourseCode} {report.CourseName}"),
|
||||
("教学班", $"{report.TaskNumber} {report.TaskName}"),
|
||||
("学期", report.TermName),
|
||||
("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")),
|
||||
("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")),
|
||||
("统计对象", $"{summary.StudentCount} 份已发布有效成绩")
|
||||
]));
|
||||
|
||||
body.Append(Heading("一、分析摘要", 1));
|
||||
body.Append(Callout(BuildExecutiveSummary(report)));
|
||||
body.Append(MetricsTable(summary));
|
||||
|
||||
body.Append(Heading("二、分数段分布", 1));
|
||||
body.Append(Paragraph("图 1 当前教学班各分数段人数", 20, false, Muted, 80, 80));
|
||||
body.Append(ImageParagraph(mainPart, DrawScoreBands(summary.ScoreBands), "分数段分布图", 6.3, 3.0));
|
||||
body.Append(DataTable(
|
||||
["分数段", "下限", "上限", "人数", "占比"],
|
||||
summary.ScoreBands.Select(x => new[]
|
||||
{
|
||||
x.Label,
|
||||
x.LowerBound.ToString("0.#"),
|
||||
x.UpperBound?.ToString("0.#") ?? "无上限",
|
||||
x.StudentCount.ToString(),
|
||||
Percent(x.StudentCount, summary.StudentCount)
|
||||
}),
|
||||
[1800, 1500, 1500, 1500, 3060]));
|
||||
|
||||
body.Append(Heading("三、同课程教学班对比", 1));
|
||||
body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80));
|
||||
var peerChartHeight = Math.Clamp(1.45 + report.PeerTeachingClasses.Count * 0.32, 1.8, 3.35);
|
||||
body.Append(ImageParagraph(mainPart, DrawPeerAverages(report.PeerTeachingClasses), "教学班平均分对比图", 6.3, peerChartHeight));
|
||||
body.Append(DataTable(
|
||||
["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"],
|
||||
report.PeerTeachingClasses.Select(x => new[]
|
||||
{
|
||||
$"{x.TaskNumber}{(x.IsSelected ? "(当前)" : "")}\n{x.TeacherNames}",
|
||||
x.StudentCount.ToString(),
|
||||
Score(x.AverageScore),
|
||||
Score(x.MedianScore),
|
||||
x.StandardDeviation.ToString("0.00"),
|
||||
Rate(x.PassRate),
|
||||
Rate(x.ExcellentRate)
|
||||
}),
|
||||
[2600, 820, 1050, 1050, 1050, 1395, 1395]));
|
||||
|
||||
body.Append(Heading("四、各范围基准", 1));
|
||||
body.Append(Paragraph("范围基准按当前课程、当前学期聚合;同一教学班包含多个来源行政班时,将分别列示可用基准。", 22, false, Muted, 0, 100));
|
||||
body.Append(DataTable(
|
||||
["范围", "对象", "人数", "最高分", "平均分", "最低分", "合格率"],
|
||||
report.ScopeBenchmarks.Select(x => new[]
|
||||
{
|
||||
x.Scope, x.Name, x.StudentCount.ToString(), Score(x.HighestScore),
|
||||
Score(x.AverageScore), Score(x.LowestScore), Rate(x.PassRate)
|
||||
}),
|
||||
[980, 2200, 900, 1200, 1200, 1200, 1680]));
|
||||
|
||||
body.Append(Heading("五、历年成绩趋势", 1));
|
||||
body.Append(Paragraph("图 3 同课程全校与当前任课教师历年平均分", 20, false, Muted, 80, 80));
|
||||
body.Append(ImageParagraph(mainPart, DrawHistory(report.History), "历年平均分趋势图", 6.3, 3.15));
|
||||
body.Append(DataTable(
|
||||
["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"],
|
||||
report.History.Select(x => new[]
|
||||
{
|
||||
x.TermName,
|
||||
x.CourseStudentCount.ToString(),
|
||||
Score(x.CourseAverageScore),
|
||||
Rate(x.CoursePassRate),
|
||||
x.Instructor?.StudentCount.ToString() ?? "—",
|
||||
x.Instructor is null ? "—" : Score(x.Instructor.AverageScore),
|
||||
x.Instructor is null ? "—" : Rate(x.Instructor.PassRate)
|
||||
}),
|
||||
[1700, 1050, 1200, 1450, 1050, 1200, 1710]));
|
||||
|
||||
body.Append(Heading("六、统计口径与使用说明", 1));
|
||||
body.Append(Paragraph("1. 本报告仅统计已正式发布且纳入当前统计任务的有效成绩,不包含草稿、未发布成绩或学生逐人成绩明细。", 22, false, Ink, 0, 80));
|
||||
body.Append(Paragraph("2. 合格率按成绩达到 60 分计算,优秀率按成绩达到 90 分计算;平均分、中位数和标准差均基于同一批有效成绩。", 22, false, Ink, 0, 80));
|
||||
body.Append(Paragraph("3. 同课程教学班对比限定为当前学期;历年对比同时展示课程全校口径和当前任课教师所带教学班的加权汇总。", 22, false, Ink, 0, 80));
|
||||
body.Append(Paragraph("4. 统计结果用于教学诊断和质量改进,不应脱离样本量、课程难度、考核方式等背景作单一排名或评价。", 22, false, Ink, 0, 80));
|
||||
|
||||
body.Append(new SectionProperties(
|
||||
new HeaderReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultHeader },
|
||||
new HeaderReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenHeader },
|
||||
new FooterReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultFooter },
|
||||
new FooterReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenFooter },
|
||||
new PageSize { Width = 12240, Height = 15840 },
|
||||
new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 708, Footer = 708 }));
|
||||
}
|
||||
|
||||
private static string BuildExecutiveSummary(GradeAnalyticsController.TeachingClassAnalysisReport report)
|
||||
{
|
||||
var summary = report.Summary!;
|
||||
var band = summary.ScoreBands.OrderByDescending(x => x.StudentCount).FirstOrDefault();
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"本教学班共纳入 {summary.StudentCount} 份有效成绩,平均分 {Score(summary.AverageScore)},中位数 {Score(summary.MedianScore)},合格率 {Rate(summary.PassRate)},优秀率 {Rate(summary.ExcellentRate)}。"
|
||||
};
|
||||
if (band is not null)
|
||||
parts.Add($"人数最多的分数段为 {band.Label},共 {band.StudentCount} 人,占 {Percent(band.StudentCount, summary.StudentCount)}。 ");
|
||||
if (report.UniversityDelta is { } delta)
|
||||
parts.Add($"与本学期全校同课程相比,平均分{Direction(delta.AverageScoreDifference, "分")},合格率{Direction(delta.PassRateDifference, "个百分点")}。 ");
|
||||
parts.Add($"成绩标准差为 {summary.StandardDeviation:0.00},分数范围 {Score(summary.LowestScore)}–{Score(summary.HighestScore)}。建议结合分数段、同课程教学班和历年趋势综合研判。 ");
|
||||
return string.Concat(parts);
|
||||
}
|
||||
|
||||
private static string Direction(decimal value, string unit) =>
|
||||
value > 0 ? $"高 {value:0.0} {unit}" : value < 0 ? $"低 {Math.Abs(value):0.0} {unit}" : "持平";
|
||||
|
||||
private static W.Table MetadataTable(IEnumerable<(string Label, string Value)> items)
|
||||
{
|
||||
var rows = items.Select(x => new[] { x.Label, x.Value });
|
||||
return DataTable(["项目", "内容"], rows, [1800, 7560], false);
|
||||
}
|
||||
|
||||
private static W.Table MetricsTable(GradeAnalyticsController.TeachingClassMetrics value)
|
||||
{
|
||||
return DataTable(
|
||||
["指标", "结果", "指标", "结果"],
|
||||
[
|
||||
["最高分", Score(value.HighestScore), "最低分", Score(value.LowestScore)],
|
||||
["平均分", Score(value.AverageScore), "中位数", Score(value.MedianScore)],
|
||||
["合格人数", $"{value.PassedCount} 人", "合格率", Rate(value.PassRate)],
|
||||
["优秀人数", $"{value.ExcellentCount} 人", "优秀率", Rate(value.ExcellentRate)],
|
||||
["标准差", value.StandardDeviation.ToString("0.00"), "有效成绩", $"{value.StudentCount} 份"]
|
||||
],
|
||||
[1800, 2880, 1800, 2880]);
|
||||
}
|
||||
|
||||
private static W.Table DataTable(
|
||||
IReadOnlyList<string> headers,
|
||||
IEnumerable<string[]> rows,
|
||||
IReadOnlyList<int> widths,
|
||||
bool shadeHeader = true)
|
||||
{
|
||||
var table = new W.Table();
|
||||
table.Append(new TableProperties(
|
||||
new TableWidth { Width = ContentWidth.ToString(), Type = TableWidthUnitValues.Dxa },
|
||||
new TableIndentation { Width = 120, Type = TableWidthUnitValues.Dxa },
|
||||
new TableLayout { Type = TableLayoutValues.Fixed },
|
||||
new TableBorders(
|
||||
Border<TopBorder>(), Border<LeftBorder>(), Border<BottomBorder>(),
|
||||
Border<RightBorder>(), Border<InsideHorizontalBorder>(), Border<InsideVerticalBorder>()),
|
||||
new TableCellMarginDefault(
|
||||
new TopMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
|
||||
new TableCellLeftMargin { Width = 120, Type = TableWidthValues.Dxa },
|
||||
new BottomMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
|
||||
new TableCellRightMargin { Width = 120, Type = TableWidthValues.Dxa })));
|
||||
table.Append(new TableGrid(widths.Select(x => new GridColumn { Width = x.ToString() })));
|
||||
table.Append(Row(headers, widths, shadeHeader ? LightFill : "FFFFFF", true, true));
|
||||
foreach (var row in rows)
|
||||
table.Append(Row(row, widths, "FFFFFF", false, false));
|
||||
return table;
|
||||
}
|
||||
|
||||
private static TableRow Row(
|
||||
IReadOnlyList<string> values,
|
||||
IReadOnlyList<int> widths,
|
||||
string fill,
|
||||
bool bold,
|
||||
bool repeat)
|
||||
{
|
||||
var row = new TableRow();
|
||||
if (repeat) row.AppendChild(new TableRowProperties(new TableHeader()));
|
||||
for (var i = 0; i < widths.Count; i++)
|
||||
{
|
||||
var cell = new TableCell();
|
||||
cell.Append(new TableCellProperties(
|
||||
new TableCellWidth { Width = widths[i].ToString(), Type = TableWidthUnitValues.Dxa },
|
||||
new Shading { Fill = fill, Val = ShadingPatternValues.Clear }));
|
||||
var lines = (i < values.Count ? values[i] : "").Split('\n');
|
||||
foreach (var line in lines)
|
||||
cell.Append(Paragraph(line, 19, bold, Ink, 0, 0));
|
||||
row.Append(cell);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static T Border<T>() where T : BorderType, new() =>
|
||||
new() { Val = BorderValues.Single, Color = "D6DBE1", Size = 4 };
|
||||
|
||||
private static W.Table Callout(string text)
|
||||
{
|
||||
return DataTable(["核心结论"], [[text]], [ContentWidth]);
|
||||
}
|
||||
|
||||
private static Paragraph Heading(string text, int level)
|
||||
{
|
||||
var paragraph = new Paragraph(new ParagraphProperties(new ParagraphStyleId { Val = $"Heading{level}" }));
|
||||
paragraph.Append(Run(text, level == 1 ? 32 : 26, true, level == 1 ? Blue : DarkBlue));
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
private static Paragraph Paragraph(
|
||||
string text,
|
||||
int size,
|
||||
bool bold,
|
||||
string color,
|
||||
int before,
|
||||
int after)
|
||||
{
|
||||
var paragraph = new Paragraph(new ParagraphProperties(
|
||||
new SpacingBetweenLines { Before = before.ToString(), After = after.ToString(), Line = "264", LineRule = LineSpacingRuleValues.Auto }));
|
||||
paragraph.Append(Run(text, size, bold, color));
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
private static Run Run(string text, int size, bool bold, string color)
|
||||
{
|
||||
return new Run(
|
||||
new RunProperties(
|
||||
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
|
||||
new Bold { Val = bold },
|
||||
new Color { Val = color },
|
||||
new FontSize { Val = size.ToString() },
|
||||
new FontSizeComplexScript { Val = size.ToString() }),
|
||||
new Text(text) { Space = SpaceProcessingModeValues.Preserve });
|
||||
}
|
||||
|
||||
private static Paragraph ImageParagraph(
|
||||
MainDocumentPart mainPart,
|
||||
byte[] image,
|
||||
string description,
|
||||
double widthInches,
|
||||
double heightInches)
|
||||
{
|
||||
var part = mainPart.AddImagePart(ImagePartType.Png);
|
||||
using (var stream = new MemoryStream(image)) part.FeedData(stream);
|
||||
var relationshipId = mainPart.GetIdOfPart(part);
|
||||
var width = (long)(widthInches * 914400L);
|
||||
var height = (long)(heightInches * 914400L);
|
||||
var drawing = new W.Drawing(
|
||||
new DW.Inline(
|
||||
new DW.Extent { Cx = width, Cy = height },
|
||||
new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 },
|
||||
new DW.DocProperties { Id = (UInt32Value)(uint)(mainPart.ImageParts.Count()), Name = description, Description = description },
|
||||
new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }),
|
||||
new A.Graphic(new A.GraphicData(
|
||||
new PIC.Picture(
|
||||
new PIC.NonVisualPictureProperties(
|
||||
new PIC.NonVisualDrawingProperties { Id = 0, Name = description, Description = description },
|
||||
new PIC.NonVisualPictureDrawingProperties()),
|
||||
new PIC.BlipFill(
|
||||
new A.Blip { Embed = relationshipId, CompressionState = A.BlipCompressionValues.Print },
|
||||
new A.Stretch(new A.FillRectangle())),
|
||||
new PIC.ShapeProperties(
|
||||
new A.Transform2D(
|
||||
new A.Offset { X = 0, Y = 0 },
|
||||
new A.Extents { Cx = width, Cy = height }),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })))
|
||||
{ Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" }))
|
||||
{ DistanceFromTop = 0, DistanceFromBottom = 0, DistanceFromLeft = 0, DistanceFromRight = 0 });
|
||||
var paragraph = new Paragraph(new ParagraphProperties(
|
||||
new Justification { Val = JustificationValues.Center },
|
||||
new SpacingBetweenLines { Before = "0", After = "120" }));
|
||||
paragraph.Append(new Run(drawing));
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
private static void AddStyles(MainDocumentPart mainPart)
|
||||
{
|
||||
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
|
||||
var normal = new Style(
|
||||
new StyleName { Val = "Normal" },
|
||||
new StyleRunProperties(
|
||||
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
|
||||
new FontSize { Val = "22" }, new Color { Val = Ink }),
|
||||
new StyleParagraphProperties(
|
||||
new SpacingBetweenLines { Before = "0", After = "120", Line = "264", LineRule = LineSpacingRuleValues.Auto }))
|
||||
{ Type = StyleValues.Paragraph, StyleId = "Normal", Default = true };
|
||||
var h1 = new Style(
|
||||
new StyleName { Val = "heading 1" },
|
||||
new BasedOn { Val = "Normal" },
|
||||
new NextParagraphStyle { Val = "Normal" },
|
||||
new StyleRunProperties(new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, new Bold(), new Color { Val = Blue }, new FontSize { Val = "32" }),
|
||||
new StyleParagraphProperties(new KeepNext(), new SpacingBetweenLines { Before = "320", After = "160" }))
|
||||
{ Type = StyleValues.Paragraph, StyleId = "Heading1" };
|
||||
stylesPart.Styles = new Styles(normal, h1);
|
||||
stylesPart.Styles.Save();
|
||||
}
|
||||
|
||||
private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart)
|
||||
{
|
||||
var defaultHeader = mainPart.AddNewPart<HeaderPart>();
|
||||
defaultHeader.Header = CreateHeader();
|
||||
defaultHeader.Header.Save();
|
||||
var evenHeader = mainPart.AddNewPart<HeaderPart>();
|
||||
evenHeader.Header = CreateHeader();
|
||||
evenHeader.Header.Save();
|
||||
var defaultFooter = mainPart.AddNewPart<FooterPart>();
|
||||
defaultFooter.Footer = CreateFooter();
|
||||
defaultFooter.Footer.Save();
|
||||
var evenFooter = mainPart.AddNewPart<FooterPart>();
|
||||
evenFooter.Footer = CreateFooter();
|
||||
evenFooter.Footer.Save();
|
||||
return new HeaderFooterIds(
|
||||
mainPart.GetIdOfPart(defaultHeader),
|
||||
mainPart.GetIdOfPart(evenHeader),
|
||||
mainPart.GetIdOfPart(defaultFooter),
|
||||
mainPart.GetIdOfPart(evenFooter));
|
||||
}
|
||||
|
||||
private static Header CreateHeader() =>
|
||||
new(Paragraph("成绩分析报告 | 教务管理系统", 18, false, Muted, 0, 0));
|
||||
|
||||
private static Footer CreateFooter()
|
||||
{
|
||||
var footerParagraph = new Paragraph(new ParagraphProperties(new Justification { Val = JustificationValues.Right }));
|
||||
footerParagraph.Append(Run("第 ", 18, false, Muted));
|
||||
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
|
||||
footerParagraph.Append(new Run(new FieldCode(" PAGE ")));
|
||||
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
|
||||
footerParagraph.Append(Run(" 页", 18, false, Muted));
|
||||
return new Footer(footerParagraph);
|
||||
}
|
||||
|
||||
private static byte[] DrawScoreBands(IReadOnlyList<GradeAnalyticsController.ScoreBand> rows)
|
||||
{
|
||||
return DrawChart(1200, 540, (canvas, typeface) =>
|
||||
{
|
||||
DrawAxes(canvas, typeface, "人数", 70, 35, 1080, 430);
|
||||
var max = Math.Max(1, rows.Max(x => x.StudentCount));
|
||||
var barWidth = 150f;
|
||||
var gap = (1000f - rows.Count * barWidth) / Math.Max(1, rows.Count);
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var x = 105 + gap / 2 + i * (barWidth + gap);
|
||||
var height = rows[i].StudentCount / (float)max * 330;
|
||||
using var paint = new SKPaint { Color = new SKColor(46, 116, 181), IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRect(x, 430 - height, x + barWidth, 430), 8, 8, paint);
|
||||
DrawText(canvas, typeface, rows[i].StudentCount.ToString(), x + barWidth / 2, 415 - height, 24, Ink, SKTextAlign.Center, true);
|
||||
DrawText(canvas, typeface, rows[i].Label, x + barWidth / 2, 475, 18, Muted, SKTextAlign.Center);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] DrawPeerAverages(IReadOnlyList<GradeAnalyticsController.TeachingClassComparison> rows)
|
||||
{
|
||||
var visible = rows.Take(8).ToArray();
|
||||
var height = Math.Max(250, 120 + visible.Length * 62);
|
||||
return DrawChart(1200, height, (canvas, typeface) =>
|
||||
{
|
||||
var top = 55f;
|
||||
var rowHeight = 62f;
|
||||
DrawText(canvas, typeface, "0", 280, 40, 20, Muted, SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, "50", 700, 40, 20, Muted, SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, "100", 1120, 40, 20, Muted, SKTextAlign.Center);
|
||||
for (var i = 0; i < visible.Length; i++)
|
||||
{
|
||||
var y = top + i * rowHeight;
|
||||
var value = Math.Clamp((float)visible[i].AverageScore, 0, 100);
|
||||
DrawText(canvas, typeface, visible[i].TaskNumber, 245, y + 28, 21, visible[i].IsSelected ? Blue : Ink, SKTextAlign.Right, visible[i].IsSelected);
|
||||
using var track = new SKPaint { Color = new SKColor(235, 239, 244) };
|
||||
using var fill = new SKPaint { Color = visible[i].IsSelected ? new SKColor(46, 116, 181) : new SKColor(155, 177, 202) };
|
||||
canvas.DrawRoundRect(new SKRect(280, y, 1120, y + 34), 6, 6, track);
|
||||
canvas.DrawRoundRect(new SKRect(280, y, 280 + value / 100 * 840, y + 34), 6, 6, fill);
|
||||
DrawText(canvas, typeface, value.ToString("0.0"), 290 + value / 100 * 840, y + 27, 20, Ink, SKTextAlign.Left, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] DrawHistory(IReadOnlyList<GradeAnalyticsController.HistoricalComparison> rows)
|
||||
{
|
||||
return DrawChart(1200, 570, (canvas, typeface) =>
|
||||
{
|
||||
DrawAxes(canvas, typeface, "平均分", 70, 35, 1080, 430);
|
||||
if (rows.Count == 0) return;
|
||||
var min = 0f;
|
||||
var max = 100f;
|
||||
var points = rows.Select((x, i) => new SKPoint(
|
||||
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
|
||||
430 - ((float)x.CourseAverageScore - min) / (max - min) * 350)).ToArray();
|
||||
DrawLineSeries(canvas, typeface, points, rows.Select(x => x.CourseAverageScore).ToArray(), new SKColor(46, 116, 181));
|
||||
var teacher = rows.Select((x, i) => x.Instructor is null ? (SKPoint?)null : new SKPoint(
|
||||
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
|
||||
430 - ((float)x.Instructor.AverageScore - min) / (max - min) * 350)).ToArray();
|
||||
DrawOptionalLineSeries(canvas, typeface, teacher, rows.Select(x => x.Instructor?.AverageScore).ToArray(), new SKColor(211, 133, 45));
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
DrawText(canvas, typeface, rows[i].TermName, points[i].X, 480, 20, Muted, SKTextAlign.Center);
|
||||
using var blue = new SKPaint { Color = new SKColor(46, 116, 181), StrokeWidth = 4 };
|
||||
using var gold = new SKPaint { Color = new SKColor(211, 133, 45), StrokeWidth = 4 };
|
||||
canvas.DrawLine(760, 520, 805, 520, blue);
|
||||
canvas.DrawLine(940, 520, 985, 520, gold);
|
||||
DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink);
|
||||
DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink);
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] DrawChart(int width, int height, Action<SKCanvas, SKTypeface> draw)
|
||||
{
|
||||
using var bitmap = new SKBitmap(width, height);
|
||||
using var canvas = new SKCanvas(bitmap);
|
||||
canvas.Clear(SKColors.White);
|
||||
using var typeface = SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.Default;
|
||||
draw(canvas, typeface);
|
||||
using var image = SKImage.FromBitmap(bitmap);
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 92);
|
||||
return data.ToArray();
|
||||
}
|
||||
|
||||
private static void DrawAxes(SKCanvas canvas, SKTypeface typeface, string label, float left, float top, float right, float bottom)
|
||||
{
|
||||
using var axis = new SKPaint { Color = new SKColor(190, 198, 207), StrokeWidth = 2 };
|
||||
canvas.DrawLine(left, bottom, right, bottom, axis);
|
||||
canvas.DrawLine(left, top, left, bottom, axis);
|
||||
DrawText(canvas, typeface, label, left, 25, 21, Muted);
|
||||
}
|
||||
|
||||
private static void DrawLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint[] points, decimal[] values, SKColor color)
|
||||
{
|
||||
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
||||
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
||||
using var 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++)
|
||||
{
|
||||
canvas.DrawCircle(points[i], 7, fill);
|
||||
DrawText(canvas, typeface, values[i].ToString("0.0"), points[i].X, points[i].Y - 14, 19, Ink, SKTextAlign.Center, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawOptionalLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint?[] points, decimal?[] values, SKColor color)
|
||||
{
|
||||
var available = points.Select((point, index) => (point, index)).Where(x => x.point.HasValue).ToArray();
|
||||
if (available.Length == 0) return;
|
||||
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
||||
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
||||
for (var i = 1; i < available.Length; i++) canvas.DrawLine(available[i - 1].point!.Value, available[i].point!.Value, paint);
|
||||
foreach (var item in available)
|
||||
{
|
||||
var point = item.point!.Value;
|
||||
canvas.DrawCircle(point, 7, fill);
|
||||
DrawText(canvas, typeface, values[item.index]!.Value.ToString("0.0"), point.X, point.Y + 28, 19, Ink, SKTextAlign.Center, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, float size, string color, SKTextAlign align = SKTextAlign.Left, bool bold = false)
|
||||
{
|
||||
using var font = new SKFont(typeface, size) { Embolden = bold };
|
||||
using var paint = new SKPaint { Color = SKColor.Parse(color), IsAntialias = true };
|
||||
canvas.DrawText(text, x, y, align, font, paint);
|
||||
}
|
||||
|
||||
private static string Score(decimal value) => value.ToString("0.0");
|
||||
private static string Rate(decimal value) => $"{value:0.0}%";
|
||||
private static string Percent(int value, int total) => total == 0 ? "0.0%" : $"{(decimal)value / total * 100m:0.0}%";
|
||||
|
||||
private sealed record HeaderFooterIds(
|
||||
string DefaultHeader,
|
||||
string EvenHeader,
|
||||
string DefaultFooter,
|
||||
string EvenFooter);
|
||||
}
|
||||
@@ -0,0 +1,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 =>
|
||||
@@ -66,6 +75,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
|
||||
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
|
||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
|
||||
Set<CourseGradeStatistic>();
|
||||
public DbSet<TeachingTaskGradeStatistic> TeachingTaskGradeStatistics =>
|
||||
Set<TeachingTaskGradeStatistic>();
|
||||
public DbSet<TeachingTaskGradeScoreBand> TeachingTaskGradeScoreBands =>
|
||||
Set<TeachingTaskGradeScoreBand>();
|
||||
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
|
||||
Set<CourseGradeStatisticsRefreshJob>();
|
||||
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
|
||||
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
|
||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||
@@ -127,6 +146,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<BackgroundJobOutboxMessage>();
|
||||
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||
Set<AppUpdateRelease>();
|
||||
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
||||
Set<SystemFeatureSetting>();
|
||||
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
|
||||
Set<CourseGradeStatisticsRefreshSetting>();
|
||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
@@ -145,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)
|
||||
@@ -450,7 +481,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<ScheduleEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind)
|
||||
.HasDefaultValue(ScheduleEntryKind.Lecture);
|
||||
.HasDefaultValue(ScheduleEntryKind.Lecture)
|
||||
.HasSentinel((ScheduleEntryKind)0);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
@@ -498,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 =>
|
||||
@@ -517,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);
|
||||
@@ -601,11 +692,26 @@ 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)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasIndex(x => x.ScheduleEntryId);
|
||||
entity.HasOne(x => x.ScheduleEntry).WithMany()
|
||||
.HasForeignKey(x => x.ScheduleEntryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentSession>(entity =>
|
||||
@@ -703,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
|
||||
@@ -802,7 +936,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Name).HasMaxLength(60);
|
||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||
entity.Property(x => x.SourceType)
|
||||
.HasDefaultValue(GradeItemSourceType.Manual);
|
||||
.HasDefaultValue(GradeItemSourceType.Manual)
|
||||
.HasSentinel((GradeItemSourceType)0);
|
||||
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
||||
entity.HasOne(x => x.GradeSheet)
|
||||
.WithMany(x => x.Items)
|
||||
@@ -843,6 +978,72 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CourseGradeStatistic>(entity =>
|
||||
{
|
||||
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.PassRate).HasPrecision(5, 2);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.CourseId, x.AcademicTermId, x.Scope, x.ScopeEntityId
|
||||
}).IsUnique().HasDatabaseName("UX_CourseGradeStatistics_Scope");
|
||||
entity.HasIndex(x => new { x.AcademicTermId, x.Scope, x.ScopeEntityId });
|
||||
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<TeachingTaskGradeStatistic>(entity =>
|
||||
{
|
||||
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.MedianScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.StandardDeviation).HasPrecision(6, 2);
|
||||
entity.Property(x => x.PassRate).HasPrecision(5, 2);
|
||||
entity.Property(x => x.ExcellentRate).HasPrecision(5, 2);
|
||||
entity.HasIndex(x => x.GradeSheetId).IsUnique();
|
||||
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
|
||||
entity.HasIndex(x => new { x.CourseId, x.AcademicTermId });
|
||||
entity.HasOne(x => x.GradeSheet).WithOne()
|
||||
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.GradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithOne()
|
||||
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.TeachingTaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<TeachingTaskGradeScoreBand>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Label).HasMaxLength(30);
|
||||
entity.Property(x => x.LowerBound).HasPrecision(5, 1);
|
||||
entity.Property(x => x.UpperBound).HasPrecision(5, 1);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.TeachingTaskGradeStatisticId,
|
||||
x.SortOrder
|
||||
}).IsUnique();
|
||||
entity.HasOne(x => x.TeachingTaskGradeStatistic)
|
||||
.WithMany(x => x.ScoreBands)
|
||||
.HasForeignKey(x => x.TeachingTaskGradeStatisticId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.GradeSheetId);
|
||||
entity.HasOne<GradeSheet>().WithMany().HasForeignKey(x => x.GradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AttendanceSheet>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -910,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);
|
||||
@@ -936,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
|
||||
@@ -1024,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);
|
||||
@@ -1186,6 +1396,27 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.GradeRecord).WithMany()
|
||||
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<OtherExamBatch>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ExamCode).HasMaxLength(60);
|
||||
entity.Property(x => x.Name).HasMaxLength(150);
|
||||
entity.Property(x => x.Organizer).HasMaxLength(150);
|
||||
entity.Property(x => x.LevelOptions).HasMaxLength(500);
|
||||
entity.Property(x => x.MaxScore).HasPrecision(8, 2);
|
||||
entity.HasIndex(x => new { x.Status, x.ExamDate });
|
||||
});
|
||||
builder.Entity<OtherExamResult>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Score).HasPrecision(8, 2);
|
||||
entity.Property(x => x.Level).HasMaxLength(50);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.OtherExamBatchId, x.StudentId, x.AttemptNumber }).IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.OtherExamBatchId });
|
||||
entity.HasOne(x => x.OtherExamBatch).WithMany(x => x.Results)
|
||||
.HasForeignKey(x => x.OtherExamBatchId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<WarningRule>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(100);
|
||||
@@ -1278,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)
|
||||
@@ -1352,6 +1586,18 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
|
||||
builder.Entity<SystemFeatureSetting>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Key).HasMaxLength(100);
|
||||
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);
|
||||
@@ -1434,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));
|
||||
|
||||
@@ -82,6 +82,30 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260803_43_refresh_sessions";
|
||||
private const string StudentPersonalProfileMigration =
|
||||
"20260803_44_student_personal_profile";
|
||||
private const string OtherExamResultsMigration =
|
||||
"20260808_45_other_exam_results";
|
||||
private const string CourseGradeStatisticsMigration =
|
||||
"20260808_46_course_grade_statistics";
|
||||
private const string CourseGradeDistributionMigration =
|
||||
"20260809_47_course_grade_distribution";
|
||||
private const string TeachingTaskGradeAnalyticsMigration =
|
||||
"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)
|
||||
{
|
||||
@@ -613,6 +637,127 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
StudentPersonalProfileMigration,
|
||||
studentPersonalProfileExists ? [] : StudentPersonalProfileStatements,
|
||||
cancellationToken);
|
||||
var otherExamCodeExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('OtherExamBatches')
|
||||
WHERE name = 'ExamCode'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
var otherExamBatchExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'OtherExamBatches'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
OtherExamResultsMigration,
|
||||
!otherExamBatchExists
|
||||
? OtherExamResultsStatements.Skip(1)
|
||||
: otherExamCodeExists
|
||||
? OtherExamResultsStatements.Skip(1)
|
||||
: OtherExamResultsStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
CourseGradeStatisticsMigration,
|
||||
CourseGradeStatisticsStatements,
|
||||
cancellationToken);
|
||||
var courseGradeStatisticColumns = (await db.Database
|
||||
.SqlQueryRaw<string>(
|
||||
"""
|
||||
SELECT name AS "Value"
|
||||
FROM pragma_table_info('CourseGradeStatistics')
|
||||
""")
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var missingDistributionStatements = CourseGradeDistributionColumns
|
||||
.Select((column, index) => new { column, index })
|
||||
.Where(x => !courseGradeStatisticColumns.Contains(x.column))
|
||||
.Select(x => CourseGradeDistributionStatements[x.index]);
|
||||
await ApplyMigrationAsync(
|
||||
CourseGradeDistributionMigration,
|
||||
missingDistributionStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
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'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
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(
|
||||
@@ -2173,6 +2318,56 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""ALTER TABLE "Students" ADD COLUMN "WeChat" TEXT NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] OtherExamResultsStatements =
|
||||
[
|
||||
"""ALTER TABLE "OtherExamBatches" ADD COLUMN "ExamCode" TEXT NULL;""",
|
||||
"""CREATE TABLE IF NOT EXISTS "OtherExamBatches" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamBatches" PRIMARY KEY, "ExamCode" TEXT NULL, "Name" TEXT NOT NULL, "Organizer" TEXT NULL, "ExamDate" TEXT NOT NULL, "MetricKind" INTEGER NOT NULL, "MaxScore" TEXT NULL, "LevelOptions" TEXT NULL, "Status" INTEGER NOT NULL, "PublicationCount" INTEGER NOT NULL, "PublishedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamBatches_Status_ExamDate" ON "OtherExamBatches" ("Status", "ExamDate");""",
|
||||
"""CREATE TABLE IF NOT EXISTS "OtherExamResults" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamResults" PRIMARY KEY, "OtherExamBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "AttemptNumber" INTEGER NOT NULL, "Score" TEXT NULL, "Level" TEXT NULL, "IsPassed" INTEGER NULL, "Notes" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_OtherExamResults_OtherExamBatches" FOREIGN KEY ("OtherExamBatchId") REFERENCES "OtherExamBatches" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_OtherExamResults_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""",
|
||||
"""DROP INDEX IF EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber";""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId" ON "OtherExamResults" ("OtherExamBatchId", "StudentId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamResults_StudentId_OtherExamBatchId" ON "OtherExamResults" ("StudentId", "OtherExamBatchId");"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeStatisticsStatements =
|
||||
[
|
||||
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatistics" PRIMARY KEY, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Scope" INTEGER NOT NULL, "ScopeEntityId" TEXT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatistics_Courses_CourseId" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "UX_CourseGradeStatistics_Scope" ON "CourseGradeStatistics" ("CourseId", "AcademicTermId", "Scope", "ScopeEntityId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId" ON "CourseGradeStatistics" ("AcademicTermId", "Scope", "ScopeEntityId");""",
|
||||
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshJobs" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshJobs" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "ErrorMessage" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE);""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId" ON "CourseGradeStatisticsRefreshJobs" ("GradeSheetId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt" ON "CourseGradeStatisticsRefreshJobs" ("Status", "CreatedAt");"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeDistributionStatements =
|
||||
[
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "Below60Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From60To69Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From70To79Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From80To89Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From90To100Count" INTEGER NOT NULL DEFAULT 0;"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeDistributionColumns =
|
||||
[
|
||||
"Below60Count",
|
||||
"From60To69Count",
|
||||
"From70To79Count",
|
||||
"From80To89Count",
|
||||
"From90To100Count"
|
||||
];
|
||||
|
||||
private static readonly string[] TeachingTaskGradeAnalyticsStatements =
|
||||
[
|
||||
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeStatistics" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "ExcellentCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "MedianScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "StandardDeviation" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "ExcellentRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeStatistics_GradeSheets" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_Courses" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_TeachingTaskGradeStatistics_AcademicTerms" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_GradeSheetId" ON "TeachingTaskGradeStatistics" ("GradeSheetId");""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_TeachingTaskId" ON "TeachingTaskGradeStatistics" ("TeachingTaskId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId" ON "TeachingTaskGradeStatistics" ("CourseId", "AcademicTermId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_AcademicTermId" ON "TeachingTaskGradeStatistics" ("AcademicTermId");""",
|
||||
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeScoreBands" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeScoreBands" PRIMARY KEY, "TeachingTaskGradeStatisticId" TEXT NOT NULL, "Label" TEXT NOT NULL, "LowerBound" TEXT NOT NULL, "UpperBound" TEXT NULL, "StudentCount" INTEGER NOT NULL, "SortOrder" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeScoreBands_Statistics" FOREIGN KEY ("TeachingTaskGradeStatisticId") REFERENCES "TeachingTaskGradeStatistics" ("Id") ON DELETE CASCADE);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeScoreBands_StatisticId_SortOrder" ON "TeachingTaskGradeScoreBands" ("TeachingTaskGradeStatisticId", "SortOrder");"""
|
||||
];
|
||||
|
||||
private static readonly string[] ApprovalTableStatements =
|
||||
[
|
||||
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
||||
@@ -2807,4 +3002,209 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] SwaggerDocumentationSettingStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "SystemFeatureSettings" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_SystemFeatureSettings" PRIMARY KEY,
|
||||
"Key" TEXT NOT NULL,
|
||||
"IsEnabled" INTEGER NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_SystemFeatureSettings_Key"
|
||||
ON "SystemFeatureSettings" ("Key");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeStatisticsRefreshSettingsStatements =
|
||||
[
|
||||
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshSettings" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshSettings" PRIMARY KEY, "Key" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "IntervalSeconds" INTEGER NOT NULL, "BatchSize" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
|
||||
];
|
||||
|
||||
private static readonly string[] NotificationInboxIndexesStatements =
|
||||
[
|
||||
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_IsRead";""",
|
||||
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_Category_CreatedAt";""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_CreatedAt_Id" ON "Notifications" ("UserId", "CreatedAt", "Id");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_IsRead_CreatedAt_Id" ON "Notifications" ("UserId", "IsRead", "CreatedAt", "Id");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_Category_CreatedAt_Id" ON "Notifications" ("UserId", "Category", "CreatedAt", "Id");"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExperimentCourseGradesStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "ExperimentCourseGrades" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentCourseGrades" PRIMARY KEY,
|
||||
"TeachingTaskId" TEXT NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"WeightedAverageScore" TEXT NULL,
|
||||
"TotalWeight" TEXT NOT NULL,
|
||||
"PublishedProjectCount" INTEGER NOT NULL,
|
||||
"RefreshedAt" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId"
|
||||
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExperimentCourseGrades_Students_StudentId"
|
||||
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""CREATE INDEX "IX_ExperimentCourseGrades_StudentId" ON "ExperimentCourseGrades" ("StudentId");""",
|
||||
"""CREATE UNIQUE INDEX "IX_ExperimentCourseGrades_TeachingTaskId_StudentId" ON "ExperimentCourseGrades" ("TeachingTaskId", "StudentId");""",
|
||||
"""
|
||||
INSERT INTO "ExperimentCourseGrades"
|
||||
("Id", "TeachingTaskId", "StudentId", "WeightedAverageScore",
|
||||
"TotalWeight", "PublishedProjectCount", "RefreshedAt",
|
||||
"CreatedAt", "UpdatedAt")
|
||||
SELECT
|
||||
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' ||
|
||||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||
substr('89ab', abs(random()) % 4 + 1, 1) ||
|
||||
substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))),
|
||||
project."TeachingTaskId",
|
||||
record."StudentId",
|
||||
CASE
|
||||
WHEN COUNT(*) = (
|
||||
SELECT COUNT(*)
|
||||
FROM "ExperimentGradeSheets" AS all_sheet
|
||||
INNER JOIN "ExperimentProjects" AS all_project
|
||||
ON all_project."Id" = all_sheet."ExperimentProjectId"
|
||||
WHERE all_sheet."Status" = 4
|
||||
AND all_project."TeachingTaskId" = project."TeachingTaskId")
|
||||
AND SUM(CASE WHEN record."TotalScore" IS NULL THEN 1 ELSE 0 END) = 0
|
||||
THEN ROUND(
|
||||
SUM(record."TotalScore" * sheet."ContributionWeight") /
|
||||
SUM(sheet."ContributionWeight"), 1)
|
||||
ELSE NULL
|
||||
END,
|
||||
(
|
||||
SELECT COALESCE(SUM(all_sheet."ContributionWeight"), 0)
|
||||
FROM "ExperimentGradeSheets" AS all_sheet
|
||||
INNER JOIN "ExperimentProjects" AS all_project
|
||||
ON all_project."Id" = all_sheet."ExperimentProjectId"
|
||||
WHERE all_sheet."Status" = 4
|
||||
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM "ExperimentGradeSheets" AS all_sheet
|
||||
INNER JOIN "ExperimentProjects" AS all_project
|
||||
ON all_project."Id" = all_sheet."ExperimentProjectId"
|
||||
WHERE all_sheet."Status" = 4
|
||||
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
|
||||
strftime('%Y-%m-%d %H:%M:%f', 'now'),
|
||||
strftime('%Y-%m-%d %H:%M:%f', 'now'),
|
||||
strftime('%Y-%m-%d %H:%M:%f', 'now')
|
||||
FROM "ExperimentGradeRecords" AS record
|
||||
INNER JOIN "ExperimentGradeSheets" AS sheet
|
||||
ON sheet."Id" = record."ExperimentGradeSheetId"
|
||||
INNER JOIN "ExperimentProjects" AS project
|
||||
ON project."Id" = sheet."ExperimentProjectId"
|
||||
WHERE sheet."Status" = 4
|
||||
GROUP BY project."TeachingTaskId", record."StudentId";
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExperimentClassroomConstraintStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "TeachingTaskAllowedExperimentClassrooms" (
|
||||
"TeachingTaskScheduleConstraintId" TEXT NOT NULL,
|
||||
"ClassroomId" TEXT NOT NULL,
|
||||
CONSTRAINT "PK_TeachingTaskAllowedExperimentClassrooms"
|
||||
PRIMARY KEY ("TeachingTaskScheduleConstraintId", "ClassroomId"),
|
||||
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Constraints"
|
||||
FOREIGN KEY ("TeachingTaskScheduleConstraintId")
|
||||
REFERENCES "TeachingTaskScheduleConstraints" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Classrooms"
|
||||
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_TeachingTaskAllowedExperimentClassrooms_ClassroomId"
|
||||
ON "TeachingTaskAllowedExperimentClassrooms" ("ClassroomId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] SeparateExperimentClassroomScopeStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "TeachingTaskScheduleConstraints"
|
||||
ADD COLUMN "ExperimentRequiredCampusId" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "TeachingTaskScheduleConstraints"
|
||||
ADD COLUMN "ExperimentRequiredBuildingId" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId"
|
||||
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredCampusId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId"
|
||||
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredBuildingId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] SelfScheduledExperimentSelectionStatements =
|
||||
[
|
||||
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionStartsAt\" TEXT NULL;",
|
||||
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionEndsAt\" TEXT NULL;",
|
||||
"""
|
||||
CREATE TABLE "ExperimentSessionInstructors" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessionInstructors" PRIMARY KEY,
|
||||
"ExperimentSessionId" TEXT NOT NULL,
|
||||
"TeacherId" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_ExperimentSessionInstructors_ExperimentSessions_ExperimentSessionId"
|
||||
FOREIGN KEY ("ExperimentSessionId") REFERENCES "ExperimentSessions" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ExperimentSessionInstructors_Teachers_TeacherId"
|
||||
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"CREATE UNIQUE INDEX \"IX_ExperimentSessionInstructors_ExperimentSessionId_TeacherId\" ON \"ExperimentSessionInstructors\" (\"ExperimentSessionId\", \"TeacherId\");",
|
||||
"CREATE INDEX \"IX_ExperimentSessionInstructors_TeacherId\" ON \"ExperimentSessionInstructors\" (\"TeacherId\");"
|
||||
];
|
||||
|
||||
private static readonly string[] ReusableCourseGroupsStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "CourseGroups" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroups" PRIMARY KEY,
|
||||
"Code" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Description" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_CourseGroups_Code" ON "CourseGroups" ("Code");
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE "CourseGroupCourses" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroupCourses" PRIMARY KEY,
|
||||
"CourseGroupId" TEXT NOT NULL,
|
||||
"CourseId" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CourseGroupCourses_CourseGroups_CourseGroupId"
|
||||
FOREIGN KEY ("CourseGroupId") REFERENCES "CourseGroups" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_CourseGroupCourses_Courses_CourseId"
|
||||
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_CourseGroupCourses_CourseGroupId_CourseId"
|
||||
ON "CourseGroupCourses" ("CourseGroupId", "CourseId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_CourseGroupCourses_CourseId" ON "CourseGroupCourses" ("CourseId");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+6241
File diff suppressed because it is too large
Load Diff
+97
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OtherExamResults : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtherExamBatches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Name = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false),
|
||||
Organizer = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true),
|
||||
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
MetricKind = table.Column<int>(type: "int", nullable: false),
|
||||
MaxScore = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
|
||||
LevelOptions = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
PublicationCount = table.Column<int>(type: "int", nullable: false),
|
||||
PublishedAt = 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_OtherExamBatches", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtherExamResults",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
OtherExamBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AttemptNumber = table.Column<int>(type: "int", nullable: false),
|
||||
Score = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
|
||||
Level = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true),
|
||||
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
|
||||
Notes = 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_OtherExamResults", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtherExamResults_OtherExamBatches_OtherExamBatchId",
|
||||
column: x => x.OtherExamBatchId,
|
||||
principalTable: "OtherExamBatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtherExamResults_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamBatches_Status_ExamDate",
|
||||
table: "OtherExamBatches",
|
||||
columns: new[] { "Status", "ExamDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber",
|
||||
table: "OtherExamResults",
|
||||
columns: new[] { "OtherExamBatchId", "StudentId", "AttemptNumber" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamResults_StudentId_OtherExamBatchId",
|
||||
table: "OtherExamResults",
|
||||
columns: new[] { "StudentId", "OtherExamBatchId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtherExamResults");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtherExamBatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6245
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OtherExamIdentityAndImport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExamCode",
|
||||
table: "OtherExamBatches",
|
||||
type: "varchar(60)",
|
||||
maxLength: 60,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExamCode",
|
||||
table: "OtherExamBatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6381
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 CourseGradeStatistics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CourseGradeStatistics",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Scope = table.Column<int>(type: "int", nullable: false),
|
||||
ScopeEntityId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
StudentCount = table.Column<int>(type: "int", nullable: false),
|
||||
PassedCount = table.Column<int>(type: "int", nullable: false),
|
||||
Below60Count = table.Column<int>(type: "int", nullable: false),
|
||||
From60To69Count = table.Column<int>(type: "int", nullable: false),
|
||||
From70To79Count = table.Column<int>(type: "int", nullable: false),
|
||||
From80To89Count = table.Column<int>(type: "int", nullable: false),
|
||||
From90To100Count = table.Column<int>(type: "int", nullable: false),
|
||||
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
|
||||
CalculatedAt = 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_CourseGradeStatistics", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId",
|
||||
column: x => x.AcademicTermId,
|
||||
principalTable: "AcademicTerms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_CourseGradeStatistics_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CourseGradeStatisticsRefreshJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CourseGradeStatisticsRefreshJobs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId",
|
||||
column: x => x.GradeSheetId,
|
||||
principalTable: "GradeSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId",
|
||||
table: "CourseGradeStatistics",
|
||||
columns: new[] { "AcademicTermId", "Scope", "ScopeEntityId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_CourseGradeStatistics_Scope",
|
||||
table: "CourseGradeStatistics",
|
||||
columns: new[] { "CourseId", "AcademicTermId", "Scope", "ScopeEntityId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId",
|
||||
table: "CourseGradeStatisticsRefreshJobs",
|
||||
column: "GradeSheetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt",
|
||||
table: "CourseGradeStatisticsRefreshJobs",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CourseGradeStatistics");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CourseGradeStatisticsRefreshJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6549
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TeachingTaskGradeAnalytics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeachingTaskGradeStatistics",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentCount = table.Column<int>(type: "int", nullable: false),
|
||||
PassedCount = table.Column<int>(type: "int", nullable: false),
|
||||
ExcellentCount = table.Column<int>(type: "int", nullable: false),
|
||||
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
MedianScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
StandardDeviation = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
|
||||
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
|
||||
ExcellentRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
|
||||
CalculatedAt = 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_TeachingTaskGradeStatistics", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_AcademicTerms_AcademicTermId",
|
||||
column: x => x.AcademicTermId,
|
||||
principalTable: "AcademicTerms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_GradeSheets_GradeSheetId",
|
||||
column: x => x.GradeSheetId,
|
||||
principalTable: "GradeSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_TeachingTasks_TeachingTaskId",
|
||||
column: x => x.TeachingTaskId,
|
||||
principalTable: "TeachingTasks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeachingTaskGradeScoreBands",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeachingTaskGradeStatisticId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Label = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
|
||||
LowerBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
UpperBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
StudentCount = table.Column<int>(type: "int", nullable: false),
|
||||
SortOrder = 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_TeachingTaskGradeScoreBands", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeScoreBands_TeachingTaskGradeStatistics_Teac~",
|
||||
column: x => x.TeachingTaskGradeStatisticId,
|
||||
principalTable: "TeachingTaskGradeStatistics",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeScoreBands_TeachingTaskGradeStatisticId_Sor~",
|
||||
table: "TeachingTaskGradeScoreBands",
|
||||
columns: new[] { "TeachingTaskGradeStatisticId", "SortOrder" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_AcademicTermId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
column: "AcademicTermId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
columns: new[] { "CourseId", "AcademicTermId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_GradeSheetId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
column: "GradeSheetId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_TeachingTaskId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
column: "TeachingTaskId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeachingTaskGradeScoreBands");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeachingTaskGradeStatistics");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6561
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BindCentralizedExperimentProjectsToSchedules : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
type: "char(36)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
column: "ScheduleEntryId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ExperimentProjects_ScheduleEntries_ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
column: "ScheduleEntryId",
|
||||
principalTable: "ScheduleEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ExperimentProjects_ScheduleEntries_ScheduleEntryId",
|
||||
table: "ExperimentProjects");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExperimentProjects_ScheduleEntryId",
|
||||
table: "ExperimentProjects");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ScheduleEntryId",
|
||||
table: "ExperimentProjects");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6564
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTeachingVenueNatures : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TeachingVenueNature",
|
||||
table: "Classrooms",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE `Classrooms`
|
||||
SET `TeachingVenueNature` = CASE
|
||||
WHEN `RoomType` LIKE '%机房%' THEN 10
|
||||
WHEN `RoomType` LIKE '%语音%' THEN 18
|
||||
WHEN `RoomType` LIKE '%实训%' THEN 4
|
||||
WHEN `RoomType` LIKE '%实验%' THEN 2
|
||||
WHEN `RoomType` LIKE '%体育%' THEN 32
|
||||
WHEN `RoomType` LIKE '%艺术%' THEN 64
|
||||
ELSE 1
|
||||
END;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TeachingVenueNature",
|
||||
table: "Classrooms");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6567
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddExperimentVenueNatureConstraints : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AllowedExperimentVenueNatures",
|
||||
table: "TeachingTaskScheduleConstraints",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowedExperimentVenueNatures",
|
||||
table: "TeachingTaskScheduleConstraints");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6595
File diff suppressed because it is too large
Load Diff
+44
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SwaggerDocumentationSetting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SystemFeatureSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Key = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "tinyint(1)", 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_SystemFeatureSettings", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SystemFeatureSettings_Key",
|
||||
table: "SystemFeatureSettings",
|
||||
column: "Key",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SystemFeatureSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user