[PROMOTE] dev → demo:商品匯入改為全背景處理
1. [REFACTOR] 商品匯入改為全背景處理:Controller 存檔後派發 ProcessProductImportJob 並立即回應,背景一條龍完成商品與圖片匯入,避免大批匯入受 Cloudflare 100 秒等請求逾時限制;結果寫入操作日誌查詢。
This commit is contained in:
commit
33d3eba998
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Jobs/Product/ProcessProductImportJob.php
Normal file
142
app/Jobs/Product/ProcessProductImportJob.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": "現在庫",
|
||||
|
||||
@ -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": "當前庫存",
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user