Warning" 被 display_errors 打在 JSON 前面)。 ini_set('display_errors', '0'); ini_set('log_errors', '1'); ob_start(); // 兜底:万一发生致命错误,丢弃半截输出,改回一个可读的 JSON 错误 register_shutdown_function(function () { $e = error_get_last(); $fatal = $e && in_array($e['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true); if (!$fatal) return; while (ob_get_level() > 0) { ob_end_clean(); } if (!headers_sent()) { header('Content-Type: application/json; charset=utf-8'); } echo json_encode(['ok' => false, 'error' => '服务器内部错误:' . $e['message']], JSON_UNESCAPED_UNICODE); }); checkLogin(); header('Content-Type: application/json; charset=utf-8'); $action = $_POST['action'] ?? ($_GET['action'] ?? ''); // 把表单提交的配置合并到已保存配置之上(留空字段沿用旧值) function mergePostedConfig($driver, array $posted) { $saved = storageConfigFor($driver); foreach ($saved as $k => $old) { if (!array_key_exists($k, $posted)) continue; if (is_array($posted[$k])) continue; $val = trim((string)$posted[$k]); if ($val === '') { continue; // 留空:沿用已保存(与保存逻辑一致) } $saved[$k] = $val; } $saved['https'] = ($saved['https'] ?? '1') === '0' ? '0' : '1'; return $saved; } function jsonOut($data) { echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } // 扫描 uploads/ 下的全部文件(返回相对 ROOT_DIR 的键,已排序) function scanLocalUploadFiles() { $out = []; if (!is_dir(UPLOAD_DIR)) return $out; $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(UPLOAD_DIR, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile()) continue; $name = $f->getFilename(); if ($name === '.htaccess' || $name[0] === '.') continue; $rel = relPathOfDir($f->getPath()) . $name; $out[] = $rel; } sort($out, SORT_STRING); return $out; } function localBytesOf(array $files) { $b = 0; foreach ($files as $f) { $p = ROOT_DIR . '/' . $f; if (is_file($p)) $b += filesize($p); } return $b; } switch ($action) { // ---------- 测试连接 ---------- case 'test': { $driver = $_POST['driver'] ?? ''; if (!in_array($driver, ['oss', 'cos', 'qiniu'], true)) { jsonOut(['ok' => false, 'error' => '请选择云端存储方式后再测试']); } $posted = isset($_POST[$driver]) && is_array($_POST[$driver]) ? $_POST[$driver] : []; $cfg = mergePostedConfig($driver, $posted); try { $obj = storageDriverInstance($driver, $cfg); $r = $obj->test(); } catch (Throwable $e) { jsonOut(['ok' => false, 'error' => '测试异常:' . $e->getMessage()]); } if (!empty($r['ok'])) { // 测试通过,顺手清掉历史错误提示 if (currentStorageDriver() === $driver) { storageClearError(); } jsonOut(['ok' => true, 'message' => $r['message'] ?? '连接成功']); } jsonOut(['ok' => false, 'error' => $r['error'] ?? '测试失败']); } // ---------- 探测云主机身份 ---------- case 'detect_cloud': { clearMetaCredentialCache(); $found = storageDetectCloudHost(); if (empty($found)) { jsonOut([ 'ok' => false, 'message' => '未检测到云主机实例元数据。说明当前服务器不在阿里云 ECS / 腾讯云 CVM 上,' . '或实例未绑定角色——请改用密钥方式填写。', ]); } $parts = []; foreach ($found as $k => $info) { $parts[] = $info['label'] . ',角色:' . ($info['role'] !== '' ? $info['role'] : '(未知)'); } jsonOut(['ok' => true, 'message' => '✔ 检测到:' . implode(';', $parts) . ',可使用免密钥方式。']); } // ---------- 统计本地图片 ---------- case 'stat': { $files = scanLocalUploadFiles(); $bytes = localBytesOf($files); jsonOut([ 'ok' => true, 'local_total' => count($files), 'local_bytes' => $bytes, 'local_bytes_h' => formatBytes($bytes), 'remote_ready' => storageIsRemote(), ]); } // ---------- 分批迁移 ---------- case 'migrate': { if (!storageIsRemote()) { jsonOut(['ok' => false, 'error' => '当前未启用对象存储(或配置不完整),无法迁移']); } @set_time_limit(120); $driver = storage(); if ($driver->id() === 'local') { jsonOut(['ok' => false, 'error' => '当前驱动为本地存储,无法迁移']); } // 游标形如 "<本次运行令牌>|<已处理条数>"。 // 之所以不用「文件名游标」:迁移会把本地文件按内容指纹改名, // 改名后的新名字可能排到游标之后,于是同一个文件会被再处理一遍 // (线上出现过「进度 880/447」这种超过总数的数字)。 // 这里把「本次运行要处理的文件清单」一次性冻结存档,之后按**下标**推进, // 改名与否都不再影响进度;令牌用来识别并丢弃上一次遗留的清单。 $cursor = (string)($_POST['cursor'] ?? ''); $limit = (int)($_POST['limit'] ?? 10); $limit = max(1, min(50, $limit)); $deleteLocal = !empty($_POST['delete_local']); $token = ''; $start = 0; if ($cursor !== '' && strpos($cursor, '|') !== false) { list($token, $idx) = explode('|', $cursor, 2); $start = max(0, (int)$idx); } $queue = null; if ($token !== '') { $saved = json_decode((string)getSetting('storage_migrate_queue', ''), true); if (is_array($saved) && ($saved['run'] ?? '') === $token && is_array($saved['files'] ?? null)) { $queue = $saved['files']; } } if ($queue === null) { // 新一轮:重新冻结清单 $queue = scanLocalUploadFiles(); $token = bin2hex(random_bytes(6)); $start = 0; setSetting('storage_migrate_queue', json_encode(['run' => $token, 'files' => $queue], JSON_UNESCAPED_UNICODE)); } $total = count($queue); $batch = array_slice($queue, $start, $limit); $migrated = 0; $failed = 0; $renamed = 0; $refs = 0; $errors = []; foreach ($batch as $rel) { $abs = ROOT_DIR . '/' . $rel; if (!is_file($abs)) { continue; } $binary = @file_get_contents($abs); if ($binary === false || $binary === '') { $failed++; if (count($errors) < 20) $errors[] = $rel . ' → 读取源文件失败'; continue; } // 目标键:已经是本内容的指纹名就沿用(保证重复迁移幂等),否则按内容指纹改名 if (isFingerprintedName($rel, $binary)) { $newKey = $rel; } else { $ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION)); // 头像目录沿用 avatar 角色段,与正常上传的命名保持一致(便于人工排查) $role = (basename(dirname($abs)) === 'avatars') ? 'avatar' : ''; $newKey = relPathOfDir(dirname($abs)) . fingerprintedFilename($binary, $ext, $role); } $up = $driver->putFile($abs, $newKey, storageMimeOfFile($abs)); if (empty($up['ok'])) { $failed++; if (count($errors) < 20) $errors[] = $rel . ' → ' . ($up['error'] ?? '上传失败'); continue; } // 顺序不可颠倒:先上传成功 → 再改写数据库引用 → 再同步本地文件名 → 最后才删。 // 任一步失败就停在这里,本地文件仍在,"本地优先" 渲染保证页面不会裂图。 if ($newKey !== $rel) { $refs += storageRewriteImageRef($rel, $newKey); $renamed += 1; // 本地文件同步改成指纹名:一是本地 / 云端 / 数据库三者保持一致, // 二是——即便不删除本地文件——下次迁移看到的已是指纹名,可直接跳过, // 从而让「重复迁移」真正幂等(不会每次又换一个新的随机后缀)。 $newAbs = ROOT_DIR . '/' . $newKey; if ($abs !== $newAbs && @rename($abs, $newAbs)) { $abs = $newAbs; } } $migrated++; if ($deleteLocal) { @unlink($abs); } } // 按「下标」推进:本批就是清单里的 [start, start+count(batch)) 段 $processed = count($batch); $next = $start + $processed; $remaining = max(0, $total - $next); if ($remaining === 0) { // 本次跑完,清掉清单;下次点迁移会重新冻结一份(此时本地已是指纹名,直接复用键) deleteSetting('storage_migrate_queue'); } jsonOut([ 'ok' => true, 'processed' => $processed, 'migrated' => $migrated, 'renamed' => $renamed, 'refs' => $refs, 'failed' => $failed, 'errors' => $errors, 'cursor' => $token . '|' . $next, 'has_more' => $remaining > 0, 'total' => $total, 'remaining' => $remaining, ]); } default: jsonOut(['ok' => false, 'error' => '未知操作']); }