[审计]批量上传功能 — 核查与实施方案

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:36:34

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/批量上传功能核查实施方案_2026-09-23.md。

批量上传功能 — 核查与实施方案

核查日期:2026-09-23

核查方式:全仓 grep + 关键文件逐行读 + PHP ini 实测 + 数据层只读探针

结论状态:待老搭档确认目标场景后再动代码(本文不含任何已落地的改动)


一、结论摘要

服务端的批量骨架已经存在,不需要重写。 真正的阻塞不在业务代码,而在 PHP ini 上限 与 限流口径 这两处环境级约束上。

四句话概括:

  1. 表单附件(发帖 / 编辑主题 / 回帖 / 编辑回帖)已经是多选 + 服务端循环落盘,能用,但被 post_max_size=8M 卡住总量。
  2. 编辑器正文图(富文本里插图)只能一张一张传,前端 input 没有 multiple,且每张一个请求 → 撞 rate_upload_max=5/60s。
  3. 本机 upload_max_filesize=2M,而代码与前端都宣称 5MB → 2MB 以上的文件必然失败,且错误信息无法解释原因。
  4. 生产(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[] + multipleThreadController::show 内联循环(:156-172)部分支持
编辑回帖附件app/Views/post/edit.php:28PostController::edit 循环部分支持
编辑器正文图assets/js/editor.js:1269-1279input 无 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)

后果:无法回答「这个附件谁传的 / 什么时候传的」。批量上传若要加按用户配额或审计,得先把这两列补上。属于顺手可修项。


四、附带发现(低风险,建议一并处理)

#位置问题
1editor.js:1148-1161粘贴板里有多张图时,只处理第一张,其余静默丢弃
2editor.js:2013前端 5MB 校验硬编码,与 ini 的 2M 不一致 → 应改为服务端下发上限
3editor.js:2032-2063上传无进度反馈、无失败重试;失败只弹一次 alert
4editor.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_filesize2M≥ 单文件上限(如 5M 或 10M)必须 ≥ attachment_max_size
post_max_size8M≥ 单文件上限 × 批量数 + 1M 余量例如 5M × 6 + 1M ≈ 32M
rate_upload_max5≥ 单次批量张数后台可配,无需改码
attachment_max_per_post5按需,建议 5~10后台可配

代价:几乎为零。收益:立刻解决阻塞点 1/2/3。

前置:必须先拿到生产环境的 ini 现值,本机值与生产不一定相同。

注意:post_max_size 调大后,发帖表单的整体请求体也变大,需确认 IIS 侧 maxAllowedContentLength(web.config)不成为新瓶颈 —— 这是个容易被忽略的第二道闸门。

方案 B:编辑器多选 + 串行队列(推荐主推)

改动范围:仅 assets/js/editor.js + 少量 i18n 键。

  1. editor.js:1269 给 fileInput 加 multiple
  2. change 回调由「取 files[0]」改为遍历 files,串行上传(复用现有 uploadAndInsertImage 的 placeholder 机制,改成队列 + 逐个 replace)
  3. 失败时保留 placeholder 并标红,支持单张重试
  4. 可选:加 xhr.upload.onprogress 进度条
  5. 顺带修 paste 分支的多图丢弃(editor.js:1148)

代价:单文件改动,不动 PHP、不动数据、不动插件。

风险:低。串行上传天然避开限流与插件预算问题。

回滚:git checkout assets/js/editor.js 一行还原。

方案 C:/api/upload 支持单请求多文件(真批量,但代价最大)

改动范围:ApiController::upload() + editor.js + 限流口径。

  1. 后端兼容两种形态:$_FILES['file'] 单文件(旧)或 file[] 数组(新),循环调 Upload::file()
  2. 返回结构改为 {success, files:[{url,...}], errors:[...]},必须保留旧字段(url)避免破坏既有调用
  3. 限流口径必须同步从「请求数」改为「文件数」 —— 否则一次请求传 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),档位不混

九、待确认项(需老搭档拍板)

  1. 目标是哪个场景? 编辑器正文图一次选多张 / 表单附件一次选多个 / 两者都要
  • 这决定走方案 B 还是 B+D
  1. 单文件上限要提到多少? 现状 ini 2M vs 代码 5M 的矛盾需要一个权威值
  2. 单次批量上限多少个? 影响 rate_upload_max 与 post_max_size 的取值
  3. 生产环境的 ini 现值是多少? 本机 2M / 8M / 20 不能代表生产(IIS + PHP 8.4)
  4. web.config 的 maxAllowedContentLength 现值? IIS 侧还有一道独立的内容长度闸门,调大 post_max_size 后可能撞它
  5. 是否接受「批量时云端镜像延迟,靠后台补齐」? 这是插件预算模型决定的,不是缺陷

附录:本次核查用到的证据命令

# 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 字节)

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 1 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录