Browse Source

模糊搜索

chengwl 6 days ago
parent
commit
b318f1905d

+ 2 - 0
config/api-platform.php

@@ -45,6 +45,7 @@ return [
             'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
             'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
+            'Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
     ],
@@ -111,6 +112,7 @@ return [
         'middleware' => [
             'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
             'Webkul\BagistoApi\Http\Middleware\VerifyGraphQLStorefrontKey',
+            'Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse',
         ],
     ],
 

+ 41 - 0
packages/Webkul/BagistoApi/src/Http/Middleware/SearchMetadataResponse.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Webkul\BagistoApi\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Adds effective Elasticsearch search details to collection responses.
+ */
+class SearchMetadataResponse
+{
+    public function handle(Request $request, Closure $next): Response
+    {
+        $response = $next($request);
+        $metadata = $request->attributes->get('bagistoapi.search_metadata');
+
+        if (! is_array($metadata) || ! $response instanceof JsonResponse) {
+            return $response;
+        }
+
+        $payload = $response->getData(true);
+
+        if ($this->isGraphQlRequest($request)) {
+            $payload['extensions']['searchMetadata'] = $metadata;
+        } else {
+            $payload['meta']['searchMetadata'] = $metadata;
+        }
+
+        $response->setData($payload);
+
+        return $response;
+    }
+
+    private function isGraphQlRequest(Request $request): bool
+    {
+        return $request->is('api/graphql', 'graphql');
+    }
+}

+ 11 - 0
packages/Webkul/BagistoApi/src/Models/Product.php

@@ -128,6 +128,13 @@ use ApiPlatform\OpenApi\Model\Operation;
                         required: false,
                         schema: ['type' => 'string'],
                     ),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'suggest',
+                        in: 'query',
+                        description: 'Set to 1 to enable Elasticsearch fuzzy matching, typo correction, and zero-result query fallback.',
+                        required: false,
+                        schema: ['type' => 'integer', 'enum' => [1], 'example' => 1],
+                    ),
                     new \ApiPlatform\OpenApi\Model\Parameter(
                         name: 'sort',
                         in: 'query',
@@ -305,6 +312,10 @@ use ApiPlatform\OpenApi\Model\Operation;
                     'type'        => 'String',
                     'description' => 'Search query to filter products by SKU or name',
                 ],
+                'suggest' => [
+                    'type'        => 'Boolean',
+                    'description' => 'Enable Elasticsearch fuzzy matching, typo correction, and zero-result query fallback.',
+                ],
                 'filter' => [
                     'type'        => 'String',
                     'description' => 'JSON filter object containing attribute filters (type, sku, category_id, price, color, name, etc.). Example: {"type":"configurable","sku":"ABC123"}',

+ 2 - 1
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -459,7 +459,8 @@ class BagistoApiServiceProvider extends ServiceProvider
 
         $this->app->singleton(ProductGraphQLProvider::class, function ($app) {
             return new ProductGraphQLProvider(
-                $app->make(Pagination::class)
+                $app->make(Pagination::class),
+                $app->make(\Webkul\Product\Repositories\ElasticSearchRepository::class)
             );
         });
 

+ 89 - 60
packages/Webkul/BagistoApi/src/State/ProductGraphQLProvider.php

@@ -8,11 +8,13 @@ use ApiPlatform\State\Pagination\Pagination;
 use ApiPlatform\State\ProviderInterface;
 use Illuminate\Pagination\LengthAwarePaginator as LaravelPaginator;
 use Webkul\BagistoApi\Models\Product;
+use Webkul\Product\Repositories\ElasticSearchRepository;
 
 class ProductGraphQLProvider implements ProviderInterface
 {
     public function __construct(
-        private readonly Pagination $pagination
+        private readonly Pagination $pagination,
+        private readonly ElasticSearchRepository $elasticSearchRepository
     ) {}
 
     private ?array $attributeTypeCache = null;
@@ -33,6 +35,8 @@ class ProductGraphQLProvider implements ProviderInterface
 
         $args = $context['args'] ?? [];
 
+        request()->attributes->remove('bagistoapi.search_metadata');
+
         $query = Product::query();
 
         $query->whereHas('attribute_values', function ($q) {
@@ -45,7 +49,28 @@ class ProductGraphQLProvider implements ProviderInterface
                 ->where('boolean_value', 1);
         });
 
-        if (! empty($args['query'])) {
+        $elasticSearchIds = null;
+        $suggest = filter_var($args['suggest'] ?? false, FILTER_VALIDATE_BOOLEAN);
+
+        if (
+            ! empty($args['query'])
+            && core()->getConfigData('catalog.products.search.engine') === 'elastic'
+            && core()->getConfigData('catalog.products.search.storefront_mode') === 'elastic'
+        ) {
+            $searchResult = $this->elasticSearchRepository->searchForApi($args['query'], $suggest);
+            $elasticSearchIds = $searchResult['ids'];
+
+            if ($searchResult['query'] !== $args['query']) {
+                request()->attributes->set('bagistoapi.search_metadata', [
+                    'originalQuery'       => $args['query'],
+                    'effectiveQuery'      => $searchResult['query'],
+                    'resultMessage'       => 'These are results for: '.$searchResult['query'],
+                    'searchInsteadMessage' => 'Search instead for '.$args['query'],
+                ]);
+            }
+
+            $query->whereIn('products.id', $elasticSearchIds);
+        } elseif (! empty($args['query'])) {
             $searchTerm = $args['query'];
 
             $query->where(function ($q) use ($searchTerm) {
@@ -63,66 +88,70 @@ class ProductGraphQLProvider implements ProviderInterface
         $locale = $args['locale'] ?? request()->attributes->get('bagisto_locale');
         $channel = $args['channel'] ?? request()->attributes->get('bagisto_channel');
 
-        switch ($sortKey) {
-            case 'TITLE':
-            case 'NAME':
-                $prefix = \DB::getTablePrefix();
-
-                // Join for requested locale/channel
-                $query->leftJoin('product_attribute_values as pav_name_locale', function ($join) use ($locale, $channel) {
-                    $join->on('products.id', '=', 'pav_name_locale.product_id')
-                        ->where('pav_name_locale.attribute_id', 2);
-
-                    if ($locale) {
-                        $join->where('pav_name_locale.locale', $locale);
-                    }
-
-                    if ($channel) {
-                        $join->where('pav_name_locale.channel', $channel);
-                    }
-                });
+        if (! empty($elasticSearchIds) && ! isset($args['sortKey'])) {
+            $query->orderByRaw('FIELD(products.id, '.implode(',', $elasticSearchIds).')');
+        } else {
+            switch ($sortKey) {
+                case 'TITLE':
+                case 'NAME':
+                    $prefix = \DB::getTablePrefix();
+
+                    // Join for requested locale/channel
+                    $query->leftJoin('product_attribute_values as pav_name_locale', function ($join) use ($locale, $channel) {
+                        $join->on('products.id', '=', 'pav_name_locale.product_id')
+                            ->where('pav_name_locale.attribute_id', 2);
+
+                        if ($locale) {
+                            $join->where('pav_name_locale.locale', $locale);
+                        }
+
+                        if ($channel) {
+                            $join->where('pav_name_locale.channel', $channel);
+                        }
+                    });
 
-                // Fallback join for null locale/channel (default values)
-                $query->leftJoin('product_attribute_values as pav_name_fallback', function ($join) {
-                    $join->on('products.id', '=', 'pav_name_fallback.product_id')
-                        ->where('pav_name_fallback.attribute_id', 2)
-                        ->whereNull('pav_name_fallback.locale')
-                        ->whereNull('pav_name_fallback.channel');
-                });
+                    // Fallback join for null locale/channel (default values)
+                    $query->leftJoin('product_attribute_values as pav_name_fallback', function ($join) {
+                        $join->on('products.id', '=', 'pav_name_fallback.product_id')
+                            ->where('pav_name_fallback.attribute_id', 2)
+                            ->whereNull('pav_name_fallback.locale')
+                            ->whereNull('pav_name_fallback.channel');
+                    });
 
-                $query->orderBy(\DB::raw("COALESCE({$prefix}pav_name_locale.text_value, {$prefix}pav_name_fallback.text_value)"), $direction)
-                    ->orderBy('products.id', $direction)
-                    ->select('products.*');
-                break;
-
-            case 'CREATED_AT':
-                $query->orderBy('products.created_at', $direction)
-                    ->orderBy('products.id', $direction);
-                break;
-
-            case 'UPDATED_AT':
-                $query->orderBy('products.updated_at', $direction)
-                    ->orderBy('products.id', $direction);
-                break;
-
-            case 'PRICE':
-                $user = auth()->user();
-                $customerGroup = $user?->getDefaultGroup()
-                    ?? app(\Webkul\Customer\Repositories\CustomerGroupRepository::class)
-                        ->findOneByField('code', 'guest');
-
-                $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
-                    $join->on('products.id', '=', 'product_price_indices.product_id')
-                        ->where('product_price_indices.customer_group_id', $customerGroup->id);
-                })
-                    ->orderBy('product_price_indices.min_price', $direction)
-                    ->orderBy('products.id', $direction)
-                    ->select('products.*');
-                break;
-
-            case 'ID':
-            default:
-                $query->orderBy('products.id', $direction);
+                    $query->orderBy(\DB::raw("COALESCE({$prefix}pav_name_locale.text_value, {$prefix}pav_name_fallback.text_value)"), $direction)
+                        ->orderBy('products.id', $direction)
+                        ->select('products.*');
+                    break;
+
+                case 'CREATED_AT':
+                    $query->orderBy('products.created_at', $direction)
+                        ->orderBy('products.id', $direction);
+                    break;
+
+                case 'UPDATED_AT':
+                    $query->orderBy('products.updated_at', $direction)
+                        ->orderBy('products.id', $direction);
+                    break;
+
+                case 'PRICE':
+                    $user = auth()->user();
+                    $customerGroup = $user?->getDefaultGroup()
+                        ?? app(\Webkul\Customer\Repositories\CustomerGroupRepository::class)
+                            ->findOneByField('code', 'guest');
+
+                    $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
+                        $join->on('products.id', '=', 'product_price_indices.product_id')
+                            ->where('product_price_indices.customer_group_id', $customerGroup->id);
+                    })
+                        ->orderBy('product_price_indices.min_price', $direction)
+                        ->orderBy('products.id', $direction)
+                        ->select('products.*');
+                    break;
+
+                case 'ID':
+                default:
+                    $query->orderBy('products.id', $direction);
+            }
         }
 
         $filters = [];

+ 6 - 1
packages/Webkul/BagistoApi/src/State/ProductRestProvider.php

@@ -23,12 +23,13 @@ use ApiPlatform\Metadata\Operation;
  *   ?color=3&size=6             → args.filter.color, size (filterable attributes)
  *   ?filter={"color":{...}}     → merged into args.filter
  *   ?locale=en&channel=default  → args.locale, args.channel
+ *   ?suggest=1                  → args.suggest=true
  */
 class ProductRestProvider extends ProductGraphQLProvider
 {
     private const RESERVED_KEYS = [
         'query', 'sort', 'order', 'page', 'per_page',
-        'locale', 'channel', 'filter',
+        'locale', 'channel', 'filter', 'suggest',
     ];
 
     public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
@@ -41,6 +42,10 @@ class ProductRestProvider extends ProductGraphQLProvider
             $args['query'] = (string) $query['query'];
         }
 
+        if (isset($query['suggest'])) {
+            $args['suggest'] = filter_var($query['suggest'], FILTER_VALIDATE_BOOLEAN);
+        }
+
         [$sortKey, $reverse] = $this->parseSort($query['sort'] ?? null, $query['order'] ?? null);
         if ($sortKey !== null) {
             $args['sortKey'] = $sortKey;

+ 49 - 0
packages/Webkul/BagistoApi/tests/Unit/Http/Middleware/SearchMetadataResponseTest.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Http\Middleware;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Tests\TestCase;
+use Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse;
+
+class SearchMetadataResponseTest extends TestCase
+{
+    public function test_it_adds_search_metadata_to_rest_response_meta(): void
+    {
+        $request = Request::create('/api/shop/products', 'GET');
+        $request->attributes->set('bagistoapi.search_metadata', $this->metadata());
+
+        $response = (new SearchMetadataResponse())->handle(
+            $request,
+            fn () => response()->json(['member' => []])
+        );
+
+        $this->assertInstanceOf(JsonResponse::class, $response);
+        $this->assertSame($this->metadata(), $response->getData(true)['meta']['searchMetadata']);
+    }
+
+    public function test_it_adds_search_metadata_to_graphql_extensions(): void
+    {
+        $request = Request::create('/api/graphql', 'POST');
+        $request->attributes->set('bagistoapi.search_metadata', $this->metadata());
+
+        $response = (new SearchMetadataResponse())->handle(
+            $request,
+            fn () => response()->json(['data' => ['products' => []]])
+        );
+
+        $this->assertInstanceOf(JsonResponse::class, $response);
+        $this->assertSame($this->metadata(), $response->getData(true)['extensions']['searchMetadata']);
+    }
+
+    private function metadata(): array
+    {
+        return [
+            'originalQuery'        => 'readyy',
+            'effectiveQuery'       => 'ready',
+            'resultMessage'        => 'These are results for: ready',
+            'searchInsteadMessage' => 'Search instead for readyy',
+        ];
+    }
+}

+ 116 - 0
packages/Webkul/BagistoApi/tests/Unit/Repositories/ElasticSearchRepositoryTest.php

@@ -0,0 +1,116 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Repositories;
+
+use Mockery;
+use Tests\TestCase;
+use Webkul\Core\Facades\ElasticSearch;
+use Webkul\Product\Repositories\ElasticSearchRepository;
+
+class ElasticSearchRepositoryTest extends TestCase
+{
+    public function test_measurement_fallback_is_attempted_before_individual_terms(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->times(3)
+            ->andReturn(
+                $this->emptySearchResponse(),
+                ['suggest' => ['name_suggest' => []]],
+                $this->searchResponse([42])
+            );
+
+        $result = $repository->searchForApi('28 inch purple water wave', true);
+
+        $this->assertSame('purple water wave', $result['query']);
+        $this->assertSame([42], $result['ids']);
+        $this->assertSame(1, $result['total']);
+    }
+
+    public function test_api_search_uses_exact_and_fuzzy_name_and_sku_clauses(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->with(Mockery::on(function (array $request) {
+                $should = $request['body']['query']['bool']['should'];
+
+                return $should[0]['multi_match']['fields'] === ['name^4', 'sku^6']
+                    && $should[0]['multi_match']['operator'] === 'and'
+                    && $should[1]['multi_match']['fuzziness'] === 'AUTO';
+            }))
+            ->andReturn($this->searchResponse([7]));
+
+        $result = $repository->searchForApi('purpel wave', true);
+
+        $this->assertSame('purpel wave', $result['query']);
+        $this->assertSame([7], $result['ids']);
+    }
+
+    public function test_typo_correction_is_retried_only_after_the_original_query_has_no_hits(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->times(3)
+            ->andReturn(
+                $this->emptySearchResponse(),
+                [
+                    'suggest' => [
+                        'name_suggest' => [
+                            ['text' => 'purpel', 'options' => [['text' => 'purple']]],
+                            ['text' => 'wave', 'options' => []],
+                        ],
+                    ],
+                ],
+                $this->searchResponse([11])
+            );
+
+        $result = $repository->searchForApi('purpel wave', true);
+
+        $this->assertSame('purple wave', $result['query']);
+        $this->assertSame([11], $result['ids']);
+    }
+
+    public function test_suggest_opt_in_is_required_for_fuzzy_matching_and_correction(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->with(Mockery::on(function (array $request) {
+                $should = $request['body']['query']['bool']['should'];
+
+                return count($should) === 1
+                    && ! isset($should[0]['multi_match']['fuzziness']);
+            }))
+            ->andReturn($this->emptySearchResponse());
+
+        $result = $repository->searchForApi('readyy');
+
+        $this->assertSame('readyy', $result['query']);
+        $this->assertSame([], $result['ids']);
+    }
+
+    private function emptySearchResponse(): array
+    {
+        return [
+            'hits' => [
+                'total' => ['value' => 0],
+                'hits'  => [],
+            ],
+        ];
+    }
+
+    private function searchResponse(array $ids): array
+    {
+        return [
+            'hits' => [
+                'total' => ['value' => count($ids)],
+                'hits'  => array_map(fn (int $id) => ['_id' => (string) $id], $ids),
+            ],
+        ];
+    }
+}

+ 183 - 0
packages/Webkul/Product/src/Repositories/ElasticSearchRepository.php

@@ -95,6 +95,189 @@ class ElasticSearchRepository
         return $results['suggest']['name_suggest'][0]['options'][0]['text'] ?? null;
     }
 
+    /**
+     * Search products for API clients with typo tolerance and zero-result fallbacks.
+     *
+     * The returned IDs are intentionally not paginated. API providers apply their
+     * own SQL-only filters before calculating totals and cursors.
+     */
+    public function searchForApi(string $queryText, bool $suggest = false): array
+    {
+        $result = $this->searchApiQuery($queryText, $suggest);
+
+        if ($result['total'] > 0 || ! $suggest) {
+            return $result;
+        }
+
+        $correctedQuery = $this->getCorrectedQuery($queryText);
+
+        if ($correctedQuery && $correctedQuery !== $queryText) {
+            $result = $this->searchApiQuery($correctedQuery, true);
+
+            if ($result['total'] > 0) {
+                return $result;
+            }
+        }
+
+        foreach ($this->getFallbackQueries($correctedQuery ?: $queryText) as $fallbackQuery) {
+            $result = $this->searchApiQuery($fallbackQuery, true);
+
+            if ($result['total'] > 0) {
+                return $result;
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Return typo-corrected text only when Elasticsearch suggests a real change.
+     */
+    public function getCorrectedQuery(string $queryText): ?string
+    {
+        $results = Elasticsearch::search([
+            'index' => $this->getIndexName(),
+            'body'  => [
+                'suggest' => [
+                    'name_suggest' => [
+                        'text' => $queryText,
+                        'term' => [
+                            'field'        => 'name',
+                            'suggest_mode' => 'missing',
+                        ],
+                    ],
+                ],
+                'size' => 0,
+            ],
+        ]);
+
+        $suggestions = $results['suggest']['name_suggest'] ?? [];
+
+        if (empty($suggestions)) {
+            return null;
+        }
+
+        $terms = [];
+
+        foreach ($suggestions as $suggestion) {
+            $terms[] = $suggestion['options'][0]['text'] ?? $suggestion['text'];
+        }
+
+        $correctedQuery = trim(implode(' ', $terms));
+
+        return $correctedQuery && $correctedQuery !== $queryText
+            ? $correctedQuery
+            : null;
+    }
+
+    /**
+     * Generate relaxed queries, removing measurements before regular terms.
+     */
+    public function getFallbackQueries(string $queryText): array
+    {
+        $terms = preg_split('/\s+/u', trim($queryText), -1, PREG_SPLIT_NO_EMPTY);
+
+        if (count($terms) < 3) {
+            return [];
+        }
+
+        $candidates = [];
+        $measurementUnits = ['cm', 'mm', 'm', 'inch', 'inches', 'in', 'ft', 'feet', 'oz', 'lb', 'lbs', 'kg', 'g'];
+
+        foreach ($terms as $index => $term) {
+            if (! preg_match('/^\d+(?:\.\d+)?$/', $term)) {
+                continue;
+            }
+
+            $remove = [$index];
+
+            if (
+                isset($terms[$index + 1])
+                && in_array(strtolower($terms[$index + 1]), $measurementUnits, true)
+            ) {
+                $remove[] = $index + 1;
+            }
+
+            $candidate = $this->withoutTerms($terms, $remove);
+
+            if (count($candidate) >= 2) {
+                $candidates[] = implode(' ', $candidate);
+            }
+        }
+
+        foreach (array_keys($terms) as $index) {
+            $candidate = $this->withoutTerms($terms, [$index]);
+
+            if (count($candidate) >= 2) {
+                $candidates[] = implode(' ', $candidate);
+            }
+        }
+
+        return array_values(array_unique($candidates));
+    }
+
+    /**
+     * Execute one relevance-ordered API query.
+     */
+    protected function searchApiQuery(string $queryText, bool $fuzzy): array
+    {
+        $should = [
+            [
+                'multi_match' => [
+                    'query'    => $queryText,
+                    'fields'   => ['name^4', 'sku^6'],
+                    'operator' => 'and',
+                    'boost'    => 4,
+                ],
+            ],
+        ];
+
+        if ($fuzzy) {
+            $should[] = [
+                'multi_match' => [
+                    'query'         => $queryText,
+                    'fields'        => ['name^2', 'sku^4'],
+                    'operator'      => 'and',
+                    'fuzziness'     => 'AUTO',
+                    'prefix_length' => 1,
+                ],
+            ];
+        }
+
+        $results = Elasticsearch::search([
+            'index' => $this->getIndexName(),
+            'body'  => [
+                'size'             => 10000,
+                'stored_fields'    => [],
+                'track_total_hits' => true,
+                'query'            => [
+                    'bool' => [
+                        'minimum_should_match' => 1,
+                        'should' => $should,
+                    ],
+                ],
+            ],
+        ]);
+
+        return [
+            'query' => $queryText,
+            'total' => $results['hits']['total']['value'] ?? 0,
+            'ids'   => collect($results['hits']['hits'] ?? [])->pluck('_id')->map(fn ($id) => (int) $id)->all(),
+        ];
+    }
+
+    /**
+     * Remove selected indexes without changing the remaining order.
+     */
+    protected function withoutTerms(array $terms, array $indexes): array
+    {
+        return array_values(array_filter(
+            $terms,
+            fn ($term, $index) => ! in_array($index, $indexes, true),
+            ARRAY_FILTER_USE_BOTH
+        ));
+    }
+
     /**
      * Prepare filters for search results.
      */