Compare commits
8
Commits
v2.1.0
..
019b3fc827
No files matched your search
@@ -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,
|
||||||
|
|||||||
Loaded 3 of 49 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user