[--no-snapshots]\n"); exit(2); } if (!is_dir($outDir) && !@mkdir($outDir, 0700, true)) { fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n"); exit(2); } // 백업 파일에는 전 현장의 사고 기록과 개인정보가 들어 있다. 웹에서 읽히는 위치에 두면 // 그 자체가 사고다. $real = realpath($outDir); if ($real !== false && str_contains(str_replace('\\', '/', $real), '/www/')) { fwrite(STDERR, "거부: 백업 대상이 웹 공개 경로(www/) 안입니다. 이 파일은 사고 기록과 개인정보를 담습니다.\n"); exit(2); } $stamp = date('Ymd-His'); $sqlPath = $outDir . '/move_detect-' . $stamp . '.sql'; $fh = fopen($sqlPath, 'wb'); if ($fh === false) { fwrite(STDERR, "덤프 파일을 열 수 없습니다.\n"); exit(2); } // 이 순서는 복구 시 참조 무결성 순서다 (events 가 snapshots/alert_log 보다 먼저). $TABLES = [ 'move_users', 'move_devices', 'move_cameras', 'move_zones', 'move_thresholds', 'move_events', 'move_event_snapshots', 'move_actions', 'move_audit_log', 'move_suppressions', 'move_alert_channels', 'move_alert_log', 'move_shift_handovers', 'move_risk_controls', 'move_device_metrics', ]; // nonce / rate_limits 는 재생성되는 휘발성 데이터라 일부러 제외한다. function q(PDO $pdo, $v): string { if ($v === null) return 'NULL'; if (is_int($v) || is_float($v)) return (string)$v; return $pdo->quote((string)$v); } fwrite($fh, "-- move_detect backup {$stamp}\n"); fwrite($fh, "-- 복구: phpMyAdmin 에서 이 파일을 실행한 뒤 스냅샷 아카이브를 storage/snapshots/ 에 풉니다.\n"); fwrite($fh, "SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n"); $counts = []; foreach ($TABLES as $table) { try { $probe = $pdo->query("SELECT * FROM {$table} LIMIT 1"); } catch (Throwable $e) { // 마이그레이션 전 DB 에는 없는 표가 있다. 백업이 그걸로 실패하면 안 된다. $counts[$table] = 'skipped'; continue; } $cols = []; for ($i = 0; $i < $probe->columnCount(); $i++) { $cols[] = $probe->getColumnMeta($i)['name']; } $colList = '`' . implode('`, `', $cols) . '`'; $n = 0; $offset = 0; fwrite($fh, "\n-- {$table}\n"); while (true) { // 청크 단위: 이벤트가 수십만 건인 현장에서 한 번에 fetchAll 하면 메모리가 터진다. $st = $pdo->prepare("SELECT * FROM {$table} LIMIT 500 OFFSET {$offset}"); $st->execute(); $rows = $st->fetchAll(PDO::FETCH_ASSOC); if ($rows === []) break; foreach ($rows as $row) { $vals = []; foreach ($cols as $c) $vals[] = q($pdo, $row[$c] ?? null); fwrite($fh, "INSERT INTO {$table} ({$colList}) VALUES (" . implode(', ', $vals) . ");\n"); $n++; } $offset += 500; } $counts[$table] = $n; } fwrite($fh, "\nSET FOREIGN_KEY_CHECKS=1;\n"); fclose($fh); @chmod($sqlPath, 0600); // ---- 스냅샷 --------------------------------------------------------------------------- $snapNote = 'skipped (--no-snapshots)'; if (!isset($args['no-snapshots'])) { $dir = rtrim($cfg->snapshotDir(), '/\\'); if (!is_dir($dir)) { $snapNote = 'snapshot dir 없음'; } elseif (!class_exists('ZipArchive')) { // ext-zip 이 없으면 아카이브를 만들 수 없다. 조용히 넘어가면 이미지 없는 백업을 // 완전한 백업으로 착각하게 된다. $snapNote = 'ZipArchive 없음 — storage/snapshots/ 를 FTP 로 별도 내려받으세요'; } else { $zipPath = $outDir . '/move_detect-snapshots-' . $stamp . '.zip'; $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { $files = glob($dir . '/*.jpg') ?: []; foreach ($files as $f) $zip->addFile($f, basename($f)); $zip->close(); @chmod($zipPath, 0600); $snapNote = count($files) . '개 -> ' . basename($zipPath); } else { $snapNote = '아카이브를 만들지 못했습니다'; } } } // ---- 복구 안내 ------------------------------------------------------------------------- $restore = $outDir . '/RESTORE.txt'; file_put_contents($restore, implode("\n", [ 'move_detect 복구 절차', '', '1. 새 DB 를 만들고 schema.sql 을 먼저 실행합니다 (표 구조).', '2. migrations/ 의 SQL 을 날짜 순서대로 실행합니다.', '3. 이 폴더의 move_detect-<시각>.sql 을 실행합니다 (데이터).', '4. move_detect-snapshots-<시각>.zip 을 풀어 storage/snapshots/ 에 넣습니다.', '5. config.php 의 DB 접속 정보를 새 DB 로 맞춥니다.', '', '주의:', '- 이 백업에는 사고 기록과 작업자 이미지(개인정보)가 들어 있습니다.', ' 웹에서 접근 가능한 경로에 두지 마십시오.', '- move_nonces / move_rate_limits 는 휘발성이라 백업에 포함되지 않습니다 (자동 재생성).', '- 복구 후 tools/verify_audit_chain.php 로 감사로그 무결성을 확인하십시오.', '', '생성: ' . date('c'), ]) . "\n"); fwrite(STDOUT, "DB 덤프: {$sqlPath}\n"); foreach ($counts as $t => $n) fwrite(STDOUT, sprintf(" %-24s %s\n", $t, $n)); fwrite(STDOUT, "스냅샷: {$snapNote}\n"); fwrite(STDOUT, "복구 안내: {$restore}\n"); exit(0);