diff --git a/README.md b/README.md index ab45263..c3daff5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,15 @@ - **多图上传**:观影照片支持一次选择多张。 - **头像兜底**:未上传头像的观影人自动生成"首字母 + 配色"的 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 评分与剧情简介,并预览海报(海报仅供参考,仍需本地上传)。 @@ -72,6 +81,15 @@ MovieLog_Server/ ├── 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 # 统计仪表板与年度报告查询 @@ -91,10 +109,13 @@ MovieLog_Server/ ├── 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 # 公共脚本(含各页面共用的表单联动函数) +├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底) ├── assets/.gitkeep # 静态占位资源目录 ├── db/movies.db # SQLite 数据库(运行时自动生成,不纳入版本控制) └── uploads/ # 上传图片(运行时自动生成,不纳入版本控制) @@ -110,7 +131,7 @@ MovieLog_Server/ ## 数据库结构 -建表逻辑集中在 `config.php` 的 `initDB()`,每次请求都会执行 `CREATE TABLE IF NOT EXISTS` 与幂等的 `ALTER TABLE` 迁移,因此**升级代码无需手动改库**。 +建表逻辑集中在 `lib/db.php` 的 `initDB()`(由 `config.php` 在每次请求引导时调用),每次请求都会执行 `CREATE TABLE IF NOT EXISTS` 与幂等的 `ALTER TABLE` 迁移,因此**升级代码无需手动改库**。 | 表 | 作用 | 关键字段 | | --- | --- | --- | @@ -120,6 +141,15 @@ MovieLog_Server/ | `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`)。 @@ -133,6 +163,10 @@ MovieLog_Server/ - 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` 首次保存时在此生成)。 ### 步骤 @@ -160,7 +194,7 @@ php -S localhost:8000 # 浏览器访问 http://localhost:8000/login.php ``` -> 用 PHP 内置服务器时,`config.php` 中 `posterUrl()` 等函数返回的是以 `/` 开头的根路径,建议直接从项目根目录启动,或通过站点根目录访问。 +> 用 PHP 内置服务器时,`posterUrl()`(`lib/helpers.php`)与 `storageUrl()`(`lib/storage.php`)返回的是以 `/` 开头的站点绝对路径(如 `/uploads/posters/xxx.webp`),建议从项目根目录启动,或通过站点根目录访问。 ### Nginx 参考配置 @@ -227,6 +261,19 @@ CSV **共 9 列,顺序固定**: 阿甘正传, , 万达影城, 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 避免浏览器跨域。 @@ -253,6 +300,239 @@ CSV **共 9 列,顺序固定**: --- +## 图片存储与对象存储(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://.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 放在页面 `` 里 → **403**。 + +原因是浏览器对子资源(``/` + \ No newline at end of file diff --git a/config.php b/config.php index d7d117d..b451b61 100644 --- a/config.php +++ b/config.php @@ -11,6 +11,13 @@ ini_set('display_errors', 1); session_start(); +// 跨域资源引用策略:本站页面加载对象存储(OSS / COS / 七牛)等第三方域名的图片时, +// 浏览器不发送 Referer,避免被云厂商的防盗链(bucket referer policy)拦成 403。 +// 同源请求(本站自身的页面与接口)仍照常携带 Referer,不影响任何安全检查。 +if (!headers_sent()) { + header('Referrer-Policy: same-origin'); +} + // 项目根目录(lib/ 下的模块统一用它拼接绝对路径) define('ROOT_DIR', __DIR__); @@ -32,11 +39,13 @@ foreach ([POSTER_DIR, TICKET_DIR, PHOTO_DIR, AVATAR_DIR] as $dir) { } // ===== 功能模块 ===== -require_once ROOT_DIR . '/lib/db.php'; // 数据库连接与初始化 -require_once ROOT_DIR . '/lib/helpers.php'; // 通用工具函数 -require_once ROOT_DIR . '/lib/auth.php'; // 登录校验与账号管理 -require_once ROOT_DIR . '/lib/movies.php'; // 电影 / 影院 / 搜索 -require_once ROOT_DIR . '/lib/stats.php'; // 统计与年度报告 +require_once ROOT_DIR . '/lib/db.php'; // 数据库连接与初始化 +require_once ROOT_DIR . '/lib/settings.php'; // 系统设置读写 +require_once ROOT_DIR . '/lib/storage.php'; // 对象存储门面(含各云厂商驱动) +require_once ROOT_DIR . '/lib/helpers.php'; // 通用工具函数 +require_once ROOT_DIR . '/lib/auth.php'; // 登录校验与账号管理 +require_once ROOT_DIR . '/lib/movies.php'; // 电影 / 影院 / 搜索 +require_once ROOT_DIR . '/lib/stats.php'; // 统计与年度报告 // 初始化数据库 initDB(); diff --git a/detail.php b/detail.php index 759a702..404e488 100644 --- a/detail.php +++ b/detail.php @@ -345,13 +345,14 @@ $admin = getCurrentAdmin(); $movie['photo_path'], 'label' => '📸 旧照片']; } foreach ($movie['photos'] as $p) { - if (file_exists(__DIR__ . '/' . $p['photo_path'])) { + if (storageExists($p['photo_path'] ?? '')) { $all_photos[] = ['path' => $p['photo_path'], 'label' => '📸 观影照片']; } } @@ -361,14 +362,14 @@ $admin = getCurrentAdmin();

🖼️ 影像记录 张