Jelajahi Sumber

Merge branch 'dev' of http://gogs.hnwmzp.cn/chengwenliang/nshop into dev

chengwl 4 hari lalu
induk
melakukan
724ffa01e7

+ 1 - 0
config/api-platform.php

@@ -46,6 +46,7 @@ return [
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
             'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
             'Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse',
+            'Webkul\BagistoApi\Http\Middleware\WrapApiResponse',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
     ],

+ 3 - 0
config/filesystems.php

@@ -62,6 +62,9 @@ return [
             'endpoint'                => env('AWS_ENDPOINT'),
             'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
             'throw'                   => false,
+            'http'                    => [
+                'verify' => false,
+            ],
         ],
 
     ],

+ 1 - 0
packages/Webkul/BagistoApi/config/api-platform-vendor.php

@@ -44,6 +44,7 @@ return [
             'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
             'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
+            'Webkul\BagistoApi\Http\Middleware\WrapApiResponse',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
     ],

+ 1 - 0
packages/Webkul/BagistoApi/config/api-platform.php

@@ -44,6 +44,7 @@ return [
             'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
             'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
+            'Webkul\BagistoApi\Http\Middleware\WrapApiResponse',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
     ],

+ 182 - 0
packages/Webkul/BagistoApi/src/Http/Middleware/WrapApiResponse.php

@@ -0,0 +1,182 @@
+<?php
+
+namespace Webkul\BagistoApi\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * WrapApiResponse
+ *
+ * 将评论相关 REST 接口的响应统一包装为 ApiResponse 格式(与
+ * Longyi\RewardPoints\Helpers\ApiResponse 输出结构保持一致):
+ *
+ * 成功:
+ *   {
+ *       "success": true,
+ *       "message": "Success",
+ *       "data": [ ... ],
+ *       "pagination": { ... }   // 仅分页列表接口
+ *   }
+ *
+ * 失败:
+ *   {
+ *       "success": false,
+ *       "message": "...",
+ *       "data": { ... }
+ *   }
+ */
+class WrapApiResponse
+{
+    /**
+     * 需要精确匹配的路径结尾(用于避免误伤 products 下的其它接口)。
+     */
+    private const REVIEW_PATH_SUFFIX = '/reviews';
+
+    /**
+     * Handle an incoming request.
+     */
+    public function handle(Request $request, Closure $next): Response
+    {
+        $response = $next($request);
+
+        if (! $this->shouldWrap($request)) {
+            return $response;
+        }
+
+        // 仅处理 JSON 响应
+        if (! $this->isJsonResponse($response)) {
+            return $response;
+        }
+
+        $body = $this->decodeJson($response->getContent());
+
+        if ($body === null) {
+            return $response;
+        }
+
+        if ($response->isSuccessful()) {
+            $wrapped = $this->wrapSuccess($request, $body);
+        } else {
+            $wrapped = $this->wrapError($response, $body);
+        }
+
+        $response->setContent(json_encode($wrapped, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+
+        return $response;
+    }
+
+    /**
+     * 包装成功响应。
+     */
+    private function wrapSuccess(Request $request, mixed $body): array
+    {
+        $wrapped = [
+            'success' => true,
+            'message' => 'Success',
+            'data'    => $this->toSnakeCase($body),
+        ];
+
+        // 分页信息由 PaginationHeaderNormalizer 暂存在 request attributes 中
+        $meta = $request->attributes->get('bagistoapi.pagination');
+
+        if (is_array($meta)) {
+            $wrapped['pagination'] = [
+                'current_page' => (int) $meta['page'],
+                'per_page'     => (int) $meta['per_page'],
+                'total'        => (int) $meta['total'],
+                'last_page'    => (int) $meta['total_pages'],
+                'has_more'     => $meta['has_next'] ?? null,
+            ];
+        }
+
+        return $wrapped;
+    }
+
+    /**
+     * 包装错误响应。
+     */
+    private function wrapError(Response $response, mixed $body): array
+    {
+        // Api Platform 错误响应通常为 RFC 7807 格式,detail 字段存放具体错误信息
+        $message = 'Error';
+
+        if (is_array($body)) {
+            $message = $body['detail']
+                ?? $body['message']
+                ?? $body['title']
+                ?? 'Error';
+        } elseif (is_string($body) && $body !== '') {
+            $message = $body;
+        }
+
+        return [
+            'success' => false,
+            'message' => $message,
+            'data'    => $this->toSnakeCase($body),
+        ];
+    }
+
+    /**
+     * 递归将数组 key 转为 snake_case(与 ApiResponse 输出保持一致)。
+     */
+    private function toSnakeCase(mixed $data): mixed
+    {
+        if (! is_array($data)) {
+            return $data;
+        }
+
+        $result = [];
+
+        foreach ($data as $key => $value) {
+            $snakeKey = strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', (string) $key));
+
+            $result[$snakeKey] = is_array($value) ? $this->toSnakeCase($value) : $value;
+        }
+
+        return $result;
+    }
+
+    /**
+     * 判断当前请求是否需要包装。
+     */
+    private function shouldWrap(Request $request): bool
+    {
+        $path = $request->path();
+
+        // 直接以 /reviews 开头的评论接口(列表/单条/创建/更新/删除)
+        if (str_starts_with($path, 'api/shop/reviews')) {
+            return true;
+        }
+
+        // 商品子资源评论列表:/api/shop/products/{productId}/reviews
+        if (str_starts_with($path, 'api/shop/products/')
+            && str_ends_with($path, self::REVIEW_PATH_SUFFIX)) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * 判断响应是否为 JSON。
+     */
+    private function isJsonResponse(Response $response): bool
+    {
+        $contentType = $response->headers->get('Content-Type', '');
+
+        return str_contains($contentType, 'application/json')
+            || str_contains($contentType, 'application/merge-patch+json');
+    }
+
+    /**
+     * 解码 JSON 内容,失败时返回 null。
+     */
+    private function decodeJson(string $content): mixed
+    {
+        $decoded = json_decode($content, true);
+
+        return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
+    }
+}

+ 14 - 4
packages/Webkul/BagistoApi/src/Models/ProductReview.php

@@ -27,11 +27,14 @@ use ApiPlatform\OpenApi\Model\Operation;
             openapi: new Operation(
                 tags: ['Product'],
                 summary: 'List product reviews',
-                description: 'Returns product reviews. Defaults to `approved` reviews only; pass `status` to override. Supports filtering by `product_id`, `status`, and `rating`, with `page` + `per_page` (alias `limit`, max 50) pagination. Mirrors the GraphQL `productReviews` query.',
+                description: 'Returns product reviews. Defaults to `approved` reviews only; pass `status` to override. Supports filtering by `product_id`, `status`, `rating`, `has_images`, and `attachment_type`, with `page` + `per_page` (alias `limit`, max 50) pagination. Mirrors the GraphQL `productReviews` query.',
                 parameters: [
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'product_id', in: 'query', description: 'Filter by product ID', required: false, schema: ['type' => 'integer', 'example' => 1]),
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'status', in: 'query', description: 'Filter by status. Defaults to `approved` when omitted.', required: false, schema: ['type' => 'string', 'enum' => ['approved', 'pending'], 'example' => 'approved']),
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'rating', in: 'query', description: 'Filter by star rating (1-5)', required: false, schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 5, 'example' => 5]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'has_images', in: 'query', description: 'Only return reviews that have image attachments when set to true (Photos filter). Accepts true/false/1/0.', required: false, schema: ['type' => 'boolean', 'example' => true]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'attachment_type', in: 'query', description: 'Filter by attachment media type (image or video).', required: false, schema: ['type' => 'string', 'enum' => ['image', 'video'], 'example' => 'image']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'sort', in: 'query', description: 'Sort reviews. `newest` sorts by created_at descending; default sorts by id ascending.', required: false, schema: ['type' => 'string', 'enum' => ['newest', 'oldest'], 'example' => 'newest']),
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'page', in: 'query', description: 'Page number (1-based)', required: false, schema: ['type' => 'integer', 'default' => 1, 'example' => 1]),
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'per_page', in: 'query', description: 'Items per page (alias: `limit`). Default 30, max 50.', required: false, schema: ['type' => 'integer', 'default' => 30, 'maximum' => 50, 'example' => 10]),
                 ],
@@ -263,10 +266,13 @@ use ApiPlatform\OpenApi\Model\Operation;
             openapi: new Operation(
                 tags: ['Product'],
                 summary: 'List reviews for a product',
-                description: 'Returns reviews scoped to the given product ID. Defaults to `approved` reviews only; pass `status` to override. Supports filtering by `status` and `rating`, with `page` + `per_page` (alias `limit`, max 50) pagination.',
+                description: 'Returns reviews scoped to the given product ID. Defaults to `approved` reviews only; pass `status` to override. Supports filtering by `status`, `rating`, `has_images`, and `attachment_type`, with `page` + `per_page` (alias `limit`, max 50) pagination.',
                 parameters: [
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'status', in: 'query', description: 'Filter by status. Defaults to `approved` when omitted.', required: false, schema: ['type' => 'string', 'enum' => ['approved', 'pending'], 'example' => 'approved']),
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'rating', in: 'query', description: 'Filter by star rating (1-5)', required: false, schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 5, 'example' => 5]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'has_images', in: 'query', description: 'Only return reviews that have image attachments when set to true (Photos filter). Accepts true/false/1/0.', required: false, schema: ['type' => 'boolean', 'example' => true]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'attachment_type', in: 'query', description: 'Filter by attachment media type (image or video).', required: false, schema: ['type' => 'string', 'enum' => ['image', 'video'], 'example' => 'image']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'sort', in: 'query', description: 'Sort reviews. `newest` sorts by created_at descending; default sorts by id ascending.', required: false, schema: ['type' => 'string', 'enum' => ['newest', 'oldest'], 'example' => 'newest']),
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'page', in: 'query', description: 'Page number (1-based)', required: false, schema: ['type' => 'integer', 'default' => 1, 'example' => 1]),
                     new \ApiPlatform\OpenApi\Model\Parameter(name: 'per_page', in: 'query', description: 'Items per page (alias: `limit`). Default 30, max 50.', required: false, schema: ['type' => 'integer', 'default' => 30, 'maximum' => 50, 'example' => 10]),
                 ],
@@ -372,11 +378,15 @@ class ProductReview extends \Webkul\Product\Models\ProductReview
 
     public function getAttachmentUrls()
     {
-        return $this->images->first() ? $this->images->map(function ($item) {
+        if ($this->images->isEmpty()) {
+            return [];
+        }
+
+        return $this->images->map(function ($item) {
             return [
                 'type' => $item->type,
                 'url'  => $item->url(),
             ];
-        })->toJson() : null;
+        })->values()->all();
     }
 }

+ 13 - 0
packages/Webkul/BagistoApi/src/State/ProductReviewProcessor.php

@@ -49,6 +49,19 @@ class ProductReviewProcessor implements ProcessorInterface
                 $allData = $request->all();
 
                 $data->fill($allData);
+
+                // API Platform 使用 SnakeCaseToCamelCaseNameConverter,请求体字段会被
+                // 转成 camelCase(如 productId),但 fill() 使用原始 snake_case 数据,
+                // 导致 product_id 未被填充而违反外键约束。这里显式兜底读取 product_id。
+                if (! $data->getAttribute('product_id')) {
+                    $productId = $request->input('product_id')
+                        ?? $request->input('productId')
+                        ?? ($allData['product_id'] ?? $allData['productId'] ?? null);
+
+                    if ($productId !== null) {
+                        $data->setAttribute('product_id', (int) $productId);
+                    }
+                }
             }
         }
         if ($operation instanceof \ApiPlatform\Metadata\Post) {

+ 66 - 17
packages/Webkul/BagistoApi/src/State/ProductReviewProvider.php

@@ -21,12 +21,23 @@ class ProductReviewProvider implements ProviderInterface
 
     public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
     {
-        $args = $context['args'] ?? [];
+        // GraphQL 走 $context['args'];REST 走 request()->query()。
+        // 两者合并,REST 查询参数优先,保证 REST 与 GraphQL 行为一致。
+        $args = array_merge($context['args'] ?? [], request()->query());
+
         $query = ProductReview::query();
 
+        // 优先读取商品子资源路由的路径参数(/products/{productId}/reviews),
+        // 其次读取查询参数 product_id / productId。
+        $productId = $uriVariables['productId']
+            ?? $uriVariables['product_id']
+            ?? $args['product_id']
+            ?? $args['productId']
+            ?? null;
+
         // Apply filters
-        if (! empty($args['product_id'])) {
-            $query->where('product_id', (int) $args['product_id']);
+        if (! empty($productId)) {
+            $query->where('product_id', (int) $productId);
         }
         /** Default to approved reviews for storefront API */
         $query->where('status', isset($args['status']) ? (string) $args['status'] : 'approved');
@@ -34,31 +45,69 @@ class ProductReviewProvider implements ProviderInterface
             $query->where('rating', (int) $args['rating']);
         }
 
+        // 仅返回带图片附件的评论(前端 Photos 筛选)
+        if (! empty($args['has_images'])) {
+            $hasImages = filter_var($args['has_images'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+
+            if ($hasImages === true) {
+                $query->has('images');
+            } elseif ($hasImages === false) {
+                $query->doesntHave('images');
+            }
+        }
+        // 按附件类型过滤:image / video
+        if (! empty($args['attachment_type'])) {
+            $type = (string) $args['attachment_type'];
+            $query->whereHas('images', function ($q) use ($type) {
+                $q->where('type', $type);
+            });
+        }
+
         // Eager load relationships
         $query->with(['product', 'customer']);
 
-        // Cursor-based pagination (offset-based cursors from API Platform)
+        // 分页参数:REST 用 page / per_page(别名 limit),GraphQL 用 first / last / after / before
+        $restPage    = isset($args['page']) ? max(1, (int) $args['page']) : null;
+        $restPerPage = isset($args['per_page'])
+            ? (int) $args['per_page']
+            : (isset($args['limit']) ? (int) $args['limit'] : null);
+
         $first = isset($args['first']) ? (int) $args['first'] : null;
         $last = isset($args['last']) ? (int) $args['last'] : null;
         $after = $args['after'] ?? null;
         $before = $args['before'] ?? null;
 
-        $perPage = $first ?? $last ?? 30;
-        $offset = 0;
-
-        if ($after) {
-            $decoded = base64_decode($after, true);
-            $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
-        }
-
-        if ($before) {
-            $decoded = base64_decode($before, true);
-            $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
-            $offset = max(0, $cursor - $perPage);
+        if ($restPage !== null || $restPerPage !== null) {
+            // REST 分页
+            $perPage = $restPerPage !== null ? min(max(1, $restPerPage), 50) : 30;
+            $page = $restPage ?? 1;
+            $offset = ($page - 1) * $perPage;
+        } else {
+            // GraphQL 游标分页
+            $perPage = $first ?? $last ?? 30;
+            $offset = 0;
+
+            if ($after) {
+                $decoded = base64_decode($after, true);
+                $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
+            }
+
+            if ($before) {
+                $decoded = base64_decode($before, true);
+                $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
+                $offset = max(0, $cursor - $perPage);
+            }
         }
 
-        $query->orderBy('id', 'asc');
+        // 排序:sort=newest(按创建时间倒序)或 sort=oldest(默认,按 id 升序)
+        $sort = $args['sort'] ?? null;
 
+        /*if ($sort !== null && strtolower((string) $sort) === 'newest') {
+            $query->orderBy('created_at', 'desc')->orderBy('id', 'desc');
+        } else {
+            $query->orderBy('id', 'asc');
+        }*/
+        $query->orderBy('created_at', 'desc')->orderBy('id', 'desc');
         $total = (clone $query)->count();
 
         if ($offset > $total) {

+ 103 - 55
packages/Webkul/Shop/src/Http/Controllers/API/HomeController.php

@@ -3,6 +3,7 @@
 namespace Webkul\Shop\Http\Controllers\API;
 
 use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Cache;
 use Webkul\Category\Repositories\CategoryRepository;
 use Webkul\Core\Repositories\ChannelRepository;
 use Webkul\Product\Repositories\ProductRepository;
@@ -10,6 +11,11 @@ use Webkul\Theme\Repositories\ThemeCustomizationRepository;
 
 class HomeController extends APIController
 {
+    /**
+     * 缓存 TTL(秒)
+     */
+    const CACHE_TTL = 300;
+
     /**
      * Create a new controller instance.
      */
@@ -30,6 +36,8 @@ class HomeController extends APIController
      */
     public function index(): JsonResponse
     {
+        $startTime = microtime(true);
+
         $locale = core()->getRequestedLocaleCode();
 
         // 通过 X-Channel header 区分 PC(default) / M(wap) 端
@@ -37,35 +45,48 @@ class HomeController extends APIController
         $channel = $this->channelRepository->findOneByField('code', $channelCode)
             ?: core()->getCurrentChannel();
 
-        // 1. 获取主题自定义数据(banner、分类轮播、产品轮播等)
-        $customizations = $this->themeCustomizationRepository
-            ->orderBy('sort_order')
-            ->findWhere([
-                'status'     => 1,
-                'channel_id' => $channel->id,
-                'theme_code' => $channel->theme,
-            ]);
-
-        $sections = [];
-        foreach ($customizations as $item) {
-            $section = $this->formatSection($item, $locale);
-            if ($section !== null) {
-                $sections[] = $section;
+        $cacheKey = 'home_api:' . $channel->code . ':' . $locale;
+
+        $data = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($channel, $locale) {
+            // 1. 获取主题自定义数据(banner、分类轮播、产品轮播等)
+            $customizations = $this->themeCustomizationRepository
+                ->orderBy('sort_order')
+                ->findWhere([
+                    'status'     => 1,
+                    'channel_id' => $channel->id,
+                    'theme_code' => $channel->theme,
+                ]);
+
+            $sections = [];
+            foreach ($customizations as $item) {
+                $section = $this->formatSection($item, $locale);
+                if ($section !== null) {
+                    $sections[] = $section;
+                }
             }
-        }
 
-        // 2. 获取分类树(供导航使用)
-        $categories = $this->categoryRepository->getVisibleCategoryTree(
-            $channel->root_category_id
-        );
+            // 2. 获取分类树
+            $categories = Cache::remember(
+                'home_categories:' . $channel->code . ':' . $locale,
+                self::CACHE_TTL,
+                fn () => $this->categoryRepository->getVisibleCategoryTree($channel->root_category_id)
+            );
 
-        return response()->json([
-            'success' => true,
-            'data'    => [
+            return [
                 'channel'    => $channel->code,
                 'sections'   => $sections,
                 'categories' => $this->formatCategoryTree($categories),
-            ],
+            ];
+        });
+
+        $elapsed = round((microtime(true) - $startTime) * 1000);
+
+        // \Log::info("[Home API] {$channel->code}:{$locale} | 耗时: {$elapsed}ms");
+
+        return response()->json([
+            'success' => true,
+            'data'    => $data,
+            '_debug'  => ['elapsed_ms' => $elapsed],
         ]);
     }
 
@@ -122,41 +143,68 @@ class HomeController extends APIController
      */
     protected function getProductCarouselData(array $filters): array
     {
-        $params = [
-            'status'              => 1,
-            'visible_individually'=> 1,
-            'limit'               => $filters['limit'] ?? 12,
-            'sort'                => $filters['sort'] ?? 'name-asc',
-        ];
-
-        if (! empty($filters['new'])) {
-            $params['new'] = 1;
-        }
-
-        if (! empty($filters['featured'])) {
-            $params['featured'] = 1;
-        }
+        $cacheKey = 'home_products:' . md5(json_encode($filters));
+
+        return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($filters) {
+            $params = [
+                'status'              => 1,
+                'visible_individually'=> 1,
+                'limit'               => $filters['limit'] ?? 12,
+                'sort'                => $filters['sort'] ?? 'name-asc',
+            ];
 
-        $products = $this->productRepository->getAll($params);
+            if (! empty($filters['new'])) {
+                $params['new'] = 1;
+            }
 
-        return $products->map(function ($product) {
-            $image = $product->images->first()
-                ?? $product->base_image;
+            if (! empty($filters['featured'])) {
+                $params['featured'] = 1;
+            }
 
-            return [
-                'id'               => $product->id,
-                'sku'              => $product->sku,
-                'name'             => $product->name,
-                'slug'             => $product->slug,
-                'type'             => $product->type,
-                'price'            => $product->getTypeInstance()->getMinimalPrice(),
-                'special_price'    => $product->special_price,
-                'image_url'        => $image->url ?? null,
-                'is_new'           => (bool) ($product->new ?? false),
-                'is_featured'      => (bool) ($product->featured ?? false),
-                'short_description'=> $product->short_description,
-            ];
-        })->values()->toArray();
+            // 使用 ProductRepository 查询,通过 params 传参,不走原生 SQL
+            $products = $this->productRepository
+                ->with(['images', 'reviews', 'price_indices'])
+                ->getAll($params);
+
+            // 批量加载 price_indices 后用内存计算 min_price,避免每个产品查一次
+            $customerGroupId = null;
+            try {
+                $customerGroupId = app(\Webkul\Customer\Repositories\CustomerRepository::class)
+                    ->getCurrentGroup()->id;
+            } catch (\Exception $e) {
+                $customerGroupId = 1;
+            }
+            $channelId = core()->getCurrentChannel()->id;
+
+            return $products->map(function ($product) use ($customerGroupId, $channelId) {
+                $image = $product->images->first();
+                $approvedReviews = $product->reviews->where('status', 'approved');
+
+                // 直接从 price_indices 计算 min_price,避免调 getMinimalPrice()
+                $index = $product->price_indices
+                    ->where('customer_group_id', $customerGroupId)
+                    ->where('channel_id', $channelId)
+                    ->first();
+                $minPrice = $index ? (float) $index->min_price : (float) $product->price;
+
+                return [
+                    'id'               => $product->id,
+                    'sku'              => $product->sku,
+                    'name'             => $product->name,
+                    'slug'             => $product->slug,
+                    'type'             => $product->type,
+                    'price'            => (float) $product->price,
+                    'min_price'        => $minPrice,
+                    'special_price'    => $product->special_price,
+                    'image_url'        => $image->url ?? null,
+                    'is_new'           => (bool) ($product->new ?? false),
+                    'is_featured'      => (bool) ($product->featured ?? false),
+                    'short_description'=> $product->short_description,
+                    'rating'           => round($approvedReviews->avg('rating') ?? 0, 1),
+                    'review_count'     => $approvedReviews->count(),
+                ];
+            })->values()->toArray();
+        });
     }
 
     /**

+ 50 - 0
packages/Webkul/Shop/src/Listeners/ThemeCacheCleaner.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace Webkul\Shop\Listeners;
+
+use Illuminate\Support\Facades\Cache;
+
+class ThemeCacheCleaner
+{
+    /**
+     * 清除指定 Theme 的所有首页缓存。
+     */
+    protected function clearHomeCache($theme): void
+    {
+        $localeCodes = array_keys(core()->getAllLocales()->toArray()) ?: [core()->getDefaultLocaleCode()];
+
+        foreach ($localeCodes as $locale) {
+            // 清除首页整页缓存
+            Cache::forget('home_api:' . $theme->channel->code . ':' . $locale);
+            // 清除分类树缓存
+            Cache::forget('home_categories:' . $theme->channel->code . ':' . $locale);
+        }
+    }
+
+    /**
+     * 主题创建后清除缓存。
+     */
+    public function afterCreate($theme): void
+    {
+        $this->clearHomeCache($theme);
+    }
+
+    /**
+     * 主题更新后清除缓存。
+     */
+    public function afterUpdate($theme): void
+    {
+        $this->clearHomeCache($theme);
+    }
+
+    /**
+     * 主题删除前清除缓存。
+     */
+    public function beforeDelete($themeId): void
+    {
+        $theme = app(\Webkul\Theme\Repositories\ThemeCustomizationRepository::class)->find($themeId);
+        if ($theme) {
+            $this->clearHomeCache($theme);
+        }
+    }
+}

+ 16 - 0
packages/Webkul/Shop/src/Providers/EventServiceProvider.php

@@ -9,6 +9,7 @@ use Webkul\Shop\Listeners\Invoice;
 use Webkul\Shop\Listeners\Order;
 use Webkul\Shop\Listeners\Refund;
 use Webkul\Shop\Listeners\Shipment;
+use Webkul\Shop\Listeners\ThemeCacheCleaner;
 
 class EventServiceProvider extends ServiceProvider
 {
@@ -78,5 +79,20 @@ class EventServiceProvider extends ServiceProvider
         'sales.refund.save.after' => [
             [Refund::class, 'afterCreated'],
         ],
+
+        /**
+         * Theme customization cache cleanup.
+         */
+        'theme_customization.create.after' => [
+            [ThemeCacheCleaner::class, 'afterCreate'],
+        ],
+
+        'theme_customization.update.after' => [
+            [ThemeCacheCleaner::class, 'afterUpdate'],
+        ],
+
+        'theme_customization.delete.before' => [
+            [ThemeCacheCleaner::class, 'beforeDelete'],
+        ],
     ];
 }