封裝一個功能完整的 FileUploader 類別,涵蓋檔案大小/類型驗證、上傳目錄管理、中文檔名編碼處理、衝突自動重命名、錯誤代碼對照、回傳結構化資訊等實務需求。
<?php
/**
* 檔案上傳處理類別
* 支援:大小限制、類型過濾、中文檔名、自動重命名、多檔上傳
*/
class FileUploader {
// 錯誤代碼常數
const ERR_NO_FILE = 'UPLOAD_ERR_NO_FILE';
const ERR_INVALID_TYPE = 'UPLOAD_ERR_INVALID_TYPE';
const ERR_EXCEEDS_SIZE = 'UPLOAD_ERR_EXCEEDS_SIZE';
const ERR_FILE_EXISTS = 'UPLOAD_ERR_FILE_EXISTS';
const ERR_MOVE_FAILED = 'UPLOAD_ERR_MOVE_FAILED';
const ERR_UPLOAD_FAILED = 'UPLOAD_ERR_UPLOAD_FAILED';
const ERR_DIR_NOT_WRITABLE = 'UPLOAD_ERR_DIR_NOT_WRITABLE';
// 內部屬性
private $file; // $_FILES 陣列單一元素
private $upload_dir; // 上傳目錄
private $allowed_types; // 允許類型陣列
private $max_size; // 最大位元組數
private $rename; // 是否自動重命名
private $encoding; // 檔名編碼轉換
private $overwrite; // 是否覆蓋同名檔案
// 回傳結果
private $result = [
'success' => false,
'error' => null,
'file' => null, // 成功時的檔案資訊陣列
];
/**
* 建構子
* @param array $file $_FILES['field_name'] 單一檔案資訊
* @param string $upload_dir 上傳目錄路徑
* @param array $options 選項陣列
*/
public function __construct($file, $upload_dir, $options = []) {
$this->file = $file;
$this->upload_dir = rtrim($upload_dir, '/') . '/';
// 預設選項
$defaults = [
'allowed_types' => [], // 空陣列 = 不限制
'max_size' => 10 * 1024 * 1024, // 10MB
'rename' => true, // 自動重命名避免衝突
'encoding' => 'UTF-8', // 目標編碼
'overwrite' => false, // 不覆蓋
];
$options = array_merge($defaults, $options);
$this->allowed_types = array_map('strtolower', $options['allowed_types']);
$this->max_size = $options['max_size'];
$this->rename = $options['rename'];
$this->encoding = $options['encoding'];
$this->overwrite = $options['overwrite'];
}
/**
* 執行上傳
* @return array 結果陣列
*/
public function upload() {
// 1. 基本檢查
if (!$this->validateUpload()) return $this->result;
// 2. 類型檢查
if (!$this->checkType()) return $this->result;
// 3. 大小檢查
if (!$this->checkSize()) return $this->result;
// 4. 目錄檢查
if (!$this->ensureDirectory()) return $this->result;
// 5. 產生目標檔名
$filename = $this->generateFilename();
$dest_path = $this->upload_dir . $filename;
// 6. 處理同名檔案
if (file_exists($dest_path) && !$this->overwrite) {
if ($this->rename) {
$filename = $this->generateUniqueFilename($filename);
$dest_path = $this->upload_dir . $filename;
} else {
$this->setError(self::ERR_FILE_EXISTS, "檔案已存在: $filename");
return $this->result;
}
}
// 7. 移動檔案
if (!move_uploaded_file($this->file['tmp_name'], $dest_path)) {
$this->setError(self::ERR_MOVE_FAILED, '檔案移動失敗,請檢查權限');
return $this->result;
}
// 8. 設定權限(可選)
@chmod($dest_path, 0644);
// 9. 成功回傳
$this->result['success'] = true;
$this->result['file'] = [
'original_name' => $this->file['name'],
'saved_name' => $filename,
'path' => $dest_path,
'url' => $this->getPublicUrl($filename),
'size' => $this->file['size'],
'mime' => $this->file['type'],
'extension' => $this->getExtension($this->file['name']),
];
return $this->result;
}
/**
* 批次上傳多個檔案
* @param array $files $_FILES['field_name'] (多檔案格式)
* @return array 每個檔案的結果陣列
*/
public static function uploadMultiple($files, $upload_dir, $options = []) {
$results = [];
// 正規化多檔案陣列結構
$normalized = self::normalizeFilesArray($files);
foreach ($normalized as $file) {
$uploader = new self($file, $upload_dir, $options);
$results[] = $uploader->upload();
}
return $results;
}
// ===== 內部方法 =====
private function validateUpload() {
if (empty($this->file) || !is_array($this->file)) {
$this->setError(self::ERR_NO_FILE, '無上傳檔案資訊');
return false;
}
if ($this->file['error'] !== UPLOAD_ERR_OK) {
$this->setError(self::ERR_UPLOAD_FAILED, $this->getUploadErrorMessage($this->file['error']));
return false;
}
if (!is_uploaded_file($this->file['tmp_name'])) {
$this->setError(self::ERR_UPLOAD_FAILED, '非合法上傳檔案');
return false;
}
return true;
}
private function checkType() {
if (empty($this->allowed_types)) return true;
$ext = strtolower($this->getExtension($this->file['name']));
if (!in_array($ext, $this->allowed_types)) {
$this->setError(self::ERR_INVALID_TYPE,
"檔案類型不允許: .$ext (允許: " . implode(', ', $this->allowed_types) . ")");
return false;
}
return true;
}
private function checkSize() {
if ($this->file['size'] > $this->max_size) {
$this->setError(self::ERR_EXCEEDS_SIZE,
sprintf('檔案過大: %.2f MB (限制: %.2f MB)',
$this->file['size'] / 1024 / 1024,
$this->max_size / 1024 / 1024));
return false;
}
return true;
}
private function ensureDirectory() {
if (!is_dir($this->upload_dir)) {
if (!mkdir($this->upload_dir, 0755, true)) {
$this->setError(self::ERR_DIR_NOT_WRITABLE, '無法建立上傳目錄');
return false;
}
}
if (!is_writable($this->upload_dir)) {
$this->setError(self::ERR_DIR_NOT_WRITABLE, '上傳目錄不可寫入');
return false;
}
return true;
}
private function generateFilename() {
$original = $this->file['name'];
$ext = $this->getExtension($original);
$name = $this->getBasename($original);
// 編碼轉換(解決中文檔名問題)
if ($this->encoding && function_exists('mb_convert_encoding')) {
$name = mb_convert_encoding($name, $this->encoding, 'auto');
}
// 過濾危險字元
$name = preg_replace('/[^a-zA-Z0-9_\-\.\(\)\[\] ]/u', '', $name);
$name = trim($name);
if ($name === '') $name = 'file';
return $this->rename ? $this->generateUniqueName($name, $ext) : "$name.$ext";
}
private function generateUniqueName($name, $ext) {
return $name . '_' . date('YmdHis') . '_' . substr(md5(uniqid(microtime(true), true)), 0, 8) . ".$ext";
}
private function generateUniqueFilename($filename) {
$info = pathinfo($filename);
$name = $info['filename'];
$ext = isset($info['extension']) ? $info['extension'] : '';
$counter = 1;
while (file_exists($this->upload_dir . $name . '_' . $counter . ($ext ? ".$ext" : ''))) {
$counter++;
}
return $name . '_' . $counter . ($ext ? ".$ext" : '');
}
private function getExtension($filename) {
$pos = strrpos($filename, '.');
return $pos !== false ? substr($filename, $pos + 1) : '';
}
private function getBasename($filename) {
$pos = strrpos($filename, '.');
return $pos !== false ? substr($filename, 0, $pos) : $filename;
}
private function getPublicUrl($filename) {
// 根據實際部署調整:可能是 /uploads/xxx 或完整 URL
return '/uploads/' . $filename;
}
private function getUploadErrorMessage($code) {
static $messages = [
UPLOAD_ERR_INI_SIZE => '超過 php.ini 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 $messages[$code] ?? '未知上傳錯誤 (代碼: ' . $code . ')';
}
private function setError($code, $message) {
$this->result['success'] = false;
$this->result['error'] = ['code' => $code, 'message' => $message];
}
// 正規化 $_FILES 多檔案結構
private static function normalizeFilesArray($files) {
$normalized = [];
if (isset($files['name']) && is_array($files['name'])) {
// 多檔案:name, type, tmp_name, error, size 皆為陣列
$count = count($files['name']);
for ($i = 0; $i < $count; $i++) {
$normalized[] = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
}
} else {
// 單檔案
$normalized[] = $files;
}
return $normalized;
}
}
?>
<?php
require_once 'FileUploader.php';
$upload_dir = __DIR__ . '/uploads/';
$options = [
'allowed_types' => ['jpg', 'jpeg', 'png', 'gif', 'pdf'],
'max_size' => 5 * 1024 * 1024, // 5MB
'rename' => true,
];
$uploader = new FileUploader($_FILES['avatar'], $upload_dir, $options);
$result = $uploader->upload();
if ($result['success']) {
echo "上傳成功!\n";
echo "檔案: {$result['file']['saved_name']}\n";
echo "大小: {$result['file']['size']} bytes\n";
echo "網址: {$result['file']['url']}\n";
// 寫入資料庫: $result['file']['saved_name']
} else {
echo "上傳失敗: [{$result['error']['code']}] {$result['error']['message']}\n";
}
?>
<?php
// HTML:
$results = FileUploader::uploadMultiple($_FILES['photos'], $upload_dir, $options);
$success = 0;
foreach ($results as $i => $result) {
if ($result['success']) {
$success++;
// 處理成功檔案...
} else {
echo "檔案 $i 失敗: {$result['error']['message']}\n";
}
}
echo "成功: $success / " . count($results) . "\n";
?>
<!-- 必須加上 enctype -->
<form action="upload.php" method="POST" enctype="multipart/form-data">
<!-- 單檔 -->
<input type="file" name="avatar" accept="image/*">
<!-- 多檔 -->
<input type="file" name="photos[]" multiple accept="image/*">
<!-- 可選:限制客戶端檔案大小(瀏覽器支援度有限,後端必須再驗證) -->
<input type="hidden" name="MAX_FILE_SIZE" value="5242880"> <!-- 5MB -->
<button type="submit">上傳</button>
</form>
這個類別解決了實務上最常見的中文檔名亂碼、同名覆蓋、類型大小驗證、錯誤處理等問題,可直接投入生產環境使用。