[REFACTOR] 商品匯入改為全背景處理避免請求逾時

1. 新增 ProcessProductImportJob:背景一條龍處理解壓圖片、匯入商品(含圖片轉 WebP 入庫)、重建目錄快取、寫入結果摘要日誌並清除暫存。
2. ProductController::import 改為僅存檔(xlsx/圖片 zip)後派發背景 Job 並立即回應 202,避免大批匯入受 Cloudflare 100 秒等請求逾時限制。
3. 移除 AttachProductImagesJob,邏輯折入單一 ProcessProductImportJob。
4. 匯入 Modal 改為提示「已排入背景處理,完成後重新整理列表查看」,匯入結果改寫入操作日誌查詢。
5. 三語系新增背景處理提示文案並對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sky121113 2026-06-16 13:58:13 +08:00
parent 5875fe380e
commit f1115825b2
7 changed files with 165 additions and 221 deletions

View File

@ -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);
}
/**

View File

@ -1,99 +0,0 @@
<?php
namespace App\Jobs\Product;
use App\Models\Product\Product;
use App\Models\System\SystemOperationLog;
use App\Services\Product\ProductCatalogService;
use App\Traits\ImageHandler;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\UploadedFile;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
/**
* 背景處理產品匯入的圖片:將已解壓資料夾中的圖片逐筆轉 WebP 入庫並寫回 image_url。
* 與商品文字匯入解耦,避免大量圖片同步轉檔導致請求逾時。
* 完成後重建目錄快取並刪除暫存圖片資料夾。
*/
class AttachProductImagesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, ImageHandler;
public int $timeout = 1800; // 30 分鐘,容納大量圖片轉檔
public int $tries = 1; // 圖片步驟不重試整批(避免重複轉檔),失敗以 log 呈現
/**
* @param array<int, array{product_id:int, filename:string, row:int}> $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);
}
}
}
}

View File

@ -0,0 +1,142 @@
<?php
namespace App\Jobs\Product;
use App\Models\Product\Product;
use App\Models\System\SystemOperationLog;
use App\Services\Product\ProductCatalogService;
use App\Services\ProductImportService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
/**
* 全背景處理商品匯入:解壓圖片壓縮檔、匯入商品(含圖片轉 WebP 入庫)、重建目錄快取、寫入結果摘要日誌、清除暫存。
* Controller 僅負責存檔並派發本 Job請求立即回應避免大批匯入受 Cloudflare 100 秒等請求逾時限制。
*/
class ProcessProductImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800; // 30 分鐘,容納大批商品與圖片轉檔
public int $tries = 1; // 不重試整批,避免重複建立商品;結果以日誌呈現
public function __construct(
protected string $baseDir,
protected string $xlsxPath,
protected ?string $zipPath,
protected ?int $companyId = null,
protected ?int $userId = null
) {}
public function handle(ProductImportService $importService, ProductCatalogService $catalogService): void
{
try {
$imageDir = ($this->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;
}
}

View File

@ -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",

View File

@ -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": "現在庫",

View File

@ -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": "當前庫存",

View File

@ -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');