承接上篇,本文將分散的驗證邏輯封裝為可復用的 UploadHandler 類,涵蓋 MIME/擴展名雙重校驗、魔數檢測防偽、唯一文件名生成、目錄自動創建、錯誤信息國際化等生產級特性。
$_FILES['error'] !== UPLOAD_ERR_OK 直接映射友好提示;$_FILES['size'] 與配置上限;pathinfo($name, PATHINFO_EXTENSION) 取後綴,strtolower 統一小寫,in_array 判斷;$_FILES['type'] 參考,但**不可信**;function getRealMime(string $tmpPath): ?string {
$finfo = new finfo(FILEINFO_MIME_TYPE);
return $finfo->file($tmpPath); // 如 image/png, application/pdf
}
// 圖片專用:getimagesize() 同時獲取尺寸
function isTrueImage(string $tmpPath): bool {
$info = @getimagesize($tmpPath);
return $info !== false && in_array($info[2], [IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP]);
}
<?php
class UploadHandler {
private $config = [
'allowExt' => ['jpg','jpeg','png','gif','webp','pdf'],
'allowMime' => ['image/jpeg','image/png','image/gif','image/webp','application/pdf'],
'maxSize' => 20 * 1024 * 1024, // 20MB
'saveRoot' => '/var/www/uploads',
'checkMime' => true, // 是否啟用魔數檢測
];
private $errors = [];
public function __construct(array $customConfig = []) {
$this->config = array_merge($this->config, $customConfig);
}
public function upload(string $fieldName): ?array {
if (!isset($_FILES[$fieldName])) {
$this->errors[] = "字段 $fieldName 不存在";
return null;
}
$file = $_FILES[$fieldName];
// 1. 錯誤碼
if ($file['error'] !== UPLOAD_ERR_OK) {
$this->errors[] = $this->mapError($file['error']);
return null;
}
// 2. 大小
if ($file['size'] > $this->config['maxSize']) {
$this->errors[] = '文件超過大小限制';
return null;
}
// 3. 擴展名
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->config['allowExt'])) {
$this->errors[] = '不允許的文件類型';
return null;
}
// 4. MIME 雙重校驗
if ($this->config['checkMime']) {
$realMime = $this->getRealMime($file['tmp_name']);
if (!$realMime || !in_array($realMime, $this->config['allowMime'])) {
$this->errors[] = '文件內容類型非法';
return null;
}
}
// 5. HTTP POST 上傳驗證
if (!is_uploaded_file($file['tmp_name'])) {
$this->errors[] = '非合法上傳方式';
return null;
}
// 6. 目標目錄準備
$dateDir = date('Y/m/d');
$saveDir = $this->config['saveRoot'] . '/' . $dateDir;
if (!is_dir($saveDir)) {
if (!mkdir($saveDir, 0755, true)) {
$this->errors[] = '無法創建保存目錄';
return null;
}
}
// 7. 唯一文件名:md5(uniqid(microtime(true), true)) + 擴展名
$filename = md5(uniqid(microtime(true), true)) . '.' . $ext;
$destPath = $saveDir . '/' . $filename;
// 8. 移動文件
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
$this->errors[] = '文件移動失敗';
return null;
}
// 9. 返回標準化信息
return [
'origin_name' => $file['name'],
'save_path' => $destPath,
'url' => str_replace($_SERVER['DOCUMENT_ROOT'], '', $destPath), // 相對 Web 根目錄
'size' => $file['size'],
'mime' => $this->getRealMime($destPath),
'ext' => $ext,
'width' => null,
'height' => null,
];
}
private function getRealMime(string $path): ?string {
$finfo = new finfo(FILEINFO_MIME_TYPE);
return $finfo->file($path);
}
private function mapError(int $code): string {
static $map = [
UPLOAD_ERR_INI_SIZE => '超過服務器配置上限 (upload_max_filesize)',
UPLOAD_ERR_FORM_SIZE => '超過表單 MAX_FILE_SIZE 限制',
UPLOAD_ERR_PARTIAL => '文件僅部分上傳',
UPLOAD_ERR_NO_FILE => '未選擇文件',
UPLOAD_ERR_NO_TMP_DIR => '服務器臨時目錄缺失',
UPLOAD_ERR_CANT_WRITE => '文件寫入失敗',
UPLOAD_ERR_EXTENSION => '被 PHP 擴展阻止',
];
return $map[$code] ?? '未知上傳錯誤';
}
public function getErrors(): array { return $this->errors; }
}
<?php
require_once 'UploadHandler.php';
$uploader = new UploadHandler([
'allowExt' => ['jpg','png','pdf'],
'maxSize' => 5 * 1024 * 1024,
'saveRoot' => __DIR__ . '/data/uploads',
]);
$result = $uploader->upload('avatar'); // 對應
if ($result) {
echo json_encode(['code'=>0, 'msg'=>'上傳成功', 'data'=>$result]);
} else {
echo json_encode(['code'=>1, 'msg'=>implode('; ', $uploader->getErrors())]);
}
| 決策點 | 方案 | 理由 |
|---|---|---|
| 命名策略 | md5(uniqid(microtime(true), true)) | 極低碰撞概率、無業務含義、不洩露用戶信息 |
| 目錄結構 | Y/m/d 日期分層 | 單目錄文件數可控、利於備份清理、天然分佈 |
| 魔數檢測 | finfo + getimagesize 雙重 | finfo 通用、getimagesize 專精圖片尺寸,雙重保險 |
| 錯誤處理 | 收集數組而非拋異常 | 上傳屬業務邏輯分支,非異常流,收集便於前端聚合展示 |
| 返回 URL | 相對 DocumentRoot | 解耦存儲路徑與訪問路徑,支援 CDN/對象存儲遷移 |
下篇將講解多文件上傳、分片斷點續傳、對象存儲(S3/OSS)適配、圖片處理管道(縮略圖/水印/格式轉換)等進階主題。