60ab5be3eb
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) — отдельным шагом с явным approve. Решения заказчика: * Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log, webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log) * FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log * Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м, balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК * Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже работает per-partition; совместимо с hole #1 per-RLS-scope fix) Phase A: * db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE, PK→(id, partition_key), +7 retention seeds в system_settings, FK от failed_webhook_jobs/rejected_deals_log удалены * MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array (name => partition_key), 2 → 9 таблиц * PartitionsCreateMonths: автоматически покрывает все 9 * load_initial_schema: после schema.sql вызывает Artisan partitions:create-months --ahead=2 (без этого первый INSERT падает) * 2026_05_22_000001_tenant_operations_log: idempotency guard * VerifyAuditChains: per-partition scan через pg_inherits; fallback на single-scope для не-партиционированной таблицы; per-RLS-scope partition_clause сохранён внутри каждой партиции * AuditChainBreachMail: +partitionName param (NULL=fallback на tableName) * PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из system_settings, dry-run mode, safety guard retention=0 * SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин) Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh. Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup). Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail — pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_* (FK actor_admin_user_id после partitioning pd_processing_log, отдельная задача для hole #4 follow-up, не блокирует). cspell +2 слова (партиционировать, дёшева). Pint --fix чистый. Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
129 lines
8.1 KiB
PHP
129 lines
8.1 KiB
PHP
<?php
|
|
|
|
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
|
use App\Jobs\Supplier\CsvReconcileJob;
|
|
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
|
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
|
use App\Services\SchedulerHeartbeatTracker;
|
|
use Illuminate\Foundation\Inspiring;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Schedule;
|
|
|
|
Artisan::command('inspire', function () {
|
|
$this->comment(Inspiring::quote());
|
|
})->purpose('Display an inspiring quote');
|
|
|
|
// Hole #6: heartbeat-трекинг всех cron-задач.
|
|
// SchedulerHeartbeatTracker::recordRun() оборачивает каждую задачу через
|
|
// before/after/onFailure хуки Laravel Scheduler — минимально инвазивный подход.
|
|
/** @var SchedulerHeartbeatTracker $hb */
|
|
$hb = app(SchedulerHeartbeatTracker::class);
|
|
|
|
// Spec §6.1: ежедневный сброс projects.delivered_today=0 в 00:00 МСК.
|
|
// delivered_in_month НЕ трогаем — это месячный счётчик, отдельный cron Plan 4.
|
|
//
|
|
// NB: без `withoutOverlapping()` — операция идемпотентна (UPDATE WHERE delivered_today <> 0)
|
|
// и завершается за < 1 сек на любом ожидаемом объёме, overlap физически невозможен.
|
|
// Кроме того, `withoutOverlapping` требует таблицу `cache_locks`, которой в нашей
|
|
// schema.sql нет (Laravel-default-миграции удалены, см. project_state.md фаза 1).
|
|
Schedule::command('projects:reset-delivered-today')
|
|
->dailyAt('00:00')
|
|
->timezone('Europe/Moscow')
|
|
->before(fn () => $startTimes['projects:reset-delivered-today'] = microtime(true))
|
|
->onSuccess(function () use ($hb, &$startTimes): void {
|
|
$name = 'projects:reset-delivered-today';
|
|
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
|
|
$hb->recordRunResult($name, true, null, $ms);
|
|
})
|
|
->onFailure(function () use ($hb): void {
|
|
$hb->recordRunResult('projects:reset-delivered-today', false, 'Command failed', null);
|
|
});
|
|
|
|
// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService.
|
|
Schedule::command('projects:reset-monthly')
|
|
->monthlyOn(1, '00:00')
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('projects:reset-monthly', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('projects:reset-monthly', false, 'Command failed', null));
|
|
|
|
// Audit #2 Phase 14 P2: partition maintenance — создаёт разделы на 3 месяца вперёд.
|
|
// Без этой записи partitions:create-months не запускается автоматически.
|
|
Schedule::command('partitions:create-months')
|
|
->daily()
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('partitions:create-months', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('partitions:create-months', false, 'Command failed', null));
|
|
|
|
// Hole #2 (23.05.2026): удаление устаревших месячных партиций согласно retention
|
|
// (system_settings: partition_retention_months_<table>).
|
|
// Запускается еженедельно в воскресенье в 03:00 МСК — вне пиковых часов,
|
|
// но раз в неделю достаточно (данные удаляются целыми месяцами).
|
|
Schedule::command('partitions:drop-expired')
|
|
->weeklyOn(0, '03:00')
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
|
|
|
|
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
|
|
//
|
|
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
|
|
// (см. project_state.md фаза 1). Операции идемпотентны: SyncSupplierProjectsJob
|
|
// делает diff'ы (skip-no-diff), CleanupJob — UPDATE WHERE conditions, RefreshSession
|
|
// — Cache::lock guard внутри handle, RetryFailedSupplierJobs — WHERE retried_at
|
|
// фильтр. На multi-server prod может потребовать cache_locks таблицу.
|
|
Schedule::job(new RefreshSupplierSessionJob)->hourly()
|
|
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', false, 'Job failed', null));
|
|
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
|
|
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
|
|
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время до
|
|
// портального дедлайна 21:00. Session refresh — на 15 мин раньше sync (17:45).
|
|
Schedule::job(new RefreshSupplierSessionJob)
|
|
->dailyAt('17:45')
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
|
|
Schedule::job(new SyncSupplierProjectsJob)
|
|
->dailyAt('18:00')
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
|
|
Schedule::job(new CleanupInactiveSupplierProjectsJob)
|
|
->dailyAt('02:00')
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', false, 'Job failed', null));
|
|
Schedule::command('supplier:retry-failed')->hourly()
|
|
->onSuccess(fn () => $hb->recordRunResult('supplier:retry-failed', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('supplier:retry-failed', false, 'Command failed', null));
|
|
|
|
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
|
|
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
|
|
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes()
|
|
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null));
|
|
|
|
// Audit #2 Phase 14 P2: авто-детекция штормов упавших webhook-джобов.
|
|
// Сканирует за последние 10 мин, порог 200, дедуп 60 мин.
|
|
Schedule::command('incidents:watch-failures')
|
|
->everyTenMinutes()
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('incidents:watch-failures', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('incidents:watch-failures', false, 'Command failed', null));
|
|
|
|
// Hole #1: ежедневная проверка SHA-256 hash-chain в 6 audit-таблицах.
|
|
// Разрыв → incidents_log (severity high) + email kdv1@bk.ru.
|
|
// Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
|
|
Schedule::command('audit:verify-chains')
|
|
->dailyAt('04:00')
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('audit:verify-chains', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('audit:verify-chains', false, 'Command failed', null));
|
|
|
|
// Hole #6: проверка пульса планировщика — hourly МСК.
|
|
Schedule::command('scheduler:check-heartbeats')
|
|
->hourly()
|
|
->timezone('Europe/Moscow')
|
|
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
|
|
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
|