Files
MovieLog_Server/README.md
T
laoyang 558c0db576 评价感受「感动」的图标换成 💖,修缺字形
🥹(U+1F979,含泪微笑)是 Emoji 14.0 才新增的码位(2021-09),
较旧的系统 emoji 字体里没有这个字形——例如本机 Segoe UI Emoji 的
cmap 里就没有 U+1F979,Windows 上页面的「感动」图标就显示成空白。
其余 11 个感受用的都是 Emoji 1.0~11.0(2010–2018)的老码位,显示正常。

现换成 💖(U+1F496,Emoji 1.0 / 2010),各平台字体都有字形。
感受在库里只存 key(moved),emoji 与文案统一在 feelingCatalog() 里,
所以这是纯显示层变更:不需要数据迁移,已有的评价完全不受影响。

同时补了一条约定注释:以后加新感受只用 Emoji 11.0(2018)及以前的
老码位,并在注释里一律写码位而不写 emoji 字符本身。

- lib/reviews.php  feelingCatalog() 中 moved 的 emoji;新增约定注释
- README.md        同步感受清单里的图标
2026-09-22 15:07:10 +08:00

697 lines
53 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` 鉴权后由服务器读取回吐,**桶可保持私有**)。两者的取舍与配置见下文《图片存储与对象存储》。
### 信息辅助
- **豆瓣自动获取**:在添加页输入片名时会**实时下拉联想候选**(带海报缩略图、年份、电影/剧集标注),**不必把片名打全**——从候选里点一条即可;也可以直接按回车或点「📡 自动获取」由服务端挑最匹配的条目。自动填充年份、地区、类型、片长、导演、主演、豆瓣评分、又名与剧情简介,**并把海报下载入库**——提交时若未手动上传图片,就直接采用自动获取的海报。
- **CSV 批量导入**:一次性导入历史数据,自动识别 UTF-8 / GBK / GB2312 编码,自动创建不存在的影院,影院名去重,全程事务保护(出错自动回滚),并输出逐行错误报告。
### 检索与统计
- **首页快捷搜索**:按片名模糊搜索。
- **高级搜索**:片名 + 影院 + 观影人 + 日期区间,多条件组合筛选。
- **统计仪表板**:总览卡片(电影数 / 影院数 / 观影人数 / 观影人次 / 时间跨度),按影院、按观影人、按年份、最近 12 个月的条形图排行。
### 年度报告
- **个性化年度报告**:以「当前登录账号所关联的观影人」为视角生成,而非全站数据。
- **报告内容**:年度观影总数、观影天数、最常去的影院、最常一起观影的人、单场最多人的电影、首末场日期、月均频率、平均间隔天数、备注关键词云、今年共同观影人排行、年末最近观看、年度观影记录墙(含海报与同行人头像)。
- **可视化**:月度观影分布、星期观影偏好(Chart.js 双图),整体为深色"影院感"主题。
### 多用户与权限
- **Session 登录**:`password_hash` / `password_verify` 加盐哈希存储口令,全站页面均需登录。
- **用户管理**:可新增、编辑、删除管理员账号,可修改用户名与密码。
- **账号 ↔ 观影人绑定**:每个管理员账号可关联一个观影人,绑定后即可查看个人年度报告。
- **安全约束**:不允许删除最后一个管理员,也不允许删除自己。
- **个人资料**:修改自己的用户名与密码(需验证旧密码)。
### 评价与讨论
- **谁可以评价**:**只要有登录账号就能评价**,与「是不是观影人」无关——没一起去看过的人也能打分、写评价、参与讨论。评价的归属是账号(`admins`),不是观影人(`persons`)。
- **打分 1-10**:详情页「💬 评价与讨论」区块里点数字即可打分,右侧实时显示「7 分 · 不错」;打分用的是原生单选框,禁用 JS 也能照常提交。
- **写评价**:一段正文(最多 5000 字)。**每个账号对每部电影只有一条评价**,再次提交即修改,页面上会标注「已编辑」。
- **讨论**:任意账号都能在任意一条评价下面参与讨论(正文最多 1000 字)。
- **观后感受(可多选)**:**写评价时**和**发讨论时**都可以勾选自己的观后感受,每种感受一个专属 emoji:😄 开心、💖 感动、🥰 温暖、🤩 激动、🤔 深思、😰 紧张、😨 恐惧、😲 震惊、😢 难受、😠 气愤、😴 无聊、😵 困惑。勾选多少个都行(最多 6 个),展示时按固定顺序排列。
- 评价上的感受会显示在这条评价正文下面;修改评价时原来勾的会自动带上,取消勾选即清空。
- 感受只把 key(如 `happy`)存进数据库(`reviews.feelings` / `review_comments.feelings`),emoji 与文案统一在 `lib/reviews.php` 的 `feelingCatalog()` 里维护,**以后换 emoji / 改文案都不用动数据**。
- **评分概况**:详情页标题栏显示「N 条评价 · 均分 X」;首页列表卡片上有评价的电影会显示 `⭐ 均分`。
- **删除**:只能删除**自己**的评价与讨论;删除评价时其下的讨论一并删除。删除电影时也会清掉对应的评价与讨论(SQLite 默认未开启外键级联,由程序手动清理)。
### 基础数据维护
- **影院管理**:增删改查,支持「所在地方 / 影院名称 / 详细地址」三段式信息,内置 50 种预设配色,每个影院可用专属颜色区分;删除已被引用的影院时会给出醒目提示并要求二次确认(关联电影的影院字段将被置空)。
- **观影人管理**:增删改查,支持头像裁剪上传与专属配色;已被电影引用的观影人不允许直接删除。
### 界面与体验
- 复古票根色系(主色 `#e67e22` 橙)的响应式设计,移动端断点适配(600 / 768px)。
- 导航为下拉菜单,自动高亮当前页;顶部始终显示当前用户头像与用户名。
- **点导航栏左上角的「🎬 电影日志」即可回到首页**(右键 / Ctrl+点击还能在新标签页打开),不必再展开右侧折叠菜单去找「首页」。
- 登录页为深色渐变 + 毛玻璃风格。
- **详情页图片用本页灯箱查看**:海报、票根、观影照片串成一个序列,点任一张即在当前页弹出(不再 `window.open` 到新窗口),**左右箭头翻看并循环**,底部显示「3 / 8」进度与图片说明。
可按 **← / →** 翻页、**Esc** 关闭、点画面空白处关闭,手机上还能**左右滑动**切换;弹出时锁定背景滚动,并从相邻位置预取下一张,翻页不白屏。
只有一张图时不显示左右箭头;关闭后焦点会回到原来点开的那张图上。
---
## 技术栈
| 项目 | 说明 |
| --- | --- |
| 语言 | 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 | [豆瓣](https://movie.douban.com/)(公开网页接口,**无需申请 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 # 电影/影院读取、搜索,以及表单关联数据的解析
│ ├── reviews.php # 评价与讨论:打分 1-10、正文、讨论、观后感受目录(emoji)
│ └── 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 # 豆瓣接口(片名候选 / 详情 + 海报入库 / 缩略图代理),返回 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 → settings → storage → helpers → auth → movies → reviews → 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` |
| `reviews` | 电影评价 | `movie_id`、`admin_id`(评价人是账号,不是观影人)、`rating`(1-10)、`content`、`feelings`(写评价时选的观后感受,同样是 JSON 数组)、`created_at`、`updated_at`;**`(movie_id, admin_id)` 上有唯一索引**,保证一个账号一部电影只一条 |
| `review_comments` | 评价下的讨论 | `review_id`、`admin_id`、`content`、`feelings`(感受 key 的 JSON 数组,如 `["happy","sad"]`)、`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`)、`reviews.movie_id → movies.id`(`ON DELETE CASCADE`)、`review_comments.review_id → reviews.id`(`ON DELETE CASCADE`)。
> SQLite 默认是**关闭**外键约束的(`PRAGMA foreign_keys` 未开启),上面这些 `ON DELETE CASCADE` 实际不会生效。因此删除电影、删除评价时,程序都会**显式地把子表数据一并清掉**(见 `delete.php` 与 `lib/reviews.php`),不要依赖级联。
**历史数据迁移**:`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. **确认「自动获取」可用(可选)**:该功能由服务端直接请求豆瓣的公开网页接口实现,**无需申请任何 API Key**。只需保证:
- PHP 已启用 `curl` 扩展(本项目的图片下载、对象存储也都依赖它);
- 服务器能出网访问 `movie.douban.com`、`m.douban.com` 与 `img*.doubanio.com`。
若服务器网络受限导致请求失败,「自动获取」会返回明确提示,但**不影响其它功能**——照常手动填写信息、上传海报即可。
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. 未绑定观影人时报告为空并给出提示。
### 打分、写评价与参与讨论
1. 打开任意一部电影的详情页,滚到最下面的「💬 评价与讨论」。
2. **打分**:点「我的评分」后面的 1-10 任意一个数字(选中会变橙色),旁边实时显示「几分 · 怎么样」。
3. **写评价**:在下面文本框里写正文;再到「我看完的感受(可多选)」里勾几个感受 emoji,点「📝 发表评价」。
- 已经评价过时,这一块会变成「✏️ 修改我的评价」,按钮是「💾 保存修改」——同一部电影不会留下两条你的评价,**原来勾的感受会自动带上**,取消勾选就是清掉。
- 评价保存后,感受会以只读小标签显示在这条评价正文下面。
4. **参与讨论**:在别人的(或自己的)任意一条评价下面,写一句话,同样可以勾选感受,点「💬 发表讨论」。
- 讨论会带上评价人的头像、昵称,以及你选的感受 emoji。
5. **删除**:自己发的评价和讨论,右侧有删除按钮(别人的没有);删评价时下面的讨论会一起删掉。
> 评价人是**登录账号**。家里几个人各有一个账号时,同一部电影可以各自打分,标题栏会显示「N 条评价 · 均分 X」。
### 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`
供添加页前端调用,服务端代请求豆瓣(既避免浏览器跨域,也复用服务器的出网能力)。
共 **4 种模式**,全部**需要登录**(未登录一律返回 `HTTP 401` + `{"error": "…"}`):
| 模式 | 请求 | 作用 | 是否写文件 |
| --- | --- | --- | --- |
| 1. 按片名抓取 | `?q=片名[&old_poster=键]` | 搜索 → 取详情 → 下载海报入库 | 是(写海报) |
| 2. 按条目 id 抓取 | `?id=豆瓣条目id[&old_poster=键]` | 跳过搜索,直接取详情 | 是(写海报) |
| 3. 下拉候选 | `?action=suggest&q=关键词` | 只回候选列表 | **否** |
| 4. 缩略图代理 | `?action=thumb&u=图片地址` | 代取豆瓣缩略图字节 | **否** |
**模式 1 / 2**(抓取详情并把海报入库)
- **参数**
- `q`:片名,中文或英文均可(英文名会命中对应的中文条目)
- `id`:豆瓣条目 id(纯数字)。下拉选中后走这条路,**省一次搜索,也不会「再搜一次选错条目」**
- `old_poster`:可选。用于清理「上一次抓取、但本次未采用」的海报文件,前端在重复获取时自动回传
- **成功响应**:
```json
{
"ok": true,
"source": "douban",
"douban_id": "34780991",
"douban_url": "https://movie.douban.com/subject/34780991/",
"title": "哪吒之魔童闹海",
"original_title": "",
"year": "2025",
"genre": "剧情 / 喜剧 / 动画",
"runtime": "144分钟",
"director": "饺子",
"actors": "吕艳婷 / 囧森瑟夫 / 瀚墨",
"plot": "……剧情简介……",
"rating": "8.4",
"country": "中国大陆",
"alias": "哪吒2 / 哪吒2之魔童闹海 / Ne Zha 2",
"poster": "/uploads/posters/xxxxxxxxxxxx_xxxxxx_poster.jpg",
"poster_key": "uploads/posters/xxxxxxxxxxxx_xxxxxx_poster.jpg"
}
```
- **字段说明**:
- `poster` 是给 `<img>` 预览的地址。海报已由本接口**下载入库**,所以它是本站同源地址(或存储直链),不是豆瓣的外链。
- `poster_key` 是提交表单时应写入 `movies.poster_path` 的存储键。前端把它放进隐藏域 `fetched_poster_key` 回传;
提交时**手动上传的海报优先**,没有才采用该键。键的目录与文件名、以及文件是否真实存在,都由服务端校验。
- `rating` 为豆瓣评分(保留一位小数)。无评分、或该片暂无海报时,对应字段为空串。
**模式 3**(片名下拉联想的候选列表;**不取详情、不写任何文件**,可随输入频繁调用)
- **请求**:`GET fetch_movie.php?action=suggest&q=关键词`
- **成功响应**:
```json
{
"ok": true,
"items": [
{
"id": "34780991",
"title": "哪吒之魔童闹海",
"sub_title": "哪吒之魔童闹海",
"year": "2025",
"episode": "",
"kind": "电影",
"thumb": "fetch_movie.php?action=thumb&u=https%3A%2F%2Fimg9.doubanio.com%2F..."
}
]
}
```
- **说明**:
- `q` 为空时直接返回 `{"ok": true, "items": []}`,**不会请求上游**;上游无结果时同样返回空数组(不算错误)
- 会过滤掉「影人」类条目、按条目 id 去重,默认**最多 8 条**
- `kind` 由豆瓣的 `episode`(集数)推断:有集数即「剧集」,否则「电影」
- `thumb` 是**本站代理地址**(见模式 4),不是豆瓣外链;该候选没有海报时为空串
- **失败**:上游请求失败时为 `{"error": "搜索失败:…"}`
**模式 4**(缩略图服务端代取)
- **请求**:`GET fetch_movie.php?action=thumb&u=<图片地址>`(`u` 需 URL 编码)
- **成功**:直接回吐图片字节,带 `Content-Type: image/*` 与 `Cache-Control: public, max-age=86400`
- **为什么要代理**:豆瓣图片有**防盗链**——只有 `Referer` 为豆瓣域名时才返回 200,其它一律 403。
浏览器直连(本站 `Referrer-Policy: same-origin`,跨域图片不带 Referer)必然被拒,所以只能由服务端带 Referer 代取。
- **安全**:该地址做了**严格白名单**(只允许 `https://img*.doubanio.com/view/photo/…jpg|png|webp`),
否则就成了 SSRF 跳板。非白名单地址返回 `HTTP 400`;目标不是图片返回 `HTTP 404`。
- **失败**:`400`(地址不合法)/ `404`(取不到或非图片)
**模式 1 / 2 的失败响应**:`{"error": "错误说明"}`(如「豆瓣没有找到「xxx」,换个更准确的片名试试」);未知 `action` 返回 `{"error": "不支持的 action:…"}`。
> **关于数据来源与合规**:本功能使用的是豆瓣的**公开网页接口**(非官方开放 API,也不需要 API Key),
> 按需抓取少量条目用于个人观影记录,并已加入登录鉴权。请自行评估使用场景与豆瓣的服务条款;
> 若需商用或大规模采集,请改用官方或商业数据源。
---
## 图片存储与对象存储(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. **「自动获取」会写入存储**:`fetch_movie.php` 已要求登录后才能调用,且只请求固定的豆瓣域名、只把文件写入 `uploads/posters/`;表单回传的 `fetched_poster_key` 与 `old_poster` 都要通过「目录前缀 + 文件名 + 数据库引用」三重校验才会被采用或删除。若你的部署不需要该功能,可直接删除 `fetch_movie.php` 并移除 `add.php` 里的按钮。
- **`action=thumb` 是服务端代取,必须保持白名单**:它以服务端身份去取 `u` 指定的图片,若放开任意 URL 就是**SSRF 跳板**。代码里 `validateDoubanImageUrl()` 只放行 `https://img*.doubanio.com/view/photo/…`,改动时**务必保留**。环境变量 `MOVIELOG_DOUBAN_IMG_ALLOW` 是给本地 mock 测试留的豁免口子(默认不设置即完全关闭),**生产环境不要设置**。
- **下拉联想的候选文本来自豆瓣,前端一律转义后再插入**:不要改成直接拼 `innerHTML`,否则豆瓣侧内容可注入脚本。
3. **删除或保护 `test_crop.php`**:该文件是开发调试页,**没有登录校验**,仅用于验证 Base64 图片能否落盘,生产环境应直接删除。
4. **关闭调试输出**:`config.php` 顶部当前为 `error_reporting(E_ALL)` + `display_errors=1`,上线前请改为关闭或仅写日志,避免泄露路径与数据库信息。
5. **保证 CA 证书可用**:图片下载与「自动获取」都依赖 `curl`,且 `fetch_movie.php` 已启用证书校验(`CURLOPT_SSL_VERIFYPEER = true`)。若服务器缺少 CA 证书束(`curl.cainfo` 为空,常见于最小化安装),请求会失败,请安装 `ca-certificates` 包。
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)。
- 迁移到云端后**本地文件不会自动删除**,需要自行确认后再清理;本地优先策略意味着本地文件存在时始终读本地,云端副本仅作备份。
- **下拉联想会随输入向豆瓣实时发请求**(已做 320 ms 防抖 + 会话内结果缓存 + 丢弃过期请求,同一关键词只查一次),但**服务端本身没有做接口级限流**。若担心被豆瓣限流,可把 `add.php` 里的 `DEBOUNCE` 调大,或让下拉只在按下「📡 自动获取」时才工作。
- **缩略图代理(`action=thumb`)每张图都要经服务器回源一次**。已加 `Cache-Control: max-age=86400`,同一张图在浏览器侧一天内只回源一次,正常使用量很小;若把下拉列表做得很大、或候选很多,建议改成在服务端落小图缓存。
---
## 版本控制说明
以下内容为运行时产物或敏感数据,**不纳入版本控制**(见 `.gitignore`):
- `db/`(SQLite 数据库文件)
- `uploads/`(用户上传的图片)
- `*.log`、编辑器与系统临时文件
---
## 许可
个人项目,未指定开源许可。