跳轉到

如何在後台搜尋商品

概述

CYBERBIZ 後台商品搜尋功能提供強大的全文檢索與多維度篩選能力,透過 Elasticsearch 實現高效能的商品查詢。本文件詳細說明後台商品搜尋的技術架構、API 端點、搜尋參數及實作細節。

架構概覽

商品搜尋系統採用三層架構:

graph TD
    A[Frontend - Admin UI] --> B[Rails Controller]
    B --> C[Use Case Layer]
    C --> D[Repository Layer]
    D --> E[Elasticsearch Index]
    D --> F[MySQL Database]

核心元件

元件 檔案路徑 功能說明
Controller app/controllers/admin/products_controller.rb 處理 HTTP 請求與回應
Use Case app/features/admin_context/product/use_cases/elastic_search_products.rb 商業邏輯處理
Repository app/features/admin_context/product/repositories/products.rb 資料存取層
Elastic Searcher app/services/product_filters/elastic_searcher.rb Elasticsearch 查詢建構

API 端點

基本搜尋

GET /admin/products/search
GET /admin/products/search.json
GET /admin/products/search?version=v2

搜尋參數

基本參數

參數名稱 類型 說明 範例
q String 全文搜尋關鍵字 q=iPhone
page Integer 頁碼 page=1
per Integer 每頁筆數(預設:10) per=20
limit Integer 結果數量限制 limit=100

篩選參數

# 參數範例
{
  product_types: ["電子產品", "配件"],
  vendors: ["Apple", "Samsung"],
  tags: ["熱銷", "新品"]
}
{
  sell_status: ["on_sale", "upcoming", "off_sale"],
  publicity: ["published", "unpublished"],
  inventory_availability: ["in_stock", "out_of_stock"]
}
{
  pos_shops: [1, 2, 3],        # POS 商店 ID
  ec_shop: true,                # 電商商店
  branch_stores: [4, 5],        # 分店 ID
  warehouse_ids: [1, 2]         # 倉庫 ID
}
{
  custom_collections: [1, 2],   # 自訂分類 ID
  smart_collections: [3, 4]     # 智慧分類 ID
}

搜尋實作流程

1. Controller 層處理

app/controllers/admin/products_controller.rb:280-299
def search
  result =
    if params[:version] == 'v2'
      if shop.has_plugin?('new_admin_product')
        # 使用 V2 搜尋(Elasticsearch)
        search_use_case = AdminContext::Product::UseCases::ElasticSearchProducts.new(
          admin_product_repository, language
        )
        options = params.permit!.to_h.symbolize_keys
        options[:creator_id] = current_user.id if restrict_product_scope_by_user?
        search_result = search_use_case.execute(shop, current_user, options)
        search_result[:products] = search_result.delete(:data)
        search_result
      else
        { error: t('.v2_permission_deny') }
      end
    else
      # 使用傳統搜尋(資料庫)
      get_products
    end
  render(json: result)
end

版本差異

  • V1 搜尋:使用 MySQL 資料庫查詢,透過 Ransack gem 建構 SQL 查詢
  • V2 搜尋:使用 Elasticsearch,支援全文檢索與更複雜的篩選條件

2. Use Case 層處理

app/features/admin_context/product/use_cases/elastic_search_products.rb
class ElasticSearchProducts
  def execute(shop, current_user, query_params)
    query = query_params[:q]

    # 參數裝飾處理
    query_params = ::Products::SearchParamsDecorator.new(query_params)
                                                    .execute(current_user)

    # 設定預載入關聯
    includes = %i[variants tags photos options pos_shop branch_store pim_infos]
    add_shop_specific_includes!(shop, includes)

    # 執行搜尋
    @repository.search_products(
      shop,
      @language,
      query,
      query_params,
      includes: includes,
      methods: %i[photo]
    )
  end
end

3. Repository 層處理

app/features/admin_context/product/repositories/products.rb:115-132
def search_products(shop, language, query, query_params, options = {})
  # 執行 Elasticsearch 查詢
  search_result = get_search_products(shop, query, query_params)
  product_ids = search_result.map(&:id)

  # 從資料庫載入完整商品資料
  methods = options.delete(:methods)
  relations = options.delete(:includes)
  relations.unshift(:dicts) if language.try(:not_default?)

  @products_rmodel = shop.products.includes(relations).where(id: product_ids)

  # 回傳結果
  {
    data: convert_products(@products_rmodel, language, relations),
    total_pages: search_result.total_pages,
    total_count: search_result.total_count,
    current_page: search_result.current_page,
    per: search_result.limit_value
  }
end

Elasticsearch 索引結構

ProductVariantsIndex 欄位

# 主要索引欄位
{
  shop_id: Integer,                    # 商店 ID
  product_id: Integer,                 # 商品 ID
  title: String,                        # 商品標題
  sku: String,                          # SKU
  price: Float,                         # 價格
  inventory_quantity: Integer,         # 庫存數量
  tags_id: Array<Integer>,             # 標籤 ID
  option_text: Array<String>,          # 選項文字
  custom_collection_id: Array<Integer>, # 自訂分類 ID
  smart_collection_id: Array<Integer>,  # 智慧分類 ID
  pos_shop_id: Integer,                # POS 商店 ID
  branch_store_id: Integer,            # 分店 ID
  warehouse_id: Integer,                # 倉庫 ID
  product_availability: String,        # 商品可用性
  selling_availability: Boolean,       # 銷售可用性
  inventory_availability: String,      # 庫存可用性
  bonus_availability: String,          # 紅利可用性
  searchable: Boolean                  # 是否可搜尋
}

篩選器實作

app/services/product_filters/elastic_searcher.rb
class ElasticSearcher < BaseElasticSearch
  def search(terms = {}, options = {})
    chain = filter(terms)
    chain = paginate(chain, options) if options[:non_page].blank?
    chain
  end

  private

  def filter(terms)
    chain = ProductVariantsIndex.filter(term: { shop_id: @shop.id })

    # 套用各種篩選條件
    chain = filter_by_inventory_availability(chain, terms[:inventory_availability])
    chain = filter_by_product_availability(chain, terms[:product_availability])
    chain = filter_by_selling_availability(chain, terms[:selling_availability])
    chain = filter_by_price(chain, terms[:price])
    chain = filter_by_option_texts(chain, terms[:option_text])
    chain = filter_by_custom_collection_ids(chain, terms[:custom_collection_ids])
    chain = filter_by_tags_ids(chain, terms[:tags_ids])
    chain = filter_by_pos_shop_ids(chain, terms[:pos_shop_ids])

    chain
  end
end

進階功能

批次操作

商品搜尋結果支援批次操作:

// 批次刪除商品
POST /admin/products/batch_destroy
{
  "products": [1, 2, 3, 4, 5]  // 商品 ID 陣列
}
// 批次更新商品狀態
POST /admin/products/set
{
  "operation": {
    "type": "publish",           // 操作類型
    "value": true
  },
  "products": [1, 2, 3]
}
// 根據搜尋條件匯出
POST /admin/products/export_with_search
{
  "q": "iPhone",
  "product_types": ["電子產品"],
  "tags": ["熱銷"]
}

權限控制

# 商品權限檢查
def restrict_product_scope_by_user?
  return false unless shop.has_plugin?('product_permission')
  !current_user.has_right?('products_view_all')
end

# 限制搜尋範圍
if restrict_product_scope_by_user?
  conditions[:creator_id_eq] = current_user.id
end

權限注意事項

  • POS 使用者只能搜尋所屬 POS 商店的商品
  • 具有 product_permission 插件的商店可限制使用者只能查看自己建立的商品
  • 管理員需要 products_view_all 權限才能查看所有商品

效能優化

1. N+1 查詢預防

# 預載入關聯以避免 N+1 查詢
products = products.includes(%i[
  variants      # 商品規格
  options       # 商品選項
  tags          # 標籤
  photos        # 圖片
  pos_shop      # POS 商店
])

2. 分頁載入

# 預設每頁 10 筆
DEFAULT_PAGE_COUNT = 10

products = products.page(page).per(limit)

3. Elasticsearch 聚合

# 使用聚合取得統計資料
def aggregate(chain, aggregations)
  aggs = {
    min_price: { min: { field: :price } },
    max_price: { max: { field: :price } },
    product_count: { cardinality: { field: :product_id } }
  }
  chain.aggs(aggs).aggs
end

前端整合

TypeScript 型別定義

frontend/admin/src/features/products/domain/models/ProductSearchParams.ts
export type SearchProductV2Params = {
  q: string;                                    // 搜尋關鍵字
  types: ProductType[];                         // 商品類型
  vendors: ProductVendor[];                      // 供應商
  tags: ProductTag[];                           // 標籤
  genres?: ProductGenreOption[];                // 類別
  sell_status: ProductSellStatusOption[];       // 銷售狀態
  publicity: ProductPublicityOption[];          // 發佈狀態
  stores: BranchStore[];                        // 商店
  warehouses?: ProductWarehouseOption[];        // 倉庫
  custom_collections: ProductCollection[];      // 自訂分類
  smart_collections: ProductCollection[];       // 智慧分類
  handles?: string[];                           // 商品代碼
  store_types?: ProductStoreTypeOption[];       // 商店類型
}

API 呼叫範例

// 搜尋商品
async searchProducts(params: SearchProductV2Params) {
  const response = await fetch('/admin/products/search?version=v2', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(params)
  });

  return response.json();
}

// 使用範例
const results = await searchProducts({
  q: 'iPhone',
  types: ['電子產品'],
  tags: ['熱銷'],
  sell_status: ['on_sale'],
  page: 1,
  per: 20
});

疑難排解

常見問題

搜尋結果為空

可能原因:

  1. Elasticsearch 索引未更新
  2. 權限不足
  3. 篩選條件過於嚴格

解決方案:

# 重建索引
rails c
> ProductVariantsIndex.purge!
> ProductVariantsIndex.import!

搜尋速度緩慢

可能原因:

  1. N+1 查詢問題
  2. 索引未優化
  3. 查詢條件複雜

解決方案: - 檢查並優化預載入關聯 - 使用 explain 分析 Elasticsearch 查詢 - 考慮增加快取層

權限錯誤

錯誤訊息: v2_permission_deny

解決方案: 確認商店已啟用 new_admin_product 插件:

shop.has_plugin?('new_admin_product')

相關文件

附錄

完整參數對照表

參數 資料庫欄位 Elasticsearch 欄位 說明
q title, sku, tags.name _all 全文搜尋
product_types products.product_type product_type 商品類型
vendors products.vendor vendor 供應商
tags tags.id tags_id 標籤
pos_shops products.pos_shop_id pos_shop_id POS 商店
warehouse_ids products.warehouse_id warehouse_id 倉庫
sell_status 計算欄位 selling_availability 銷售狀態
publicity products.published product_availability 發佈狀態

程式碼位置索引

功能 檔案路徑
後端
Controller app/controllers/admin/products_controller.rb:280-299
Use Case app/features/admin_context/product/use_cases/elastic_search_products.rb
Repository app/features/admin_context/product/repositories/products.rb:115-132
Elastic Searcher app/services/product_filters/elastic_searcher.rb
前端
Search Params frontend/admin/src/features/products/domain/models/ProductSearchParams.ts
Products Contract frontend/admin/src/features/products/index/ProductsContract.ts
Products Repository frontend/admin/src/features/products/domain/source/ProductsRepository.ts