HousekeepingJobs.PruneAutoTestEntitiesAsync — Hangfire-recurring (02:45 UTC
daily, Cron:PruneAutoTestEntities). Удаляет:
- orgs по regex autotest-паттерна в имени (quality-, soak-, UI-a11y-,
SmokeOrgA/B-, PerfBaseline-, Reg-, RT-ratetest-, Probe Org, cat3*,
visual-, factory-smoke-, mt6*, doc4*, auth2*, rep5*, rt8*, wiz9*,
i18n*, twofa*, iso*, mob1*, axdet-, load-, s19-test- + любое имя
с [0-9]{10,}) старше 3 дней + все их таблицы с OrganizationId;
- orphan autotest-юзеров (без удалённой org) по regex в email и доменам
@e.kz / @test-fm.local.
Защищены: SuperAdmin (OrganizationId IS NULL), admin@food-market.local,
admin@demo-market.local, demo-*@food-market.local, market@email.com,
«Demo Market» и «Демо-магазин» — явные исключения.
deploy/stage-prune-autotest.sql — ровно та же логика как SQL-скрипт для
ручного запуска. Прогнал на stage: было 8653 user'ов / 8634 orgs →
стало 300 users / 305 orgs. SuperAdmin/demo/market остались.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
482 lines
25 KiB
C#
482 lines
25 KiB
C#
using foodmarket.Domain.Inventory;
|
||
using foodmarket.Domain.Purchases;
|
||
using foodmarket.Domain.Sales;
|
||
using foodmarket.Infrastructure.Persistence;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace foodmarket.Api.Background;
|
||
|
||
/// <summary>Hangfire-джобы для регулярной чистки исторических данных:
|
||
/// <c>StockMovement</c> старше 2 лет (компенсирует разрастание таблицы при
|
||
/// большом обороте продаж), <c>SuperAdminAuditLog</c> старше 90 дней. Период
|
||
/// можно поменять через конфиг <c>Hangfire:Retention:*</c>.
|
||
///
|
||
/// Реализованы как scoped-сервис, чтобы Hangfire разрешил <see cref="AppDbContext"/>
|
||
/// и фильтр tenant'а корректно (контекст НЕТ tenant'а — но cleanup идёт без
|
||
/// tenant-фильтра через IgnoreQueryFilters, потому что это межтенантная задача).</summary>
|
||
public class HousekeepingJobs
|
||
{
|
||
private readonly AppDbContext _db;
|
||
private readonly IConfiguration _cfg;
|
||
private readonly ILogger<HousekeepingJobs> _log;
|
||
|
||
public HousekeepingJobs(AppDbContext db, IConfiguration cfg, ILogger<HousekeepingJobs> log)
|
||
{
|
||
_db = db;
|
||
_cfg = cfg;
|
||
_log = log;
|
||
}
|
||
|
||
/// <summary>Удаляет <see cref="StockMovement"/> старше N дней (по умолчанию 730).
|
||
/// Stock-инвариант (Stock.Quantity ≡ Σ StockMovement) ломается на удалённых
|
||
/// записях, поэтому удаляем ТОЛЬКО действительно старые движения — там
|
||
/// невозможно сослаться через unpost (документы уже закрыты).
|
||
///
|
||
/// Возвращает количество удалённых строк (для логов/мониторинга).</summary>
|
||
public async Task<int> PruneStockMovementsAsync(CancellationToken ct = default)
|
||
{
|
||
var days = _cfg.GetValue("Hangfire:Retention:StockMovementDays", 730);
|
||
var threshold = DateTime.UtcNow.AddDays(-days);
|
||
|
||
var deleted = await _db.StockMovements
|
||
.IgnoreQueryFilters()
|
||
.Where(m => m.OccurredAt < threshold)
|
||
.ExecuteDeleteAsync(ct);
|
||
_log.LogInformation("Hangfire/PruneStockMovements: удалено {Count} движений старше {Threshold:O}",
|
||
deleted, threshold);
|
||
return deleted;
|
||
}
|
||
|
||
/// <summary>Удаляет <c>super_admin_audit_log</c> старше N дней (по умолчанию 90).
|
||
/// SuperAdmin'ские действия в каждой орге — отдельный аудит, для compliance
|
||
/// храним недолго (квартал) и зачищаем.</summary>
|
||
public async Task<int> PruneAuditLogAsync(CancellationToken ct = default)
|
||
{
|
||
var days = _cfg.GetValue("Hangfire:Retention:AuditLogDays", 90);
|
||
var threshold = DateTime.UtcNow.AddDays(-days);
|
||
|
||
var deleted = await _db.SuperAdminAuditLogs
|
||
.IgnoreQueryFilters()
|
||
.Where(a => a.CreatedAt < threshold)
|
||
.ExecuteDeleteAsync(ct);
|
||
_log.LogInformation("Hangfire/PruneAuditLog: удалено {Count} записей старше {Threshold:O}",
|
||
deleted, threshold);
|
||
return deleted;
|
||
}
|
||
|
||
/// <summary>Sprint 20: удаляет <c>OrgAuditLog</c> старше N дней
|
||
/// (по умолчанию 90). Per-tenant журнал мутаций отчётности — растёт
|
||
/// быстрее SuperAdmin-аудита, поэтому отдельный prune.</summary>
|
||
public async Task<int> PruneOrgAuditLogAsync(CancellationToken ct = default)
|
||
{
|
||
var days = _cfg.GetValue("Cleanup:OrgAuditLogDays", 90);
|
||
var threshold = DateTime.UtcNow.AddDays(-days);
|
||
|
||
var deleted = await _db.OrgAuditLogs
|
||
.IgnoreQueryFilters()
|
||
.Where(a => a.CreatedAt < threshold)
|
||
.ExecuteDeleteAsync(ct);
|
||
_log.LogInformation("Hangfire/PruneOrgAuditLog: удалено {Count} записей старше {Threshold:O}",
|
||
deleted, threshold);
|
||
return deleted;
|
||
}
|
||
|
||
/// <summary>Sprint 20: удаляет draft-документы старше N дней (по умолчанию 30).
|
||
/// Что чистим: Supply / RetailSale / Demand в статусе Draft, без PostedAt.
|
||
/// Каскадно зацепляются связанные lines (FK ON DELETE CASCADE — настроено
|
||
/// в configurations).
|
||
///
|
||
/// Безопасность: draft = пользователь его не провёл, движений не было,
|
||
/// удаление не нарушает Stock-инвариант. Per-tenant фильтр НЕ применяется
|
||
/// (cross-tenant cleanup), но в WHERE добавляется проверка status.</summary>
|
||
public async Task<(int Supplies, int Sales, int Demands)> PruneDraftsAsync(CancellationToken ct = default)
|
||
{
|
||
var days = _cfg.GetValue("Cleanup:DraftDays", 30);
|
||
var threshold = DateTime.UtcNow.AddDays(-days);
|
||
|
||
var supplies = await _db.Supplies.IgnoreQueryFilters()
|
||
.Where(s => s.Status == SupplyStatus.Draft && s.CreatedAt < threshold)
|
||
.ExecuteDeleteAsync(ct);
|
||
var sales = await _db.RetailSales.IgnoreQueryFilters()
|
||
.Where(s => s.Status == RetailSaleStatus.Draft && s.CreatedAt < threshold)
|
||
.ExecuteDeleteAsync(ct);
|
||
var demands = await _db.Demands.IgnoreQueryFilters()
|
||
.Where(d => d.Status == DemandStatus.Draft && d.CreatedAt < threshold)
|
||
.ExecuteDeleteAsync(ct);
|
||
|
||
_log.LogInformation("Hangfire/PruneDrafts: supplies={S} sales={Sa} demands={D} (старше {Threshold:O})",
|
||
supplies, sales, demands, threshold);
|
||
return (supplies, sales, demands);
|
||
}
|
||
|
||
/// <summary>Sprint 20: удаляет refresh-токены OpenIddict со статусом
|
||
/// revoked старше N дней (по умолчанию 7). Активные / неревокированные
|
||
/// не трогаем — у них собственный TTL.
|
||
///
|
||
/// OpenIddict таблица — <c>OpenIddictTokens</c>. Status = "revoked" |
|
||
/// "redeemed" | "valid". Type = "refresh_token" | "access_token".
|
||
/// Удаляем только refresh-токены: access-токены протухают быстро (15
|
||
/// минут), их Hangfire-cleanup'a в OpenIddict уже есть.</summary>
|
||
public async Task<int> PruneRevokedRefreshTokensAsync(CancellationToken ct = default)
|
||
{
|
||
var days = _cfg.GetValue("Cleanup:RevokedRefreshTokenDays", 7);
|
||
var threshold = DateTime.UtcNow.AddDays(-days);
|
||
|
||
// OpenIddict ED таблица: тип `OpenIddictEntityFrameworkCoreToken`.
|
||
// Используем raw SQL чтобы не тащить лишних using-ов; колонки
|
||
// прибиты конвенцией OpenIddict.
|
||
var deleted = await _db.Database.ExecuteSqlInterpolatedAsync($@"
|
||
DELETE FROM ""OpenIddictTokens""
|
||
WHERE ""Type"" = 'refresh_token'
|
||
AND ""Status"" IN ('revoked', 'redeemed')
|
||
AND ""CreationDate"" < {threshold}", ct);
|
||
_log.LogInformation("Hangfire/PruneRevokedRefreshTokens: удалено {Count} токенов старше {Threshold:O}",
|
||
deleted, threshold);
|
||
return deleted;
|
||
}
|
||
|
||
/// <summary>Sprint 25: hard-delete тестовых организаций, которые
|
||
/// создаёт <c>~/quality-watchdog.sh</c> по конвенции
|
||
/// <c>quality-{epoch}-A/B</c>. Stage-БД иначе разрастается на
|
||
/// ~2 org/час = ~50/день. Делается строго по имени-шаблону, чтобы
|
||
/// никогда не задеть реальные орги (даже архивные).
|
||
///
|
||
/// Реализация: динамически находим все таблицы с колонкой
|
||
/// <c>OrganizationId</c> через information_schema, и DELETE'им из
|
||
/// них строки для match'ащих org-id. Заказ в одной транзакции через
|
||
/// CTE с DEFERRABLE FK не понадобится — PG умеет резолвить cascade
|
||
/// если делать DELETE в правильном порядке (сначала дочерние,
|
||
/// потом родительские); используем общий приём «удаляем по списку
|
||
/// org-id из всех таблиц, последний — Organizations».
|
||
///
|
||
/// Порог давности — <c>Cleanup:QualityTestOrgHours</c> (default 24)
|
||
/// чтобы свежие диагностические прогоны не зачищались до того, как
|
||
/// инженер посмотрит лог. Возвращает количество удалённых
|
||
/// организаций.</summary>
|
||
public async Task<int> PruneQualityTestOrgsAsync(CancellationToken ct = default)
|
||
{
|
||
var minHours = _cfg.GetValue("Cleanup:QualityTestOrgHours", 24);
|
||
var threshold = DateTime.UtcNow.AddHours(-minHours);
|
||
|
||
var ids = await _db.Organizations
|
||
.IgnoreQueryFilters()
|
||
.Where(o => o.Name.StartsWith("quality-") && o.CreatedAt < threshold)
|
||
.Select(o => o.Id)
|
||
.ToListAsync(ct);
|
||
|
||
if (ids.Count == 0)
|
||
{
|
||
_log.LogInformation("Hangfire/PruneQualityTestOrgs: нет кандидатов старше {Threshold:O}", threshold);
|
||
return 0;
|
||
}
|
||
|
||
// Динамически собираем DELETE для каждой таблицы с колонкой "OrganizationId".
|
||
// Имена таблиц: snake_case (наши доменные) + PascalCase (Identity/OpenIddict).
|
||
// Идём по public-схеме, исключаем сам organizations (удаляем в конце).
|
||
// Тестовые юзеры распознаются по email LIKE 'quality-%@test-fm.local'.
|
||
//
|
||
// FK ordering: между tenant-таблицами есть ссылки (employees → employee_roles),
|
||
// поэтому делаем итеративные проходы: на каждом проходе пытаемся DELETE
|
||
// из каждой таблицы; если FK-violation — пропускаем, на след. проходе
|
||
// зависимые строки уже удалены и DELETE проходит. Максимум 10 проходов
|
||
// (depth FK графа гораздо меньше).
|
||
var sql = $@"
|
||
DO $do$
|
||
DECLARE
|
||
org_ids uuid[] := ARRAY[{string.Join(",", ids.Select(i => $"'{i}'::uuid"))}];
|
||
tbl text;
|
||
pass int := 0;
|
||
remaining int;
|
||
BEGIN
|
||
-- 1. Итеративная очистка tenant-таблиц с OrganizationId.
|
||
-- Пропускаем organizations и users — отдельные шаги ниже.
|
||
FOR pass IN 1..10 LOOP
|
||
remaining := 0;
|
||
FOR tbl IN
|
||
SELECT c.table_name
|
||
FROM information_schema.columns c
|
||
JOIN information_schema.tables t
|
||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||
WHERE c.table_schema = 'public'
|
||
AND c.column_name = 'OrganizationId'
|
||
AND t.table_type = 'BASE TABLE'
|
||
AND c.table_name NOT IN ('organizations', 'users')
|
||
LOOP
|
||
BEGIN
|
||
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
|
||
EXCEPTION WHEN foreign_key_violation THEN
|
||
remaining := remaining + 1;
|
||
END;
|
||
END LOOP;
|
||
EXIT WHEN remaining = 0;
|
||
END LOOP;
|
||
IF remaining > 0 THEN
|
||
RAISE WARNING 'PruneQualityTestOrgs: % таблиц остались с FK-violation после 10 проходов', remaining;
|
||
END IF;
|
||
|
||
-- 2. Чистим AspNetUser* и OpenIddict-токены тестовых юзеров по email-шаблону.
|
||
-- Эти таблицы НЕ имеют OrganizationId, идут отдельным сегментом.
|
||
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||
);
|
||
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||
);
|
||
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||
);
|
||
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||
);
|
||
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
|
||
SELECT u.""Id""::text FROM public.users u
|
||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||
);
|
||
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
|
||
SELECT u.""Id""::text FROM public.users u
|
||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||
);
|
||
|
||
-- 3. AppUser (public.users) — привязан через OrganizationId, чистим по нему.
|
||
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
|
||
|
||
-- 4. Сама organizations.
|
||
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
|
||
END $do$;
|
||
";
|
||
|
||
await _db.Database.ExecuteSqlRawAsync(sql, ct);
|
||
_log.LogInformation("Hangfire/PruneQualityTestOrgs: удалено {Count} тест-org (старше {Hours}ч)",
|
||
ids.Count, minHours);
|
||
return ids.Count;
|
||
}
|
||
|
||
/// <summary>Sprint 30: расширение cleanup'а на ВСЕ автотестовые орги/юзеры
|
||
/// (не только <c>quality-*</c>): e2e, perf, smoke, a11y, ratetest, mt6*,
|
||
/// cat3*, visual-*, factory-smoke-*, auth2*, doc4*, rep5*, rt8*, wiz9*,
|
||
/// i18n*, twofa*, iso*, mob14*, mob15*, axdet-*, probe-*, load-*,
|
||
/// s19-test-* и всё с суффиксом из ≥10 цифр.
|
||
///
|
||
/// Запускается тем же механизмом (Hangfire recurring) что
|
||
/// <see cref="PruneQualityTestOrgsAsync"/>. Порог давности
|
||
/// <c>Cleanup:AutoTestOrgDays</c> (default 3) — чтобы живые e2e-прогоны
|
||
/// успевали создать-использовать org и завершиться.
|
||
///
|
||
/// Защищены от удаления:
|
||
/// <list type="bullet">
|
||
/// <item>Demo Market и Демо-магазин (seeded) + любые орги с именем
|
||
/// ровно <c>Demo Market</c> / <c>Демо-магазин</c>;</item>
|
||
/// <item>demo-admin@/demo-cashier@/demo-keeper@ юзеры;</item>
|
||
/// <item>SuperAdmin (без OrganizationId) — не удаляется ни при каких
|
||
/// условиях (условие OrganizationId IS NOT NULL в юзер-DELETE);</item>
|
||
/// <item>market@email.com — явное исключение по email.</item>
|
||
/// </list></summary>
|
||
public async Task<int> PruneAutoTestEntitiesAsync(CancellationToken ct = default)
|
||
{
|
||
var minDays = _cfg.GetValue("Cleanup:AutoTestOrgDays", 3);
|
||
var threshold = DateTime.UtcNow.AddDays(-minDays);
|
||
|
||
// Regex в PG: '~*' = case-insensitive match. Собираем выражение по
|
||
// «автотестовым» префиксам/признакам, аналогично QuickLoginController.
|
||
// Условие на имя орги. ВАЖНО: даже «просто цифры в конце» ловит почти
|
||
// любой e2e-прогон, который суффиксит имя timestamp'ом.
|
||
const string autoTestOrgRx =
|
||
@"[0-9]{10,}" + // любой длинный цифровой суффикс
|
||
@"|^quality-" +
|
||
@"|^soak-" +
|
||
@"|^(ui-)?a11y-" +
|
||
@"|^smokeorg[ab]?-" +
|
||
@"|^perfbaseline-" +
|
||
@"|^reg-" +
|
||
@"|^rt-ratetest-" +
|
||
@"|^probe\s" + // "Probe Org"
|
||
@"|^cat3[0-9]-" +
|
||
@"|^visual-" +
|
||
@"|^factory-smoke-" +
|
||
@"|^mt6[0-9]-" +
|
||
@"|^doc4[0-9]-" +
|
||
@"|^auth2[0-9]-" +
|
||
@"|^rep5[0-9]-" +
|
||
@"|^rt8[0-9]-" +
|
||
@"|^wiz9[0-9]-" +
|
||
@"|^i18n[0-9]+-" +
|
||
@"|^twofa[0-9]+-" +
|
||
@"|^iso[a-z]*[0-9]*-" +
|
||
@"|^mob1[0-9]-" +
|
||
@"|^axdet-" +
|
||
@"|^load-sales-" +
|
||
@"|^load-" +
|
||
@"|^s19-test-";
|
||
|
||
// SqlQueryRaw<Guid> — удобный способ вытащить только Id без mapping'а
|
||
// к entity. Параметры через FormattableString небезопасно соединять
|
||
// (regex из конфига → потенциальный inject), поэтому используем Npgsql-
|
||
// параметры напрямую.
|
||
var orgIdSql = @"
|
||
SELECT ""Id"" FROM public.organizations
|
||
WHERE ""Name"" <> 'Demo Market'
|
||
AND ""Name"" <> 'Демо-магазин'
|
||
AND ""Name"" ~* @rx
|
||
AND ""CreatedAt"" < @thr
|
||
LIMIT 20000";
|
||
var ids = await _db.Database.SqlQueryRaw<Guid>(orgIdSql,
|
||
new Npgsql.NpgsqlParameter("@rx", autoTestOrgRx),
|
||
new Npgsql.NpgsqlParameter("@thr", threshold))
|
||
.ToListAsync(ct);
|
||
|
||
if (ids.Count == 0)
|
||
{
|
||
_log.LogInformation("Hangfire/PruneAutoTestEntities: нет org-кандидатов старше {Days}д", minDays);
|
||
// Юзеры без org (orphan-autotest) всё равно могут остаться — обрабатываем ниже.
|
||
}
|
||
|
||
// Тот же трюк с итеративным DELETE по information_schema, что в
|
||
// PruneQualityTestOrgsAsync. Юзеры дополнительно вычищаются по
|
||
// регулярке email'а (широкий set autotest-паттернов).
|
||
var sql = $@"
|
||
DO $do$
|
||
DECLARE
|
||
org_ids uuid[] := ARRAY[{(ids.Count == 0 ? "NULL::uuid" : string.Join(",", ids.Select(i => $"'{i}'::uuid")))}];
|
||
tbl text;
|
||
pass int := 0;
|
||
remaining int;
|
||
autotest_user_rx text := '^(cat3[0-9]|visual|mt6[0-9]|factory|doc4[0-9]|auth2[0-9]|rep5[0-9]|rt8[0-9]|wiz9[0-9]|quality|i18n[0-9]+|twofa[0-9]+|a11y|mob1[0-9]|iso|axdet|smoke|ratetest|perf|probe|load|s19-test|admin-[0-9]|cashier-[0-9]|keeper-[0-9]|reg-mt)';
|
||
autotest_domain_rx text := '(@test-fm\.local|@e\.kz)$';
|
||
threshold_ts timestamp with time zone := '{threshold:O}'::timestamp with time zone;
|
||
BEGIN
|
||
IF array_length(org_ids, 1) IS NOT NULL AND org_ids[1] IS NOT NULL THEN
|
||
-- 1. Итеративная очистка tenant-таблиц.
|
||
FOR pass IN 1..10 LOOP
|
||
remaining := 0;
|
||
FOR tbl IN
|
||
SELECT c.table_name
|
||
FROM information_schema.columns c
|
||
JOIN information_schema.tables t
|
||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||
WHERE c.table_schema = 'public'
|
||
AND c.column_name = 'OrganizationId'
|
||
AND t.table_type = 'BASE TABLE'
|
||
AND c.table_name NOT IN ('organizations', 'users')
|
||
LOOP
|
||
BEGIN
|
||
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
|
||
EXCEPTION WHEN foreign_key_violation THEN
|
||
remaining := remaining + 1;
|
||
END;
|
||
END LOOP;
|
||
EXIT WHEN remaining = 0;
|
||
END LOOP;
|
||
|
||
-- 2. AspNet* / OpenIddict для юзеров этих org.
|
||
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||
);
|
||
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||
);
|
||
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||
);
|
||
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||
);
|
||
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
|
||
SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||
);
|
||
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
|
||
SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||
);
|
||
|
||
-- 3. Юзеры этих org.
|
||
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
|
||
|
||
-- 4. Сами organizations.
|
||
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
|
||
END IF;
|
||
|
||
-- 5. Orphan-юзеры: autotest-паттерн email + не SuperAdmin (OrganizationId
|
||
-- IS NOT NULL) + не demo-* + не market@email.com + создан раньше threshold.
|
||
-- Эти юзеры могут остаться когда org уже удалена, но AspNetUserRoles —
|
||
-- нет (если был cascade NOT), либо если тест не создал org вообще.
|
||
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||
AND u.""Email"" <> 'admin@food-market.local'
|
||
AND u.""Email"" <> 'admin@demo-market.local'
|
||
AND u.""Email"" <> 'market@email.com'
|
||
AND u.""OrganizationId"" IS NOT NULL
|
||
AND u.""CreatedAt"" < threshold_ts
|
||
);
|
||
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||
AND u.""Email"" <> 'admin@food-market.local'
|
||
AND u.""Email"" <> 'admin@demo-market.local'
|
||
AND u.""Email"" <> 'market@email.com'
|
||
AND u.""OrganizationId"" IS NOT NULL
|
||
AND u.""CreatedAt"" < threshold_ts
|
||
);
|
||
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||
AND u.""Email"" <> 'admin@food-market.local'
|
||
AND u.""Email"" <> 'admin@demo-market.local'
|
||
AND u.""Email"" <> 'market@email.com'
|
||
AND u.""OrganizationId"" IS NOT NULL
|
||
AND u.""CreatedAt"" < threshold_ts
|
||
);
|
||
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
|
||
SELECT u.""Id"" FROM public.users u
|
||
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||
AND u.""Email"" <> 'admin@food-market.local'
|
||
AND u.""Email"" <> 'admin@demo-market.local'
|
||
AND u.""Email"" <> 'market@email.com'
|
||
AND u.""OrganizationId"" IS NOT NULL
|
||
AND u.""CreatedAt"" < threshold_ts
|
||
);
|
||
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
|
||
SELECT u.""Id""::text FROM public.users u
|
||
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||
AND u.""Email"" <> 'admin@food-market.local'
|
||
AND u.""Email"" <> 'admin@demo-market.local'
|
||
AND u.""Email"" <> 'market@email.com'
|
||
AND u.""OrganizationId"" IS NOT NULL
|
||
AND u.""CreatedAt"" < threshold_ts
|
||
);
|
||
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
|
||
SELECT u.""Id""::text FROM public.users u
|
||
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||
AND u.""Email"" <> 'admin@food-market.local'
|
||
AND u.""Email"" <> 'admin@demo-market.local'
|
||
AND u.""Email"" <> 'market@email.com'
|
||
AND u.""OrganizationId"" IS NOT NULL
|
||
AND u.""CreatedAt"" < threshold_ts
|
||
);
|
||
DELETE FROM public.users u
|
||
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||
AND u.""Email"" <> 'admin@food-market.local'
|
||
AND u.""Email"" <> 'admin@demo-market.local'
|
||
AND u.""Email"" <> 'market@email.com'
|
||
AND u.""OrganizationId"" IS NOT NULL
|
||
AND u.""CreatedAt"" < threshold_ts;
|
||
END $do$;
|
||
";
|
||
|
||
await _db.Database.ExecuteSqlRawAsync(sql, ct);
|
||
_log.LogInformation("Hangfire/PruneAutoTestEntities: удалено {Count} org (старше {Days}д) + orphan autotest-юзеры",
|
||
ids.Count, minDays);
|
||
return ids.Count;
|
||
}
|
||
}
|