2026-09-21 12:56:30 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
// 文件: lib/helpers.php
|
|
|
|
|
|
// 说明: 通用工具函数(输出转义、文件名、颜色、上传文件读写、图片 URL)
|
2026-09-21 22:06:02 +08:00
|
|
|
|
// 图片的读写与 URL 生成一律通过 lib/storage.php 收口,
|
|
|
|
|
|
// 以便在“本地硬盘”与“对象存储”之间切换。
|
2026-09-21 12:56:30 +08:00
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
// 允许上传的图片扩展名
|
|
|
|
|
|
function allowedImageExts() {
|
|
|
|
|
|
return ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 安全输出
|
|
|
|
|
|
function h($str) {
|
|
|
|
|
|
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 22:06:02 +08:00
|
|
|
|
// 生成带「内容指纹」的文件名:<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) {
|
2026-09-21 12:56:30 +08:00
|
|
|
|
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
|
|
|
|
|
|
if (!in_array($ext, allowedImageExts())) {
|
|
|
|
|
|
$ext = 'jpg';
|
|
|
|
|
|
}
|
2026-09-21 22:06:02 +08:00
|
|
|
|
if ($binary !== null && $binary !== '') {
|
|
|
|
|
|
return fingerprintedFilename($binary, $ext);
|
|
|
|
|
|
}
|
2026-09-21 12:56:30 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 22:06:02 +08:00
|
|
|
|
// 把绝对目录转换成相对 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));
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
2026-09-21 22:06:02 +08:00
|
|
|
|
return rtrim($rel, '/') . '/';
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 22:06:02 +08:00
|
|
|
|
// 删除已保存的文件(本地与云端按当前存储方式处理,不存在时静默跳过)
|
|
|
|
|
|
function deleteStoredFile($relative_path) {
|
|
|
|
|
|
if (empty($relative_path)) return false;
|
|
|
|
|
|
return storageDelete($relative_path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理单个图片上传字段,成功返回相对键(如 uploads/posters/x.jpg),失败返回 null
|
|
|
|
|
|
// 注意:$target_dir 为兼容保留,实际落盘位置由存储层根据键推导
|
2026-09-21 12:56:30 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-21 22:06:02 +08:00
|
|
|
|
$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)) {
|
2026-09-21 12:56:30 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-09-21 22:06:02 +08:00
|
|
|
|
return $key;
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理多张图片上传字段(如 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;
|
|
|
|
|
|
}
|
2026-09-21 22:06:02 +08:00
|
|
|
|
$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)) {
|
2026-09-21 12:56:30 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$stmt = $db->prepare("INSERT INTO movie_photos (movie_id, photo_path) VALUES (?, ?)");
|
2026-09-21 22:06:02 +08:00
|
|
|
|
$stmt->execute([$movie_id, $key]);
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 22:06:02 +08:00
|
|
|
|
// 海报 URL(不存在时回退占位图)
|
2026-09-21 12:56:30 +08:00
|
|
|
|
function posterUrl($path) {
|
2026-09-21 22:06:02 +08:00
|
|
|
|
if (!storageExists($path)) {
|
|
|
|
|
|
return '/assets/placeholder-poster.jpg';
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
2026-09-21 22:06:02 +08:00
|
|
|
|
return storageUrl($path);
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 头像处理 =====
|
2026-09-21 22:21:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 默认头像 URL:未上传头像的人统一使用它。
|
|
|
|
|
|
// 资源随仓库分发(assets/default-avatar.png)。
|
|
|
|
|
|
// 若该文件缺失——比如只部署了 PHP 而漏了 assets/——则退化为内联 SVG 剪影,
|
|
|
|
|
|
// 避免出现「兜底图本身就是坏图」:这正是本函数此前的问题(返回了一个并不存在的路径)。
|
|
|
|
|
|
// 注意:这里用 is_file() 探测,路径在站点目录内,不会像站外路径那样触碰 open_basedir 限制。
|
|
|
|
|
|
function defaultAvatarUrl() {
|
|
|
|
|
|
static $url = null;
|
|
|
|
|
|
if ($url !== null) {
|
|
|
|
|
|
return $url;
|
|
|
|
|
|
}
|
|
|
|
|
|
$root = defined('ROOT_DIR') ? ROOT_DIR : dirname(__DIR__);
|
|
|
|
|
|
if (is_file($root . '/assets/default-avatar.png')) {
|
|
|
|
|
|
$url = '/assets/default-avatar.png';
|
|
|
|
|
|
return $url;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 几何比例与 assets/default-avatar.png 保持一致,两种情形视觉无差别
|
|
|
|
|
|
$svg = "<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 200 200'>"
|
|
|
|
|
|
. "<rect width='200' height='200' fill='#e9edf3'/>"
|
|
|
|
|
|
. "<circle cx='100' cy='74' r='33' fill='#7f8896'/>"
|
|
|
|
|
|
. "<ellipse cx='100' cy='200' rx='74' ry='93' fill='#7f8896'/>"
|
|
|
|
|
|
. "</svg>";
|
|
|
|
|
|
$url = 'data:image/svg+xml,' . rawurlencode($svg);
|
|
|
|
|
|
return $url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 头像 URL:已上传 → 存储层 URL;未上传 → 统一的默认头像。
|
|
|
|
|
|
// 说明:$name 是历史遗留参数(早期按姓名首字母生成彩色 SVG 头像),
|
|
|
|
|
|
// 现在一律使用默认头像,参数仅保留以兼容既有调用点。
|
2026-09-21 12:56:30 +08:00
|
|
|
|
function getAvatarUrl($avatar_path, $name = '') {
|
2026-09-21 22:06:02 +08:00
|
|
|
|
if (storageExists($avatar_path)) {
|
|
|
|
|
|
return storageUrl($avatar_path);
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
2026-09-21 22:21:01 +08:00
|
|
|
|
return defaultAvatarUrl();
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 保存 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);
|
2026-09-21 22:06:02 +08:00
|
|
|
|
if ($binary === false || $binary === '') return false;
|
2026-09-21 12:56:30 +08:00
|
|
|
|
|
2026-09-21 22:06:02 +08:00
|
|
|
|
$mime = storageMimeFromContents($binary);
|
2026-09-21 12:56:30 +08:00
|
|
|
|
|
|
|
|
|
|
$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];
|
|
|
|
|
|
|
2026-09-21 22:06:02 +08:00
|
|
|
|
$filename = fingerprintedFilename($binary, $ext, $prefix);
|
|
|
|
|
|
$key = relPathOfDir($save_dir) . $filename;
|
|
|
|
|
|
if (!storagePutContents($binary, $key, $mime)) return false;
|
2026-09-21 12:56:30 +08:00
|
|
|
|
|
2026-09-21 22:06:02 +08:00
|
|
|
|
return $key;
|
2026-09-21 12:56:30 +08:00
|
|
|
|
}
|