Przeglądaj źródła

Merge branch 'dev-rewardPoints' into dev

# Conflicts:
#	config/api-platform.php
bianjunhui 4 dni temu
rodzic
commit
79f038a7b8

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

+ 6 - 2
packages/Webkul/BagistoApi/src/Models/ProductReview.php

@@ -372,11 +372,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) {

+ 40 - 16
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');
@@ -37,24 +48,37 @@ class ProductReviewProvider implements ProviderInterface
         // 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');