Files
Academic-Affairs-System/src/Jiaowu.Api/Infrastructure/Operations/OperationalHealthService.cs
T
biss 4d8de1e4ae “运维与审计控制台”。
主要能力:
SuperAdmin 专用入口:组织与权限 → 运维与审计。
操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。
汇总自动排课、课表发布、补考安排三类失败后台任务。
实时检查数据库、缓存、任务通道及积压状态。
聚合 5xx、失败/重试任务、健康探针和备份时效告警。
SQLite 在线备份;MySQL 调用原生客户端备份。
SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。
MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
2026-07-27 16:37:45 +08:00

201 lines
7.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
namespace Jiaowu.Api.Infrastructure.Operations;
public sealed record OperationalComponentHealth(
string Key,
string Label,
string Status,
string Backend,
long? LatencyMilliseconds,
string Detail);
public sealed record OperationalHealthSnapshot(
DateTime CheckedAt,
string OverallStatus,
IReadOnlyCollection<OperationalComponentHealth> Components,
BackgroundJobBacklogSnapshot? Backlog);
public sealed class OperationalHealthService(
AppDbContext db,
IServiceProvider services,
IBackgroundJobTransport transport,
BackgroundJobMonitoringService monitoring,
DatabaseOptions databaseOptions,
AppCacheOptions cacheOptions,
IConfiguration configuration)
{
public async Task<OperationalHealthSnapshot> CheckAsync(
CancellationToken cancellationToken)
{
var database = await CheckDatabaseAsync(cancellationToken);
var cache = await CheckCacheAsync(cancellationToken);
var (messaging, backlog) = await CheckMessagingAsync(cancellationToken);
var components = new[] { database, cache, messaging };
var overall = components.Any(x => x.Status == "unhealthy")
? "unhealthy"
: components.Any(x => x.Status == "warning")
? "warning"
: "healthy";
return new OperationalHealthSnapshot(
DateTime.UtcNow,
overall,
components,
backlog);
}
private async Task<OperationalComponentHealth> CheckDatabaseAsync(
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var canConnect = await db.Database.CanConnectAsync(cancellationToken);
stopwatch.Stop();
return canConnect
? new OperationalComponentHealth(
"database",
"数据库",
"healthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
"连接与基础查询正常。")
: new OperationalComponentHealth(
"database",
"数据库",
"unhealthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
"无法建立数据库连接。");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return new OperationalComponentHealth(
"database",
"数据库",
"unhealthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
SafeMessage(exception));
}
}
private async Task<OperationalComponentHealth> CheckCacheAsync(
CancellationToken cancellationToken)
{
if (!cacheOptions.Enabled)
{
return new OperationalComponentHealth(
"cache",
"缓存",
"warning",
"disabled",
null,
"缓存已通过配置关闭,所有查询将直接访问数据源。");
}
var hasRedisConfiguration = !string.IsNullOrWhiteSpace(
configuration.GetConnectionString("Redis"));
var distributedCache = services.GetService<IDistributedCache>();
if (!hasRedisConfiguration || distributedCache is null)
{
return new OperationalComponentHealth(
"cache",
"缓存",
"healthy",
"memory",
null,
"使用进程内混合缓存;服务重启后缓存会自然重建。");
}
var stopwatch = Stopwatch.StartNew();
try
{
await distributedCache.GetAsync(
"jiaowu:operations:health-probe",
cancellationToken);
stopwatch.Stop();
return new OperationalComponentHealth(
"cache",
"缓存",
"healthy",
"redis",
stopwatch.ElapsedMilliseconds,
"Redis 连接与读取探针正常。");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return new OperationalComponentHealth(
"cache",
"缓存",
"unhealthy",
"redis",
stopwatch.ElapsedMilliseconds,
SafeMessage(exception));
}
}
private async Task<(OperationalComponentHealth Component,
BackgroundJobBacklogSnapshot? Backlog)> CheckMessagingAsync(
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
BackgroundJobBacklogSnapshot? backlog = null;
try
{
var healthy = await transport.CheckHealthAsync(cancellationToken);
backlog = await monitoring.GetSnapshotAsync(cancellationToken);
stopwatch.Stop();
var stuck = backlog.ExpiredLeases > 0 ||
backlog.OldestUnfinishedAgeSeconds > 1800;
var status = !healthy
? "unhealthy"
: stuck
? "warning"
: "healthy";
var detail = !healthy
? "后台任务传输不可用。"
: stuck
? $"发现 {backlog.ExpiredLeases} 个过期租约,最早未完成任务已等待 " +
$"{Math.Round(backlog.OldestUnfinishedAgeSeconds ?? 0)} 秒。"
: $"待发布 {backlog.Pending + backlog.Publishing}" +
$"待处理 {backlog.Published + backlog.Processing}。";
return (
new OperationalComponentHealth(
"messaging",
"后台任务通道",
status,
transport.IsDurable ? "rabbitmq" : "memory",
stopwatch.ElapsedMilliseconds,
detail),
backlog);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return (
new OperationalComponentHealth(
"messaging",
"后台任务通道",
"unhealthy",
transport.IsDurable ? "rabbitmq" : "memory",
stopwatch.ElapsedMilliseconds,
SafeMessage(exception)),
backlog);
}
}
private static string SafeMessage(Exception exception)
{
var message = exception.GetBaseException().Message;
return message.Length <= 300 ? message : message[..300];
}
}