diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 0717ea8..296354b 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -589,7 +589,7 @@ class ProductController extends Controller return $importService->downloadTemplate(); } - public function import(Request $request, ProductImportService $importService) + public function import(Request $request) { $request->validate([ 'file' => 'required|file|mimes:xlsx|max:5120', @@ -606,126 +606,31 @@ class ProductController extends Controller ], 422); } - // 有圖片壓縮檔:解壓到暫存目錄,圖片轉檔交由背景 Job 處理 (避免大量同步轉檔逾時) - $imageDir = $request->hasFile('images') ? $this->extractImageZip($request->file('images')) : null; + // 存檔到耐久暫存目錄並派發背景 Job,請求立即回應,避免大批匯入受 Cloudflare 100 秒等請求逾時限制 + $baseDir = storage_path('app/product-imports/' . Str::uuid()->toString()); + \Illuminate\Support\Facades\File::ensureDirectoryExists($baseDir); - try { - $results = $importService->import( - $request->file('file')->getRealPath(), - $companyId, - auth()->id(), - $imageDir, - deferImages: $imageDir !== null - ); - } catch (\Throwable $e) { - if ($imageDir && is_dir($imageDir)) { - \Illuminate\Support\Facades\File::deleteDirectory($imageDir); - } - throw $e; + $request->file('file')->move($baseDir, 'data.xlsx'); + $xlsxPath = $baseDir . '/data.xlsx'; + + $zipPath = null; + if ($request->hasFile('images')) { + $request->file('images')->move($baseDir, 'images.zip'); + $zipPath = $baseDir . '/images.zip'; } - foreach ($results['company_ids'] as $importedCompanyId) { - $this->catalogService->rebuildCache($importedCompanyId); - } - - // 派發背景圖片處理 Job (Job 負責轉檔 + 重建快取 + 刪暫存目錄) - $queuedImages = 0; - if ($imageDir) { - if (!empty($results['pending_images'])) { - $queuedImages = count($results['pending_images']); - \App\Jobs\Product\AttachProductImagesJob::dispatch( - $results['pending_images'], - $imageDir, - $companyId, - auth()->id() - ); - } else { - // 沒有任何圖片需處理,立即清掉暫存目錄 - \Illuminate\Support\Facades\File::deleteDirectory($imageDir); - } - } - - $message = __(':count products imported successfully', ['count' => $results['success_count']]); - if ($queuedImages > 0) { - $message .= ' · ' . __(':count images are being processed in the background', ['count' => $queuedImages]); - } + \App\Jobs\Product\ProcessProductImportJob::dispatch( + $baseDir, + $xlsxPath, + $zipPath, + $companyId, + auth()->id() + ); return response()->json([ 'success' => true, - 'message' => $message, - 'results' => $results, - ]); - } - - /** - * 將上傳的圖片壓縮檔安全解壓到暫存目錄,回傳目錄路徑。 - * 防護:副檔名白名單、basename 防穿越、項目數/單檔/總量上限 (防 zip bomb)、內容驗證為圖片。 - */ - private function extractImageZip(\Illuminate\Http\UploadedFile $zipFile): string - { - $allowedExt = ['jpg', 'jpeg', 'png', 'webp']; - $maxEntries = 2000; - $maxFileBytes = 5 * 1024 * 1024; // 單檔解壓上限 5MB - $maxTotalBytes = 200 * 1024 * 1024; // 總解壓上限 200MB - - $dir = storage_path('app/tmp/product_import_' . Str::uuid()->toString()); - \Illuminate\Support\Facades\File::ensureDirectoryExists($dir); - - $zip = new \ZipArchive(); - if ($zip->open($zipFile->getRealPath()) !== true) { - throw new \RuntimeException(__('Unable to read the image archive')); - } - - if ($zip->numFiles > $maxEntries) { - $zip->close(); - throw new \RuntimeException(__('The image archive contains too many files')); - } - - $totalBytes = 0; - - for ($i = 0; $i < $zip->numFiles; $i++) { - $stat = $zip->statIndex($i); - $entryName = $stat['name']; - - // 跳過目錄與含路徑穿越的項目,只取檔名 - if (str_ends_with($entryName, '/') || str_contains($entryName, '..')) { - continue; - } - - $base = basename($entryName); - $ext = strtolower(pathinfo($base, PATHINFO_EXTENSION)); - if (!in_array($ext, $allowedExt, true)) { - continue; - } - - if ($stat['size'] > $maxFileBytes) { - continue; - } - - $totalBytes += $stat['size']; - if ($totalBytes > $maxTotalBytes) { - $zip->close(); - throw new \RuntimeException(__('The image archive is too large when extracted')); - } - - $stream = $zip->getStream($entryName); - if ($stream === false) { - continue; - } - $contents = stream_get_contents($stream); - fclose($stream); - - // 內容驗證:確認真為圖片,擋掉偽裝檔 - if ($contents === false || getimagesizefromstring($contents) === false) { - continue; - } - - file_put_contents($dir . '/' . $base, $contents); - } - - $zip->close(); - - return $dir; + 'message' => __('Import has been queued. Refresh the list shortly to see the imported products.'), + ], 202); } /** diff --git a/app/Jobs/Product/AttachProductImagesJob.php b/app/Jobs/Product/AttachProductImagesJob.php deleted file mode 100644 index b4ac695..0000000 --- a/app/Jobs/Product/AttachProductImagesJob.php +++ /dev/null @@ -1,99 +0,0 @@ - $pendingImages - */ - public function __construct( - protected array $pendingImages, - protected string $imageDir, - protected ?int $companyId = null, - protected ?int $userId = null - ) {} - - public function handle(ProductCatalogService $catalogService): void - { - $success = 0; - $missing = []; - - try { - foreach ($this->pendingImages as $item) { - $product = Product::withoutGlobalScopes()->find($item['product_id']); - if (!$product) { - $missing[] = $item; - continue; - } - - $path = rtrim($this->imageDir, '/') . '/' . basename($item['filename']); - if (!is_file($path) || getimagesize($path) === false) { - $missing[] = $item; - continue; - } - - try { - $oldImage = $product->image_url; - $file = new UploadedFile($path, basename($path), null, null, true); - $storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320); - $product->update(['image_url' => Storage::url($storedPath)]); - - if ($oldImage) { - Storage::disk('public')->delete(str_replace('/storage/', '', $oldImage)); - } - $success++; - } catch (\Throwable $e) { - $missing[] = $item; - } - } - - // 圖片已更新,重建目錄快取讓機台拿到新圖 - if ($this->companyId) { - $catalogService->rebuildCache($this->companyId); - } - - SystemOperationLog::create([ - 'company_id' => $this->companyId, - 'user_id' => $this->userId, - 'module' => 'product', - 'action' => 'update', - 'target_type' => Product::class, - 'note' => 'product_import_images_processed', - 'new_values' => [ - 'image_success' => $success, - 'image_missing' => count($missing), - 'missing_detail' => $missing, - ], - ]); - } finally { - // 無論成敗,清掉暫存圖片資料夾 - if (is_dir($this->imageDir)) { - File::deleteDirectory($this->imageDir); - } - } - } -} diff --git a/app/Jobs/Product/ProcessProductImportJob.php b/app/Jobs/Product/ProcessProductImportJob.php new file mode 100644 index 0000000..97a4521 --- /dev/null +++ b/app/Jobs/Product/ProcessProductImportJob.php @@ -0,0 +1,142 @@ +zipPath && is_file($this->zipPath)) + ? $this->extractImageZip($this->zipPath) + : null; + + // 背景執行,無請求逾時壓力,圖片同步轉檔即可(deferImages = false) + $results = $importService->import($this->xlsxPath, $this->companyId, $this->userId, $imageDir); + + foreach ($results['company_ids'] as $companyId) { + $catalogService->rebuildCache($companyId); + } + + SystemOperationLog::create([ + 'company_id' => $this->companyId, + 'user_id' => $this->userId, + 'module' => 'product', + 'action' => 'import', + 'target_type' => Product::class, + 'note' => 'product_import_completed', + 'new_values' => [ + 'success_count' => $results['success_count'], + 'created_count' => $results['created_count'], + 'updated_count' => $results['updated_count'], + 'error_count' => $results['error_count'], + 'image_success' => $results['image_success'], + 'image_missing' => count($results['image_missing']), + 'errors' => array_slice($results['errors'], 0, 50), + 'image_missing_detail' => array_slice($results['image_missing'], 0, 50), + ], + ]); + } finally { + if (is_dir($this->baseDir)) { + File::deleteDirectory($this->baseDir); + } + } + } + + /** + * 將圖片壓縮檔安全解壓到 baseDir/images,回傳目錄路徑。 + * 防護:副檔名白名單、basename 防穿越、項目數/單檔/總量上限 (防 zip bomb)、內容驗證為圖片。 + */ + private function extractImageZip(string $zipPath): string + { + $allowedExt = ['jpg', 'jpeg', 'png', 'webp']; + $maxEntries = 2000; + $maxFileBytes = 5 * 1024 * 1024; + $maxTotalBytes = 200 * 1024 * 1024; + + $dir = rtrim($this->baseDir, '/') . '/images'; + File::ensureDirectoryExists($dir); + + $zip = new \ZipArchive(); + if ($zip->open($zipPath) !== true) { + throw new \RuntimeException(__('Unable to read the image archive')); + } + + if ($zip->numFiles > $maxEntries) { + $zip->close(); + throw new \RuntimeException(__('The image archive contains too many files')); + } + + $totalBytes = 0; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $entryName = $stat['name']; + + if (str_ends_with($entryName, '/') || str_contains($entryName, '..')) { + continue; + } + + $base = basename($entryName); + $ext = strtolower(pathinfo($base, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowedExt, true)) { + continue; + } + + if ($stat['size'] > $maxFileBytes) { + continue; + } + + $totalBytes += $stat['size']; + if ($totalBytes > $maxTotalBytes) { + $zip->close(); + throw new \RuntimeException(__('The image archive is too large when extracted')); + } + + $stream = $zip->getStream($entryName); + if ($stream === false) { + continue; + } + $contents = stream_get_contents($stream); + fclose($stream); + + if ($contents === false || getimagesizefromstring($contents) === false) { + continue; + } + + file_put_contents($dir . '/' . $base, $contents); + } + + $zip->close(); + + return $dir; + } +} diff --git a/lang/en.json b/lang/en.json index c9dc84d..c6b1680 100644 --- a/lang/en.json +++ b/lang/en.json @@ -921,6 +921,7 @@ "Import Products": "Import Products", "Import Staff Cards": "Import Staff Cards", "Import failed": "Import failed", + "Import has been queued. Refresh the list shortly to see the imported products.": "Import has been queued. Refresh the list shortly to see the imported products.", "Important Notice": "Important Notice", "Imported with errors. Please check the console.": "Imported with errors. Please check the console.", "In Stock": "In Stock", diff --git a/lang/ja.json b/lang/ja.json index 7b74256..c21bd69 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -921,6 +921,7 @@ "Import Products": "Import Products", "Import Staff Cards": "スタッフカードのインポート", "Import failed": "インポート失敗", + "Import has been queued. Refresh the list shortly to see the imported products.": "インポートをバックグラウンド処理に登録しました。しばらくしてから一覧を再読み込みして結果をご確認ください。", "Important Notice": "Important Notice", "Imported with errors. Please check the console.": "Imported with errors. Please check the console.", "In Stock": "現在庫", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 1d6b970..413dc63 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -921,6 +921,7 @@ "Import Products": "匯入商品", "Import Staff Cards": "匯入員工識別卡", "Import failed": "匯入失敗", + "Import has been queued. Refresh the list shortly to see the imported products.": "匯入已排入背景處理,完成後請重新整理列表查看結果。", "Important Notice": "重要通知", "Imported with errors. Please check the console.": "部分資料匯入失敗,請查看瀏覽器主控台。", "In Stock": "當前庫存", diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php index 3533c9f..6473b0e 100644 --- a/resources/views/admin/products/index.blade.php +++ b/resources/views/admin/products/index.blade.php @@ -1310,18 +1310,11 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be const imagesInput = document.getElementById('product-import-images'); if (imagesInput) imagesInput.value = ''; - if (data.results.image_missing && data.results.image_missing.length > 0) { - console.warn('Image files not found for some rows:', data.results.image_missing); - } sessionStorage.setItem('productImportToast', JSON.stringify({ message: data.message, type: 'success' })); - if (data.results.error_count > 0) { - console.error('Product import partially failed:', data.results.errors); - } - window.location.reload(); } else { if (window.showToast) window.showToast(data.message || 'Error', 'error');