Files
MovieLog_Server/README.md
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

590 lines
42 KiB
Markdown
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.
# MovieLog_Server · 电影全纪录
一个轻量级的个人/家庭观影记录系统。纯 PHP 编写,使用 SQLite 作为数据库,**无需任何框架、无需 Composer、无需配置数据库服务**,上传到支持 PHP 的空间即可运行。
> 记录每一帧感动 —— 观影记录、影院、同行人、票根与照片,以及属于你的年度观影报告。
---
## 功能特性
### 观影记录管理
- **瀑布流首页**:按观影日期倒序展示所有电影,卡片显示海报、日期、影院(影院专属配色)、同行人头像。
- **添加电影**:支持电影名、剧情简介、观影日期、电影院、同行人、海报、票根、多张观影照片。
- **编辑 / 删除**:编辑页可替换或裁剪已有图片;删除电影时同步清理关联的图片文件。
- **电影详情**:海报大图、基本信息、备注、同行人列表(含头像)、影院信息、影像记录画廊(票根 + 观影照片,点击可查看原图)。
### 图片处理
- **内置裁剪器**:基于 Cropper.js,票根 / 海报 / 观影照片 / 观影人头像均可在浏览器内裁剪后再保存,服务端接收 Base64 图片落盘。
- **多图上传**:观影照片支持一次选择多张。
- **头像兜底**:未上传头像的观影人一律显示 `assets/default-avatar.png` 默认头像(人形剪影);若该资源缺失(例如只部署了 PHP 而漏了 `assets/`),`getAvatarUrl()` 会退化为内联 SVG 剪影 —— 保证任何情况下都不会出现"坏图"。
- 上传目录按类型分离:`uploads/posters/`、`uploads/tickets/`、`uploads/photos/`、`uploads/avatars/`。
- **破图兜底**:图片加载失败时由前端脚本替换为内联占位图,避免会话过期或权限异常时满屏破图刷屏控制台。
### 图片存储(本地 / 云对象存储)
- **四种存储后端**:本地硬盘(默认)、阿里云 OSS、腾讯云 COS、七牛云 Kodo,在「系统设置」页切换,**无需改代码**。
- **免密钥鉴权**:部署在阿里云 ECS / 腾讯云 CVM 上时,可直接绑定 RAM/CAM 角色读取实例元数据获取临时凭据,**不必在站内保存长期密钥**。
- **本地优先读取**:数据库里只存相对对象键(如 `uploads/posters/xxx.webp`);本地存在就本地读,否则读云端。**因此切换存储方式不会影响已有图片**,迁移可以慢慢来。
- **内容指纹命名**:文件名为 `md5(内容)前 12 位_6 位随机[_角色].ext`,内容不变则名字不变,可以放心下长缓存。
- **一键迁移**:把已有本地图片上传到云端并自动改写数据库引用(分批执行、带进度、幂等可重入)。
- **两种图片访问方式**:**直连存储**(浏览器直接访问对象存储)与**服务端代理**(经本站 `media.php` 鉴权后由服务器读取回吐,**桶可保持私有**)。两者的取舍与配置见下文《图片存储与对象存储》。
### 信息辅助
- **OMDb 自动获取**:在添加页输入片名后点击「📡 自动获取」,自动填充年份、类型、片长、导演、主演、IMDb 评分与剧情简介,并预览海报(海报仅供参考,仍需本地上传)。
- **CSV 批量导入**:一次性导入历史数据,自动识别 UTF-8 / GBK / GB2312 编码,自动创建不存在的影院,影院名去重,全程事务保护(出错自动回滚),并输出逐行错误报告。
### 检索与统计
- **首页快捷搜索**:按片名模糊搜索。
- **高级搜索**:片名 + 影院 + 观影人 + 日期区间,多条件组合筛选。
- **统计仪表板**:总览卡片(电影数 / 影院数 / 观影人数 / 观影人次 / 时间跨度),按影院、按观影人、按年份、最近 12 个月的条形图排行。
### 年度报告
- **个性化年度报告**:以「当前登录账号所关联的观影人」为视角生成,而非全站数据。
- **报告内容**:年度观影总数、观影天数、最常去的影院、最常一起观影的人、单场最多人的电影、首末场日期、月均频率、平均间隔天数、备注关键词云、今年共同观影人排行、年末最近观看、年度观影记录墙(含海报与同行人头像)。
- **可视化**:月度观影分布、星期观影偏好(Chart.js 双图),整体为深色"影院感"主题。
### 多用户与权限
- **Session 登录**:`password_hash` / `password_verify` 加盐哈希存储口令,全站页面均需登录。
- **用户管理**:可新增、编辑、删除管理员账号,可修改用户名与密码。
- **账号 ↔ 观影人绑定**:每个管理员账号可关联一个观影人,绑定后即可查看个人年度报告。
- **安全约束**:不允许删除最后一个管理员,也不允许删除自己。
- **个人资料**:修改自己的用户名与密码(需验证旧密码)。
### 基础数据维护
- **影院管理**:增删改查,支持「所在地方 / 影院名称 / 详细地址」三段式信息,内置 50 种预设配色,每个影院可用专属颜色区分;删除已被引用的影院时会给出醒目提示并要求二次确认(关联电影的影院字段将被置空)。
- **观影人管理**:增删改查,支持头像裁剪上传与专属配色;已被电影引用的观影人不允许直接删除。
### 界面与体验
- 复古票根色系(主色 `#e67e22` 橙)的响应式设计,移动端断点适配(600 / 768px)。
- 导航为下拉菜单,自动高亮当前页;顶部始终显示当前用户头像与用户名。
- 登录页为深色渐变 + 毛玻璃风格。
---
## 技术栈
| 项目 | 说明 |
| --- | --- |
| 语言 | PHP(过程式 + PDO) |
| 数据库 | SQLite(`db/movies.db`,首次运行自动建表) |
| 前端 | 原生 HTML / CSS / JavaScript,无构建步骤 |
| 第三方库 | [Cropper.js 1.5.13](https://github.com/fengyuanchen/cropperjs)(图片裁剪)、[Chart.js 4.4.0](https://www.chartjs.org/)(图表),均由 CDN 引入 |
| 外部 API | [OMDb API](http://www.omdbapi.com/)(电影信息查询,需申请 API Key) |
---
## 目录结构
```
MovieLog_Server/
├── config.php # 引导入口:错误报告、会话、路径常量、上传目录,并加载 lib/ 各模块
├── lib/ # 功能模块(被 config.php 统一引入,页面无需单独 require)
│ ├── db.php # DB 连接、建表、字段与历史数据迁移、默认管理员
│ ├── helpers.php # 输出转义、文件名、颜色校验、上传/删除文件、图片 URL
│ ├── settings.php # 系统设置读写;云厂商密钥以 enc:v1:gcm 密文落库
│ ├── storage.php # 存储门面:本地优先读取、URL 生成、上传/删除、迁移、代理读取
│ ├── storage/ # 各存储后端驱动
│ │ ├── local.php # 本地硬盘
│ │ ├── oss.php # 阿里云 OSS(签名 V1,支持 RAM 角色免密钥 / 内网 endpoint)
│ │ ├── cos.php # 腾讯云 COS
│ │ ├── qiniu.php # 七牛云 Kodo
│ │ ├── http.php # 共用 HTTP 客户端(cURL)与签名/编码工具
│ │ └── meta.php # 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色)与本机地域查询
│ ├── auth.php # 登录校验、管理员账号增删改
│ ├── movies.php # 电影/影院读取、搜索,以及表单关联数据的解析
│ └── stats.php # 统计仪表板与年度报告查询
├── index.php # 首页:观影列表(瀑布流)+ 快捷搜索
├── add.php # 添加电影
├── edit.php # 编辑电影
├── detail.php # 电影详情
├── delete.php # 删除电影(连带清理图片文件)
├── search.php # 高级搜索
├── stats.php # 统计仪表板
├── annual_report.php # 年度报告
├── cinemas.php # 影院管理
├── persons.php # 观影人管理
├── import.php # CSV 批量导入
├── users.php # 用户(管理员)管理
├── change_password.php # 修改自己的用户名/密码
├── login.php # 登录
├── logout.php # 退出
├── nav.php # 导航栏组件(被各页面 include)
├── settings.php # 系统设置页:存储后端选择、图片访问方式、连接测试、迁移
├── storage_api.php # 存储维护接口(stat / migrate / test),返回 JSON
├── media.php # 服务端图片代理:鉴权后由服务器读取对象并同源回吐
├── fetch_movie.php # OMDb 代理接口,返回 JSON
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
├── css/style.css # 全局样式
├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底)
├── assets/ # 静态资源
│ └── default-avatar.png # 默认头像(未上传头像时统一使用)
├── db/movies.db # SQLite 数据库(运行时自动生成,不纳入版本控制)
└── uploads/ # 上传图片(运行时自动生成,不纳入版本控制)
├── posters/ # 海报
├── tickets/ # 票根
├── photos/ # 观影照片
└── avatars/ # 观影人头像
```
> 页面只需 `require_once 'config.php'`,`lib/` 下的模块会由引导文件统一加载,并保证依赖顺序(db → helpers → auth → movies → stats)。
---
## 数据库结构
建表逻辑集中在 `lib/db.php` 的 `initDB()`(由 `config.php` 在每次请求引导时调用),每次请求都会执行 `CREATE TABLE IF NOT EXISTS` 与幂等的 `ALTER TABLE` 迁移,因此**升级代码无需手动改库**。
| 表 | 作用 | 关键字段 |
| --- | --- | --- |
| `admins` | 管理员账号 | `username`(唯一)、`password`(哈希)、`avatar`、`person_id`(关联观影人) |
| `cinemas` | 电影院 | `place`(所在地方)、`name`、`address`、`color`(专属配色) |
| `movies` | 电影记录 | `title`、`info`、`remark`、`poster_path`、`ticket_path`、`photo_path`(历史遗留单图)、`cinema_id`、`watch_date`、`created_at` |
| `persons` | 观影人 | `name`(唯一)、`color`、`avatar` |
| `viewers` | 电影 ↔ 观影人关联 | `movie_id`、`person_id`、`name`(历史遗留字段) |
| `movie_photos` | 电影多图 | `movie_id`、`photo_path`、`created_at` |
| `settings` | 键值配置与缓存 | `name`(唯一)、`value`。存存储驱动选择、各云厂商配置、图片访问方式等;**云厂商密钥以 `enc:v1:gcm:` 前缀密文落库**(密钥文件 `db/.settings.key`,首次保存时自动生成,**丢失后密文不可恢复**) |
`settings` 中除配置外的运行时缓存键(都可安全清空,程序会自动重建):
| 键 | 含义 | 有效期 |
| --- | --- | --- |
| `cache_meta_aliyun` / `cache_meta_tencent` | 实例角色换来的临时凭据 | 到期前 5 分钟失效 |
| `cache_ecs_region` | 本机 ECS 所在地域(用于判断内网 endpoint 是否可用) | 1 小时 |
| `cache_oss_internal` | 内网 endpoint 可用性判定结论 | 可用 1 小时 / 不可用 30 分钟 |
外键约束:`movies.cinema_id → cinemas.id`(`ON DELETE SET NULL`)、`viewers.movie_id → movies.id`(`ON DELETE CASCADE`)、`movie_photos.movie_id → movies.id`(`ON DELETE CASCADE`)。
**历史数据迁移**:`initDB()` 会自动把早期版本存在 `viewers.name` 里的姓名去重后写入 `persons`,并回填 `viewers.person_id`,老库可直接升级。
---
## 安装部署
### 环境要求
- PHP 7.4+(推荐 8.x),需启用扩展:`pdo_sqlite`、`fileinfo`、`mbstring`、`curl`
- Web 服务器:Apache / Nginx / PHP 内置服务器均可
- **使用对象存储时额外需要**:
- `curl` 扩展可用,且系统存在可信 CA 证书(`/etc/ssl/certs/ca-certificates.crt`、`/etc/pki/tls/certs/ca-bundle.crt` 等常见路径之一)。找不到 CA 时程序会回退给 curl 自行处理,但部分精简系统需要手动安装 `ca-certificates`,否则 HTTPS 访问对象存储会失败。
- 若使用「实例角色(免密钥)」鉴权:服务器需运行在对应云上,且实例已绑定 RAM(阿里云)/ CAM(腾讯云)角色。
- **`settings.php` 会写入 `db/`**:请确保 Web 用户对 `db/` 有写权限(密钥文件 `db/.settings.key` 首次保存时在此生成)。
### 步骤
1. **放置代码**:将项目文件放到 Web 根目录或其子目录。
2. **确保可写**:保证项目根目录可写,程序会自动创建 `db/` 与 `uploads/` 及其子目录;或手动创建并授权:
```bash
mkdir -p db uploads/posters uploads/tickets uploads/photos uploads/avatars
chmod -R 755 db uploads
```
3. **配置 OMDb API Key**(可选,仅影响「自动获取」功能):编辑 `fetch_movie.php`,将 `OMDB_API_KEY` 换成你在 [omdbapi.com](http://www.omdbapi.com/apikey.aspx) 申请的免费 Key。
4. **访问站点**:浏览器打开 `login.php`。
5. **首次登录**:数据库为空时会自动创建默认管理员:
- 用户名 `admin`
- 密码 `123456`
> **登录后请立即在「修改密码」中更换。**
### 本地快速运行
```bash
php -S localhost:8000
# 浏览器访问 http://localhost:8000/login.php
```
> 用 PHP 内置服务器时,`posterUrl()`(`lib/helpers.php`)与 `storageUrl()`(`lib/storage.php`)返回的是以 `/` 开头的站点绝对路径(如 `/uploads/posters/xxx.webp`),建议从项目根目录启动,或通过站点根目录访问。
### Nginx 参考配置
```nginx
server {
listen 80;
server_name movielog.local;
root /path/to/MovieLog_Server;
index index.php;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
```
---
## 使用说明
### 添加一部电影
1. 顶部导航点击「➕ 添加电影」。
2. 输入片名后点「📡 自动获取」可自动填充影片信息(可选)。
3. 填写观影日期(必填)、选择或新建电影院。
4. 「一起看的人」逐行添加:选择已有观影人,或在下拉框中选「➕ 新增」并输入姓名,新姓名会自动进入观影人库。
5. 上传海报(推荐 2:3);上传票根会弹出裁剪框,裁剪结果以 Base64 提交。
6. 观影照片支持一次多选。
7. 保存后自动跳转到详情页。
### 生成年度报告
年度报告是**按人**生成的,需要先建立账号与观影人的绑定:
1. 进入「用户管理」,找到自己的账号,在「关联观影人」中选择对应的观影人并保存。
2. 进入「年度报告」,页面顶部切换年份即可查看。
3. 未绑定观影人时报告为空并给出提示。
### CSV 批量导入
CSV **共 9 列,顺序固定**:
```
电影名称, 地点(忽略), 电影院名称, 观影日期, 观影人(空格分隔), 电影票图片路径, 海报图片路径, 备注, 合影图片路径
```
- **观影人**:多个姓名用空格分隔,例如 `张三 李四 王五`。
- **图片路径**:可带目录,系统只取最后一个 `/` 后的文件名;若整段被括号包裹(如 `(电影票/阿甘正传.jpg)`),会先取括号内内容再取文件名。
- **观影日期可为空**,空日期的记录会排在列表最后。
- **导入前**需先把图片文件放入 `uploads/tickets/`、`uploads/posters/`、`uploads/photos/`,文件名与 CSV 中提取出的保持一致。
- 编码支持 UTF-8 / GBK / GB2312,自动识别转换。
- 导入在事务中执行,失败会整体回滚;成功后显示「导入 N 条 / 跳过 M 条」及逐行错误原因。
示例行:
```csv
阿甘正传, , 万达影城, 2024-01-15, 张三 李四, (电影票/阿甘正传.jpg), (海报/阿甘正传.jpg), 经典励志片, (合影/阿甘正传.jpg)
```
### 把图片放到对象存储(可选)
不改也能用(默认本地硬盘)。想用云存储时:
1. 进入**系统设置**,选择存储后端(阿里云 OSS / 腾讯云 COS / 七牛云 Kodo),填写 Bucket、Endpoint、对象前缀等。
2. 选择鉴权方式:
- **实例角色(免密钥,推荐)**:服务器在对应云上并绑定了角色时选它,不用在站内保存任何长期密钥。
- **AccessKey**:手动填写密钥,入库即加密。
3. 点**测试连通性**——它会执行「写入探针 → **读回校验** → 删除探针」,因此**同时验证了写和读两条链路**,并会明确报出内网 endpoint 的判定结论。
4. 选择**图片访问方式**(直连存储 / 服务端代理),两个选项的取舍见《图片存储与对象存储》§2。
5. 需要的话点**迁移**,把已有本地图片搬到云端(分批、可重入,建议先备份 `db/` 与 `uploads/`)。
6. 迁移完成后**全站看图确认无误,再清理本地文件**——读取是本地优先的。
### 接口:`fetch_movie.php`
供添加页前端调用,服务端代请求 OMDb 避免浏览器跨域。
- **请求**:`GET fetch_movie.php?q=电影名称`
- **响应**:`Content-Type: application/json`
- **成功**:
```json
{
"title": "Forrest Gump",
"year": "1994",
"rated": "PG-13",
"released": "06 Jul 1994",
"runtime": "142 min",
"genre": "Drama, Romance",
"director": "Robert Zemeckis",
"actors": "Tom Hanks, Robin Wright, Gary Sinise",
"plot": "...",
"poster": "https://...",
"imdbRating": "8.8"
}
```
- **失败**:`{"error": "错误说明"}`(如「未找到相关电影,请尝试更准确的关键词」)
---
## 图片存储与对象存储(OSS / COS / 七牛)
> **这一节回答两个问题**:① 想用「直连存储」,对象存储那边到底要怎么配?② 我们在这条路上踩过的坑分别长什么样、怎么排。
### 1. 几个设计约定(先理解这个,后面的现象才好解释)
| 约定 | 说明 |
| --- | --- |
| **库里只存相对对象键** | `movies.poster_path` 等字段保存的是 `uploads/posters/xxxx.webp` 这种**相对键**,不含域名、不含 bucket、不含前缀。所以改域名 / 换 bucket / 改前缀都不用动数据库 |
| **本地优先读取** | `storageUrl()` / `storageExists()` 先看本地 `uploads/` 里有没有这个文件:有 → 走站内路径 `/uploads/...`;没有 → 交给当前存储驱动。**这是「切换存储方式不影响已有图片」和「可以慢慢迁移」的根本原因** |
| **内容指纹命名** | 文件名为 `md5(内容)前 12 位_6 位随机[_角色].ext`。内容不变则名字不变 → 可以下发 `immutable` 长缓存 |
| **门面 + 驱动** | 页面只调用 `lib/storage.php` 门面(`storageUrl` / `storageExists` / `storagePutFile` / `storageDelete` / `storageRead` / `storageAccessMode` …),不直接碰 SDK。加云厂商 = 加一个 `lib/storage/*.php` 驱动 |
| **密钥加密落库** | 云厂商 AccessKey 以 `enc:v1:gcm:` 密文存进 `settings` 表,密钥文件 `db/.settings.key`。**该文件丢失后密文不可恢复**,只能重新填写密钥。用实例角色(免密钥)则不存在这个问题 |
| **对象前缀** | 桶内可再套一层目录(如 `movielog/`)。前缀只在驱动层拼接,库中键始终干净。同一个桶给多个站点用时靠它隔离 |
### 2. 两种图片访问方式:怎么选
| | **直连存储**(direct) | **服务端代理**(proxy) |
| --- | --- | --- |
| 图片 URL | `https://<bucket>.oss-….aliyuncs.com/movielog/uploads/…` | `/media.php?k=uploads/…`(同源) |
| 桶权限要求 | **必须公共读**(或对每个对象签名,但那样 URL 会过期) | **可以私有**,服务器用实例角色/AK 签名读取 |
| 防盗链白名单 | 需要包含本站域名,或保持「允许空 Referer」(本项目统一下发 `Referrer-Policy: same-origin`,跨域请求不带 Referer) | 不需要(浏览器根本不访问对象存储) |
| 跨域 CORS | 只有想在浏览器里**读像素**(裁剪)时才需要 | **完全不需要**(同源) |
| 谁能看到图 | 任何拿到 URL 的人 | 必须登录本站 |
| 流量路径 | 浏览器 ⇄ 对象存储(不走服务器带宽) | 浏览器 ⇄ 你的服务器 ⇄ 对象存储(**双倍流量、占服务器带宽**) |
| 额外延迟 | 无 | 每张图多一次服务器到存储的往返(同地域约 10–30 ms;跨地域约 60–100 ms) |
| 适用 | 对性能敏感、图片可公开、愿意维护桶策略 | **希望桶保持私有**、不想维护防盗链/CORS、图片需要登录才可见 |
> **建议**:个人/家庭站点优先选**服务端代理**——桶私有、免防盗链、免 CORS、裁剪天然可用。只有当图片确实可以公开、且希望省下服务器中转带宽与流量时,才用直连。
>
> 选择位置:**系统设置 → 图片访问方式**。切换后无需重启,立即生效(回滚就是切回另一项)。
### 3. 直连存储:对象存储必须怎么配(重点)
直连模式下,图片是**浏览器直接去对象存储取**的。因此下面每一项都会直接影响「图片能不能显示」。逐项过一遍。
#### ① 桶读写权限:必须允许匿名读取
浏览器请求对象存储时**不带任何身份**,所以桶(或该目录下的对象)必须允许匿名 GET。
| 云 | 控制台位置 | 要选的值 |
| --- | --- | --- |
| 阿里云 OSS | Bucket → **读写权限**(ACL) | **公共读** |
| 腾讯云 COS | 存储桶 → **权限管理** → 访问权限 | **公有读私有写** |
| 七牛 Kodo | 空间设置 → 访问控制 | **公开** |
⚠️ 改成公共读意味着**任何人拿到 URL 就能看图**。如果存的是私人内容,请用服务端代理而不是直连。
#### ② 防盗链(Referer 白名单):开了就必须放行本站
防盗链是对象存储的「只允许特定来源引用」策略。**开了但没把本站域名加进白名单,就会出现最迷惑的现象**:
> 把图片 URL 直接粘到浏览器地址栏 → **能打开**;同一个 URL 放在页面 `<img>` 里 → **403**。
原因是浏览器对子资源(`<img>`/`<script>`)会带上当前页面的 `Referer`,而地址栏直接访问不带。**这几乎总是防盗链造成的,不是图片坏了。**
| 云 | 控制台位置 |
| --- | --- |
| 阿里云 OSS | Bucket → **数据安全** → 防盗链 |
| 腾讯云 COS | 存储桶 → **安全管理** → 防盗链设置 |
| 七牛 Kodo | 域名管理 → **防盗链** |
配置要点:
1. 把站点域名加进白名单,例如 `movie.example.com`,需要子域时写 `*.example.com`。
2. **不要图省事直接关掉防盗链** —— 如果这个桶还给别的用途(比如当备份盘)用,加白名单是加法,更安全。
3. 确认「**允许空 Referer**」是开着的(各家默认开启)。本项目 `config.php` 里统一下发了 `Referrer-Policy: same-origin`,即**跨域请求不带 Referer、同源请求照常带**,所以即使白名单里漏配了本站,页面里的图片也能正常显示。这算一层兜底,但**不要拿它替代白名单**。
#### ③ 跨域 CORS:只有「裁剪 / 读像素」才需要
这不是防盗链,是**另一个开关**,两者经常被混为一谈。
浏览器里 `cropper.getCroppedCanvas()` + `canvas.toDataURL()` 需要**读取图片的像素**。如果图片来自别的域名且未获授权,Canvas 会被标记为「被污染」(tainted),读取时抛 `SecurityError`,表现就是「点了裁剪保存没反应 / 弹错误」。
**要让直连模式下的裁剪可用,必须同时满足两件事**(缺一不可):
1. **桶上配置 CORS 规则**:来源 `https://你的域名`,方法 `GET`(`HEAD` 建议一起),允许的请求头建议 `*`,暴露的响应头至少 `ETag`、`Content-Length`,缓存时间 600 秒。
2. **图片标签声明跨域模式**:`<img src="…" crossorigin="anonymous">`。**只配 CORS 而不加这个属性,Canvas 依然会被污染**;反之只加属性而不配 CORS,图片会**直接加载失败**(比不能裁剪更糟)。
> ⚠️ **本仓库当前没有给图片加 `crossorigin="anonymous"`**,因此**直连存储模式下裁剪功能不可用**,只能上传原图、不能二次裁剪。
> 如果你需要裁剪,最省事的做法是**改用服务端代理**(图片同源,两个条件全免);坚持直连则要自行在 `add.php` / `edit.php` / `persons.php` 的裁剪预览 `<img>` 上加 `crossorigin="anonymous"`,并先在桶上把 CORS 配对。
#### ④ 强制下载(`x-oss-force-download` / `Content-Disposition: attachment`)
桶级「强制下载」**不影响 `<img>` 正常显示**(浏览器对子资源忽略该头),但会让「点击图片看原图」变成弹下载框。想恢复成预览就把桶的强制下载关掉。
#### ⑤ Endpoint 必须与桶所在地域一致
`oss-cn-beijing.aliyuncs.com` 这类 endpoint 里的地域**必须和桶实际所在地域对应**,否则报
`The bucket you are attempting to access must be addressed using the specified endpoint`。
Endpoint 在 OSS 控制台「概览」页可以看到,填写时**不要带 `https://`**。
#### ⑥ 自定义域名 / CDN(可选)
绑定了自定义域名或 CDN 才填;填了之后图片 URL 会使用该域名。**填一个没绑定的域名会直接报错**(`CnameDenied` / `SecondLevelDomainForbidden`)。七牛云**必须**绑定域名(它不提供默认外链域名)。
#### ⑦ 直连模式检查清单
- [ ] 桶 ACL = 公共读 / 公有读私有写 / 公开
- [ ] 防盗链白名单包含本站域名,或该桶未开防盗链
- [ ] 桶地域与填写的 endpoint 地域一致
- [ ] 系统设置页点「测试连通性」→ 提示可读写
- [ ] 首页 / 详情 / 编辑预览 / 统计 / 年度报告 / 头像 逐页看图
- [ ] 需要裁剪的话:桶 CORS + `<img crossorigin="anonymous">` 两者都到位
- [ ] 知道这个桶的图片对任何拿到 URL 的人可见
### 4. 服务端代理是怎么回事
图片 URL 变成 `/media.php?k=uploads/posters/xxx.webp`,请求打到本站,由服务器去对象存储取,再**同源**吐给浏览器。因此:桶可以私有、不需要防盗链、不需要 CORS。
`media.php` 做对的几件事(都值得保留,改动时别退化):
- **未登录直接 403 纯文本**(刻意不用 302 跳登录页 —— 否则 `<img>` 会拿到一段 HTML,控制台刷满「HTML 当图片解析失败」的噪声,掩盖真正原因)。
- **鉴权后立刻 `session_write_close()`**:PHP 的会话文件锁是**排他**的,如果一直持有到脚本结束,浏览器并行为一张张图片开的连接会全部串行等待,页面图片越多越慢(见下方坑 5)。
- **对象键白名单**:只允许 `uploads/{posters,tickets,photos,avatars}/` 下的纯文件名,并显式拒绝 `..`。否则 `?k=../config.php` 就是任意文件读取。
- **Content-Type 由本地扩展名白名单决定**,绝不采信上游返回的类型。否则桶里若被塞进 `.html` / `.svg`,就会以 `text/html` 在**本站域名下**执行(同源 XSS)。同时下发 `X-Content-Type-Options: nosniff`。
- **条件请求透传**:`If-None-Match` / `If-Modified-Since` / `Range` 转给上游,上游回 304 就短路返回,省流量。
- **长缓存**:对象名是内容指纹,永不原地变更,所以下发 `Cache-Control: private, max-age=31536000, immutable`(`private` 必须有,因为内容是按登录会话授权的)。
#### 内网 endpoint 只在与桶同地域时可用
服务器与桶**在同一地域**时,把请求地址换成 `oss-<地域>-internal.aliyuncs.com` 可以走内网:**流量免费、延迟更低**。设置页 OSS 面板有「服务端读取走内网 endpoint」开关。
但注意:**内网 endpoint 只在与桶同地域的 ECS 上可路由**。跨地域时 DNS 会照常解析出一个地址(毫秒级,看起来毫无异常),TCP 却**永远连不上**。所以程序会:
1. 先比**本机 ECS 地域**(实例元数据 `region-id`)与**桶地域**,不同就直接跳过内网(零成本、结论确定);
2. 地域相同或无法判定时,才真的发一个签名 `HEAD` 到桶根探测一次(连接超时 2 秒);
3. 结论缓存起来,并显示在**系统设置 → 当前状态 →「服务端读取通道」**;
4. 万一判断失效,内网尝试只等 2 秒就回退公网(不是默认的 10 秒)。
### 5. 迁移已有图片到云端
**系统设置 → 图片存储 → 迁移**:
1. 先把本地 `uploads/` 与 `db/` **备份一份**(迁移会改写数据库里的图片引用)。
2. 迁移是**分批**执行的(前端循环调用 `storage_api.php?action=migrate` 并显示进度),中断了可以直接重跑——已迁移的记录会被跳过,不会重复计数。
3. 迁移过程中会把文件重命名为内容指纹名,并**同步改写数据库引用**。
4. 迁移完成后**本地文件不会自动删除**,因为读取是「本地优先」的:本地还在就继续走本地,删掉本地文件后才真正走云端。**删本地文件前先全站看图确认。**
### 6. 踩坑记录(按现象索引)
#### 坑 1 · 图片在页面上 403,URL 粘到地址栏却能打开
**对象存储的防盗链(Referer 白名单)**。浏览器对 `<img>` 会带 `Referer`,地址栏直粘不带。
→ 解法见 §3②(加白名单);临时绕法是在统一引导文件下发 `Referrer-Policy: same-origin`(本项目已做);根治是改用服务端代理。
**诊断**:`curl` 分别带 / 不带 `Referer` 各打一次,对比状态码,并把 403 的响应体打出来 —— 里面会直接写 `You are denied by bucket referer policy.` 之类的原始判决。
#### 坑 2 · 迁到云端之后,某些图「整块消失」或 404
代码还在把数据库里的**对象键当本地路径**拼:`file_exists(__DIR__.'/'.$path)` 在迁云后必然为假 → **整个图库区块不渲染**(比 404 更隐蔽,因为页面上什么都没有);`'/'.$path` 则会拼出一个不存在的站内路径 → 404。
→ 解法:所有地方统一走门面 `storageExists()` / `storageUrl()`。**只要绕过门面直接拼路径,迁云后就一定会出问题**,改动时务必全局搜一遍。
#### 坑 3 · 防盗链修好了,裁剪还是不能用
**裁剪要的是 CORS,不是 Referer。** 两者是不同的开关。
→ 见 §3③(桶 CORS + `<img crossorigin>`);或者直接切服务端代理。
#### 坑 4 · 开了服务端代理后「慢到无法接受」
**内网 endpoint 跨地域**:服务器在 `cn-qingdao`、桶在 `cn-beijing`,程序却按桶的地域推导出 `oss-cn-beijing-internal.aliyuncs.com` 并「内网优先」,于是**每张图都先白等满一次连接超时(默认 10 秒)**才回退公网。
**这个地址的 DNS 是能解析的**(0.001 秒返回 A 记录),所以从日志上看不出任何异常,只会看到「很慢」。
→ 解法见 §4「内网 endpoint 只在与桶同地域时可用」。本仓库已改为「先比地域、结论缓存、内网只等 2 秒」。
#### 坑 5 · 图片数量越多越慢,慢得不成比例
**会话锁**。代理页都要登录,`session_start()` 在请求开头;PHP 的会话文件锁是**排他**的,若持有到脚本结束(而取存储的耗时操作就在锁内),浏览器为同源图片同时开的约 6 条连接会**全部卡在同一把锁上**——总耗时 ≈ 图片数 × 单张耗时。
→ 解法:鉴权通过后立刻 `session_write_close()`。本仓库 `media.php` 已处理。
#### 坑 6 · 控制台刷满「图片解析失败」,但服务端一切正常
代理页未登录时返回了 **302 跳登录页**,`<img>` 拿到的是 HTML。
→ 解法:图片接口未授权要返回 **403 纯文本**;前端再加一层坏图占位兜底。
#### 坑 7 · 想当然地平铺上游的 `Content-Type`,等于开了同源 XSS
桶里若被放进 `.html` / `.svg`,并且你把它返回的类型原样透传,浏览器会以 `text/html` 在**本站域名下**执行它。
→ 解法:**只按扩展名白名单**决定响应类型,并加 `X-Content-Type-Options: nosniff`。
#### 坑 8 · `?k=../config.php` 能读到任意文件
对象键没有校验,`k` 参数直接被拼进存储请求。
→ 解法:严格白名单(限定目录前缀 + 字符集 + 拒绝 `..`)。
#### 坑 9 · JSON 接口突然解析失败,报 `Unexpected token '<'`
`display_errors` 开着时,PHP 的 **Warning** 会以 HTML 形式打在 JSON 前面。触发源常常是宝塔等环境注入的 `.user.ini`:`open_basedir=/站点目录/:/tmp/`,此时对 `/etc/ssl/…` 这类**站外路径**调用 `is_file()` 会抛 `open_basedir restriction in effect` 警告。
→ 解法:探测系统文件之前**自己先判断 `open_basedir` 允不允许**(`storagePathAllowed()`),并且所有探测加 `@` 抑制。生产环境还应关闭 `display_errors`。
#### 坑 10 · 用命令行排查时「图片根本不在云上」——假象
`db/.settings.key` 的属主是 Web 用户(如 `www`,权限 600)。换一个用户跑 `php` 或 PHP 脚本,**解密失败** → 读出的云存储配置是空的 → 门面回退本地 → 表现为「404、耗时 0 秒」,很容易误判成「对象没上传成功」。
→ 解法:**必须以 Web 用户身份**跑诊断代码(临时放一个带随机 token 门禁、用完即删的诊断页,或用 Web 用户执行 CLI)。
#### 坑 11 · 桶名看错了一个字母,排查了半天
Bucket 名称是全局唯一标识,**字母一个都不能错**,而拼错时通常不会给出「你拼错了」这种提示,只会得到 404 / 403(阿里云会回 `NoSuchBucket`)。
→ 在响应体里找 `<BucketName>`,或直接以控制台实际显示为准,别凭记忆填写。
#### 坑 12 · 部署脚本「备份成功」但实际没备份
`cp -p` 多级路径(`lib/storage/oss.php`)到备份目录时,**子目录不存在会静默失败**,脚本继续往下走覆盖了线上文件,事后才发现没有可回滚的副本。
→ 解法:备份循环里先 `mkdir -p "$BK/$(dirname "$f")"`;备份后**递归统计文件数并与预期条数比对**,数量不符立即中止(这是覆盖前的最后一道止损闸);覆盖后再逐文件比对 sha256。
### 7. 排障手册(可直接复制的命令)
```bash
# ① 判断是不是防盗链:A / B 对比(把 URL 换成你的)
URL="https://<bucket>.oss-cn-xxx.aliyuncs.com/movielog/uploads/posters/xxx.webp"
curl -sS -o /dev/null -w "无 Referer %{http_code}\n" "$URL"
curl -sS -o /dev/null -w "带本站 Referer %{http_code}\n" -H "Referer: https://你的域名/" "$URL"
curl -sS -o /dev/null -w "带陌生 Referer %{http_code}\n" -H "Referer: https://evil.example.com/" "$URL"
# 403 时把响应体打出来,里面有云厂商的原始判决
curl -sS -H "Referer: https://你的域名/" "$URL"
# ② 是否配了 CORS(裁剪用)
curl -sS -D - -o /dev/null -H "Origin: https://你的域名" "$URL" | grep -i "access-control"
# ③ 在服务器上量内外网可达性与耗时(诊断「代理很慢」第一步)
curl -o /dev/null -s -w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} total=%{time_total} code=%{http_code}\n' \
--max-time 20 https://<bucket>.oss-cn-xxx-internal.aliyuncs.com/
curl -o /dev/null -s -w 'public conn=%{time_connect} tls=%{time_appconnect} total=%{time_total} code=%{http_code}\n' \
--max-time 20 https://<bucket>.oss-cn-xxx.aliyuncs.com/
# ④ 本机 ECS 地域(与桶地域对比,不同就别走内网)
curl -s --max-time 5 http://100.100.100.200/latest/meta-data/region-id
# ⑤ 端到端计时:在**服务器本机**打自己的站点,排除公网 RTT
# (Cookie 用浏览器里已登录的 PHPSESSID)
curl -sk -H "Host: 你的域名" -H "Cookie: PHPSESSID=xxxx" -o /dev/null \
-w "ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n" \
"https://127.0.0.1/media.php?k=uploads/posters/xxx.webp"
```
**读结果的方法**:`time_namelookup` 很快但 `time_connect` 拿不到 → 目标不可路由(典型就是跨地域内网);
单张图服务端耗时应当在**几百毫秒内**;若接近 10 秒、或总耗时随图片数量线性增长,回去看坑 4 与坑 5。
### 8. 部署与运维注意
- **改桶权限前先确认代码已上线**:直连↔代理的切换顺序建议「先部署代码 → 切访问方式 → 确认图片正常 → 最后才改桶 ACL / 防盗链」,每一步都可单独回滚。
- **代理模式会占服务器出网带宽**:图片流量走一遍服务器。用「阿里云内网 endpoint + 同地域 ECS」可以免掉云端到服务器的流量费;跨地域则要计费。
- **`uploads/` 与 `db/` 不要暴露**:见《安全注意事项》。
- **迁移/删除本地文件是有损操作**:先备份 `db/` 与 `uploads/`,再动手。
---
## 安全注意事项
部署到公网前请务必处理以下事项:
1. **修改默认口令**:默认账号 `admin / 123456` 必须在首次登录后立即更换。
2. **更换 OMDb API Key**:`fetch_movie.php` 中当前硬编码的 Key 为开发期临时使用,请替换为你自己的 Key;若仓库需要公开,建议将该值改为从环境变量读取。
3. **删除或保护 `test_crop.php`**:该文件是开发调试页,**没有登录校验**,仅用于验证 Base64 图片能否落盘,生产环境应直接删除。
4. **关闭调试输出**:`config.php` 顶部当前为 `error_reporting(E_ALL)` + `display_errors=1`,上线前请改为关闭或仅写日志,避免泄露路径与数据库信息。
5. **启用 HTTPS 并校正证书校验**:`fetch_movie.php` 中 `CURLOPT_SSL_VERIFYPEER` 被设为 `false`,生产环境应改为 `true`。
6. **保护数据目录**:`db/`(含 `movies.db`)与 `uploads/` 应禁止直接目录列举;SQLite 文件更应通过 Web 服务器规则**拒绝任何 HTTP 访问**。Nginx 示例:
```nginx
location ~ ^/(db|uploads/.*\.(db|sqlite)) { deny all; }
```
7. **限制上传目录执行权限**:确保 `uploads/` 下不允许执行 PHP。
### 与对象存储相关的额外事项
8. **桶不要长期开公共读**:直连存储要求桶允许匿名读取,等于**任何拿到 URL 的人都能看图**。私人 / 家庭内容请改用「服务端代理」,把桶设为**私有**——那才是真正的访问控制。
9. **防盗链不是访问控制**:`Referer` 是客户端可伪造的普通请求头,`curl -H Referer` 就能绕过。它只能挡住「别的网站盗链」,不能保护内容。别把它当成安全边界。
10. **保护好 `db/.settings.key`**:云厂商 AccessKey 以密文存在数据库里,这个文件是唯一的解密密钥。应随 `db/` 一起纳入访问控制与备份策略;优先改用实例角色(RAM / CAM 角色)**免密钥**方案,从根上不落长期密钥。
11. **`media.php` 是新增的对外接口**:它按 `k` 参数从对象存储取对象。仓库里已做**对象键白名单**(防目录穿越)与**类型白名单**(防同源 XSS)两层校验,改动此文件时**务必保留**,不要把 `$_GET['k']` 直接拼进存储请求。
12. **不要把上游 `Content-Type` 原样透传**:桶里若有 `.html` / `.svg`,透传类型会让它在本站域名下执行(同源 XSS)。始终按扩展名白名单决定,并加 `X-Content-Type-Options: nosniff`。
---
## 已知限制与待改进
- `movies.photo_path` 与 `viewers.name` 是历史遗留字段,保留用于兼容旧数据与 CSV 导入,新代码统一使用 `movie_photos` 与 `person_id`。
- 除 `delete.php` 通过 GET 触发(带前端 `confirm`)外,其余写操作均为 POST;删除类接口未做 CSRF Token 校验。
- 暂无分页,数据量增大后首页与统计页需要补充分页或懒加载。
- `config.php` 的 `initDB()` 在每次请求都会跑一遍建表与迁移,数据量或并发上升后可改为一次性迁移脚本。
- 无「回收站」,删除电影与其图片文件不可恢复。
- `assets/placeholder-poster.jpg` 仍是代码中的兜底引用,**仓库未包含该文件**;页面在检测到 placeholder 时会改用 CSS 占位块渲染,正常使用不受影响。`assets/default-avatar.png` 已随仓库提供(默认头像),并在资源缺失时由 `getAvatarUrl()` 退化为内联 SVG 剪影。
- **「直连存储」模式下裁剪功能不可用**:裁剪需要读取图片像素,直连时图片来自对象存储域名,必须同时满足「桶配 CORS」与「`<img crossorigin="anonymous">`」两个条件才能读像素;本仓库未加 `crossorigin` 属性,因此直连模式下只能上传、不能二次裁剪。需要裁剪请改用「服务端代理」(同源,两个条件全免)。详见《图片存储与对象存储》§3③。
- **服务端代理模式下所有图片流量经服务器中转**,会占用服务器出网带宽,并给每张图增加一次服务器↔存储的往返(同地域约 10–30 ms,跨地域约 60–100 ms)。图片量大且可公开时可考虑改回直连。
- **内网 endpoint 只在服务器与桶同地域时生效**。跨地域部署下这条免费通道用不了,程序会自动跳过并走公网(详见 §4)。
- 迁移到云端后**本地文件不会自动删除**,需要自行确认后再清理;本地优先策略意味着本地文件存在时始终读本地,云端副本仅作备份。
---
## 版本控制说明
以下内容为运行时产物或敏感数据,**不纳入版本控制**(见 `.gitignore`):
- `db/`(SQLite 数据库文件)
- `uploads/`(用户上传的图片)
- `*.log`、编辑器与系统临时文件
---
## 许可
个人项目,未指定开源许可。