☰
PHP文件上传下载实战:安全校验、大文件分片与断点续传源码
2026/11/5 4:49:35 网站建设 项目流程

PHP文件上传下载这块内容,我前后在好几个项目里反复写过、改过、也踩过。标题里说的“实战源码大全”,落到实际工作里其实不是一堆复制粘贴的代码片段,而是一整套要考虑边界、考虑攻击面、考虑大文件和并发的东西。很多刚接触的人觉得上传就是move_uploaded_file,下载就是readfile,两行代码就完事;真放到线上,用户传一个 300MB 的视频、传一个文件名里带中文和空格的表格、传一个伪装成图片的脚本、下载到一半断网再续传,问题就全冒出来了。这篇内容就是把这些年我在 PHP 文件上传下载上积累的做法整理出来,从整体设计到具体代码,从参数计算到排错思路,尽量做到照着改就能用。适合刚上手 PHP 业务开发的同学,也适合已经写过上传但被各种诡异问题折磨过的朋友。

1. 先把上传链路拆开看:从浏览器到落盘到底经过了几层

1.1 一次上传的完整生命周期

我习惯在动手写代码之前,先在纸上把一次上传的路径画出来,因为绝大多数“玄学问题”都出在这条链路的某一层被悄悄截断了。浏览器端拿到一个<input type="file">选中的文件,构造一个multipart/form-data的请求体,里面用一段 boundary 把各个字段切开,文件字段包含一段自己声明的Content-Type和原始文件名。这个声明完全由浏览器给出,客户端可以随意伪造,所以它只能当作参考,不能当作依据。

请求到了 Web 服务器,Nginx 或 Apache 先做一层判断:请求体大小有没有超过client_max_body_size(Nginx 的配置项),超了直接返回 413,PHP 代码一行都不会执行。过了这层,PHP 的 SAPI 开始解析请求体,把普通字段塞进$_POST,把文件字段塞进$_FILES,同时把文件内容写到upload_tmp_dir指定的临时目录里,文件名通常是类似phpXXXXXX的随机串。这一步是 PHP 内核完成的,和你的代码无关。

脚本开始执行后,你拿到的$_FILES['file']是一个包含name、type、tmp_name、error、size五个键的数组。tmp_name指向那个临时文件,error是这次上传的状态码。如果你在脚本结束前没有调用move_uploaded_file把它搬走,PHP 会在请求结束时自动删掉临时文件。这也是为什么“先把文件留在临时目录,等另一个请求来处理”这种思路行不通——临时文件的生命周期只到当前请求结束。

move_uploaded_file这个函数有两个作用:一是检查源路径确实是本次请求上传上来的文件(内部走is_uploaded_file的校验逻辑),二是把文件移动到目标位置。用rename或copy也能搬文件,但会丢掉这层安全检查,万一tmp_name被构造成别的路径,就可能被拿来做任意文件读取。所以这一步不要图省事。

1.2 为什么我不建议直接用 $_FILES['file']['name'] 做文件名

原始文件名里能塞的东西比想象中多。首先是路径分隔符,老版本客户端可能传上来../../etc/passwd这种;其次是特殊字符,空格、引号、分号、换行都可能出现,落到 shell 命令或 HTTP 头里就是事故;再次是双扩展名,shell.php.jpg在某些配置下会被当成 PHP 解析;还有大小写绕过,.PHP、.PhP都可能漏过只做了小写比较的校验。中文名还涉及编码问题,Windows 客户端可能用 GBK,Linux 服务端按 UTF-8 处理,最后存下来的名字是一堆乱码。

我的做法是:存储名和展示名彻底分开。存储名用bin2hex(random_bytes(16))生成 32 位十六进制串,加上从白名单里取出来的扩展名,形如a3f1...9c.jpg。原始文件名单独存到数据库的一个字段里,只在列表展示和下载时使用。这样即使原始名里有再多奇怪字符,也不会影响磁盘上的路径。下载时把原始名放进Content-Disposition的filename*参数里,按 RFC 5987 做 URL 编码,中文名就能正常显示。

另外要养成一个习惯:任何来自用户的路径片段,在使用前都过一遍basename()。它会把../../这类东西砍掉,虽然不能解决所有问题,但能挡掉最低级的一类目录穿越。

1.3 存储拓扑:本地盘、对象存储与临时目录的分工

小项目直接存本地盘最省事,但要提前想清楚目录怎么分。我一般按日期分目录,形如storage/uploads/202506/12/,单个目录里的文件数控制在几千以内,避免ls或文件系统索引变慢。如果预计量级很大,可以再按文件哈希的前两位做二级打散。

上传目录的位置有讲究。理想情况是放在 Web 根目录之外,通过 PHP 读取后输出,这样任何脚本都无法被直接访问执行。如果因为历史原因必须放在 Web 根目录下,至少要做两件事:一是在该目录下禁用脚本执行,二是不允许目录列表。前者在 Nginx 里用location匹配该目录下的.php并deny all,后者用autoindex off。

对象存储适合文件量大、需要多机共享、需要 CDN 加速的场景。这时候 PHP 的角色从“搬文件”变成“发凭证”:前端拿着后端签发的临时上传凭证直传对象存储,后端只记录元数据;下载时后端做权限校验,校验通过再返回一个有时效的签名地址,或者由后端反向代理输出。这样能把大流量从应用服务器上挪走,但代价是链路更长,排查问题时要多看好几层日志。

2. 上传核心参数与校验逻辑:把错误挡在落盘之前

2.1 php.ini 里那几个真正会被踩到的参数

这几个参数值得挨个说清楚,因为它们之间有关联,改一个不改另一个就会出怪事。

参数默认值作用常见坑
file_uploadsOn是否允许上传关掉后$_FILES直接为空
upload_max_filesize2M单个文件上限只改这个不改下面那个,大文件仍然失败
post_max_size8M整个请求体上限必须大于upload_max_filesize
max_file_uploads20单次请求文件数上限超出部分被静默丢弃,不报错
upload_tmp_dir系统临时目录临时文件落地位置分区空间不足,大文件写不进去
max_execution_time30脚本执行时间大文件搬运超时,尤其是走网络存储
memory_limit128M内存上限用file_get_contents读大文件直接爆

post_max_size和upload_max_filesize的关系要重点说。假设你要支持 20MB 的文件,upload_max_filesize设 20M,那post_max_size至少要设到 22M 甚至 24M,因为请求体里除了文件内容,还有 boundary 分隔符、其他表单字段、文件名的编码开销。我一般按“目标大小 × 1.2 + 1M”来估。如果只改upload_max_filesize到 20M 而post_max_size还是默认的 8M,结果就是上传 10MB 文件时$_FILES整个为空,连错误码都拿不到,非常容易误判成前端问题。

还有一个隐蔽的点:post_max_size被突破时,PHP 不会给出UPLOAD_ERR_*里的任何一个错误码,$_POST和$_FILES都是空数组。所以判断逻辑里必须先看$_SERVER['CONTENT_LENGTH']和$_FILES是否为空,再去看具体错误码,否则用户看到的是“请选择文件”,实际是超限了。

max_file_uploads也值得留意。默认 20,做批量上传时如果用户一次选了 30 张图,超出的 10 张不会报错,就是没了。要么把这个值调大,要么在前端限制选择数量并在后端明确提示。

2.2 错误码对照与统一返回

$_FILES['file']['error']的取值我整理成一张表,实际排查时对着看最快。

值常量含义处理方式
0UPLOAD_ERR_OK成功继续后续校验
1UPLOAD_ERR_INI_SIZE超过upload_max_filesize提示大小上限,记录配置值
2UPLOAD_ERR_FORM_SIZE超过表单MAX_FILE_SIZE前端限制被绕过,按 1 处理
3UPLOAD_ERR_PARTIAL只传了一部分网络中断,提示重试
4UPLOAD_ERR_NO_FILE没有文件被上传用户没选文件
6UPLOAD_ERR_NO_TMP_DIR临时目录不存在运维问题,检查upload_tmp_dir
7UPLOAD_ERR_CANT_WRITE临时文件写入失败磁盘满或权限问题
8UPLOAD_ERR_EXTENSION被某个扩展中断查php -m里的上传相关扩展

5 这个值在历史文档里存在,但实际不使用,可以忽略。写判断的时候不要用if ($error == 4)这种散落的魔法数字,定义一个映射数组,把错误码翻译成给用户看的文案,同一个码在不同业务里文案可能不一样,集中管理方便改。

2.3 白名单校验的三层结构

单靠扩展名校验是不够的,我用的是三层:扩展名白名单、真实 MIME 探测、关键类型的结构校验。

第一层,扩展名。取pathinfo($name, PATHINFO_EXTENSION)之后转小写,和白名单数组比对。这里要注意pathinfo对多扩展名的处理,shell.php.jpg取出来是jpg,看起来没问题,但某些服务器配置下会按最后一个能识别的扩展名解析,也可能按第一个。所以在重命名阶段,存储名直接用白名单里的扩展名,不要保留用户给的多段扩展名,从根上断掉这个风险。

第二层,真实 MIME。$_FILES['file']['type']是客户端给的,完全不可信。要用finfo_file(finfo_open(FILEINFO_MIME_TYPE), $tmp_name)读文件头判断。图片、PDF、ZIP、Office 文档的头部特征都很明确,和扩展名对不上就拒绝。这一层能挡住大部分改扩展名的伪装。

第三层,结构校验。图片用getimagesize(),返回 false 就不是有效图片;PDF 检查开头是不是%PDF-;ZIP 类文件检查前四个字节是不是PK\x03\x04。这一层挡的是“文件头伪造”的情况,虽然成本高一点,但对公开上传入口很值。

对图片还有一个更彻底的处理:二次渲染。用 GD 或 Imagick 读入图片再重新输出一遍,能剥离掉 EXIF 里的敏感信息,也能让藏在图片数据段里的脚本载荷失效。代价是动图会丢帧、图片质量可能轻微变化,业务上能接受就用。

2.4 目录与权限:上传目录千万别放在可执行路径下

权限这块的经验是:目录 755、文件 644,属主是 PHP 进程的运行用户,不要用 777。777 意味着任何本地用户都能改写,一旦有别的漏洞就能被利用。

目录结构上,我倾向于把上传目录放在项目根目录之外的storage目录里,PHP 通过绝对路径访问。如果放在public/uploads下,务必在 Web 服务器配置里加一段:该目录下的脚本文件一律拒绝。Nginx 的写法是在 server 块里加一条针对该路径的正则 location,返回 403;Apache 可以在目录下放.htaccess里写php_flag engine off,但要注意.htaccess本身也可能被上传覆盖,所以更可靠的是在主配置里限制。

还有一点容易被忽略:上传目录不要和代码目录混在一起。混在一起的后果是,备份、部署、清理临时文件这些操作都可能误伤,而且一旦出现目录遍历漏洞,攻击面会直接扩大到整个代码库。

3. 下载环节:不只是 readfile 那么简单

3.1 响应头逐个说清楚

下载功能的核心其实就是把响应头写对。下面这几个头各有各的用途,少一个就可能出问题。

Content-Type决定浏览器怎么处理这个响应。想强制下载就用application/octet-stream,想让浏览器预览(比如 PDF)就用真实的 MIME。Content-Disposition用attachment触发下载,文件名通过filename*=UTF-8''加 URL 编码支持中文,同时保留一个filename=的 ASCII 兜底给老客户端。

Content-Length必须给准确,否则浏览器不知道总大小,进度条会不准甚至卡住。Accept-Ranges: bytes告诉客户端支持断点续传。X-Content-Type-Options: nosniff防止浏览器猜类型,对安全有帮助。如果是敏感文件,再加Cache-Control: private, no-store,避免中间层缓存。

一个常见的错误是下载下来的文件比原文件大几字节,通常是输出之前有 BOM 或者多余的空行。解决办法是在输出文件内容的脚本里不要写?>结束标签,或者在输出前调ob_end_clean()清掉所有缓冲区。

3.2 大文件下载的内存控制

file_get_contents读文件再echo是最容易踩坑的写法,一个 500MB 的视频直接就把memory_limit打爆了。正确的做法是分块读分块写:

$fp = fopen($path, 'rb'); while (!feof($fp)) { echo fread($fp, 8192); flush(); } fclose($fp);

readfile内部也是类似的流式处理,用起来更简洁,但要确认输出缓冲已经关掉,否则数据还是会在缓冲区里堆积。如果用了框架,框架自身的输出缓冲也要先清掉。

搬大文件还要考虑max_execution_time。上传 20MB 和下载 500MB 的耗时完全不是一个量级,尤其是磁盘 IO 慢的时候。可以在下载脚本开头调set_time_limit(0),让脚本不被超时打断。但要注意,这只是 PHP 层的限制,前面还有 Nginx 的send_timeout、fastcgi_read_timeout等参数,这些也要一起调。

3.3 Range 请求与断点续传实现

断点续传的本质是客户端发一个带Range: bytes=start-end的请求,服务端返回 206 状态码和对应的片段。实现上要处理三种情况:没有 Range 头就正常返回 200 全量;Range 合法就返回 206 加Content-Range;Range 不合法(比如起点超过文件大小)就返回 416 加Content-Range: bytes */总大小。

$size = filesize($path); $start = 0; $end = $size - 1; $code = 200; if (isset($_SERVER['HTTP_RANGE'])) { if (preg_match('/bytes=(\d*)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) { if ($m[1] !== '') { $start = (int)$m[1]; } if ($m[2] !== '') { $end = (int)$m[2]; } if ($start > $end || $start >= $size) { header('Content-Range: bytes */' . $size); http_response_code(416); exit; } $end = min($end, $size - 1); $code = 206; } }

拿到区间之后,用fseek定位再按块输出,Content-Length要给$end - $start + 1。多段 Range(逗号分隔多个区间)的实现复杂度高很多,实际业务里少见,我一般直接忽略这种情况,按全量返回,客户端也能正常工作。

3.4 下载权限与防盗链

如果文件路径是download.php?id=123这种形式,一定要在输出前做权限校验:这个文件属不属于当前用户、当前用户有没有该资源的访问权。校验不通过就返回 403,不要泄露文件是否存在的信息。

对于公开分享类的下载,我倾向用带时效的签名链接:sign = hash_hmac('sha256', $fileId . $expire, $secret),链接里带上expire和sign,服务端校验签名和过期时间。这样链接即使被转发,过了时效也失效,而且不需要每次都查会话。签名里一定要包含过期时间和文件标识,否则一个签名能套用所有文件。

防盗链可以从 Referer 和签名两个角度做,Referer 可以被伪造,只能算辅助手段。真正有效的是签名加频率限制:同一 IP 或同一用户单位时间内请求次数超阈值就限流,防止被批量拉取。

4. 实操:一套可复现的上传下载最小系统

4.1 目录结构与基础配置

我用的结构大致是这样:项目根下有public(Web 根)、storage(上传文件、临时分片)、src(业务代码)、config。storage通过常量或配置指向绝对路径,代码里不出现相对路径拼接,减少出错。

project/ ├── public/ │ ├── index.php │ └── download.php ├── storage/ │ ├── uploads/20250612/ │ └── chunks/ ├── src/ │ ├── UploadService.php │ └── DownloadService.php └── config/app.php

配置里放三个东西:允许的扩展名数组、允许的 MIME 数组、单文件大小上限。大小上限和php.ini里的值保持一致,代码里再校验一次,形成双保险。

4.2 上传接口的完整实现

下面这段是我常用的骨架,去掉了框架依赖,核心逻辑都在。

<?php declare(strict_types=1); const MAX_SIZE = 20 * 1024 * 1024; const ALLOW_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'zip', 'mp4']; const ALLOW_MIME = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'application/zip', 'video/mp4', ]; const UPLOAD_ROOT = '/var/app/storage/uploads'; function handleUpload(array $file): array { if (!isset($file['error'])) { return [false, '没有接收到文件']; } if ($file['error'] !== UPLOAD_ERR_OK) { $map = [ UPLOAD_ERR_INI_SIZE => '文件超过服务端限制', UPLOAD_ERR_FORM_SIZE => '文件超过表单限制', UPLOAD_ERR_PARTIAL => '文件只上传了一部分,请重试', UPLOAD_ERR_NO_FILE => '请选择文件', UPLOAD_ERR_NO_TMP_DIR => '服务端临时目录不可用', UPLOAD_ERR_CANT_WRITE => '服务端写入失败', UPLOAD_ERR_EXTENSION => '上传被服务端扩展中断', ]; return [false, $map[$file['error']] ?? '未知上传错误']; } if ($file['size'] <= 0 || $file['size'] > MAX_SIZE) { return [false, '文件大小不合法']; } $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if (!in_array($ext, ALLOW_EXT, true)) { return [false, '不支持的文件类型']; } $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mime, ALLOW_MIME, true)) { return [false, '文件内容类型不匹配']; } if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) { if (@getimagesize($file['tmp_name']) === false) { return [false, '图片文件已损坏或格式不符']; } } $dir = UPLOAD_ROOT . '/' . date('Ym'); if (!is_dir($dir) && !mkdir($dir, 0755, true)) { return [false, '存储目录创建失败']; } $name = bin2hex(random_bytes(16)) . '.' . $ext; $target = $dir . '/' . $name; if (!move_uploaded_file($file['tmp_name'], $target)) { return [false, '文件保存失败']; } chmod($target, 0644); return [true, ['path' => $target, 'name' => $name, 'origin' => $file['name']]]; }

这段代码里有几个点值得强调。错误码映射放在函数内部,是因为不同接口的文案需求不一样;size <= 0的判断是为了挡掉空文件;MIME 校验用finfo_file读临时文件,不用客户端传的type;图片额外做getimagesize;落盘名用随机串加白名单扩展名,原始名单独返回给上层存库。chmod是为了避免某些环境下 umask 导致文件权限过宽。

4.3 下载接口与断点续传对接

下载接口要做的第一件事是校验,第二件事才是输出。校验包括登录态、资源归属、签名有效性。输出部分按前面说的 Range 逻辑处理。

<?php declare(strict_types=1); $id = $_GET['id'] ?? ''; $row = findFileById($id); // 上层自己实现,返回 path/name/size if (!$row || !is_file($row['path'])) { http_response_code(404); exit; } $size = filesize($row['path']); $start = 0; $end = $size - 1; $code = 200; if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d*)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) { $start = $m[1] === '' ? 0 : (int)$m[1]; $end = $m[2] === '' ? $size - 1 : (int)$m[2]; if ($start >= $size || $start > $end) { header('Content-Range: bytes */' . $size); http_response_code(416); exit; } $end = min($end, $size - 1); $code = 206; } $encodedName = rawurlencode($row['name']); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . preg_replace('/[^\x20-\x7e]/', '_', $row['name']) . '"; filename*=UTF-8\'\'' . $encodedName); header('Content-Length: ' . ($end - $start + 1)); header('Accept-Ranges: bytes'); header('X-Content-Type-Options: nosniff'); if ($code === 206) { header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size); } http_response_code($code); set_time_limit(0); while (ob_get_level() > 0) { ob_end_clean(); } $fp = fopen($row['path'], 'rb'); fseek($fp, $start); $left = $end - $start + 1; while ($left > 0 && !feof($fp)) { $chunk = fread($fp, min(8192, $left)); if ($chunk === false || $chunk === '') { break; } echo $chunk; flush(); $left -= strlen($chunk); } fclose($fp);

Content-Disposition那段里,filename给的是把非 ASCII 字符替换成下划线的简化版,filename*给的是完整编码版,现代浏览器优先用后者。两个都写是为了兼容。输出前清缓冲这一步很关键,漏掉的话返回的文件会比原文件多几个字节,某些格式就损坏了。

4.4 前端分片上传的关键参数

超过二三十兆的文件,单请求上传在弱网下体验很差,进度条卡在 99% 然后失败是常事。这时候用分片。前端用File.slice按固定大小切开,每片单独发请求,后端存到storage/chunks/{uploadId}/下,全部到齐后按序号拼接。

const CHUNK_SIZE = 2 * 1024 * 1024; async function upload(file) { const total = Math.ceil(file.size / CHUNK_SIZE); const uploadId = await initUpload(file.name, file.size); for (let i = 0; i < total; i++) { const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); const fd = new FormData(); fd.append('uploadId', uploadId); fd.append('index', String(i)); fd.append('total', String(total)); fd.append('chunk', blob); await fetch('/upload_chunk.php', { method: 'POST', body: fd }); } const res = await fetch('/upload_merge.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId }), }); return res.json(); }

参数选择上,2MB 一片是折中:太小请求数多、HTTP 头开销占比高;太大弱网下单片超时风险高。移动端我一般降到 1MB 甚至 512KB。uploadId由后端生成,避免前端拼出冲突的 ID。合并阶段要按 index 排序,逐片读取追加,最后算一次整体哈希和前端算出来的对比,不一致就作废重传。合并完成后删掉分片目录,避免磁盘被撑满。

5. 常见问题与排查实录

5.1 问题速查表

现象最可能的原因排查方向
上传后$_FILES为空post_max_size被突破看CONTENT_LENGTH、改配置
只有第一个文件成功max_file_uploads限制调大并检查前端字段名
Nginx 返回 413client_max_body_size太小改 Nginx 配置并 reload
大文件上传到一半失败临时目录分区满或执行超时查磁盘、查超时配置
下载文件名是乱码Content-Disposition编码不对补filename*
下载文件损坏输出前有缓冲内容清空输出缓冲
图片上传成功但打不开内容与扩展名不匹配加getimagesize校验
断点续传位置错乱Range 解析没处理边界检查 416 分支和fseek
并发上传同名覆盖用了原始文件名改随机存储名
分片合并后哈希不符分片顺序错或丢片校验index连续性和总数

5.2 几个反直觉的坑

第一个坑是临时文件被提前清理。有些清理脚本会定期扫/tmp下超过一定时间的文件,如果上传中途或者处理中途被删,move_uploaded_file就会失败。我们的做法是把upload_tmp_dir指到项目自己的目录,清理策略自己控制,只删超过 24 小时的文件,并且避开正在处理的请求。

第二个坑是finfo对某些文件判断不一致。不同版本的 magic 数据库识别结果可能有差异,同一个文件在开发机和线上服务器上识别出的 MIME 不一样。所以白名单不要卡得太死,允许的 MIME 列表要比扩展名列表宽一点,否则会出现“本地能传线上不能传”的问题。

第三个坑是中文文件名的编码。Windows 环境下某些客户端传上来的文件名是 GBK 编码,服务端按 UTF-8 处理会变成乱码。可以在入口处检测编码,用mb_detect_encoding判断后统一转成 UTF-8,转换失败就丢弃原始名只用随机名,反正展示名可以后续让用户自己改。

第四个坑是Content-Length与实际输出不一致。有些服务器开了压缩,Content-Length会被改写,导致下载卡住。下载接口的响应要显式关掉压缩,或者干脆不要依赖Content-Length做进度计算。

第五个坑是并发场景下的目录创建。两个请求同时判断目录不存在、同时mkdir,第二个会报错。代码里要判断is_dir之后mkdir失败但目录已存在的情况,把它当作成功处理,而不是直接返回错误。

6. 安全加固清单与性能取舍

6.1 一份可以直接照着勾的清单

上传侧:存储名随机化;扩展名白名单,不保留用户给的多段扩展名;MIME 用finfo读文件头判断;图片做getimagesize或二次渲染;上传目录不在可执行路径下,或显式禁用脚本执行;目录权限 755、文件 644;限制单文件大小和单请求文件数;校验失败的文件不要留在临时目录里;记录上传日志,包含时间、用户、原始名、存储名、大小、结果。

下载侧:输出前校验资源归属;路径拼接过basename,或者干脆不接收路径参数只接收 ID;带时效的签名链接;X-Content-Type-Options: nosniff;敏感文件加no-store;限流防批量拉取;不把真实磁盘路径暴露在任何返回里。

还有一个容易忘的点:错误信息不要泄露内部路径。把move_uploaded_file的警告直接输出到页面上,等于告诉别人你的目录结构。用@抑制加自定义错误日志,对外只返回通用文案。

6.2 性能上的几个取舍

同步上传适合小文件,逻辑简单,出问题好查。分片上传用户体验好,但后端要处理合并、清理、并发,代码量翻几倍。直传对象存储能卸掉应用服务器的带宽压力,但权限模型要重新设计,签名一旦泄露就有风险。

我一般的判断标准是:单文件普遍在 5MB 以下、日上传量不大,就用同步上传;有视频、有大附件、或者移动端用户多,就上分片;文件量级到 TB 级别、需要 CDN 分发,才考虑直传对象存储。不要一开始就上最复杂的方案,维护成本会压过收益。

清理策略也要提前设计。分片目录、临时文件、失败的上传残留,如果不定时清理,磁盘迟早会满。我通常写一个定时任务,每天凌晨扫一遍,删除超过 24 小时的孤儿分片和超过 7 天且没有关联记录的临时文件,删除前先确认没有被数据库引用。

最后分享一个我自己的习惯:在测试环境准备一组“坏样本”,包括改了扩展名的脚本、截断的图片、超过限制的大文件、带中文和空格的文件名、零字节文件。每次改完上传相关代码,把这组样本跑一遍,比看代码审查有效得多。这套样本我维护了好几年,每次线上出过的问题都会补一个进去,慢慢就成了一份自己的回归清单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询