Files
MovieLog_Server/lib/helpers.php
T
laoyang 294c34bbc2 feat: 补齐默认头像资源,修复「删除头像」静默失效
问题:添加观影人页面的头像预览指向 /assets/default-avatar.png,但仓库里
从来没有这个文件 → 该预览一直是坏图;同时未上传头像时会按姓名首字母生成
彩色 SVG 头像,导致「没有头像」这一种状态有两种表现,行为不统一。

改动
- assets/default-avatar.png(新增):200x200 中性灰人形剪影,随仓库分发;
  生成脚本见 _tooling/make_default_avatar.py(Pillow,4 倍超采样)。
- lib/helpers.php:新增 defaultAvatarUrl();getAvatarUrl() 在未上传头像时
  一律返回默认头像(不再生成首字母头像);资源缺失时退化为内联 SVG 剪影,
  彻底消除「兜底图本身就是坏图」这类问题。
- persons.php:
  * 后端开始处理 delete_avatar —— 此前前端设了标记、后端从未读取,
    点「删除」再保存头像原样保留,是个静默失效的按钮。
  * 上传新头像优先于删除标记,避免「先点删除、后又重新选图」时上传被忽略。
  * removeAvatar() 复用同一个隐藏域,重复点击不再叠加多个同名 input。
  * 表单预览统一走 getAvatarUrl()(顺带修掉头像键存在但文件丢失时仍渲染坏图)。
- README.md:更新头像兜底说明、目录树与「已知限制」。

验证
- storage_test.py 新增第 [21] 节 21 项断言(资源合法性、HTTP 可访问、四个分支、
  资源缺失降级、页面渲染、删除头像端到端),合计 196 项全绿。
- 47 个端点的 A/B 回归:剔除「预期的头像地址替换」与「persons.php 内联 JS 重写」
  后无任何其它差异(0 处未解释变更)。
- 第 10 批部署已上线:线上 assets/default-avatar.png 的 sha256 与本地逐字节一致,
  全站无 PHP 诊断泄漏,数据行数不变(movies 219 / persons 17 / viewers 449)。
2026-09-21 22:21:01 +08:00

199 lines
7.9 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);
}
// ===== 头像处理 =====
// 默认头像 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 头像),
// 现在一律使用默认头像,参数仅保留以兼容既有调用点。
function getAvatarUrl($avatar_path, $name = '') {
if (storageExists($avatar_path)) {
return storageUrl($avatar_path);
}
return defaultAvatarUrl();
}
// ===== 保存 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;
}