用戶文件系統(GBK/UTF-8) → HTTP Request (Header 編碼) → PHP $_FILES['name'] (內部編碼) → 服務器文件系統 (GBK/UTF-8/EXT4)
│ │ │ │
Windows默認GBK 瀏覽器依賴頁面編碼 PHP腳本編碼(UTF-8) Linux默認UTF-8, Windows GBK
任何一環編碼不匹配,中文文件名即會亂碼或保存失敗。
| 方案 | 原理 | 優點 | 缺點 | 推薦場景 |
|---|---|---|---|---|
| 編碼轉換 | iconv/mb_convert_encoding 轉為服務器文件系統編碼 | 保留原文件名、用戶友好 | 依賴服務器編碼、特殊字符風險、跨平台遷移麻煩 | 內部系統、用戶強需保留原名、Windows 服務器 |
| 隨機重命名 | 時間戳+隨機數/UUID+原擴展名 | 零編碼問題、極簡、分布式無衝突 | 失去語義、不利 SEO/用戶識別 | 通用首選、高併發、對象存儲、微服務 |
| 原名 + Hash 前綴 | hash_original.ext | 兼顧唯一性與可讀性 | 長文件名限制、特殊字符仍需處理 | 用戶可見下載場景 |
| Base64/URL 編碼存儲 | 文件名編碼入庫,物理文件用 Hash | 完美保留原名、支持任意字符 | 需數據庫關聯、下載時解碼 | 嚴格合規、多語言文件名 |
<?php
/**
* 安全保存上傳文件 (編碼轉換版)
* @param array $file $_FILES 項
* @param string $saveDir 物理保存目錄
* @param string $serverEncoding 服務器文件系統編碼 (Linux: UTF-8, Windows: GBK/CP936)
* @return array ['success'=>bool, 'path'=>string, 'original'=>string, 'saved'=>string, 'error'=>string]
*/
function saveUploadWithEncoding(array $file, string $saveDir, string $serverEncoding = 'UTF-8'): array {
// 1. 基礎校驗
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
return ['success' => false, 'error' => '非法上傳文件'];
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => '上傳錯誤代碼: ' . $file['error']];
}
$originalName = $file['name'];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$allowedExt = ['jpg','jpeg','png','gif','pdf','doc','docx','xls','xlsx','txt','zip','rar'];
if (!in_array($ext, $allowedExt)) {
return ['success' => false, 'error' => '不允許的文件類型'];
}
// 2. 編碼轉換:PHP 內部 UTF-8 → 服務器文件系統編碼
try {
$serverName = mb_convert_encoding($originalName, $serverEncoding, 'UTF-8');
} catch (Exception $e) {
// 兜底:忽略非法字符
$serverName = mb_convert_encoding($originalName, $serverEncoding, 'UTF-8//IGNORE');
}
// 3. 防衝突:同名文件添加序號
$baseName = pathinfo($serverName, PATHINFO_FILENAME);
$counter = 0;
$finalName = $serverName;
while (file_exists($saveDir . DIRECTORY_SEPARATOR . $finalName)) {
$counter++;
$finalName = $baseName . '_' . $counter . '.' . $ext;
}
$savePath = $saveDir . DIRECTORY_SEPARATOR . $finalName;
// 4. 移動文件
if (!move_uploaded_file($file['tmp_name'], $savePath)) {
return ['success' => false, 'error' => '文件保存失敗,檢查目錄權限'];
}
// 5. 設置權限 (Linux)
@chmod($savePath, 0644);
return [
'success' => true,
'path' => $savePath,
'original' => $originalName,
'saved' => $finalName
];
}
// ========== 使用 ==========
$result = saveUploadWithEncoding($_FILES['file'], '/var/www/uploads', 'UTF-8'); // Linux
// $result = saveUploadWithEncoding($_FILES['file'], 'D:/uploads', 'GBK'); // Windows
<?php
/**
* 生成安全唯一文件名
* 格式:年月日時分秒_微秒_隨機6位.擴展名 例:20240520143025_123456_a1b2c3.jpg
*/
function generateSafeFilename(string $originalName): string {
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$ext = $ext ? '.' . $ext : '';
$timestamp = date('YmdHis');
$micro = substr(microtime(), 2, 6); // 微秒部分
$random = substr(md5(uniqid(mt_rand(), true)), 0, 6);
return $timestamp . '_' . $micro . '_' . $random . $ext;
}
/**
* 通用上傳處理 (重命名版) - 支持單文件/多文件
*/
function handleUpload(array $file, string $saveDir, array $allowedExt = []): array {
$defaultAllowed = ['jpg','jpeg','png','gif','webp','pdf','doc','docx','xls','xlsx','txt','zip','rar','mp4','mp3'];
$allowedExt = $allowedExt ?: $defaultAllowed;
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
return ['success' => false, 'error' => '非法上傳'];
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => '錯誤代碼: ' . $file['error']];
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExt)) {
return ['success' => false, 'error' => '禁止的文件類型: ' . $ext];
}
// MIME 類型二次校驗 (防偽裝)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$allowedMime = [
'jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif','webp'=>'image/webp',
'pdf'=>'application/pdf','doc'=>'application/msword',
'docx'=>'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls'=>'application/vnd.ms-excel',
'xlsx'=>'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'txt'=>'text/plain','zip'=>'application/zip','rar'=>'application/x-rar-compressed',
'mp4'=>'video/mp4','mp3'=>'audio/mpeg'
];
if (isset($allowedMime[$ext]) && $mime !== $allowedMime[$ext]) {
return ['success' => false, 'error' => '文件類型不匹配 (MIME: ' . $mime . ')'];
}
$safeName = generateSafeFilename($file['name']);
$savePath = $saveDir . DIRECTORY_SEPARATOR . $safeName;
if (!move_uploaded_file($file['tmp_name'], $savePath)) {
return ['success' => false, 'error' => '保存失敗'];
}
@chmod($savePath, 0644);
return [
'success' => true,
'path' => $savePath,
'url' => '/uploads/' . $safeName, // 相對 Web 訪問路徑
'original' => $file['name'],
'saved' => $safeName,
'size' => $file['size'],
'mime' => $mime
];
}
// 多文件批量處理
function handleMultipleUploads(array $files, string $saveDir): array {
$results = [];
// 兼容單文件/多文件數組結構
$fileArray = isset($files['name']) && is_array($files['name']) ?
array_keys($files['name']) : [0]; // 簡化處理,實際需重組數組
foreach ($fileArray as $key) {
$single = ['name'=>$files['name'][$key], 'tmp_name'=>$files['tmp_name'][$key],
'error'=>$files['error'][$key], 'size'=>$files['size'][$key]];
$results[] = handleUpload($single, $saveDir);
}
return $results;
}
-- 數據庫表設計
CREATE TABLE `attachments` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`original_name` VARCHAR(255) NOT NULL COMMENT '用戶原始文件名 (UTF-8)',
`stored_name` VARCHAR(64) NOT NULL COMMENT '物理存儲名 (Hash)',
`extension` VARCHAR(16) NOT NULL,
`mime_type` VARCHAR(64) NOT NULL,
`size` BIGINT UNSIGNED NOT NULL,
`storage_path` VARCHAR(512) NOT NULL COMMENT '相對存儲路徑',
`sha256` CHAR(64) NOT NULL COMMENT '內容哈希,去重用',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sha256` (`sha256`),
KEY `idx_original` (`original_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
<?php
function saveToStorageWithMetadata(array $file, string $storageRoot): array {
// ... 校驗邏輯同方案二 ...
// 計算內容哈希 (去重)
$content = file_get_contents($file['tmp_name']);
$sha256 = hash('sha256', $content);
// 檢查是否已存在相同內容
$pdo = getPDO(); // 您的數據庫連接
$stmt = $pdo->prepare("SELECT * FROM attachments WHERE sha256 = ?");
$stmt->execute([$sha256]);
if ($exist = $stmt->fetch()) {
// 秒傳:直接返回現有記錄,刪除臨時文件
@unlink($file['tmp_name']);
return ['success' => true, 'data' => $exist, 'instant' => true];
}
// 物理存儲名:SHA256 前 16 位 + 擴展名
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$storedName = substr($sha256, 0, 16) . ($ext ? '.' . $ext : '');
// 目錄分片:按日期或哈希前綴分目錄,避免單目錄文件過多
$dir = $storageRoot . '/' . date('Y/m/d');
if (!is_dir($dir)) mkdir($dir, 0755, true);
$savePath = $dir . '/' . $storedName;
if (!move_uploaded_file($file['tmp_name'], $savePath)) {
return ['success' => false, 'error' => '存儲失敗'];
}
// 入庫
$stmt = $pdo->prepare("INSERT INTO attachments (original_name, stored_name, extension, mime_type, size, storage_path, sha256) VALUES (?,?,?,?,?,?,?)");
$stmt->execute([
$file['name'], $storedName, $ext, $mime, $file['size'],
date('Y/m/d') . '/' . $storedName, $sha256
]);
$id = $pdo->lastInsertId();
return ['success' => true, 'data' => ['id' => $id, 'url' => '/download/' . $id], 'instant' => false];
}
| 層面 | 關鍵設置 | 建議值 |
|---|---|---|
| PHP | file_uploads | On |
| PHP | upload_max_filesize | 50M (按業務調整) |
| PHP | post_max_size | > upload_max_filesize (如 60M) |
| PHP | max_file_uploads | 20 |
| PHP | upload_tmp_dir | 獨立目錄,權限 700,同分區 |
| Nginx | client_max_body_size | 60M (匹配 post_max_size) |
| Nginx | proxy_request_buffering off | 大文件直傳上游不緩存 |
| 文件系統 | 目錄權限 | www-data:www-data 755 / 文件 644 |
| 文件系統 | inode 監控 | df -i 定期告警,分片目錄 |
| 安全 | 上傳目錄禁用腳本執行 | Nginx: location /uploads { php_flag engine off; } |
<!-- HTML -->
<input type="file" id="uploader" multiple>
<div id="progress" style="display:none"><progress value="0" max="100"></progress></div>
<script>
document.getElementById('uploader').addEventListener('change', async (e) => {
const files = e.target.files;
const formData = new FormData();
for (let f of files) formData.append('files[]', f);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
document.getElementById('progress').style.display = 'block';
document.querySelector('progress').value = (e.loaded / e.total) * 100;
}
};
xhr.onload = () => console.log('上傳結果:', JSON.parse(xhr.responseText));
xhr.send(formData);
});
</script>
中文文件名亂碼本質是 編碼協議不匹配。現代 Web 架構下的最佳實踐:
🏆 首選:隨機重命名 (方案二) — 徹底消除編碼問題、天然支持分布式、安全無注入風險
🥈 次選:原名入庫 + Hash 存儲 (方案三) — 業務需保留原名時,元數據與物理存儲分離
🛠 輔助:編碼轉換 (方案一) — 僅用于 Windows 服務器存儲、或內部系統強制保留原名
⚙️ 配套必做:MIME 雙重校驗、存儲目錄分片、上傳目錄禁執行、數據庫記錄審計、CDN 加速下載
遵循 「物理存儲無語義,邏輯名稱在數據庫」 原則,您的文件上傳系統將具備高可用、高擴展、零亂碼的企業級質量。