181 lines
8.1 KiB
C#
181 lines
8.1 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Tiku.Api.Contracts;
|
|
using Tiku.Application.Jobs;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Application.Assets;
|
|
using Tiku.Application.Storage;
|
|
using Tiku.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Tiku.Infrastructure.Storage;
|
|
|
|
namespace Tiku.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Tags("平台端-运维")]
|
|
[Route("api/platform-admin/operations")]
|
|
[Authorize(Policy = BackendPermissions.PlatformOperationsView)]
|
|
public sealed class PlatformOperationsController(
|
|
IBackgroundJobService backgroundJobService,
|
|
ICurrentUser currentUser,
|
|
TikuDbContext dbContext,
|
|
IRedisSecurityStore redisSecurityStore,
|
|
IAssetSecurityScanner assetSecurityScanner,
|
|
IObjectStorageService objectStorageService,
|
|
IOptions<AliyunOssOptions> aliyunOssOptions) : ControllerBase
|
|
{
|
|
[HttpGet("health")]
|
|
[EndpointSummary("查询受保护的依赖深度健康状态")]
|
|
public async Task<ActionResult<object>> Health(CancellationToken cancellationToken)
|
|
{
|
|
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
|
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
|
var clamAv = await assetSecurityScanner.CheckHealthAsync(cancellationToken);
|
|
var storageProvider = objectStorageService.ConfiguredDefaultProvider();
|
|
var storageConfigured = storageProvider switch
|
|
{
|
|
ObjectStorageProviders.AliyunOss => aliyunOssOptions.Value.IsConfigured,
|
|
ObjectStorageProviders.LocalDev => true,
|
|
_ => false
|
|
};
|
|
var newestHeartbeat = await dbContext.WorkerHeartbeats.AsNoTracking()
|
|
.MaxAsync(item => (DateTimeOffset?)item.LastHeartbeatAt, cancellationToken);
|
|
var workerReady = newestHeartbeat >= DateTimeOffset.UtcNow.AddMinutes(-2);
|
|
return Ok(new
|
|
{
|
|
status = database && redis && clamAv && workerReady && storageConfigured ? "healthy" : "degraded",
|
|
database,
|
|
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
|
worker = new { ready = workerReady, lastHeartbeatAt = newestHeartbeat },
|
|
clamAv,
|
|
storage = new { provider = storageProvider, configured = storageConfigured },
|
|
checkedAt = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
|
|
[HttpGet("workers")]
|
|
[EndpointSummary("查询 Worker 与周期循环状态")]
|
|
public async Task<ActionResult<object>> Workers(CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var items = await dbContext.WorkerHeartbeats.AsNoTracking()
|
|
.OrderBy(item => item.WorkerId)
|
|
.ThenBy(item => item.Processor)
|
|
.Select(item => new
|
|
{
|
|
item.WorkerId,
|
|
item.Processor,
|
|
item.StartedAt,
|
|
item.LastHeartbeatAt,
|
|
item.LastIterationStartedAt,
|
|
item.LastIterationCompletedAt,
|
|
item.LastSucceededAt,
|
|
item.LastError,
|
|
item.IsRunning
|
|
})
|
|
.ToArrayAsync(cancellationToken);
|
|
return Ok(new
|
|
{
|
|
staleAfterSeconds = 120,
|
|
items = items.Select(item => new
|
|
{
|
|
item.WorkerId,
|
|
item.Processor,
|
|
item.StartedAt,
|
|
item.LastHeartbeatAt,
|
|
item.LastIterationStartedAt,
|
|
item.LastIterationCompletedAt,
|
|
item.LastSucceededAt,
|
|
item.LastError,
|
|
item.IsRunning,
|
|
stale = item.LastHeartbeatAt < now.AddMinutes(-2)
|
|
})
|
|
});
|
|
}
|
|
|
|
[HttpGet("job-metrics")]
|
|
[EndpointSummary("查询后台任务队列指标")]
|
|
public async Task<ActionResult<object>> JobMetrics(CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var counts = await dbContext.BackgroundJobs.AsNoTracking()
|
|
.GroupBy(item => item.Status)
|
|
.Select(group => new { status = group.Key, count = group.Count() })
|
|
.ToArrayAsync(cancellationToken);
|
|
var oldestPending = await dbContext.BackgroundJobs.AsNoTracking()
|
|
.Where(item => item.Status == BackgroundJobStatus.Pending)
|
|
.MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken);
|
|
var expiredLeases = await dbContext.BackgroundJobs.AsNoTracking()
|
|
.CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken);
|
|
return Ok(new
|
|
{
|
|
counts,
|
|
oldestPendingAt = oldestPending,
|
|
queueAgeSeconds = oldestPending.HasValue ? Math.Max(0, (now - oldestPending.Value).TotalSeconds) : 0,
|
|
expiredLeases,
|
|
checkedAt = now
|
|
});
|
|
}
|
|
|
|
[HttpGet("governance-metrics")]
|
|
[EndpointSummary("查询审批、配置与通知治理指标")]
|
|
public async Task<ActionResult<object>> GovernanceMetrics(CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var approvalCounts = await dbContext.PlatformApprovalRequests.AsNoTracking()
|
|
.GroupBy(item => item.Status)
|
|
.Select(group => new { status = group.Key, count = group.Count() })
|
|
.ToArrayAsync(cancellationToken);
|
|
var expiredPending = await dbContext.PlatformApprovalRequests.AsNoTracking()
|
|
.CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, cancellationToken);
|
|
var configurationDrafts = await dbContext.PlatformConfigurationVersions.AsNoTracking()
|
|
.CountAsync(item => item.Status == PlatformConfigurationVersionStatus.Draft, cancellationToken);
|
|
var notificationCounts = await dbContext.PlatformNotificationDeliveries.AsNoTracking()
|
|
.GroupBy(item => item.Status)
|
|
.Select(group => new { status = group.Key, count = group.Count() })
|
|
.ToArrayAsync(cancellationToken);
|
|
return Ok(new { approvals = approvalCounts, expiredPending, configurationDrafts, notifications = notificationCounts, checkedAt = now });
|
|
}
|
|
|
|
[HttpGet("jobs")]
|
|
[EndpointSummary("查询全平台后台任务")]
|
|
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> Jobs(
|
|
[FromQuery] Guid? tenantId,
|
|
[FromQuery] string? jobType,
|
|
[FromQuery] BackgroundJobStatus? status,
|
|
[FromQuery] int? limit,
|
|
CancellationToken cancellationToken) =>
|
|
Ok(await backgroundJobService.ListPlatformAsync(
|
|
tenantId, jobType, status, limit ?? 100, cancellationToken));
|
|
|
|
[HttpGet("jobs/{jobId:guid}")]
|
|
[EndpointSummary("查询平台后台任务详情")]
|
|
public async Task<ActionResult<BackgroundJobItem>> Job(Guid jobId, CancellationToken cancellationToken)
|
|
{
|
|
var item = await backgroundJobService.GetAsync(jobId, null, cancellationToken);
|
|
return item is null ? NotFound() : Ok(item);
|
|
}
|
|
|
|
[HttpPost("jobs/{jobId:guid}/cancel")]
|
|
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
|
[EndpointSummary("取消平台后台任务")]
|
|
public async Task<ActionResult<BackgroundJobItem>> CancelJob(
|
|
Guid jobId,
|
|
CancelBackgroundJobDto request,
|
|
CancellationToken cancellationToken) =>
|
|
Ok(await backgroundJobService.RequestCancellationAsync(
|
|
jobId, null, ResolveUserId(), request.Reason, cancellationToken));
|
|
|
|
[HttpPost("jobs/{jobId:guid}/retry")]
|
|
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
|
[EndpointSummary("重试平台后台任务")]
|
|
public async Task<ActionResult<BackgroundJobItem>> RetryJob(Guid jobId, CancellationToken cancellationToken) =>
|
|
Ok(await backgroundJobService.RetryAsync(jobId, null, ResolveUserId(), cancellationToken));
|
|
|
|
private Guid ResolveUserId() =>
|
|
currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
|
|
}
|