1. ProductImportService 新增 deferImages 模式與第 18 欄「圖片檔名」,匯入時收集 pending_images 供背景處理,並保留同步轉檔路徑。 2. 新增 AttachProductImagesJob:背景逐筆將圖片轉為 WebP 入庫、更新時刪除舊圖、重建目錄快取、清除暫存資料夾並寫入操作日誌。 3. ProductController::import 新增圖片 zip 上傳(上限 60MB)與安全解壓 extractImageZip:副檔名白名單、getimagesize 內容驗證、basename 防路徑穿越、項目數與單檔及總量上限防 zip bomb,解壓後派發背景 Job。 4. 匯入 Modal 新增選填的圖片 .zip 上傳欄位與背景處理提示文字。 5. config/queue.php 的 redis retry_after 提高至 1830(可由 REDIS_QUEUE_RETRY_AFTER 覆寫),避免長時間圖片任務未完成即被重複派發造成重複處理。 6. 三語系新增圖片匯入相關文案並對齊排序。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
3.5 KiB
PHP
100 lines
3.5 KiB
PHP
<?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);
|
|
}
|
|
}
|
|
}
|
|
}
|