Compare commits
8
Commits
v2.1.0
...
019b3fc827
No files matched your search
@@ -36,6 +36,24 @@ Cache__AnalyticsExpirationMinutes=3
|
||||
Cache__AnalyticsLocalExpirationSeconds=30
|
||||
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__BackupWarningHours=24
|
||||
|
||||
@@ -322,6 +322,55 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
|
||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||
|
||||
### OpenTelemetry 与慢查询定位
|
||||
|
||||
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
||||
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
||||
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
||||
仍然有效。
|
||||
|
||||
```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
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
@@ -368,9 +417,10 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
||||
|
||||
### 运维与审计控制台
|
||||
|
||||
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
|
||||
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
|
||||
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
|
||||
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
|
||||
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
|
||||
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
|
||||
前端菜单隐藏。
|
||||
|
||||
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
||||
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
||||
|
||||
@@ -75,6 +75,7 @@ public sealed class ExperimentsController(
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
x.CourseId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermStartDate = x.AcademicTerm.StartDate,
|
||||
TermEndDate = x.AcademicTerm.EndDate,
|
||||
@@ -295,6 +296,78 @@ public sealed class ExperimentsController(
|
||||
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}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateProject(
|
||||
@@ -478,6 +551,101 @@ public sealed class ExperimentsController(
|
||||
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}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CancelSession(
|
||||
@@ -1029,6 +1197,28 @@ public sealed record ExperimentProjectRequest(
|
||||
DateOnly StartDate,
|
||||
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(
|
||||
Guid ClassroomId,
|
||||
DateOnly SessionDate,
|
||||
@@ -1037,6 +1227,28 @@ public sealed record ExperimentSessionRequest(
|
||||
[Range(1, 10000)] int Capacity,
|
||||
[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(
|
||||
Guid AcademicTermId,
|
||||
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