diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index e72eae8..0717ea8 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -594,6 +594,7 @@ class ProductController extends Controller $request->validate([ 'file' => 'required|file|mimes:xlsx|max:5120', 'company_id' => 'nullable|exists:companies,id', + 'images' => 'nullable|file|mimes:zip|max:61440', // 60MB;落在 php 預設 100M 與 Cloudflare 100M 之內,免改機器設定 ]); $companyId = $request->get('company_id') ?: auth()->user()->company_id; @@ -605,23 +606,128 @@ class ProductController extends Controller ], 422); } - $results = $importService->import( - $request->file('file')->getRealPath(), - $companyId, - auth()->id() - ); + // 有圖片壓縮檔:解壓到暫存目錄,圖片轉檔交由背景 Job 處理 (避免大量同步轉檔逾時) + $imageDir = $request->hasFile('images') ? $this->extractImageZip($request->file('images')) : null; + + 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; + } 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]); + } + return response()->json([ 'success' => true, - 'message' => __(':count products imported successfully', ['count' => $results['success_count']]), + '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; + } + /** * 同步商品目錄至全公司機台 (Phase 2) */ diff --git a/app/Jobs/Product/AttachProductImagesJob.php b/app/Jobs/Product/AttachProductImagesJob.php new file mode 100644 index 0000000..b4ac695 --- /dev/null +++ b/app/Jobs/Product/AttachProductImagesJob.php @@ -0,0 +1,99 @@ + $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/Services/ProductImportService.php b/app/Services/ProductImportService.php index ad80d60..5958d0b 100644 --- a/app/Services/ProductImportService.php +++ b/app/Services/ProductImportService.php @@ -6,7 +6,10 @@ use App\Models\Product\Product; use App\Models\Product\ProductCategory; use App\Models\System\SystemOperationLog; use App\Models\System\Translation; +use App\Traits\ImageHandler; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use OpenSpout\Reader\XLSX\Reader; @@ -15,7 +18,14 @@ use OpenSpout\Common\Entity\Row; class ProductImportService { - public function import(string $filePath, ?int $companyId = null, ?int $userId = null): array + use ImageHandler; + + /** + * @param string|null $imageDir 已解壓的圖片資料夾路徑 (Excel「圖片檔名」欄對應此資料夾內檔案) + * @param bool $deferImages true 時不在此同步轉檔,改將 (product_id, filename) 收集到 results['pending_images'] + * 供呼叫端丟背景 Job 處理,避免大量圖片同步轉檔逾時。 + */ + public function import(string $filePath, ?int $companyId = null, ?int $userId = null, ?string $imageDir = null, bool $deferImages = false): array { $reader = new Reader(); $reader->open($filePath); @@ -27,6 +37,9 @@ class ProductImportService 'error_count' => 0, 'errors' => [], 'company_ids' => [], + 'image_success' => 0, + 'image_missing' => [], + 'pending_images' => [], ]; $rowCount = 0; @@ -65,6 +78,7 @@ class ProductImportService 'points_half' => 'nullable|integer|min:0', 'points_half_amount' => 'nullable|integer|min:0', 'is_active' => 'nullable|boolean', + 'image_filename' => 'nullable|string|max:255', ]); if ($validator->fails()) { @@ -73,7 +87,7 @@ class ProductImportService } try { - DB::transaction(function () use ($data, $rowCount, $userId, &$results) { + DB::transaction(function () use ($data, $rowCount, $userId, $imageDir, $deferImages, &$results) { $categoryId = $this->resolveCategoryId($data['category'], $data['company_id']); $lookup = $this->buildProductLookup($data); $product = Product::withoutGlobalScopes()->where($lookup)->first(); @@ -116,6 +130,18 @@ class ProductImportService $note = 'product_import_created'; } + if ($deferImages) { + if ($imageDir && $data['image_filename']) { + $results['pending_images'][] = [ + 'product_id' => $product->id, + 'filename' => $data['image_filename'], + 'row' => $rowCount, + ]; + } + } else { + $this->attachImage($product, $data['image_filename'], $imageDir, $rowCount, $results); + } + $results['success_count']++; if ($product->company_id) { $results['company_ids'][$product->company_id] = $product->company_id; @@ -168,6 +194,7 @@ class ProductImportService '半額點數(選填)', '半額點數折抵金額(選填)', '是否啟用(1/0,選填)', + '圖片檔名(選填,需一併上傳圖片壓縮檔)', ])); $writer->addRow(Row::fromValues([ @@ -188,6 +215,7 @@ class ProductImportService 400, 40, 1, + 'latte.png', ])); $writer->close(); @@ -214,9 +242,46 @@ class ProductImportService 'points_half' => (int) $this->numericValue($cells[14] ?? null, 0), 'points_half_amount' => (int) $this->numericValue($cells[15] ?? null, 0), 'is_active' => $this->booleanValue($cells[16] ?? null), + 'image_filename' => $this->stringValue($cells[17] ?? null), ]; } + /** + * 依「圖片檔名」欄將已解壓資料夾中的圖片轉 WebP 入庫並寫回 image_url。 + * 失敗 (找不到檔/非圖) 只記錄至 image_missing,不影響商品本身寫入。 + */ + private function attachImage(Product $product, ?string $filename, ?string $imageDir, int $rowCount, array &$results): void + { + if (!$filename || !$imageDir) { + return; + } + + try { + $path = rtrim($imageDir, '/') . '/' . basename($filename); + + if (!is_file($path) || getimagesize($path) === false) { + $results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename]; + return; + } + + $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) { + $oldPath = str_replace('/storage/', '', $oldImage); + Storage::disk('public')->delete($oldPath); + } + + $results['image_success']++; + } catch (\Throwable $e) { + $results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename]; + } + } + private function isEmptyRow(array $data): bool { return empty($data['category']) diff --git a/config/queue.php b/config/queue.php index 01c6b05..7b9c2b0 100644 --- a/config/queue.php +++ b/config/queue.php @@ -66,7 +66,8 @@ return [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => 90, + // 須大於最長 Job 的 timeout(AttachProductImagesJob 1800s),否則長任務未跑完即被重派造成重複處理 + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 1830), 'block_for' => null, 'after_commit' => false, ], diff --git a/lang/en.json b/lang/en.json index 375c531..17c6bc4 100644 --- a/lang/en.json +++ b/lang/en.json @@ -6,6 +6,9 @@ "30s": "30s", "60 Seconds": "60 Seconds", "60s": "60s", + ":count image files not found": ":count image files not found", + ":count images are being processed in the background": ":count images are being processed in the background", + ":count images matched": ":count images matched", ":count products imported successfully": ":count products imported successfully", ":count staff cards imported successfully": ":count staff cards imported successfully", "A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.", @@ -1407,6 +1410,7 @@ "Optional remarks": "Optional remarks", "Optional remarks...": "Optional remarks...", "Optional. If you fill in any field, all fields below become required.": "Optional. If you fill in any field, all fields below become required.", + "Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)", "Or Choose Custom Date": "Or Choose Custom Date", "Order Details": "Order Details", "Order Info": "Order Info", @@ -1594,6 +1598,7 @@ "Product Details": "Product Details", "Product ID": "Product ID", "Product Image": "Product Image", + "Product Images (ZIP)": "Product Images (ZIP)", "Product Info": "Product Info", "Product Items": "Product Items", "Product List": "Product List", @@ -2092,6 +2097,8 @@ "The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.", "The Super Admin role is immutable.": "The Super Admin role is immutable.", "The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.", + "The image archive contains too many files": "The image archive contains too many files", + "The image archive is too large when extracted": "The image archive is too large when extracted", "The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.", "The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.", "The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.", @@ -2182,6 +2189,7 @@ "Type": "Type", "Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.", "UI Elements": "UI Elements", + "Unable to read the image archive": "Unable to read the image archive", "Unassigned": "Unassigned", "Unassigned / No Change": "Unassigned / No Change", "Unauthorized": "Unauthorized", diff --git a/lang/ja.json b/lang/ja.json index bdb48e8..dcc6d43 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -6,6 +6,9 @@ "30s": "30秒", "60 Seconds": "60秒", "60s": "60秒", + ":count image files not found": ":count 個の画像ファイルが見つかりません", + ":count images are being processed in the background": ":count 枚の画像をバックグラウンドで処理しています", + ":count images matched": ":count 枚の画像を紐付けました", ":count products imported successfully": ":count products imported successfully", ":count staff cards imported successfully": ":count 枚のスタッフカードが正常にインポートされました", "A new verification link has been sent to your email address.": "新しい確認リンクがメールアドレスに送信されました。", @@ -1407,6 +1410,7 @@ "Optional remarks": "任意の備考", "Optional remarks...": "任意の備考...", "Optional. If you fill in any field, all fields below become required.": "任意。いずれかの項目を入力すると、以下のすべての項目が必須になります。", + "Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "任意。画像のファイル名を「画像ファイル名」列に記入し、画像を .zip にまとめてアップロードしてください (最大 60MB)", "Or Choose Custom Date": "または日付を選択", "Order Details": "注文詳細", "Order Info": "注文情報", @@ -1594,6 +1598,7 @@ "Product Details": "商品詳細", "Product ID": "商品ID", "Product Image": "商品画像", + "Product Images (ZIP)": "商品画像 (ZIP)", "Product Info": "商品情報", "Product Items": "Product Items", "Product List": "商品リスト", @@ -2092,6 +2097,8 @@ "The Super Admin role cannot be deleted.": "特権管理者ロールは削除できません。", "The Super Admin role is immutable.": "特権管理者ロールは変更できません。", "The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。", + "The image archive contains too many files": "画像アーカイブのファイル数が多すぎます", + "The image archive is too large when extracted": "画像アーカイブの展開後のサイズが大きすぎます", "The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。", "The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。", "The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。", @@ -2182,6 +2189,7 @@ "Type": "タイプ", "Type to search or leave blank for system defaults.": "入力して検索、または空白のままでシステムデフォルトを使用します。", "UI Elements": "UI要素", + "Unable to read the image archive": "画像アーカイブを読み込めません", "Unassigned": "未割り当て", "Unassigned / No Change": "未割り当て / 変更なし", "Unauthorized": "未授権", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 42a4430..0d96db4 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -6,6 +6,9 @@ "30s": "30秒", "60 Seconds": "60 秒", "60s": "60秒", + ":count image files not found": ":count 個圖片檔找不到", + ":count images are being processed in the background": ":count 張圖片背景處理中", + ":count images matched": "已配對 :count 張圖片", ":count products imported successfully": "成功匯入 :count 筆商品", ":count staff cards imported successfully": "成功匯入 :count 張員工識別卡", "A new verification link has been sent to your email address.": "已將新的驗證連結發送至您的電子郵件地址。", @@ -1407,6 +1410,7 @@ "Optional remarks": "選填備註", "Optional remarks...": "選填備註...", "Optional. If you fill in any field, all fields below become required.": "選填。只要填寫任一欄位,以下欄位即全部必填。", + "Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "選填。在「圖片檔名」欄填寫檔名,再將圖片打包成 .zip 一併上傳 (上限 60MB)", "Or Choose Custom Date": "或選擇自訂日期", "Order Details": "訂單詳情", "Order Info": "單據資訊", @@ -1594,6 +1598,7 @@ "Product Details": "商品明細", "Product ID": "商品 ID", "Product Image": "商品圖片", + "Product Images (ZIP)": "商品圖片壓縮檔 (ZIP)", "Product Info": "商品資訊", "Product Items": "商品品項", "Product List": "商品清單", @@ -2092,6 +2097,8 @@ "The Super Admin role cannot be deleted.": "超級管理員角色不可刪除。", "The Super Admin role is immutable.": "超級管理員角色不可修改。", "The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。", + "The image archive contains too many files": "圖片壓縮檔內的檔案數量過多", + "The image archive is too large when extracted": "圖片壓縮檔解壓後的體積過大", "The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。", "The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。", "The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。", @@ -2182,6 +2189,7 @@ "Type": "類型", "Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。", "UI Elements": "UI元素", + "Unable to read the image archive": "無法讀取圖片壓縮檔", "Unassigned": "未指派", "Unassigned / No Change": "未指派 / 不變動", "Unauthorized": "未授權", diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php index 3b94873..3533c9f 100644 --- a/resources/views/admin/products/index.blade.php +++ b/resources/views/admin/products/index.blade.php @@ -188,6 +188,27 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
++ + {{ __('Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)') }} +
+