using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using GB5Shared.Connection;
using GB5Shared.DTO.ECM;
using GB5Shared.FileUpload;
using GB5Shared.DTO.Framework.CommonConfig;
using GB5Shared.DTO.Framework.Login;
using GB5Shared.DTO.Framework.ServerConfig;
using GB5Shared.QueryExecutor;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using static GB5Shared.GB5Constant.Constant;
namespace FrameworkBLL.OrphanCleanup
{
///
/// Periodic background job that deletes TATTACHMENT rows with OBJECTID=0
/// older than OrphanCleanupConfig.OrphanAgeHours and removes their physical files.
/// Runs once per active tenant resolved from MSERVERCONFIG.
///
public sealed class OrphanAttachmentCleanupJob : BackgroundService
{
private readonly IServiceProvider _services;
private readonly IConfiguration _config;
private readonly OrphanCleanupConfig _cfg;
private readonly ILogger _logger;
public OrphanAttachmentCleanupJob(
IServiceProvider services,
IConfiguration config,
IOptions options,
ILogger logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_config = config ?? throw new ArgumentNullException(nameof(config));
_cfg = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"OrphanAttachmentCleanupJob started. Interval={Interval}m, OrphanAge={Age}h, Batch={Batch}",
_cfg.IntervalMinutes, _cfg.OrphanAgeHours, _cfg.BatchSize);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RunCycleAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "OrphanAttachmentCleanupJob cycle failed at {Time:HH:mm:ss}", DateTime.UtcNow);
}
try
{
await Task.Delay(TimeSpan.FromMinutes(_cfg.IntervalMinutes), stoppingToken)
.ConfigureAwait(false);
}
catch (TaskCanceledException)
{
break;
}
}
_logger.LogInformation("OrphanAttachmentCleanupJob stopped.");
}
private async Task RunCycleAsync(CancellationToken ct)
{
var tenants = await GetTenantsAsync().ConfigureAwait(false);
foreach (var tenant in tenants)
{
try
{
await RunTenantCycleAsync(tenant, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex,
"OrphanAttachmentCleanupJob failed for tenant {DatabaseName}", tenant.DatabaseName);
}
}
}
private async Task RunTenantCycleAsync(ServerConfigDTO tenant, CancellationToken ct)
{
var login = BuildLogin(tenant);
using var scope = _services.CreateScope();
var attachmentDAL = scope.ServiceProvider.GetRequiredService();
var queryExecutor = scope.ServiceProvider.GetRequiredService();
var olderThan = DateTime.UtcNow.AddHours(-_cfg.OrphanAgeHours);
IEnumerable orphans = await attachmentDAL
.GetOrphanedAttachmentsAsync(olderThan, _cfg.BatchSize, login, ct)
.ConfigureAwait(false);
var orphanList = orphans.ToList();
if (orphanList.Count == 0)
return;
string basePath = GetBasePath();
foreach (var orphan in orphanList)
DeletePhysicalFile(orphan, basePath);
var ids = orphanList.Select(o => o.AttachmentId).ToList();
var tx = await queryExecutor.BeginTransactionAsync(login).ConfigureAwait(false);
try
{
await attachmentDAL.DeleteAttachmentsAsync(ids, login, tx, ct).ConfigureAwait(false);
await queryExecutor.CommitAsync(tx).ConfigureAwait(false);
}
catch
{
await queryExecutor.RollbackAsync(tx).ConfigureAwait(false);
throw;
}
_logger.LogInformation(
"OrphanCleanup [{Db}]: deleted {Count} attachment(s) older than {Hours}h",
tenant.DatabaseName, orphanList.Count, _cfg.OrphanAgeHours);
}
private async Task> GetTenantsAsync()
{
using var scope = _services.CreateScope();
var appConnection = scope.ServiceProvider.GetRequiredService();
var databaseDTO = scope.ServiceProvider.GetRequiredService>();
var systemConn = await appConnection.Gb5SystemConnectionString().ConfigureAwait(false);
int dbType = databaseDTO.CurrentValue.DataBaseType;
const string sql = @"
SELECT
SERVERCONFIG1.CLIENTID AS ClientId,
SERVERCONFIG1.DATABASENAME AS DatabaseName,
SERVERCONFIG1.DATABASETYPE AS DbType,
SERVERCONFIG1.CONNECTIONNAME AS ConnectionName
FROM MSERVERCONFIG SERVERCONFIG1
JOIN MSERVER SERVER1
ON SERVERCONFIG1.SERVERID = SERVER1.SERVERID
WHERE SERVERCONFIG1.STATUS = 1
AND SERVERCONFIG1.CONNECTIONNAME <> 'ACTIVITI'
-- Tracker §47/§48 — per-tenant opt-in, default Disabled; same fix as this
-- job's own sibling, OrphanDraftCleanupJob.
AND SERVERCONFIG1.SCHEDULERENABLED = 1";
using IDbConnection connection = dbType switch
{
DBTYPE.SQL => new SqlConnection(systemConn),
DBTYPE.POSTGRESQL => new NpgsqlConnection(systemConn),
_ => throw new NotSupportedException($"Unsupported DB type: {dbType}")
};
return (await connection.QueryAsync(sql).ConfigureAwait(false)).ToList();
}
private static LoginDTO BuildLogin(ServerConfigDTO tenant) => new()
{
UserId = -1,
ClientId = tenant.ClientId,
ConnectionDatabaseName = tenant.ConnectionName,
DatabaseName = tenant.DatabaseName,
DatabaseType = tenant.DbType,
UserName = "OrphanCleanupJob"
};
private string GetBasePath()
{
string osKey = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
System.Runtime.InteropServices.OSPlatform.Windows) ? "Windows" : "Linux";
string raw = _config[$"FileStorage:PhysicalFilePath:{osKey}"]
?? _config["FileStorage:PhysicalFilePath"]
?? throw new InvalidOperationException(
$"FileStorage:PhysicalFilePath:{osKey} (or FileStorage:PhysicalFilePath) is not configured.");
return raw.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar)
.TrimEnd(Path.DirectorySeparatorChar);
}
private static void DeletePhysicalFile(TAttachmentDTO attachment, string basePath)
{
if (string.IsNullOrWhiteSpace(attachment.CMSId) ||
string.IsNullOrWhiteSpace(attachment.DisplayFileName))
return;
string filePath = Path.Combine(
basePath, attachment.FolderName, attachment.CMSId, attachment.DisplayFileName);
if (System.IO.File.Exists(filePath))
System.IO.File.Delete(filePath);
string cmsDir = Path.GetDirectoryName(filePath)!;
if (Directory.Exists(cmsDir) && Directory.GetFileSystemEntries(cmsDir).Length == 0)
Directory.Delete(cmsDir);
}
}
}