前言

在開發 Strapi 後台管理系統時,我遇到一個實際的使用者體驗問題:在「已購買課程」列表中,管理員可以用「真實姓名」搜尋到用戶的購課記錄,但用「電話號碼」卻搜尋不到。這個不一致的行為讓管理員感到困惑。

本文將分享如何在 Strapi v5 中實作自訂搜尋邏輯,讓 Admin 面板支援跨關聯的多欄位搜尋。

問題場景

資料結構:

  • purchased-course(已購買課程):關聯到 user
  • user(用戶):包含 emailrealNamephoneNumberusernamenickname 等欄位

問題現象:

  • ✅ 搜尋「真實姓名」→ 可以找到對應的購課記錄
  • ❌ 搜尋「電話號碼」→ 找不到任何結果

為什麼會這樣?

因為 Strapi 預設的搜尋功能只會搜尋當前 Collection 的直接欄位,不會自動搜尋關聯表(relation)的欄位。當我們在 purchased-course 列表搜尋時,Strapi 只會在 purchased-course 本身的欄位中搜尋,而不會去搜尋關聯的 user 資料。

解決方案架構

Strapi 提供了三個層級可以自訂搜尋邏輯:

1. Controller 層(API 端點)

位置: src/api/purchased-course/controllers/purchased-course.ts 用途: 處理前端透過 REST API 的搜尋請求

2. Service 層(業務邏輯)

位置: src/api/purchased-course/services/purchased-course.ts 用途: 封裝可重複使用的業務邏輯

3. Content Manager Plugin Extension(Admin 面板)

位置: src/extensions/content-manager/strapi-server.ts 用途: 最重要! 這是讓 Admin 面板搜尋生效的關鍵

完整實作

Step 1: 擴充 Content Manager Plugin

這是讓 Admin 面板搜尋功能生效的必要步驟

// src/extensions/content-manager/strapi-server.ts

export default (plugin: any) => {
  // 儲存原始的 find 方法
  const originalFind = plugin.controllers['collection-types'].find;

  // 覆寫 collection-types controller 的 find 方法
  plugin.controllers['collection-types'].find = async (ctx: any, next: any) => {
    const { model } = ctx.params;
    const { query } = ctx.request;

    console.log('🔍 [CM Override] Model:', model);
    console.log('🔍 [CM Override] Query:', query);

    // 處理 purchased-course 的搜尋
    if (model === 'api::purchased-course.purchased-course' && query._q) {
      const searchTerm = String(query._q);
      console.log('🔍 [CM Override] Search term:', searchTerm);

      // 搜尋用戶的多個欄位
      const users: any = await strapi.entityService.findMany(
        'plugin::users-permissions.user',
        {
          filters: {
            $or: [
              { email: { $containsi: searchTerm } },
              { realName: { $containsi: searchTerm } },
              { phoneNumber: { $containsi: searchTerm } },  // 關鍵:加入電話號碼搜尋
              { username: { $containsi: searchTerm } },
              { nickname: { $containsi: searchTerm } },
            ],
          },
          fields: ['id'],
        }
      );

      console.log('👥 [CM Override] Found users:', users);

      const userIds = Array.isArray(users) ? users.map((u: any) => u.id) : [];

      if (userIds.length > 0) {
        console.log('✅ [CM Override] Filtering by user IDs:', userIds);

        // 將搜尋條件替換為「用戶 ID 篩選」
        delete query._q;
        if (!query.filters) {
          query.filters = {};
        }
        query.filters.user = {
          id: {
            $in: userIds,
          },
        };
      } else {
        console.log('⚠️ [CM Override] No users found, return empty');
        // 沒有找到用戶時,回傳空結果
        delete query._q;
        query.filters = {
          id: {
            $in: [-1], // 不存在的 ID,確保回傳空結果
          },
        };
      }

      console.log('📤 [CM Override] Modified query:', query);
    }

    // 呼叫原始的 find 方法
    return await originalFind(ctx, next);
  };

  return plugin;
};

Step 2: 自訂 Controller(選用,支援 REST API)

如果你的前端使用 REST API 直接呼叫,也需要在 Controller 層加入相同邏輯:

// src/api/purchased-course/controllers/purchased-course.ts

import { factories } from '@strapi/strapi';

export default factories.createCoreController(
  'api::purchased-course.purchased-course',
  ({ strapi }) => ({
    async find(ctx) {
      console.log('🔍 Purchased Course Find - Query:', ctx.query);

      const query: any = ctx.query;
      const searchQuery = query._q || query.filters?._q;

      if (searchQuery) {
        const searchTerm = String(searchQuery);
        console.log('🔍 Search term:', searchTerm);

        const users: any = await strapi.entityService.findMany(
          'plugin::users-permissions.user',
          {
            filters: {
              $or: [
                { email: { $containsi: searchTerm } },
                { realName: { $containsi: searchTerm } },
                { phoneNumber: { $containsi: searchTerm } },
                { username: { $containsi: searchTerm } },
                { nickname: { $containsi: searchTerm } },
              ],
            },
            fields: ['id'],
          }
        );

        console.log('👥 Found users:', users);

        const userIds = Array.isArray(users) ? users.map((u: any) => u.id) : [];

        if (userIds.length > 0) {
          delete query._q;
          if (query.filters) {
            delete query.filters._q;
          }

          if (!query.filters) {
            query.filters = {};
          }

          query.filters.user = {
            id: {
              $in: userIds,
            },
          };
        } else {
          delete query._q;
          if (query.filters) {
            delete query.filters._q;
          }
          query.filters = {
            id: {
              $in: [-1],
            },
          };
        }
      }

      console.log('📤 Final query:', ctx.query);

      // 呼叫預設的核心方法
      return await super.find(ctx);
    },
  })
);

Step 3: Service 層(選用,重複使用邏輯)

// src/api/purchased-course/services/purchased-course.ts

import { factories } from '@strapi/strapi';

export default factories.createCoreService(
  'api::purchased-course.purchased-course',
  ({ strapi }) => ({
    async find(params: any = {}) {
      console.log('🔍 [Service] Purchased Course Find - Params:', params);

      if (params._q || params.filters?._q) {
        const searchTerm = String(params._q || params.filters._q);

        const users: any = await strapi.entityService.findMany(
          'plugin::users-permissions.user',
          {
            filters: {
              $or: [
                { email: { $containsi: searchTerm } },
                { realName: { $containsi: searchTerm } },
                { phoneNumber: { $containsi: searchTerm } },
                { username: { $containsi: searchTerm } },
                { nickname: { $containsi: searchTerm } },
              ],
            },
            fields: ['id'],
          }
        );

        const userIds = Array.isArray(users) ? users.map((u: any) => u.id) : [];

        if (userIds.length > 0) {
          delete params._q;
          if (params.filters) {
            delete params.filters._q;
          }

          if (!params.filters) {
            params.filters = {};
          }

          params.filters.user = {
            id: {
              $in: userIds,
            },
          };
        } else {
          delete params._q;
          if (params.filters) {
            delete params.filters._q;
          }
          params.filters = {
            id: {
              $in: [-1],
            },
          };
        }
      }

      return await super.find(params);
    },
  })
);

技術細節解析

1. $containsi 操作符

{ phoneNumber: { $containsi: searchTerm } }
  • $contains:大小寫敏感的包含搜尋
  • $containsi:大小寫敏感(i = insensitive)
  • 對應 PostgreSQL 的 ILIKE '%term%'

2. $or 邏輯運算

filters: {
  $or: [
    { email: { $containsi: searchTerm } },
    { realName: { $containsi: searchTerm } },
    // ...
  ]
}

只要符合任一條件即可回傳,實現多欄位搜尋。

3. $in 操作符

query.filters.user = {
  id: {
    $in: userIds,  // [85, 176, 96, 82, ...]
  },
};

將搜尋轉換為「用戶 ID 清單篩選」,提升查詢效能。

4. 空結果處理

query.filters = {
  id: {
    $in: [-1],  // 不存在的 ID
  },
};

當找不到符合的用戶時,使用不存在的 ID(-1)確保回傳空結果,而非所有記錄。

驗證與測試

1. 資料庫層驗證

-- 檢查欄位型態
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'up_users'
  AND column_name = 'phone_number';

-- 測試搜尋邏輯(對應 $containsi)
SELECT id, real_name, phone_number
FROM up_users
WHERE phone_number ILIKE '%0987654321%';

2. 查看 Console Log

啟動開發伺服器後,搜尋時會看到詳細的 log:

npm run develop

搜尋「0987654321」時的輸出:

🔍 [CM Override] Model: api::purchased-course.purchased-course
🔍 [CM Override] Query: { page: '1', pageSize: '10', _q: '0987654321' }
🔍 [CM Override] Search term: 0987654321
👥 [CM Override] Found users: [ { id: 92, documentId: 'xxx' } ]
✅ [CM Override] Filtering by user IDs: [ 92 ]
📤 [CM Override] Modified query: {
  page: '1',
  pageSize: '10',
  filters: { user: { id: { '$in': [ 92 ] } } }
}

3. Admin 面板測試

  1. 登入 Strapi Admin:http://localhost:1337/admin
  2. 進入「已購買課程」列表
  3. 在搜尋框輸入:
    • ✅ 真實姓名:「張心慈」
    • ✅ 電話號碼:「0987654321」
    • ✅ Email:「teresa830907@gmail.com」
    • ✅ 用戶名稱或暱稱

全部都可以正常搜尋!

常見問題

Q1: 為什麼需要三個地方都修改?

  • Content Manager Extension:Admin 面板搜尋(必須
  • Controller:REST API 搜尋(如果前端使用 API)
  • Service:可重複使用的邏輯(如果其他地方也需要相同邏輯)

Q2: 搜尋效能會不會有問題?

目前的實作是:

  1. 先查詢 users 表(使用索引)
  2. 取得用戶 ID 清單
  3. 再用 id IN (...) 查詢 purchased-courses

建議優化:

  • phone_numberreal_name 欄位建立索引
  • 考慮使用 ElasticSearch 做全文搜尋(大量資料時)

Q3: 如何支援更多關聯表搜尋?

可以擴充 $or 條件,例如加入課程名稱搜尋:

// 同時搜尋用戶和課程
const users = await strapi.entityService.findMany('plugin::users-permissions.user', {
  filters: { $or: [/* ... */] }
});

const courses = await strapi.entityService.findMany('api::course.course', {
  filters: { title: { $containsi: searchTerm } }
});

const userIds = users.map(u => u.id);
const courseIds = courses.map(c => c.id);

query.filters = {
  $or: [
    { user: { id: { $in: userIds } } },
    { course: { id: { $in: courseIds } } },
  ]
};

總結

本文展示了如何在 Strapi v5 中實作自訂搜尋功能,關鍵要點:

  1. 必須擴充 Content Manager Plugin 才能讓 Admin 面板搜尋生效
  2. 使用 $containsi 實現大小寫不敏感搜尋
  3. 將關聯表搜尋轉換為 ID 清單篩選,提升效能
  4. 善用 Console Log 除錯,確保邏輯正確
  5. 記得處理「找不到結果」的情況,避免回傳所有記錄

希望這篇文章能幫助你解決 Strapi 搜尋的困擾!