Files
MovieLog_Server/lib/helpers.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

176 lines
7.0 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
// ============================================
// 文件: lib/helpers.php
// 说明: 通用工具函数(输出转义、文件名、颜色、上传文件读写、图片 URL)
// 图片的读写与 URL 生成一律通过 lib/storage.php 收口,
// 以便在“本地硬盘”与“对象存储”之间切换。
// ============================================
// 允许上传的图片扩展名
function allowedImageExts() {
return ['jpg', 'jpeg', 'png', 'gif', 'webp'];
}
// 安全输出
function h($str) {
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
// 生成带「内容指纹」的文件名:<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;
}
// 校验并归一化十六进制颜色,非法值回退为灰色
function normalizeHexColor($color, $fallback = '#888888') {
$color = trim((string)$color);
return preg_match('/^#[a-fA-F0-9]{6}$/', $color) ? $color : $fallback;
}
// 把绝对目录转换成相对 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 rtrim($rel, '/') . '/';
}
// 删除已保存的文件(本地与云端按当前存储方式处理,不存在时静默跳过)
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;
}
$ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
if (!in_array($ext, allowedImageExts())) {
return null;
}
$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 $key;
}
// 处理多张图片上传字段(如 photos[] / new_photos[]),写入 movie_photos 表
function uploadMultiplePhotos($field, $movie_id) {
if (!isset($_FILES[$field]) || !is_array($_FILES[$field]['tmp_name'])) {
return;
}
$db = getDB();
$files = $_FILES[$field];
for ($i = 0; $i < count($files['tmp_name']); $i++) {
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
continue;
}
$ext = strtolower(pathinfo($files['name'][$i], PATHINFO_EXTENSION));
if (!in_array($ext, allowedImageExts())) {
continue;
}
$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, $key]);
}
}
// 海报 URL(不存在时回退占位图)
function posterUrl($path) {
if (!storageExists($path)) {
return '/assets/placeholder-poster.jpg';
}
return storageUrl($path);
}
// ===== 头像处理 =====
function getAvatarUrl($avatar_path, $name = '') {
if (storageExists($avatar_path)) {
return storageUrl($avatar_path);
}
if (!empty($name)) {
$letter = mb_substr($name, 0, 1, 'UTF-8');
$colors = ['#e74c3c','#e67e22','#f1c40f','#2ecc71','#3498db','#9b59b6','#1abc9c','#e84393','#00b894','#0984e3'];
$color = $colors[abs(crc32($name)) % count($colors)];
return "data:image/svg+xml," . urlencode("<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'><rect width='40' height='40' fill='{$color}'/><text x='20' y='28' text-anchor='middle' font-size='20' fill='#fff' font-family='sans-serif' font-weight='bold'>{$letter}</text></svg>");
}
return '/assets/default-avatar.png';
}
// ===== 保存 Base64 图片 =====
function saveBase64Image($base64_data, $save_dir, $prefix = 'avatar') {
if (strpos($base64_data, 'base64,') !== false) {
$base64_data = substr($base64_data, strpos($base64_data, 'base64,') + 7);
}
$binary = base64_decode($base64_data);
if ($binary === false || $binary === '') return false;
$mime = storageMimeFromContents($binary);
$ext_map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp'
];
if (!isset($ext_map[$mime])) return false;
$ext = $ext_map[$mime];
$filename = fingerprintedFilename($binary, $ext, $prefix);
$key = relPathOfDir($save_dir) . $filename;
if (!storagePutContents($binary, $key, $mime)) return false;
return $key;
}