'idle', 'message' => ''); } $content = file_get_contents($file); if ($content === false) { return array('status' => 'idle', 'message' => ''); } $data = json_decode($content, true); if (!$data || !isset($data['status'])) { return array('status' => 'idle', 'message' => ''); } return $data; } /** * 写入状态文件(带文件锁) */ function writeStatus($file, $status) { $status['updated_at'] = date('Y-m-d H:i:s'); file_put_contents($file, json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX); } /** * 检查进程是否存活 */ function isProcessAlive($pid) { if (empty($pid) || !is_numeric($pid)) return false; $pid = (int)$pid; // 方法 1: posix_kill(非阻塞信号检测) if (function_exists('posix_kill')) { return @posix_kill($pid, 0); } // 方法 2: 检查 /proc 文件系统(Linux) if (file_exists("/proc/{$pid}")) { return true; } // 方法 3: 通过 ps 命令检测 $check = trim(@shell_exec("ps -p {$pid} -o pid= 2>/dev/null")); if (empty($check)) { $check = trim(@shell_exec("/bin/ps -p {$pid} -o pid= 2>/dev/null")); } return !empty($check); } /** * 尝试终止进程(兼容无 posix 扩展的主机) */ function killProcess($pid) { if (empty($pid) || !is_numeric($pid)) return false; $pid = (int)$pid; if (function_exists('posix_kill')) { return @posix_kill($pid, SIGKILL); } // 兼容 Linux / BusyBox / 受限环境:尝试调用 kill $out = @shell_exec("kill -9 {$pid} >/dev/null 2>&1; echo $?"); if ($out !== null) { return trim($out) === '0'; } return false; } /** * 格式化秒数为可读字符串 */ function formatSeconds($sec) { if ($sec < 60) return $sec . ' 秒'; if ($sec < 3600) return floor($sec / 60) . ' 分 ' . ($sec % 60) . ' 秒'; return floor($sec / 3600) . ' 小时 ' . floor(($sec % 3600) / 60) . ' 分'; } /** * 清理临时 wrapper 脚本 */ function cleanupWrapper($path) { if (!empty($path) && file_exists($path)) { @unlink($path); } } /** * 写入停止标记,通知 wrapper 主动停机 */ function requestStop($stop_marker, $reason) { if (empty($stop_marker)) return; @file_put_contents($stop_marker, $reason . ' @ ' . date('Y-m-d H:i:s')); } /** * 读取日志最后 N 行(兼容旧版 PHP) */ function readLogTail($path, $lines) { if (empty($path) || !file_exists($path) || !is_readable($path)) { return ''; } $content = @file_get_contents($path); if ($content === false || $content === '') { return ''; } $arr = preg_split("/\r\n|\n|\r/", $content); if (!$arr) return ''; $lines = max(1, (int)$lines); $tail = array_slice($arr, -$lines); return trim(implode("\n", $tail)); } /** * 补齐状态输出字段(下载链接、日志链接、错误摘要) */ function decorateStatus($st, $backup_web_base) { if (!is_array($st)) return $st; if (!isset($st['file']) && isset($st['backup_file'])) { $st['file'] = $st['backup_file']; } if (!isset($st['download_url']) && !empty($st['file'])) { $st['download_url'] = $backup_web_base . '/' . rawurlencode($st['file']); } if (!empty($st['log_path'])) { if (!isset($st['log_download_url'])) { $st['log_download_url'] = $backup_web_base . '/' . rawurlencode(basename($st['log_path'])); } if (isset($st['status']) && $st['status'] === 'error' && !isset($st['log_tail'])) { $tail = readLogTail($st['log_path'], 20); if ($tail !== '') { $st['log_tail'] = $tail; } } } return $st; } /** * 文件名是否命中配置的模式 */ function matchByPatterns($filename, $patterns_csv) { $filename = (string)$filename; $patterns = explode(',', (string)$patterns_csv); foreach ($patterns as $p) { $p = trim($p); if ($p === '') continue; if (function_exists('fnmatch')) { if (@fnmatch($p, $filename)) return true; } else { $regex = '/^' . str_replace( array('\*', '\?'), array('.*', '.'), preg_quote($p, '/') ) . '$/i'; if (preg_match($regex, $filename)) return true; } } return false; } /** * 纯 PHP 方式压缩(命令函数被禁用时兜底) */ function compressWithPhpZip($root_path, $backup_dir_name, $backup_path, $patterns_csv, $max_bytes, $log_path) { $log = array(); $log[] = '[' . date('Y-m-d H:i:s') . '] PHP zip mode start'; if (!class_exists('ZipArchive')) { $log[] = 'ZipArchive extension is not available.'; @file_put_contents($log_path, implode("\n", $log) . "\n"); return array(false, '服务器未安装 ZipArchive 扩展,无法使用纯 PHP 压缩。', 0); } if (file_exists($backup_path)) { @unlink($backup_path); } $zip = new ZipArchive(); $open_ret = $zip->open($backup_path, ZipArchive::CREATE | ZipArchive::OVERWRITE); if ($open_ret !== true) { $log[] = 'Zip open failed: ' . $open_ret; @file_put_contents($log_path, implode("\n", $log) . "\n"); return array(false, '创建压缩包失败(ZipArchive::open 返回 ' . $open_ret . ')。', 0); } $root_real = realpath($root_path); $backup_real = realpath(rtrim($root_path, '/\\') . '/' . $backup_dir_name); $added = 0; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($root_real, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $fileInfo) { if (!$fileInfo->isFile()) continue; $full = $fileInfo->getPathname(); // 排除备份目录本身 if ($backup_real && strpos($full, $backup_real . DIRECTORY_SEPARATOR) === 0) { continue; } $base = $fileInfo->getFilename(); if (!matchByPatterns($base, $patterns_csv)) continue; $rel = substr($full, strlen($root_real) + 1); $rel = str_replace('\\', '/', $rel); if ($rel === '' || $rel === false) continue; if (@$zip->addFile($full, $rel)) { $added++; } if ($added % 20 === 0) { clearstatcache(true, $backup_path); $size_now = file_exists($backup_path) ? (int)filesize($backup_path) : 0; if ($size_now > $max_bytes) { $zip->close(); $log[] = 'Size limit exceeded: ' . $size_now . ' bytes'; @file_put_contents($log_path, implode("\n", $log) . "\n"); return array(false, '压缩文件超过上限,已终止(当前 ' . round($size_now / 1024 / 1024, 2) . ' MB)。', $size_now); } } } $zip->close(); } catch (Exception $e) { @file_put_contents($log_path, implode("\n", $log) . "\nException: " . $e->getMessage() . "\n"); return array(false, '纯 PHP 压缩异常:' . $e->getMessage(), 0); } clearstatcache(true, $backup_path); $final_size = file_exists($backup_path) ? (int)filesize($backup_path) : 0; $log[] = 'Added files: ' . $added; $log[] = 'Final size: ' . $final_size . ' bytes'; @file_put_contents($log_path, implode("\n", $log) . "\n"); if ($final_size <= 0) { return array(false, '压缩完成但文件为空,请检查目录权限。', 0); } if ($final_size > $max_bytes) { return array(false, '压缩完成但超过上限(' . round($final_size / 1024 / 1024, 2) . ' MB)。', $final_size); } return array(true, 'ok', $final_size); } /** * 构建 tar 压缩命令的 find 部分 */ function buildFindArgs($patterns) { $name_args = array(); $pattern_list = explode(',', $patterns); foreach ($pattern_list as $p) { $p = trim($p); if ($p === '') continue; $name_args[] = '-name "' . addcslashes($p, '"\\$`') . '"'; } return implode(' -o ', $name_args); } /** * 是否具备至少一种命令执行能力 */ function canLaunchBackground() { $disabled_str = ini_get('disable_functions'); $disabled = array(); if ($disabled_str) { $disabled = array_map('trim', explode(',', $disabled_str)); } $funcs = array('shell_exec', 'exec', 'system', 'passthru', 'proc_open', 'popen'); foreach ($funcs as $f) { if (function_exists($f) && !in_array($f, $disabled)) { return true; } } return false; } /** * 安全执行后台命令,返回 array(成功?, PID 或 null) */ function launchBackground($cmd) { $disabled_str = ini_get('disable_functions'); $disabled = array(); if ($disabled_str) { $disabled = array_map('trim', explode(',', $disabled_str)); } // 优先使用 shell_exec(最可靠的 PID 获取方式) if (function_exists('shell_exec') && !in_array('shell_exec', $disabled)) { $output = @shell_exec($cmd); if ($output !== null) { $pid = trim($output); if (is_numeric($pid) && (int)$pid > 0) { return array(true, (int)$pid); } return array(true, null); } } // 回退方案 $fallback_funcs = array('exec', 'system', 'passthru', 'proc_open', 'popen'); foreach ($fallback_funcs as $func) { if (function_exists($func) && !in_array($func, $disabled)) { $silent_cmd = str_replace('& echo $!', '> /dev/null 2>&1 &', $cmd); if ($func === 'exec') { @exec($silent_cmd, $out, $ret); // 某些环境后台命令启动成功但返回码并不可靠,这里按“已调用”视为提交成功 return array(true, null); } if ($func === 'system' || $func === 'passthru') { @system($silent_cmd, $ret); return array(true, null); } if ($func === 'proc_open') { $descriptors = array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')); $process = @proc_open($silent_cmd, $descriptors, $pipes); if (is_resource($process)) { proc_close($process); return array(true, null); } } if ($func === 'popen') { $handle = @popen($silent_cmd, 'r'); if ($handle) { pclose($handle); return array(true, null); } } } } return array(false, null); } // ======================== API: 状态查询(自适应超时) ======================== if (isset($_GET['action']) && $_GET['action'] === 'status') { header('Content-Type: application/json; charset=utf-8'); $st = decorateStatus(readStatus($status_file), $backup_web_base); // 仅在 running 状态时进行进程检测 if ($st['status'] === 'running') { $started_at = isset($st['started_at']) ? $st['started_at'] : 'now'; $started = strtotime($started_at); $elapsed = time() - $started; $bp = isset($st['backup_path']) ? $st['backup_path'] : ''; $wp = isset($st['wrapper_path']) ? $st['wrapper_path'] : ''; $done_marker = isset($st['done_marker']) ? $st['done_marker'] : ''; $fail_marker = isset($st['fail_marker']) ? $st['fail_marker'] : ''; $log_path = isset($st['log_path']) ? $st['log_path'] : ''; $stop_marker = isset($st['stop_marker']) ? $st['stop_marker'] : ''; // 统一给前端的字段(避免前端/后端字段名不一致导致“要刷新才看到文件名”) if (!isset($st['file']) && isset($st['backup_file'])) { $st['file'] = $st['backup_file']; } // ---- 检测 0: 是否已有明确完成/失败标记(不依赖 PID)---- if (!empty($done_marker) && file_exists($done_marker)) { if (!empty($bp) && file_exists($bp) && filesize($bp) > 0) { clearstatcache(true, $bp); $actual_size = filesize($bp); $st['status'] = 'done'; $st['actual_size'] = round($actual_size / 1024 / 1024, 2); $st['elapsed'] = $elapsed; $st['message'] = '压缩完成,耗时约 ' . formatSeconds($elapsed) . ',文件大小 ' . $st['actual_size'] . ' MB'; writeStatus($status_file, $st); } else { $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩已结束,但备份文件不存在或大小为 0。' . (!empty($log_path) ? '可查看日志:' . basename($log_path) : ''); writeStatus($status_file, $st); } cleanupWrapper($wp); } elseif (!empty($fail_marker) && file_exists($fail_marker)) { $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩失败(脚本返回非 0)。' . (!empty($log_path) ? '可查看日志:' . basename($log_path) : ''); writeStatus($status_file, $st); cleanupWrapper($wp); } // 如果已经不是 running(上面已落盘改写),后续无需再走 PID/大小检测 if ($st['status'] !== 'running') { $st = decorateStatus($st, $backup_web_base); echo json_encode($st, JSON_UNESCAPED_UNICODE); exit; } // ---- 检测 1: 进程是否还活着 ---- $pid = isset($st['pid']) ? $st['pid'] : null; $alive = ($pid !== null) ? isProcessAlive($pid) : true; if (!$alive) { // 进程已退出 — 检查备份文件 if (!empty($bp) && file_exists($bp) && filesize($bp) > 0) { clearstatcache(true, $bp); $actual_size = filesize($bp); $st['status'] = 'done'; $st['actual_size'] = round($actual_size / 1024 / 1024, 2); $st['elapsed'] = $elapsed; $st['message'] = '压缩完成,耗时约 ' . formatSeconds($elapsed) . ',文件大小 ' . $st['actual_size'] . ' MB'; writeStatus($status_file, $st); cleanupWrapper($wp); } else { $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩进程意外终止(PID ' . $pid . '),备份文件未生成。' . '可能原因:磁盘空间不足、文件权限问题或命令执行被中断。'; writeStatus($status_file, $st); cleanupWrapper($wp); } } // ---- 检测 2: 压缩文件大小是否超限 / 是否卡住 ---- else { clearstatcache(true, $bp); $current_file_size = (!empty($bp) && file_exists($bp)) ? filesize($bp) : 0; $current_size_mb = round($current_file_size / 1024 / 1024, 2); // ★ 核心检测 A:压缩文件大小超过上限 → 立即终止 if ($current_file_size > $MAX_COMPRESSED_MB * 1024 * 1024) { requestStop($stop_marker, 'size_limit_exceeded'); if ($pid !== null) { killProcess($pid); } $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['actual_size'] = $current_size_mb; $st['message'] = '压缩文件已超过 ' . $MAX_COMPRESSED_MB . ' MB 上限(当前 ' . $current_size_mb . ' MB),已终止压缩进程。' . '备份文件已保留,你可以下载已压缩的部分。' . '如果需要完整备份,请调大 MAX_COMPRESSED_MB 后重试。'; writeStatus($status_file, $st); cleanupWrapper($wp); } // ★ 核心检测 B:文件是否卡住(自适应超时) // 取上次记录的文件大小和时间 $last_size = isset($st['last_file_size']) ? (int)$st['last_file_size'] : 0; $last_active_at = isset($st['last_active_at']) ? $st['last_active_at'] : $started_at; $last_active_sec = time() - strtotime($last_active_at); // 判断文件是否在增长 $file_growing = ($current_file_size > $last_size); if ($file_growing) { // 文件在增长 → 更新活跃时间戳和大小 $st['last_file_size'] = $current_file_size; $st['last_active_at'] = date('Y-m-d H:i:s'); $last_active_sec = 0; // 刚刚写入,重置 } // 核心判断 B:文件连续 STALL_TIMEOUT 秒不增长 = 卡死 if ($last_active_sec >= $STALL_TIMEOUT && $elapsed > 60) { // 真正卡住了 → 终止 requestStop($stop_marker, 'stall_timeout'); if ($pid !== null) { killProcess($pid); } $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩卡死:文件连续 ' . formatSeconds($last_active_sec) . ' 没有增长(已运行 ' . formatSeconds($elapsed) . '),已自动终止。可能原因:磁盘 I/O 挂起、NFS 断开或服务器资源耗尽。'; writeStatus($status_file, $st); cleanupWrapper($wp); } else { // 仍在正常运行 — 返回详细状态 $st['elapsed'] = $elapsed; $st['current_size'] = $current_size_mb; $st['file_growing'] = $file_growing; $st['stall_seconds'] = $last_active_sec; $st['over_soft_limit'] = ($elapsed > $MAX_RUNTIME); $st['size_limit_mb'] = $MAX_COMPRESSED_MB; $st['size_percent'] = ($MAX_COMPRESSED_MB > 0) ? min(100, round($current_size_mb / $MAX_COMPRESSED_MB * 100, 1)) : 0; // 如果超过软超时,提示但不终止 if ($elapsed > $MAX_RUNTIME && $file_growing) { $st['message'] = '压缩已超过 ' . formatSeconds($MAX_RUNTIME) . ' 软限制,但文件仍在增长中,不会终止。请耐心等待。'; } elseif ($elapsed > $MAX_RUNTIME && !$file_growing) { $st['message'] = '压缩已超过软限制,文件暂未增长。' . '如果连续 ' . formatSeconds($STALL_TIMEOUT) . ' 不恢复写入,将自动终止(当前已停 ' . formatSeconds($last_active_sec) . ')。'; } elseif ($current_size_mb > $MAX_COMPRESSED_MB * 0.8) { $st['message'] = '压缩文件已 ' . $current_size_mb . ' MB,接近 ' . $MAX_COMPRESSED_MB . ' MB 上限。超过上限将自动终止(文件已保留,可下载已压缩部分)。'; } else { $st['message'] = '压缩任务正在运行'; } // 保存更新的状态(活跃时间等) writeStatus($status_file, $st); } } } $st = decorateStatus($st, $backup_web_base); echo json_encode($st, JSON_UNESCAPED_UNICODE); exit; } // ======================== API: 重置状态 ======================== if (isset($_GET['action']) && $_GET['action'] === 'reset') { header('Content-Type: application/json; charset=utf-8'); $st = readStatus($status_file); if ($st['status'] === 'running') { $pid = isset($st['pid']) ? $st['pid'] : null; $wp = isset($st['wrapper_path']) ? $st['wrapper_path'] : ''; $stop_marker = isset($st['stop_marker']) ? $st['stop_marker'] : ''; requestStop($stop_marker, 'manual_reset'); if ($pid !== null) { killProcess($pid); } cleanupWrapper($wp); } writeStatus($status_file, array('status' => 'idle', 'message' => '状态已手动重置')); echo json_encode(array('status' => 'idle', 'message' => '状态已重置'), JSON_UNESCAPED_UNICODE); exit; } // ======================== API: 发起备份 ======================== if (isset($_GET['action']) && $_GET['action'] === 'backup') { header('Content-Type: application/json; charset=utf-8'); // ---- 防重复:检查当前状态 ---- $current = readStatus($status_file); if ($current['status'] === 'running') { $started_at = isset($current['started_at']) ? $current['started_at'] : 'now'; $started = strtotime($started_at); $elapsed = time() - $started; $cur_file = isset($current['backup_file']) ? $current['backup_file'] : ''; echo json_encode(array( 'status' => 'running', 'message' => '已有压缩任务正在运行中(已运行 ' . formatSeconds($elapsed) . '),请勿重复提交。' . '如需重新开始,请先重置状态。', 'file' => $cur_file, ), JSON_UNESCAPED_UNICODE); exit; } // ---- 准备文件名 ---- $timestamp = date('Ymd_His'); $use_shell_mode = canLaunchBackground() && DIRECTORY_SEPARATOR !== '\\'; $backup_file = $domain . '_' . $timestamp . ($use_shell_mode ? '.tar.gz' : '.zip'); $backup_path = $backup_dir_full . '/' . $backup_file; $wrapper_path = $backup_dir_full . '/._backup_' . $timestamp . '.sh'; $done_marker = $backup_dir_full . '/._backup_' . $timestamp . '.done'; $fail_marker = $backup_dir_full . '/._backup_' . $timestamp . '.fail'; $stop_marker = $backup_dir_full . '/._backup_' . $timestamp . '.stop'; $log_path = $backup_dir_full . '/backup_' . $timestamp . '.log'; // ---- 写入 running 状态 ---- writeStatus($status_file, array( 'status' => 'running', 'backup_file' => $backup_file, 'backup_path' => $backup_path, 'wrapper_path' => $wrapper_path, 'done_marker' => $done_marker, 'fail_marker' => $fail_marker, 'stop_marker' => $stop_marker, 'log_path' => $log_path, 'size_limit_mb' => $MAX_COMPRESSED_MB, 'mode' => $use_shell_mode ? 'shell' : 'php', 'started_at' => date('Y-m-d H:i:s'), 'pid' => null, 'message' => '压缩任务已启动', )); if ($use_shell_mode) { // ---- shell 后台执行 ---- $find_args = buildFindArgs($INCLUDE_PATTERNS); $wrapper_cmd = "set -e\n" . "cd " . escapeshellarg($root_path) . "\n" . "rm -f " . escapeshellarg($done_marker) . " " . escapeshellarg($fail_marker) . " " . escapeshellarg($stop_marker) . "\n" . "( find . -type f \\( " . $find_args . " \\) " . " ! -path \"./" . $backup_dir . "/*\" -print0 " . " | tar --null -T - -czf " . escapeshellarg($backup_path) . " ) >" . escapeshellarg($log_path) . " 2>&1 &\n" . "tar_pid=$!\n" . "while kill -0 $tar_pid 2>/dev/null; do\n" . " if [ -f " . escapeshellarg($stop_marker) . " ]; then\n" . " kill -9 $tar_pid >/dev/null 2>&1 || true\n" . " wait $tar_pid >/dev/null 2>&1 || true\n" . " echo 124 > " . escapeshellarg($fail_marker) . "\n" . " exit 124\n" . " fi\n" . " sleep 1\n" . "done\n" . "wait $tar_pid\n" . "rc=$?\n" . "if [ $rc -ne 0 ]; then\n" . " echo $rc > " . escapeshellarg($fail_marker) . "\n" . " exit $rc\n" . "fi\n" . "touch " . escapeshellarg($done_marker) . "\n" . "exit 0\n"; file_put_contents($wrapper_path, $wrapper_cmd); chmod($wrapper_path, 0755); $launch_cmd = "sh -c " . escapeshellarg("sh " . escapeshellarg($wrapper_path) . " >/dev/null 2>&1 & echo \$!"); $result = launchBackground($launch_cmd); $success = $result[0]; $pid = $result[1]; if ($success) { if ($pid !== null) { $st = readStatus($status_file); $st['pid'] = $pid; writeStatus($status_file, $st); } echo json_encode(array( 'status' => 'running', 'message' => '压缩任务已提交到后台运行(压缩文件上限 ' . $MAX_COMPRESSED_MB . ' MB)', 'file' => $backup_file, 'download_url' => $backup_web_base . '/' . rawurlencode($backup_file), 'size_limit_mb' => $MAX_COMPRESSED_MB, ), JSON_UNESCAPED_UNICODE); } else { writeStatus($status_file, array( 'status' => 'error', 'message' => '后台压缩启动失败。请检查命令执行权限或系统 tar/find 可用性。', 'log_path'=> $log_path, )); echo json_encode(array( 'status' => 'error', 'message' => '后台压缩启动失败。请检查命令执行权限或系统 tar/find 可用性。', ), JSON_UNESCAPED_UNICODE); cleanupWrapper($wrapper_path); } } else { // ---- 纯 PHP 压缩兜底(无命令函数环境)---- $max_bytes = $MAX_COMPRESSED_MB * 1024 * 1024; $ret = compressWithPhpZip($root_path, $backup_dir, $backup_path, $INCLUDE_PATTERNS, $max_bytes, $log_path); $ok = $ret[0]; $msg = $ret[1]; $final_size = $ret[2]; $st_now = readStatus($status_file); $started_at = isset($st_now['started_at']) ? $st_now['started_at'] : date('Y-m-d H:i:s'); $elapsed = max(0, time() - strtotime($started_at)); if ($ok) { $done = $st_now; $done['status'] = 'done'; $done['actual_size'] = round($final_size / 1024 / 1024, 2); $done['elapsed'] = $elapsed; $done['message'] = '纯 PHP 压缩完成,文件大小 ' . $done['actual_size'] . ' MB'; writeStatus($status_file, $done); echo json_encode(decorateStatus($done, $backup_web_base), JSON_UNESCAPED_UNICODE); } else { $err = $st_now; $err['status'] = 'error'; $err['actual_size'] = round($final_size / 1024 / 1024, 2); $err['elapsed'] = $elapsed; $err['message'] = $msg; writeStatus($status_file, $err); echo json_encode(decorateStatus($err, $backup_web_base), JSON_UNESCAPED_UNICODE); } } exit; } // ======================== 渲染 HTML 页面 ======================== $initial_status = decorateStatus(readStatus($status_file), $backup_web_base); // 文件排序比较函数(PHP 5.6 兼容) function sortFilesByTime($a, $b) { return filemtime($b) - filemtime($a); } ?>
备份目录暂无文件。
'; } ?> 目录,避免重复备份