正確的下載回應需包含關鍵標頭:
| 標頭 | 作用 | 範例值 |
|---|---|---|
| Content-Type | MIME 類型,application/octet-stream 強制下載 | application/octet-stream |
| Content-Disposition | 建議檔名,attachment 觸發下載而非預覽 | attachment; filename="report.pdf" |
| Content-Length | 檔案大小,支援進度條、斷點續傳 | 104857600 |
| Accept-Ranges | 宣告支援範圍請求 (bytes) | bytes |
| Content-Transfer-Encoding | 傳輸編碼 | binary |
| Cache-Control / Pragma | 快取控制 | no-cache, must-revalidate |
<?php
function downloadFile(string $filePath, string $fileName = ''): void {
// 1. 檢查檔案存在
if (!is_file($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit('檔案不存在或不可讀');
}
$fileName = $fileName ?: basename($filePath);
$fileSize = filesize($filePath);
// 2. 中文檔名 RFC 5987 編碼 (支援現代瀏覽器)
$encodedName = rawurlencode($fileName);
$contentDisposition = "attachment; filename*=UTF-8''{$encodedName}; filename=\"{$fileName}\"";
// 3. 發送標頭
header('Content-Type: application/octet-stream');
header('Content-Disposition: ' . $contentDisposition);
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
// 4. 關閉輸出緩衝,避免記憶體溢出
while (ob_get_level()) ob_end_clean();
// 5. 分塊輸出 (每次 8KB)
$handle = fopen($filePath, 'rb');
if ($handle === false) {
http_response_code(500);
exit('無法開啟檔案');
}
while (!feof($handle) && connection_status() === CONNECTION_NORMAL) {
echo fread($handle, 8192);
flush(); // 立即發送給客戶端
}
fclose($handle);
exit;
}
// 使用
downloadFile('/data/files/report_2024.pdf', '2024年報表.pdf');
<?php
function downloadWithRange(string $filePath, string $fileName = ''): void {
if (!is_file($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit('File not found');
}
$fileName = $fileName ?: basename($filePath);
$fileSize = filesize($filePath);
$encodedName = rawurlencode($fileName);
// 預設標頭
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename*=UTF-8\'\'' . $encodedName);
header('Accept-Ranges: bytes');
header('Cache-Control: no-cache, must-revalidate');
// 處理 Range 標頭
$rangeStart = 0;
$rangeEnd = $fileSize - 1;
$statusCode = 200;
if (isset($_SERVER['HTTP_RANGE'])) {
$range = $_SERVER['HTTP_RANGE'];
if (preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) {
$rangeStart = intval($matches[1]);
$rangeEnd = $matches[2] !== '' ? intval($matches[2]) : $fileSize - 1;
// 範圍驗證
if ($rangeStart >= $fileSize || $rangeEnd >= $fileSize || $rangeStart > $rangeEnd) {
header('Content-Range: bytes */' . $fileSize);
http_response_code(416); // Range Not Satisfiable
exit;
}
$statusCode = 206; // Partial Content
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
}
}
$contentLength = $rangeEnd - $rangeStart + 1;
header('Content-Length: ' . $contentLength);
http_response_code($statusCode);
while (ob_get_level()) ob_end_clean();
$handle = fopen($filePath, 'rb');
fseek($handle, $rangeStart);
$remaining = $contentLength;
$bufferSize = 8192;
while ($remaining > 0 && !feof($handle) && connection_status() === CONNECTION_NORMAL) {
$readSize = min($bufferSize, $remaining);
echo fread($handle, $readSize);
flush();
$remaining -= $readSize;
}
fclose($handle);
exit;
}
fread($handle, 8192) 固定緩衝區,記憶體佔用恆定 ~8KBwhile (ob_get_level()) ob_end_clean(); 避免 PHP 緩衝整個檔案ignore_user_abort(true) 確保傳輸中斷不殘留進程 (可選)set_time_limit(0) 移除執行時間限制realpath($filePath) 驗證在允許目錄內header('X-Content-Type-Options: nosniff')filename*=UTF-8'' 相容所有現代瀏覽器limit_rate 或 PHP 節流)PHP 僅驗證權限,實際傳輸交給 Nginx (零拷貝、不佔用 PHP 進程):
<?php
// PHP 僅驗證權限並發送內部重導向標頭
if ($userCanDownload) {
header('X-Accel-Redirect: /protected-files/' . $internalPath);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename*=UTF-8\'\'' . rawurlencode($fileName));
exit;
}
// Nginx 配置
location /protected-files/ {
internal; # 僅允許內部重導向
alias /data/files/; # 實際儲存路徑
# 可加 limit_rate 1m; 限速
}
基礎場景用分塊 fread 即可;需斷點續傳加 Range 處理;GB 級大檔、高併發生產環境強烈推薦 X-Accel-Redirect / X-Sendfile 卸載傳輸給 Web Server,PHP 僅負責授權。