[FEAT] 實作商品主檔 B012 Redis 快取機制 (Phase 1)

1. 新增 ProductCatalogService 負責商品清單轉換與 Redis 快取邏輯。
2. 新增 WarmProductsCacheCommand (artisan products:warm-cache) 用於預熱快取。
3. 修改 ProductController 在商品異動時自動重建快取。
4. 修改 MachineController@getProducts (B012) 改為 Cache-First 讀取。
5. 在 Company 模型補上 products 關聯以支援暖機指令。
This commit is contained in:
sky121113 2026-05-13 18:51:35 +08:00
parent f9d3217099
commit 2c8a2de81c
5 changed files with 199 additions and 32 deletions

View File

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\System\Company;
use App\Services\Product\ProductCatalogService;
use Illuminate\Console\Command;
class WarmProductsCacheCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'products:warm-cache {--company= : ID of the specific company to warm cache for}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Pre-warm the Redis cache for product catalogs of all or a specific company.';
/**
* Execute the console command.
*
* @param ProductCatalogService $catalogService
* @return int
*/
public function handle(ProductCatalogService $catalogService): int
{
$companyId = $this->option('company');
if ($companyId) {
$companies = Company::where('id', $companyId)->pluck('id');
} else {
// Only warm companies that actually have products to avoid empty cache entries
$companies = Company::whereHas('products')->pluck('id');
}
if ($companies->isEmpty()) {
$this->warn('No companies found with products to warm cache for.');
return self::SUCCESS;
}
$this->info("Starting to warm product catalog cache for {$companies->count()} companies...");
$bar = $this->output->createProgressBar($companies->count());
$bar->start();
foreach ($companies as $id) {
$catalogService->rebuildCache($id);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('✅ Product catalog cache warming completed!');
return self::SUCCESS;
}
}

View File

@ -12,10 +12,16 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\Product\ProductCatalogService;
class ProductController extends Controller
{
use \App\Traits\ImageHandler;
public function __construct(
private readonly ProductCatalogService $catalogService
) {}
public function index(Request $request)
{
$user = auth()->user();
@ -219,6 +225,9 @@ class ProductController extends Controller
DB::commit();
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
@ -331,6 +340,9 @@ class ProductController extends Controller
DB::commit();
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
@ -357,6 +369,9 @@ class ProductController extends Controller
$product->is_active = !$product->is_active;
$product->save();
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
$status = $product->is_active ? __('Enabled') : __('Disabled');
if (request()->ajax()) {
@ -395,8 +410,12 @@ class ProductController extends Controller
Storage::disk('public')->delete($oldPath);
}
$companyId = $product->company_id;
$product->delete();
// Rebuild catalog cache
$this->catalogService->rebuildCache($companyId);
if (request()->ajax()) {
return response()->json([
'success' => true,

View File

@ -22,6 +22,7 @@ use App\Models\StaffCardLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\System\SystemOperationLog;
use App\Services\Machine\MachineService;
use App\Services\Product\ProductCatalogService;
class MachineController extends Controller
{
@ -387,42 +388,14 @@ class MachineController extends Controller
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request)
public function getProducts(Request $request, ProductCatalogService $catalogService)
{
$machine = $request->get('machine');
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
// Cache First: Get from cache or rebuild if missing
$payload = $catalogService->getPayload($machine->company_id);
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
return response()->json($payload);
}

View File

@ -68,6 +68,14 @@ class Company extends Model
return $this->hasMany(Machine::class);
}
/**
* Get the products for the company.
*/
public function products(): HasMany
{
return $this->hasMany(\App\Models\Product\Product::class);
}
/**
* Scope僅篩選啟用的公司
*/

View File

@ -0,0 +1,105 @@
<?php
namespace App\Services\Product;
use App\Models\Product\Product;
use Illuminate\Support\Facades\Cache;
class ProductCatalogService
{
/**
* Get the Redis cache key for a company's product catalog.
*
* @param int $companyId
* @return string
*/
public function getCacheKey(int $companyId): string
{
return "products_catalog:{$companyId}";
}
/**
* Get the product catalog payload, using cache if available.
*
* @param int $companyId
* @return array
*/
public function getPayload(int $companyId): array
{
$cacheKey = $this->getCacheKey($companyId);
$cached = Cache::get($cacheKey);
if ($cached) {
return $cached;
}
return $this->rebuildCache($companyId);
}
/**
* Rebuild the product catalog cache for a company.
*
* @param int $companyId
* @return array
*/
public function rebuildCache(int $companyId): array
{
$products = Product::where('company_id', $companyId)
->with(['translations'])
->active()
->get()
->map(fn($p) => $this->mapProduct($p));
$payload = [
'success' => true,
'code' => 200,
'data' => $products->toArray(),
];
Cache::forever($this->getCacheKey($companyId), $payload);
return $payload;
}
/**
* Invalidate the product catalog cache for a company.
*
* @param int $companyId
* @return void
*/
public function invalidate(int $companyId): void
{
Cache::forget($this->getCacheKey($companyId));
}
/**
* Map a Product model to the catalog format expected by machines.
*
* @param Product $product
* @return array
*/
private function mapProduct($product): array
{
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return [
't060v00' => (string) $product->id,
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url
? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url))
: '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '',
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '',
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
}
}