Files
laoyang 690228e7c1 安装体检:修正 fileinfo 说明(只影响本机存储模式)
原先写「上传校验会更严格」是不准确的:OSS 存储模式下,附件校验走对象存储
返回的 Content-Type(lib/attach.php 的 oss 分支),根本不调用 finfo。真正用到
fileinfo 的只有本机存储分支(lib/attach.php 的 local 分支、media.php)和
media.php 的本机附件 MIME 探测。

改动:
- install.php 体检项说明改为「按文件内容识别附件类型(仅本机存储模式用得上)」,
  缺失文案补上「不影响安装」,补救提示写清 OSS 模式以对象存储返回的类型为准、
  与本扩展无关,避免管理员误以为装个扩展就补了个安全缺口。
- README 同步区分两种存储模式;补充两点排查经验:
  面板显示「有」不等于运行中的进程已加载,要看 php.ini 里的 extension 行;
  且改完 ini 必须重启/重载 PHP-FPM 才生效(reload 即可,不必 restart)。

验证:php -l 双版本通过;安装向导 60/60、主回归 25/25、PHP 8.5 兼容 19/19;
线上部署 17/17,6 个文件 md5 逐个一致,冒烟全绿。
2026-10-23 09:07:39 +08:00

774 lines
48 KiB
Markdown
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.
# 轻反馈 FeedLite(PHP + SQLite)
> **Lightweight feedback, made simple.** / 让每一条反馈都被听见。
一个**通用、轻量、自包含**的反馈系统:可以挂到任意软件、系统或开源项目的站点下,
让用户直接提交反馈与建议。任何人**无需注册**即可**发帖 / 回复**,只需昵称与邮箱(手机号选填);
页面**不展示**邮箱与手机号;有人回复时给发帖人**发邮件通知**。
新提交的反馈**先审后显**(默认不公开,管理员在 `admin.php` 决定公开或关闭并邮件告知,见 4.2 节)。
- 纯 PHP + SQLite,**无任何第三方依赖**(不需要 Composer、不需要 MySQL)
- 自带**趣味问答**人机验证(防机器人)+ 蜜罐字段 + 提交耗时校验 + 按 IP 频率限制
- 支持**图片 / 视频附件**:可存本机磁盘,或存**私有**阿里云 OSS 桶(走**内网 Endpoint + 服务器代理**,公网无法直连桶);不配置也可正常运行,只是不显示上传入口
- 自带只读 **API / Markdown 摘要**接口,方便自动化脚本或 AI 读取问题并分析
- 上手即用:clone 下来直接部署,无需建库、无需装依赖(含深色模式与响应式布局)
**适用场景**:某款软件 / 系统 / 开源项目的用户反馈区;团队内部的问题收集箱;独立开发者的建议与 Bug 收集。
---
## 一、目录结构
```
feedback/
├─ index.php 问题列表(分类 / 状态 / 搜索 / 分页 / 缩略图)
├─ thread.php 问题详情 + 回复(含"官方答复"与状态管理、附件展示)
├─ new.php 发布新问题(含图片 / 视频上传)
├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志)
├─ install.php 安装向导(数据库不存在时自动引导进来;装完建议删除)
├─ upload.php 附件上传接口(两步:领对象键 → 收文件本体,local 落盘 / OSS 代理 PUT)
├─ media.php 附件代理输出(图片/视频的**唯一对外读取通道**:无论存本机还是 OSS,前台地址都是 media.php?k=…,带鉴权与 Range)
├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON)
├─ api.php 只读数据接口(需令牌),供自动化 / AI 读取
├─ cron.php 附件过期清理 + 通知邮件补发(配计划任务)
├─ config.example.php ★ 配置模板:口令、令牌、SMTP、OSS、上传限额、分类等(= 出厂默认值)
├─ config.php 本地实际配置(**不参与版本管理**,从 config.example.php 复制而来;缺失时自动以模板为准)
├─ lib/ 内部库(bootstrap / db / util / oss / attach / captcha / mailer / layout)
├─ assets/ style.css / app.js / 图标(favicon、各尺寸 PNG、site.webmanifest)
├─ data/ SQLite 数据库目录(已用 .htaccess 拒绝访问)
├─ uploads/ 本机附件目录(仅"本机硬盘"模式下使用;PHP 按需自动创建;**目录禁止 Web 直连,附件只走 media.php**)
├─ .htaccess 禁止目录列表、禁止下载 .db/.md
└─ README.md 本文件
```
> **`settings` 表 vs `config.php`**:后台改的设置存数据库(`settings` 表),读取时覆盖 `config.php`。
> 所以 `config.php` 只是"初值",日常调整走后台即可,升级代码时也不必改它。
> 取值顺序:`settings` 表 > `config.php` > `config.example.php`(模板)> 代码内默认值。
---
## 二、部署(宝塔面板 + Apache)
1. 把整个 `feedback` 目录上传到站点根目录下,例如
`/www/wwwroot/你的域名/feedback/`
(对应访问地址:`https://你的域名/feedback/`)
2. 确认该站点使用的 PHP 版本 **≥ 7.4**(推荐 8.1~8.5;**8.5 已逐项实测,无弃用告警**),并已启用扩展:
**pdo_sqlite、sqlite3、openssl、mbstring**(宝塔默认均已有;在「软件商店 → PHP → 安装扩展」可查看)。
**curl 为可选**:有则优先用它和 OSS 通信,没有会自动退回 PHP 内置的流式请求,功能不受影响。
**fileinfo 为推荐**:**本机存储模式**下,上传时按文件**真实内容**判断类型(`finfo`);
没有也能正常用,但该模式下的类型校验会退化为「扩展名 + 浏览器声明的 MIME」。
注意:**OSS 存储模式**的类型判定走对象存储返回的 `Content-Type`,不经过 `finfo`,
所以这个扩展只影响本机存储。
⚠️ 新装一套 PHP(尤其宝塔里的 8.5)时 `fileinfo` 常常**默认没开**,从旧版本切过来要顺手检查一次。
面板上显示"有"不等于已在运行中的进程里加载 —— 要看 `php.ini` 里的 `extension=` 行,
而且**改完 ini 必须重启/重载 PHP-FPM** 才会生效(reload 即可,不必 restart)。
3. 给数据库目录写权限(宝塔一般已允许):
```bash
chown -R www:www /www/wwwroot/你的域名/feedback
chmod -R 755 /www/wwwroot/你的域名/feedback/data
```
4. 首次访问 `https://你的域名/feedback/` —— 此时 `data/` 下还没有数据库,
程序会**自动把你送到安装向导 `install.php`**(见 **第二节之二**)。按向导填完即装好。
5. 若你更愿意手工配置:从 `config.example.php` 复制一份为 `config.php`,改掉这三项后刷新页面即可建库。
- `admin_password` —— 管理员口令(回复时填入即以「官方」身份答复;也是审核后台 `admin.php` 的登录口令)
- `api_token` —— 读取接口的令牌(自己设一串随机字符)
- `ip_salt` —— 随机字符串(用于给访客 IP 做哈希)
6. 按需配置邮件(见第四节),并把 `smtp.enabled` 改为 `true`。
7. 如需让用户上传截图 / 录屏,按**第五节**配置阿里云 OSS;不配置也能正常用,只是不显示上传入口。
> 若站点是 **Nginx**,`.htaccess` 不生效,请在站点配置里加一条:
> ```nginx
> location ^~ /feedback/data/ { deny all; }
> location ~ ^/feedback/.*\.(db|md|log)$ { deny all; }
> ```
---
## 二之二、安装向导(`install.php`)
**什么时候出现**:只要 `data/` 下还没有数据库文件,访问站点的任何页面都会 302 到 `install.php`。
反过来,数据库一旦存在,向导就只显示「已经装好了」,**不会**覆盖任何数据。
向导分三步,一屏走完:
1. **环境体检** —— PHP 版本(≥7.4)、必需扩展(`pdo_sqlite` / `openssl`)、
`站点根 / data / uploads` 目录可写性、`config.example.php` 是否在。有任一项不过就拒绝提交。
2. **填写站点信息** —— 站点标题、站点描述、官方邮箱、站点根地址(后两项可留空)。
3. **设置管理员口令** —— 至少 8 位;也可以留空、装完后在后台补设(**补设之前后台登不进去**)。
提交后向导会:
- 生成 `config.php`(在 `config.example.php` 的**文本**上逐项替换,注释与 OSS / SMTP 段落原样保留);
- 自动填好 `api_token` 与 `ip_salt`(随机串,不用你操心);
- 建库建表;
- 把口令以 **bcrypt 哈希**写入数据库 `settings` 表 —— **明文不进 `config.php`、不进任何文件**。
**装完请做两件事**:
1. **删掉 `install.php`**(已安装态下它会拒绝重装,但少一个入口少一分风险)。
2. 若口令留空了,进后台「站点设置 → 登录口令」补设一个。
> 想重装(例如换了数据库位置):先手工移走 `data/feedback.db`,再刷新即可。
> 向导**不会**替你删除任何数据。
---
## 三、首页加链接
已在官网 `index.html` 的**顶部导航**与**页脚「产品」列**加入「问题反馈」入口,指向 `feedback/`。
(本地文件:`(你的站点首页)`)
### 页脚里有「管理后台」入口
站点页脚固定显示一个「管理后台」链接(指向 `admin.php`),不需要手动在地址栏敲文件名。
旁边那句「由 FeedLite 驱动」可以关掉:
- 后台「**站点设置 → 页脚**」→ 取消勾选「在页脚显示『由 FeedLite 驱动』」
→ 页脚只剩「管理后台」链接;
- 同一处还能改「FeedLite」二字指向的地址(默认是本项目开源仓库)。
只接受 `http://` / `https://` 开头的网址。
---
## 四、邮件通知(SMTP)
回复提交时会尝试给发帖人发送通知邮件。在 `config.php` 的 `smtp` 段填写即可,例如:
**阿里云邮件推送(推荐,需先开通并验证域名)**
```php
'host' => 'smtpdm.aliyun.com', 'port' => 465, 'secure' => 'ssl',
'user' => 'noreply@example.com', 'pass' => '你的SMTP密码',
'from' => 'noreply@example.com', 'from_name' => '轻反馈',
```
**腾讯企业邮**
```php
'host' => 'smtp.exmail.qq.com', 'port' => 465, 'secure' => 'ssl',
```
**QQ 邮箱 / 163 邮箱**
```php
'host' => 'smtp.qq.com'(或 smtp.163.com), 'port' => 465, 'secure' => 'ssl',
'pass' => '邮箱里生成的"授权码"(不是登录密码)',
```
- `secure = 'ssl'` 用 465 端口;`secure = 'tls'` 用 587 端口(STARTTLS);`''` 用 25 端口(不加密)。
- 若服务器缺少 CA 证书导致 TLS 报错,可临时把 `'verify_ssl' => false`。
- 通知策略在 `notify` 段:`on_every_reply = true` 表示**任何人回复都通知**发帖人;
改为 `false` 则**仅"官方"回复**才通知。
- 发送结果会记录在数据库 `notify_log` 表;失败的可用 `cron.php` 补发。
**可选:计划任务补发**(宝塔 → 计划任务 → Shell 脚本,每天一次)
```bash
php /www/wwwroot/你的域名/feedback/cron.php
```
---
## 四之二、审核发布(先审后显)
**默认行为**:访客提交的问题**不会立刻出现在公开列表里**。它先以"待审核"状态落到库中,
由管理员在审核后台决定**公开**还是**关闭**。这样公开区里只留下确认属于应用自身的问题,
其余的(环境差异、服务器限制、用法误解等)不占用版面,但**提问人一定会收到一封说明邮件**。
### 4.2.1 三种结果
| 管理员操作 | 数据库变化 | 提问人看到什么 |
|---|---|---|
| **通过并公开** | `visible = 1`;若之前是 `closed` 一并改回 `open` | 问题出现在公开列表,所有人可查看与回复 |
| **关闭并邮件告知** | `status = 'closed'`、`visible = 0`、写入 `mod_note` | 收到一封邮件:结论 + 管理员写的说明 + 原始反馈内容 |
| **撤下** | `visible = 0`(不改状态) | 从公开列表消失;管理员仍可在详情页看到 |
> 关闭时**必须填写说明**(留空会拒绝提交)——这段内容就是要发给提问人的正文,
> 所以请写得像"回复用户"而不是"内部备注"。
### 4.2.2 入口与登录
- 地址:`https://你的域名/feedback/admin.php`
- 口令:**初始就是 `config.php` 里的 `admin_password`**;登录后在「站点设置 → 修改登录口令」里改掉它,
新口令以 `password_hash()` 存进数据库,**优先级高于 `config.php`**(config 里的值此后只是出厂默认)
- 登录态存在会话里(`$_SESSION['fb_admin']` + 最后活跃时间),**空闲 2 小时自动退出**;
页面右上角「退出登录」可主动退出
- 口令连续输错 **10 次 / 小时**会被限流(按 IP 哈希计数),防暴力破解
- 该页带 `noindex,nofollow`,不会被搜索引擎收录
### 4.2.3 后台五个页签
| 页签 | 能做什么 |
|---|---|
| **待审核** | 列出 `visible = 0` 且状态非 `closed` 的问题。每张卡片有正文全文与提问人邮箱,配「通过并公开」「关闭并邮件告知」两个操作 |
| **全部问题** | 全量列表,支持按状态 / 分类 / 是否公开筛选,按标题或正文搜索,分页浏览 |
| **问题详情** | 以**官方身份回复**(可顺带改状态)、改处理状态与可见性、关闭并发信、**删除问题**(连同附件一起删) |
| **站点设置** | 附件数量与大小上限、存储方式(关闭 / 本机硬盘 / 阿里云 OSS)、OSS 桶与鉴权方式、审核开关,以及**修改登录口令** |
| **操作日志** | 最近 100 条管理动作(登录 / 通过 / 关闭 / 撤下 / 改状态 / 回复 / 删除 / 改设置 / 改口令),带时间与问题编号 |
> 关闭时**必须填写说明**(留空会拒绝提交)——这段内容就是要发给提问人的正文,
> 所以请写得像"回复用户"而不是"内部备注"。
### 4.2.4 关闭邮件长什么样
`lib/mailer.php` 的 `fb_notify_closed_html()` 生成正文,大致包含:
1. 一句结论("你反馈的问题经确认不属于应用问题");
2. 管理员填写的说明原文(`mod_note`);
3. 原始反馈内容(标题 + 正文摘录),方便对方回想是哪一条;
4. 结尾给出联系邮箱,说明"如仍有疑问可回复本邮件"。
主题由 `config.php` 的 `moderation.close_mail_subject` 决定,支持 `%s` 占位(会换成问题标题)。
### 4.2.5 操作留痕(`mod_log`)
每一次管理动作都会写一条记录到 `mod_log` 表(`action` / `thread_id` / `detail` / `created_at` / `ip_hash`),
在后台「操作日志」页可见。IP 仍只存加盐哈希,不落明文。
### 4.2.6 站点设置存在哪(`settings` 表覆盖 `config.php`)
后台改的设置**不写回 `config.php`**,而是存进数据库的 `settings` 表(键值对,值为 JSON):
- 读取时由 `fb_cfg()` 把 `settings` 的内容**叠加在 `config.php` 之上**(`array_replace_recursive`),
所以 `config.php` 成了"出厂默认值",部署后不必再手工改它;
- 只有白名单里的键能被改(`fb_setting_keys()`,见 `lib/util.php`),`db_path` 之类敏感项改不动;
- 保存后本进程内立即生效(`fb_set_setting()` 会刷新缓存并递增配置版本号)。
> 因此:**升级代码时可以继续不上传 `config.php`**,服务器上的真实配置不会被覆盖,
> 后台在数据库里改过的设置也不会丢。
- 发送结果同样记录进 `notify_log` 表(`reply_id = 0` 表示这是"关闭通知"而非"回复通知")。
- **SMTP 未启用时**:关闭操作照常生效,页面会提示"邮件未发出:SMTP 未启用…",
说明文本仍保存在 `mod_note` 里,配好 SMTP 后可人工补发。
### 4.2.5 相关配置(`config.php` 的 `moderation` 段)
```php
'moderation' => [
'enabled' => true, // false = 回到"提交即公开"的旧行为
'close_mail_subject' => '关于你反馈的问题《%s》',
],
```
### 4.2.6 数据库迁移(自动、幂等)
`lib/db.php` 在每次建库/升级时会检查并补齐三列,老站点不用手工改库:
| 列 | 类型 | 说明 |
|---|---|---|
| `visible` | `INTEGER NOT NULL DEFAULT 0` | 是否公开。**已存在的历史数据会被置为 1**(功能上线前的内容视为已通过) |
| `mod_note` | `TEXT` | 关闭说明(同时是邮件正文) |
| `moderated_at` | `INTEGER` | 最近一次审核时间;有值即出现在「已处理」里 |
另有索引 `idx_threads_visible(visible, created_at DESC)`,保证列表过滤不拖慢。
### 4.2.7 访客侧的配套表现
- 提交成功后跳到 `thread.php?id=…&submitted=1`,显示「提交成功,等待审核」的说明页。
- 审核中的帖子对普通访客**等同不存在**(列表不显示、详情页返回 404),避免泄露 URL 就能围观。
- 审核中的帖子**不允许回复**(POST 会被拦下并提示"还在审核中")。
- 管理员登录后,上述限制全部放开,方便先看完整页再决定。
---
## 五、附件上传(图片 / 视频)
### 5.0 先选存储方式(后台「站点设置 → 附件存储」,**随时可改**)
| 选项 | 文件存在哪 | 适合什么情况 | 代价 |
|---|---|---|---|
| **关闭** | — | 暂时不接受附件 | 上传入口自动隐藏,其余功能照常 |
| **本机硬盘** | 站点目录 `feedback/uploads/YYYY/MM/` | 想零配置、立刻可用;量小、访问不密集 | 占用服务器磁盘与带宽;需保证目录对 PHP 可写 |
| **阿里云 OSS** | 你自己的**私有** Bucket(经服务器代理读写,走内网 Endpoint) | 量大、正式对外;希望桶私有、公网不可直连 | 附件流量经服务器中转(占带宽);需先建**同地域**桶并授权 |
> **切换不会让旧附件失效**:每条附件走哪种存储,由它自己的对象键前缀决定——
> `local/…` 一律按本机文件读,`fb/…`(或你配的 `oss.prefix`)一律按 OSS 读。
> 所以中途从 OSS 换成硬盘(或反过来)时,**已经发过的帖子里的附件照常显示**。
> ★ **前台地址与存储方式无关(统一代理)**
> 无论选「本机硬盘」还是「阿里云 OSS」,页面上的图片 / 视频地址**永远**是同一个形态:
> ```
> media.php?k=<对象键> 例:media.php?k=local%2F2026%2F09%2Fabcd.jpg
> ```
> 这样做有两个好处:
> 1. **路径统一**:存储方式对页面与前端 JS 完全透明,换存储不用改前端、也不会出现两套 URL 逻辑;
> 2. **不暴露真实地址**:本机模式下不会给出 `uploads/2026/09/xxxx.jpg` 这类可被猜目录、被批量抓取的直链;
> OSS 模式下更不会出现任何 bucket / endpoint / 签名痕迹。
> 真实的物理位置只由服务器在 `media.php` 内部按对象键前缀解析。
> 数据库里**只存对象键**,地址是渲染时现算的 —— 所以这项改动对**历史附件也立即生效,无需迁移数据**。
**本机硬盘模式的两个注意点**
1. 目录由 PHP **按需自动创建**(`fb_local_writable()` 里的 `@mkdir`),属主就是 PHP 进程用户,天然可写。
⚠️ **不要用 SFTP/部署账号手动建这个 `uploads/` 目录**——那样属主会变成部署账号,
PHP 写不进去,上传会失败。后台「存储体检」会明确显示"可写 ✅ / 不可写 ❌"并给出处置办法;
真遇到了,**删掉整个 `uploads/`(含 `.htaccess`)再刷新页面**,PHP 会重建。
2. 目录里自动带一个 `.htaccess`:**整个目录一律拒绝 Web 直接访问**(附件只走 `media.php` 代理,
真实路径不外泄),并额外禁止执行 `.php`/`.pl`/`.py`/`.cgi`/`.sh` 等脚本。
该规则带**版本标记**(`# fb-local-guard: vN`),程序每次确保目录时比对版本,
老站点上残留的旧版 `.htaccess` 会被**自动升级**,不需要手工替换。
对象键由服务端生成并做正则白名单校验,客户端无法指定路径。
---
### 5.1 OSS 代理架构的原理与开通(约 15 分钟)
**架构:私有桶 + 内网 Endpoint + 服务器代理**
本程序把 OSS 桶设为**私有**,且只使用**内网 Endpoint**(`*-internal.aliyuncs.com`,只有同地域的阿里云 ECS 能连)。
这样一来,浏览器**既拿不到可直连的 URL,也连不上内网地址**,所以附件的读写都经本站服务器中转:
```
上传:浏览器 → upload.php(收文件) → 服务器用临时凭证 PUT 到内网 OSS
读取:浏览器 → media.php(鉴权 + Range) → 服务器 GET 内网 OSS 并流式转发
```
**为什么这样设计**
- **桶可以彻底私有**:公网完全无法直连,AccessKey / 签名永不出服务器,`media.php` 只在「对象属于本站且帖子对当前访客可见」时才转发。
- **只走内网 Endpoint**:不暴露公网地址,内网访问**免流量费、延迟更低**。
- **代价**:附件流量经过服务器(占带宽),且服务器磁盘临时空间要能放下单个上传文件(PHP 会先落临时文件再 PUT)。若你的量特别大、又希望省服务器带宽,可改用「公共读桶 + 自定义域名」的经典直连方案(本程序不再内置该路径)。
> ⚠️ **内网 Endpoint 跨地域不通**!ECS 在哪个地域,桶就必须建在哪个地域。
> 例如 ECS 在 `cn-qingdao`,桶却在 `cn-beijing`,则 `oss-cn-beijing-internal` 完全连不上(连接超时)。
> 后台「存储体检 → 测一下桶连通性」会真实探测并提示;跨地域时要么把桶换到同地域,要么在后台关掉「使用内网 Endpoint」回落公网。
### 5.1a 鉴权方式:两种,后台可选
| 选项 | 需要准备 | 说明 |
|---|---|---|
| **ECS 实例 RAM 角色**(推荐) | 给 ECS 实例绑定一个 RAM 角色 | 服务器上**不保存任何长期密钥**;凭证由内网元数据服务(`http://100.100.100.200`)动态取回,阿里云自动轮换 |
| **静态 AccessKey** | 一对长期 AK/SK | 服务器不在阿里云 ECS 上时的唯一选择;密钥存在数据库 `settings` 表里(后台填写,页面永不回显 SK) |
> ⚠️ **RAM 角色名必须和实例上实际绑定的角色一致**。填错会表现为
> 「读取实例 RAM 角色失败(HTTP 404)」——后台「存储体检」会直接报出来。
> 到 ECS 控制台看实例详情里的「RAM 角色名」,或访问
> `http://100.100.100.200/latest/meta-data/ram/security-credentials/` 看可用角色列表。
> **这个角色必须授予该 Bucket 的读写权限**(至少 `oss:PutObject` + `oss:GetObject` + `oss:DeleteObject`)。
### 5.1b 开通步骤
> **前提(选 ECS 实例 RAM 角色时)**:本站运行在**阿里云 ECS** 上。
#### 先花 30 秒理解原理
给 ECS 实例绑定一个 RAM 角色后,实例内部可以通过**内网元数据服务**
(`http://100.100.100.200`,链路本地地址、不出公网)读到一份**临时凭证**:
```
AccessKeyId / AccessKeySecret / SecurityToken / Expiration
```
这份凭证只有几小时时效,并且会由阿里云侧**自动周期轮换**。本程序只负责
「把它取回来 → 缓存起来 → 拿去给上传签名」。好处是:
- 密钥**不落盘、不进配置文件** → 备份、Git、误截图都不会泄漏长期凭证;
- 权限由 RAM 角色决定 → 可以精确到「只有这个 Bucket 的这个前缀」;
- 要吊销?改角色的权限策略即可,不用满世界换密钥。
---
**第 1 步:创建 Bucket**(OSS 控制台 → Bucket 列表 → 创建 Bucket)
| 项 | 建议值 |
|---|---|
| 地域 | ⚠️ **必须与 ECS 同地域**(内网 Endpoint 跨地域不通);ECS 在哪个地域就选哪个 |
| 读写权限 | **私有**(本程序经 `media.php` 代理读取,桶无需公共读;私有最安全) |
| 存储类型 | 标准存储(低频访问会额外产生取回费用,不建议) |
**第 2 步:建一条最小权限的 RAM 策略**(RAM 控制台 → 权限策略 → 创建策略 → 脚本编辑)
只授予**这一个 Bucket** 的读写删权限,其余一概不给:
```json
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": ["oss:PutObject", "oss:GetObject", "oss:DeleteObject"],
"Resource": ["acs:oss:*:*:你的bucket名/*"]
}
]
}
```
命名建议 `FeedLiteOssPolicy`。
**第 3 步:建一个 RAM 角色并赋予该策略**(RAM 控制台 → 角色 → 创建角色)
- 角色类型:选「**阿里云账号**」/「当前账号」→ 下一步
- 角色名称:`FeedLiteOssRole`(**记住这个名字**,第 7 步要填)
- 权限:勾上第 2 步创建的 `FeedLiteOssPolicy`
> 这里**不需要**勾「允许 RAM 用户扮演该角色」——扮演它的是 ECS 实例,不是某个用户。
**第 4 步:把角色授予 ECS 实例**
ECS 控制台 → 实例 → 找到本站所在实例 → 更多 → 实例设置 → **授予/收回 RAM 角色**
→ 选 `FeedLiteOssRole` → 确定。
> 菜单里找不到时,走 ECS 控制台左侧「**实例 RAM 角色**」→ 创建 → 再回实例详情授予。
> ⚠️ 同一个实例**只能有一个 RAM 角色**。若该实例已绑定别的角色,请把上面那条 OSS 策略
> **追加到那个角色的权限里**,而不是再绑一个角色。
> ⚠️ 授予以外的新角色后建议**重启一次实例**,让元数据服务立刻生效。
**第 5 步:验证(关键一步,别跳过)**
SSH 登录这台 ECS(角色名换成你自己的):
```bash
curl -s http://100.100.100.200/latest/meta-data/ram/security-credentials/FeedLiteOssRole
```
期望输出(`Code` 必须是 `Success`):
```json
{"AccessKeyId":"STS.NT…","AccessKeySecret":"…","SecurityToken":"CAIS…","Expiration":"2026-09-15T14:20:00Z","Code":"Success"}
```
返回 `404` / `Forbidden` / 连不通 → 说明**角色名写错**或**角色没授予实例**,先解决再往下走。
(服务器上没有 curl 也没关系,程序自带体检,见第 8 步。)
**第 6 步:无需配置 CORS,也无需绑定自定义域名**
代理架构下浏览器**不直接访问 OSS**(既不同域、也不碰公网 Endpoint),所以:
- **不用配 CORS**:不存在浏览器跨域 POST 到 OSS 的环节;
- **不用绑自定义域名**:附件一律经本站 `media.php` 输出,图片点开是**预览**而非下载,不受 OSS 默认域名的 `Content-Disposition` 影响。
(这两件事在旧的「浏览器直传 + 公共读」方案里是必需的,代理方案把它们都省掉了。)
**第 7 步:填写 `config.php` 的 `oss` 段(或在后台「站点设置」里填)**
```php
'oss' => [
'enabled' => true,
'endpoint' => 'oss-cn-qingdao.aliyuncs.com', // 不含 http://;填公网形式即可,程序自动转内网
'bucket' => '你的bucket名',
'instance_role' => 'FeedLiteOssRole', // ★ 第 3 步的角色名
'use_internal_endpoint' => true, // ★ 走内网(同地域必须开;跨地域不可达时改 false)
'prefix' => 'fb', // 对象键前缀,多应用共用一个桶时用它隔离
// 以下是可选增强,一般保持默认即可
// 'metadata_v2' => true, // 加固模式:读元数据前先取一次性 token
],
```
注意:**这里没有 `access_key` / `secret_key`** —— 实例角色鉴权真的不需要。
(若站点不在阿里云 ECS 上,才改用后台的「静态 AccessKey」鉴权方式。)
**第 8 步:跑一次体检**
```bash
cd /www/wwwroot/你的站点/feedback
php cron.php
```
(**命令行方式不需要 `--token`**;只有用浏览器访问 `cron.php?token=…` 时才要带。)
期望看到:
```
附件:共 0 个,已清理超过 365 天的附件 0 个(剩余 0 个,占用 0 B)。
附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …abc123),已取得临时安全令牌,有效期至 2026-09-15 14:20。
桶连通性:✅ http://你的bucket.oss-cn-qingdao-internal.aliyuncs.com(内网 Endpoint,HTTP 404)— 连通正常。
```
- 「**附件存储凭证**」有尾号、有效期,且「**桶连通性:✅**」→ 配置成功,去发帖页试传一张图即可。
- 「桶连通性:❌ … HTTP 0」→ 连不上桶:多半是**桶与 ECS 不同地域**(内网不可达),换同地域的桶,或后台关掉「使用内网 Endpoint」。
- 「附件存储未启用(…)」→ 第 7 步没保存成功:检查 `enabled` 是否 `true`、`instance_role` 是否填了角色名。
- `⚠️ 取不到凭证:…` → 按提示回到第 4 / 5 步排查。
**第 9 步:无需绑定自定义域名**
代理架构下附件经 `media.php` 输出,图片点开是预览而非下载,**不需要**再给桶绑自定义域名。
(`oss.domain` 配置项保留但已不参与附件读取,可留空。)
---
#### 可调项(都在 `config.php` 的 `oss` 段,或后台「站点设置」)
| 参数 | 默认 | 说明 |
|---|---|---|
| `instance_role` | 空 | ★ 必填(实例角色鉴权时):ECS 实例 RAM 角色名 |
| `use_internal_endpoint` | `true` | ★ 走内网 Endpoint(自动把 endpoint 改写成 `*-internal`)。**仅同地域可用**;跨地域必须关掉 |
| `metadata_url` | 空 = `http://100.100.100.200` | 元数据服务地址,只有联调/自测才需要改 |
| `metadata_v2` | `false` | `true` = **加固模式**:先取一次性 token 再读元数据(防被 SSRF 探测) |
| `metadata_ttl` | `600` | 加固模式 token 的有效期(秒,最长 21600) |
| `credential_cache` | 空 = 与数据库同目录的 `oss.sts.php` | 凭证缓存文件(含 `<?php exit; ?>` 头 + 权限 0600,站点配置失误也无法被下载) |
| `credential_refresh_margin` | `300` | 到期前多少秒就提前换新,避免恰好在过期瞬间用旧凭证 |
| `prefix` | `fb` | 对象键前缀:`fb/2026/09/xxxx.jpg`。**多应用共用一个桶时用它隔离**(各自用不同前缀 + RAM 策略按前缀授权) |
| `image_process` | `true` | 缩略图借用 OSS 图片处理(服务端缩放后再转发,省下行流量;GIF 自动跳过以保留动画) |
| `verify` | `true` | 提交表单时 HEAD 回源复核(存在性/大小/类型),防伪造 |
#### 程序做了哪些防呆
- **不会每个请求都问元数据服务**:临时凭证有**进程内记忆 + 本地文件缓存**,到 `credential_refresh_margin` 才换新。
- **`media.php` 有鉴权边界**:对象键必须在本站附件表(或本人刚上传的待入库登记)且帖子对当前访客可见,否则 403 —— **防止本站沦为「能读整个桶」的开放代理**(共用桶时尤其致命)。
- **上传成功先登记「待入库」**:解决「上传完成但还没提交表单」期间缩略图预览的鉴权(登记表 2 小时过期,`cron.php` 清理;提交入库后自动转正)。
- **`security-token` 自动随请求发出且参与签名**:HEAD / DELETE / GET 的签名 URL、PUT 的 Authorization 头都会带上并算进签名(漏算会 403 `SignatureDoesNotMatch`,见 `lib/oss.php` 注释)。
- **内网 Endpoint 自动走 http**:内网域名的证书是公网域名签发的,https 会校验失败;内网链路由 VPC 隔离保证安全。
- **取不到凭证时绝不返回"半截"凭证**,而是明确报错;前端也不会显示出上传入口。
- **代理全程流式**:上传 PUT 与读取 GET 都不把整个文件读进内存,50MB 视频在 `memory_limit=128M` 下也安全;读取透传 `Range`,视频可拖动进度条。
#### 常见问题
| 现象 | 原因与处理 |
|---|---|
| `⚠️ 取不到凭证:读取实例 RAM 角色失败(HTTP 404)` | 角色名写错,或角色没授予该实例 |
| `⚠️ 取不到凭证:…(HTTP 0)` | 元数据服务不通:安全组/网络策略挡住了 `100.100.100.200`,或系统设了 `http_proxy` |
| 桶连通性 ❌(HTTP 0) | **桶与 ECS 不同地域**,内网 Endpoint 不可达 → 换同地域的桶,或后台关掉「使用内网 Endpoint」 |
| 上传返回 403 / AccessDenied | 角色策略与 Bucket(或前缀)不匹配:至少要 `oss:PutObject`,读取还需 `oss:GetObject`、清理需 `oss:DeleteObject` |
| 上传返回 502「写入对象存储失败」 | 看具体报错:SignatureDoesNotMatch → 校准服务器 NTP 时间;AccessDenied → RAM 授权;HTTP 0 → 地域/网络 |
| 图片/视频显示 403「无权访问该附件」 | 该附件不属于任何公开帖(先审后显未通过),或对象键不是本站登记的 —— 属正常拦截 |
| `SignatureDoesNotMatch` | ① 服务器系统时间不准(签名带时间戳),校准 NTP;② **使用了临时凭证但 `security-token` 没算进签名**、或带 body 的 PUT 没把 Content-Type 算进签名 —— 见 `lib/oss.php` 里 `fb_oss_sign_v1()` / `fb_oss_auth_headers()` 的注释 |
| 站点不在阿里云 ECS 上 | 改用「静态 AccessKey」鉴权(后台可切),内网开关关掉走公网 Endpoint;或干脆用本机硬盘模式 |
### 5.2 限额与清理
**全部限额都可以在后台「站点设置」里改**,改完立即生效,不必动 `config.php`:
| 项目 | 默认值 | 后台字段 |
|---|---|---|
| 图片数量 | 4 张 / 帖 | 每个问题最多几张图片 |
| 单张图片 | 5 MB | 单张图片上限(MB) |
| 图片格式 | jpg / png / webp / gif | **不含 SVG**(SVG 可执行脚本,属安全风险),此项固定 |
| 视频数量 | 1 个 / 帖 | 每个问题最多几个视频(改为 0 即关闭视频) |
| 单个视频 | 50 MB | 单个视频上限(MB) |
| 视频格式 | mp4 / mov / webm | 这几种浏览器可直接播放,此项固定 |
| 全站总量 | 1 GB | 全站附件总量上限(MB),达到后暂停接收新上传 |
| 保留期 | 365 天 | 附件保留天数,超期由 `cron.php` 自动删除 |
| 上传频率 | 60 次 / IP / 小时 | 每 IP 每小时最多上传几次 |
| 压缩最长边 | 1600 px | 图片压缩后的最长边(像素) |
> 后台对数值做了**上下限夹取**(如单张图片 0.2~50 MB、总量 10 MB~100 GB),
> 避免手滑填成 0 或天文数字。想恢复出厂值,把 `settings` 表里对应的行删掉即可。
### 5.3 计划任务(每天一次)
```bash
php /www/wwwroot/你的域名/feedback/cron.php
```
会做两件事:**删除超过保留期的附件**(先删 OSS 对象,成功后再删库记录,失败留待下次重试)、
**补发发送失败的通知邮件**。
### 5.4 费用参考(免费软件,几乎不花钱)
- 存储:标准存储约 **0.12 元/GB/月**(40GB 资源包约 9 元/年)
- 上传流量:**免费**
- 外网下行流量:约 0.25~0.50 元/GB(这是唯一可能产生费用的项,被恶意刷流量时需留意)
- 请求次数:PUT 每月 500 万次免费
- 图片处理(缩略图用到的 `x-oss-process`):每月 10TB 免费额度
> 按本项目的限额(1GB 上限、365 天清理),存储费一年不到 **2 元**。
> 如担心被刷下行流量,可在 OSS 控制台设置**流量带宽阈值告警**。
### 5.5 查看附件:缩略图 → 点击放大 → 左右切换
列表页显示缩略图(每帖最多 3 张,用 OSS 图片处理接口,流量很小)。详情页的附件区是「画廊」:
| 操作 | 效果 |
|---|---|
| 点击正文里的缩略图 | 页内遮罩放大(不跳新标签页) |
| ‹ › 按钮 / 键盘 ← → | 在同一帖的多个附件间切换 |
| 手机左右滑动 | 同上 |
| 键盘 `Home` / `End` | 跳到第一个 / 最后一个 |
| `Esc` 或点空白处 | 关闭 |
| 底部「查看原图」 | 新标签页打开未经处理的原图 |
- 视频以「放大」按钮进入同一条浏览序列,正文里的内嵌播放器保持可独立播放。
- 同一帖(或同一条回复)的附件构成一个独立画廊;不同帖之间不会互相串。
- 灯箱里加载的是 1600px 的处理图(省流量),需要原图时点「查看原图」。
- 图片本身是 `<a href="原图">`,即使浏览器禁用 JavaScript 也能点开原图。
- GIF 不做 OSS 图片处理 —— 否则会被压成静态首帧、丢失动画。
实现位置:`lib/attach.php` 的 `fb_attach_render()` 输出 `data-gallery` / `data-lb-*` 标记,
`assets/app.js` 的 `bindLightbox()` 负责交互,样式在 `assets/style.css` 的 `.lb-*`。
### 5.6 安全设计
- 对象键(key)由**服务端随机生成**并在策略里用 `eq` 锁定 → 客户端无法改名、无法覆盖已有文件
- 策略里 `content-length-range` 限制大小、`starts-with $Content-Type` 限制类型
- 扩展名 + MIME 双重白名单;凭证接口有 CSRF 校验和按 IP 频率限制
- 提交时**服务端 HEAD 复核**:对象不存在 / 大小不符 / 返回的 Content-Type 不在白名单 → 直接拒绝
- 附件展示的替代文字与文件名全部 HTML 转义
---
## 六、只读数据接口(供自动化 / AI 分析)
所有接口都需要令牌,支持 `?token=XXX` 或请求头 `X-Api-Token`。
| 用途 | 地址 |
|---|---|
| 主题列表 | `api.php?token=XXX&action=list` |
| 单个主题(含回复、附件) | `api.php?token=XXX&action=thread&id=12` |
| 全量导出(含回复、附件) | `api.php?token=XXX&action=export&since=1757000000` |
| 统计(含附件数量与占用) | `api.php?token=XXX&action=stats` |
| **Markdown 摘要**(便于阅读/喂给 AI) | `api.php?token=XXX&action=digest&status=open` |
通用参数:`status`(状态)、`category`(分类)、`since`(Unix 时间戳)、`limit`(默认 100)。
默认**不返回**邮箱与手机号;确需内部联系时加 `&contacts=1`。
附件以 `attachments` 数组返回(`kind` / `url` / `thumb` / `size` / `name`),可直接下载分析。
示例:
```bash
curl "https://你的域名/feedback/api.php?token=XXX&action=digest&status=open"
```
---
## 七、安全与隐私
- 数据库中**只保存**昵称、邮箱、手机号(选填)与内容;页面渲染**从不输出**邮箱与手机号。
- 访客 IP 以**加盐哈希**保存(`ip_hash`),不保存明文 IP。
- 全部输出经过 HTML 转义,SQL 全部使用预处理语句。
- 人机验证采用一次性会话题目,答对即焚;答错 5 次自动换题。
- 另有:蜜罐字段、最短填写耗时校验、按 IP 的每小时发帖/回复上限、CSRF 令牌。
- OSS 附件走**私有桶 + 内网 Endpoint + 服务器代理**:公网无法直连桶,读取经 `media.php` 鉴权(对象必须属于本站且帖子对当前访客可见,**防开放代理**);服务器上**不保存任何 OSS 长期密钥**,改用 ECS 实例 RAM 角色的临时凭证(见 5.1)。
- `data/` 目录用 `.htaccess` 拒绝一切直接访问;请勿删除该文件。
### 更换管理员口令 / 令牌
- **管理员口令**:登录后台后在「站点设置 → 登录口令」里改(至少 8 位)。改完即以 bcrypt 哈希存进数据库,`config.php` 里的 `admin_password` 只是「未设置」时的初始值通道。
- **令牌 `api_token` / 盐 `ip_salt`**:这两项没有后台界面,改 `config.php` 即可,无需改代码。
### 口令存在哪、是否加密(重要)
| 状态 | 存在哪 | 是否加密 | 怎么变成下一档 |
|---|---|---|---|
| 刚 clone 下来、没装 | `config.php` 的 `admin_password` = `CHANGE_ME_admin` | 明文,但**该值已被程序明确禁用**,无法登录 | 跑 `install.php`,或到后台设定口令 |
| 手工在 `config.php` 指定了口令 | `config.php` 的 `admin_password` | **明文**(配置文件里) | 登录一次后到后台改一次口令 → 转为数据库哈希 |
| 正常状态(推荐) | 数据库 `settings` 表的 `admin.password_hash` | **是**,bcrypt 单向哈希(`$2y$…`),不可逆 | 已是最终形态 |
- 后台「站点设置 → 登录口令」会把当前处于哪一档**如实显示出来**,Linux 下也能直接查库确认。
- 明文口令**永远不会**由安装向导或后台写进任何文件:向导只把哈希写进数据库。
- 怀疑口令泄露:到后台改掉即可。若把数据库里 `admin.password_hash` 这一行删掉,
会退回使用 `config.php` 的值(也因此回到明文档,建议删完立刻重新设定)。
---
## 七之二、`config.php` 里的三个"必改"参数是什么
> **走安装向导的话,这一段可以跳过**:`install.php` 会替你生成 `config.php`,
> 并自动填好 `api_token` 与 `ip_salt`(都是随机串),管理员口令则直接以哈希入库、不进文件。
> 下面这些是「想手工配置」时看的说明。三项出厂值都是 `CHANGE_ME_...` 占位符。
### `admin_password` —— 登录口令(初始值,可留空)
- **干什么用**:两个地方共用同一串 ——
① 详情页回复框里填它 → 以「官方」身份发言;
② `/feedback/admin.php` 登录 → 进管理后台(审核、回复、改状态、删帖、改站点设置)。
- **建议**:优先用安装向导;若手工配,**登录后台后立刻在「站点设置 → 登录口令」里改成你自己的一串**(至少 8 位)。
改完会以 `password_hash()` 存进数据库,`config.php` 里这行就只是"出厂默认值"了 ——
⚠️ 此时改 `config.php` **不会**再影响登录口令,忘了新口令就去数据库把 `settings` 表里
`admin.password_hash` 这一行删掉,即可退回用 `config.php` 里的值。
- ⚠️ **占位值 `CHANGE_ME_admin` 已被程序明确禁用**(空值同样):也就是说,**clone 下来不做任何配置时,
后台任何人都登不进去** —— 这是刻意的,避免"文档里公开的默认口令"变成线上后门。
- **它不是什么**:站点面向访客没有账号体系,也没有访客登录页。它只是一串比对用的口令 ——
表单里填的内容与库里的哈希(或 `config.php` 里的值)比对一次,一致就按官方处理。
- **怎么配**:随便生成一个 20~32 位的随机串,例如
```bash
php -r "echo bin2hex(random_bytes(16));"
```
把它填进 `'admin_password' => '...'`。**不要用你其它网站用过的密码**(这串值会明文躺在配置文件里)。
- **注意**:口令只用来判身份,所以**不要**把它填到公开的地方;一旦怀疑泄露,
到后台「站点设置」改掉即可(或删掉数据库里的哈希回退到 `config.php`)。
### `api_token` —— 给脚本 / AI 用的只读钥匙
- **干什么用**:`api.php` 提供的只读接口(`list` / `thread` / `export` / `stats` / `digest`)都要带令牌才返回数据。你后续想让 AI 或脚本"读一遍所有反馈、按 id 逐条分析、自动出摘要",用的就是它。
- **为什么单独一把钥匙**:读数据是高频且自动化的,用同一个口令既有泄露风险、也没法单独吊销。分开以后,哪天令牌泄了只换它,管理员口令不受影响。
- **怎么配**:同样用随机串(建议 ≥ 32 位),填进 `'api_token' => '...'`。
- **怎么用**:
```bash
curl "https://你的域名/feedback/api.php?token=你的令牌&action=digest&status=open"
# 也可以走请求头: -H "X-Api-Token: 你的令牌"
```
- **重要**:接口默认**不返回**邮箱和手机号;只有显式加 `&contacts=1` 才会带上。也就是说令牌泄露也不会直接泄露访客联系方式(但仍建议改掉)。
- **如果不用**:不配置也没关系(保持 `CHANGE_ME_api_token` 时接口会拒绝所有请求),站点本身照常运行。
### `ip_salt` —— 给访客 IP 加盐,避免存明文 IP
- **干什么用**:发帖、回复、频率限制都要记录"谁",直接存 IP 明文属于个人信息。程序的做法是把 `IP + ip_salt` 做一次 SHA-256,数据库里只落一串哈希(`ip_hash` 字段)。这样一来:同一个 IP 每次算出同一个哈希(所以**限流仍然有效** —— 能判断"这两次是不是同一个人"),但**无法从这个哈希反推出 IP**(因为盐是保密的,彩虹表也打不中)。
- **隐私政策对得上**:`upa.html` 里"不保存明文 IP"的说法,靠的就是这一项。
- **怎么配**:换成一串**随机且保密**的值(长度随意,建议 16 位以上)。**一旦上线就不要再改** —— 改了之后,历史记录的哈希与新哈希对不上,限流统计会从零开始(历史数据不会坏,只是同一个人会被当成新访客)。
- **和另外两项的区别**:它不需要你记住、也不需要日常使用,只是"设置完就忘掉"的一把盐。但它**不能留空、不能是默认值**,否则哈希就等价于 `sha256(IP)`,容易被暴力枚举反查。
### 一次性改完(示例)
```php
'admin_password' => 'a3f1c9e07b2d4f88b6e5c1a09d3f7b62', // 回复框当暗号 + 审核后台登录口令(两处共用)
'api_token' => '9c4d2e7a1b8f50316ed0a7c2b5f4183a6e9d27c5', // 给脚本/AI 读数据
'ip_salt' => 'q7Zx2Lm9Vt4Bp0Ry', // 给 IP 加盐,设完就别再改
```
---
## 八、常见问题
**Q:提交后提示"会话已过期"?**
A:浏览器未接受 Cookie(或站点非 HTTPS 导致 SameSite 问题)。请确认以 https 访问。
**Q:邮件没发出去?**
A:看 `config.php` 的 `smtp.enabled` 是否为 `true`;查看数据库 `notify_log` 表的 `err` 字段里的具体报错。
**Q:想改问题分类?**
A:改 `config.php` 的 `categories` 段即可(键值对,键会存进数据库)。
**Q:用户说"我提交了反馈,怎么列表里看不到"?**
A:这是正常的 —— 新反馈默认进入待审核,通过后才公开。到 `/feedback/admin.php` 处理即可。
**Q:想改回"提交即公开"?**
A:把 `config.php` 的 `moderation.enabled` 改成 `false`(历史数据里 `visible = 0` 的仍不会显示,需在后台手动通过)。
**Q:关闭时提示邮件未发出?**
A:关闭操作已经生效(说明存在 `mod_note` 里),只是 SMTP 没开或报错。检查 `smtp.enabled`
与 `notify_log` 里的 `err` 字段;配好后可在后台「撤下」再「关闭」一次重发。
**Q:想给某条问题改状态(如"已解决")?**
A:进 `/feedback/admin.php` → 全部问题 → 点「管理」进详情页,回复框下方有「处理状态」下拉,选好连同回复一起提交;也可以只改状态不回复。
**Q:后台改了设置,但前台好像没变?**
A:设置存在数据库 `settings` 表里,保存后**当前进程内立即生效**,其他进程最多等一个请求周期也会读到。
若长时间没变化,检查是否被**页面缓存**挡住了(宝塔「网站加速」/`fastcgi_cache`),清一次缓存即可。
**Q:附件上传报"本机附件目录不可写"?**
A:看后台「站点设置 → 存储体检」里的提示。最常见原因是**用 SFTP 手动建了 `uploads/`**,
属主是部署账号而非 PHP 用户。处置:删掉整个 `uploads/` 目录(含里面的 `.htaccess`),
刷新一次设置页,PHP 会自动重建并把它设为自己可写。
**Q:上传成功但图片打不开(403)?**
A:用了 OSS 且桶上配了**防盗链(Referer 白名单)**。到 OSS 控制台把站点域名
(如 `feedback.example.com`,或 `*.example.com`)加入白名单,并**允许空 Referer**
(否则在浏览器地址栏直接打开链接也会 403)。
**Q:想换 OSS 桶 / 换鉴权方式?**
A:后台「站点设置」里改,保存即生效。**旧附件不会失效**——每条附件按自己的对象键前缀
(`local/…` 或 OSS 前缀)去对应存储读取。只有"两个存储都不可用"时旧附件才会打不开。
**Q:数据库文件存在哪里?想备份?**
A:默认在 `data/feedback.db`(及其 `-wal`/`-shm` 文件)。定期打包 `data/` 目录即可(附件在 OSS 上,按需单独备份)。
**Q:看不到上传按钮?**
A:说明 OSS 未配置或 `oss.enabled` 为 `false`。填好 `config.php` 的 `oss` 段(`endpoint` / `bucket` / `instance_role`)并改为 `true` 即可。
**Q:上传失败提示"写入对象存储失败"?**
A:三种常见原因:
1. **桶与 ECS 不同地域** → 内网 Endpoint 不可达(报 HTTP 0 / 连接超时):换同地域的桶,或后台关掉「使用内网 Endpoint」;
2. 角色策略与 Bucket(或前缀)不匹配 → 至少要有 `oss:PutObject`(读取还需 `oss:GetObject`、清理需 `oss:DeleteObject`);
3. 取不到临时凭证 → 跑 `php cron.php` 看体检输出(多半是角色名写错或角色没授予实例)。
**Q:怎么确认临时凭证和桶是好的?**
A:`php cron.php`(命令行方式不需 token),会打印「附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …)…」和「桶连通性:✅ http://你的bucket.oss-cn-xxx-internal.aliyuncs.com …— 连通正常」。两行都正常就没问题;带 ❌ 或 ⚠️ 按提示排查。
**Q:图片/视频打不开,提示"无权访问该附件(403)"?**
A:这是 `media.php` 的正常拦截——该附件所属的帖子还没通过审核(先审后显),或对象键不是本站登记的。审核通过后即可正常显示;管理员登录状态下始终可见。
**Q:提示"附件存储空间已满"?**
A:全站附件总量达到 `upload.total_cap_bytes`(默认 1GB)。可在 OSS 控制台清理,或调大该值。
**Q:想彻底关闭附件功能?**
A:把 `upload.image_max_per_post` 与 `upload.video_max_per_post` 都设为 `0`(或 `oss.enabled = false`)。