Files
tiku-backend.net/Tiku.Api/Controllers/PlatformOperationsController.cs
xiong 290a0c7bd7
Some checks failed
ci / release-gate (push) Has been cancelled
feat(platform): harden governance and approval workflows
2026-08-03 11:43:18 +08:00

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.");
}