using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Jobs; using Tiku.Domain.Operations; namespace Tiku.Infrastructure.Jobs; internal sealed partial class BackgroundJobService { public async Task> ListAsync( Guid tenantId, string? jobType = null, int limit = 50, CancellationToken cancellationToken = default) { var query = dbContext.BackgroundJobs.AsNoTracking() .Where(job => job.TenantId == tenantId); if (!string.IsNullOrWhiteSpace(jobType)) { var normalized = NormalizeJobType(jobType); query = query.Where(job => job.JobType == normalized); } var jobs = await query .OrderByDescending(job => job.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) .ToArrayAsync(cancellationToken); return jobs.Select(ToItem).ToArray(); } public async Task GetAsync( Guid jobId, Guid? tenantId, CancellationToken cancellationToken = default) { var query = dbContext.BackgroundJobs.AsNoTracking().Where(item => item.Id == jobId); if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value); var job = await query.SingleOrDefaultAsync(cancellationToken); return job is null ? null : ToItem(job); } public async Task> ListPlatformAsync( Guid? tenantId = null, string? jobType = null, BackgroundJobStatus? status = null, int limit = 100, CancellationToken cancellationToken = default) { var query = dbContext.BackgroundJobs.AsNoTracking().AsQueryable(); if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value); if (!string.IsNullOrWhiteSpace(jobType)) { var normalized = NormalizeJobType(jobType); query = query.Where(item => item.JobType == normalized); } if (status.HasValue) query = query.Where(item => item.Status == status.Value); return (await query.OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(limit, 1, 500)) .ToArrayAsync(cancellationToken)) .Select(ToItem) .ToArray(); } public async Task RequestCancellationAsync( Guid jobId, Guid? tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default) { dbContext.ChangeTracker.Clear(); if (string.IsNullOrWhiteSpace(reason)) throw new BackgroundJobException("background_job_cancel_reason_required", "Cancellation reason is required."); var job = await FindMutableAsync(jobId, tenantId, cancellationToken); if (job.Status is BackgroundJobStatus.Succeeded or BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled) throw new BackgroundJobException("background_job_not_cancellable", "Only pending or processing jobs can be cancelled."); var now = DateTimeOffset.UtcNow; job.CancellationRequestedAt = now; job.CancellationRequestedBy = actorUserId; job.CancellationReason = reason.Trim(); if (job.Status == BackgroundJobStatus.Pending) { job.Status = BackgroundJobStatus.Cancelled; job.CompletedAt = now; } AddMutationAudit(job, actorUserId, "background_job.cancel_requested"); await dbContext.SaveChangesAsync(cancellationToken); return ToItem(job); } public async Task RetryAsync( Guid jobId, Guid? tenantId, Guid actorUserId, CancellationToken cancellationToken = default) { dbContext.ChangeTracker.Clear(); var job = await FindMutableAsync(jobId, tenantId, cancellationToken); if (job.Status is not (BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled)) throw new BackgroundJobException("background_job_not_retryable", "Only failed or cancelled jobs can be retried."); job.Status = BackgroundJobStatus.Pending; job.RunAfter = DateTimeOffset.UtcNow; job.StartedAt = null; job.CompletedAt = null; job.LockedBy = null; job.LockExpiresAt = null; job.LastError = null; job.CancellationRequestedAt = null; job.CancellationRequestedBy = null; job.CancellationReason = null; AddMutationAudit(job, actorUserId, "background_job.retry_requested"); await dbContext.SaveChangesAsync(cancellationToken); return ToItem(job); } private async Task FindMutableAsync( Guid jobId, Guid? tenantId, CancellationToken cancellationToken) { var query = dbContext.BackgroundJobs.Where(item => item.Id == jobId); if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value); return await query.SingleOrDefaultAsync(cancellationToken) ?? throw new BackgroundJobException("background_job_not_found", "Background job was not found."); } private void AddMutationAudit(BackgroundJob job, Guid actorUserId, string action) { dbContext.AuditLogs.Add(new AuditLog { TenantId = job.TenantId, ActorUserId = actorUserId, Action = action, TargetType = "background_job", TargetId = job.Id.ToString(), Details = JsonSerializer.SerializeToElement(new { job.JobType, job.Status }) }); } }