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 条踩坑记录、可直接复制的排障命令手册
This commit is contained in:
+273
@@ -0,0 +1,273 @@
|
||||
<?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' => '未知操作']);
|
||||
}
|
||||
Reference in New Issue
Block a user