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:
2026-09-21 22:06:02 +08:00
parent dd957d45bd
commit 673a8abf78
21 changed files with 3746 additions and 57 deletions
+380
View File
@@ -0,0 +1,380 @@
<?php
// ============================================
// 文件: lib/storage.php
// 说明: 对象存储门面层。对上暴露一组过程式函数,对下按设置选择驱动。
//
// 设计要点:
// 1. 数据库里始终存"相对键"(如 uploads/posters/xxx.jpg),不存完整 URL,
// 换存储或换域名时无需改动历史数据。
// 2. 生成 URL 时"本地优先":本地存在同名文件就按本地路径输出。
// 这样从本地切换到云端后,历史图片自动继续可用,无需先迁移。
// 3. 云端上传失败自动回退本地保存,绝不因为云配置问题导致用户丢图。
// ============================================
require_once __DIR__ . '/storage/http.php';
require_once __DIR__ . '/storage/meta.php';
require_once __DIR__ . '/storage/local.php';
require_once __DIR__ . '/storage/oss.php';
require_once __DIR__ . '/storage/cos.php';
require_once __DIR__ . '/storage/qiniu.php';
// ---------- 驱动实例 ----------
// 按驱动标识 + 配置构造驱动对象
function storageDriverInstance($driver, array $cfg) {
switch ($driver) {
case 'oss': return new StorageOss($cfg);
case 'cos': return new StorageCos($cfg);
case 'qiniu': return new StorageQiniu($cfg);
default: return new StorageLocal();
}
}
// 当前生效的驱动实例(每请求缓存)
function storage() {
static $driver = null;
static $currentId = null;
$id = currentStorageDriver();
// 配置不完整时退回本地驱动,否则会出现
// "文件传到了云端、URL 却按本地算" 的错配,图片直接裂开。
if ($id !== 'local' && !storageConfigComplete($id)) {
$id = 'local';
}
if ($driver === null || $currentId !== $id) {
$driver = storageDriverInstance($id, $id === 'local' ? [] : storageConfigFor($id));
$currentId = $id;
}
return $driver;
}
// 当前是否使用远端存储(配置不完整时按本地处理,避免产生无效 URL)
function storageIsRemote() {
$id = currentStorageDriver();
if ($id === 'local') return false;
return storageConfigComplete($id);
}
// 判断某个驱动的配置是否已填齐
function storageConfigComplete($driver) {
if ($driver === 'local') return true;
$c = storageConfigFor($driver);
switch ($driver) {
case 'oss':
$auth = ($c['auth_mode'] === 'ecs_role')
|| (trim($c['access_key_id']) !== '' && trim($c['access_key_secret']) !== '');
return $auth && trim($c['bucket']) !== '' && trim($c['endpoint']) !== '';
case 'cos':
$auth = ($c['auth_mode'] === 'cvm_role')
|| (trim($c['secret_id']) !== '' && trim($c['secret_key']) !== '');
return $auth && trim($c['bucket']) !== '' && trim($c['region']) !== '';
case 'qiniu':
// 七牛必须绑定域名才能对外访问。若只填了密钥和空间名,
// 上传会成功但生成不出可访问 URL(页面会指向本地 404),
// 因此这里必须一并要求 custom_domain。
return trim($c['access_key']) !== '' && trim($c['secret_key']) !== ''
&& trim($c['bucket']) !== ''
&& trim($c['custom_domain']) !== '';
}
return false;
}
// ---------- 错误记录(供设置页展示)----------
function storageRecordError($message) {
setSettingJson('storage_last_error', [
'time' => date('Y-m-d H:i:s'),
'message' => storageShortError($message),
'driver' => currentStorageDriver(),
]);
}
function storageLastError() {
return getSettingJson('storage_last_error', []);
}
function storageClearError() {
deleteSetting('storage_last_error');
}
// ---------- 写入 ----------
/**
* 上传一个本地文件到当前存储。云端失败时自动回退到本地保存。
* @return bool 是否成功(含回退成功的场景)
*/
function storagePutFile($srcPath, $key, $mime = null) {
$key = ltrim((string)$key, '/');
if ($key === '' || !is_file($srcPath)) {
storageRecordError('上传失败:源文件不存在或目标键为空');
return false;
}
$mime = $mime ?: storageMimeOfFile($srcPath);
$driver = storage();
if ($driver->id() === 'local') {
$r = $driver->putFile($srcPath, $key, $mime);
if (empty($r['ok'])) storageRecordError($r['error']);
return !empty($r['ok']);
}
$r = $driver->putFile($srcPath, $key, $mime);
if (!empty($r['ok'])) return true;
// 云端失败 → 回退本地,避免丢图
$fallback = new StorageLocal();
$l = $fallback->putFile($srcPath, $key, $mime);
storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']);
return !empty($l['ok']);
}
// 上传一段二进制内容(用于 base64 裁剪结果)
function storagePutContents($binary, $key, $mime = null) {
$key = ltrim((string)$key, '/');
if ($key === '' || $binary === '' || $binary === null) {
storageRecordError('上传失败:内容为空或目标键为空');
return false;
}
$mime = $mime ?: 'application/octet-stream';
$driver = storage();
if ($driver->id() === 'local') {
$r = $driver->putContents($binary, $key, $mime);
if (empty($r['ok'])) storageRecordError($r['error']);
return !empty($r['ok']);
}
$r = $driver->putContents($binary, $key, $mime);
if (!empty($r['ok'])) return true;
$fallback = new StorageLocal();
$l = $fallback->putContents($binary, $key, $mime);
storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']);
return !empty($l['ok']);
}
// ---------- 删除 ----------
/**
* 删除一个对象:本地有就删本地;当前是远端存储时同时尝试删除云端对象。
*/
function storageDelete($key) {
if ($key === null || trim((string)$key) === '') return false;
$key = ltrim((string)$key, '/');
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $key)) return false; // 绝对 URL 无法定位对象
$ok = false;
$local = new StorageLocal();
if ($local->exists($key)) {
$ok = (bool)$local->delete($key);
}
if (storageIsRemote()) {
$r = storage()->delete($key);
if (empty($r['ok'])) {
storageRecordError($r['error']);
} else {
$ok = true;
}
}
return $ok;
}
// ---------- 读取 / URL ----------
// 是否为绝对地址(http/https/协议相对)
function storageIsAbsoluteUrl($path) {
return (bool)preg_match('#^(https?:)?//#i', (string)$path);
}
// 本地是否已存在该文件
function storageHasLocalFile($key) {
$key = ltrim((string)$key, '/');
return $key !== '' && is_file(ROOT_DIR . '/' . $key);
}
// ---------- 图片访问方式 ----------
// direct:图片 URL 直接指向存储(要求桶可被匿名读取,或由防盗链/签名兜底)
// proxy :图片 URL 指向本站 media.php,由服务端凭实例角色/密钥读取后回吐
// —— 桶可以设为「私有」,且因同源交付,Canvas 读像素(裁剪)也不再受限
function storageAccessMode() {
return ((string)getSetting('storage_access_mode', 'direct')) === 'proxy' ? 'proxy' : 'direct';
}
// 指向本站代理的图片地址。
// 逐段编码、保留斜杠(与 storageEncodeKey 同样的策略):URL 可读、nginx 日志好看。
// 目录穿越由 media.php 侧的白名单负责,不依赖这里的编码。
function storageProxyUrl($key) {
$parts = array_map('rawurlencode', explode('/', ltrim((string)$key, '/')));
return '/media.php?k=' . implode('/', $parts);
}
/**
* 生成可访问的 URL。规则:
* 1. 已经是绝对地址 → 原样返回
* 2. 本地存在同名文件 → 走本地路径(同源,无需绕代理)
* 3. 代理模式 → 指向本站 media.php
* 4. 否则交给当前驱动生成
*/
function storageUrl($key) {
if ($key === null || trim((string)$key) === '') return null;
$key = (string)$key;
if (storageIsAbsoluteUrl($key)) return $key;
$trimmed = ltrim($key, '/');
if (storageHasLocalFile($trimmed)) return '/' . $trimmed;
if (storageAccessMode() === 'proxy') return storageProxyUrl($trimmed);
return storage()->url($trimmed);
}
/**
* 服务端读取对象内容(供 media.php 回吐)。
* 本地优先:本地有文件就直接读盘,省一次外网往返;否则交给驱动。
* $conditional 为透传给上游的条件头(如 ['If-None-Match: "abc"'])。
*/
function storageRead($key, array $conditional = []) {
$key = ltrim((string)$key, '/');
if ($key === '') {
return ['ok' => false, 'status' => 400, 'headers' => [], 'body' => '', 'error' => '缺少对象键'];
}
if (storageHasLocalFile($key)) {
$abs = ROOT_DIR . '/' . $key;
$body = @file_get_contents($abs);
if ($body !== false) {
return [
'ok' => true,
'status' => 200,
'headers' => [
'content-type' => storageMimeOfFile($abs),
'content-length' => (string)strlen($body),
'etag' => '"' . md5($body) . '"',
'last-modified' => gmdate('D, d M Y H:i:s \G\M\T', (int)@filemtime($abs)),
],
'body' => $body,
'error' => '',
];
}
}
$drv = storage();
if (!method_exists($drv, 'get')) {
return [
'ok' => false, 'status' => 501, 'headers' => [], 'body' => '',
'error' => '当前存储驱动(' . $drv->label() . ')暂不支持服务端读取',
];
}
return $drv->get($key, $conditional);
}
/**
* 判断图片是否"应当被视为存在"。
* 本地:真实检查文件;云端:键非空即视为存在(远端不做逐次 HEAD 探测)。
*/
function storageExists($key) {
if ($key === null || trim((string)$key) === '') return false;
if (storageIsAbsoluteUrl($key)) return true;
if (storageHasLocalFile($key)) return true;
return storageIsRemote();
}
// ---------- MIME ----------
function storageMimeOfFile($path) {
if (function_exists('finfo_open') && is_file($path)) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
finfo_close($finfo);
if ($mime) return $mime;
}
}
return storageMimeFromName($path);
}
function storageMimeFromContents($binary) {
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_buffer($finfo, $binary);
finfo_close($finfo);
if ($mime) return $mime;
}
}
return 'application/octet-stream';
}
function storageMimeFromName($name) {
$map = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
'txt' => 'text/plain',
];
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
return $map[$ext] ?? 'application/octet-stream';
}
// ---------- 诊断(设置页用)----------
// 探测当前服务器是否位于某朵云上(仅做可达性判断,不拉取凭据)
function storageDetectCloudHost() {
$hosts = ['aliyun' => 'ECS(阿里云)', 'tencent' => 'CVM(腾讯云)'];
$found = [];
foreach ($hosts as $key => $label) {
$cred = $key === 'aliyun'
? aliyunEcsRamCredentials('', true)
: tencentCvmCamCredentials('', true);
if (!empty($cred['ok'])) {
$found[$key] = ['label' => $label, 'role' => $cred['role'] ?? '', 'expires' => $cred['expires'] ?? 0];
}
}
return $found;
}
// ---------- 迁移改名:改写数据库里的图片引用 ----------
/**
* 数据库里所有存放「图片相对键」的字段(表, 字段)。
*
* 迁移时若把文件按指纹改名,必须一并改写这些引用,
* 否则库里仍指向旧名,图片会裂开——特别是勾选了"迁移后删除本地文件"时无法回退。
*/
function storageImageRefFields() {
return [
['movies', 'poster_path'],
['movies', 'ticket_path'],
['movies', 'photo_path'], // 遗留字段,仍可能有历史数据
['movie_photos', 'photo_path'],
['persons', 'avatar'],
['admins', 'avatar'],
];
}
/**
* 把所有等于 $oldKey 的图片引用改写为 $newKey。
* 同一条旧键被多行引用时会一次全部改掉。
*
* @return int 受影响行数合计
*/
function storageRewriteImageRef($oldKey, $newKey) {
$oldKey = (string)$oldKey;
$newKey = (string)$newKey;
if ($oldKey === '' || $newKey === '' || $oldKey === $newKey) return 0;
try {
$db = getDB();
} catch (Throwable $e) {
return 0;
}
$affected = 0;
foreach (storageImageRefFields() as $pair) {
list($table, $column) = $pair;
// 表名/字段名来自上面的硬编码清单,非用户输入
try {
$stmt = $db->prepare("UPDATE {$table} SET {$column} = ? WHERE {$column} = ?");
$stmt->execute([$newKey, $oldKey]);
$affected += $stmt->rowCount();
} catch (PDOException $e) {
// 表或字段不存在(旧库)时跳过,不阻断迁移
}
}
return $affected;
}