feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

View File

@@ -1,6 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using System.Formats.Tar;
using System.IO.Compression;
using Tiku.Application.Assets;
using Tiku.Application.Content;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
@@ -10,6 +13,8 @@ using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Observability;
using System.Diagnostics;
namespace Tiku.Infrastructure.Jobs;
@@ -25,7 +30,20 @@ internal sealed class BackgroundJobService(
CancellationToken cancellationToken = default)
{
var normalizedJobType = NormalizeJobType(command.JobType);
if (!(await featureAccessService.EvaluateAsync(
var idempotencyKey = NormalizeIdempotencyKey(command.IdempotencyKey);
if (idempotencyKey is not null)
{
var existing = await dbContext.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == command.TenantId && item.JobType == normalizedJobType &&
item.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existing is not null)
{
return ToItem(existing);
}
}
if (!command.IsSystemJob && !(await featureAccessService.EvaluateAsync(
command.TenantId,
ResolveRequiredFeature(normalizedJobType, command.Payload),
FeatureAccessOperation.Write,
@@ -33,7 +51,7 @@ internal sealed class BackgroundJobService(
{
throw new InvalidOperationException("Tenant feature entitlement does not allow this background job.");
}
var quotaMetric = ResolveQuotaMetric(normalizedJobType);
var quotaMetric = command.IsSystemJob ? null : ResolveQuotaMetric(normalizedJobType);
var quotaConsumed = false;
if (quotaMetric is not null)
{
@@ -53,6 +71,7 @@ internal sealed class BackgroundJobService(
{
TenantId = command.TenantId,
JobType = normalizedJobType,
IdempotencyKey = idempotencyKey,
Payload = command.Payload,
RunAfter = command.RunAfter,
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
@@ -62,6 +81,23 @@ internal sealed class BackgroundJobService(
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException) when (idempotencyKey is not null)
{
dbContext.ChangeTracker.Clear();
var existing = await dbContext.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == command.TenantId && item.JobType == normalizedJobType &&
item.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existing is not null)
{
if (quotaConsumed && quotaMetric is not null)
{
await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None);
}
return ToItem(existing);
}
throw;
}
catch
{
if (quotaConsumed && quotaMetric is not null)
@@ -167,12 +203,26 @@ internal sealed class BackgroundJobService(
bool alreadyClaimed,
CancellationToken cancellationToken)
{
var startedTimestamp = Stopwatch.GetTimestamp();
if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) ||
(alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId)))
{
return false;
}
if (!(await featureAccessService.EvaluateAsync(
await dbContext.Entry(job).ReloadAsync(cancellationToken);
if (job.CancellationRequestedAt.HasValue)
{
job.CompletedAt = DateTimeOffset.UtcNow;
await CompleteAsync(
job,
workerId,
BackgroundJobStatus.Cancelled,
job.Result,
null,
cancellationToken);
return true;
}
if (job.JobType is not ("asset_security_scan" or "tenant_export") && !(await featureAccessService.EvaluateAsync(
job.TenantId,
ResolveRequiredFeature(job.JobType, job.Payload),
FeatureAccessOperation.Write,
@@ -206,7 +256,11 @@ internal sealed class BackgroundJobService(
$"Background job {job.JobType}", job.Id.ToString("N")),
(provider, token) => ProcessCoreAsync(provider, job, token),
cancellationToken);
job.Status = BackgroundJobStatus.Succeeded;
var cancellationRequested = await dbContext.BackgroundJobs.AsNoTracking()
.Where(item => item.Id == job.Id)
.Select(item => item.CancellationRequestedAt != null)
.SingleAsync(cancellationToken);
job.Status = cancellationRequested ? BackgroundJobStatus.Cancelled : BackgroundJobStatus.Succeeded;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = null;
job.Result = result;
@@ -214,7 +268,13 @@ internal sealed class BackgroundJobService(
catch (Exception exception) when (exception is not OperationCanceledException)
{
job.RetryCount++;
job.LastError = exception.Message;
job.LastError = exception is AssetSecurityScannerException scannerException
? $"{scannerException.Code}: {scannerException.Message}"
: exception.Message;
if (exception is AssetSecurityScannerException assetScanException)
{
await RecordAssetScanRetryAsync(job, assetScanException, cancellationToken);
}
job.Status = job.RetryCount > job.MaxRetries
? BackgroundJobStatus.Failed
: BackgroundJobStatus.Pending;
@@ -225,6 +285,7 @@ internal sealed class BackgroundJobService(
finally
{
await CompleteAsync(job, workerId, job.Status, job.Result, job.LastError, cancellationToken);
WorkerTelemetry.RecordJob(job.JobType, job.Status.ToString(), Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
}
return true;
@@ -274,6 +335,126 @@ internal sealed class BackgroundJobService(
return jobs.Select(ToItem).ToArray();
}
public async Task<BackgroundJobItem?> 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<IReadOnlyCollection<BackgroundJobItem>> 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<BackgroundJobItem> 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<BackgroundJobItem> 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<BackgroundJob> 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 })
});
}
private async Task<JsonElement> ProcessCoreAsync(
IServiceProvider scopedProvider,
BackgroundJob job,
@@ -285,7 +466,8 @@ internal sealed class BackgroundJobService(
{
"content_export" => await ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
"content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken),
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
"asset_security_scan" => await ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken),
"tenant_export" => await ProcessTenantExportAsync(scopedProvider, scopedDbContext, job, cancellationToken),
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken),
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken),
@@ -334,6 +516,308 @@ internal sealed class BackgroundJobService(
});
}
private static async Task<JsonElement> ProcessAssetSecurityScanAsync(
IServiceProvider scopedProvider,
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var assetId = GetJsonGuid(job.Payload, "assetId") ??
throw new InvalidOperationException("asset_security_scan job requires assetId.");
var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == job.TenantId && item.Id == assetId,
cancellationToken) ?? throw new InvalidOperationException("Asset security scan target was not found.");
if (asset.UploadStatus != AssetUploadStatus.Verified ||
string.IsNullOrWhiteSpace(asset.Bucket) ||
string.IsNullOrWhiteSpace(asset.ObjectKey))
{
throw new InvalidOperationException("Asset must have a verified object location before security scanning.");
}
asset.SecurityScanStatus = AssetSecurityScanStatus.Scanning;
await scopedDbContext.SaveChangesAsync(cancellationToken);
var scanner = scopedProvider.GetRequiredService<IAssetSecurityScanner>();
var storage = scopedProvider.GetRequiredService<Tiku.Application.Storage.IObjectStorageService>();
await using var content = await storage.OpenReadAsync(
new Tiku.Application.Storage.ObjectStorageReadRequest(
job.TenantId,
asset.StorageProvider switch
{
AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev,
_ => throw new InvalidOperationException("Asset storage provider does not support security scanning.")
},
asset.Bucket,
asset.ObjectKey),
cancellationToken);
var result = await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken);
var infected = result.Verdict == AssetSecurityScanVerdict.Infected;
asset.SecurityScanStatus = infected ? AssetSecurityScanStatus.Failed : AssetSecurityScanStatus.Passed;
asset.SecurityScannedAt = DateTimeOffset.UtcNow;
asset.SecurityScanProvider = result.Provider;
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new
{
verdict = result.Verdict.ToString(),
result.Signature,
result.BytesScanned
});
scopedDbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
{
TenantId = job.TenantId,
AssetId = asset.Id,
Provider = result.Provider,
ScanStatus = asset.SecurityScanStatus,
RiskLevel = infected ? AssetSecurityRiskLevel.Critical : AssetSecurityRiskLevel.None,
IssueCodes = infected ? [result.Signature ?? "malware_detected"] : [],
Details = asset.SecurityScanSummary
});
await scopedDbContext.SaveChangesAsync(cancellationToken);
return JsonSerializer.SerializeToElement(new
{
assetId = asset.Id,
status = asset.SecurityScanStatus.ToString(),
result.Signature,
result.BytesScanned
});
}
private async Task RecordAssetScanRetryAsync(
BackgroundJob job,
AssetSecurityScannerException exception,
CancellationToken cancellationToken)
{
var assetId = GetJsonGuid(job.Payload, "assetId");
if (assetId is null)
{
return;
}
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == job.TenantId && item.Id == assetId,
cancellationToken);
if (asset is null)
{
return;
}
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
asset.SecurityScanProvider = "clamav";
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new { errorCode = exception.Code });
dbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
{
TenantId = job.TenantId,
AssetId = asset.Id,
Provider = "clamav",
ScanStatus = AssetSecurityScanStatus.Pending,
RiskLevel = AssetSecurityRiskLevel.None,
IssueCodes = [exception.Code],
Details = asset.SecurityScanSummary
});
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task<JsonElement> ProcessTenantExportAsync(
IServiceProvider scopedProvider,
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var operationId = GetJsonGuid(job.Payload, "operationId") ??
throw new InvalidOperationException("tenant_export job requires operationId.");
var operation = await scopedDbContext.TenantLifecycleOperations.SingleOrDefaultAsync(item =>
item.TenantId == job.TenantId && item.Id == operationId &&
item.OperationType == TenantLifecycleOperationType.Export,
cancellationToken) ?? throw new InvalidOperationException("Tenant export operation was not found.");
operation.Status = TenantLifecycleOperationStatus.Processing;
operation.StartedAt ??= DateTimeOffset.UtcNow;
operation.LastError = null;
await scopedDbContext.SaveChangesAsync(cancellationToken);
var temporaryPath = Path.Combine(Path.GetTempPath(), $"tiku-tenant-export-{operation.Id:N}.tar.gz");
try
{
var tenant = await scopedDbContext.Tenants.AsNoTracking().SingleAsync(item => item.Id == job.TenantId, cancellationToken);
var memberships = await scopedDbContext.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == job.TenantId)
.Select(item => new { item.UserId, item.Role, item.Status, item.CreatedAt, item.UpdatedAt })
.ToArrayAsync(cancellationToken);
var domains = await scopedDbContext.TenantDomains.AsNoTracking()
.Where(item => item.TenantId == job.TenantId)
.Select(item => new { item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.CreatedAt, item.UpdatedAt })
.ToArrayAsync(cancellationToken);
var assets = await scopedDbContext.ContentAssets.AsNoTracking()
.Where(item => item.TenantId == job.TenantId && item.Status == ContentStatus.Active)
.ToArrayAsync(cancellationToken);
var storage = scopedProvider.GetRequiredService<Tiku.Application.Storage.IObjectStorageService>();
await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, FileOptions.Asynchronous))
await using (var gzip = new GZipStream(file, CompressionLevel.Fastest, leaveOpen: false))
await using (var archive = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: false))
{
await WriteJsonEntryAsync(archive, "manifest.json", new
{
format = "tiku-tenant-export",
version = 1,
tenantId = job.TenantId,
operationId,
generatedAt = DateTimeOffset.UtcNow,
exclusions = new[] { "password_hashes", "auth_tokens", "refresh_tokens", "secret_plaintext", "data_protection_keys", "global_platform_data" },
tables = new[] { "tenant", "tenant_memberships", "tenant_domains", "content_assets" }
}, cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/tenant.jsonl", new[]
{
new { tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode, tenant.BillingStatus, tenant.OwnerUserId, tenant.CreatedAt, tenant.UpdatedAt }
}, cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/tenant_memberships.jsonl", memberships, cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/tenant_domains.jsonl", domains, cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/content_assets.jsonl", assets.Select(item => new
{
item.Id,
item.AssetKey,
item.Title,
item.FileName,
item.StorageProvider,
item.Bucket,
item.ObjectKey,
item.MimeType,
item.FileSizeBytes,
item.ChecksumSha256,
item.UploadStatus,
item.SecurityScanStatus,
item.CreatedAt,
item.UpdatedAt
}), cancellationToken);
foreach (var asset in assets.Where(item =>
item.UploadStatus == AssetUploadStatus.Verified &&
item.SecurityScanStatus is AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired &&
!string.IsNullOrWhiteSpace(item.Bucket) &&
!string.IsNullOrWhiteSpace(item.ObjectKey)))
{
var provider = asset.StorageProvider switch
{
AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev,
_ => null
};
if (provider is null) continue;
await using var content = await storage.OpenReadAsync(
new Tiku.Application.Storage.ObjectStorageReadRequest(
job.TenantId, provider, asset.Bucket!, asset.ObjectKey!),
cancellationToken);
var name = SanitizeTarPath(asset.FileName ?? asset.Id.ToString("N"));
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, $"assets/{asset.Id:N}/{name}")
{
DataStream = content
});
}
}
await using var upload = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.Asynchronous);
var providerName = storage.ConfiguredDefaultProvider();
var bucket = storage.ConfiguredDefaultBucket();
var objectKey = storage.ValidateObjectKey(job.TenantId, $"{job.TenantId:N}/tenant-exports/{operation.Id:N}.tar.gz");
var written = await storage.WriteObjectAsync(
new Tiku.Application.Storage.ObjectStorageWriteRequest(
job.TenantId,
providerName,
bucket,
objectKey,
"application/gzip",
upload,
upload.Length,
Upsert: false),
cancellationToken);
var exportAsset = new ContentAsset
{
TenantId = job.TenantId,
Title = "Tenant export archive",
FileName = $"tenant-export-{operation.Id:N}.tar.gz",
AssetType = ContentAssetType.Document,
StorageProvider = providerName switch
{
Tiku.Application.Storage.ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
Tiku.Application.Storage.ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
_ => AssetStorageProvider.ExternalUrl
},
Bucket = written.Bucket,
ObjectKey = written.ObjectKey,
MimeType = "application/gzip",
FileSizeBytes = written.SizeBytes,
ChecksumSha256 = written.ChecksumSha256,
UploadStatus = AssetUploadStatus.Verified,
VerifiedAt = DateTimeOffset.UtcNow,
VerifiedSizeBytes = written.SizeBytes,
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
Source = "tenant_export"
};
scopedDbContext.ContentAssets.Add(exportAsset);
operation.ExportAssetId = exportAsset.Id;
operation.Status = TenantLifecycleOperationStatus.Succeeded;
operation.CompletedAt = DateTimeOffset.UtcNow;
operation.Result = JsonSerializer.SerializeToElement(new
{
exportAssetId = exportAsset.Id,
written.SizeBytes,
assetCount = assets.Length
});
await scopedDbContext.SaveChangesAsync(cancellationToken);
job.OutputAssetId = exportAsset.Id;
return operation.Result;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
operation.Status = TenantLifecycleOperationStatus.Failed;
operation.LastError = exception.Message;
operation.CompletedAt = DateTimeOffset.UtcNow;
await scopedDbContext.SaveChangesAsync(cancellationToken);
throw;
}
finally
{
if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
}
}
private static async Task WriteJsonEntryAsync<T>(
TarWriter archive,
string name,
T value,
CancellationToken cancellationToken)
{
var stream = new MemoryStream();
await JsonSerializer.SerializeAsync(stream, value, cancellationToken: cancellationToken);
stream.Position = 0;
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream });
await stream.DisposeAsync();
}
private static async Task WriteJsonLinesEntryAsync<T>(
TarWriter archive,
string name,
IEnumerable<T> values,
CancellationToken cancellationToken)
{
var stream = new MemoryStream();
await using (var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(false), leaveOpen: true))
{
foreach (var value in values)
{
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteLineAsync(JsonSerializer.Serialize(value));
}
}
stream.Position = 0;
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream });
await stream.DisposeAsync();
}
private static string SanitizeTarPath(string value)
{
var name = Path.GetFileName(value).Replace('\\', '_').Replace('/', '_');
return string.IsNullOrWhiteSpace(name) ? "asset.bin" : name;
}
private async Task<JsonElement> ProcessContentExportAsync(
TikuDbContext scopedDbContext,
BackgroundJob job,
@@ -504,6 +988,17 @@ internal sealed class BackgroundJobService(
return jobType.Trim().ToLowerInvariant();
}
private static string? NormalizeIdempotencyKey(string? value)
{
var normalized = value?.Trim();
if (string.IsNullOrEmpty(normalized)) return null;
if (normalized.Length > 200)
{
throw new BackgroundJobException("background_job_idempotency_key_too_long", "Idempotency key cannot exceed 200 characters.");
}
return normalized;
}
private static string ResolveRequiredFeature(string jobType, JsonElement payload) => jobType switch
{
"content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType"))
@@ -575,12 +1070,16 @@ internal sealed class BackgroundJobService(
job.Id,
job.TenantId,
job.JobType,
job.IdempotencyKey,
job.Status,
job.RetryCount,
job.MaxRetries,
job.RunAfter,
job.StartedAt,
job.CompletedAt,
job.CancellationRequestedAt,
job.CancellationRequestedBy,
job.CancellationReason,
job.LastError,
job.OutputAssetId,
job.Result);