feat(s30): PruneAutoTestEntities + одноразовая зачистка стейджа (шаг 7/9)

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>
This commit is contained in:
nns 2026-10-04 15:49:25 +05:00
parent 5728b729a8
commit c69114f8ab
4 changed files with 411 additions and 1 deletions

View file

@ -0,0 +1,174 @@
-- deploy/stage-prune-autotest.sql
-- Одноразовая зачистка стейджа от автотестового мусора. Пишется как idempotent
-- SQL-скрипт — его же логика живёт в HousekeepingJobs.PruneAutoTestEntitiesAsync
-- (Hangfire 02:45 UTC daily). Этот файл пригоден для ручного запуска
-- (например, на prod-vm через `docker exec food-market-stage-postgres-1 psql
-- -U food_market -d food_market -f /tmp/stage-prune-autotest.sql`).
--
-- Что НЕ трогает:
-- * admin@food-market.local (SuperAdmin — OrganizationId IS NULL)
-- * admin@demo-market.local
-- * market@email.com
-- * demo-*@food-market.local (quick-login demo-trio)
-- * «Demo Market» и «Демо-магазин» (seeded orgs)
--
-- Условие удаления: имя орги подходит под регулярку autotest ИЛИ email юзера
-- подходит под autotest-патéрн / служебный домен. Для свежих прогонов —
-- threshold 3 дня (меняется через psql -v).
\set threshold_days 3
DO $do$
DECLARE
threshold_ts timestamp with time zone := NOW() - INTERVAL '3 days';
org_ids uuid[];
tbl text;
pass int := 0;
remaining int;
autotest_org_rx text := '[0-9]{10,}' ||
'|^quality-|^soak-|^(ui-)?a11y-|^smokeorg[ab]?-|^perfbaseline-|^reg-' ||
'|^rt-ratetest-|^probe\s|^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-';
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)$';
deleted_orgs int;
deleted_users int;
BEGIN
SELECT array_agg("Id") INTO org_ids FROM public.organizations
WHERE "Name" <> 'Demo Market'
AND "Name" <> 'Демо-магазин'
AND "Name" ~* autotest_org_rx
AND "CreatedAt" < threshold_ts;
IF array_length(org_ids, 1) IS NOT NULL THEN
RAISE NOTICE 'Нашли % autotest-органов для удаления', array_length(org_ids, 1);
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;
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)
);
GET DIAGNOSTICS deleted_users = ROW_COUNT;
DELETE FROM public.users WHERE "OrganizationId" = ANY(org_ids);
DELETE FROM public.organizations WHERE "Id" = ANY(org_ids);
GET DIAGNOSTICS deleted_orgs = ROW_COUNT;
RAISE NOTICE 'Удалено orgs=%, users с org=%', deleted_orgs, deleted_users;
ELSE
RAISE NOTICE 'Нет autotest-органов старше %', threshold_ts;
END IF;
-- Orphan autotest-юзеры (без tagged org). Исключения — такие же как в
-- Hangfire-методе: SuperAdmin, demo-trio, admin@demo-market.local, market@.
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;
GET DIAGNOSTICS deleted_users = ROW_COUNT;
RAISE NOTICE 'Удалено orphan autotest-юзеров: %', deleted_users;
END $do$;
-- Итог
SELECT 'users_remaining' AS metric, COUNT(*) AS value FROM users
UNION ALL
SELECT 'orgs_remaining', COUNT(*) FROM organizations;

View file

@ -33,7 +33,9 @@
Клик — моментальный логин через мастер-пароль. На проде блока нет. Клик — моментальный логин через мастер-пароль. На проде блока нет.
- [x] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — при Enabled=true жёлтая полоса - [x] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — при Enabled=true жёлтая полоса
сверху + `<meta robots="noindex,nofollow">` в index.html стенда. сверху + `<meta robots="noindex,nofollow">` в index.html стенда.
- [ ] 7. **Чистка автотестового мусора** — SQL-скрипт для стейджа + расширение - [x] 7. **Чистка автотестового мусора** — 8653 → 300 user'ов, 8634 → 305 org
(стейдж, 2026-10-04). Протестирован на существующей БД: SuperAdmin / admin@
demo-market.local / market@email.com остались, свежие прогоны (<3д) сохранены. SQL-скрипт для стейджа + расширение
`HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны, `HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны,
чтобы cron 02:30 UTC гасил новые юзеры сам. Не трогать: SuperAdmin, чтобы cron 02:30 UTC гасил новые юзеры сам. Не трогать: SuperAdmin,
admin@demo-market.local, market@email.com, demo-* аккаунты. admin@demo-market.local, market@email.com, demo-* аккаунты.

View file

@ -72,6 +72,16 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronQualityOrgs, cronExpression: cronQualityOrgs,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Sprint 30: широкий cleanup всех autotest-органа/юзеров (e2e, perf,
// smoke, a11y, ratetest, mt6*, cat3*, ...). Default 02:45 UTC
// (между prune-quality 02:30 и prune-stock 03:30).
var cronAutoTest = _cfg["Hangfire:Cron:PruneAutoTestEntities"] ?? "45 2 * * *";
_jobs.AddOrUpdate<HousekeepingJobs>(
recurringJobId: "prune-autotest-entities",
methodCall: j => j.PruneAutoTestEntitiesAsync(CancellationToken.None),
cronExpression: cronAutoTest,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor. // Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час

View file

@ -254,4 +254,228 @@ SELECT c.table_name
ids.Count, minHours); ids.Count, minHours);
return ids.Count; 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;
}
} }