Files
FeedLite/README.md
T
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

38 KiB
Raw Blame History

轻反馈 FeedLite(PHP + SQLite)

Lightweight feedback, made simple. / 让每一条反馈都被听见。

一个通用、轻量、自包含的反馈系统:可以挂到任意软件、系统或开源项目的站点下, 让用户直接提交反馈与建议。任何人无需注册即可发帖 / 回复,只需昵称与邮箱(手机号选填); 页面不展示邮箱与手机号;有人回复时给发帖人发邮件通知。 新提交的反馈先审后显(默认不公开,管理员在 admin.php 决定公开或关闭并邮件告知,见 4.2 节)。

  • 纯 PHP + SQLite,无任何第三方依赖(不需要 Composer、不需要 MySQL)
  • 自带趣味问答人机验证(防机器人)+ 蜜罐字段 + 提交耗时校验 + 按 IP 频率限制
  • 支持图片 / 视频附件:浏览器直传阿里云 OSS,不占用服务器磁盘与带宽(不配置也可正常运行,只是不显示上传入口)
  • 自带只读 API / Markdown 摘要接口,方便自动化脚本或 AI 读取问题并分析
  • 上手即用:clone 下来直接部署,无需建库、无需装依赖(含深色模式与响应式布局)

适用场景:某款软件 / 系统 / 开源项目的用户反馈区;团队内部的问题收集箱;独立开发者的建议与 Bug 收集。


一、目录结构

feedback/
├─ index.php          问题列表(分类 / 状态 / 搜索 / 分页 / 缩略图)
├─ thread.php         问题详情 + 回复(含"官方答复"与状态管理、附件展示)
├─ new.php            发布新问题(含图片 / 视频上传)
├─ admin.php          管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志)
├─ upload.php         附件上传接口(OSS 直传下发凭证;本机模式下接收文件本体)
├─ 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 按需自动创建)
├─ .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.0~8.3),并已启用扩展: pdo_sqlite、sqlite3、openssl、mbstring(宝塔默认均已有;在「软件商店 → PHP → 安装扩展」可查看)。 curl 为可选:有则优先用它和 OSS 通信,没有会自动退回 PHP 内置的流式请求,功能不受影响。
  3. 给数据库目录写权限(宝塔一般已允许):
    chown -R www:www /www/wwwroot/你的域名/feedback
    chmod -R 755 /www/wwwroot/你的域名/feedback/data
    
  4. 首次访问 https://你的域名/feedback/ 会自动建库建表。
  5. 打开 config.php,修改这三项(务必!):
    • admin_password —— 管理员口令(回复时填入即以「官方」身份答复;也是审核后台 admin.php 的登录口令)
    • api_token —— 读取接口的令牌(自己设一串随机字符)
    • ip_salt —— 随机字符串(用于给访客 IP 做哈希)
  6. 按需配置邮件(见第四节),并把 smtp.enabled 改为 true。
  7. 如需让用户上传截图 / 录屏,按第五节配置阿里云 OSS;不配置也能正常用,只是不显示上传入口。

若站点是 Nginx,.htaccess 不生效,请在站点配置里加一条:

location ^~ /feedback/data/ { deny all; }
location ~ ^/feedback/.*\.(db|md|log)$ { deny all; }

三、首页加链接

已在官网 index.html 的顶部导航与页脚「产品」列加入「问题反馈」入口,指向 feedback/。 (本地文件:(你的站点首页))


四、邮件通知(SMTP)

回复提交时会尝试给发帖人发送通知邮件。在 config.php 的 smtp 段填写即可,例如:

阿里云邮件推送(推荐,需先开通并验证域名)

'host' => 'smtpdm.aliyun.com', 'port' => 465, 'secure' => 'ssl',
'user' => 'noreply@example.com', 'pass' => '你的SMTP密码',
'from' => 'noreply@example.com', 'from_name' => '轻反馈',

腾讯企业邮

'host' => 'smtp.exmail.qq.com', 'port' => 465, 'secure' => 'ssl',

QQ 邮箱 / 163 邮箱

'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 脚本,每天一次)

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 段)

'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(浏览器直传) 量大、正式对外 需先建桶并授权;会有少量 OSS 费用

切换不会让旧附件失效:每条附件走哪种存储,由它自己的对象键前缀决定—— local/… 一律按本机文件读,fb/…(或你配的 oss.prefix)一律按 OSS 读。 所以中途从 OSS 换成硬盘(或反过来)时,已经发过的帖子里的附件照常显示。

本机硬盘模式的两个注意点

  1. 目录由 PHP 按需自动创建(fb_local_writable() 里的 @mkdir),属主就是 PHP 进程用户,天然可写。 ⚠️ 不要用 SFTP/部署账号手动建这个 uploads/ 目录——那样属主会变成部署账号, PHP 写不进去,上传会失败。后台「存储体检」会明确显示"可写 ✅ / 不可写 ❌"并给出处置办法; 真遇到了,删掉整个 uploads/(含 .htaccess)再刷新页面,PHP 会重建。
  2. 目录里自动带一个 .htaccess:禁止列目录、禁止执行 .php/.pl/.py/.cgi/.sh 等脚本, 只允许当静态图片 / 视频读取。对象键由服务端生成并做正则白名单校验,客户端无法指定路径。

5.1 OSS 直传的原理与开通(约 15 分钟)

为什么这样设计:如果让用户把文件传到本站服务器,硬盘和带宽会被迅速吃满。所以本程序让浏览器 直接把文件 POST 到 OSS,服务器只做两件事:

  1. 下发一段有时效、有范围的上传凭证(PostObject 策略签名 + HMAC-SHA1);
  2. 提交表单时用 HEAD 回源复核对象确实存在、大小与类型合规,再把元信息写进 SQLite。

文件实体永远不落在本站磁盘上(选 OSS 模式时)。

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:PostObject + 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 近的(如华东1 杭州、华北2 北京)
读写权限 公共读(简单);想更严格选私有,程序会自动为展示链接签名
存储类型 标准存储(低频访问会额外产生取回费用,不建议)

第 2 步:建一条最小权限的 RAM 策略(RAM 控制台 → 权限策略 → 创建策略 → 脚本编辑)

只授予这一个 Bucket 的读写删权限,其余一概不给:

{
  "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(角色名换成你自己的):

curl -s http://100.100.100.200/latest/meta-data/ram/security-credentials/FeedLiteOssRole

期望输出(Code 必须是 Success):

{"AccessKeyId":"STS.NT…","AccessKeySecret":"…","SecurityToken":"CAIS…","Expiration":"2026-09-15T14:20:00Z","Code":"Success"}

返回 404 / Forbidden / 连不通 → 说明角色名写错或角色没授予实例,先解决再往下走。 (服务器上没有 curl 也没关系,程序自带体检,见第 8 步。)

第 6 步:配置 Bucket 的 CORS(Bucket → 数据安全 → 跨域设置 → 创建规则)

浏览器是跨域把文件直接 POST 到 OSS 的,必须放行:

项 填什么
来源 Sources https://你的域名(你的站点域名,可多行)
允许方法 Methods 勾 POST、GET、HEAD
允许 Headers *
暴露 Headers ETag

第 7 步:填写 config.php 的 oss 段

'oss' => [
    'enabled'    => true,
    'endpoint'   => 'oss-cn-hangzhou.aliyuncs.com',   // 不含 http://
    'bucket'     => '你的bucket名',
    'instance_role' => 'FeedLiteOssRole',         // ★ 第 3 步的角色名

    // 以下是可选增强,一般保持默认即可
    // 'metadata_v2' => true,                          // 加固模式:读元数据前先取一次性 token
    // 'domain'      => 'https://fb-img.example.com',   // 自定义访问域名(见第 9 步)
    // 'visibility'  => 'private',                     // 私有 Bucket 改成 private
],

注意:这里没有 access_key / secret_key —— 真的不需要。

第 8 步:跑一次体检

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。
  • 出现「附件存储凭证:…」且尾号、有效期都有 → 配置成功,去发帖页试传一张图即可。
  • 出现「附件存储未启用(请在 config.php 的 oss 段填写 endpoint / bucket / instance_role 并置 enabled = true)」→ 说明第 7 步没保存成功:回去检查 enabled 是不是 true、instance_role 是不是填了角色名。
  • 出现 ⚠️ 取不到凭证:… → 按提示回到第 4 / 5 步排查。

第 9 步(强烈推荐):绑定自定义域名

Bucket → 传输管理 → 域名管理 → 绑定域名。不绑也能用,但 OSS 默认域名会带 Content-Disposition: attachment,图片点开是下载而不是预览。 绑定后把域名填到 oss.domain(如 https://fb-img.example.com)。

控制台会给出一个 CNAME 目标,去 DNS 加一条 CNAME 记录;若走 HTTPS,还需要在 OSS 上传该域名的证书。


可调项(都在 config.php 的 oss 段)

参数 默认 说明
instance_role 空 ★ 必填:ECS 实例 RAM 角色名
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 到期前多少秒就提前换新,避免恰好在过期瞬间用旧凭证
sign_ttl 600 上传凭证有效期(秒)
visibility public 改成 private 时,展示链接自动带上签名
image_process true 列表缩略图借用 OSS 图片处理,省流量

程序做了哪些防呆

  • 不会每个请求都问元数据服务:临时凭证有进程内记忆 + 本地文件缓存,到 credential_refresh_margin 才换新。
  • 上传策略有效期会被夹在临时凭证有效期之内 —— 否则 OSS 会因为「策略比凭证晚到期」而直接拒签。
  • security-token 会自动随请求发出:PostObject 表单里多一个 x-oss-security-token 域; HEAD / DELETE 的签名 URL 上也会带 security-token。它不参与签名计算,只是让 OSS 认出这是谁的临时身份。
  • 取不到凭证时绝不返回"半截"凭证,而是明确报错;前端也不会显示出上传入口。
  • 提交表单时仍会 HEAD 回源复核(存在性 / 大小 / 类型),防伪造、防超限。

常见问题

现象 原因与处理
⚠️ 取不到凭证:读取实例 RAM 角色失败(HTTP 404) 角色名写错,或角色没授予该实例
⚠️ 取不到凭证:…(HTTP 0) 元数据服务不通:安全组/网络策略挡住了 100.100.100.200,或系统设了 http_proxy
上传时浏览器报 CORS 第 6 步的跨域规则没配,或来源域名写错
上传返回 403 角色策略与 Bucket 不匹配(至少要 oss:PutObject;私有 Bucket 还需 GetObject/DeleteObject)
图片点开变成下载 没绑自定义域名(见第 9 步)
SignatureDoesNotMatch ① 服务器系统时间不准(签名带时间戳),校准 NTP;② 使用了临时凭证但 security-token 没算进签名 —— 这是本程序修过的一个真实缺陷,见 lib/oss.php 里 fb_oss_sign_v1() 的注释
站点不在阿里云 ECS 上 改用「静态 AccessKey」鉴权(后台可切),或走本机硬盘模式

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.250 MB、总量 10 MB100 GB), 避免手滑填成 0 或天文数字。想恢复出厂值,把 settings 表里对应的行删掉即可。

5.3 计划任务(每天一次)

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),可直接下载分析。

示例:

curl "https://你的域名/feedback/api.php?token=XXX&action=digest&status=open"

七、安全与隐私

  • 数据库中只保存昵称、邮箱、手机号(选填)与内容;页面渲染从不输出邮箱与手机号。
  • 访客 IP 以加盐哈希保存(ip_hash),不保存明文 IP。
  • 全部输出经过 HTML 转义,SQL 全部使用预处理语句。
  • 人机验证采用一次性会话题目,答对即焚;答错 5 次自动换题。
  • 另有:蜜罐字段、最短填写耗时校验、按 IP 的每小时发帖/回复上限、CSRF 令牌。
  • 附件不经过本站服务器;服务器上不保存任何 OSS 长期密钥,改用 ECS 实例 RAM 角色的临时凭证(见 5.1)。
  • data/ 目录用 .htaccess 拒绝一切直接访问;请勿删除该文件。

更换管理员口令 / 令牌

直接修改 config.php 即可,无需改代码。


七之二、config.php 里的三个"必改"参数是什么

这三项都写在 config.php 的「安全」段落里,出厂值都是 CHANGE_ME_... 占位符,上线前必须换成自己的随机值。

admin_password —— 登录口令(初始值)

  • 干什么用:两个地方共用同一串,所以必须一致 —— ① 详情页回复框里填它 → 以「官方」身份发言; ② /feedback/admin.php 登录 → 进管理后台(审核、回复、改状态、删帖、改站点设置)。

  • 建议:登录后台后在「站点设置 → 修改登录口令」里改成你自己的一串(至少 8 位)。 改完会以 password_hash() 存进数据库,config.php 里这行就只是"出厂默认值"了 —— ⚠️ 此时改 config.php 不会再影响登录口令,忘了新口令就去数据库把 settings 表里 admin.password_hash 这一行删掉,即可退回用 config.php 里的值。

  • 它不是什么:站点面向访客没有账号体系,也没有访客登录页。它只是一串比对用的口令 —— 表单里填的内容与库里的哈希(或 config.php 里的值)比对一次,一致就按官方处理。

  • 怎么配:随便生成一个 20~32 位的随机串,例如

    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' => '...'。
  • 怎么用:
    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),容易被暴力枚举反查。

一次性改完(示例)

'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:上传失败提示"上传到存储失败(403)"? A:三种常见原因:

  1. CORS 未配置或来源域名不匹配 → 回到 Bucket「跨域设置」补上你的站点域名,方法勾 POST;
  2. 角色策略与 Bucket 不匹配 → 至少要有 oss:PutObject(私有 Bucket 还需 GetObject / DeleteObject);
  3. 取不到临时凭证 → 跑 php cron.php 看体检输出(多半是角色名写错或角色没授予实例)。

Q:怎么确认临时凭证是好的? A:php cron.php --token=你的api_token,会打印一行「附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …),已取得临时安全令牌,有效期至 …」。没有这行或带 ⚠️ 就是有问题。

Q:提示"附件存储空间已满"? A:全站附件总量达到 upload.total_cap_bytes(默认 1GB)。可在 OSS 控制台清理,或调大该值。

Q:想彻底关闭附件功能? A:把 upload.image_max_per_post 与 upload.video_max_per_post 都设为 0(或 oss.enabled = false)。