默認情況下,瀏覽器對文本、圖片、PDF 等已註冊 MIME 類型會直接預覽。若需強制彈出「另存為」對話框,必須發送正確的 HTTP 響應頭。本文給出最小可用代碼與生產級封裝。
Content-Type: application/octet-stream // 通用二進制流,阻止瀏覽器按 MIME 預覽
Content-Disposition: attachment; filename="xxx.pdf" // 指示為附件,建議保存文件名
filename 必須經過 RFC 5987 編碼,兼容中文文件名:
filename="fallback.txt"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt
<?php
$filePath = '/data/files/report_202406.pdf';
$fileName = '2024年上半年報告.pdf';
if (!is_file($filePath)) {
http_response_code(404);
exit('文件不存在');
}
$size = filesize($filePath);
$encodedName = rawurlencode($fileName);
header('Content-Type: application/octet-stream');
header('Content-Length: ' . $size);
header("Content-Disposition: attachment; filename=\"{$encodedName}\"; filename*=UTF-8''{$encodedName}");
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
readfile($filePath); // 直接輸出文件內容,不佔用 PHP 內存
<?php
class FileDownloader
{
public function send(string $filePath, string $downloadName = '', array $options = []): void
{
if (!is_file($filePath)) {
http_response_code(404);
throw new RuntimeException('文件不存在');
}
$downloadName = $downloadName ?: basename($filePath);
$size = filesize($filePath);
$mime = $this->getMime($filePath);
$etag = md5($filePath . $size . filemtime($filePath));
$lastModified = gmdate('D, d M Y H:i:s', filemtime($filePath)) . ' GMT';
// 緩存驗證
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
http_response_code(304); return;
}
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($filePath)) {
http_response_code(304); return;
}
// Range 斷點續傳
$start = 0; $end = $size - 1; $partial = false;
if (isset($_SERVER['HTTP_RANGE'])) {
if (preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int)$m[1];
$end = $m[2] !== '' ? (int)$m[2] : $size - 1;
if ($start >= $size || $end >= $size || $start > $end) {
header('Content-Range: bytes */' . $size);
http_response_code(416); return;
}
$partial = true;
}
}
// 響應頭
header('Content-Type: ' . $mime);
header('Accept-Ranges: bytes');
header('Content-Length: ' . ($partial ? $end - $start + 1 : $size));
header('ETag: ' . $etag);
header('Last-Modified: ' . $lastModified);
header('Cache-Control: private, max-age=3600');
$encoded = rawurlencode($downloadName);
header("Content-Disposition: attachment; filename=\"{$encoded}\"; filename*=UTF-8''{$encoded}");
if ($partial) {
http_response_code(206);
header("Content-Range: bytes {$start}-{$end}/{$size}");
}
if ($_SERVER['REQUEST_METHOD'] === 'HEAD') return;
// 輸出文件流
$fp = fopen($filePath, 'rb');
fseek($fp, $start);
$buffer = 1024 * 8; // 8KB
while (!feof($fp) && ($partial ? ftell($fp) <= $end : true)) {
$read = min($buffer, $partial ? $end - ftell($fp) + 1 : $buffer);
echo fread($fp, $read);
flush();
}
fclose($fp);
}
private function getMime(string $path): string
{
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $path);
finfo_close($finfo);
return $mime ?: 'application/octet-stream';
}
}
// 使用
$downloader = new FileDownloader();
$downloader->send('/data/files/report.pdf', '2024報告.pdf');
| 現象 | 原因 | 解決 |
|---|---|---|
| 中文文件名亂碼/截斷 | 未做 RFC 5987 編碼 | 同時發送 filename (URL 編碼) 和 filename* (UTF-8) |
| 大文件記憶體耗盡 | 用 file_get_contents 全讀入內存 | 用 readfile() 或分塊 fread 輸出 |
| 無法斷點續傳 | 未處理 Range 請求 | 實現 206 Partial Content 邏輯 |
| Nginx 代理截斷輸出 | proxy_buffering on 預設緩衝 | 場所加 proxy_buffering off; 或 X-Accel-Redirect 內部重定向 |
| Session 阻塞並發下載 | 默認 Session 文件鎖 | 下載前 session_write_close() 釋放鎖 |
將文件傳輸卸載給 Web 服務器,PHP 僅發送內部重定向頭:
<?php
// Apache + mod_xsendfile
header('X-Sendfile: /absolute/path/to/file.pdf');
// Nginx
header('X-Accel-Redirect: /protected/files/file.pdf');
// 需 nginx.conf: location /protected/ { internal; alias /data/files/; }
此法可繞過 PHP 內存/執行時間限制,支援高併發大文件分發,是生產環境首選。