场景教程:消息搜索与本地存储

聊天应用里的“搜索”通常不是一件事,而是三种不同来源的检索:服务端文本搜索(跨会话、跨设备,依赖服务端历史)、本地历史分页 + 客户端过滤(只覆盖已加载消息)、本地数据库检索(仅 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 截断。

参数类型必填说明
keywordstring是关键词,去空白后不能为空;匹配不区分大小写
limituint32否返回条数,默认 20、上限 100(超出由 SDK 收敛)
返回字段类型说明
messagesMessage[]命中的消息,按时间倒序;direction 已按当前用户计算
totaluint32命中总数,用于展示“共 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 子串匹配(对搜索列),不是全文索引。

JavaScript
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,
});
Electron 的边界

聊天室会话不落本地库(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 后重连
1002Token 无效或已过期否重新签发 Token;不要循环重试旧 Token
3001长连接未就绪是等待 CONNECTED 事件后再发;或改用 REST 轮询前先重连
3002 / 3003超时 / 网络错误是指数退避重试(1s、2s、4s… 上限 30s),并提示“搜索失败可重试”
2001 / 2002服务端内部错误 / 依赖不可用是退避重试;持续失败按 requestId 排查
JavaScript
// 统一重试封装:只对可重试错误退避,参数/权限错误直接抛出。
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 本地检索依赖本机同步进度;重新安装或清理本地库后需要重新同步。
  • 没有搜索高亮与搜索历史接口;高亮由客户端在拿到消息文本后自行实现。

相关链接​