主要能力: SuperAdmin 专用入口:组织与权限 → 运维与审计。 操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。 汇总自动排课、课表发布、补考安排三类失败后台任务。 实时检查数据库、缓存、任务通道及积压状态。 聚合 5xx、失败/重试任务、健康探针和备份时效告警。 SQLite 在线备份;MySQL 调用原生客户端备份。 SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。 MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
201 lines
7.0 KiB
C#
201 lines
7.0 KiB
C#
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];
|
||
}
|
||
}
|