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) : ControllerBase { [HttpGet("health")] [EndpointSummary("查询受保护的依赖深度健康状态")] public async Task> 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> 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> 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> 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>> 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> 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> 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> 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."); }