Files
laoyang ddf2eeafaa feat: 「自动获取」改用豆瓣数据源,海报自动下载入库
此前的 OMDb 数据源必须用英文片名,国产片覆盖极差。改用豆瓣公开接口后
中文片名直接可搜,冷门片/老片/剧集也能命中。

fetch_movie.php(全量重写)
- 搜索走 movie.douban.com/j/subject_suggest;详情走移动端
  m.douban.com/rexxar/api/v2/movie/<id>(PC 详情页有反爬 302,故用移动端接口)
- 海报下载后写入 uploads/posters/,文件名沿用内容指纹规则
- 新增登录鉴权:未登录返回 401(此前该接口完全公开)
- 启用 TLS 证书校验(原为 CURLOPT_SSL_VERIFYPEER=false)
- 魔数兜底 + 12MB 上限:挡掉反爬返回的 HTML 页面与异常大响应
- old_poster 残留清理:重复点击时删掉上一张,且仅删「未被数据库引用」的文件
- 接口地址支持环境变量覆盖,便于用 mock 做端到端测试

lib/helpers.php
- 新增 isValidStoredKey():校验表单回传的存储键,防目录穿越与越权引用

add.php
- 前端改用新字段(豆瓣评分 / 地区 / 又名),并把 poster_key 放进隐藏域回传
- 提交时「手动上传的海报优先」,没有才采用自动获取已入库的海报

README.md
- 同步为豆瓣数据源说明,移除已过时的 OMDb API Key 与 SSL 校验两条安全提示

验证
- 本地 mock 豆瓣端到端:33/33 通过
- 既有存储回归套件:196/196 通过
- A/B 归一化比对:除 add.php 按预期变化外,其余 45 个响应完全一致
- 线上实测:流浪地球 / 哪吒之魔童闹海 均抓取成功,海报落盘为合法 JPEG
2026-09-21 23:05:40 +08:00

217 lines
8.7 KiB
PHP
Raw Permalink 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, '/') . '/';
}
// 校验一个表单回传的存储键是否合法:必须严格落在 $prefix 目录下,
// 且文件名不含任何路径分隔符 / 特殊字符。
//
// 用途:自动获取海报后,前端会把服务端抓取入库的 poster_key 回传;
// 这个键来自客户端,不可信任——要防止「目录穿越」和「引用别的目录下的图」
// 之类的越权操作。校验通过后仍应用 storageExists() 确认文件真实存在。
function isValidStoredKey($key, $prefix) {
$key = ltrim((string)$key, '/');
$prefix = rtrim((string)$prefix, '/') . '/';
if ($key === '' || strpos($key, $prefix) !== 0) {
return false;
}
$name = substr($key, strlen($prefix));
return $name !== ''
&& strpos($name, '/') === false
&& (bool)preg_match('/^[A-Za-z0-9._-]+$/', $name);
}
// 删除已保存的文件(本地与云端按当前存储方式处理,不存在时静默跳过)
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;
}