前言
在開發 Strapi 後台管理系統時,我遇到一個實際的使用者體驗問題:在「已購買課程」列表中,管理員可以用「真實姓名」搜尋到用戶的購課記錄,但用「電話號碼」卻搜尋不到。這個不一致的行為讓管理員感到困惑。
本文將分享如何在 Strapi v5 中實作自訂搜尋邏輯,讓 Admin 面板支援跨關聯的多欄位搜尋。
問題場景
資料結構:
purchased-course(已購買課程):關聯到useruser(用戶):包含email、realName、phoneNumber、username、nickname等欄位
問題現象:
- ✅ 搜尋「真實姓名」→ 可以找到對應的購課記錄
- ❌ 搜尋「電話號碼」→ 找不到任何結果
為什麼會這樣?
因為 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 面板測試
- 登入 Strapi Admin:
http://localhost:1337/admin - 進入「已購買課程」列表
- 在搜尋框輸入:
- ✅ 真實姓名:「張心慈」
- ✅ 電話號碼:「0987654321」
- ✅ Email:「teresa830907@gmail.com」
- ✅ 用戶名稱或暱稱
全部都可以正常搜尋!
常見問題
Q1: 為什麼需要三個地方都修改?
- Content Manager Extension:Admin 面板搜尋(必須)
- Controller:REST API 搜尋(如果前端使用 API)
- Service:可重複使用的邏輯(如果其他地方也需要相同邏輯)
Q2: 搜尋效能會不會有問題?
目前的實作是:
- 先查詢
users表(使用索引) - 取得用戶 ID 清單
- 再用
id IN (...)查詢purchased-courses
建議優化:
- 在
phone_number、real_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 中實作自訂搜尋功能,關鍵要點:
- 必須擴充 Content Manager Plugin 才能讓 Admin 面板搜尋生效
- 使用
$containsi實現大小寫不敏感搜尋 - 將關聯表搜尋轉換為 ID 清單篩選,提升效能
- 善用 Console Log 除錯,確保邏輯正確
- 記得處理「找不到結果」的情況,避免回傳所有記錄
希望這篇文章能幫助你解決 Strapi 搜尋的困擾!