上传后必须在文件落盘(moveTo成功)后立即同步调用病毒扫描接口,严禁异步或使用临时路径;超时设为30秒以上,推荐Guzzle发送multipart请求;扫描失败必须中断业务流程,不可“尽力而为”。

上传后立即调用病毒扫描接口,别等入库再扫
文件上传和病毒扫描必须串行,且扫描动作得在文件落盘后、业务逻辑(比如存数据库、生成缩略图)之前执行。ThinkPHP 的 moveTo() 是关键节点——它把临时文件真正写入服务器磁盘,这时才有真实路径供第三方扫描工具或 API 使用。
常见错误是:先 moveTo(),再异步发消息给扫描服务,结果业务流程已推进,带毒文件已被分发或展示;或者压根没等 moveTo() 完成就拿 $_FILES['file']['tmp_name'] 去调扫描接口,而该路径在 PHP 请求结束后自动销毁,第三方根本读不到。
- 务必在
$file->moveTo($path)成功返回后再发起扫描请求 - 扫描接口超时设置至少 30 秒(大文件+网络延迟),ThinkPHP 的
Http::timeout()或 cURL 的CURLOPT_TIMEOUT要显式设高 - 若使用本地 ClamAV,注意
clamd进程是否运行、socket 权限是否允许 PHP 用户访问(如 Unix socket 路径/var/run/clamd.scan/clamd.sock)
用 Guzzle 发起同步 HTTP 扫描请求,别用 file_get_contents
第三方病毒扫描接口基本是 RESTful 形式(如微步、云甲、ClamAV 的 clamd-rest),需要 POST 文件二进制流或 base64,file_get_contents 不支持带文件体的 POST,也难控制 header 和超时,直接弃用。
Guzzle 是最稳妥的选择,它原生支持 multipart 表单上传,能复用连接、自动处理重定向和错误状态码,且 ThinkPHP 8.x 默认已集成(或可通过 composer require guzzlehttp/guzzle 补上)。
立即学习“PHP免费学习笔记(深入)”;
- 示例:上传文件到扫描接口
$client = new \GuzzleHttp\Client();
$response = $client->post('https://api.virus-scan.example.com/scan', [
'headers' => ['Authorization' => 'Bearer ' . $token],
'multipart' => [
[
'name' => 'file',
'contents' => fopen($realPath, 'r'),
'filename' => $file->getOriginalName(),
]
],
'timeout' => 45,
]);
$result = json_decode($response->getBody()->getContents(), true);
fopen($realPath, 'r') 必须指向 moveTo() 后的真实路径,不是临时路径base64_encode(file_get_contents($realPath)),但需确认大小限制(base64 膨胀约 33%,10MB 原文件变 13MB+)扫描失败时必须中断后续流程,不能“尽力而为”
很多开发者把扫描当成可选环节,返回非 200 就打日志继续走,这是高危操作。一旦接口超时、认证失败、网络抖动,就等于跳过了检测——攻击者可能正利用这个窗口上传 webshell。
ThinkPHP 中,应在控制器里显式判断扫描结果,且只认可明确的 “clean” 或 “safe” 字段值。任何其他响应(包括空响应、500、timeout、JSON 解析失败)都应抛出异常或直接返回错误,阻止 Db::insert() 或 Storage::put() 执行。
- 不要只检查
$response->getStatusCode() === 200,还得解析 body 看data.status或result字段 - 典型危险响应:
{"code":200,"msg":"success","data":{"status":"unknown"}}—— “unknown” 不等于安全,必须拒绝 - 建议统一封装一个
VirusScanner::scan($path): bool方法,内部处理重试(最多 1 次)、超时、格式校验,只返回 true/false
大文件上传要关掉 ThinkPHP 自动验证,改用流式扫描
ThinkPHP 默认对上传文件做 size 和 ext 验证,但病毒扫描接口往往有 100MB 甚至 500MB 限制,而 PHP 的 upload_max_filesize 和 post_max_size 又卡得更死。硬调大这两个值会导致内存暴涨、请求超时,不现实。
正确做法是:关闭框架级大小校验,改用流式上传 + 分块扫描(如果接口支持),或先传到对象存储(如 OSS/COS),再由后端拉取 URL 提交给扫描服务。
- 禁用验证:在
validate(['size'=>'0'])或控制器中跳过$request->validate()的 size 规则 - 改用
$_FILES['file']['tmp_name']+fopen(..., 'rb')分片读取,每次读 2MB 发一次 chunk 到扫描接口(需接口支持Content-Range) - 更推荐方案:用
Storage::disk('oss')->putFile()直接上传到云存储,拿到外网可访问 URL,再 POST 这个 URL 给扫描接口(避免走 PHP 内存中转)



















