ThinkPHP 6/8 提供 `think\facade\Filesystem` 與 `think\File` 類,支援本地、FTP、S3、OSS、COS 等多種驅動,內建驗證、自動命名、圖片處理。本文系統講解最佳實踐。
return [
'default' => env('filesystem.driver', 'local'),
'disks' => [
'local' => [
'type' => 'local',
'root' => public_path() . '/uploads', // 公開可訪問目錄
'visibility' => 'public',
],
'private' => [
'type' => 'local',
'root' => storage_path('app/private'), // 非公開目錄
'visibility' => 'private',
],
'aliyun_oss' => [
'type' => 'aliyun',
'access_key' => env('OSS_KEY'),
'secret_key' => env('OSS_SECRET'),
'bucket' => env('OSS_BUCKET'),
'endpoint' => env('OSS_ENDPOINT'),
'domain' => env('OSS_DOMAIN'), // 自定義域名
],
'qiniu' => [
'type' => 'qiniu',
'access_key' => env('QINIU_AK'),
'secret_key' => env('QINIU_SK'),
'bucket' => env('QINIU_BUCKET'),
'domain' => env('QINIU_DOMAIN'),
],
],
];
'file|max:10240|ext:jpg,png,gif,webp|image:width<2000,height<2000',
'files.*' => 'file|max:5120|ext:jpg,png,gif,webp',
];
protected $uploadMsg = [
'file.max' => '檔案大小不能超過 10MB',
'file.ext' => '僅支援 jpg, png, gif, webp 格式',
'file.image' => '圖片尺寸不能超過 2000x2000',
];
// 單檔上傳
public function single(): Response
{
$file = $this->request->file('file');
if (empty($file)) {
return json(['code' => 400, 'msg' => '未接收到檔案']);
}
try {
// 驗證
Validate::rule($this->uploadRule)->message($this->uploadMsg)->check(['file' => $file]);
// 儲存 (自動生成唯一文件名:日期目錄 + md5 + 副檔名)
$savename = $file->move(Filesystem::disk('local'))->getSaveName();
$url = Filesystem::disk('local')->url($savename);
// 可選:生成縮圖
$this->makeThumb($savename);
return json([
'code' => 200,
'msg' => '上傳成功',
'data' => [
'name' => $file->getOriginalName(),
'savename' => $savename,
'url' => $url,
'size' => $file->getSize(),
'type' => $file->getMimeType(),
]
]);
} catch (ValidateException $e) {
return json(['code' => 422, 'msg' => $e->getMessage()]);
} catch (\Throwable $e) {
\think\facade\Log::error('Upload failed: ' . $e->getMessage());
return json(['code' => 500, 'msg' => '伺服器內部錯誤']);
}
}
// 多檔上傳
public function multiple(): Response
{
$files = $this->request->file('files');
if (empty($files)) {
return json(['code' => 400, 'msg' => '未接收到檔案']);
}
$results = [];
foreach ($files as $file) {
try {
Validate::rule($this->uploadRule)->message($this->uploadMsg)->check(['file' => $file]);
$savename = $file->move(Filesystem::disk('local'))->getSaveName();
$results[] = [
'name' => $file->getOriginalName(),
'savename' => $savename,
'url' => Filesystem::disk('local')->url($savename),
'size' => $file->getSize(),
];
} catch (ValidateException $e) {
$results[] = ['name' => $file->getOriginalName(), 'error' => $e->getMessage()];
}
}
return json(['code' => 200, 'msg' => '批次上傳完成', 'data' => $results]);
}
// 圖片縮圖生成 (依賴 intervention/image)
private function makeThumb(string $savename): void
{
$image = \Image::make(Filesystem::disk('local')->path($savename));
$image->fit(300, 300, function ($constraint) {
$constraint->upsize();
});
$thumbName = 'thumb_' . $savename;
$image->save(Filesystem::disk('local')->path($thumbName));
}
}
use Intervention\Image\Facades\Image;
// 上傳後自動處理
public function processImage(string $savename): array
{
$path = Filesystem::disk('local')->path($savename);
$img = Image::make($path);
// 1. 自動修正 EXIF 方向
$img->orientate();
// 2. 生成多尺寸縮圖
$sizes = [
'thumb' => [150, 150],
'medium' => [600, 600],
'large' => [1200, 1200],
];
$thumbs = [];
foreach ($sizes as $name => [$w, $h]) {
$thumbName = "{$name}_{$savename}";
$img->clone()->fit($w, $h)->save(Filesystem::disk('local')->path($thumbName));
$thumbs[$name] = Filesystem::disk('local')->url($thumbName);
}
// 3. 添加水印 (右下角,透明度 30%)
$watermark = Image::make(public_path('watermark.png'));
$img->insert($watermark, 'bottom-right', 10, 10)->save($path);
// 4. 轉 WebP (現代瀏覽器更小)
$webpName = pathinfo($savename, PATHINFO_FILENAME) . '.webp';
$img->encode('webp', 85)->save(Filesystem::disk('local')->path($webpName));
return [
'original' => Filesystem::disk('local')->url($savename),
'webp' => Filesystem::disk('local')->url($webpName),
'thumbs' => $thumbs,
];
}
// 上傳至 OSS
$savename = $file->move(Filesystem::disk('aliyun_oss'))->getSaveName();
$url = Filesystem::disk('aliyun_oss')->url($savename); // 自動生成簽名 URL 或公開 URL
// 臨時授權下載 (私有桶)
$url = Filesystem::disk('aliyun_oss')->temporaryUrl($savename, now()->addMinutes(30));
| 風險 | 防護措施 |
|---|---|
| 惡意腳本上傳 (.php, .jsp 等) | 嚴格 `ext` 白名單、`image` 驗證、儲存目錄禁用腳本執行權限 (Nginx `location ~ \.php$ { deny all; }`) |
| 檔名路徑遍歷 (../../etc/passwd) | ThinkPHP `move()` 自動生成安全檔名,不使用用戶原始檔名 |
| 大檔案佔滿磁碟 | `max` 限制、定時清理臨時檔、磁碟配額監控 |
| 圖片炸彈 (壓縮極大、像素極大) | `image:width<2000,height<2000`、Intervention Image 讀取前檢查尺寸 |
| 上傳介面被濫用 (刷存儲、刷流量) | 登入態驗證、頻率限制 (RateLimiter)、驗證碼、簽名直傳 (OSS 直傳) |
// 後端:生成 OSS 直傳簽名 (Post Policy)
public function getOssPolicy(): Response
{
$oss = Filesystem::disk('aliyun_oss')->getAdapter()->getClient();
$policy = [
'expiration' => date('c', time() + 3600),
'conditions' => [
['content-length-range', 0, 10 * 1024 * 1024], // 10MB
['starts-with', '$key', 'uploads/'],
],
];
$policyBase64 = base64_encode(json_encode($policy));
$signature = base64_encode(hash_hmac('sha1', $policyBase64, config('filesystem.disks.aliyun_oss.secret_key'), true));
return json([
'policy' => $policyBase64,
'signature' => $signature,
'access_key' => config('filesystem.disks.aliyun_oss.access_key'),
'host' => config('filesystem.disks.aliyun_oss.domain'),
'dir' => 'uploads/' . date('Ymd') . '/',
]);
}
ThinkPHP 文件上傳核心優勢在於 **統一 Filesystem 抽象、內建驗證規則、多驅動無縫切換**。掌握「驗證→儲存→處理→回應」標準流程,配合安全加固與雲存儲直傳,能應對從簡單頭像上傳到億級附件系統的各種場景。