star-cloud/app/Jobs/Product/AttachProductImagesJob.php
sky121113 4a86be6cbf [FEAT] 商品匯入支援圖片:Excel 圖片檔名欄 + 圖片壓縮檔背景處理
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>
2026-06-16 13:41:22 +08:00

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);
}
}
}
}