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:
+75
-31
@@ -2,6 +2,8 @@
|
||||
// ============================================
|
||||
// 文件: lib/helpers.php
|
||||
// 说明: 通用工具函数(输出转义、文件名、颜色、上传文件读写、图片 URL)
|
||||
// 图片的读写与 URL 生成一律通过 lib/storage.php 收口,
|
||||
// 以便在“本地硬盘”与“对象存储”之间切换。
|
||||
// ============================================
|
||||
|
||||
// 允许上传的图片扩展名
|
||||
@@ -14,12 +16,42 @@ function h($str) {
|
||||
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
function uniqueFilename($original) {
|
||||
// 生成带「内容指纹」的文件名:<md5前12位>_<6位随机>[_角色].ext
|
||||
//
|
||||
// 设计:
|
||||
// - 指纹段取图片内容的 md5 前 12 位,便于识别/比对同一张图(同名同图);
|
||||
// - 再拼 6 位随机后缀,保证文件名唯一——避免同一张图被多条记录引用时,
|
||||
// 删掉其一就把共用的文件一起删掉(删除逻辑不做引用计数);
|
||||
// - 角色段沿用原语义(poster / ticket / photo / avatar),便于人工排查。
|
||||
function fingerprintedFilename($binary, $ext, $role = '') {
|
||||
$ext = strtolower((string)$ext);
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
$fingerprint = substr(md5((string)$binary), 0, 12);
|
||||
$rand = bin2hex(random_bytes(3)); // 6 个十六进制字符
|
||||
$role = trim((string)$role);
|
||||
return $fingerprint . '_' . $rand . ($role !== '' ? '_' . $role : '') . '.' . $ext;
|
||||
}
|
||||
|
||||
// 文件名是否已经是「该内容的」指纹名(前 12 位 == 内容 md5 前 12 位)。
|
||||
// 迁移时用它跳过已改过名的文件,保证重复迁移幂等(不会每次都换新随机后缀)。
|
||||
function isFingerprintedName($filename, $binary) {
|
||||
$base = basename((string)$filename);
|
||||
if (strlen($base) < 12) return false;
|
||||
return substr(md5((string)$binary), 0, 12) === substr($base, 0, 12);
|
||||
}
|
||||
|
||||
// 生成唯一文件名。传入 $binary 时按内容指纹命名;否则退化为时间戳 + uniqid。
|
||||
// (保留 $binary 可选,是为了兼容"拿不到内容"的调用场景。)
|
||||
function uniqueFilename($original, $binary = null) {
|
||||
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
if ($binary !== null && $binary !== '') {
|
||||
return fingerprintedFilename($binary, $ext);
|
||||
}
|
||||
return date('YmdHis') . '_' . uniqid() . '.' . $ext;
|
||||
}
|
||||
|
||||
@@ -29,18 +61,24 @@ function normalizeHexColor($color, $fallback = '#888888') {
|
||||
return preg_match('/^#[a-fA-F0-9]{6}$/', $color) ? $color : $fallback;
|
||||
}
|
||||
|
||||
// 删除项目内已存在的上传文件(文件不存在时静默跳过)
|
||||
function deleteStoredFile($relative_path) {
|
||||
if (!empty($relative_path)) {
|
||||
$full = ROOT_DIR . '/' . $relative_path;
|
||||
if (file_exists($full)) {
|
||||
return unlink($full);
|
||||
}
|
||||
// 把绝对目录转换成相对 ROOT_DIR 的键前缀(如 /x/uploads/posters/ → uploads/posters/)
|
||||
function relPathOfDir($absDir) {
|
||||
$rel = str_replace('\\', '/', (string)$absDir);
|
||||
$root = rtrim(str_replace('\\', '/', ROOT_DIR), '/') . '/';
|
||||
if (strpos($rel, $root) === 0) {
|
||||
$rel = substr($rel, strlen($root));
|
||||
}
|
||||
return false;
|
||||
return rtrim($rel, '/') . '/';
|
||||
}
|
||||
|
||||
// 处理单个图片上传字段,成功返回相对路径,失败返回 null
|
||||
// 删除已保存的文件(本地与云端按当前存储方式处理,不存在时静默跳过)
|
||||
function deleteStoredFile($relative_path) {
|
||||
if (empty($relative_path)) return false;
|
||||
return storageDelete($relative_path);
|
||||
}
|
||||
|
||||
// 处理单个图片上传字段,成功返回相对键(如 uploads/posters/x.jpg),失败返回 null
|
||||
// 注意:$target_dir 为兼容保留,实际落盘位置由存储层根据键推导
|
||||
function uploadImageFile($field, $target_dir, $relative_prefix) {
|
||||
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
|
||||
return null;
|
||||
@@ -49,11 +87,15 @@ function uploadImageFile($field, $target_dir, $relative_prefix) {
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
return null;
|
||||
}
|
||||
$filename = uniqueFilename($_FILES[$field]['name']);
|
||||
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $target_dir . $filename)) {
|
||||
$tmpPath = $_FILES[$field]['tmp_name'];
|
||||
$binary = @file_get_contents($tmpPath);
|
||||
$filename = uniqueFilename($_FILES[$field]['name'], $binary === false ? null : $binary);
|
||||
$key = $relative_prefix . $filename;
|
||||
$mime = storageMimeOfFile($tmpPath);
|
||||
if (!storagePutFile($tmpPath, $key, $mime)) {
|
||||
return null;
|
||||
}
|
||||
return $relative_prefix . $filename;
|
||||
return $key;
|
||||
}
|
||||
|
||||
// 处理多张图片上传字段(如 photos[] / new_photos[]),写入 movie_photos 表
|
||||
@@ -71,27 +113,31 @@ function uploadMultiplePhotos($field, $movie_id) {
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
continue;
|
||||
}
|
||||
$filename = uniqueFilename($files['name'][$i]);
|
||||
if (!move_uploaded_file($files['tmp_name'][$i], PHOTO_DIR . $filename)) {
|
||||
$tmpPath = $files['tmp_name'][$i];
|
||||
$binary = @file_get_contents($tmpPath);
|
||||
$filename = uniqueFilename($files['name'][$i], $binary === false ? null : $binary);
|
||||
$key = 'uploads/photos/' . $filename;
|
||||
$mime = storageMimeOfFile($tmpPath);
|
||||
if (!storagePutFile($tmpPath, $key, $mime)) {
|
||||
continue;
|
||||
}
|
||||
$stmt = $db->prepare("INSERT INTO movie_photos (movie_id, photo_path) VALUES (?, ?)");
|
||||
$stmt->execute([$movie_id, 'uploads/photos/' . $filename]);
|
||||
$stmt->execute([$movie_id, $key]);
|
||||
}
|
||||
}
|
||||
|
||||
// 海报 URL
|
||||
// 海报 URL(不存在时回退占位图)
|
||||
function posterUrl($path) {
|
||||
if ($path && file_exists(ROOT_DIR . '/' . $path)) {
|
||||
return '/' . $path;
|
||||
if (!storageExists($path)) {
|
||||
return '/assets/placeholder-poster.jpg';
|
||||
}
|
||||
return '/assets/placeholder-poster.jpg';
|
||||
return storageUrl($path);
|
||||
}
|
||||
|
||||
// ===== 头像处理 =====
|
||||
function getAvatarUrl($avatar_path, $name = '') {
|
||||
if ($avatar_path && file_exists(ROOT_DIR . '/' . $avatar_path)) {
|
||||
return '/' . $avatar_path;
|
||||
if (storageExists($avatar_path)) {
|
||||
return storageUrl($avatar_path);
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$letter = mb_substr($name, 0, 1, 'UTF-8');
|
||||
@@ -108,11 +154,9 @@ function saveBase64Image($base64_data, $save_dir, $prefix = 'avatar') {
|
||||
$base64_data = substr($base64_data, strpos($base64_data, 'base64,') + 7);
|
||||
}
|
||||
$binary = base64_decode($base64_data);
|
||||
if ($binary === false) return false;
|
||||
if ($binary === false || $binary === '') return false;
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $binary);
|
||||
finfo_close($finfo);
|
||||
$mime = storageMimeFromContents($binary);
|
||||
|
||||
$ext_map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
@@ -123,9 +167,9 @@ function saveBase64Image($base64_data, $save_dir, $prefix = 'avatar') {
|
||||
if (!isset($ext_map[$mime])) return false;
|
||||
$ext = $ext_map[$mime];
|
||||
|
||||
$filename = date('YmdHis') . '_' . uniqid() . '_' . $prefix . '.' . $ext;
|
||||
$filepath = $save_dir . $filename;
|
||||
if (file_put_contents($filepath, $binary) === false) return false;
|
||||
$filename = fingerprintedFilename($binary, $ext, $prefix);
|
||||
$key = relPathOfDir($save_dir) . $filename;
|
||||
if (!storagePutContents($binary, $key, $mime)) return false;
|
||||
|
||||
return str_replace(ROOT_DIR . '/', '', $filepath);
|
||||
return $key;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user