场景教程:消息搜索与本地存储
聊天应用里的“搜索”通常不是一件事,而是三种不同来源的检索:服务端文本搜索(跨会话、跨设备,依赖服务端历史)、本地历史分页 + 客户端过滤(只覆盖已加载消息)、本地数据库检索(仅 Electron 落库的端可用)。本教程给出三端可运行的实现,并把每种的覆盖范围、分页能力、离线可用性与限制讲清楚。
本项目没有客户端全文索引、分词、拼音/首字母匹配、搜索高亮或搜索历史接口;服务端搜索是大小写不敏感的子串匹配,只匹配消息文本内容。
聊天室消息不持久化(断连即离开、不补发),因此服务端搜索与历史分页都取不到聊天室消息;需要留存请在业务侧归档。
撤回后消息内容被替换为撤回占位(原文本不再保留在消息体里),因此撤回消息不会被文本搜索命中。
三种检索路径怎么选
| 路径 | 覆盖范围 | 分页 | 离线可用 | 适用端 |
|---|---|---|---|---|
| 服务端文本搜索 searchMessages | 当前用户全部会话(单聊/群聊/超级群)的文本消息 | 否:只有 limit(默认 20、上限 100)与 total,无 offset/beforeSeq | 否(需要长连接或 REST 可达) | Web / Android / Flutter / Electron |
| 历史分页 getHistoryMessages + 本地过滤 | 单会话,按 beforeSeq 向前翻页(每页默认 20、上限 100) | 是(按 seq 游标) | 否(首次需要服务端) | 全部端(Web/Flutter 只有内存,Android/Electron 有本地库) |
| 本地数据库检索(Electron localDb) | 本机已落库消息,可按会话、时间窗、消息类型、发送者过滤 | 是(offset + limit + total) | 是(重启后仍可查) | 仅 Electron(主进程) |
方案 A:服务端文本搜索
SDK 的 searchMessages 转发到客户端 REST GET /api/apps/{appkey}/messages/search?keyword=&limit=。服务端遍历当前用户全部会话的消息,对 content.text 做小写化后的子串匹配,按消息时间倒序返回,total 是命中总数(不受 limit 影响),结果按 limit 截断。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| keyword | string | 是 | 关键词,去空白后不能为空;匹配不区分大小写 |
| limit | uint32 | 否 | 返回条数,默认 20、上限 100(超出由 SDK 收敛) |
| 返回字段 | 类型 | 说明 |
|---|---|---|
| messages | Message[] | 命中的消息,按时间倒序;direction 已按当前用户计算 |
| total | uint32 | 命中总数,用于展示“共 N 条”与判断是否被截断 |
import { createClient, ConversationType } from '@ni-paas/im-web';
const client = createClient({ appkey: APPKEY, serverUrl: SERVER_URL });
await client.connect(token);
// 全局搜索:只返回前 limit 条,total 才是命中总数。
async function search(keyword, limit = 20) {
const res = await client.searchMessages({ keyword, limit });
if (res.code !== 0) {
// 1001 参数错误(keyword 为空)/ 1003 无权限
throw new Error(`[${res.code}] ${res.msg}`);
}
const { messages, total } = res.data;
return { messages, total, truncated: messages.length < total };
}
// 会话内搜索:先按会话过滤(服务端没有按会话过滤的参数)。
async function searchInConversation(keyword, conversationType, targetId) {
const { messages } = await search(keyword, 100);
return messages.filter(
(m) => Number(m.conversationType) === Number(conversationType) && String(m.targetId) === String(targetId),
);
}IMClient client = IMClient.init(context, new IMClientOptions("demo", "http://127.0.0.1:8080"));
client.connect(token, new ConnectCallback() {
@Override public void onSuccess(String userId) { /* 已连接 */ }
@Override public void onError(IMError error) { /* 1002/3002 */ }
});
// 搜索:回调在主线程投递;result.total 为命中总数。
client.searchMessages(new SearchMessagesOptions("你好", 20), new ResultCallback<MessageSearchResult>() {
@Override public void onSuccess(MessageSearchResult result) {
boolean truncated = result.getMessages().size() < result.getTotal();
render(result.getMessages(), result.getTotal(), truncated);
}
@Override public void onError(IMError error) {
// 1001 参数错误;1003 无权限
toast("搜索失败:" + error.getCode() + " " + error.getMessage());
}
});final engine = await RCIMIWEngine.create(
'YOUR_APP_KEY',
RCIMIWEngineOptions.create(naviServer: 'https://im.example.com'),
);
final code = await engine.connect(token, 10);
if (code != 0) throw Exception('connect failed: $code');
Future<List<Message>> search(String keyword) async {
final res = await engine.searchMessages(keyword: keyword, limit: 20);
if (res.code != 0) {
// 1001 参数错误 / 1003 无权限
throw Exception("[${res.code}] ${res.msg}");
}
final result = res.data!;
return result.messages; // result.total 为命中总数
}因为服务端没有 offset 分页,“加载更多搜索结果”无法通过 searchMessages 实现。可选做法:① 限制 limit=100 并在 UI 上只展示前 N 条;② 引导用户补充关键词(越具体命中越少);③ 先按会话分页定位,再在本地过滤。
方案 B:历史分页 + 客户端过滤
单会话内翻历史用 getHistoryMessages:beforeSeq 是向前翻页游标(只取 seq 更小的消息),每页默认 20、上限 100;返回 hasMore 表示是否还有更早的消息。
// 分页加载历史,直到命中足够的关键词结果或不再有更多。
async function searchOlder(keyword, conversationType, targetId, maxPages = 5) {
const hits = [];
let beforeSeq = undefined;
for (let page = 0; page < maxPages; page += 1) {
const res = await client.getHistoryMessages(conversationType, targetId, { limit: 50, beforeSeq });
if (res.code !== 0) throw new Error(`[${res.code}] ${res.msg}`);
const { messages, hasMore } = res.data;
for (const m of messages) {
const text = m.content && typeof m.content.text === "string" ? m.content.text : "";
if (text.toLowerCase().includes(keyword.toLowerCase())) hits.push(m);
}
if (!hasMore || messages.length === 0) break;
beforeSeq = messages[messages.length - 1].seq; // 序列号最小的那条
}
return hits;
}// 注意:分页是“单会话”的;Web/Android 的 IMLib 不提供本地全文索引,
// 这里只是把已加载的页在内存里过滤,不要直接读 SDK 的私有 SQLite 文件。
void loadOlder(IMClient client, ConversationType type, String targetId, String keyword) {
client.getHistoryMessages(type, targetId, new HistoryOptions(50, null),
new ResultCallback<HistoryPage>() {
@Override public void onSuccess(HistoryPage page) {
for (Message m : page.getMessages()) {
String text = m.getContent() == null ? "" : m.getContent().getText();
if (text != null && text.toLowerCase().contains(keyword.toLowerCase())) {
appendHit(m);
}
}
// page.getHasMore() 为 false 时已经到头
}
@Override public void onError(IMError error) { /* 1001/1003/3002 */ }
});
}Future<List<Message>> searchOlder(String keyword, int conversationType, String targetId) async {
final hits = <Message>[];
int? beforeSeq;
for (var page = 0; page < 5; page++) {
final res = await client.getHistoryMessages(
conversationType,
targetId,
limit: 50,
beforeSeq: beforeSeq,
);
if (res.code != 0) throw Exception("[${res.code}] ${res.msg}");
final data = res.data!;
for (final m in data.messages) {
final text = m.content.text ?? "";
if (text.toLowerCase().contains(keyword.toLowerCase())) hits.add(m);
}
if (!data.hasMore || data.messages.isEmpty) break;
beforeSeq = data.messages.last.seq;
}
return hits;
}方案 C:Electron 本地库检索
Electron 的 IMLib 在本地 SQLite 落库(localDb),提供真正的本地检索:searchMessages(关键词 + 可选会话/时间窗/消息类型 + offset/limit + total)、searchMessagesByUser(某会话内某发送者的消息)、getMessageCount(按过滤条件计数)、getMessagesAround(围绕某个时间点取上下文)。匹配是 SQL LIKE 子串匹配(对搜索列),不是全文索引。
import electronMain from '@ni-paas-im/electron/main';
import { app } from 'electron';
import { join } from 'node:path';
const client = new electronMain.IMElectronClient({
appkey: APPKEY,
serverUrl: SERVER_URL,
dbPath: join(app.getPath('userData'), 'im.db'),
});
await client.connect(token);
// 1) 全局本地搜索(可跨会话),带分页与总数
const page = await client.localDb.searchMessages({
keyword: '你好',
offset: 0,
limit: 20,
// conversationType / targetId / startTime / endTime / messageTypes 可组合过滤
});
const { messages, total } = page.data;
// 2) 某会话内某人的消息
const mine = await client.localDb.searchMessagesByUser(1, 'user2', 'alice', { limit: 20 });
// 3) 命中数量(用于“共 N 条”与是否继续翻页)
const count = await client.localDb.getMessageCount(1, 'user2', { keyword: '你好' });
// 4) 跳到命中的上下文(前后各若干条)
const around = await client.localDb.getMessagesAround(1, "user2", {
timestamp: messages[0].timestamp,
beforeCount: 10,
afterCount: 10,
});聊天室会话不落本地库(isPersistentConversationType 为 false),因此本地搜索不会返回聊天室消息。
本地库只覆盖“本机已同步到的消息”;重装、换设备或清理 localDb.clearAll() 之后需要重新从服务端分页拉取。
本地搜索与时间窗使用本机 SQLite 的存储字段;跨设备结果不一定一致(各端同步进度不同)。
各端本地存储事实(决定“能不能离线搜”)
| 端 | 存储介质 | 消息持久化 | 离线可搜 | 清理方式 |
|---|---|---|---|---|
| Web(IMLib) | 进程内存 Map | 否 | 否 | 页面销毁即释放 |
| Web(IMKit IMLocalStore) | localStorage(不可用降级内存) | 宿主自定义的会话快照/草稿/主题,非消息库 | 否 | IMKit.store.clear() |
| Android | 系统 SQLite(按用户隔离) | 是 | 否(无公开查询 API) | deleteConversation 删除会话行;彻底清理需宿主清除应用数据 |
| Electron | 本地 SQLite(node:sqlite) | 是 | 是 | localDb.clearMessages / deleteMessagesByTime / clearAll |
| Flutter | 客户端实例内存缓存 | 否 | 否 | 实例销毁 |
完整存储口径(含离线补发窗口与清理接口)见 本地存储与离线时长;Electron 本地库的 26 个方法见 Electron 客户端类。
错误处理与重试
| 错误码 | 含义 | 是否重试 | 处理建议 |
|---|---|---|---|
| 1001 | 参数错误(keyword 为空、limit 非法) | 否 | 修正参数;前端做空值与长度校验 |
| 1003 | 无权限(Token 不属于该应用/用户) | 否 | 重新签发 Token 后重连 |
| 1002 | Token 无效或已过期 | 否 | 重新签发 Token;不要循环重试旧 Token |
| 3001 | 长连接未就绪 | 是 | 等待 CONNECTED 事件后再发;或改用 REST 轮询前先重连 |
| 3002 / 3003 | 超时 / 网络错误 | 是 | 指数退避重试(1s、2s、4s… 上限 30s),并提示“搜索失败可重试” |
| 2001 / 2002 | 服务端内部错误 / 依赖不可用 | 是 | 退避重试;持续失败按 requestId 排查 |
// 统一重试封装:只对可重试错误退避,参数/权限错误直接抛出。
const RETRYABLE = new Set([3001, 3002, 3003, 2001, 2002]);
export async function searchWithRetry(client, keyword, { attempts = 3 } = {}) {
let last;
for (let i = 0; i < attempts; i += 1) {
const res = await client.searchMessages({ keyword, limit: 20 });
if (res.code === 0) return res.data;
last = res;
if (!RETRYABLE.has(res.code)) break;
const wait = Math.min(1000 * 2 ** i, 30000) + Math.random() * 200;
await new Promise((resolve) => setTimeout(resolve, wait));
}
throw new Error(`搜索失败:[${last.code}] ${last.msg}`);
}限制与已知取舍
- 服务端搜索没有分页参数(只有 limit 与 total),最多返回 100 条;需要“翻页”只能改用历史分页或本地库检索。
- 只匹配文本消息的 content.text;图片/文件/自定义消息的文本摘要不参与匹配。
- 撤回消息内容被替换为撤回占位,搜索不再命中(这是协议语义,不是缺陷)。
- 聊天室消息不持久化:不落服务端历史、不落本地库,任何搜索路径都取不到。
- Android 的本地 SQLite 由 SDK 管理且未暴露查询 API;不要绕过 SDK 直接读库(升级后表结构可能变化)。
- Electron 本地检索依赖本机同步进度;重新安装或清理本地库后需要重新同步。
- 没有搜索高亮与搜索历史接口;高亮由客户端在拿到消息文本后自行实现。
相关链接
- 本地存储与离线时长:各端落库内容与离线补发窗口
- IMLib 进阶:消息收发与本地存储:发送状态机与去重
- 消息进阶:扩展、回应、流式与合并转发
- 消息模块接口:服务端消息查询与导出
- IMKit 自定义会话列表与主题:把搜索结果渲染进会话/消息列表