'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) . ' 分'; } /** 启动后宽限秒数:PID 尚未写入文件或子进程仍在 exec 时不判死 */ define('BACKUP_STARTUP_GRACE_SEC', 12); /** * 清理临时 wrapper / pid 等 */ function cleanupWrapper($path, $pid_path = '') { if (!empty($path) && file_exists($path)) { @unlink($path); } if (!empty($pid_path) && file_exists($pid_path)) { @unlink($pid_path); } } /** * 解析用于存活检测的 PID(优先 wrapper 自写 pid 文件,防止 echo \$! 与真实进程不一致) */ function resolveMonitorPid($st) { if (!empty($st['pid_path']) && is_readable($st['pid_path'])) { $from_file = trim((string)@file_get_contents($st['pid_path'])); if ($from_file !== '' && ctype_digit($from_file)) { return (int)$from_file; } } if (isset($st['pid']) && $st['pid'] !== null && $st['pid'] !== '') { return (int)$st['pid']; } return null; } /** * 压缩成功落盘(统一两种检测路径的重复代码) */ function finalizeBackupSuccess($status_file, &$st, $bp, $elapsed, $wp, $pid_path = '') { 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, $pid_path); } /** * 进程已退出且无果:带上日志 / fail 标记提示 */ function finalizeBackupUnexpectedExit($status_file, &$st, $elapsed, $wp, $log_path, $pid_path, $fail_marker) { $extra = ''; if (!empty($fail_marker) && file_exists($fail_marker)) { $code = trim((string)@file_get_contents($fail_marker)); if ($code !== '') { $extra .= '(脚本退出码 ' . $code . ')'; } } if (!empty($log_path) && is_readable($log_path)) { $tail = readLogTail($log_path, 12); if ($tail !== '') { $st['log_tail'] = $tail; } } $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩已结束但未生成备份文件' . $extra . '。请查看下方日志;手工能 tar 而网页失败时,多为后台任务收到挂起信号(已用 nohup)或 cd/权限/open_basedir 与 CLI 不一致。'; writeStatus($status_file, $st); cleanupWrapper($wp, $pid_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'] : ''; $pid_path = isset($st['pid_path']) ? $st['pid_path'] : ''; $mode = isset($st['mode']) ? $st['mode'] : ''; // 统一给前端的字段(避免前端/后端字段名不一致导致“要刷新才看到文件名”) 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) { finalizeBackupSuccess($status_file, $st, $bp, $elapsed, $wp, $pid_path); } else { $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩已结束,但备份文件不存在或大小为 0。' . (!empty($log_path) ? ' 可查看日志:' . basename($log_path) : ''); if (!empty($log_path)) { $tail = readLogTail($log_path, 12); if ($tail !== '') { $st['log_tail'] = $tail; } } writeStatus($status_file, $st); cleanupWrapper($wp, $pid_path); } } elseif (!empty($fail_marker) && file_exists($fail_marker)) { $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩失败(脚本返回非 0)。' . (!empty($log_path) ? ' 可查看日志:' . basename($log_path) : ''); if (!empty($log_path)) { $tail = readLogTail($log_path, 12); if ($tail !== '') { $st['log_tail'] = $tail; } } writeStatus($status_file, $st); cleanupWrapper($wp, $pid_path); } if ($st['status'] !== 'running') { $st = decorateStatus($st, $backup_web_base); echo json_encode($st, JSON_UNESCAPED_UNICODE); exit; } // ---- 检测 1: 进程是否仍存活(wrapper 自写 pid 文件优先;启动后宽限期内不因 PID 未就绪判死)---- $monitor_pid = resolveMonitorPid($st); $json_pid = (isset($st['pid']) && $st['pid'] !== null && $st['pid'] !== '') ? (int)$st['pid'] : null; if ($monitor_pid !== null) { $alive = isProcessAlive($monitor_pid); $kill_id = $monitor_pid; } elseif ($json_pid !== null) { $alive = isProcessAlive($json_pid); $kill_id = $json_pid; } elseif ($mode === 'php') { // 纯 PHP 压缩在同一次请求里阻塞执行,无独立 PID,不能用进程存活推断失败 $alive = true; $kill_id = null; } elseif ($elapsed < BACKUP_STARTUP_GRACE_SEC && $mode === 'shell') { $alive = true; $kill_id = $json_pid; } else { $alive = false; $kill_id = $json_pid; } if (!$alive) { if (!empty($fail_marker) && file_exists($fail_marker)) { $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩失败(脚本返回非 0)。'; if (!empty($log_path)) { $tail = readLogTail($log_path, 12); if ($tail !== '') { $st['log_tail'] = $tail; } } writeStatus($status_file, $st); cleanupWrapper($wp, $pid_path); } elseif (!empty($bp) && file_exists($bp) && filesize($bp) > 0) { finalizeBackupSuccess($status_file, $st, $bp, $elapsed, $wp, $pid_path); } else { finalizeBackupUnexpectedExit($status_file, $st, $elapsed, $wp, $log_path, $pid_path, $fail_marker); } } // ---- 检测 2: 大小上限 / 卡住(互斥分支,避免后段覆盖前段的 error)---- 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); if ($current_file_size > $MAX_COMPRESSED_MB * 1024 * 1024) { requestStop($stop_marker, 'size_limit_exceeded'); if ($kill_id !== null) { killProcess($kill_id); } $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, $pid_path); } else { $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; } if ($mode !== 'php' && $last_active_sec >= $STALL_TIMEOUT && $elapsed > 60) { requestStop($stop_marker, 'stall_timeout'); if ($kill_id !== null) { killProcess($kill_id); } $st['status'] = 'error'; $st['elapsed'] = $elapsed; $st['message'] = '压缩卡死:文件连续 ' . formatSeconds($last_active_sec) . ' 没有增长(已运行 ' . formatSeconds($elapsed) . '),已自动终止。可能原因:磁盘 I/O 挂起、NFS 断开或服务器资源耗尽。'; writeStatus($status_file, $st); cleanupWrapper($wp, $pid_path); } 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') { $wp = isset($st['wrapper_path']) ? $st['wrapper_path'] : ''; $pid_path = isset($st['pid_path']) ? $st['pid_path'] : ''; $stop_marker = isset($st['stop_marker']) ? $st['stop_marker'] : ''; requestStop($stop_marker, 'manual_reset'); $kill_pid = resolveMonitorPid($st); if ($kill_pid === null && isset($st['pid']) && $st['pid'] !== null && $st['pid'] !== '') { $kill_pid = (int)$st['pid']; } if ($kill_pid !== null) { killProcess($kill_pid); } cleanupWrapper($wp, $pid_path); } 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'; $pid_path = $backup_dir_full . '/._backup_' . $timestamp . '.pid'; $launch_log = $backup_dir_full . '/._backup_' . $timestamp . '.launch.log'; // ---- 写入 running 状态 ---- writeStatus($status_file, array( 'status' => 'running', 'backup_file' => $backup_file, 'backup_path' => $backup_path, 'wrapper_path' => $wrapper_path, 'pid_path' => $pid_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) { $find_args = buildFindArgs($INCLUDE_PATTERNS); // 不用 set -e:cd/管道失败时也要写入 fail_marker;首行写真实 sh PID 供状态轮询 $wrapper_cmd = "umask 022\n" . "echo \$\$ > " . escapeshellarg($pid_path) . "\n" . "cd " . escapeshellarg($root_path) . " || { echo 1 > " . escapeshellarg($fail_marker) . "; exit 1; }\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 echo \$rc > " . escapeshellarg($fail_marker) . "; exit \$rc; fi\n" . "touch " . escapeshellarg($done_marker) . "\n" . "exit 0\n"; file_put_contents($wrapper_path, $wrapper_cmd); chmod($wrapper_path, 0755); // nohup:避免 PHP-FPM/会话结束向后代发 SIGHUP 把 tar 链杀掉(与页面说明一致) $launcher_inner = 'nohup sh ' . escapeshellarg($wrapper_path) . ' >>' . escapeshellarg($launch_log) . ' 2>&1 & echo $!'; $launch_cmd = 'sh -c ' . escapeshellarg($launcher_inner); $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, $pid_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); } ?>
备份目录暂无文件。
'; } ?> 目录,避免重复备份