274 lines
10 KiB
PHP
274 lines
10 KiB
PHP
<?php
|
||||
|
|
// ============================================
|
|||
|
|
// 文件: storage_api.php
|
|||
|
|
// 说明: 存储相关的 AJAX 接口:连通性测试、云主机身份探测、历史图片迁移
|
|||
|
|
// 全部需要登录;返回 JSON。
|
|||
|
|
// ============================================
|
|||
|
|
|
|||
|
|
require_once 'config.php';
|
|||
|
|
|
|||
|
|
// 本文件是 JSON 接口:任何 PHP 诊断都不得写进响应体。
|
|||
|
|
// 否则前端的 res.json() 会直接失败,真正的错误反而被 SyntaxError 掩盖
|
|||
|
|
// (实际踩过:lib/storage/http.php 探测站外 CA 路径触发 open_basedir 警告,
|
|||
|
|
// "<br /><b>Warning</b>" 被 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' => '未知操作']);
|
|||
|
|
}
|