Files
MovieLog_Server/storage_api.php
T
laoyang 673a8abf78 feat: 云端图片存储(OSS/COS/七牛)+ 服务端代理读取,并补全文档
新增存储抽象层:图片可存本地或云对象存储,切换存储方式不影响已有图片。

存储层
- lib/storage.php 存储门面:本地优先读取、URL 生成、上传/删除、批量迁移、
  服务端读取(storageRead)与访问方式判定(storageAccessMode)
- lib/storage/{local,oss,cos,qiniu}.php 四种后端驱动;http.php 共用 cURL 客户端;
  meta.php 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色,免密钥)
- lib/settings.php 设置读写;云厂商密钥以 enc:v1:gcm 密文落库(db/.settings.key)
- 文件名改为内容指纹(md5 前 12 位_6 位随机[_角色].ext),内容不变则名不变,
  因此可下发 immutable 长缓存

两种图片访问方式
- 直连存储:storageUrl() 产出对象存储地址(桶需允许匿名读)
- 服务端代理:media.php 鉴权后由服务器签名读取并同源回吐,桶可保持私有
  · 未登录返回 403 纯文本(刻意不用 302,否则 <img> 会拿到一段 HTML)
  · 对象键白名单防目录穿越;Content-Type 按扩展名白名单决定 + nosniff(防同源 XSS)
  · 透传 If-None-Match / Range,上游 304 直接短路;private + immutable 长缓存
  · 鉴权后立即 session_write_close(),避免排他会话锁把并行图片请求串行化

内网 endpoint 判定(修「开了代理后慢到不可用」)
- 内网地址只在与桶同地域的 ECS 上可路由;跨地域时 DNS 能解析但 TCP 连不通,
  无脑「内网优先」会让每次读取先白等满一次连接超时(默认 10 秒)
- 改为先比本机 ECS 地域与桶地域(零成本、结论确定)→ 不同即跳过;
  结论落缓存;内网只给 2 秒连接超时,传输层失败立刻标记且短期内不再重试
- 「测试连通性」升级为 PUT → GET 回读 → DELETE,并显式报出内网判定结论

界面与页面
- settings.php 系统设置页:驱动选择、各云配置、图片访问方式、连接测试、迁移进度
- storage_api.php 提供 stat / migrate / test 三个 JSON 接口
- detail / edit / annual_report / persons 原先直接拼本地路径(file_exists / '/'.$path),
  迁云后会整块不渲染或 404,统一改为走存储门面
- js/main.js 增加破图占位兜底;config.php 统一下发 Referrer-Policy: same-origin

文档
- README 新增《图片存储与对象存储》整章:设计约定、两种访问方式取舍表、
  直连存储逐项配置说明(桶 ACL / 防盗链白名单 / 跨域 CORS 与 crossorigin /
  强制下载 / endpoint 地域 / 自定义域名)与上线检查清单、服务端代理实现要点、
  迁移步骤、12 条踩坑记录、可直接复制的排障命令手册
2026-09-21 22:06:02 +08:00

274 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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' => '未知操作']);
}