prepare($sql); $stmt->execute($params); return (int)$stmt->fetchColumn(); } function retention_delete(PDO $db, string $sql, array $params, bool $dryRun): int { if ($dryRun) { return 0; } $stmt = $db->prepare($sql); $stmt->execute($params); return $stmt->rowCount(); } $summary = [ 'mode' => $dryRun ? 'dry-run' : 'execute', 'sms_log_retention_days' => SMS_LOG_RETENTION_DAYS, 'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS, 'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS, 'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS, 'sms_log' => ['matched' => 0, 'rows_deleted' => 0], 'sensor_log' => ['matched' => 0, 'rows_deleted' => 0], 'sensor_metric' => ['matched' => 0, 'rows_deleted' => 0], 'admin_audit' => ['kept_lines' => null, 'removed_lines' => null], ]; try { $db = get_db(); } catch (Throwable $e) { $summary['error'] = 'DB connection failed: ' . $e->getMessage(); header('Content-Type: application/json; charset=utf-8'); echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; exit(2); } $smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS); $summary['sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]); $summary['sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun); $sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS); $summary['sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]); $summary['sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun); $metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS); $summary['sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]); $summary['sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun); $auditPath = admin_audit_log_path(); if (is_file($auditPath)) { $cutoffTs = time() - (ADMIN_AUDIT_RETENTION_DAYS * 86400); $kept = []; $removed = 0; // 감사로그 회전은 동시 append(admin_audit_log: file_put_contents FILE_APPEND|LOCK_EX)와 // 경합한다. 읽기+필터+재기록 전체를 배타 락(flock LOCK_EX)으로 묶어, 회전 도중 // 들어온 기록이 유실되지 않게 한다(append 는 락 해제까지 대기 후 새 파일에 추가). $fh = fopen($auditPath, 'c+'); if ($fh !== false && flock($fh, LOCK_EX)) { while (($line = fgets($fh)) !== false) { $line = rtrim($line, "\r\n"); if ($line === '') { continue; } $record = json_decode($line, true); $recordTs = isset($record['time']) ? strtotime((string)$record['time']) : false; if ($recordTs !== false && $recordTs < $cutoffTs) { $removed++; continue; } $kept[] = $line; } if (!$dryRun && $removed > 0) { ftruncate($fh, 0); rewind($fh); fwrite($fh, $kept ? implode(PHP_EOL, $kept) . PHP_EOL : ''); fflush($fh); } flock($fh, LOCK_UN); } if ($fh !== false) { fclose($fh); } $summary['admin_audit']['kept_lines'] = count($kept); $summary['admin_audit']['removed_lines'] = $removed; } admin_audit_log('retention_cleanup', 'system', true, [ 'mode' => $summary['mode'], 'sms_matches' => $summary['sms_log']['matched'], 'sensor_matches' => $summary['sensor_log']['matched'], 'metric_matches' => $summary['sensor_metric']['matched'], ]); header('Content-Type: application/json; charset=utf-8'); echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;