方案速覽表
| 方案 | 核心函數 | 適用場景 | 關鍵特性 |
| 基礎版 | `readfile()` | <10MB、低併發 | 代碼最少、內部緩衝 |
| 分塊版 | `fread()` + `flush()` | 大文件、控制內存 | 固定緩衝區、實時輸出 |
| 斷點續傳版 | 解析 `HTTP_RANGE` + `fseek()` | 大文件、支援續傳/多線程 | 206 Partial Content、Content-Range |
| Nginx 加速版 | `X-Accel-Redirect` | 高併發、大文件、生產標準 | 零拷貝、PHP 瞬時釋放、Nginx 原生 Range |
1. 基礎版:readfile() 一行搞定
<?php
function downloadBasic(string $filePath, string $fileName = '') {
if (!is_file($filePath)) { http_response_code(404); exit('Not Found'); }
$fileName = $fileName ?: basename($filePath);
$encoded = rawurlencode($fileName);
header('Content-Type: application/octet-stream');
header("Content-Disposition: attachment; filename=\"{$encoded}\"; filename*=UTF-8''{$encoded}");
header('Content-Length: ' . filesize($filePath));
header('Accept-Ranges: bytes');
header('Cache-Control: private, must-revalidate');
header('Pragma: private');
readfile($filePath);
exit;
}
?>
2. 分塊版:大文件恆定內存
<?php
function downloadChunked(string $filePath, string $fileName = '', int $bufferSize = 1024*1024) {
if (!is_file($filePath)) { http_response_code(404); exit('Not Found'); }
$fileName = $fileName ?: basename($filePath);
$encoded = rawurlencode($fileName);
$fileSize = filesize($filePath);
header('Content-Type: application/octet-stream');
header("Content-Disposition: attachment; filename=\"{$encoded}\"; filename*=UTF-8''{$encoded}");
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes');
header('Cache-Control: private, must-revalidate');
header('Pragma: private');
$fp = fopen($filePath, 'rb');
while (!feof($fp) && connection_status() === CONNECTION_NORMAL) {
echo fread($fp, $bufferSize);
@ob_flush(); @flush();
}
fclose($fp);
exit;
}
?>
3. 斷點續傳版:完整 Range 支持
<?php
function downloadResumable(string $filePath, string $fileName = '', int $bufferSize = 1024*1024) {
if (!is_file($filePath)) { http_response_code(404); exit('Not Found'); }
$fileName = $fileName ?: basename($filePath);
$encoded = rawurlencode($fileName);
$fileSize = filesize($filePath);
// 解析 Range
$start = 0; $end = $fileSize - 1; $partial = false;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int)$m[1];
$end = $m[2] !== '' ? (int)$m[2] : $fileSize - 1;
$partial = true;
}
$start = max(0, min($start, $fileSize - 1));
$end = max($start, min($end, $fileSize - 1));
$length = $end - $start + 1;
header('Content-Type: application/octet-stream');
header("Content-Disposition: attachment; filename=\"{$encoded}\"; filename*=UTF-8''{$encoded}");
header('Accept-Ranges: bytes');
header('Cache-Control: private, must-revalidate');
header('Pragma: private');
if ($partial) {
header('HTTP/1.1 206 Partial Content');
header("Content-Range: bytes {$start}-{$end}/{$fileSize}");
header("Content-Length: {$length}");
} else {
header("Content-Length: {$fileSize}");
}
$fp = fopen($filePath, 'rb');
fseek($fp, $start);
$remaining = $length;
while (!feof($fp) && $remaining > 0 && connection_status() === CONNECTION_NORMAL) {
$read = min($bufferSize, $remaining);
echo fread($fp, $read);
@ob_flush(); @flush();
$remaining -= $read;
}
fclose($fp);
exit;
}
?>
4. Nginx X-Accel-Redirect:生產環境黃金標準
PHP 控制器 (Laravel/Symfony/ThinkPHP/原生通用)
<?php
// 僅權限驗證,返回內部重定向頭
$file = StorageFile::findOrFail($id);
if (!$file->canDownload(auth()->user())) abort(403);
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/' . $file->storage_path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => 'attachment; filename="' . rawurlencode($file->original_name) . '"',
// 可選:Content-Length 由 Nginx 自動添加
]);
?>
Nginx 配置
location /protected-files/ {
internal; # 禁止外部直接訪問
alias /mnt/storage/; # 文件實際存儲根目錄 (需以 / 結尾)
sendfile on; # 零拷貝
tcp_nopush on;
# 可選限速
# limit_rate_after 10m;
# limit_rate 1m;
}
安全防護清單 (所有方案通用)
- 路徑遍歷:`$real = realpath($input); if (strpos($real, $allowedRoot) !== 0) abort(400);`
- 權限驗證:每次下載檢查用戶身份、文件所有權、下載配額
- 禁用輸出緩衝:`while (ob_get_level()) ob_end_clean(); ini_set('zlib.output_compression', 'Off');`
- 無限執行時間:`set_time_limit(0);`
- 審計日誌:記錄 user_id、file_id、ip、range、status、bytes_sent
- 防盜鏈:簽名 URL (JWT/HMAC) 含過期時間、Referer 檢查
中文文件名編碼最佳實踐
$encoded = rawurlencode($fileName); // RFC 3986
header("Content-Disposition: attachment; filename=\"{$encoded}\"; filename*=UTF-8''{$encoded}");
// 兼容:IE/Edge 老版本用 filename,現代瀏覽器用 filename* (RFC 5987)
常見問題 FAQ
| 問題 | 原因 | 解決 |
| 下載文件打開損壞/多出 HTML/空白 | 腳本有 BOM、echo/var_dump、錯誤信息、HTML 輸出混入文件流 | 下載腳本**純輸出文件**,無任何其他輸出;`ini_set('display_errors', 0)` |
| 大文件下載中斷/超時 | PHP `max_execution_time`、Nginx `proxy_read_timeout`、瀏覽器超時 | `set_time_limit(0)`;Nginx `proxy_read_timeout 3600`;分片/斷點續傳 |
| 內存耗盡 | 用 `readfile()`/`file_get_contents()` 讀大文件 | 改用分塊 `fread` 或 `X-Accel-Redirect` |
| Range 請求返回 200 而非 206 | 未正確解析 `HTTP_RANGE`、未發送 `Accept-Ranges: bytes` | 檢查解析邏輯、確保發送 `Accept-Ranges` 頭 |