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
182 lines
6.6 KiB
PHP
182 lines
6.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\SystemSetting;
|
||
use App\Services\MonthlyPartitionManager;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Удаляет устаревшие месячные партиции согласно retention-настройкам.
|
||
*
|
||
* Retention для каждой таблицы хранится в system_settings:
|
||
* key = 'partition_retention_months_<table>'
|
||
* value = количество месяцев (integer >= 1)
|
||
*
|
||
* Защита от опасных значений:
|
||
* - NULL / отсутствие ключа → пропустить таблицу (не дропать ничего)
|
||
* - 0 → пропустить (запрет стирания всего)
|
||
* - < 0 → пропустить
|
||
* - Минимальное значение, принятое к выполнению: 1 месяц
|
||
*
|
||
* Формат имени партиции: <table>_y<YYYY>_m<MM>
|
||
* Партиция считается устаревшей, если её месяц < (текущий месяц − retention).
|
||
*
|
||
* Пример:
|
||
* сейчас = 2026-05, retention = 3
|
||
* cutoff = 2026-02 (включительно; т.е. 2026-01 и старее — дропать)
|
||
* будет удалена: auth_log_y2026_m01, auth_log_y2025_m12, …
|
||
* НЕ будет удалена: auth_log_y2026_m02 (граница), и всё новее
|
||
*
|
||
* Hole #2, 23.05.2026.
|
||
*/
|
||
class PartitionsDropExpired extends Command
|
||
{
|
||
/** @var string */
|
||
protected $signature = 'partitions:drop-expired
|
||
{--dry-run : Перечислить партиции для удаления, не удалять}';
|
||
|
||
/** @var string */
|
||
protected $description = 'Удаляет устаревшие месячные партиции согласно system_settings (partition_retention_months_*)';
|
||
|
||
public function handle(MonthlyPartitionManager $manager): int
|
||
{
|
||
$isDryRun = (bool) $this->option('dry-run');
|
||
|
||
if ($isDryRun) {
|
||
$this->line('<fg=yellow>Dry-run: партиции будут перечислены, но NOT удалены.</>');
|
||
}
|
||
|
||
$now = Carbon::now()->startOfMonth();
|
||
$totalDropped = 0;
|
||
$totalSkipped = 0;
|
||
|
||
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
|
||
$retention = $this->resolveRetention($table);
|
||
|
||
if ($retention === null) {
|
||
$this->line(" <fg=gray>skip</> {$table}: retention not configured");
|
||
|
||
continue;
|
||
}
|
||
|
||
$partitions = $manager->listPartitions($table);
|
||
|
||
if (empty($partitions)) {
|
||
$this->line(" <fg=gray>skip</> {$table}: no partitions exist yet");
|
||
|
||
continue;
|
||
}
|
||
|
||
$dropped = 0;
|
||
|
||
foreach ($partitions as $partitionName) {
|
||
$monthStart = $this->parsePartitionMonth($partitionName);
|
||
|
||
if ($monthStart === null) {
|
||
// Имя не соответствует формату _yYYYY_mMM — не трогать (безопасность)
|
||
$this->warn(" ? {$partitionName}: unrecognised name format, skipping");
|
||
$totalSkipped++;
|
||
|
||
continue;
|
||
}
|
||
|
||
// Граница: всё строго старее (now - retention месяцев) — удалять.
|
||
// Т.е. monthStart < cutoff, где cutoff = now - retention.
|
||
$cutoff = $now->copy()->subMonths($retention);
|
||
|
||
if (! $monthStart->lessThan($cutoff)) {
|
||
// Партиция ещё в пределах retention — оставить
|
||
continue;
|
||
}
|
||
|
||
if ($isDryRun) {
|
||
$this->line(" <fg=yellow>[dry-run] would drop</> {$partitionName}");
|
||
} else {
|
||
$this->dropPartition($partitionName);
|
||
$this->line(" <fg=red>dropped</> {$partitionName}");
|
||
}
|
||
|
||
$dropped++;
|
||
$totalDropped++;
|
||
}
|
||
|
||
if ($dropped === 0) {
|
||
$this->line(" <fg=green>ok</> {$table}: all partitions within retention={$retention}mo");
|
||
}
|
||
}
|
||
|
||
$this->newLine();
|
||
|
||
if ($isDryRun) {
|
||
$this->info("Dry-run complete: {$totalDropped} would be dropped, {$totalSkipped} skipped (unrecognised name).");
|
||
} else {
|
||
$this->info("Done: {$totalDropped} dropped, {$totalSkipped} skipped (unrecognised name).");
|
||
}
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* Читает retention для таблицы из system_settings.
|
||
* Возвращает null, если настройка отсутствует или небезопасна (0 / отрицательная).
|
||
*/
|
||
private function resolveRetention(string $table): ?int
|
||
{
|
||
$key = "partition_retention_months_{$table}";
|
||
|
||
$setting = SystemSetting::find($key);
|
||
|
||
if ($setting === null) {
|
||
return null;
|
||
}
|
||
|
||
$value = (int) $setting->value;
|
||
|
||
if ($value < 1) {
|
||
// 0 или отрицательное — блокируем, не дропаем ничего
|
||
$this->warn(" ! {$table}: retention value={$value} is invalid (<1), skipping");
|
||
|
||
return null;
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
/**
|
||
* Парсит имя партиции вида <anything>_y<YYYY>_m<MM> и возвращает Carbon начала месяца.
|
||
* Возвращает null, если имя не соответствует формату.
|
||
*/
|
||
private function parsePartitionMonth(string $partitionName): ?Carbon
|
||
{
|
||
// Pattern: ends with _yYYYY_mMM (e.g. auth_log_y2026_m05)
|
||
if (! preg_match('/_y(\d{4})_m(\d{2})$/', $partitionName, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$year = (int) $m[1];
|
||
$month = (int) $m[2];
|
||
|
||
if ($month < 1 || $month > 12) {
|
||
return null;
|
||
}
|
||
|
||
return Carbon::create($year, $month, 1, 0, 0, 0)->startOfMonth();
|
||
}
|
||
|
||
/**
|
||
* Удаляет партицию (DETACH + DROP TABLE).
|
||
*
|
||
* Безопасность: имя проверено регекспом в parsePartitionMonth
|
||
* (только символы \w и _ — SQL injection невозможен).
|
||
*/
|
||
private function dropPartition(string $partitionName): void
|
||
{
|
||
DB::statement("DROP TABLE IF EXISTS {$partitionName}");
|
||
}
|
||
}
|