Compare commits
6
Commits
v2.1.0
..
8cb9b4f57b
@@ -36,6 +36,24 @@ Cache__AnalyticsExpirationMinutes=3
|
|||||||
Cache__AnalyticsLocalExpirationSeconds=30
|
Cache__AnalyticsLocalExpirationSeconds=30
|
||||||
Cache__MaximumPayloadKilobytes=2048
|
Cache__MaximumPayloadKilobytes=2048
|
||||||
|
|
||||||
|
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
|
||||||
|
Observability__Enabled=true
|
||||||
|
Observability__ServiceName=jiaowu-api
|
||||||
|
Observability__SlowQueryThresholdMilliseconds=500
|
||||||
|
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
||||||
|
Observability__IncludeSqlText=false
|
||||||
|
Observability__MaximumSqlTextLength=2000
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||||
|
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
||||||
|
|
||||||
|
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||||
|
PerformanceReporting__Enabled=false
|
||||||
|
# PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||||
|
# PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||||
|
# PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||||
|
PerformanceReporting__CacheSeconds=30
|
||||||
|
PerformanceReporting__TimeoutSeconds=10
|
||||||
|
|
||||||
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
||||||
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
||||||
Operations__BackupWarningHours=24
|
Operations__BackupWarningHours=24
|
||||||
|
|||||||
@@ -322,6 +322,55 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
|
|||||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||||
|
|
||||||
|
### OpenTelemetry 与慢查询定位
|
||||||
|
|
||||||
|
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
||||||
|
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
||||||
|
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
||||||
|
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
||||||
|
仍然有效。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Observability__Enabled=true
|
||||||
|
Observability__ServiceName=jiaowu-api
|
||||||
|
Observability__SlowQueryThresholdMilliseconds=500
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库指标包括 `jiaowu.db.command.duration`、`jiaowu.db.command.slow` 和
|
||||||
|
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
|
||||||
|
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
|
||||||
|
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
|
||||||
|
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
|
||||||
|
|
||||||
|
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||||
|
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
||||||
|
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
||||||
|
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
||||||
|
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
||||||
|
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
||||||
|
|
||||||
|
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
||||||
|
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
||||||
|
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
||||||
|
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
||||||
|
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
||||||
|
|
||||||
|
```text
|
||||||
|
PerformanceReporting__Enabled=true
|
||||||
|
PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||||
|
PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||||
|
PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||||
|
PerformanceReporting__CacheSeconds=30
|
||||||
|
PerformanceReporting__TimeoutSeconds=10
|
||||||
|
```
|
||||||
|
|
||||||
|
`PrometheusBaseUrl` 必须指向可访问 `/api/v1/query` 和 `/api/v1/query_range` 的
|
||||||
|
Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置或指标源暂时不可用时,页面
|
||||||
|
会显示明确的空状态,不会改查业务数据库或拖慢正常请求。若 Collector/Prometheus 对
|
||||||
|
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||||
|
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
||||||
|
|
||||||
### 后台任务与 RabbitMQ
|
### 后台任务与 RabbitMQ
|
||||||
|
|
||||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||||
@@ -368,9 +417,10 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
|||||||
|
|
||||||
### 运维与审计控制台
|
### 运维与审计控制台
|
||||||
|
|
||||||
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
|
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
|
||||||
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
|
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
|
||||||
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
|
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
|
||||||
|
前端菜单隐藏。
|
||||||
|
|
||||||
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
||||||
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ public sealed class ExperimentsController(
|
|||||||
x.TaskNumber,
|
x.TaskNumber,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.AcademicTermId,
|
x.AcademicTermId,
|
||||||
|
x.CourseId,
|
||||||
TermName = x.AcademicTerm!.Name,
|
TermName = x.AcademicTerm!.Name,
|
||||||
TermStartDate = x.AcademicTerm.StartDate,
|
TermStartDate = x.AcademicTerm.StartDate,
|
||||||
TermEndDate = x.AcademicTerm.EndDate,
|
TermEndDate = x.AcademicTerm.EndDate,
|
||||||
@@ -295,6 +296,78 @@ public sealed class ExperimentsController(
|
|||||||
return Created(string.Empty, new { project.Id });
|
return Created(string.Empty, new { project.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateProjects(
|
||||||
|
ExperimentProjectBatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var taskIds = request.TeachingTaskIds
|
||||||
|
.Where(x => x != Guid.Empty)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (taskIds.Count == 0)
|
||||||
|
return ValidationProblem("请至少选择一个教学任务。");
|
||||||
|
if (taskIds.Count > 100)
|
||||||
|
return ValidationProblem("单次最多为 100 个教学任务创建实验项目。");
|
||||||
|
|
||||||
|
var tasks = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.Include(x => x.AcademicTerm)
|
||||||
|
.Where(x =>
|
||||||
|
taskIds.Contains(x.Id) &&
|
||||||
|
x.Status == TeachingTaskStatus.Published)
|
||||||
|
.OrderBy(x => x.TaskNumber)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (tasks.Count != taskIds.Count)
|
||||||
|
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
||||||
|
|
||||||
|
var first = tasks[0];
|
||||||
|
if (tasks.Any(x =>
|
||||||
|
x.AcademicTermId != first.AcademicTermId ||
|
||||||
|
x.CourseId != first.CourseId))
|
||||||
|
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||||||
|
|
||||||
|
var code = request.Code.Trim();
|
||||||
|
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
taskIds.Contains(x.TeachingTaskId) &&
|
||||||
|
x.Code == code)
|
||||||
|
.Select(x => x.TeachingTask!.TaskNumber)
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (conflictingTaskNumbers.Count > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
||||||
|
|
||||||
|
var projects = new List<ExperimentProject>(tasks.Count);
|
||||||
|
foreach (var task in tasks)
|
||||||
|
{
|
||||||
|
var item = request.ForTeachingTask(task.Id);
|
||||||
|
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||||||
|
if (problem is not null) return ValidationProblem(problem);
|
||||||
|
|
||||||
|
projects.Add(new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
Code = code,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
ArrangementMode = request.ArrangementMode,
|
||||||
|
Description = Normalize(request.Description),
|
||||||
|
Requirements = Normalize(request.Requirements),
|
||||||
|
StartDate = request.StartDate,
|
||||||
|
EndDate = request.EndDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ExperimentProjects.AddRange(projects);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Created(string.Empty, new
|
||||||
|
{
|
||||||
|
Count = projects.Count,
|
||||||
|
ProjectIds = projects.Select(x => x.Id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateProject(
|
public async Task<ActionResult> UpdateProject(
|
||||||
@@ -478,6 +551,101 @@ public sealed class ExperimentsController(
|
|||||||
return Created(string.Empty, new { session.Id });
|
return Created(string.Empty, new { session.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("sessions/batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public Task<ActionResult> CreateSessions(
|
||||||
|
ExperimentSessionBatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.Items.Count == 0)
|
||||||
|
return Task.FromResult<ActionResult>(
|
||||||
|
ValidationProblem("请至少添加一条实验排课。"));
|
||||||
|
if (request.Items.Count > 100)
|
||||||
|
return Task.FromResult<ActionResult>(
|
||||||
|
ValidationProblem("单次最多安排 100 条实验场次。"));
|
||||||
|
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
|
||||||
|
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
|
||||||
|
request.Items.Count)
|
||||||
|
return Task.FromResult<ActionResult>(
|
||||||
|
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
|
||||||
|
|
||||||
|
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
|
async transaction =>
|
||||||
|
{
|
||||||
|
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
|
||||||
|
var projects = await ScopedProjects()
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.AcademicTerm)
|
||||||
|
.Where(x => projectIds.Contains(x.Id))
|
||||||
|
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
||||||
|
if (projects.Count != projectIds.Count)
|
||||||
|
return ValidationProblem(
|
||||||
|
"部分实验项目不存在或不在当前管理范围内。");
|
||||||
|
|
||||||
|
var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>(
|
||||||
|
request.Items.Count);
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
{
|
||||||
|
var project = projects[item.ProjectId];
|
||||||
|
if (project.Status == ExperimentProjectStatus.Closed)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"实验项目“{project.Name}”已关闭,不能再增加场次。");
|
||||||
|
|
||||||
|
var sessionRequest = item.ToSessionRequest();
|
||||||
|
var problem = await ValidateSessionAsync(
|
||||||
|
project,
|
||||||
|
sessionRequest,
|
||||||
|
cancellationToken);
|
||||||
|
if (problem is not null)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"实验项目“{project.Name}”:{problem}");
|
||||||
|
|
||||||
|
var session = new ExperimentSession
|
||||||
|
{
|
||||||
|
ExperimentProjectId = project.Id,
|
||||||
|
ClassroomId = item.ClassroomId,
|
||||||
|
SessionDate = item.SessionDate,
|
||||||
|
StartPeriod = item.StartPeriod,
|
||||||
|
PeriodCount = item.PeriodCount,
|
||||||
|
Capacity = await ResolveSessionCapacityAsync(
|
||||||
|
project,
|
||||||
|
item.Capacity,
|
||||||
|
cancellationToken),
|
||||||
|
Notes = Normalize(item.Notes)
|
||||||
|
};
|
||||||
|
db.ExperimentSessions.Add(session);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
createdSessions.Add((project, session));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (project, session) in createdSessions.Where(x =>
|
||||||
|
x.Project.Status == ExperimentProjectStatus.Published))
|
||||||
|
{
|
||||||
|
var userIds = await RosterUserIdsAsync(
|
||||||
|
project.TeachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
if (userIds.Count == 0) continue;
|
||||||
|
await NotificationService.SendToUserIdsAsync(
|
||||||
|
db,
|
||||||
|
userIds,
|
||||||
|
"新增实验场次",
|
||||||
|
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
|
||||||
|
"/experiments",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Created(string.Empty, new
|
||||||
|
{
|
||||||
|
Count = createdSessions.Count,
|
||||||
|
SessionIds = createdSessions.Select(x => x.Session.Id)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancellationToken,
|
||||||
|
IsolationLevel.Serializable);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("sessions/{id:guid}")]
|
[HttpDelete("sessions/{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> CancelSession(
|
public async Task<ActionResult> CancelSession(
|
||||||
@@ -1029,6 +1197,28 @@ public sealed record ExperimentProjectRequest(
|
|||||||
DateOnly StartDate,
|
DateOnly StartDate,
|
||||||
DateOnly EndDate);
|
DateOnly EndDate);
|
||||||
|
|
||||||
|
public sealed record ExperimentProjectBatchRequest(
|
||||||
|
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||||
|
[Required, MaxLength(40)] string Code,
|
||||||
|
[Required, MaxLength(120)] string Name,
|
||||||
|
ExperimentArrangementMode ArrangementMode,
|
||||||
|
[MaxLength(1000)] string? Description,
|
||||||
|
[MaxLength(1000)] string? Requirements,
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly EndDate)
|
||||||
|
{
|
||||||
|
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||||||
|
new(
|
||||||
|
teachingTaskId,
|
||||||
|
Code,
|
||||||
|
Name,
|
||||||
|
ArrangementMode,
|
||||||
|
Description,
|
||||||
|
Requirements,
|
||||||
|
StartDate,
|
||||||
|
EndDate);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record ExperimentSessionRequest(
|
public sealed record ExperimentSessionRequest(
|
||||||
Guid ClassroomId,
|
Guid ClassroomId,
|
||||||
DateOnly SessionDate,
|
DateOnly SessionDate,
|
||||||
@@ -1037,6 +1227,28 @@ public sealed record ExperimentSessionRequest(
|
|||||||
[Range(1, 10000)] int Capacity,
|
[Range(1, 10000)] int Capacity,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record ExperimentSessionBatchRequest(
|
||||||
|
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||||
|
|
||||||
|
public sealed record ExperimentSessionBatchItem(
|
||||||
|
Guid ProjectId,
|
||||||
|
Guid ClassroomId,
|
||||||
|
DateOnly SessionDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 30)] int PeriodCount,
|
||||||
|
[Range(1, 10000)] int Capacity,
|
||||||
|
[MaxLength(500)] string? Notes)
|
||||||
|
{
|
||||||
|
public ExperimentSessionRequest ToSessionRequest() =>
|
||||||
|
new(
|
||||||
|
ClassroomId,
|
||||||
|
SessionDate,
|
||||||
|
StartPeriod,
|
||||||
|
PeriodCount,
|
||||||
|
Capacity,
|
||||||
|
Notes);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record ExperimentPeriodOption(
|
public sealed record ExperimentPeriodOption(
|
||||||
Guid AcademicTermId,
|
Guid AcademicTermId,
|
||||||
int PeriodNumber,
|
int PeriodNumber,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
|
|||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Operations;
|
using Jiaowu.Api.Infrastructure.Operations;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -18,8 +19,27 @@ public sealed class OperationsController(
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
OperationalHealthService healthService,
|
OperationalHealthService healthService,
|
||||||
DatabaseBackupService backupService,
|
DatabaseBackupService backupService,
|
||||||
|
PerformanceReportService performanceReportService,
|
||||||
OperationsOptions options) : ControllerBase
|
OperationsOptions options) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpGet("performance")]
|
||||||
|
public async Task<ActionResult<PerformanceReport>> GetPerformance(
|
||||||
|
[FromQuery] string? range = "1h",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await performanceReportService.GetAsync(
|
||||||
|
range,
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
return ValidationProblem(
|
||||||
|
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("summary")]
|
[HttpGet("summary")]
|
||||||
public async Task<ActionResult<OperationsSummary>> GetSummary(
|
public async Task<ActionResult<OperationsSummary>> GetSummary(
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Jiaowu.Api.Domain.Academic;
|
|||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -110,15 +109,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
return Ok(tasks.Select(task =>
|
return Ok(tasks.Select(task =>
|
||||||
{
|
{
|
||||||
constraints.TryGetValue(task.Id, out var constraint);
|
constraints.TryGetValue(task.Id, out var constraint);
|
||||||
var weeklyHours = task.WeeklyHours;
|
|
||||||
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
|
|
||||||
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
|
||||||
task.CourseTotalHours,
|
|
||||||
task.CoursePracticeHours,
|
|
||||||
task.StartWeek,
|
|
||||||
task.EndWeek,
|
|
||||||
out var regularWeeklyHours))
|
|
||||||
weeklyHours = regularWeeklyHours;
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
task.Id,
|
task.Id,
|
||||||
@@ -132,7 +122,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
task.Capacity,
|
task.Capacity,
|
||||||
task.StartWeek,
|
task.StartWeek,
|
||||||
task.EndWeek,
|
task.EndWeek,
|
||||||
WeeklyHours = weeklyHours,
|
task.WeeklyHours,
|
||||||
task.CourseTotalHours,
|
task.CourseTotalHours,
|
||||||
task.CoursePracticeHours,
|
task.CoursePracticeHours,
|
||||||
task.SchedulingMode,
|
task.SchedulingMode,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public sealed class SchedulesController(
|
|||||||
{
|
{
|
||||||
entry.Id,
|
entry.Id,
|
||||||
entry.TeachingTaskId,
|
entry.TeachingTaskId,
|
||||||
|
entry.Kind,
|
||||||
TaskNumber = entry.TeachingTask!.TaskNumber,
|
TaskNumber = entry.TeachingTask!.TaskNumber,
|
||||||
TaskName = entry.TeachingTask.Name,
|
TaskName = entry.TeachingTask.Name,
|
||||||
CourseCode = entry.TeachingTask.Course!.Code,
|
CourseCode = entry.TeachingTask.Course!.Code,
|
||||||
@@ -167,6 +168,7 @@ public sealed class SchedulesController(
|
|||||||
Entries = source.Entries.Select(entry => new ScheduleEntry
|
Entries = source.Entries.Select(entry => new ScheduleEntry
|
||||||
{
|
{
|
||||||
TeachingTaskId = entry.TeachingTaskId,
|
TeachingTaskId = entry.TeachingTaskId,
|
||||||
|
Kind = entry.Kind,
|
||||||
ClassroomId = entry.ClassroomId,
|
ClassroomId = entry.ClassroomId,
|
||||||
DayOfWeek = entry.DayOfWeek,
|
DayOfWeek = entry.DayOfWeek,
|
||||||
StartPeriod = entry.StartPeriod,
|
StartPeriod = entry.StartPeriod,
|
||||||
@@ -419,6 +421,7 @@ public sealed class SchedulesController(
|
|||||||
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (validation is not null) return validation;
|
||||||
entry.TeachingTaskId = request.TeachingTaskId;
|
entry.TeachingTaskId = request.TeachingTaskId;
|
||||||
|
entry.Kind = request.Kind;
|
||||||
entry.ClassroomId = request.ClassroomId;
|
entry.ClassroomId = request.ClassroomId;
|
||||||
entry.DayOfWeek = request.DayOfWeek;
|
entry.DayOfWeek = request.DayOfWeek;
|
||||||
entry.StartPeriod = request.StartPeriod;
|
entry.StartPeriod = request.StartPeriod;
|
||||||
@@ -508,34 +511,52 @@ public sealed class SchedulesController(
|
|||||||
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
||||||
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
||||||
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
||||||
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
var targetHours = TeachingTaskHours.TargetHours(
|
||||||
task.Course!,
|
task.Course!,
|
||||||
task.StartWeek,
|
request.Kind);
|
||||||
task.EndWeek,
|
if (targetHours == 0)
|
||||||
out var requiredWeeklyHours))
|
|
||||||
return ValidationProblem(
|
return ValidationProblem(
|
||||||
"该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。");
|
request.Kind == ScheduleEntryKind.Experiment
|
||||||
if (requiredWeeklyHours == 0)
|
? "该课程没有实践学时,不能安排实验课。"
|
||||||
return ValidationProblem(
|
: "该课程没有理论学时,不能安排理论课。");
|
||||||
"该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。");
|
var existingEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
var existingHours = await db.ScheduleEntries.AsNoTracking()
|
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.SchedulePlanId == plan.Id &&
|
x.SchedulePlanId == plan.Id &&
|
||||||
x.TeachingTaskId == request.TeachingTaskId &&
|
x.TeachingTaskId == request.TeachingTaskId &&
|
||||||
|
x.Kind == request.Kind &&
|
||||||
x.Id != entryId)
|
x.Id != entryId)
|
||||||
.SumAsync(x => x.PeriodCount, cancellationToken);
|
.Select(x => new
|
||||||
if (existingHours + request.PeriodCount > requiredWeeklyHours)
|
{
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.PeriodCount
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingHours = existingEntries.Sum(x =>
|
||||||
|
TeachingTaskHours.ScheduledHours(
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeekPattern,
|
||||||
|
x.PeriodCount));
|
||||||
|
var proposedHours = TeachingTaskHours.ScheduledHours(
|
||||||
|
request.StartWeek,
|
||||||
|
request.EndWeek,
|
||||||
|
request.WeekPattern,
|
||||||
|
request.PeriodCount);
|
||||||
|
if (existingHours + proposedHours > targetHours)
|
||||||
return ValidationProblem(
|
return ValidationProblem(
|
||||||
$"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" +
|
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "实验" : "理论")}课" +
|
||||||
$"当前操作后将达到 {existingHours + request.PeriodCount} 学时," +
|
$"共需 {targetHours} 学时;当前操作后将达到 " +
|
||||||
"实践学时请在实验管理中安排。");
|
$"{existingHours + proposedHours} 学时。");
|
||||||
|
|
||||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
x => x.TeachingTaskId == request.TeachingTaskId,
|
x => x.TeachingTaskId == request.TeachingTaskId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiresClassroom != false;
|
||||||
if (requiresClassroom && !request.ClassroomId.HasValue)
|
if (requiresClassroom && !request.ClassroomId.HasValue)
|
||||||
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
||||||
if (!requiresClassroom && request.ClassroomId.HasValue)
|
if (!requiresClassroom && request.ClassroomId.HasValue)
|
||||||
@@ -559,6 +580,10 @@ public sealed class SchedulesController(
|
|||||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||||
|
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
!IsExperimentRoom(classroom.RoomType))
|
||||||
|
return ValidationProblem(
|
||||||
|
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||||
@@ -612,6 +637,7 @@ public sealed class SchedulesController(
|
|||||||
{
|
{
|
||||||
SchedulePlanId = planId,
|
SchedulePlanId = planId,
|
||||||
TeachingTaskId = request.TeachingTaskId,
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
|
Kind = request.Kind,
|
||||||
ClassroomId = request.ClassroomId,
|
ClassroomId = request.ClassroomId,
|
||||||
DayOfWeek = request.DayOfWeek,
|
DayOfWeek = request.DayOfWeek,
|
||||||
StartPeriod = request.StartPeriod,
|
StartPeriod = request.StartPeriod,
|
||||||
@@ -629,6 +655,12 @@ public sealed class SchedulesController(
|
|||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
|
private static bool IsExperimentRoom(string roomType) =>
|
||||||
|
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(
|
private async Task<ActionResult> SaveAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
bool created,
|
bool created,
|
||||||
@@ -726,7 +758,8 @@ public sealed record ScheduleEntryRequest(
|
|||||||
[Range(1, 30)] int StartWeek,
|
[Range(1, 30)] int StartWeek,
|
||||||
[Range(1, 30)] int EndWeek,
|
[Range(1, 30)] int EndWeek,
|
||||||
WeekPattern WeekPattern,
|
WeekPattern WeekPattern,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes,
|
||||||
|
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
|
||||||
|
|
||||||
public sealed record AutomaticScheduleJobResponse(
|
public sealed record AutomaticScheduleJobResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
|
|||||||
public SchedulePlan? SchedulePlan { get; set; }
|
public SchedulePlan? SchedulePlan { get; set; }
|
||||||
public Guid TeachingTaskId { get; set; }
|
public Guid TeachingTaskId { get; set; }
|
||||||
public TeachingTask? TeachingTask { get; set; }
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
|
||||||
public Guid? ClassroomId { get; set; }
|
public Guid? ClassroomId { get; set; }
|
||||||
public Classroom? Classroom { get; set; }
|
public Classroom? Classroom { get; set; }
|
||||||
public int DayOfWeek { get; set; }
|
public int DayOfWeek { get; set; }
|
||||||
@@ -129,3 +130,9 @@ public enum WeekPattern
|
|||||||
Odd = 2,
|
Odd = 2,
|
||||||
Even = 3
|
Even = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ScheduleEntryKind
|
||||||
|
{
|
||||||
|
Lecture = 1,
|
||||||
|
Experiment = 2
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||||
|
ObservabilityOptions options,
|
||||||
|
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||||
|
: DbCommandInterceptor
|
||||||
|
{
|
||||||
|
public const string ActivitySourceName = "Jiaowu.Api.Database";
|
||||||
|
public const string MeterName = "Jiaowu.Api.Database";
|
||||||
|
|
||||||
|
private static readonly ActivitySource ActivitySource =
|
||||||
|
new(ActivitySourceName);
|
||||||
|
private static readonly Meter Meter = new(MeterName);
|
||||||
|
private static readonly Histogram<double> CommandDuration =
|
||||||
|
Meter.CreateHistogram<double>(
|
||||||
|
"jiaowu.db.command.duration",
|
||||||
|
"ms",
|
||||||
|
"EF Core database command duration");
|
||||||
|
private static readonly Counter<long> SlowCommandCount =
|
||||||
|
Meter.CreateCounter<long>(
|
||||||
|
"jiaowu.db.command.slow",
|
||||||
|
"{command}",
|
||||||
|
"EF Core commands exceeding the configured slow-query threshold");
|
||||||
|
private static readonly Counter<long> FailedCommandCount =
|
||||||
|
Meter.CreateCounter<long>(
|
||||||
|
"jiaowu.db.command.failed",
|
||||||
|
"{command}",
|
||||||
|
"Failed EF Core database commands");
|
||||||
|
|
||||||
|
public override DbDataReader ReaderExecuted(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
DbDataReader result)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "reader");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<DbDataReader> ReaderExecutedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
DbDataReader result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "reader");
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int NonQueryExecuted(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
int result)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "nonquery");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<int> NonQueryExecutedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
int result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "nonquery");
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object? ScalarExecuted(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
object? result)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "scalar");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<object?> ScalarExecutedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandExecutedEventData eventData,
|
||||||
|
object? result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "scalar");
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void CommandFailed(
|
||||||
|
DbCommand command,
|
||||||
|
CommandErrorEventData eventData) =>
|
||||||
|
Observe(
|
||||||
|
command,
|
||||||
|
eventData.Duration,
|
||||||
|
"failed",
|
||||||
|
eventData.Exception.GetType().Name);
|
||||||
|
|
||||||
|
public override Task CommandFailedAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandErrorEventData eventData,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(
|
||||||
|
command,
|
||||||
|
eventData.Duration,
|
||||||
|
"failed",
|
||||||
|
eventData.Exception.GetType().Name);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void CommandCanceled(
|
||||||
|
DbCommand command,
|
||||||
|
CommandEndEventData eventData) =>
|
||||||
|
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||||
|
|
||||||
|
public override Task CommandCanceledAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandEndEventData eventData,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Observe(
|
||||||
|
DbCommand command,
|
||||||
|
TimeSpan duration,
|
||||||
|
string commandKind,
|
||||||
|
string? errorType = null)
|
||||||
|
{
|
||||||
|
if (!options.Enabled) return;
|
||||||
|
|
||||||
|
var queryName = GetQueryName(command.CommandText);
|
||||||
|
var statementHash = GetStatementHash(command.CommandText);
|
||||||
|
var provider = GetProviderName(command);
|
||||||
|
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
||||||
|
var tags = new TagList
|
||||||
|
{
|
||||||
|
{ "db.system.name", provider },
|
||||||
|
{ "db.operation.name", commandKind },
|
||||||
|
{ "db.query.name", queryName }
|
||||||
|
};
|
||||||
|
if (errorType is not null)
|
||||||
|
tags.Add("error.type", errorType);
|
||||||
|
|
||||||
|
var durationMilliseconds = duration.TotalMilliseconds;
|
||||||
|
CommandDuration.Record(durationMilliseconds, tags);
|
||||||
|
if (errorType is not null)
|
||||||
|
FailedCommandCount.Add(1, tags);
|
||||||
|
|
||||||
|
using var activity = ActivitySource.StartActivity(
|
||||||
|
ActivityKind.Client,
|
||||||
|
Activity.Current?.Context ?? default,
|
||||||
|
startTime: DateTimeOffset.UtcNow - duration,
|
||||||
|
name: queryName);
|
||||||
|
if (activity is not null)
|
||||||
|
{
|
||||||
|
activity.SetTag("db.system.name", provider);
|
||||||
|
activity.SetTag("db.operation.name", commandKind);
|
||||||
|
activity.SetTag("db.query.name", queryName);
|
||||||
|
activity.SetTag("db.statement.hash", statementHash);
|
||||||
|
activity.SetTag(
|
||||||
|
"db.namespace",
|
||||||
|
EmptyToNull(command.Connection?.Database));
|
||||||
|
if (options.IncludeSqlText)
|
||||||
|
{
|
||||||
|
activity.SetTag(
|
||||||
|
"db.query.text",
|
||||||
|
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||||
|
}
|
||||||
|
if (errorType is not null)
|
||||||
|
{
|
||||||
|
activity.SetTag("error.type", errorType);
|
||||||
|
activity.SetStatus(ActivityStatusCode.Error, errorType);
|
||||||
|
}
|
||||||
|
activity.SetEndTime(DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorType is not null)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
"Database command failed after {DurationMs:F1} ms: " +
|
||||||
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
|
"error {ErrorType}, trace {TraceId}).",
|
||||||
|
durationMilliseconds,
|
||||||
|
queryName,
|
||||||
|
commandKind,
|
||||||
|
provider,
|
||||||
|
statementHash,
|
||||||
|
errorType,
|
||||||
|
traceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SlowCommandCount.Add(1, tags);
|
||||||
|
if (options.IncludeSqlText)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
|
"trace {TraceId}). " +
|
||||||
|
"SQL template: {SqlTemplate}",
|
||||||
|
durationMilliseconds,
|
||||||
|
queryName,
|
||||||
|
commandKind,
|
||||||
|
provider,
|
||||||
|
statementHash,
|
||||||
|
traceId,
|
||||||
|
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
|
"trace {TraceId}).",
|
||||||
|
durationMilliseconds,
|
||||||
|
queryName,
|
||||||
|
commandKind,
|
||||||
|
provider,
|
||||||
|
statementHash,
|
||||||
|
traceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetQueryName(string commandText)
|
||||||
|
{
|
||||||
|
using var reader = new StringReader(commandText);
|
||||||
|
while (reader.ReadLine() is { } line)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.Length == 0) continue;
|
||||||
|
if (trimmed.StartsWith("-- ", StringComparison.Ordinal))
|
||||||
|
return Truncate(trimmed[3..].Trim(), 120);
|
||||||
|
return $"{FirstToken(trimmed)}:{GetStatementHash(commandText)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"unknown:{GetStatementHash(commandText)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetStatementHash(string commandText)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandText));
|
||||||
|
return Convert.ToHexString(bytes.AsSpan(0, 6)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstToken(string value)
|
||||||
|
{
|
||||||
|
var end = value.IndexOfAny([' ', '\t', '\r', '\n', '(']);
|
||||||
|
var token = end < 0 ? value : value[..end];
|
||||||
|
return token.Length == 0
|
||||||
|
? "command"
|
||||||
|
: token.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetProviderName(DbCommand command)
|
||||||
|
{
|
||||||
|
var typeName = command.GetType().FullName ?? command.GetType().Name;
|
||||||
|
if (typeName.Contains("MySql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "mysql";
|
||||||
|
if (typeName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "sqlite";
|
||||||
|
return "other_sql";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? EmptyToNull(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
private static string Truncate(string value, int maximumLength) =>
|
||||||
|
value.Length <= maximumLength
|
||||||
|
? value
|
||||||
|
: value[..maximumLength];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed class ObservabilityOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Observability";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public string ServiceName { get; set; } = "jiaowu-api";
|
||||||
|
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
||||||
|
public bool IncludeSqlText { get; set; }
|
||||||
|
public int MaximumSqlTextLength { get; set; } = 2000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed class PerformanceReportService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IMemoryCache cache,
|
||||||
|
PerformanceReportingOptions options,
|
||||||
|
ObservabilityOptions observability,
|
||||||
|
ILogger<PerformanceReportService> logger)
|
||||||
|
{
|
||||||
|
public async Task<PerformanceReport> GetAsync(
|
||||||
|
string? range,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rangeSpec = PerformanceRange.TryParse(range);
|
||||||
|
if (rangeSpec is null)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(range),
|
||||||
|
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||||
|
|
||||||
|
if (!options.Enabled ||
|
||||||
|
string.IsNullOrWhiteSpace(options.PrometheusBaseUrl))
|
||||||
|
{
|
||||||
|
return PerformanceReport.NotConfigured(
|
||||||
|
rangeSpec.Key,
|
||||||
|
options.GrafanaBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = $"performance-report:{rangeSpec.Key}";
|
||||||
|
if (cache.TryGetValue<PerformanceReport>(cacheKey, out var cached))
|
||||||
|
return cached!;
|
||||||
|
|
||||||
|
PerformanceReport report;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
report = await LoadAsync(rangeSpec, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Performance report source is unavailable for range {Range}.",
|
||||||
|
rangeSpec.Key);
|
||||||
|
report = PerformanceReport.Unavailable(
|
||||||
|
rangeSpec.Key,
|
||||||
|
options.GrafanaBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Set(
|
||||||
|
cacheKey,
|
||||||
|
report,
|
||||||
|
TimeSpan.FromSeconds(options.CacheSeconds));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PerformanceReport> LoadAsync(
|
||||||
|
PerformanceRange range,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var from = now - range.Duration;
|
||||||
|
var requestCountSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var requestBucketSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_bucket",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var errorCountSelector = Selector(
|
||||||
|
options.RequestDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName),
|
||||||
|
("http_response_status_code", "=~", "5.."));
|
||||||
|
var databaseCountSelector = Selector(
|
||||||
|
options.DatabaseDurationMetric + "_count",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var databaseBucketSelector = Selector(
|
||||||
|
options.DatabaseDurationMetric + "_bucket",
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var slowDatabaseSelector = Selector(
|
||||||
|
options.SlowDatabaseMetric,
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
var failedDatabaseSelector = Selector(
|
||||||
|
options.FailedDatabaseMetric,
|
||||||
|
(options.ServiceLabel, "=", observability.ServiceName));
|
||||||
|
|
||||||
|
var requestCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({requestCountSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var serverErrorCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({errorCountSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var requestP95Task = QueryScalarAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||||
|
"* 1000",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var databaseP95Task = QueryScalarAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({databaseBucketSelector}[{range.RateWindow}])))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var slowCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({slowDatabaseSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var failedCountTask = QueryScalarAsync(
|
||||||
|
$"sum(increase({failedDatabaseSelector}[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var requestTimelineTask = QueryRangeAsync(
|
||||||
|
$"sum(rate({requestCountSelector}[{range.RateWindow}]))",
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
range.StepSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
var latencyTimelineTask = QueryRangeAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||||
|
"* 1000",
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
range.StepSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
var routeLatencyTask = QueryVectorAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le, http_route) (rate({requestBucketSelector}" +
|
||||||
|
$"[{range.RateWindow}]))) * 1000",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var routeCountTask = QueryVectorAsync(
|
||||||
|
$"sum by (http_route) (increase({requestCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var routeErrorTask = QueryVectorAsync(
|
||||||
|
$"sum by (http_route) (increase({errorCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var queryLatencyTask = QueryVectorAsync(
|
||||||
|
"histogram_quantile(0.95, " +
|
||||||
|
$"sum by (le, db_query_name) (rate({databaseBucketSelector}" +
|
||||||
|
$"[{range.RateWindow}])))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var queryCountTask = QueryVectorAsync(
|
||||||
|
$"sum by (db_query_name) (increase({databaseCountSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
var querySlowTask = QueryVectorAsync(
|
||||||
|
$"sum by (db_query_name) (increase({slowDatabaseSelector}" +
|
||||||
|
$"[{range.PrometheusRange}]))",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
requestCountTask,
|
||||||
|
serverErrorCountTask,
|
||||||
|
requestP95Task,
|
||||||
|
databaseP95Task,
|
||||||
|
slowCountTask,
|
||||||
|
failedCountTask,
|
||||||
|
requestTimelineTask,
|
||||||
|
latencyTimelineTask,
|
||||||
|
routeLatencyTask,
|
||||||
|
routeCountTask,
|
||||||
|
routeErrorTask,
|
||||||
|
queryLatencyTask,
|
||||||
|
queryCountTask,
|
||||||
|
querySlowTask);
|
||||||
|
|
||||||
|
var requestCount = await requestCountTask;
|
||||||
|
var serverErrorCount = await serverErrorCountTask;
|
||||||
|
double? errorRate = requestCount is > 0 && serverErrorCount.HasValue
|
||||||
|
? serverErrorCount.Value / requestCount.Value * 100
|
||||||
|
: requestCount == 0
|
||||||
|
? 0
|
||||||
|
: null;
|
||||||
|
var timeline = MergeTimeline(
|
||||||
|
await requestTimelineTask,
|
||||||
|
await latencyTimelineTask);
|
||||||
|
var endpoints = MergeRanking(
|
||||||
|
await routeLatencyTask,
|
||||||
|
await routeCountTask,
|
||||||
|
await routeErrorTask,
|
||||||
|
"http_route");
|
||||||
|
var databaseQueries = MergeRanking(
|
||||||
|
await queryLatencyTask,
|
||||||
|
await queryCountTask,
|
||||||
|
await querySlowTask,
|
||||||
|
"db_query_name");
|
||||||
|
|
||||||
|
return new PerformanceReport(
|
||||||
|
"ready",
|
||||||
|
range.Key,
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
"prometheus",
|
||||||
|
EmptyToNull(options.GrafanaBaseUrl),
|
||||||
|
null,
|
||||||
|
new PerformanceHeadline(
|
||||||
|
Round(requestCount),
|
||||||
|
Round(await requestP95Task),
|
||||||
|
Round(errorRate),
|
||||||
|
Round(await databaseP95Task),
|
||||||
|
Round(await slowCountTask),
|
||||||
|
Round(await failedCountTask)),
|
||||||
|
timeline,
|
||||||
|
endpoints,
|
||||||
|
databaseQueries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<double?> QueryScalarAsync(
|
||||||
|
string query,
|
||||||
|
DateTime time,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var vector = await QueryVectorAsync(
|
||||||
|
query,
|
||||||
|
time,
|
||||||
|
cancellationToken);
|
||||||
|
return vector.FirstOrDefault()?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<PrometheusSample>> QueryVectorAsync(
|
||||||
|
string query,
|
||||||
|
DateTime time,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uri = BuildUri(
|
||||||
|
"api/v1/query",
|
||||||
|
("query", query),
|
||||||
|
("time", ToUnixSeconds(time).ToString(
|
||||||
|
CultureInfo.InvariantCulture)));
|
||||||
|
using var document = await SendAsync(uri, cancellationToken);
|
||||||
|
var data = document.RootElement.GetProperty("data");
|
||||||
|
var result = data.GetProperty("result");
|
||||||
|
var samples = new List<PrometheusSample>();
|
||||||
|
foreach (var item in result.EnumerateArray())
|
||||||
|
{
|
||||||
|
var labels = ReadLabels(item.GetProperty("metric"));
|
||||||
|
if (!TryReadValue(item.GetProperty("value"), out var value))
|
||||||
|
continue;
|
||||||
|
samples.Add(new PrometheusSample(labels, value));
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<PerformanceSeriesPoint>> QueryRangeAsync(
|
||||||
|
string query,
|
||||||
|
DateTime from,
|
||||||
|
DateTime to,
|
||||||
|
int stepSeconds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var uri = BuildUri(
|
||||||
|
"api/v1/query_range",
|
||||||
|
("query", query),
|
||||||
|
("start", ToUnixSeconds(from).ToString(
|
||||||
|
CultureInfo.InvariantCulture)),
|
||||||
|
("end", ToUnixSeconds(to).ToString(
|
||||||
|
CultureInfo.InvariantCulture)),
|
||||||
|
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)));
|
||||||
|
using var document = await SendAsync(uri, cancellationToken);
|
||||||
|
var result = document.RootElement
|
||||||
|
.GetProperty("data")
|
||||||
|
.GetProperty("result");
|
||||||
|
var first = result.EnumerateArray().FirstOrDefault();
|
||||||
|
if (first.ValueKind == JsonValueKind.Undefined ||
|
||||||
|
!first.TryGetProperty("values", out var values))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var points = new List<PerformanceSeriesPoint>();
|
||||||
|
foreach (var value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!TryReadValue(value, out var measurement)) continue;
|
||||||
|
var timestamp = value[0].GetDouble();
|
||||||
|
points.Add(new PerformanceSeriesPoint(
|
||||||
|
DateTimeOffset.FromUnixTimeMilliseconds(
|
||||||
|
checked((long)(timestamp * 1000))).UtcDateTime,
|
||||||
|
measurement));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonDocument> SendAsync(
|
||||||
|
Uri uri,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.BearerToken))
|
||||||
|
{
|
||||||
|
request.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", options.BearerToken);
|
||||||
|
}
|
||||||
|
using var response = await httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(
|
||||||
|
cancellationToken);
|
||||||
|
var document = await JsonDocument.ParseAsync(
|
||||||
|
stream,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!document.RootElement.TryGetProperty("status", out var status) ||
|
||||||
|
status.GetString() != "success")
|
||||||
|
{
|
||||||
|
document.Dispose();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Prometheus 返回了非成功查询状态。");
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri BuildUri(
|
||||||
|
string relativePath,
|
||||||
|
params (string Key, string Value)[] parameters)
|
||||||
|
{
|
||||||
|
var baseUri = new Uri(
|
||||||
|
options.PrometheusBaseUrl.TrimEnd('/') + "/",
|
||||||
|
UriKind.Absolute);
|
||||||
|
var query = string.Join(
|
||||||
|
"&",
|
||||||
|
parameters.Select(parameter =>
|
||||||
|
$"{Uri.EscapeDataString(parameter.Key)}=" +
|
||||||
|
$"{Uri.EscapeDataString(parameter.Value)}"));
|
||||||
|
return new Uri(baseUri, $"{relativePath}?{query}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Selector(
|
||||||
|
string metric,
|
||||||
|
params (string Label, string Operator, string Value)[] filters)
|
||||||
|
{
|
||||||
|
var matchers = string.Join(
|
||||||
|
",",
|
||||||
|
filters.Select(filter =>
|
||||||
|
$"{filter.Label}{filter.Operator}\"" +
|
||||||
|
$"{EscapePrometheusValue(filter.Value)}\""));
|
||||||
|
return $"{metric}{{{matchers}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapePrometheusValue(string value) =>
|
||||||
|
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||||
|
.Replace("\r", "\\r", StringComparison.Ordinal)
|
||||||
|
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> ReadLabels(
|
||||||
|
JsonElement metric)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
foreach (var property in metric.EnumerateObject())
|
||||||
|
result[property.Name] = property.Value.GetString() ?? "";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadValue(
|
||||||
|
JsonElement value,
|
||||||
|
out double measurement)
|
||||||
|
{
|
||||||
|
measurement = 0;
|
||||||
|
if (value.ValueKind != JsonValueKind.Array ||
|
||||||
|
value.GetArrayLength() < 2)
|
||||||
|
return false;
|
||||||
|
var raw = value[1].GetString();
|
||||||
|
return double.TryParse(
|
||||||
|
raw,
|
||||||
|
NumberStyles.Float,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out measurement) &&
|
||||||
|
double.IsFinite(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PerformanceTimelinePoint> MergeTimeline(
|
||||||
|
IReadOnlyList<PerformanceSeriesPoint> requestRate,
|
||||||
|
IReadOnlyList<PerformanceSeriesPoint> latency)
|
||||||
|
{
|
||||||
|
var points = new SortedDictionary<DateTime, PerformanceTimelinePoint>();
|
||||||
|
foreach (var point in requestRate)
|
||||||
|
{
|
||||||
|
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||||
|
point.Timestamp,
|
||||||
|
Math.Round(point.Value, 3),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
foreach (var point in latency)
|
||||||
|
{
|
||||||
|
points.TryGetValue(point.Timestamp, out var existing);
|
||||||
|
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||||
|
point.Timestamp,
|
||||||
|
existing?.RequestsPerSecond,
|
||||||
|
Math.Round(point.Value, 2));
|
||||||
|
}
|
||||||
|
return points.Values.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PerformanceRankingItem> MergeRanking(
|
||||||
|
IReadOnlyList<PrometheusSample> latency,
|
||||||
|
IReadOnlyList<PrometheusSample> count,
|
||||||
|
IReadOnlyList<PrometheusSample> exceptional,
|
||||||
|
string label)
|
||||||
|
{
|
||||||
|
var names = latency
|
||||||
|
.Concat(count)
|
||||||
|
.Concat(exceptional)
|
||||||
|
.Select(item => item.Labels.GetValueOrDefault(label))
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
var items = names.Select(name =>
|
||||||
|
{
|
||||||
|
var latencyValue = FindValue(latency, label, name);
|
||||||
|
var countValue = FindValue(count, label, name);
|
||||||
|
var exceptionalValue = FindValue(exceptional, label, name);
|
||||||
|
return new PerformanceRankingItem(
|
||||||
|
name!,
|
||||||
|
Round(latencyValue),
|
||||||
|
Round(countValue),
|
||||||
|
Round(exceptionalValue));
|
||||||
|
});
|
||||||
|
return items
|
||||||
|
.OrderByDescending(item => item.P95Milliseconds ?? -1)
|
||||||
|
.ThenByDescending(item => item.RequestCount ?? -1)
|
||||||
|
.Take(10)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? FindValue(
|
||||||
|
IReadOnlyList<PrometheusSample> samples,
|
||||||
|
string label,
|
||||||
|
string? name) =>
|
||||||
|
samples.FirstOrDefault(item =>
|
||||||
|
item.Labels.GetValueOrDefault(label) == name)?.Value;
|
||||||
|
|
||||||
|
private static double ToUnixSeconds(DateTime value) =>
|
||||||
|
new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(value, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
|
||||||
|
/ 1000d;
|
||||||
|
|
||||||
|
private static double? Round(double? value) =>
|
||||||
|
value.HasValue && double.IsFinite(value.Value)
|
||||||
|
? Math.Round(value.Value, 2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? EmptyToNull(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
private sealed record PrometheusSample(
|
||||||
|
IReadOnlyDictionary<string, string> Labels,
|
||||||
|
double Value);
|
||||||
|
|
||||||
|
private sealed record PerformanceSeriesPoint(
|
||||||
|
DateTime Timestamp,
|
||||||
|
double Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PerformanceHeadline(
|
||||||
|
double? RequestCount,
|
||||||
|
double? RequestP95Milliseconds,
|
||||||
|
double? ServerErrorRatePercent,
|
||||||
|
double? DatabaseP95Milliseconds,
|
||||||
|
double? SlowDatabaseCommandCount,
|
||||||
|
double? FailedDatabaseCommandCount);
|
||||||
|
|
||||||
|
public sealed record PerformanceTimelinePoint(
|
||||||
|
DateTime Timestamp,
|
||||||
|
double? RequestsPerSecond,
|
||||||
|
double? RequestP95Milliseconds);
|
||||||
|
|
||||||
|
public sealed record PerformanceRankingItem(
|
||||||
|
string Name,
|
||||||
|
double? P95Milliseconds,
|
||||||
|
double? RequestCount,
|
||||||
|
double? ExceptionalCount);
|
||||||
|
|
||||||
|
public sealed record PerformanceReport(
|
||||||
|
string Status,
|
||||||
|
string Range,
|
||||||
|
DateTime? From,
|
||||||
|
DateTime? To,
|
||||||
|
DateTime GeneratedAt,
|
||||||
|
string DataSource,
|
||||||
|
string? DashboardUrl,
|
||||||
|
string? Detail,
|
||||||
|
PerformanceHeadline? Headline,
|
||||||
|
IReadOnlyList<PerformanceTimelinePoint> Timeline,
|
||||||
|
IReadOnlyList<PerformanceRankingItem> Endpoints,
|
||||||
|
IReadOnlyList<PerformanceRankingItem> DatabaseQueries)
|
||||||
|
{
|
||||||
|
public static PerformanceReport NotConfigured(
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl) =>
|
||||||
|
Empty(
|
||||||
|
"not_configured",
|
||||||
|
range,
|
||||||
|
dashboardUrl,
|
||||||
|
"尚未配置 Prometheus 数据源。请先部署指标存储并设置 " +
|
||||||
|
"PerformanceReporting__PrometheusBaseUrl。");
|
||||||
|
|
||||||
|
public static PerformanceReport Unavailable(
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl) =>
|
||||||
|
Empty(
|
||||||
|
"unavailable",
|
||||||
|
range,
|
||||||
|
dashboardUrl,
|
||||||
|
"性能数据源暂时不可用。系统业务不受影响,请检查 Prometheus 与网络配置。");
|
||||||
|
|
||||||
|
private static PerformanceReport Empty(
|
||||||
|
string status,
|
||||||
|
string range,
|
||||||
|
string? dashboardUrl,
|
||||||
|
string detail) =>
|
||||||
|
new(
|
||||||
|
status,
|
||||||
|
range,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
"prometheus",
|
||||||
|
string.IsNullOrWhiteSpace(dashboardUrl) ? null : dashboardUrl,
|
||||||
|
detail,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record PerformanceRange(
|
||||||
|
string Key,
|
||||||
|
TimeSpan Duration,
|
||||||
|
string PrometheusRange,
|
||||||
|
string RateWindow,
|
||||||
|
int StepSeconds)
|
||||||
|
{
|
||||||
|
public static PerformanceRange? TryParse(string? value) =>
|
||||||
|
value?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"15m" => new("15m", TimeSpan.FromMinutes(15), "15m", "1m", 30),
|
||||||
|
"1h" => new("1h", TimeSpan.FromHours(1), "1h", "5m", 60),
|
||||||
|
"24h" => new("24h", TimeSpan.FromHours(24), "24h", "15m", 900),
|
||||||
|
"7d" => new("7d", TimeSpan.FromDays(7), "7d", "1h", 3600),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
public sealed partial class PerformanceReportingOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PerformanceReporting";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string PrometheusBaseUrl { get; set; } = "";
|
||||||
|
public string BearerToken { get; set; } = "";
|
||||||
|
public string GrafanaBaseUrl { get; set; } = "";
|
||||||
|
public int CacheSeconds { get; set; } = 30;
|
||||||
|
public int TimeoutSeconds { get; set; } = 10;
|
||||||
|
public string ServiceLabel { get; set; } = "service_name";
|
||||||
|
public string RequestDurationMetric { get; set; } =
|
||||||
|
"http_server_request_duration_seconds";
|
||||||
|
public string DatabaseDurationMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_duration_milliseconds";
|
||||||
|
public string SlowDatabaseMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_slow_total";
|
||||||
|
public string FailedDatabaseMetric { get; set; } =
|
||||||
|
"jiaowu_db_command_failed_total";
|
||||||
|
|
||||||
|
public static bool IsMetricOrLabelName(string value) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value) &&
|
||||||
|
PrometheusNamePattern().IsMatch(value);
|
||||||
|
|
||||||
|
[GeneratedRegex("^[a-zA-Z_:][a-zA-Z0-9_:]*$")]
|
||||||
|
private static partial Regex PrometheusNamePattern();
|
||||||
|
}
|
||||||
@@ -416,6 +416,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
builder.Entity<ScheduleEntry>(entity =>
|
builder.Entity<ScheduleEntry>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.Property(x => x.Kind)
|
||||||
|
.HasDefaultValue(ScheduleEntryKind.Lecture);
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new
|
entity.HasIndex(x => new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260728_40_experiment_grade_management";
|
"20260728_40_experiment_grade_management";
|
||||||
private const string AppUpdateReleasesMigration =
|
private const string AppUpdateReleasesMigration =
|
||||||
"20260729_41_app_update_releases";
|
"20260729_41_app_update_releases";
|
||||||
|
private const string IntegratedExperimentSchedulingMigration =
|
||||||
|
"20260802_42_integrated_experiment_scheduling";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -577,6 +579,20 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
AppUpdateReleasesMigration,
|
AppUpdateReleasesMigration,
|
||||||
AppUpdateReleasesStatements,
|
AppUpdateReleasesStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var scheduleEntryKindExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('ScheduleEntries')
|
||||||
|
WHERE name = 'Kind'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
IntegratedExperimentSchedulingMigration,
|
||||||
|
scheduleEntryKindExists
|
||||||
|
? []
|
||||||
|
: IntegratedExperimentSchedulingStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2714,4 +2730,12 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
|
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] IntegratedExperimentSchedulingStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "ScheduleEntries"
|
||||||
|
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+5991
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 IntegratedExperimentScheduling : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Kind",
|
||||||
|
table: "ScheduleEntries",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Kind",
|
||||||
|
table: "ScheduleEntries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -3296,6 +3296,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<int>("EndWeek")
|
b.Property<int>("EndWeek")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("varchar(500)");
|
.HasColumnType("varchar(500)");
|
||||||
|
|||||||
@@ -74,97 +74,62 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
constraints.TryGetValue(task.Id, out var constraint);
|
constraints.TryGetValue(task.Id, out var constraint);
|
||||||
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
var taskCompleted = true;
|
||||||
task.Course!,
|
foreach (var kind in new[]
|
||||||
task.StartWeek,
|
{
|
||||||
task.EndWeek,
|
ScheduleEntryKind.Lecture,
|
||||||
out var requiredWeeklyHours))
|
ScheduleEntryKind.Experiment
|
||||||
|
})
|
||||||
{
|
{
|
||||||
messages.Add(
|
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
|
||||||
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
|
var scheduledHours = entries
|
||||||
processedTasks++;
|
.Where(x =>
|
||||||
if (reportProgress is not null)
|
x.TeachingTaskId == task.Id &&
|
||||||
|
x.Kind == kind)
|
||||||
|
.Sum(TeachingTaskHours.ScheduledHours);
|
||||||
|
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
|
||||||
|
if (scheduledHours > targetHours)
|
||||||
{
|
{
|
||||||
await reportProgress(
|
messages.Add(
|
||||||
new(tasks.Count, processedTasks, created, completedTasks),
|
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
|
||||||
cancellationToken);
|
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
|
||||||
|
taskCompleted = false;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var scheduledHours = entries
|
var remainingHours = targetHours - scheduledHours;
|
||||||
.Where(x => x.TeachingTaskId == task.Id)
|
while (remainingHours > 0)
|
||||||
.Sum(x => x.PeriodCount);
|
|
||||||
if (scheduledHours > requiredWeeklyHours)
|
|
||||||
{
|
|
||||||
messages.Add(
|
|
||||||
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
|
|
||||||
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
|
|
||||||
processedTasks++;
|
|
||||||
if (reportProgress is not null)
|
|
||||||
{
|
{
|
||||||
await reportProgress(
|
var candidate = FindBestCandidateForHours(
|
||||||
new(tasks.Count, processedTasks, created, completedTasks),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var remainingHours = requiredWeeklyHours - scheduledHours;
|
|
||||||
if (remainingHours == 0)
|
|
||||||
{
|
|
||||||
completedTasks++;
|
|
||||||
processedTasks++;
|
|
||||||
if (reportProgress is not null)
|
|
||||||
{
|
|
||||||
await reportProgress(
|
|
||||||
new(tasks.Count, processedTasks, created, completedTasks),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (remainingHours > 0)
|
|
||||||
{
|
|
||||||
var desiredBlock = remainingHours >= 2 ? 2 : 1;
|
|
||||||
var candidate = FindBestCandidate(
|
|
||||||
plan.Id,
|
|
||||||
task,
|
|
||||||
constraint,
|
|
||||||
desiredBlock,
|
|
||||||
activePeriods,
|
|
||||||
classrooms,
|
|
||||||
entries,
|
|
||||||
cancellationToken);
|
|
||||||
if (candidate is null && desiredBlock > 1)
|
|
||||||
{
|
|
||||||
candidate = FindBestCandidate(
|
|
||||||
plan.Id,
|
plan.Id,
|
||||||
task,
|
task,
|
||||||
constraint,
|
constraint,
|
||||||
1,
|
kind,
|
||||||
|
remainingHours,
|
||||||
activePeriods,
|
activePeriods,
|
||||||
classrooms,
|
classrooms,
|
||||||
entries,
|
entries,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
if (candidate is null) break;
|
||||||
|
|
||||||
|
db.ScheduleEntries.Add(candidate);
|
||||||
|
entries.Add(candidate);
|
||||||
|
created++;
|
||||||
|
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
|
||||||
}
|
}
|
||||||
if (candidate is null) break;
|
|
||||||
|
|
||||||
db.ScheduleEntries.Add(candidate);
|
if (remainingHours > 0)
|
||||||
entries.Add(candidate);
|
{
|
||||||
created++;
|
messages.Add(
|
||||||
remainingHours -= candidate.PeriodCount;
|
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
|
||||||
|
(kind == ScheduleEntryKind.Experiment
|
||||||
|
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||||
|
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||||
|
taskCompleted = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remainingHours == 0)
|
if (taskCompleted) completedTasks++;
|
||||||
{
|
|
||||||
completedTasks++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
messages.Add(
|
|
||||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
|
||||||
}
|
|
||||||
|
|
||||||
processedTasks++;
|
processedTasks++;
|
||||||
if (reportProgress is not null)
|
if (reportProgress is not null)
|
||||||
@@ -185,11 +150,51 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
processedTasks);
|
processedTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ScheduleEntry? FindBestCandidateForHours(
|
||||||
|
Guid planId,
|
||||||
|
TeachingTask task,
|
||||||
|
TeachingTaskScheduleConstraint? constraint,
|
||||||
|
ScheduleEntryKind kind,
|
||||||
|
int remainingHours,
|
||||||
|
HashSet<int> activePeriods,
|
||||||
|
IReadOnlyList<Classroom> classrooms,
|
||||||
|
IReadOnlyList<ScheduleEntry> entries,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var weekCount = task.EndWeek - task.StartWeek + 1;
|
||||||
|
foreach (var periodCount in remainingHours >= 2
|
||||||
|
? new[] { 2, 1 }
|
||||||
|
: new[] { 1 })
|
||||||
|
{
|
||||||
|
var maxOccurrences = Math.Min(
|
||||||
|
weekCount,
|
||||||
|
remainingHours / periodCount);
|
||||||
|
for (var occurrences = maxOccurrences; occurrences >= 1; occurrences--)
|
||||||
|
{
|
||||||
|
var candidate = FindBestCandidate(
|
||||||
|
planId,
|
||||||
|
task,
|
||||||
|
constraint,
|
||||||
|
kind,
|
||||||
|
periodCount,
|
||||||
|
occurrences,
|
||||||
|
activePeriods,
|
||||||
|
classrooms,
|
||||||
|
entries,
|
||||||
|
cancellationToken);
|
||||||
|
if (candidate is not null) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static ScheduleEntry? FindBestCandidate(
|
private static ScheduleEntry? FindBestCandidate(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
TeachingTask task,
|
TeachingTask task,
|
||||||
TeachingTaskScheduleConstraint? constraint,
|
TeachingTaskScheduleConstraint? constraint,
|
||||||
|
ScheduleEntryKind kind,
|
||||||
int periodCount,
|
int periodCount,
|
||||||
|
int occurrenceCount,
|
||||||
HashSet<int> activePeriods,
|
HashSet<int> activePeriods,
|
||||||
IReadOnlyList<Classroom> classrooms,
|
IReadOnlyList<Classroom> classrooms,
|
||||||
IReadOnlyList<ScheduleEntry> entries,
|
IReadOnlyList<ScheduleEntry> entries,
|
||||||
@@ -198,50 +203,60 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
||||||
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||||
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
|
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
|
||||||
var rooms = EligibleRooms(task, constraint, classrooms);
|
var rooms = EligibleRooms(task, constraint, kind, classrooms);
|
||||||
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
|
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
||||||
foreach (var day in allowedDays)
|
for (var startWeek = task.StartWeek;
|
||||||
|
startWeek + occurrenceCount - 1 <= task.EndWeek;
|
||||||
|
startWeek++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
foreach (var day in allowedDays)
|
||||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||||
continue;
|
|
||||||
|
|
||||||
var roomOptions = constraint?.RequiresClassroom == false
|
|
||||||
? new Classroom?[] { null }
|
|
||||||
: rooms.Cast<Classroom?>().ToArray();
|
|
||||||
foreach (var room in roomOptions)
|
|
||||||
{
|
{
|
||||||
var proposed = new ScheduleEntry
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
{
|
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||||
SchedulePlanId = planId,
|
|
||||||
TeachingTaskId = task.Id,
|
|
||||||
TeachingTask = task,
|
|
||||||
ClassroomId = room?.Id,
|
|
||||||
DayOfWeek = day,
|
|
||||||
StartPeriod = start,
|
|
||||||
PeriodCount = periodCount,
|
|
||||||
StartWeek = task.StartWeek,
|
|
||||||
EndWeek = task.EndWeek,
|
|
||||||
WeekPattern = WeekPattern.All,
|
|
||||||
Notes = "自动排课"
|
|
||||||
};
|
|
||||||
if (entries.Any(existing =>
|
|
||||||
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
|
||||||
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var sameTaskDay = entries.Count(x =>
|
var roomOptions = kind != ScheduleEntryKind.Experiment &&
|
||||||
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
|
constraint?.RequiresClassroom == false
|
||||||
var dayLoad = entries.Count(x => x.DayOfWeek == day);
|
? new Classroom?[] { null }
|
||||||
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
|
: rooms.Cast<Classroom?>().ToArray();
|
||||||
var score = sameTaskDay * 1000 + dayLoad * 10 + start + roomWaste / 10;
|
foreach (var room in roomOptions)
|
||||||
candidates.Add((proposed, score));
|
{
|
||||||
|
var proposed = new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = planId,
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
TeachingTask = task,
|
||||||
|
Kind = kind,
|
||||||
|
ClassroomId = room?.Id,
|
||||||
|
DayOfWeek = day,
|
||||||
|
StartPeriod = start,
|
||||||
|
PeriodCount = periodCount,
|
||||||
|
StartWeek = startWeek,
|
||||||
|
EndWeek = startWeek + occurrenceCount - 1,
|
||||||
|
WeekPattern = WeekPattern.All,
|
||||||
|
Notes = kind == ScheduleEntryKind.Experiment
|
||||||
|
? "自动排课 · 实验课"
|
||||||
|
: "自动排课 · 理论课"
|
||||||
|
};
|
||||||
|
if (entries.Any(existing =>
|
||||||
|
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
||||||
|
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var sameTaskDay = entries.Count(x =>
|
||||||
|
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
|
||||||
|
var dayLoad = entries.Count(x => x.DayOfWeek == day);
|
||||||
|
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
|
||||||
|
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
|
||||||
|
roomWaste / 10 + startWeek;
|
||||||
|
candidates.Add((proposed, score));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,9 +271,11 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
private static IReadOnlyList<Classroom> EligibleRooms(
|
private static IReadOnlyList<Classroom> EligibleRooms(
|
||||||
TeachingTask task,
|
TeachingTask task,
|
||||||
TeachingTaskScheduleConstraint? constraint,
|
TeachingTaskScheduleConstraint? constraint,
|
||||||
|
ScheduleEntryKind kind,
|
||||||
IReadOnlyList<Classroom> classrooms)
|
IReadOnlyList<Classroom> classrooms)
|
||||||
{
|
{
|
||||||
if (constraint?.RequiresClassroom == false) return [];
|
if (kind != ScheduleEntryKind.Experiment &&
|
||||||
|
constraint?.RequiresClassroom == false) return [];
|
||||||
var allowedRoomIds = constraint?.AllowedClassrooms
|
var allowedRoomIds = constraint?.AllowedClassrooms
|
||||||
.Select(x => x.ClassroomId)
|
.Select(x => x.ClassroomId)
|
||||||
.ToHashSet() ?? [];
|
.ToHashSet() ?? [];
|
||||||
@@ -273,10 +290,17 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
room.Building!.CampusId == requiredCampusId) &&
|
room.Building!.CampusId == requiredCampusId) &&
|
||||||
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
||||||
room.BuildingId == requiredBuildingId) &&
|
room.BuildingId == requiredBuildingId) &&
|
||||||
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
|
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
|
||||||
|
(kind != ScheduleEntryKind.Experiment || IsExperimentRoom(room.RoomType)))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsExperimentRoom(string roomType) =>
|
||||||
|
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static int[] ParseAllowedDays(string? value)
|
private static int[] ParseAllowedDays(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
|
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
|
||||||
|
|||||||
@@ -191,57 +191,30 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
CoursePracticeHours = x.Course.PracticeHours
|
CoursePracticeHours = x.Course.PracticeHours
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var invalidHours = requiredTasks.FirstOrDefault(task =>
|
|
||||||
!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
|
||||||
task.CourseTotalHours,
|
|
||||||
task.CoursePracticeHours,
|
|
||||||
task.StartWeek,
|
|
||||||
task.EndWeek,
|
|
||||||
out _));
|
|
||||||
if (invalidHours is not null)
|
|
||||||
{
|
|
||||||
throw new SchedulePublishValidationException(
|
|
||||||
$"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" +
|
|
||||||
"不能按授课周次整除,请先调整教学任务周次。");
|
|
||||||
}
|
|
||||||
|
|
||||||
var requiredWeeklyHours = requiredTasks
|
|
||||||
.Select(task =>
|
|
||||||
{
|
|
||||||
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
|
||||||
task.CourseTotalHours,
|
|
||||||
task.CoursePracticeHours,
|
|
||||||
task.StartWeek,
|
|
||||||
task.EndWeek,
|
|
||||||
out var hours);
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
Task = task,
|
|
||||||
Hours = hours
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
var scheduledHours = plan.Entries
|
var scheduledHours = plan.Entries
|
||||||
.GroupBy(x => x.TeachingTaskId)
|
.GroupBy(x => new { x.TeachingTaskId, x.Kind })
|
||||||
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
.ToDictionary(
|
||||||
var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
|
group => (group.Key.TeachingTaskId, group.Key.Kind),
|
||||||
scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
|
group => group.Sum(TeachingTaskHours.ScheduledHours));
|
||||||
if (incomplete is not null)
|
foreach (var task in requiredTasks)
|
||||||
{
|
{
|
||||||
throw new SchedulePublishValidationException(
|
var targets = new[]
|
||||||
$"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
|
{
|
||||||
$"{incomplete.Hours} 个普通排课学时,不能发布。");
|
(Kind: ScheduleEntryKind.Lecture,
|
||||||
}
|
Hours: Math.Max(0, task.CourseTotalHours - task.CoursePracticeHours),
|
||||||
|
Label: "理论课"),
|
||||||
var excessive = requiredWeeklyHours.FirstOrDefault(item =>
|
(Kind: ScheduleEntryKind.Experiment,
|
||||||
scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours);
|
Hours: Math.Max(0, task.CoursePracticeHours),
|
||||||
if (excessive is not null)
|
Label: "实验课")
|
||||||
{
|
};
|
||||||
var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id);
|
foreach (var target in targets)
|
||||||
throw new SchedulePublishValidationException(
|
{
|
||||||
$"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " +
|
var actual = scheduledHours.GetValueOrDefault((task.Id, target.Kind));
|
||||||
$"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" +
|
if (actual == target.Hours) continue;
|
||||||
"请删除已包含的实践学时后再发布。");
|
throw new SchedulePublishValidationException(
|
||||||
|
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
|
||||||
|
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
||||||
@@ -275,7 +248,8 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
Fail(entry, "排课周次不在教学任务的授课周次内");
|
Fail(entry, "排课周次不在教学任务的授课周次内");
|
||||||
|
|
||||||
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
|
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
|
||||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
var requiresClassroom = entry.Kind == ScheduleEntryKind.Experiment ||
|
||||||
|
constraint?.RequiresClassroom != false;
|
||||||
if (requiresClassroom && entry.ClassroomId is null)
|
if (requiresClassroom && entry.ClassroomId is null)
|
||||||
Fail(entry, "该课程需要占用教室");
|
Fail(entry, "该课程需要占用教室");
|
||||||
if (!requiresClassroom && entry.ClassroomId is not null)
|
if (!requiresClassroom && entry.ClassroomId is not null)
|
||||||
@@ -296,6 +270,9 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
{
|
{
|
||||||
if (classroom is null || !classroom.IsEnabled)
|
if (classroom is null || !classroom.IsEnabled)
|
||||||
Fail(entry, "所选教室不存在或已停用");
|
Fail(entry, "所选教室不存在或已停用");
|
||||||
|
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
!IsExperimentRoom(classroom.RoomType))
|
||||||
|
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
|
||||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||||
classroom.Building!.CampusId != campusId)
|
classroom.Building!.CampusId != campusId)
|
||||||
Fail(entry, "所选教室不在指定校区");
|
Fail(entry, "所选教室不在指定校区");
|
||||||
@@ -328,6 +305,12 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
|
private static bool IsExperimentRoom(string roomType) =>
|
||||||
|
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
[DoesNotReturn]
|
[DoesNotReturn]
|
||||||
private static void Fail(ScheduleEntry entry, string message)
|
private static void Fail(ScheduleEntry entry, string message)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,10 +16,35 @@ public static class TeachingTaskHours
|
|||||||
public static int TargetHours(
|
public static int TargetHours(
|
||||||
Course course,
|
Course course,
|
||||||
TeachingTaskSchedulingMode schedulingMode) =>
|
TeachingTaskSchedulingMode schedulingMode) =>
|
||||||
schedulingMode == TeachingTaskSchedulingMode.Flexible
|
course.TotalHours;
|
||||||
? course.TotalHours
|
|
||||||
|
public static int TargetHours(Course course, ScheduleEntryKind kind) =>
|
||||||
|
kind == ScheduleEntryKind.Experiment
|
||||||
|
? Math.Max(0, course.PracticeHours)
|
||||||
: RegularScheduleHours(course);
|
: RegularScheduleHours(course);
|
||||||
|
|
||||||
|
public static int ScheduledHours(ScheduleEntry entry) =>
|
||||||
|
ScheduledHours(
|
||||||
|
entry.StartWeek,
|
||||||
|
entry.EndWeek,
|
||||||
|
entry.WeekPattern,
|
||||||
|
entry.PeriodCount);
|
||||||
|
|
||||||
|
public static int ScheduledHours(
|
||||||
|
int startWeek,
|
||||||
|
int endWeek,
|
||||||
|
WeekPattern weekPattern,
|
||||||
|
int periodCount)
|
||||||
|
{
|
||||||
|
if (endWeek < startWeek || periodCount <= 0) return 0;
|
||||||
|
var occurrences = Enumerable.Range(startWeek, endWeek - startWeek + 1)
|
||||||
|
.Count(week =>
|
||||||
|
weekPattern == WeekPattern.All ||
|
||||||
|
weekPattern == WeekPattern.Odd && week % 2 == 1 ||
|
||||||
|
weekPattern == WeekPattern.Even && week % 2 == 0);
|
||||||
|
return occurrences * periodCount;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool TryResolveRegularWeeklyHours(
|
public static bool TryResolveRegularWeeklyHours(
|
||||||
Course course,
|
Course course,
|
||||||
int startWeek,
|
int startWeek,
|
||||||
@@ -62,14 +87,8 @@ public static class TeachingTaskHours
|
|||||||
|
|
||||||
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
|
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||||
{
|
{
|
||||||
if (targetHours == 0)
|
|
||||||
{
|
|
||||||
return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," +
|
|
||||||
"无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。";
|
|
||||||
}
|
|
||||||
|
|
||||||
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
|
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
|
||||||
$"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " +
|
$"{course.PracticeHours};理论课和实验课均应进入课表。当前第 " +
|
||||||
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
||||||
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
allowUnpublishedPlan,
|
allowUnpublishedPlan,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var slots = await db.ScheduleTimeSlots.AsNoTracking()
|
var slots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.TagWith("Timetable.LoadTimeSlots")
|
||||||
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
|
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
|
||||||
.OrderBy(x => x.PeriodNumber)
|
.OrderBy(x => x.PeriodNumber)
|
||||||
.Select(x => new TimetableSlotDto(
|
.Select(x => new TimetableSlotDto(
|
||||||
@@ -76,6 +77,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries = await source
|
entries = await source
|
||||||
|
.TagWith("Timetable.LoadScheduleEntries")
|
||||||
.OrderBy(x => x.DayOfWeek)
|
.OrderBy(x => x.DayOfWeek)
|
||||||
.ThenBy(x => x.StartPeriod)
|
.ThenBy(x => x.StartPeriod)
|
||||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||||
@@ -104,7 +106,14 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
x.UpdatedAt))
|
x.UpdatedAt,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
x.Kind))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +292,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await source
|
return await source
|
||||||
|
.TagWith("Timetable.LoadFlexibleCourses")
|
||||||
.OrderBy(x => x.Course!.Code)
|
.OrderBy(x => x.Course!.Code)
|
||||||
.ThenBy(x => x.TaskNumber)
|
.ThenBy(x => x.TaskNumber)
|
||||||
.Select(x => new FlexibleCourseDto(
|
.Select(x => new FlexibleCourseDto(
|
||||||
@@ -326,9 +336,11 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
legacyQuery, resourceType, resourceId, studentId);
|
legacyQuery, resourceType, resourceId, studentId);
|
||||||
|
|
||||||
var legacySessions = await legacyQuery
|
var legacySessions = await legacyQuery
|
||||||
|
.TagWith("Timetable.LoadLegacyExamEntries")
|
||||||
.OrderBy(x => x.ExamDate)
|
.OrderBy(x => x.ExamDate)
|
||||||
.ThenBy(x => x.StartPeriod)
|
.ThenBy(x => x.StartPeriod)
|
||||||
.Select(x => new ExamSessionProjection(
|
.Select(x => new ExamSessionProjection(
|
||||||
|
x.Id,
|
||||||
x.Id,
|
x.Id,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
@@ -356,71 +368,58 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
MapToEntryDto(x, slotLookup)));
|
MapToEntryDto(x, slotLookup)));
|
||||||
|
|
||||||
// ── Mixed-room sessions (ExamRoomAssignment) ──
|
// ── Mixed-room sessions (ExamRoomAssignment) ──
|
||||||
// Load all mixed rooms for this term into memory, then filter
|
// Start from room/session links so the resource predicate stays in SQL.
|
||||||
var allMixedRooms = await db.ExamRooms.AsNoTracking()
|
// Loading every room, seat and roster for the term made a single
|
||||||
.Where(room =>
|
// timetable request scale with the entire exam plan.
|
||||||
room.ExamPlan!.AcademicTermId == academicTermId &&
|
var mixedQuery = db.ExamRoomSessions.AsNoTracking()
|
||||||
room.ExamPlan.Status == ExamPlanStatus.Published)
|
.Where(link =>
|
||||||
.Include(room => room.ExamPlan)
|
link.ExamRoom!.ExamPlan!.AcademicTermId == academicTermId &&
|
||||||
.Include(room => room.Classroom)
|
link.ExamRoom.ExamPlan.Status == ExamPlanStatus.Published);
|
||||||
.ThenInclude(c => c!.Building)
|
mixedQuery = ApplyMixedResourceFilter(
|
||||||
.ThenInclude(b => b!.Campus)
|
mixedQuery, resourceType, resourceId, studentId);
|
||||||
.Include(room => room.Invigilators)
|
|
||||||
.ThenInclude(i => i.Teacher)
|
var mixedSessions = await mixedQuery
|
||||||
.Include(room => room.SessionLinks)
|
.TagWith("Timetable.LoadMixedExamEntries")
|
||||||
.ThenInclude(link => link.ExamSession)
|
|
||||||
.ThenInclude(s => s!.TeachingTask)
|
|
||||||
.ThenInclude(t => t!.Course)
|
|
||||||
.Include(room => room.SessionLinks)
|
|
||||||
.ThenInclude(link => link.ExamSession)
|
|
||||||
.ThenInclude(s => s!.TeachingTask)
|
|
||||||
.ThenInclude(t => t!.Teachers)
|
|
||||||
.ThenInclude(tt => tt.Teacher)
|
|
||||||
.Include(room => room.SessionLinks)
|
|
||||||
.ThenInclude(link => link.ExamSession)
|
|
||||||
.ThenInclude(s => s!.TeachingTask)
|
|
||||||
.ThenInclude(t => t!.Classes)
|
|
||||||
.ThenInclude(tc => tc.AdministrativeClass)
|
|
||||||
.Include(room => room.Seats)
|
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
|
.OrderBy(link => link.ExamRoom!.ExamDate)
|
||||||
|
.ThenBy(link => link.ExamRoom!.StartPeriod)
|
||||||
|
.ThenBy(link => link.ExamSession!.TeachingTask!.Course!.Code)
|
||||||
|
.Select(link => new ExamSessionProjection(
|
||||||
|
link.ExamSessionId,
|
||||||
|
link.ExamRoomId,
|
||||||
|
link.ExamSession!.TeachingTaskId,
|
||||||
|
link.ExamSession.TeachingTask!.TaskNumber,
|
||||||
|
link.ExamSession.TeachingTask.Name,
|
||||||
|
link.ExamSession.TeachingTask.Course!.Code,
|
||||||
|
link.ExamSession.TeachingTask.Course.Name,
|
||||||
|
link.ExamSession.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(t => t.IsPrimary)
|
||||||
|
.Select(t => t.Teacher!.Name).ToList(),
|
||||||
|
link.ExamSession.TeachingTask.Classes
|
||||||
|
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
||||||
|
link.ExamRoom!.Classroom!.Name,
|
||||||
|
link.ExamRoom.Classroom.Building!.Name,
|
||||||
|
link.ExamRoom.Classroom.Building.Campus!.Name,
|
||||||
|
link.ExamRoom.ExamDate,
|
||||||
|
link.ExamRoom.StartPeriod,
|
||||||
|
link.ExamRoom.PeriodCount,
|
||||||
|
link.ExamRoom.ExamPlan!.Name,
|
||||||
|
link.ExamRoom.Invigilators
|
||||||
|
.Select(i => i.Teacher!.Name).ToList(),
|
||||||
|
null,
|
||||||
|
link.ExamRoom.UpdatedAt))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
foreach (var room in allMixedRooms)
|
if (resourceType == TimetableResourceType.Class && !studentId.HasValue)
|
||||||
{
|
{
|
||||||
foreach (var link in room.SessionLinks)
|
result.AddRange(mixedSessions
|
||||||
{
|
.GroupBy(x => x.ExamSessionId)
|
||||||
var session = link.ExamSession;
|
.Select(group => MapClassExamEntryDto(group, slotLookup)));
|
||||||
if (session == null || session.TeachingTask == null) continue;
|
}
|
||||||
|
else
|
||||||
// Apply resource filter in memory
|
{
|
||||||
if (!MatchesMixedResource(
|
result.AddRange(mixedSessions.Select(x =>
|
||||||
room, session, resourceType, resourceId, studentId))
|
MapToEntryDto(x, slotLookup)));
|
||||||
continue;
|
|
||||||
|
|
||||||
result.Add(MapToEntryDto(new ExamSessionProjection(
|
|
||||||
room.Id,
|
|
||||||
session.TeachingTaskId,
|
|
||||||
session.TeachingTask.TaskNumber,
|
|
||||||
session.TeachingTask.Name,
|
|
||||||
session.TeachingTask.Course!.Code,
|
|
||||||
session.TeachingTask.Course.Name,
|
|
||||||
session.TeachingTask.Teachers
|
|
||||||
.OrderByDescending(t => t.IsPrimary)
|
|
||||||
.Select(t => t.Teacher!.Name).ToList(),
|
|
||||||
session.TeachingTask.Classes
|
|
||||||
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
|
||||||
room.Classroom!.Name,
|
|
||||||
room.Classroom.Building!.Name,
|
|
||||||
room.Classroom.Building.Campus!.Name,
|
|
||||||
room.ExamDate,
|
|
||||||
room.StartPeriod,
|
|
||||||
room.PeriodCount,
|
|
||||||
room.ExamPlan!.Name,
|
|
||||||
room.Invigilators
|
|
||||||
.Select(i => i.Teacher!.Name).ToList(),
|
|
||||||
null,
|
|
||||||
room.UpdatedAt), slotLookup));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -439,6 +438,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
db,
|
db,
|
||||||
studentId.Value);
|
studentId.Value);
|
||||||
var sessions = await db.ExperimentSessions.AsNoTracking()
|
var sessions = await db.ExperimentSessions.AsNoTracking()
|
||||||
|
.TagWith("Timetable.LoadExperimentEntries")
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id &&
|
x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id &&
|
||||||
@@ -535,9 +535,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
return date.AddDays(1 - dayOfWeek);
|
return date.AddDays(1 - dayOfWeek);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesMixedResource(
|
private static IQueryable<ExamRoomSession> ApplyMixedResourceFilter(
|
||||||
ExamRoomAssignment room,
|
IQueryable<ExamRoomSession> source,
|
||||||
ExamSession session,
|
|
||||||
TimetableResourceType resourceType,
|
TimetableResourceType resourceType,
|
||||||
Guid resourceId,
|
Guid resourceId,
|
||||||
Guid? studentId)
|
Guid? studentId)
|
||||||
@@ -545,20 +544,27 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
switch (resourceType)
|
switch (resourceType)
|
||||||
{
|
{
|
||||||
case TimetableResourceType.Classroom:
|
case TimetableResourceType.Classroom:
|
||||||
return room.ClassroomId == resourceId;
|
return source.Where(link =>
|
||||||
|
link.ExamRoom!.ClassroomId == resourceId);
|
||||||
case TimetableResourceType.Teacher:
|
case TimetableResourceType.Teacher:
|
||||||
return room.Invigilators.Any(i => i.TeacherId == resourceId) ||
|
return source.Where(link =>
|
||||||
session.TeachingTask!.Teachers.Any(
|
link.ExamRoom!.Invigilators.Any(i =>
|
||||||
t => t.TeacherId == resourceId);
|
i.TeacherId == resourceId) ||
|
||||||
|
link.ExamSession!.TeachingTask!.Teachers.Any(t =>
|
||||||
|
t.TeacherId == resourceId));
|
||||||
case TimetableResourceType.Class:
|
case TimetableResourceType.Class:
|
||||||
if (studentId.HasValue)
|
if (studentId.HasValue)
|
||||||
return session.TeachingTask!.Classes.Any(c =>
|
{
|
||||||
c.AdministrativeClassId == resourceId) ||
|
return source.Where(link =>
|
||||||
room.Seats.Any(s => s.StudentId == studentId.Value);
|
link.ExamRoom!.Seats.Any(s =>
|
||||||
return session.TeachingTask!.Classes.Any(c =>
|
s.StudentId == studentId.Value &&
|
||||||
c.AdministrativeClassId == resourceId);
|
s.ExamSessionId == link.ExamSessionId));
|
||||||
|
}
|
||||||
|
return source.Where(link =>
|
||||||
|
link.ExamSession!.TeachingTask!.Classes.Any(c =>
|
||||||
|
c.AdministrativeClassId == resourceId));
|
||||||
default:
|
default:
|
||||||
return true;
|
return source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,7 +614,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
|
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
|
||||||
: x.TeacherNames;
|
: x.TeacherNames;
|
||||||
return new TimetableEntryDto(
|
return new TimetableEntryDto(
|
||||||
x.Id,
|
x.ExamRoomId,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.TaskNumber,
|
x.TaskNumber,
|
||||||
x.TaskName,
|
x.TaskName,
|
||||||
@@ -631,8 +637,42 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
x.UpdatedAt);
|
x.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TimetableEntryDto MapClassExamEntryDto(
|
||||||
|
IEnumerable<ExamSessionProjection> sessions,
|
||||||
|
IReadOnlyDictionary<int, TimetableSlotDto> slotLookup)
|
||||||
|
{
|
||||||
|
var rooms = sessions.ToList();
|
||||||
|
var first = rooms[0];
|
||||||
|
var roomCount = rooms
|
||||||
|
.Select(x => x.ExamRoomId)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
var buildingNames = rooms
|
||||||
|
.Select(x => x.BuildingName)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var campusNames = rooms
|
||||||
|
.Select(x => x.CampusName)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var entry = MapToEntryDto(first, slotLookup);
|
||||||
|
return entry with
|
||||||
|
{
|
||||||
|
Id = first.ExamSessionId,
|
||||||
|
TeacherNames = first.TeacherNames,
|
||||||
|
ClassroomName = roomCount == 1
|
||||||
|
? first.ClassroomName
|
||||||
|
: $"分散至 {roomCount} 个考场",
|
||||||
|
BuildingName = buildingNames.Count == 1 ? buildingNames[0] : null,
|
||||||
|
CampusName = campusNames.Count == 1 ? campusNames[0] : null,
|
||||||
|
UpdatedAt = rooms.Max(x => x.UpdatedAt),
|
||||||
|
ExamRoomCount = roomCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record ExamSessionProjection(
|
private sealed record ExamSessionProjection(
|
||||||
Guid Id,
|
Guid ExamSessionId,
|
||||||
|
Guid ExamRoomId,
|
||||||
Guid TeachingTaskId,
|
Guid TeachingTaskId,
|
||||||
string TaskNumber,
|
string TaskNumber,
|
||||||
string TaskName,
|
string TaskName,
|
||||||
@@ -762,7 +802,9 @@ public sealed record TimetableEntryDto(
|
|||||||
string? ExperimentProjectCode = null,
|
string? ExperimentProjectCode = null,
|
||||||
string? ExperimentProjectName = null,
|
string? ExperimentProjectName = null,
|
||||||
DateOnly? ExperimentDate = null,
|
DateOnly? ExperimentDate = null,
|
||||||
ExperimentArrangementMode? ExperimentArrangementMode = null);
|
ExperimentArrangementMode? ExperimentArrangementMode = null,
|
||||||
|
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture,
|
||||||
|
int ExamRoomCount = 1);
|
||||||
|
|
||||||
public sealed record FlexibleCourseDto(
|
public sealed record FlexibleCourseDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ public static class TimetableExcelExporter
|
|||||||
$"{Location(entry)}\n" +
|
$"{Location(entry)}\n" +
|
||||||
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
||||||
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
||||||
: $"{entry.CourseName}\n" +
|
: $"{(entry.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "【实验课】" : "")}{entry.CourseName}\n" +
|
||||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||||
$"{Location(entry)}\n" +
|
$"{Location(entry)}\n" +
|
||||||
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
|
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||||
<PackageReference Include="SkiaSharp" Version="3.119.2" />
|
<PackageReference Include="SkiaSharp" Version="3.119.2" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Jiaowu.Api.Infrastructure.Auth;
|
|||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Middleware;
|
using Jiaowu.Api.Infrastructure.Middleware;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||||
using Jiaowu.Api.Infrastructure.Operations;
|
using Jiaowu.Api.Infrastructure.Operations;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -19,6 +20,9 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Resources;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
EnvironmentFile.Load();
|
EnvironmentFile.Load();
|
||||||
@@ -78,6 +82,12 @@ var backgroundJobOptions = builder.Configuration
|
|||||||
var operationsOptions = builder.Configuration
|
var operationsOptions = builder.Configuration
|
||||||
.GetSection(OperationsOptions.SectionName)
|
.GetSection(OperationsOptions.SectionName)
|
||||||
.Get<OperationsOptions>() ?? new OperationsOptions();
|
.Get<OperationsOptions>() ?? new OperationsOptions();
|
||||||
|
var observabilityOptions = builder.Configuration
|
||||||
|
.GetSection(ObservabilityOptions.SectionName)
|
||||||
|
.Get<ObservabilityOptions>() ?? new ObservabilityOptions();
|
||||||
|
var performanceReportingOptions = builder.Configuration
|
||||||
|
.GetSection(PerformanceReportingOptions.SectionName)
|
||||||
|
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||||
var rabbitMqOptions = builder.Configuration
|
var rabbitMqOptions = builder.Configuration
|
||||||
.GetSection(RabbitMqOptions.SectionName)
|
.GetSection(RabbitMqOptions.SectionName)
|
||||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||||
@@ -110,6 +120,46 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
|||||||
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
||||||
|
observabilityOptions.ServiceName.Length > 100 ||
|
||||||
|
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
||||||
|
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Observability 服务名、慢查询阈值或 SQL 文本长度超出允许范围。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
|
||||||
|
performanceReportingOptions.TimeoutSeconds is < 1 or > 60 ||
|
||||||
|
performanceReportingOptions.BearerToken.Length > 8000 ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.ServiceLabel) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.RequestDurationMetric) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.DatabaseDurationMetric) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.SlowDatabaseMetric) ||
|
||||||
|
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||||
|
performanceReportingOptions.FailedDatabaseMetric) ||
|
||||||
|
(performanceReportingOptions.Enabled &&
|
||||||
|
!IsHttpUrl(performanceReportingOptions.PrometheusBaseUrl)) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(performanceReportingOptions.GrafanaBaseUrl) &&
|
||||||
|
!IsHttpUrl(performanceReportingOptions.GrafanaBaseUrl)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
|
||||||
|
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
|
||||||
|
parsedOtlpEndpoint.Scheme is not ("http" or "https")))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT 必须是有效的 HTTP 或 HTTPS 绝对地址。");
|
||||||
|
}
|
||||||
|
|
||||||
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||||
@@ -193,11 +243,23 @@ builder.Services.AddSingleton(cacheOptions);
|
|||||||
builder.Services.AddSingleton(officialDocumentOptions);
|
builder.Services.AddSingleton(officialDocumentOptions);
|
||||||
builder.Services.AddSingleton(backgroundJobOptions);
|
builder.Services.AddSingleton(backgroundJobOptions);
|
||||||
builder.Services.AddSingleton(operationsOptions);
|
builder.Services.AddSingleton(operationsOptions);
|
||||||
|
builder.Services.AddSingleton(observabilityOptions);
|
||||||
|
builder.Services.AddSingleton(performanceReportingOptions);
|
||||||
builder.Services.AddSingleton(rabbitMqOptions);
|
builder.Services.AddSingleton(rabbitMqOptions);
|
||||||
|
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||||
|
{
|
||||||
|
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
|
||||||
|
});
|
||||||
builder.Services.Configure<OfficialDocumentOptions>(
|
builder.Services.Configure<OfficialDocumentOptions>(
|
||||||
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||||
builder.Services.AddDbContextPool<AppDbContext>(options =>
|
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
||||||
{
|
{
|
||||||
|
options.AddInterceptors(
|
||||||
|
services.GetRequiredService<DatabaseCommandTelemetryInterceptor>());
|
||||||
|
|
||||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var sqliteConnectionString = builder.Configuration.GetConnectionString("SQLite")
|
var sqliteConnectionString = builder.Configuration.GetConnectionString("SQLite")
|
||||||
@@ -240,6 +302,28 @@ builder.Services.AddDbContextPool<AppDbContext>(options =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (observabilityOptions.Enabled &&
|
||||||
|
!string.IsNullOrWhiteSpace(otlpEndpoint))
|
||||||
|
{
|
||||||
|
builder.Services
|
||||||
|
.AddOpenTelemetry()
|
||||||
|
.ConfigureResource(resource =>
|
||||||
|
resource.AddService(observabilityOptions.ServiceName))
|
||||||
|
.WithMetrics(metrics => metrics
|
||||||
|
.AddAspNetCoreInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddRuntimeInstrumentation()
|
||||||
|
.AddMeter(DatabaseCommandTelemetryInterceptor.MeterName))
|
||||||
|
.WithTracing(tracing => tracing
|
||||||
|
.AddAspNetCoreInstrumentation(options =>
|
||||||
|
options.Filter = context =>
|
||||||
|
!context.Request.Path.StartsWithSegments("/health/live"))
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddSource(DatabaseCommandTelemetryInterceptor.ActivitySourceName))
|
||||||
|
.WithMetrics(metrics => metrics.AddOtlpExporter())
|
||||||
|
.WithTracing(tracing => tracing.AddOtlpExporter());
|
||||||
|
}
|
||||||
|
|
||||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
||||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||||
{
|
{
|
||||||
@@ -616,4 +700,8 @@ static async Task<IResult> CheckMessagingHealthAsync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool IsHttpUrl(string value) =>
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
|
||||||
|
uri.Scheme is "http" or "https";
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -19,6 +19,26 @@
|
|||||||
"AnalyticsLocalExpirationSeconds": 30,
|
"AnalyticsLocalExpirationSeconds": 30,
|
||||||
"MaximumPayloadKilobytes": 2048
|
"MaximumPayloadKilobytes": 2048
|
||||||
},
|
},
|
||||||
|
"Observability": {
|
||||||
|
"Enabled": true,
|
||||||
|
"ServiceName": "jiaowu-api",
|
||||||
|
"SlowQueryThresholdMilliseconds": 500,
|
||||||
|
"IncludeSqlText": false,
|
||||||
|
"MaximumSqlTextLength": 2000
|
||||||
|
},
|
||||||
|
"PerformanceReporting": {
|
||||||
|
"Enabled": false,
|
||||||
|
"PrometheusBaseUrl": "",
|
||||||
|
"BearerToken": "",
|
||||||
|
"GrafanaBaseUrl": "",
|
||||||
|
"CacheSeconds": 30,
|
||||||
|
"TimeoutSeconds": 10,
|
||||||
|
"ServiceLabel": "service_name",
|
||||||
|
"RequestDurationMetric": "http_server_request_duration_seconds",
|
||||||
|
"DatabaseDurationMetric": "jiaowu_db_command_duration_milliseconds",
|
||||||
|
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
|
||||||
|
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
|
||||||
|
},
|
||||||
"Operations": {
|
"Operations": {
|
||||||
"BackupDirectory": "data/backups",
|
"BackupDirectory": "data/backups",
|
||||||
"BackupWarningHours": 24,
|
"BackupWarningHours": 24,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -157,6 +158,21 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
EndDate = new DateOnly(2027, 1, 15)
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
};
|
};
|
||||||
var college = new College { Code = "LAB", Name = "实验学院" };
|
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||||
|
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "LAB-BUILDING",
|
||||||
|
Name = "实验楼",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var laboratory = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB-101",
|
||||||
|
Name = "实验室 101",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 40,
|
||||||
|
RoomType = "实验室"
|
||||||
|
};
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
Code = "LAB-01",
|
Code = "LAB-01",
|
||||||
@@ -185,7 +201,7 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
Name = "实验学时拆分测试",
|
Name = "实验学时拆分测试",
|
||||||
Version = "V1"
|
Version = "V1"
|
||||||
};
|
};
|
||||||
db.AddRange(term, college, course, task, plan);
|
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
|
||||||
db.ScheduleTimeSlots.AddRange(
|
db.ScheduleTimeSlots.AddRange(
|
||||||
new ScheduleTimeSlot
|
new ScheduleTimeSlot
|
||||||
{
|
{
|
||||||
@@ -203,22 +219,22 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
StartsAt = new TimeOnly(8, 55),
|
StartsAt = new TimeOnly(8, 55),
|
||||||
EndsAt = new TimeOnly(9, 40)
|
EndsAt = new TimeOnly(9, 40)
|
||||||
});
|
});
|
||||||
db.TeachingTaskScheduleConstraints.Add(
|
|
||||||
new TeachingTaskScheduleConstraint
|
|
||||||
{
|
|
||||||
TeachingTask = task,
|
|
||||||
RequiresClassroom = false
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var result = await new AutomaticScheduleGenerator(db)
|
var result = await new AutomaticScheduleGenerator(db)
|
||||||
.GenerateAsync(plan, CancellationToken.None);
|
.GenerateAsync(plan, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(1, result.CreatedEntries);
|
Assert.Equal(2, result.CreatedEntries);
|
||||||
Assert.Equal(1, result.CompletedTasks);
|
Assert.Equal(1, result.CompletedTasks);
|
||||||
Assert.Equal(
|
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
|
||||||
1,
|
Assert.Equal(2, entries.Count);
|
||||||
(await db.ScheduleEntries.SingleAsync()).PeriodCount);
|
Assert.Contains(entries, x =>
|
||||||
|
x.Kind == ScheduleEntryKind.Lecture &&
|
||||||
|
TeachingTaskHours.ScheduledHours(x) == 16);
|
||||||
|
Assert.Contains(entries, x =>
|
||||||
|
x.Kind == ScheduleEntryKind.Experiment &&
|
||||||
|
x.ClassroomId == laboratory.Id &&
|
||||||
|
TeachingTaskHours.ScheduledHours(x) == 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Query_name_uses_tag_without_exposing_statement_text()
|
||||||
|
{
|
||||||
|
const string sql =
|
||||||
|
"-- Timetable.LoadMixedExamEntries\n" +
|
||||||
|
"SELECT * FROM ExamRooms WHERE SecretValue = @p0";
|
||||||
|
|
||||||
|
var queryName =
|
||||||
|
DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
|
||||||
|
|
||||||
|
Assert.Equal("Timetable.LoadMixedExamEntries", queryName);
|
||||||
|
Assert.DoesNotContain("SecretValue", queryName);
|
||||||
|
Assert.DoesNotContain("@p0", queryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Untagged_query_name_is_stable_hash_not_statement_text()
|
||||||
|
{
|
||||||
|
const string sql =
|
||||||
|
"SELECT * FROM Students WHERE StudentNumber = @studentNumber";
|
||||||
|
|
||||||
|
var first = DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
|
||||||
|
var second = DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
|
||||||
|
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
Assert.StartsWith("select:", first);
|
||||||
|
Assert.DoesNotContain("Students", first);
|
||||||
|
Assert.DoesNotContain("StudentNumber", first);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ef_command_records_duration_metric()
|
||||||
|
{
|
||||||
|
await using var connection =
|
||||||
|
new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var setupOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using (var setupDb = new AppDbContext(setupOptions))
|
||||||
|
await setupDb.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
double? recordedDuration = null;
|
||||||
|
using var listener = new MeterListener
|
||||||
|
{
|
||||||
|
InstrumentPublished = (instrument, meterListener) =>
|
||||||
|
{
|
||||||
|
if (instrument.Meter.Name ==
|
||||||
|
DatabaseCommandTelemetryInterceptor.MeterName &&
|
||||||
|
instrument.Name == "jiaowu.db.command.duration")
|
||||||
|
{
|
||||||
|
meterListener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<double>(
|
||||||
|
(_, measurement, _, _) => recordedDuration = measurement);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
var interceptor = new DatabaseCommandTelemetryInterceptor(
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
||||||
|
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.AddInterceptors(interceptor)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(queryOptions);
|
||||||
|
|
||||||
|
await db.AcademicTerms
|
||||||
|
.TagWith("Observability.Tests.TermCount")
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(recordedDuration);
|
||||||
|
Assert.True(recordedDuration >= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -55,6 +56,48 @@ public sealed class ExamArrangementServiceTests
|
|||||||
Assert.Contains("1个教学班场次处理完成", result.Message);
|
Assert.Contains("1个教学班场次处理完成", result.Message);
|
||||||
Assert.Contains("1名监考教师", result.Message);
|
Assert.Contains("1名监考教师", result.Message);
|
||||||
Assert.Contains("程序设计", result.Message);
|
Assert.Contains("程序设计", result.Message);
|
||||||
|
|
||||||
|
plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id);
|
||||||
|
plan.Status = ExamPlanStatus.Published;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var timetableService = new TimetableDataService(db);
|
||||||
|
var classroomTimetable = await timetableService.BuildAsync(
|
||||||
|
TimetableResourceType.Classroom,
|
||||||
|
seed.LargeClassroom.Id,
|
||||||
|
seed.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
var unrelatedClassroomTimetable = await timetableService.BuildAsync(
|
||||||
|
TimetableResourceType.Classroom,
|
||||||
|
seed.SmallClassroom.Id,
|
||||||
|
seed.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
var invigilatorTimetable = await timetableService.BuildAsync(
|
||||||
|
TimetableResourceType.Teacher,
|
||||||
|
room.Invigilators.Single().TeacherId,
|
||||||
|
seed.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"程序设计",
|
||||||
|
Assert.Single(classroomTimetable!.ExamEntries).CourseName);
|
||||||
|
Assert.Empty(unrelatedClassroomTimetable!.ExamEntries);
|
||||||
|
Assert.Contains(
|
||||||
|
"监考:监考教师",
|
||||||
|
Assert.Single(invigilatorTimetable!.ExamEntries).TeacherNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -108,6 +151,46 @@ public sealed class ExamArrangementServiceTests
|
|||||||
Assert.Equal(6, await db.ExamSeats.CountAsync());
|
Assert.Equal(6, await db.ExamSeats.CountAsync());
|
||||||
Assert.Contains("2个教学班混排至2个考场", result.Message);
|
Assert.Contains("2个教学班混排至2个考场", result.Message);
|
||||||
Assert.Contains("自动包含同组1个场次", result.Message);
|
Assert.Contains("自动包含同组1个场次", result.Message);
|
||||||
|
|
||||||
|
plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id);
|
||||||
|
plan.Status = ExamPlanStatus.Published;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.FirstAsync();
|
||||||
|
var timetableService = new TimetableDataService(db);
|
||||||
|
var classTimetable = await timetableService.BuildAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
student.AdministrativeClassId,
|
||||||
|
seed.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
var studentTimetable = await timetableService.BuildAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
student.AdministrativeClassId,
|
||||||
|
seed.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
student.Id,
|
||||||
|
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var classExam = Assert.Single(classTimetable!.ExamEntries);
|
||||||
|
Assert.Equal(first.Id, classExam.Id);
|
||||||
|
Assert.Equal(2, classExam.ExamRoomCount);
|
||||||
|
Assert.Equal("分散至 2 个考场", classExam.ClassroomName);
|
||||||
|
Assert.DoesNotContain("监考:", classExam.TeacherNames);
|
||||||
|
|
||||||
|
var studentExam = Assert.Single(studentTimetable!.ExamEntries);
|
||||||
|
Assert.Equal(1, studentExam.ExamRoomCount);
|
||||||
|
Assert.Contains(
|
||||||
|
studentExam.ClassroomName,
|
||||||
|
new[] { seed.LargeClassroom.Name, seed.SmallClassroom.Name });
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -12,6 +12,112 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class ExperimentsControllerTests
|
public sealed class ExperimentsControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchProjects_CreateSameDefinitionForSameCourseTasks()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
|
||||||
|
var result = await controller.CreateProjects(
|
||||||
|
fixture.BatchProjectRequest(
|
||||||
|
ExperimentArrangementMode.Centralized),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
var projects = await fixture.Db.ExperimentProjects
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(2, projects.Count);
|
||||||
|
Assert.Equal(2, projects.Select(x => x.TeachingTaskId).Distinct().Count());
|
||||||
|
Assert.All(projects, project =>
|
||||||
|
{
|
||||||
|
Assert.Equal("LAB-BATCH", project.Code);
|
||||||
|
Assert.Equal("公共实验任务", project.Name);
|
||||||
|
Assert.Equal(ExperimentProjectStatus.Draft, project.Status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await controller.CreateProjects(
|
||||||
|
fixture.BatchProjectRequest(
|
||||||
|
ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var projectIds = await fixture.Db.ExperimentProjects
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = await controller.CreateSessions(
|
||||||
|
new ExperimentSessionBatchRequest(
|
||||||
|
[
|
||||||
|
new ExperimentSessionBatchItem(
|
||||||
|
projectIds[0],
|
||||||
|
fixture.Classroom.Id,
|
||||||
|
fixture.Term.StartDate,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
20,
|
||||||
|
null),
|
||||||
|
new ExperimentSessionBatchItem(
|
||||||
|
projectIds[1],
|
||||||
|
fixture.Classroom.Id,
|
||||||
|
fixture.Term.StartDate,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
20,
|
||||||
|
null)
|
||||||
|
]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchSessions_CreatesRowsAtomicallyWhenAllAreValid()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await controller.CreateProjects(
|
||||||
|
fixture.BatchProjectRequest(
|
||||||
|
ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var projectIds = await fixture.Db.ExperimentProjects
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = await controller.CreateSessions(
|
||||||
|
new ExperimentSessionBatchRequest(
|
||||||
|
[
|
||||||
|
new ExperimentSessionBatchItem(
|
||||||
|
projectIds[0],
|
||||||
|
fixture.Classroom.Id,
|
||||||
|
fixture.Term.StartDate,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
20,
|
||||||
|
null),
|
||||||
|
new ExperimentSessionBatchItem(
|
||||||
|
projectIds[1],
|
||||||
|
fixture.SecondClassroom.Id,
|
||||||
|
fixture.Term.StartDate,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
20,
|
||||||
|
null)
|
||||||
|
]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
|
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
|
||||||
{
|
{
|
||||||
@@ -421,6 +527,15 @@ public sealed class ExperimentsControllerTests
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
var secondTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2099-1-CSLAB-02",
|
||||||
|
Name = "系统实验教学班 02",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 40,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
db.AddRange(
|
db.AddRange(
|
||||||
manager,
|
manager,
|
||||||
studentUser,
|
studentUser,
|
||||||
@@ -435,7 +550,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
teacher,
|
teacher,
|
||||||
term,
|
term,
|
||||||
course,
|
course,
|
||||||
task);
|
task,
|
||||||
|
secondTask);
|
||||||
for (var period = 1; period <= 12; period++)
|
for (var period = 1; period <= 12; period++)
|
||||||
{
|
{
|
||||||
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
|
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
|
||||||
@@ -486,6 +602,21 @@ public sealed class ExperimentsControllerTests
|
|||||||
Term.StartDate,
|
Term.StartDate,
|
||||||
Term.StartDate.AddDays(14));
|
Term.StartDate.AddDays(14));
|
||||||
|
|
||||||
|
public ExperimentProjectBatchRequest BatchProjectRequest(
|
||||||
|
ExperimentArrangementMode mode) =>
|
||||||
|
new(
|
||||||
|
Db.TeachingTasks
|
||||||
|
.OrderBy(x => x.TaskNumber)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToList(),
|
||||||
|
"LAB-BATCH",
|
||||||
|
"公共实验任务",
|
||||||
|
mode,
|
||||||
|
"多个教学任务共用的实验内容。",
|
||||||
|
"携带校园卡。",
|
||||||
|
Term.StartDate,
|
||||||
|
Term.StartDate.AddDays(14));
|
||||||
|
|
||||||
public ExperimentSessionRequest SessionRequest(
|
public ExperimentSessionRequest SessionRequest(
|
||||||
int startPeriod,
|
int startPeriod,
|
||||||
int periodCount,
|
int periodCount,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.System;
|
|||||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Operations;
|
using Jiaowu.Api.Infrastructure.Operations;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
@@ -201,6 +202,7 @@ public sealed class OperationsControllerTests
|
|||||||
|
|
||||||
var services = new ServiceCollection()
|
var services = new ServiceCollection()
|
||||||
.AddLogging()
|
.AddLogging()
|
||||||
|
.AddMemoryCache()
|
||||||
.BuildServiceProvider();
|
.BuildServiceProvider();
|
||||||
var logger = services.GetRequiredService<
|
var logger = services.GetRequiredService<
|
||||||
ILogger<DatabaseBackupService>>();
|
ILogger<DatabaseBackupService>>();
|
||||||
@@ -224,10 +226,19 @@ public sealed class OperationsControllerTests
|
|||||||
configuration,
|
configuration,
|
||||||
environment,
|
environment,
|
||||||
logger);
|
logger);
|
||||||
|
var performance = new PerformanceReportService(
|
||||||
|
new HttpClient(),
|
||||||
|
services.GetRequiredService<
|
||||||
|
Microsoft.Extensions.Caching.Memory.IMemoryCache>(),
|
||||||
|
new PerformanceReportingOptions(),
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
services.GetRequiredService<
|
||||||
|
ILogger<PerformanceReportService>>());
|
||||||
var controller = new OperationsController(
|
var controller = new OperationsController(
|
||||||
db,
|
db,
|
||||||
health,
|
health,
|
||||||
backups,
|
backups,
|
||||||
|
performance,
|
||||||
operationsOptions);
|
operationsOptions);
|
||||||
return new OperationsFixture(services, db, controller, backups);
|
return new OperationsFixture(services, db, controller, backups);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class PerformanceReportServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Configured_source_returns_cached_native_report()
|
||||||
|
{
|
||||||
|
var handler = new PrometheusHandler();
|
||||||
|
using var httpClient = new HttpClient(handler);
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
httpClient,
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
PrometheusBaseUrl = "https://prometheus.test/",
|
||||||
|
BearerToken = "read-only-token",
|
||||||
|
GrafanaBaseUrl = "https://grafana.test/",
|
||||||
|
CacheSeconds = 30
|
||||||
|
},
|
||||||
|
new ObservabilityOptions { ServiceName = "jiaowu-api" },
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
var first = await service.GetAsync("1h", CancellationToken.None);
|
||||||
|
var second = await service.GetAsync("1h", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("ready", first.Status);
|
||||||
|
Assert.Equal("prometheus", first.DataSource);
|
||||||
|
Assert.Equal("https://grafana.test/", first.DashboardUrl);
|
||||||
|
Assert.NotNull(first.Headline);
|
||||||
|
Assert.Equal(5, first.Headline.RequestCount);
|
||||||
|
Assert.Equal(2, first.Timeline.Count);
|
||||||
|
Assert.Equal(
|
||||||
|
"/api/timetables/classes/{classId}",
|
||||||
|
Assert.Single(first.Endpoints).Name);
|
||||||
|
Assert.Equal(
|
||||||
|
"Timetable.LoadScheduleEntries",
|
||||||
|
Assert.Single(first.DatabaseQueries).Name);
|
||||||
|
Assert.Same(first, second);
|
||||||
|
Assert.Equal(14, handler.RequestCount);
|
||||||
|
Assert.True(handler.AllRequestsAuthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Missing_source_returns_directed_empty_state()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
new HttpClient(new RejectingHandler()),
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions(),
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
var report = await service.GetAsync("24h", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("not_configured", report.Status);
|
||||||
|
Assert.Contains("Prometheus", report.Detail);
|
||||||
|
Assert.Empty(report.Timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unsupported_range_is_rejected_before_querying_source()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
new HttpClient(new RejectingHandler()),
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions(),
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||||
|
service.GetAsync("30d", CancellationToken.None));
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||||
|
service.GetAsync(null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_timeout_returns_unavailable_report()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new PerformanceReportService(
|
||||||
|
new HttpClient(new TimeoutHandler()),
|
||||||
|
cache,
|
||||||
|
new PerformanceReportingOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
PrometheusBaseUrl = "https://prometheus.test/"
|
||||||
|
},
|
||||||
|
new ObservabilityOptions(),
|
||||||
|
NullLogger<PerformanceReportService>.Instance);
|
||||||
|
|
||||||
|
var report = await service.GetAsync("1h", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("unavailable", report.Status);
|
||||||
|
Assert.Contains("暂时不可用", report.Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PrometheusHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private int requestCount;
|
||||||
|
private int authenticatedCount;
|
||||||
|
|
||||||
|
public int RequestCount => requestCount;
|
||||||
|
public bool AllRequestsAuthenticated =>
|
||||||
|
authenticatedCount == requestCount;
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref requestCount);
|
||||||
|
if (request.Headers.Authorization?.Scheme == "Bearer" &&
|
||||||
|
request.Headers.Authorization.Parameter == "read-only-token")
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref authenticatedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRange = request.RequestUri!.AbsolutePath.EndsWith(
|
||||||
|
"/query_range",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
var json = isRange
|
||||||
|
? $$"""
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"resultType": "matrix",
|
||||||
|
"result": [{
|
||||||
|
"metric": {},
|
||||||
|
"values": [
|
||||||
|
[{{now - 60}}, "2"],
|
||||||
|
[{{now}}, "3"]
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
: $$"""
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"resultType": "vector",
|
||||||
|
"result": [{
|
||||||
|
"metric": {
|
||||||
|
"http_route": "/api/timetables/classes/{classId}",
|
||||||
|
"db_query_name": "Timetable.LoadScheduleEntries"
|
||||||
|
},
|
||||||
|
"value": [{{now}}, "5"]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
json,
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RejectingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"未配置时不应访问外部数据源。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TimeoutHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new TaskCanceledException("Prometheus query timed out.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,40 +32,41 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||||
Assert.Null(result.ActiveAcademicTermId);
|
Assert.Null(result.ActiveAcademicTermId);
|
||||||
Assert.Equal("检查未通过", result.CurrentStep);
|
Assert.Equal("检查未通过", result.CurrentStep);
|
||||||
Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage);
|
Assert.Contains("理论课应安排 32 学时,当前已安排 16 学时", result.ErrorMessage);
|
||||||
Assert.Null(result.PublishedAt);
|
Assert.Null(result.PublishedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Processor_excludes_practice_hours_for_existing_tasks()
|
public async Task Processor_requires_experiment_hours_in_regular_schedule()
|
||||||
{
|
{
|
||||||
var result = await RunPublishAsync(
|
var result = await RunPublishAsync(
|
||||||
weeklyHours: 2,
|
weeklyHours: 2,
|
||||||
scheduledHours: 1,
|
scheduledHours: 1,
|
||||||
practiceHours: 16);
|
practiceHours: 16,
|
||||||
|
experimentScheduledHours: 1);
|
||||||
|
|
||||||
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
||||||
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Processor_rejects_practice_hours_already_added_to_draft()
|
public async Task Processor_rejects_missing_experiment_hours()
|
||||||
{
|
{
|
||||||
var result = await RunPublishAsync(
|
var result = await RunPublishAsync(
|
||||||
weeklyHours: 2,
|
weeklyHours: 2,
|
||||||
scheduledHours: 2,
|
scheduledHours: 1,
|
||||||
practiceHours: 16);
|
practiceHours: 16);
|
||||||
|
|
||||||
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
||||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||||
Assert.Contains("普通课表应为 1 学时", result.ErrorMessage);
|
Assert.Contains("实验课应安排 16 学时,当前已安排 0 学时", result.ErrorMessage);
|
||||||
Assert.Contains("删除已包含的实践学时", result.ErrorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<PublishResult> RunPublishAsync(
|
private static async Task<PublishResult> RunPublishAsync(
|
||||||
int weeklyHours,
|
int weeklyHours,
|
||||||
int scheduledHours,
|
int scheduledHours,
|
||||||
int practiceHours = 0)
|
int practiceHours = 0,
|
||||||
|
int experimentScheduledHours = 0)
|
||||||
{
|
{
|
||||||
var databasePath = Path.Combine(
|
var databasePath = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
@@ -97,6 +98,21 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
EndDate = new DateOnly(2027, 1, 15)
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
};
|
};
|
||||||
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
||||||
|
var campus = new Campus { Code = "PUB-CAMPUS", Name = "发布测试校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "PUB-BUILDING",
|
||||||
|
Name = "实验楼",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var laboratory = new Classroom
|
||||||
|
{
|
||||||
|
Code = "PUB-LAB",
|
||||||
|
Name = "发布测试实验室",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 80,
|
||||||
|
RoomType = "实验室"
|
||||||
|
};
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
Code = "PUB-01",
|
Code = "PUB-01",
|
||||||
@@ -135,6 +151,21 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
EndWeek = 16,
|
EndWeek = 16,
|
||||||
WeekPattern = WeekPattern.All
|
WeekPattern = WeekPattern.All
|
||||||
});
|
});
|
||||||
|
if (experimentScheduledHours > 0)
|
||||||
|
{
|
||||||
|
plan.Entries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
Classroom = laboratory,
|
||||||
|
DayOfWeek = 2,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = experimentScheduledHours,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
}
|
||||||
var job = new SchedulePublishJob
|
var job = new SchedulePublishJob
|
||||||
{
|
{
|
||||||
SchedulePlan = plan,
|
SchedulePlan = plan,
|
||||||
@@ -143,7 +174,16 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
CurrentStep = "等待后台检查"
|
CurrentStep = "等待后台检查"
|
||||||
};
|
};
|
||||||
jobId = job.Id;
|
jobId = job.Id;
|
||||||
db.AddRange(term, college, course, task, plan, job);
|
db.AddRange(
|
||||||
|
term,
|
||||||
|
college,
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
laboratory,
|
||||||
|
course,
|
||||||
|
task,
|
||||||
|
plan,
|
||||||
|
job);
|
||||||
db.ScheduleTimeSlots.AddRange(
|
db.ScheduleTimeSlots.AddRange(
|
||||||
new ScheduleTimeSlot
|
new ScheduleTimeSlot
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ public sealed class ScheduleSettingsControllerTests
|
|||||||
});
|
});
|
||||||
Assert.Contains("测试教师", json);
|
Assert.Contains("测试教师", json);
|
||||||
Assert.Contains(classroom.Id.ToString(), json);
|
Assert.Contains(classroom.Id.ToString(), json);
|
||||||
Assert.Contains("\"WeeklyHours\":3", json);
|
Assert.Contains("\"WeeklyHours\":4", json);
|
||||||
Assert.Contains("\"CoursePracticeHours\":16", json);
|
Assert.Contains("\"CoursePracticeHours\":16", json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Jiaowu.Api.Tests;
|
|||||||
public sealed class SchedulesControllerTests
|
public sealed class SchedulesControllerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Manual_entry_rejects_hours_reserved_for_experiments()
|
public async Task Manual_entry_adds_experiment_hours_to_regular_schedule()
|
||||||
{
|
{
|
||||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
@@ -33,6 +33,21 @@ public sealed class SchedulesControllerTests
|
|||||||
EndDate = new DateOnly(2027, 1, 17)
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
};
|
};
|
||||||
var college = new College { Code = "LAB", Name = "实验学院" };
|
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||||
|
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "LAB-BUILDING",
|
||||||
|
Name = "实验楼",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var laboratory = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB-201",
|
||||||
|
Name = "实验室 201",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 40,
|
||||||
|
RoomType = "实验室"
|
||||||
|
};
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
Code = "LAB-01",
|
Code = "LAB-01",
|
||||||
@@ -71,7 +86,7 @@ public sealed class SchedulesControllerTests
|
|||||||
EndWeek = 16,
|
EndWeek = 16,
|
||||||
WeekPattern = WeekPattern.All
|
WeekPattern = WeekPattern.All
|
||||||
});
|
});
|
||||||
db.AddRange(term, college, course, task, plan);
|
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
|
||||||
db.ScheduleTimeSlots.AddRange(
|
db.ScheduleTimeSlots.AddRange(
|
||||||
new ScheduleTimeSlot
|
new ScheduleTimeSlot
|
||||||
{
|
{
|
||||||
@@ -102,22 +117,22 @@ public sealed class SchedulesControllerTests
|
|||||||
plan.Id,
|
plan.Id,
|
||||||
new ScheduleEntryRequest(
|
new ScheduleEntryRequest(
|
||||||
task.Id,
|
task.Id,
|
||||||
null,
|
laboratory.Id,
|
||||||
2,
|
|
||||||
2,
|
2,
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
|
1,
|
||||||
16,
|
16,
|
||||||
WeekPattern.All,
|
WeekPattern.All,
|
||||||
null),
|
null,
|
||||||
|
ScheduleEntryKind.Experiment),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
var problem = Assert.IsType<ObjectResult>(result);
|
Assert.IsType<CreatedResult>(result);
|
||||||
var details = Assert.IsType<ValidationProblemDetails>(problem.Value);
|
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
|
||||||
Assert.Contains(
|
Assert.Equal(2, entries.Count);
|
||||||
"实践学时请在实验管理中安排",
|
Assert.Equal(ScheduleEntryKind.Experiment, entries[1].Kind);
|
||||||
details.Detail);
|
Assert.Equal(laboratory.Id, entries[1].ClassroomId);
|
||||||
Assert.Single(await db.ScheduleEntries.ToListAsync());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class TeachingTaskHoursTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Standard_schedule_excludes_practice_hours()
|
public void Standard_schedule_includes_theory_and_experiment_hours()
|
||||||
{
|
{
|
||||||
var course = new Course
|
var course = new Course
|
||||||
{
|
{
|
||||||
@@ -52,12 +52,12 @@ public sealed class TeachingTaskHoursTests
|
|||||||
16,
|
16,
|
||||||
out var weeklyHours));
|
out var weeklyHours));
|
||||||
Assert.Equal(3, weeklyHours);
|
Assert.Equal(3, weeklyHours);
|
||||||
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 3));
|
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 4));
|
||||||
|
|
||||||
var result = TeachingTaskHours.Validate(course, 1, 16, 4);
|
var result = TeachingTaskHours.Validate(course, 1, 16, 3);
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Contains("普通课表应安排 48 学时", result);
|
Assert.Contains("理论课和实验课均应进入课表", result);
|
||||||
Assert.Contains("实践学时 16", result);
|
Assert.Contains("共 48 学时", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -79,8 +79,6 @@ public sealed class TeachingTaskHoursTests
|
|||||||
16,
|
16,
|
||||||
1,
|
1,
|
||||||
TeachingTaskSchedulingMode.Flexible));
|
TeachingTaskSchedulingMode.Flexible));
|
||||||
Assert.Contains(
|
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 1));
|
||||||
"无需进入普通课表",
|
|
||||||
TeachingTaskHours.Validate(course, 1, 16, 1)!);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -42,6 +42,7 @@ declare module 'vue' {
|
|||||||
ElResult: typeof import('element-plus/es')['ElResult']
|
ElResult: typeof import('element-plus/es')['ElResult']
|
||||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
@@ -52,6 +53,7 @@ declare module 'vue' {
|
|||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
|
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
|
||||||
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -0,0 +1,762 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { Connection, RefreshRight, TopRight } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import * as echarts from 'echarts/core'
|
||||||
|
import { LineChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
])
|
||||||
|
|
||||||
|
type ReportStatus = 'ready' | 'not_configured' | 'unavailable'
|
||||||
|
type ReportRange = '15m' | '1h' | '24h' | '7d'
|
||||||
|
|
||||||
|
interface PerformanceHeadline {
|
||||||
|
requestCount?: number
|
||||||
|
requestP95Milliseconds?: number
|
||||||
|
serverErrorRatePercent?: number
|
||||||
|
databaseP95Milliseconds?: number
|
||||||
|
slowDatabaseCommandCount?: number
|
||||||
|
failedDatabaseCommandCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelinePoint {
|
||||||
|
timestamp: string
|
||||||
|
requestsPerSecond?: number
|
||||||
|
requestP95Milliseconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RankingItem {
|
||||||
|
name: string
|
||||||
|
p95Milliseconds?: number
|
||||||
|
requestCount?: number
|
||||||
|
exceptionalCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerformanceReport {
|
||||||
|
status: ReportStatus
|
||||||
|
range: ReportRange
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
generatedAt: string
|
||||||
|
dataSource: string
|
||||||
|
dashboardUrl?: string
|
||||||
|
detail?: string
|
||||||
|
headline?: PerformanceHeadline
|
||||||
|
timeline: TimelinePoint[]
|
||||||
|
endpoints: RankingItem[]
|
||||||
|
databaseQueries: RankingItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeOptions: { value: ReportRange; label: string }[] = [
|
||||||
|
{ value: '15m', label: '15 分钟' },
|
||||||
|
{ value: '1h', label: '1 小时' },
|
||||||
|
{ value: '24h', label: '24 小时' },
|
||||||
|
{ value: '7d', label: '7 天' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const range = ref<ReportRange>('1h')
|
||||||
|
const loading = ref(true)
|
||||||
|
const report = ref<PerformanceReport>()
|
||||||
|
const chartElement = ref<HTMLElement>()
|
||||||
|
let chart: echarts.ECharts | undefined
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
|
||||||
|
const hasSamples = computed(() =>
|
||||||
|
Boolean(report.value?.headline) &&
|
||||||
|
(report.value?.timeline.length ?? 0) > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const operatingNote = computed(() => {
|
||||||
|
const headline = report.value?.headline
|
||||||
|
if (!headline) return '等待指标样本'
|
||||||
|
if ((headline.failedDatabaseCommandCount ?? 0) > 0)
|
||||||
|
return '存在失败的数据库命令'
|
||||||
|
if ((headline.serverErrorRatePercent ?? 0) >= 1)
|
||||||
|
return '服务端错误率需要关注'
|
||||||
|
if ((headline.slowDatabaseCommandCount ?? 0) > 0)
|
||||||
|
return '存在超过阈值的慢查询'
|
||||||
|
return '当前采样窗口内未见明显异常'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatNumber(value?: number, digits = 0) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '—'
|
||||||
|
return value.toLocaleString('zh-CN', {
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return '—'
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisTime(value: string) {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
month: range.value === '7d' ? '2-digit' : undefined,
|
||||||
|
day: range.value === '7d' ? '2-digit' : undefined,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricWidth(value?: number, rows: RankingItem[] = []) {
|
||||||
|
if (value == null || value <= 0) return '0%'
|
||||||
|
const maximum = Math.max(
|
||||||
|
...rows.map((row) => row.p95Milliseconds ?? 0),
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return `${Math.max(4, value / maximum * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReport() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.get<PerformanceReport>(
|
||||||
|
'/operations/performance',
|
||||||
|
{ params: { range: range.value } },
|
||||||
|
)
|
||||||
|
report.value = data
|
||||||
|
await nextTick()
|
||||||
|
renderChart()
|
||||||
|
} catch (error) {
|
||||||
|
report.value = {
|
||||||
|
status: 'unavailable',
|
||||||
|
range: range.value,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
dataSource: 'prometheus',
|
||||||
|
detail: apiErrorMessage(error),
|
||||||
|
timeline: [],
|
||||||
|
endpoints: [],
|
||||||
|
databaseQueries: [],
|
||||||
|
}
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
disposeChart()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
disposeChart()
|
||||||
|
if (!chartElement.value || !report.value?.timeline.length) return
|
||||||
|
chart = echarts.init(chartElement.value)
|
||||||
|
const times = report.value.timeline.map((point) =>
|
||||||
|
formatAxisTime(point.timestamp),
|
||||||
|
)
|
||||||
|
chart.setOption({
|
||||||
|
animationDuration: 420,
|
||||||
|
color: ['#1f7468', '#b47722'],
|
||||||
|
grid: { left: 50, right: 54, top: 54, bottom: 38 },
|
||||||
|
legend: {
|
||||||
|
top: 4,
|
||||||
|
left: 0,
|
||||||
|
itemWidth: 18,
|
||||||
|
itemHeight: 3,
|
||||||
|
textStyle: {
|
||||||
|
color: '#53636d',
|
||||||
|
fontFamily: '"Cascadia Mono", Consolas, monospace',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: 'rgba(30, 41, 50, 0.94)',
|
||||||
|
borderWidth: 0,
|
||||||
|
textStyle: { color: '#fff', fontSize: 12 },
|
||||||
|
valueFormatter: (value: unknown) =>
|
||||||
|
typeof value === 'number' ? value.toFixed(2) : '—',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: times,
|
||||||
|
axisLine: { lineStyle: { color: '#cbd4d7' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#79878f', fontSize: 10, hideOverlap: true },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '请求 / 秒',
|
||||||
|
nameTextStyle: { color: '#63727b', fontSize: 10 },
|
||||||
|
splitLine: { lineStyle: { color: '#e8edef', type: 'dashed' } },
|
||||||
|
axisLabel: { color: '#79878f', fontSize: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: 'P95 / ms',
|
||||||
|
nameTextStyle: { color: '#8b6b36', fontSize: 10 },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { color: '#8b6b36', fontSize: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '请求速率',
|
||||||
|
type: 'line',
|
||||||
|
smooth: 0.22,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
areaStyle: { color: 'rgba(31, 116, 104, 0.10)' },
|
||||||
|
data: report.value.timeline.map(
|
||||||
|
(point) => point.requestsPerSecond ?? null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '接口 P95',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
smooth: 0.22,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { width: 1.5 },
|
||||||
|
data: report.value.timeline.map(
|
||||||
|
(point) => point.requestP95Milliseconds ?? null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
resizeObserver = new ResizeObserver(() => chart?.resize())
|
||||||
|
resizeObserver.observe(chartElement.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeChart() {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = undefined
|
||||||
|
chart?.dispose()
|
||||||
|
chart = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(range, loadReport)
|
||||||
|
onMounted(loadReport)
|
||||||
|
onUnmounted(disposeChart)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="performance-panel" aria-labelledby="performance-title">
|
||||||
|
<header class="performance-heading">
|
||||||
|
<div>
|
||||||
|
<span>PERFORMANCE RAIL / {{ report?.dataSource?.toUpperCase() || 'PROMETHEUS' }}</span>
|
||||||
|
<h3 id="performance-title">系统性能</h3>
|
||||||
|
<p>从接口进入数据库,定位响应时间消耗在哪里。</p>
|
||||||
|
</div>
|
||||||
|
<div class="performance-actions">
|
||||||
|
<div class="range-switch" aria-label="性能报表时间范围">
|
||||||
|
<button
|
||||||
|
v-for="item in rangeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: range === item.value }"
|
||||||
|
:aria-pressed="range === item.value"
|
||||||
|
@click="range = item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
:icon="RefreshRight"
|
||||||
|
:loading="loading"
|
||||||
|
aria-label="刷新性能报表"
|
||||||
|
@click="loadReport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading && !report" class="performance-loading">
|
||||||
|
<el-skeleton :rows="5" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="report?.status !== 'ready'"
|
||||||
|
:class="`is-${report?.status || 'unavailable'}`"
|
||||||
|
class="source-state"
|
||||||
|
>
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{{ report?.status === 'not_configured' ? '等待连接性能数据源' : '性能数据暂不可用' }}
|
||||||
|
</strong>
|
||||||
|
<p>{{ report?.detail }}</p>
|
||||||
|
<small>
|
||||||
|
业务接口继续正常运行;报表不会回退读取教务业务数据库。
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="report?.dashboardUrl"
|
||||||
|
:href="report.dashboardUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
打开监控平台
|
||||||
|
<el-icon><TopRight /></el-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="performance-strip">
|
||||||
|
<div class="strip-lead">
|
||||||
|
<span>采样结论</span>
|
||||||
|
<strong>{{ operatingNote }}</strong>
|
||||||
|
<small>
|
||||||
|
{{ formatTime(report.from) }} — {{ formatTime(report.to) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>请求总量</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.requestCount) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>接口 P95</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.requestP95Milliseconds, 1) }}<small>ms</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>5xx 比例</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.serverErrorRatePercent, 2) }}<small>%</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>数据库 P95</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.databaseP95Milliseconds, 1) }}<small>ms</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>慢查询</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.slowDatabaseCommandCount) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>查询失败</dt>
|
||||||
|
<dd>{{ formatNumber(report.headline?.failedDatabaseCommandCount) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasSamples" class="performance-rail">
|
||||||
|
<div ref="chartElement" class="rail-chart" aria-label="请求速率与接口 P95 趋势图" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="samples-empty">
|
||||||
|
数据源已连接,但该时间范围内尚未收到请求指标。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rankings">
|
||||||
|
<article class="ranking-board">
|
||||||
|
<div class="ranking-heading">
|
||||||
|
<div>
|
||||||
|
<span>HTTP ROUTES</span>
|
||||||
|
<h4>接口耗时排行</h4>
|
||||||
|
</div>
|
||||||
|
<small>P95 / 请求量 / 5xx</small>
|
||||||
|
</div>
|
||||||
|
<div v-if="report.endpoints.length" class="ranking-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in report.endpoints"
|
||||||
|
:key="item.name"
|
||||||
|
class="ranking-row"
|
||||||
|
>
|
||||||
|
<b>{{ String(index + 1).padStart(2, '0') }}</b>
|
||||||
|
<div>
|
||||||
|
<strong :title="item.name">{{ item.name }}</strong>
|
||||||
|
<span>
|
||||||
|
<i
|
||||||
|
:style="{ width: metricWidth(item.p95Milliseconds, report.endpoints) }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
|
||||||
|
<dt>{{ formatNumber(item.requestCount) }} 次 · {{ formatNumber(item.exceptionalCount) }} 错误</dt>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="ranking-empty">该范围内没有接口指标。</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ranking-board database-board">
|
||||||
|
<div class="ranking-heading">
|
||||||
|
<div>
|
||||||
|
<span>DATABASE QUERIES</span>
|
||||||
|
<h4>数据库查询排行</h4>
|
||||||
|
</div>
|
||||||
|
<small>P95 / 调用量 / 慢查询</small>
|
||||||
|
</div>
|
||||||
|
<div v-if="report.databaseQueries.length" class="ranking-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in report.databaseQueries"
|
||||||
|
:key="item.name"
|
||||||
|
class="ranking-row"
|
||||||
|
>
|
||||||
|
<b>{{ String(index + 1).padStart(2, '0') }}</b>
|
||||||
|
<div>
|
||||||
|
<strong :title="item.name">{{ item.name }}</strong>
|
||||||
|
<span>
|
||||||
|
<i
|
||||||
|
:style="{ width: metricWidth(item.p95Milliseconds, report.databaseQueries) }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
|
||||||
|
<dt>{{ formatNumber(item.requestCount) }} 次 · {{ formatNumber(item.exceptionalCount) }} 慢</dt>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="ranking-empty">该范围内没有数据库指标。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="performance-foot">
|
||||||
|
<span>生成于 {{ formatTime(report.generatedAt) }} · 页面数据采用短时缓存</span>
|
||||||
|
<a
|
||||||
|
v-if="report.dashboardUrl"
|
||||||
|
:href="report.dashboardUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
查看原始调用链
|
||||||
|
<el-icon><TopRight /></el-icon>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.performance-panel {
|
||||||
|
--ink: #1e2932;
|
||||||
|
--muted: #66747e;
|
||||||
|
--line: #d8dee1;
|
||||||
|
--paper: #f6f8f8;
|
||||||
|
--panel: #fff;
|
||||||
|
--signal: #1f7468;
|
||||||
|
--warning: #b47722;
|
||||||
|
--danger: #b1463e;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading {
|
||||||
|
align-items: flex-end;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(31, 116, 104, 0.06), transparent 38%),
|
||||||
|
var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 22px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading span,
|
||||||
|
.ranking-heading span,
|
||||||
|
.strip-lead > span {
|
||||||
|
color: var(--signal);
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading h3 {
|
||||||
|
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 21px;
|
||||||
|
margin: 5px 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-heading p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch {
|
||||||
|
background: #edf1f1;
|
||||||
|
display: flex;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #68767e;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
transition: background .16s ease, color .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch button.active {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-switch button:focus-visible {
|
||||||
|
outline: 2px solid var(--signal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-loading {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state {
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #f8faf9;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
margin: 22px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state > .el-icon {
|
||||||
|
background: #e7efed;
|
||||||
|
color: var(--signal);
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state p,
|
||||||
|
.source-state small {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-state p { font-size: 12px; }
|
||||||
|
.source-state small { font-size: 10px; }
|
||||||
|
.source-state.is-unavailable > .el-icon { background: #f6e9e8; color: var(--danger); }
|
||||||
|
|
||||||
|
.source-state a,
|
||||||
|
.performance-foot a {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--signal);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 11px;
|
||||||
|
gap: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-strip {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-lead {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-lead > span { color: #84c7bc; }
|
||||||
|
.strip-lead strong { display: block; font-size: 13px; margin: 9px 0 13px; }
|
||||||
|
.strip-lead small { color: #aebbc2; font-family: "Cascadia Mono", Consolas, monospace; font-size: 9px; }
|
||||||
|
|
||||||
|
.performance-strip > dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-strip dl > div {
|
||||||
|
border-right: 1px solid #e5eaec;
|
||||||
|
padding: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-strip dl > div:last-child { border-right: 0; }
|
||||||
|
.performance-strip dt { color: var(--muted); font-size: 10px; margin-bottom: 9px; }
|
||||||
|
.performance-strip dd { font-family: "Cascadia Mono", Consolas, monospace; font-size: 18px; margin: 0; }
|
||||||
|
.performance-strip dd small { color: var(--muted); font-size: 9px; margin-left: 3px; }
|
||||||
|
|
||||||
|
.performance-rail {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 16px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-chart { height: 260px; width: 100%; }
|
||||||
|
|
||||||
|
.samples-empty,
|
||||||
|
.ranking-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 44px 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.samples-empty { border-bottom: 1px solid var(--line); }
|
||||||
|
|
||||||
|
.rankings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-board {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-board:last-child { border-right: 0; }
|
||||||
|
|
||||||
|
.ranking-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-heading h4 {
|
||||||
|
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-heading > small {
|
||||||
|
color: #89959b;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list { display: grid; gap: 1px; }
|
||||||
|
|
||||||
|
.ranking-row {
|
||||||
|
align-items: center;
|
||||||
|
background: #f8fafa;
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr) 116px;
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row > b {
|
||||||
|
color: #9aa6ab;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row > div { min-width: 0; }
|
||||||
|
.ranking-row strong {
|
||||||
|
display: block;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row > div > span {
|
||||||
|
background: #dfe7e7;
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row i {
|
||||||
|
background: var(--signal);
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-board .ranking-row i { background: var(--warning); }
|
||||||
|
|
||||||
|
.ranking-row dl {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row dd {
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
margin: 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row dt { color: var(--muted); font-size: 9px; }
|
||||||
|
|
||||||
|
.performance-foot {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--paper);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 11px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.range-switch button { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.performance-strip { grid-template-columns: 190px minmax(0, 1fr); }
|
||||||
|
.performance-strip > dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.performance-strip dl > div:nth-child(3) { border-right: 0; }
|
||||||
|
.performance-strip dl > div:nth-child(-n+3) { border-bottom: 1px solid #e5eaec; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.performance-heading { align-items: flex-start; gap: 16px; }
|
||||||
|
.performance-actions { align-items: flex-end; flex-direction: column-reverse; }
|
||||||
|
.performance-strip { display: block; }
|
||||||
|
.rankings { grid-template-columns: 1fr; }
|
||||||
|
.ranking-board { border-bottom: 1px solid var(--line); border-right: 0; }
|
||||||
|
.ranking-board:last-child { border-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.performance-heading { display: block; padding: 17px 14px; }
|
||||||
|
.performance-actions { align-items: stretch; flex-direction: row; margin-top: 14px; }
|
||||||
|
.range-switch { flex: 1; overflow-x: auto; }
|
||||||
|
.range-switch button { flex: 1 0 auto; padding-inline: 9px; }
|
||||||
|
.source-state { grid-template-columns: auto 1fr; margin: 12px; padding: 16px; }
|
||||||
|
.source-state a { grid-column: 2; }
|
||||||
|
.performance-strip > dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.performance-strip dl > div,
|
||||||
|
.performance-strip dl > div:nth-child(3) { border-bottom: 1px solid #e5eaec; border-right: 1px solid #e5eaec; }
|
||||||
|
.performance-strip dl > div:nth-child(even) { border-right: 0; }
|
||||||
|
.performance-strip dl > div:nth-last-child(-n+2) { border-bottom: 0; }
|
||||||
|
.performance-rail { padding-inline: 8px; }
|
||||||
|
.rail-chart { height: 230px; }
|
||||||
|
.ranking-board { padding-inline: 12px; }
|
||||||
|
.ranking-heading > small { display: none; }
|
||||||
|
.ranking-row { grid-template-columns: 22px minmax(0, 1fr) 96px; padding-inline: 7px; }
|
||||||
|
.performance-foot { align-items: flex-start; gap: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -502,6 +502,8 @@ button { cursor: pointer; }
|
|||||||
.cell-add { margin: auto; color: transparent; font-size: 9px; }
|
.cell-add { margin: auto; color: transparent; font-size: 9px; }
|
||||||
.timetable-cell.editable:hover .cell-add { color: #a0a8b4; }
|
.timetable-cell.editable:hover .cell-add { color: #a0a8b4; }
|
||||||
.schedule-card { min-width: 0; padding: 8px 9px; display: grid; gap: 4px; position: relative; border-left: 3px solid #45bcae; color: #eaf2ff; background: linear-gradient(130deg, #263f80, #1a2e64); box-shadow: 0 4px 10px rgba(24,40,83,.12); cursor: pointer; }
|
.schedule-card { min-width: 0; padding: 8px 9px; display: grid; gap: 4px; position: relative; border-left: 3px solid #45bcae; color: #eaf2ff; background: linear-gradient(130deg, #263f80, #1a2e64); box-shadow: 0 4px 10px rgba(24,40,83,.12); cursor: pointer; }
|
||||||
|
.schedule-card.experiment { border-left-color: #f2b84b; background: linear-gradient(130deg, #705022, #4d3518); }
|
||||||
|
.schedule-card.experiment span { color: #ffd88a; }
|
||||||
.schedule-card.readonly { cursor: default; }
|
.schedule-card.readonly { cursor: default; }
|
||||||
.schedule-card span { padding-right: 16px; color: #74d5c8; font: 700 8px/1.2 Consolas, monospace; letter-spacing: .04em; }
|
.schedule-card span { padding-right: 16px; color: #74d5c8; font: 700 8px/1.2 Consolas, monospace; letter-spacing: .04em; }
|
||||||
.schedule-card b { overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
|
.schedule-card b { overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const statusFilter = ref('')
|
|||||||
const projectDialog = ref(false)
|
const projectDialog = ref(false)
|
||||||
const editingProjectId = ref('')
|
const editingProjectId = ref('')
|
||||||
const projectForm = reactive({
|
const projectForm = reactive({
|
||||||
teachingTaskId: '',
|
teachingTaskIds: [] as string[],
|
||||||
code: '',
|
code: '',
|
||||||
name: '',
|
name: '',
|
||||||
arrangementMode: 'Centralized',
|
arrangementMode: 'Centralized',
|
||||||
@@ -47,13 +47,20 @@ const sessionForm = reactive({
|
|||||||
notes: '',
|
notes: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const batchSessionDialog = ref(false)
|
||||||
|
const batchSessionProjectIds = ref<string[]>([])
|
||||||
|
const batchSessionRows = ref<any[]>([])
|
||||||
|
|
||||||
const participantsDialog = ref(false)
|
const participantsDialog = ref(false)
|
||||||
const participantSession = ref<any>(null)
|
const participantSession = ref<any>(null)
|
||||||
const participants = ref<any[]>([])
|
const participants = ref<any[]>([])
|
||||||
const participantsLoading = ref(false)
|
const participantsLoading = ref(false)
|
||||||
|
|
||||||
const selectedTask = computed(() =>
|
const selectedTask = computed(() =>
|
||||||
options.tasks.find((task) => task.id === projectForm.teachingTaskId),
|
options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]),
|
||||||
|
)
|
||||||
|
const batchProjectOptions = computed(() =>
|
||||||
|
projects.value.filter((project) => project.status !== 'Closed'),
|
||||||
)
|
)
|
||||||
const activePeriods = computed(() =>
|
const activePeriods = computed(() =>
|
||||||
options.periods.filter((period) =>
|
options.periods.filter((period) =>
|
||||||
@@ -82,7 +89,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
function resetProjectForm() {
|
function resetProjectForm() {
|
||||||
editingProjectId.value = ''
|
editingProjectId.value = ''
|
||||||
Object.assign(projectForm, {
|
Object.assign(projectForm, {
|
||||||
teachingTaskId: '',
|
teachingTaskIds: [],
|
||||||
code: '',
|
code: '',
|
||||||
name: '',
|
name: '',
|
||||||
arrangementMode: 'Centralized',
|
arrangementMode: 'Centralized',
|
||||||
@@ -106,7 +113,7 @@ function openCreateProject() {
|
|||||||
function openEditProject(project: any) {
|
function openEditProject(project: any) {
|
||||||
editingProjectId.value = project.id
|
editingProjectId.value = project.id
|
||||||
Object.assign(projectForm, {
|
Object.assign(projectForm, {
|
||||||
teachingTaskId: project.teachingTaskId,
|
teachingTaskIds: [project.teachingTaskId],
|
||||||
code: project.code,
|
code: project.code,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
arrangementMode: project.arrangementMode,
|
arrangementMode: project.arrangementMode,
|
||||||
@@ -118,13 +125,13 @@ function openEditProject(project: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
if (!projectForm.teachingTaskId || !projectForm.code.trim()
|
if (!projectForm.teachingTaskIds.length || !projectForm.code.trim()
|
||||||
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
|
||||||
ElMessage.warning('请填写教学任务、项目编码、名称和开放日期')
|
ElMessage.warning('请填写教学任务、项目编码、名称和开放日期')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
teachingTaskId: projectForm.teachingTaskId,
|
teachingTaskId: projectForm.teachingTaskIds[0],
|
||||||
code: projectForm.code,
|
code: projectForm.code,
|
||||||
name: projectForm.name,
|
name: projectForm.name,
|
||||||
arrangementMode: projectForm.arrangementMode,
|
arrangementMode: projectForm.arrangementMode,
|
||||||
@@ -138,8 +145,12 @@ async function saveProject() {
|
|||||||
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
||||||
ElMessage.success('实验项目已更新')
|
ElMessage.success('实验项目已更新')
|
||||||
} else {
|
} else {
|
||||||
await http.post('/experiments', payload)
|
await http.post('/experiments/batch', {
|
||||||
ElMessage.success('实验项目已创建')
|
...payload,
|
||||||
|
teachingTaskId: undefined,
|
||||||
|
teachingTaskIds: projectForm.teachingTaskIds,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
|
||||||
}
|
}
|
||||||
projectDialog.value = false
|
projectDialog.value = false
|
||||||
await load()
|
await load()
|
||||||
@@ -148,6 +159,78 @@ async function saveProject() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBatchSession() {
|
||||||
|
batchSessionProjectIds.value = []
|
||||||
|
batchSessionRows.value = []
|
||||||
|
batchSessionDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBatchSessionRows() {
|
||||||
|
const existing = new Map(batchSessionRows.value.map((row) => [row.projectId, row]))
|
||||||
|
batchSessionRows.value = batchSessionProjectIds.value.map((projectId) => {
|
||||||
|
const current = existing.get(projectId)
|
||||||
|
if (current) return current
|
||||||
|
const project = projects.value.find((item) => item.id === projectId)
|
||||||
|
const firstPeriod = options.periods.find((item) =>
|
||||||
|
item.academicTermId === project?.academicTermId,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
classroomId: '',
|
||||||
|
sessionDate: project?.startDate ?? '',
|
||||||
|
startPeriod: firstPeriod?.periodNumber,
|
||||||
|
periodCount: 2,
|
||||||
|
capacity: 30,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFirstBatchTime() {
|
||||||
|
const first = batchSessionRows.value[0]
|
||||||
|
if (!first) return
|
||||||
|
batchSessionRows.value.slice(1).forEach((row) => {
|
||||||
|
row.sessionDate = first.sessionDate
|
||||||
|
row.startPeriod = first.startPeriod
|
||||||
|
row.periodCount = first.periodCount
|
||||||
|
})
|
||||||
|
ElMessage.success('已套用首行的日期和节次,请分别选择不冲突的实验室')
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchProject(projectId: string) {
|
||||||
|
return projects.value.find((project) => project.id === projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodsForProject(projectId: string) {
|
||||||
|
const project = batchProject(projectId)
|
||||||
|
return options.periods.filter((period) =>
|
||||||
|
period.academicTermId === project?.academicTermId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBatchSessions() {
|
||||||
|
if (!batchSessionRows.value.length) {
|
||||||
|
ElMessage.warning('请至少选择一个实验项目')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (batchSessionRows.value.some((row) =>
|
||||||
|
!row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount,
|
||||||
|
)) {
|
||||||
|
ElMessage.warning('请完整填写每个项目的日期、节次、实验室和容量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await http.post('/experiments/sessions/batch', {
|
||||||
|
items: batchSessionRows.value,
|
||||||
|
})
|
||||||
|
batchSessionDialog.value = false
|
||||||
|
ElMessage.success(`已批量安排 ${batchSessionRows.value.length} 个实验场次`)
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openSession(project: any) {
|
function openSession(project: any) {
|
||||||
selectedProject.value = project
|
selectedProject.value = project
|
||||||
const firstPeriod = options.periods.find((item) =>
|
const firstPeriod = options.periods.find((item) =>
|
||||||
@@ -382,8 +465,11 @@ onMounted(async () => {
|
|||||||
<p v-else>把实验项目分成两条运行轨道:集中排入固定课次,或开放场次供学生自主预约。</p>
|
<p v-else>把实验项目分成两条运行轨道:集中排入固定课次,或开放场次供学生自主预约。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="intro-actions">
|
<div class="intro-actions">
|
||||||
|
<el-button v-if="!isStudent" :icon="Calendar" @click="openBatchSession">
|
||||||
|
批量排课
|
||||||
|
</el-button>
|
||||||
<el-button v-if="!isStudent" type="primary" :icon="Plus" @click="openCreateProject">
|
<el-button v-if="!isStudent" type="primary" :icon="Plus" @click="openCreateProject">
|
||||||
新建实验项目
|
批量设置实验任务
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -627,19 +713,22 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="projectDialog"
|
v-model="projectDialog"
|
||||||
:title="editingProjectId ? '编辑实验项目' : '新建实验项目'"
|
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||||
width="720px"
|
width="720px"
|
||||||
top="5vh"
|
top="5vh"
|
||||||
>
|
>
|
||||||
<el-form label-position="top" class="experiment-form">
|
<el-form label-position="top" class="experiment-form">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||||
<el-form-item label="所属教学任务" required>
|
<el-form-item :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="projectForm.teachingTaskId"
|
v-model="projectForm.teachingTaskIds"
|
||||||
filterable
|
filterable
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
:disabled="!!editingProjectId"
|
:disabled="!!editingProjectId"
|
||||||
placeholder="选择已发布教学任务"
|
placeholder="选择同一学期、同一课程的已发布教学任务"
|
||||||
@change="onTaskChange"
|
@change="onTaskChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@@ -647,8 +736,14 @@ onMounted(async () => {
|
|||||||
:key="task.id"
|
:key="task.id"
|
||||||
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
|
||||||
:value="task.id"
|
:value="task.id"
|
||||||
|
:disabled="!!selectedTask
|
||||||
|
&& (task.academicTermId !== selectedTask.academicTermId
|
||||||
|
|| task.courseId !== selectedTask.courseId)"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<small v-if="!editingProjectId" class="form-help">
|
||||||
|
已选 {{ projectForm.teachingTaskIds.length }} 个;实验编码、名称、内容和开放日期将一次应用到这些教学任务。
|
||||||
|
</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid two">
|
<div class="form-grid two">
|
||||||
<el-form-item label="项目编码" required>
|
<el-form-item label="项目编码" required>
|
||||||
@@ -708,7 +803,13 @@ onMounted(async () => {
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="projectDialog = false">取消</el-button>
|
<el-button @click="projectDialog = false">取消</el-button>
|
||||||
<el-button type="primary" @click="saveProject">保存项目</el-button>
|
<el-button type="primary" @click="saveProject">
|
||||||
|
{{ editingProjectId
|
||||||
|
? '保存项目'
|
||||||
|
: projectForm.teachingTaskIds.length
|
||||||
|
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
|
||||||
|
: '创建项目' }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -778,6 +879,86 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="batchSessionDialog"
|
||||||
|
title="批量安排实验场次"
|
||||||
|
width="min(1180px, 94vw)"
|
||||||
|
top="4vh"
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
title="一次提交整批排课;系统会逐条检查管理范围、开放日期、实验室、课表、教师和班级冲突,任何一条失败都不会写入本批次。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<el-form label-position="top" class="batch-session-form">
|
||||||
|
<el-form-item label="选择实验项目" required>
|
||||||
|
<el-select
|
||||||
|
v-model="batchSessionProjectIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="选择需要一起排课的实验项目"
|
||||||
|
@change="syncBatchSessionRows"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="project in batchProjectOptions"
|
||||||
|
:key="project.id"
|
||||||
|
:label="`${project.courseCode} · ${project.name} · ${project.taskNumber}`"
|
||||||
|
:value="project.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<div v-if="batchSessionRows.length" class="batch-session-tools">
|
||||||
|
<span>共 {{ batchSessionRows.length }} 条排课</span>
|
||||||
|
<el-button size="small" @click="applyFirstBatchTime">套用首行日期与节次</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="batch-session-table">
|
||||||
|
<article v-for="(row, index) in batchSessionRows" :key="row.projectId" class="batch-session-row">
|
||||||
|
<div class="batch-project-cell">
|
||||||
|
<span>{{ index + 1 }}</span>
|
||||||
|
<div>
|
||||||
|
<b>{{ batchProject(row.projectId)?.name }}</b>
|
||||||
|
<small>{{ batchProject(row.projectId)?.taskNumber }} · {{ batchProject(row.projectId)?.classNames.join('、') || '选课学生' }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="row.sessionDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="实验日期"
|
||||||
|
/>
|
||||||
|
<el-select v-model="row.startPeriod" placeholder="起始节次">
|
||||||
|
<el-option
|
||||||
|
v-for="period in periodsForProject(row.projectId)"
|
||||||
|
:key="period.periodNumber"
|
||||||
|
:label="period.name"
|
||||||
|
:value="period.periodNumber"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="row.periodCount" :min="1" :max="12" controls-position="right" />
|
||||||
|
<el-select v-model="row.classroomId" filterable placeholder="实验室">
|
||||||
|
<el-option
|
||||||
|
v-for="room in options.classrooms"
|
||||||
|
:key="room.id"
|
||||||
|
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
|
||||||
|
:value="room.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="row.capacity" :min="1" :max="10000" controls-position="right" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!batchSessionRows.length" :image-size="60" description="选择实验项目后,在同一张表中完成排课" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchSessionDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!batchSessionRows.length" @click="saveBatchSessions">
|
||||||
|
提交整批排课
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="participantsDialog"
|
v-model="participantsDialog"
|
||||||
:title="`${participantSession?.project?.arrangementMode === 'Centralized' ? '应到名单' : '预约名单'} · ${participantSession ? formatSessionTime(participantSession) : ''}`"
|
:title="`${participantSession?.project?.arrangementMode === 'Centralized' ? '应到名单' : '预约名单'} · ${participantSession ? formatSessionTime(participantSession) : ''}`"
|
||||||
@@ -912,6 +1093,7 @@ onMounted(async () => {
|
|||||||
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
|
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
|
||||||
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
.experiment-form { display: grid; gap: 13px; }
|
.experiment-form { display: grid; gap: 13px; }
|
||||||
|
.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; }
|
||||||
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
|
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
|
||||||
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
|
||||||
.form-section > header span { color: var(--lab-teal); font: 700 10px/1 Consolas, monospace; letter-spacing: .08em; }
|
.form-section > header span { color: var(--lab-teal); font: 700 10px/1 Consolas, monospace; letter-spacing: .08em; }
|
||||||
@@ -920,6 +1102,25 @@ onMounted(async () => {
|
|||||||
.mode-choice :deep(.el-radio-button__inner) { display: grid; gap: 5px; width: 100%; padding: 13px; }
|
.mode-choice :deep(.el-radio-button__inner) { display: grid; gap: 5px; width: 100%; padding: 13px; }
|
||||||
.mode-choice b { font-size: 13px; }
|
.mode-choice b { font-size: 13px; }
|
||||||
.mode-choice small { font-size: 10px; font-weight: 400; }
|
.mode-choice small { font-size: 10px; font-weight: 400; }
|
||||||
|
.batch-session-form { display: grid; gap: 12px; margin-top: 16px; }
|
||||||
|
.batch-session-tools { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 12px; }
|
||||||
|
.batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
||||||
|
.batch-session-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(230px, 1.4fr) 150px 120px 110px minmax(230px, 1.3fr) 110px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 1000px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #dce5e9;
|
||||||
|
background: #f9fbfc;
|
||||||
|
}
|
||||||
|
.batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
|
.batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; }
|
||||||
|
.batch-project-cell > div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.batch-project-cell b, .batch-project-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.batch-project-cell b { color: var(--lab-ink); font-size: 12px; }
|
||||||
|
.batch-project-cell small { color: var(--muted); font-size: 10px; }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.session-ticket { transition: none; }
|
.session-ticket { transition: none; }
|
||||||
}
|
}
|
||||||
@@ -936,5 +1137,6 @@ onMounted(async () => {
|
|||||||
.ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; }
|
.ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; }
|
||||||
.project-actions, .student-booking-summary { padding-inline: 14px; }
|
.project-actions, .student-booking-summary { padding-inline: 14px; }
|
||||||
.mode-choice { grid-template-columns: 1fr; }
|
.mode-choice { grid-template-columns: 1fr; }
|
||||||
|
.intro-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
|
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
|
||||||
|
import PerformanceReportPanel from '../components/PerformanceReportPanel.vue'
|
||||||
|
|
||||||
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
||||||
|
|
||||||
@@ -406,6 +407,8 @@ onMounted(refreshAll)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<PerformanceReportPanel />
|
||||||
|
|
||||||
<section class="console-split">
|
<section class="console-split">
|
||||||
<article class="alerts-panel">
|
<article class="alerts-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ const publishStatusText = computed(() => {
|
|||||||
const selectedTaskConstraint = computed(() =>
|
const selectedTaskConstraint = computed(() =>
|
||||||
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
||||||
)
|
)
|
||||||
|
const isExperimentRoom = (room: any) =>
|
||||||
|
['实验', '实训', '机房', '语音'].some((keyword) => room.roomType?.includes(keyword))
|
||||||
|
const entryClassrooms = computed(() =>
|
||||||
|
classrooms.value.filter((room) =>
|
||||||
|
(!selectedTaskConstraint.value?.requiredCampusId
|
||||||
|
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
|
||||||
|
(!selectedTaskConstraint.value?.requiredBuildingId
|
||||||
|
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
|
||||||
|
(!selectedTaskConstraint.value?.allowedClassroomIds?.length
|
||||||
|
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
|
||||||
|
(entryForm.kind !== 'Experiment' || isExperimentRoom(room)),
|
||||||
|
),
|
||||||
|
)
|
||||||
const entryWeekdays = computed(() => {
|
const entryWeekdays = computed(() => {
|
||||||
const allowedDays = selectedTaskConstraint.value?.allowedDayOfWeeks ?? []
|
const allowedDays = selectedTaskConstraint.value?.allowedDayOfWeeks ?? []
|
||||||
return allowedDays.length
|
return allowedDays.length
|
||||||
@@ -416,7 +429,7 @@ function openManualHandling() {
|
|||||||
async function autoSchedule() {
|
async function autoSchedule() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'系统会保留当前手工安排,并为尚未排满的教学任务分配教师可用时间和符合约束的教室。生成后仍可手工调整。',
|
'系统会保留当前手工安排,同时补齐理论课和实验课。实验课仅使用实验室、实训室、机房等场地;生成后仍可手工调整。',
|
||||||
'开始自动排课',
|
'开始自动排课',
|
||||||
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
|
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
|
||||||
)
|
)
|
||||||
@@ -657,6 +670,7 @@ function openEntry(entry?: any, day?: number, period?: number) {
|
|||||||
editingEntryId.value = entry?.id ?? ''
|
editingEntryId.value = entry?.id ?? ''
|
||||||
Object.assign(entryForm, {
|
Object.assign(entryForm, {
|
||||||
teachingTaskId: entry?.teachingTaskId,
|
teachingTaskId: entry?.teachingTaskId,
|
||||||
|
kind: entry?.kind ?? 'Lecture',
|
||||||
classroomId: entry?.classroomId,
|
classroomId: entry?.classroomId,
|
||||||
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
|
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
|
||||||
startPeriod: entry?.startPeriod ?? period ?? 1,
|
startPeriod: entry?.startPeriod ?? period ?? 1,
|
||||||
@@ -679,7 +693,7 @@ function changeEntryTask() {
|
|||||||
!task.allowedDayOfWeeks.includes(entryForm.dayOfWeek)) {
|
!task.allowedDayOfWeeks.includes(entryForm.dayOfWeek)) {
|
||||||
entryForm.dayOfWeek = task.allowedDayOfWeeks[0]
|
entryForm.dayOfWeek = task.allowedDayOfWeeks[0]
|
||||||
}
|
}
|
||||||
if (task.requiresClassroom === false) {
|
if (task.requiresClassroom === false && entryForm.kind !== 'Experiment') {
|
||||||
entryForm.classroomId = null
|
entryForm.classroomId = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -687,15 +701,25 @@ function changeEntryTask() {
|
|||||||
if (room && (
|
if (room && (
|
||||||
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
|
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
|
||||||
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
|
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
|
||||||
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id))
|
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
|
||||||
|
(entryForm.kind === 'Experiment' && !isExperimentRoom(room))
|
||||||
)) {
|
)) {
|
||||||
entryForm.classroomId = null
|
entryForm.classroomId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeEntryKind() {
|
||||||
|
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
|
||||||
|
if (entryForm.kind === 'Experiment' && room && !isExperimentRoom(room)) {
|
||||||
|
entryForm.classroomId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEntry() {
|
async function saveEntry() {
|
||||||
if (!entryForm.teachingTaskId ||
|
if (!entryForm.teachingTaskId ||
|
||||||
(selectedTaskConstraint.value?.requiresClassroom !== false && !entryForm.classroomId)) {
|
((entryForm.kind === 'Experiment' ||
|
||||||
|
selectedTaskConstraint.value?.requiresClassroom !== false) &&
|
||||||
|
!entryForm.classroomId)) {
|
||||||
ElMessage.warning('请选择教学任务,并按课程要求选择教室。')
|
ElMessage.warning('请选择教学任务,并按课程要求选择教室。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -714,7 +738,8 @@ async function saveEntry() {
|
|||||||
ElMessage.warning('所选星期不在该教学任务允许的上课日内。')
|
ElMessage.warning('所选星期不在该教学任务允许的上课日内。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
|
if (entryForm.kind !== 'Experiment' &&
|
||||||
|
selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
|
||||||
try {
|
try {
|
||||||
const base = `/schedules/plans/${selected.value.id}/entries`
|
const base = `/schedules/plans/${selected.value.id}/entries`
|
||||||
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
|
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
|
||||||
@@ -928,10 +953,13 @@ onBeforeUnmount(() => {
|
|||||||
v-for="entry in entriesAt(day.value, period)"
|
v-for="entry in entriesAt(day.value, period)"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="schedule-card"
|
class="schedule-card"
|
||||||
:class="{ readonly: !isDraft }"
|
:class="{ readonly: !isDraft, experiment: entry.kind === 'Experiment' }"
|
||||||
@click="isDraft && openEntry(entry)"
|
@click="isDraft && openEntry(entry)"
|
||||||
>
|
>
|
||||||
<span>{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}</span>
|
<span>
|
||||||
|
{{ entry.kind === 'Experiment' ? '实验课' : '理论课' }} ·
|
||||||
|
{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}
|
||||||
|
</span>
|
||||||
<b>{{ entry.courseName }}</b>
|
<b>{{ entry.courseName }}</b>
|
||||||
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName || '不占用教室' }}</small>
|
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName || '不占用教室' }}</small>
|
||||||
<i>{{ entry.startWeek }}—{{ entry.endWeek }} 周 / 连上 {{ entry.periodCount }} 节</i>
|
<i>{{ entry.startWeek }}—{{ entry.endWeek }} 周 / 连上 {{ entry.periodCount }} 节</i>
|
||||||
@@ -986,15 +1014,23 @@ onBeforeUnmount(() => {
|
|||||||
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
|
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="课次类型" required>
|
||||||
|
<el-radio-group v-model="entryForm.kind" @change="changeEntryKind">
|
||||||
|
<el-radio-button value="Lecture">理论课</el-radio-button>
|
||||||
|
<el-radio-button value="Experiment" :disabled="!selectedTaskConstraint?.coursePracticeHours">
|
||||||
|
实验课
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="selectedTaskConstraint"
|
v-if="selectedTaskConstraint"
|
||||||
:title="`普通课表每周 ${selectedTaskConstraint.weeklyHours} 学时(实践 ${selectedTaskConstraint.coursePracticeHours} 学时另由实验管理安排);可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
|
:title="`课程共 ${selectedTaskConstraint.courseTotalHours} 学时:理论 ${selectedTaskConstraint.courseTotalHours - selectedTaskConstraint.coursePracticeHours} 学时、实验 ${selectedTaskConstraint.coursePracticeHours} 学时,均须在本课表排足;可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
/>
|
/>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="selectedTaskConstraint?.requiresClassroom === false"
|
v-if="selectedTaskConstraint?.requiresClassroom === false && entryForm.kind !== 'Experiment'"
|
||||||
title="该课程不占用教室,仍会校验教师和行政班时间冲突。"
|
title="该课程不占用教室,仍会校验教师和行政班时间冲突。"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
@@ -1002,19 +1038,15 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<el-form-item
|
<el-form-item
|
||||||
v-else
|
v-else
|
||||||
label="教室"
|
:label="entryForm.kind === 'Experiment' ? '实验室 / 实训室 / 机房' : '教室'"
|
||||||
required
|
required
|
||||||
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
|
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
|
||||||
>
|
>
|
||||||
<el-select v-model="entryForm.classroomId" filterable>
|
<el-select v-model="entryForm.classroomId" filterable>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in classrooms.filter((room) =>
|
v-for="item in entryClassrooms"
|
||||||
(!selectedTaskConstraint?.requiredCampusId || room.campusId === selectedTaskConstraint.requiredCampusId) &&
|
|
||||||
(!selectedTaskConstraint?.requiredBuildingId || room.buildingId === selectedTaskConstraint.requiredBuildingId) &&
|
|
||||||
(!selectedTaskConstraint?.allowedClassroomIds?.length || selectedTaskConstraint.allowedClassroomIds.includes(room.id))
|
|
||||||
)"
|
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:label="`${item.campusName} / ${item.buildingName} / ${item.name}(${item.capacity}人)`"
|
:label="`${item.campusName} / ${item.buildingName} / ${item.name}(${item.roomType},${item.capacity}人)`"
|
||||||
:value="item.id"
|
:value="item.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|||||||
@@ -79,12 +79,10 @@ const selectedCourse = computed(() =>
|
|||||||
)
|
)
|
||||||
const regularScheduleHours = (course: any) =>
|
const regularScheduleHours = (course: any) =>
|
||||||
Math.max(0, Number(course?.totalHours ?? 0) - Number(course?.practiceHours ?? 0))
|
Math.max(0, Number(course?.totalHours ?? 0) - Number(course?.practiceHours ?? 0))
|
||||||
const targetCourseHours = (course: any, schedulingMode: string) =>
|
const targetCourseHours = (course: any) =>
|
||||||
schedulingMode === 'Flexible'
|
Number(course?.totalHours ?? 0)
|
||||||
? Number(course?.totalHours ?? 0)
|
|
||||||
: regularScheduleHours(course)
|
|
||||||
const selectedCourseTargetHours = computed(() =>
|
const selectedCourseTargetHours = computed(() =>
|
||||||
targetCourseHours(selectedCourse.value, form.schedulingMode),
|
targetCourseHours(selectedCourse.value),
|
||||||
)
|
)
|
||||||
const plannedHours = computed(() =>
|
const plannedHours = computed(() =>
|
||||||
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
|
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
|
||||||
@@ -124,7 +122,7 @@ const generationPlannedHours = computed(() =>
|
|||||||
)
|
)
|
||||||
const generationHoursMatch = computed(() =>
|
const generationHoursMatch = computed(() =>
|
||||||
!selectedGenerationCourse.value ||
|
!selectedGenerationCourse.value ||
|
||||||
generationPlannedHours.value === regularScheduleHours(selectedGenerationCourse.value),
|
generationPlannedHours.value === Number(selectedGenerationCourse.value?.totalHours ?? 0),
|
||||||
)
|
)
|
||||||
const manageableCourses = computed(() => {
|
const manageableCourses = computed(() => {
|
||||||
if (isSuperAdmin.value) return courses.value
|
if (isSuperAdmin.value) return courses.value
|
||||||
@@ -364,7 +362,7 @@ async function save() {
|
|||||||
}
|
}
|
||||||
if (!hoursMatch.value) {
|
if (!hoursMatch.value) {
|
||||||
ElMessage.warning(
|
ElMessage.warning(
|
||||||
`该课程普通课表应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时;实践学时请在实验管理中安排。`,
|
`该课程理论课和实验课共应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时。`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -551,7 +549,7 @@ async function generatePublicTasks() {
|
|||||||
}
|
}
|
||||||
if (!generationHoursMatch.value) {
|
if (!generationHoursMatch.value) {
|
||||||
ElMessage.warning(
|
ElMessage.warning(
|
||||||
`该课程普通课表应安排 ${regularScheduleHours(selectedGenerationCourse.value)} 学时,当前安排合计 ${generationPlannedHours.value} 学时;实践学时不进入普通课表。`,
|
`该课程理论课和实验课共应安排 ${selectedGenerationCourse.value?.totalHours ?? 0} 学时,当前安排合计 ${generationPlannedHours.value} 学时。`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -773,7 +771,7 @@ onMounted(async () => {
|
|||||||
<el-radio-button value="Flexible">非排时课程</el-radio-button>
|
<el-radio-button value="Flexible">非排时课程</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<small class="field-hint">
|
<small class="field-hint">
|
||||||
正常排课只安排非实践学时;全部由实验模块安排的课程请选择“非排时课程”。
|
正常排课会同时安排理论课和实验课;只有无需固定星期、节次和场地的课程才选择“非排时课程”。
|
||||||
</small>
|
</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="授课教师">
|
<el-form-item label="授课教师">
|
||||||
|
|||||||
@@ -1014,12 +1014,14 @@ onMounted(async () => {
|
|||||||
:class="{
|
:class="{
|
||||||
'exam-block': entry.isExam,
|
'exam-block': entry.isExam,
|
||||||
'experiment-block': entry.isExperiment,
|
'experiment-block': entry.isExperiment,
|
||||||
|
'scheduled-experiment-block': !entry.isExperiment && entry.kind === 'Experiment',
|
||||||
}"
|
}"
|
||||||
:style="gridEntryStyle(entry)"
|
:style="gridEntryStyle(entry)"
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
||||||
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
||||||
|
<span v-if="!entry.isExperiment && entry.kind === 'Experiment'" class="entry-kind scheduled-experiment-kind">实验课</span>
|
||||||
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
||||||
</strong>
|
</strong>
|
||||||
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
||||||
@@ -1064,12 +1066,14 @@ onMounted(async () => {
|
|||||||
:class="{
|
:class="{
|
||||||
'exam-block': entry.isExam,
|
'exam-block': entry.isExam,
|
||||||
'experiment-block': entry.isExperiment,
|
'experiment-block': entry.isExperiment,
|
||||||
|
'scheduled-experiment-block': !entry.isExperiment && entry.kind === 'Experiment',
|
||||||
}"
|
}"
|
||||||
:style="dayEntryStyle(entry)"
|
:style="dayEntryStyle(entry)"
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
<span v-if="entry.isExam" class="entry-kind">考试</span>
|
||||||
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
|
||||||
|
<span v-if="!entry.isExperiment && entry.kind === 'Experiment'" class="entry-kind scheduled-experiment-kind">实验课</span>
|
||||||
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
|
||||||
</strong>
|
</strong>
|
||||||
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
|
||||||
@@ -1113,7 +1117,11 @@ onMounted(async () => {
|
|||||||
第 {{ entry.startWeek }} 周 ·
|
第 {{ entry.startWeek }} 周 ·
|
||||||
第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节
|
第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节
|
||||||
</small>
|
</small>
|
||||||
<small>{{ entry.teacherNames.join('、') || '监考教师待定' }}</small>
|
<small v-if="entry.examRoomCount > 1">
|
||||||
|
任课教师:{{ entry.teacherNames.join('、') || '待定' }} ·
|
||||||
|
学生登录后可查看本人考场及监考教师
|
||||||
|
</small>
|
||||||
|
<small v-else>{{ entry.teacherNames.join('、') || '监考教师待定' }}</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -1329,6 +1337,9 @@ onMounted(async () => {
|
|||||||
.course-block.experiment-block small, .day-course-block.experiment-block small { color: #527a74; }
|
.course-block.experiment-block small, .day-course-block.experiment-block small { color: #527a74; }
|
||||||
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
|
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
|
||||||
.entry-kind.experiment-kind { background: #168276; }
|
.entry-kind.experiment-kind { background: #168276; }
|
||||||
|
.course-block.scheduled-experiment-block, .day-course-block.scheduled-experiment-block { border-left-color: #b77819; background: #fff6df; color: #73521f; }
|
||||||
|
.course-block.scheduled-experiment-block strong, .day-course-block.scheduled-experiment-block strong { color: #66430e; }
|
||||||
|
.entry-kind.scheduled-experiment-kind { background: #b77819; }
|
||||||
.exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; }
|
.exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; }
|
||||||
.exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; }
|
.exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; }
|
||||||
.exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; }
|
.exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user