大檔案上傳需解決 PHP 限制、記憶體、逾時、網路中斷等問題。本文提供生產級分片上傳方案:前端切片、後端合併、秒傳檢查、進度追蹤。
| 挑戰 | 對策 |
|---|---|
| post_max_size / upload_max_filesize | 分片上傳,單片 < 10MB |
| max_execution_time / max_input_time | 分片請求獨立,後端合併異步處理 |
| 記憶體耗盡 | 串流寫入,不載入完整檔案 |
| 網路中斷重傳 | 斷點續傳:記錄已上傳分片 |
| 重複上傳浪費 | 秒傳:檔案雜湊比對 |
<?php
// config.php
define('UPLOAD_CHUNK_DIR', __DIR__ . '/storage/chunks/');
define('UPLOAD_FINAL_DIR', __DIR__ . '/storage/files/');
define('CHUNK_SIZE', 5 * 1024 * 1024); // 5MB
define('MAX_FILE_SIZE', 5 * 1024 * 1024 * 1024); // 5GB
// 確保目錄存在
foreach ([UPLOAD_CHUNK_DIR, UPLOAD_FINAL_DIR] as $dir) {
if (!is_dir($dir)) mkdir($dir, 0755, true);
}
// upload_chunk.php - 接收單一切片
require 'config.php';
$fileId = $_POST['file_id'] ?? ''; // 前端生成的檔案唯一 ID (雜湊)
$chunkIndex = (int)($_POST['chunk_index'] ?? -1);
$totalChunks = (int)($_POST['total_chunks'] ?? 0);
$fileName = $_POST['file_name'] ?? '';
$fileHash = $_POST['file_hash'] ?? ''; // SHA256,用於秒傳
if (!$fileId || $chunkIndex < 0 || !$totalChunks) {
http_response_code(400);
exit(json_encode(['error' => '參數錯誤']));
}
// 秒傳檢查:合併前先查最終檔案是否存在
if ($chunkIndex === 0 && $fileHash) {
$finalPath = UPLOAD_FINAL_DIR . $fileHash . '_' . basename($fileName);
if (file_exists($finalPath)) {
exit(json_encode(['skip' => true, 'path' => $finalPath, 'message' => '秒傳成功']));
}
}
$chunkDir = UPLOAD_CHUNK_DIR . $fileId . '/';
if (!is_dir($chunkDir)) mkdir($chunkDir, 0755, true);
$chunkFile = $chunkDir . $chunkIndex;
$input = fopen('php://input', 'rb');
$output = fopen($chunkFile, 'wb');
stream_copy_to_stream($input, $output);
fclose($input);
fclose($output);
// 記錄進度 (Redis/資料庫/檔案)
$progressFile = $chunkDir . '.progress';
$progress = file_exists($progressFile) ? json_decode(file_get_contents($progressFile), true) : [];
$progress[$chunkIndex] = true;
file_put_contents($progressFile, json_encode($progress));
echo json_encode(['success' => true, 'chunk' => $chunkIndex]);
?>
<?php
// merge_chunks.php - 合併分片
require 'config.php';
$fileId = $_POST['file_id'] ?? '';
$fileName = $_POST['file_name'] ?? '';
$fileHash = $_POST['file_hash'] ?? '';
$totalChunks = (int)($_POST['total_chunks'] ?? 0);
if (!$fileId || !$totalChunks) {
http_response_code(400);
exit(json_encode(['error' => '參數錯誤']));
}
$chunkDir = UPLOAD_CHUNK_DIR . $fileId . '/';
$finalName = ($fileHash ? $fileHash . '_' : '') . basename($fileName);
$finalPath = UPLOAD_FINAL_DIR . $finalName;
// 驗證所有分片存在
for ($i = 0; $i < $totalChunks; $i++) {
if (!file_exists($chunkDir . $i)) {
http_response_code(400);
exit(json_encode(['error' => "分片 $i 缺失"]));
}
}
// 串流合併 (不載入記憶體)
$finalFp = fopen($finalPath, 'wb');
for ($i = 0; $i < $totalChunks; $i++) {
$chunkFp = fopen($chunkDir . $i, 'rb');
stream_copy_to_stream($chunkFp, $finalFp);
fclose($chunkFp);
}
fclose($finalFp);
// 驗證雜湊 (秒傳依賴)
if ($fileHash) {
$actualHash = hash_file('sha256', $finalPath);
if ($actualHash !== $fileHash) {
unlink($finalPath);
http_response_code(400);
exit(json_encode(['error' => '檔案雜湊不符,可能損壞']));
}
}
// 清理分片
array_map('unlink', glob($chunkDir . '*'));
rmdir($chunkDir);
echo json_encode(['success' => true, 'path' => $finalPath, 'size' => filesize($finalPath)]);
?>
<script>
// 簡化版分片上傳邏輯
class ChunkUploader {
constructor(file, options = {}) {
this.file = file;
this.chunkSize = options.chunkSize || 5 * 1024 * 1024;
this.onProgress = options.onProgress || (() => {});
this.onComplete = options.onComplete || (() => {});
this.onError = options.onError || (() => {});
}
async upload() {
// 1. 計算檔案 SHA256 (Web Worker 避免阻塞)
this.fileHash = await this.calculateHash();
// 2. 檢查秒傳
const instant = await this.checkInstant();
if (instant) return this.onComplete(instant);
// 3. 分片上傳
this.totalChunks = Math.ceil(this.file.size / this.chunkSize);
this.fileId = this.generateFileId();
for (let i = 0; i < this.totalChunks; i++) {
await this.uploadChunk(i);
this.onProgress((i + 1) / this.totalChunks);
}
// 4. 通知後端合併
return this.merge();
}
async uploadChunk(index) {
const start = index * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
const formData = new FormData();
formData.append('file_id', this.fileId);
formData.append('chunk_index', index);
formData.append('total_chunks', this.totalChunks);
formData.append('file_name', this.file.name);
formData.append('file_hash', this.fileHash);
formData.append('chunk', chunk);
const response = await fetch('/upload_chunk.php', {
method: 'POST',
body: formData
});
return response.json();
}
async merge() {
const response = await fetch('/merge_chunks.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
file_id: this.fileId,
file_name: this.file.name,
file_hash: this.fileHash,
total_chunks: this.totalChunks
})
});
return this.onComplete(await response.json());
}
async calculateHash() {
// 使用 SparkMD5 或 Web Crypto API
const buffer = await this.file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0')).join('');
}
}
// 使用
const uploader = new ChunkUploader(fileInput.files[0], {
onProgress: (p) => progressBar.style.width = (p * 100) + '%',
onComplete: (res) => console.log('上傳完成:', res),
});
uploader.upload();
finfo_file() 檢查 MIME,非信任副檔名Promise.all() 並行上傳 3-5 片分片上傳是大檔案上傳的標準解法,核心在於「前端切片 + 後端串流合併 + 雜湊驗證」,配合秒傳、斷點續傳、安全掃毒可構建企業級上傳系統。