# 轻反馈 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」。 ⚠️ 新装一套 PHP(尤其宝塔里的 8.5)时 `fileinfo` 常常**默认没开**,从旧版本切过来要顺手检查一次。 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` | 凭证缓存文件(含 `` 头 + 权限 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 的处理图(省流量),需要原图时点「查看原图」。 - 图片本身是 ``,即使浏览器禁用 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`)。