本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/批量上传功能核查实施方案_2026-09-23.md。
批量上传功能 — 核查与实施方案
核查日期:2026-09-23
核查方式:全仓 grep + 关键文件逐行读 + PHP ini 实测 + 数据层只读探针
结论状态:待老搭档确认目标场景后再动代码(本文不含任何已落地的改动)
一、结论摘要
服务端的批量骨架已经存在,不需要重写。 真正的阻塞不在业务代码,而在 PHP ini 上限 与 限流口径 这两处环境级约束上。
四句话概括:
- 表单附件(发帖 / 编辑主题 / 回帖 / 编辑回帖)已经是多选 + 服务端循环落盘,能用,但被
post_max_size=8M卡住总量。 - 编辑器正文图(富文本里插图)只能一张一张传,前端
input没有multiple,且每张一个请求 → 撞rate_upload_max=5/60s。 - 本机
upload_max_filesize=2M,而代码与前端都宣称 5MB → 2MB 以上的文件必然失败,且错误信息无法解释原因。 - 生产(IIS + PHP 8.4)的 ini 现值未核实,动手前必须现场确认,否则方案参数全是猜的。
二、现状盘点(哪些已经支持批量)
| 场景 | 入口 | 现状 | 批量支持 |
|---|---|---|---|
| 发帖附件 | app/Views/post/create.php:67 attachments[] + multiple | 表单同步提交,Settings::handleAttachments() 循环落盘 | 部分支持 |
| 编辑主题附件 | app/Views/thread/edit.php:92 | 同上(ThreadController::edit → handleAttachments) | 部分支持 |
| 回帖附件 | app/Views/thread/show.php:374 reply_attachments[] + multiple | ThreadController::show 内联循环(:156-172) | 部分支持 |
| 编辑回帖附件 | app/Views/post/edit.php:28 | PostController::edit 循环 | 部分支持 |
| 编辑器正文图 | assets/js/editor.js:1269-1279 | input 无 multiple,change 只取 files[0] | 不支持 |
| 粘贴多图 | assets/js/editor.js:1148-1161 | 找到第一张 image 就 return,其余丢弃 | 不支持 |
关键事实:Settings::handleAttachments()(app/Helpers/Settings.php:703-732)本身已经是 for 循环,逐文件调 Upload::file() 并入库。也就是说「批量上传」的服务端骨架早就有了,问题只在上限和体验。
四个表单的 enctype="multipart/form-data" 均已就位(post/create.php:34、thread/edit.php:35、thread/show.php:369、post/edit.php:20),无需改动。
三、真正的阻塞点(5 条,按危险程度排序)
阻塞点 1:post_max_size = 8M — 批量上传最危险的坑
证据
php -r 'echo ini_get("post_max_size");' → 8M
php -r 'echo ini_get("max_file_uploads");' → 20机制:表单附件走同步 POST。当 N 个文件的总字节数超过 post_max_size 时,PHP 会静默丢弃整个请求体 —— $_POST 和 $_FILES 同时变空。
后果:用户填好的标题、正文、附件全部消失,页面还会像「正常提交了一个无附件帖子」一样继续走,因为 !empty($_FILES['attachments']['name'][0]) 判定为假(PostController.php:117、:149),直接跳过附件分支。
为什么现在没暴露:attachment_max_per_post 实际配的是 5,且历史附件多为小 zip(实测最大 55KB),5 个文件很难堆到 8M。一旦放开数量或提高单文件上限,这个坑立刻踩中。
这条是所有方案里必须优先处理的。
阻塞点 2:upload_max_filesize = 2M 与代码宣称的 5MB 不一致
证据
php -r 'echo ini_get("upload_max_filesize");' → 2M
config.php:24 define('MAX_FILE_SIZE', 5 * 1024 * 1024); → 5MB
settings.attachment_max_size = 5242880 → 5MB(实测值)
assets/js/editor.js:2013 if (file.size > 5 * 1024 * 1024) → 5MB后果:上传 2MB~5MB 之间的文件,PHP 层就以 UPLOAD_ERR_INI_SIZE 拒收,tmp_name 为空。而 Upload::file()(app/Helpers/Upload.php:453-455)只判断 error !== UPLOAD_ERR_OK 就返回「文件上传失败」—— 用户看到的是笼统失败,无法得知是服务器配置限制,会反复重试。
这条属于既有缺陷,不是批量上传引入的,但批量场景会放大它(用户一次选 5 个 3MB 的图,全失败)。
阻塞点 3:rate_upload_max = 5 / 60s — 编辑器连传第 6 张就被 429
证据
settings: rate_upload_max = 5, rate_upload_window = 60 (实测值,代码默认是 5)
app/Controllers/ApiController.php:162 RateLimiter::hitConfig('upload', 5, 60, true);
assets/js/editor.js:2037 xhr.open("POST", "/api/upload?ajax=1", true) ← 一次请求一个文件RateLimiter::check()(app/Helpers/RateLimiter.php:225-254)是 IP 文件缓存 + Session 双维度计数,两个维度都要过。编辑器插图每张一个请求 → 第 6 张开始直接 429(JSON 错误)。
注意:表单附件走同步提交,不经过 /api/upload,所以不受这条限流约束。这条只打编辑器正文图。
阻塞点 4:attachment_max_per_post = 5,且发帖/编辑分支无数量校验
证据
settings.attachment_max_per_post = 5 (实测值,代码默认 10)
ThreadController.php:121-125 回帖分支:有数量校验 + 明确提示
PostController.php:117 / :149 发帖分支:只有 can_attach 权限校验,无数量校验
ThreadController.php:396-400 编辑分支:同样无数量校验后果:发帖时选 20 个文件也能提交(受 max_file_uploads=20 兜底),静默全落盘,超出后台配置的意图。前端 onchange 只把文件名列出来,不提示「已选 8 个 / 上限 5 个」。
阻塞点 5:attachments.user_id / created_at 从未写入
证据(business.sqlite 实测 DDL)
CREATE TABLE attachments (
id INTEGER PRIMARY KEY, thread_id INTEGER, post_id INTEGER,
user_id INTEGER, -- 有列
filename TEXT NOT NULL, original_name TEXT NOT NULL,
file_size INTEGER DEFAULT 0, mime_type TEXT,
created_at TEXT, -- 有列
download_count INTEGER NOT NULL DEFAULT 0)但三处 INSERT 都只写 6 列(Settings.php:721、ThreadController.php:167、PostController.php 内联):
INSERT INTO attachments (post_id, thread_id, filename, original_name, file_size, mime_type)后果:无法回答「这个附件谁传的 / 什么时候传的」。批量上传若要加按用户配额或审计,得先把这两列补上。属于顺手可修项。
四、附带发现(低风险,建议一并处理)
| # | 位置 | 问题 |
|---|---|---|
| 1 | editor.js:1148-1161 | 粘贴板里有多张图时,只处理第一张,其余静默丢弃 |
| 2 | editor.js:2013 | 前端 5MB 校验硬编码,与 ini 的 2M 不一致 → 应改为服务端下发上限 |
| 3 | editor.js:2032-2063 | 上传无进度反馈、无失败重试;失败只弹一次 alert |
| 4 | editor.js:1269 | 隐藏 input 缺 multiple,用户无法一次选多图 |
| 5 | 发帖/编辑视图 | 无「已选 N 个 / 上限 M 个」提示,超限后知后觉 |
五、插件影响面(重要,别漏)
Upload::file() 是全站唯一落盘口,末尾触发 upload_after 钩子(app/Helpers/Upload.php:543-550)。
bitiful_storage 插件的 hook/upload_after.php 会按文件调 SyncQueue::enqueueVariants(),每个文件为 orig / md_ / thumb_ 各写一条 pending 台账,且占 Plugin::budget(n) 的「单插件每请求所有 hook 查询总和 ≤ 5」预算。
批量上传的直接后果:一次请求传 N 个文件 → 钩子触发 N 次 → 前几次耗尽预算后,其余文件的入队会被整批跳过。
- 不丢数据(有后台「全量补齐」兜底),但会表现为「批量传的图,云端镜像延迟」
- 方案设计时要么接受这个行为,要么把批量拆成多次请求(拆请求反而更符合插件预算模型)
- 核心不变量不能破:
S3Client::objectUrl必须逐字符等于UrlRewriter::objectUrl
六、三个方案(按侵入度递增,可单独也可叠加)
方案 A:配置层止血(零代码,最先做)
改动:只动 ini + 后台设置,不碰任何 PHP/JS。
| 项 | 现值 | 建议 | 说明 |
|---|---|---|---|
upload_max_filesize | 2M | ≥ 单文件上限(如 5M 或 10M) | 必须 ≥ attachment_max_size |
post_max_size | 8M | ≥ 单文件上限 × 批量数 + 1M 余量 | 例如 5M × 6 + 1M ≈ 32M |
rate_upload_max | 5 | ≥ 单次批量张数 | 后台可配,无需改码 |
attachment_max_per_post | 5 | 按需,建议 5~10 | 后台可配 |
代价:几乎为零。收益:立刻解决阻塞点 1/2/3。
前置:必须先拿到生产环境的 ini 现值,本机值与生产不一定相同。
注意:post_max_size 调大后,发帖表单的整体请求体也变大,需确认 IIS 侧 maxAllowedContentLength(web.config)不成为新瓶颈 —— 这是个容易被忽略的第二道闸门。
方案 B:编辑器多选 + 串行队列(推荐主推)
改动范围:仅 assets/js/editor.js + 少量 i18n 键。
editor.js:1269给fileInput加multiplechange回调由「取files[0]」改为遍历files,串行上传(复用现有uploadAndInsertImage的 placeholder 机制,改成队列 + 逐个 replace)- 失败时保留 placeholder 并标红,支持单张重试
- 可选:加
xhr.upload.onprogress进度条 - 顺带修
paste分支的多图丢弃(editor.js:1148)
代价:单文件改动,不动 PHP、不动数据、不动插件。
风险:低。串行上传天然避开限流与插件预算问题。
回滚:git checkout assets/js/editor.js 一行还原。
方案 C:/api/upload 支持单请求多文件(真批量,但代价最大)
改动范围:ApiController::upload() + editor.js + 限流口径。
- 后端兼容两种形态:
$_FILES['file']单文件(旧)或file[]数组(新),循环调Upload::file() - 返回结构改为
{success, files:[{url,...}], errors:[...]},必须保留旧字段(url)避免破坏既有调用 - 限流口径必须同步从「请求数」改为「文件数」 —— 否则一次请求传 10 张就绕过了 5 次/分钟的防线。这是本方案唯一的安全点,不能省。
代价:改核心 API + 安全口径。
收益:请求数从 N 降到 1,体验最好。
风险:中。限流改口径要保证不放大拒绝面(RateLimiter 的 IP 文件缓存有「不可解析即保守拒绝」的既有语义,改动需保持)。
与插件的关系:单请求传 N 文件 → 钩子仍触发 N 次 → 预算问题不会因为合并请求而消失,只是从「N 个请求各占一份预算」变成「1 个请求内争抢同一份预算」,实际更糟。
因此:方案 C 的收益主要是省往返,不是省预算。 如果只是想让用户一次选多图,方案 B 已经够,不必上 C。
方案 D:表单附件 AJAX 化(可选,最后考虑)
把发帖/回帖的 attachments[] 改成「先 AJAX 上传拿到 rel 路径 → 提交时只传路径」。
- 好处:彻底绕开
post_max_size对整个 POST 的破坏(阻塞点 1 的根治) - 代价:改 4 个视图 + 4 个控制器分支;引入「已上传未提交」的临时附件态,需要清理逻辑
- 可复用的兜底:
Settings::cleanupOrphanAttachments()(Settings.php:748)已能扫孤儿,但改它之前必须重读 REF 文档(必须先Content::decode(),否则会误判) - 结论:除非
post_max_size无法调大,否则不推荐。方案 A 的配置调整已经能解决,成本差一个数量级。
七、推荐路径
第 0 步(必做):拿到生产 php.ini 现值 + web.config 的 maxAllowedContentLength
↓ 没有这一步,后面参数全是猜的
第 1 步:方案 A(配置止血)→ 冒烟:传一个 3MB 文件 + 一次传 5 个文件
第 2 步:方案 B(编辑器多选串行)→ 冒烟:一次选 8 张图,观察是否 429 / 是否全部落盘
第 3 步:顺手修附带发现 1~5 + 补 user_id/created_at 写入
第 4 步(可选):若仍需要「一次请求传多文件」,再评估方案 C为什么这个顺序:A 是零代码且必须先做(否则 B 做完仍然传不了 3MB 的图);B 是用户能直接感知的体验改善;C 的边际收益最低且带安全口径变更。
八、风险与回滚
| 方案 | 风险 | 回滚 |
|---|---|---|
| A | 生产 ini 调整需重启 PHP / 回收应用池;post_max_size 过大增加内存放大面 | 改回原值 |
| B | 纯前端,最坏情况是上传按钮失灵 | git checkout assets/js/editor.js |
| C | 限流口径变更可能引入绕过或误拒 | 保留旧分支,按配置开关切换 |
| D | 临时附件孤儿堆积 | 需先写 dry-run 清理脚本 |
全站不变量(三个方案都不得破坏):
Upload::file()仍是唯一落盘口,插件镜像链路不破Upload::isImageAttachment()仍是图片判定单一真源- 附件下载仍一律走
/attachment/{id},禁止uploadUrl($att['filename'])直链 - 列表摘要用
thumb_(400×300)、正文图用md_(800×600),档位不混
九、待确认项(需老搭档拍板)
- 目标是哪个场景? 编辑器正文图一次选多张 / 表单附件一次选多个 / 两者都要
- 这决定走方案 B 还是 B+D
- 单文件上限要提到多少? 现状 ini 2M vs 代码 5M 的矛盾需要一个权威值
- 单次批量上限多少个? 影响
rate_upload_max与post_max_size的取值 - 生产环境的 ini 现值是多少? 本机
2M / 8M / 20不能代表生产(IIS + PHP 8.4) web.config的maxAllowedContentLength现值? IIS 侧还有一道独立的内容长度闸门,调大post_max_size后可能撞它- 是否接受「批量时云端镜像延迟,靠后台补齐」? 这是插件预算模型决定的,不是缺陷
附录:本次核查用到的证据命令
# ini 上限
php -r 'foreach(["upload_max_filesize","post_max_size","max_file_uploads","max_execution_time"] as $k){printf("%-22s %s\n",$k,ini_get($k));}'
# 代码常量
grep -n "MAX_FILE_SIZE\|ALLOWED_EXTENSIONS" config.php
# 数据层(只读探针,已删除)
# data/meta/business.sqlite → attachments DDL / 行数 / settings 附件与限流键实测值汇总:
upload_max_filesize = 2M
post_max_size = 8M
max_file_uploads = 20
max_execution_time = 0
attachment_allowed_ext = jpg,jpeg,png,gif,webp,pdf,zip,doc,docx
attachment_max_per_post = 5
attachment_max_size = 5242880
rate_upload_max = 5
rate_upload_window = 60
attachments 表行数 = 14(历史附件最大 55808 字节)