Rocket.Chat 统一消息历史接口 `GET /v1/rooms.history`:跨房间类型的历史记录加载与游标分页实战解析
在 Rocket.Chat 中,历史消息的 REST 化读取此前依赖 channels.history、groups.history、im.history、dm.history 这一组按房间类型拆分、各走独立路由的端点。本篇文章围绕新版变更集 .changeset/rest-rooms-history.md 引入的 GET /v1/rooms.history 端点展开:它用一个统一路由覆盖频道、私信、讨论等所有房间类型,并支持在开启 Accounts_AllowAnonymousRead 时匿名读取公开频道历史。读完本文,你将掌握该端点的请求参数与校验规则、游标分页与未读消息计算的底层实现、权限判定链路,以及如何在客户端使用同一端点稳定加载历史消息。
一、为什么需要一个新的历史记录端点
在 Rocket.Chat 既有代码库中,房间类型与历史接口长期呈一一对应的关系:channels.history 对应公开频道、groups.history 对应私有群组、im.history/dm.history 对应私信会话。这类设计带来的直接问题是:
- 调用方必须预先知道目标房间属于哪种类型,才能拼出正确的路由;
- 无法用一条通用逻辑处理“任取一个房间读历史”的场景(如管理后台、机器人、跨类型聚合查询);
- 匿名访问公开频道历史需要额外打通各类型端点的鉴权路径。
GET /v1/rooms.history 正是为解决这些问题而新增的统一历史记录接口。它接受一个 roomId,即可读取任意类型房间的消息历史;当系统开启 Accounts_AllowAnonymousRead 时,还能以未登录(匿名)身份读取公开频道的历史消息。从变更集声明看,该功能同时涉及 @rocket.chat/rest-typings(类型与请求校验 Schema)与 @rocket.chat/meteor(服务端实现)两个包的 minor 版本变更。
与旧版本 DDP 方法的关系
接口实现处的注释指出:其 count 默认取 20,是为了“匹配被替换的 DDP 方法”,而非沿用通用的 API_Default_Count 设置(但仍受 API_Upper_Count_Limit 上限约束)。也就是说,这个 REST 端点实际是把原先以 Meteor DDP 方式提供的“房间历史加载”能力迁移到了 HTTP REST 层,并统一了行为,详见 服务端实现 中的默认值处理。
二、请求格式与参数说明
端点的路由定义与查询参数校验位于 服务端路由注册,其查询参数的类型声明和 JSON Schema 校验规则定义在 packages/rest-typings/src/v1/rooms.ts。核心请求参数如下:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
roomId |
string | 是 | 目标房间 ID,Schema 要求 minLength: 1 |
next |
string | 否 | 游标:向“更新”方向翻页(取比该游标更新的消息),不可与 previous 同时给出 |
previous |
string | 否 | 游标:向“更旧”方向翻页,不可与 next 同时给出 |
lastSeen |
string | 否 | ISO 8601 日期时间字符串,仅用于定位“未读分隔线”,不作为翻页边界 |
count |
integer | 否 | 本次返回的最大条数,minimum: 1;缺省按 20 处理并受 API_Upper_Count_Limit 上限钳制 |
showThreadMessages |
boolean | 否 | 是否把线程回复并入历史结果,默认 true |
需要特别留意 Schema 上的两条硬约束:
required: ['roomId']—— 其余全部可选;additionalProperties: false—— 出现任何未声明字段(如拼写错误的room_id)都会被校验层直接拒绝,返回 400。
请求校验由 isRoomsHistoryProps = ajvQuery.compile<RoomsHistoryProps>(RoomsHistorySchema) 完成(见 类型与校验),这意味着该端点的入参在编译期(TypeScript 类型)与运行期(AJV 校验)两侧都受到严格约束。
一个典型的首次请求示例:
GET /api/v1/rooms.history?roomId=GENERAL&count=50
首次请求不携带任何游标,接口返回最新的一页消息(见下文游标语义)。翻页时使用响应体 cursor 字段回填 next 或 previous。
三、响应结构解析
端点对 200 成功响应的约束由 roomsHistoryResponseSchema 定义(见 响应 Schema 定义),同时也注册了 400 / 401 / 403 / 404 等错误响应。成功响应结构如下:
{
"messages": [
/* 数组,元素为 IMessage,若调用方已登录则已按用户做脱敏/过滤(normalizeMessagesForUser) */
],
"cursor": {
"next": "1710000000000",
"previous": "1709999000000"
},
"firstUnread": {},
"unreadNotLoaded": 12,
"success": true
}
| 字段 | 必填 | 说明 |
|---|---|---|
messages |
是 | 本次返回的消息列表(IMessage 数组),按时间由新到旧排列 |
cursor.next |
是 | 指向更新消息的游标(时间戳毫秒字符串);没有更多新消息时为 null |
cursor.previous |
是 | 指向更旧消息的游标;首页(最新端)向前翻页为空时为 null |
firstUnread |
否 | 若指定了 lastSeen,返回该时间点之后的第一条未读消息 |
unreadNotLoaded |
否 | 自 lastSeen 起、当前页未包含的未读消息数量 |
success |
是 | 恒为 true |
messages、cursor、success 三个字段在 Schema 中被标记为必填,firstUnread 与 unreadNotLoaded 为可选,整个对象不允许出现额外字段。响应体的实际构造逻辑来自工具函数 loadRoomHistory 的返回值(见 loadRoomHistory.ts)。
四、鉴权与权限判定链路
端点配置使用 authOrAnonRequired: true(见 端点注册),即“登录认证或匿名访问”二选一。其鉴权流程可以归纳为三层:
- 身份层(中间件):请求要么携带有效登录态,要么系统已开启
Accounts_AllowAnonymousRead从而放行匿名请求——实现处的注释明确说明“匿名读取已由中间件基于Accounts_AllowAnonymousRead先行把关”。 - 房间可访问性:处理器通过
Rooms.findOneById加载房间,房间不存在返回 404;随后调用canAccessRoomAsync(room, this.user)做细粒度访问控制,失败返回 403(见 处理器逻辑)。 - 公开频道预览权限:对已登录但未订阅该频道的用户,若其没有
preview-c-room权限,则同样返回 403;已订阅用户不受此限制(见 预览权限校验)。
因此,能够访问该接口的典型身份包括:房间成员、持有 preview-c-room 的非订阅用户,以及在 Accounts_AllowAnonymousRead 开启时访问公开频道的匿名访客。综合下来,接口对外暴露 200(成功)、400(参数非法)、401(未认证且不允许匿名)、403(无权限)、404(房间不存在)五类状态码。
五、游标分页机制:cursor 的生成、解码与方向语义
rooms.history 采用基于时间戳的游标分页,而非传统的 offset/limit。所有游标逻辑都封装在 loadRoomHistory.ts 中。
游标的编解码
游标本身只是消息时间戳的毫秒数转字符串:
encodeHistoryCursor(ts)返回`${ts.getTime()}`;decodeHistoryCursor(cursor)先要求纯数字格式,再校验是否可解析为合法日期,任一环节失败即抛出error-invalid-cursor。
两个方向互斥
loadRoomHistory 的第一步就是断言 next 与 previous 不能同时出现,否则抛出 error-cursor-conflict。方向由游标唯一决定:
- 携带
next:表示沿时间轴“向前/更新”翻页,查询区间为(next 时间戳, FAR_FUTURE]; - 携带
previous:表示沿时间轴“向后/更旧”翻页,查询区间为(-∞, previous 时间戳); - 两者都不带:从房间最新消息开始取第一页。
源码中使用 FAR_FUTURE = new Date(8640000000000000)(即 JS Date 的上界)作为时间范围查询的封闭上界,注释说明这是为了防止“客户端打上的未来时间戳”在开区间查询中被意外丢弃(见 FAR_FUTURE 定义)。
排序、翻页探测与 _id 决胜
实现细节上非常讲究:
- 查询排序方向与翻页方向相反(
next时ts升序、否则降序),并多取一条(limit: count + 1)用于探测是否还有后续页; - 取回记录后先在内存中按“
ts相同则用_id做决胜”的规则重排。之所以需要这一条,是因为 MongoDB 的{ rid, ts, _updatedAt }复合索引无法支撑{ ts, _id }排序,若不加决胜,同一时间戳下的消息返回顺序将不确定(见 排序与决胜逻辑); - 若发现确实存在更多页,则把多取的最后一条弹掉;
- 携带
next时,最终将整页反转,因此无论朝哪个方向翻页,返回的messages都统一为“由新到旧”排列(见 loadRoomHistory.ts)。
cursor 中各字段何时为 null
结果对象中的 hasNewer/hasOlder 逻辑决定了游标是否为空:
- 携带
next翻页时,hasOlder恒为true(说明更旧一侧总还有数据),而hasNewer取决于本页是否被截断(是否真的还有更新消息); - 携带
previous翻页时相反; - 首页因从最新端起步,
hasNewer由截断标志决定、hasOlder由是否存在更早消息决定。
最终 next/previous 要么是对应方向最新/最旧一条消息的时间戳游标,要么是 null(表示该方向已到尽头)。客户端判断“是否还能继续翻”只需看 cursor.next/cursor.previous 是否为 null。
六、lastSeen 与未读定位:不破坏翻页的“未读分隔”
这是 rooms.history 区别于传统分页接口的另一大能力:在返回历史页的同时附带未读定位信息。
loadRoomHistory 注释解释得很清楚——lastSeen 只用来“摆放未读分隔线”,如果拿它当翻页边界,页面会在标记处被截断,这正是分房间类型的旧 *.history 端点无法提供该能力的原因(见 设计注释)。
具体算法(computeUnread,见 未读计算)在以下条件全部满足时生效:
- 请求携带了
lastSeen且可解析为合法时间; - 当前页存在最旧一条消息(
oldest); oldest.ts严格晚于lastSeen(即当前加载窗口确实跨过了未读边界)。
满足时,接口会并行执行两条查询:取 (lastSeen, oldest.ts] 区间内按 ts 升序的第一条作为 firstUnread,并统计该区间内消息总数作为 unreadNotLoaded。这样前端可以在页面中精确渲染“从这里开始是你的未读”的提示,同时不会影响原有翻页游标的连续性。
七、服务端的数据可见性与系统消息过滤
历史查询并非简单地把房间内全部消息倒出来,服务端做了两层过滤,均由 loadRoomHistory 统一处理:
- 可见性过滤:调用
Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes/Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes系列查询,只读取“可见”消息; - 系统消息过滤:通过
getHiddenSystemMessages(room, settings.get('Hide_System_Messages'))计算当前设置Hide_System_Messages下需要隐藏的系统消息类型集合,并从结果中剔除(见 loadRoomHistory.ts)。
对已登录用户,返回前还会经 normalizeMessagesForUser(records, userId) 处理,按用户身份对消息做脱敏与展示级适配(例如依据用户的订阅情况过滤掉无权看到的消息内容)。
八、在客户端与类型层使用该端点
类型定义
RoomsHistoryProps 与配套 Schema 已并入 @rocket.chat/rest-typings,并作为 GET 参数类型挂载到 Endpoints 接口上(见 类型声明)。升级 @rocket.chat/rest-typings 到含本次 minor 变更的版本后,sdk.rest.get('/v1/rooms.history', ...) 即可获得完整的请求/响应类型推导与运行期校验。
客户端调用示例
Rocket.Chat 客户端自身的房间历史管理器 RoomHistoryManager 已经切换到该端点。从 RoomHistoryManager.ts 与同文件 第二处调用 可以看到统一调用形态:
const result = await sdk.rest.get('/v1/rooms.history', {
roomId,
count,
lastSeen,
next: nextCursor ?? undefined,
});
翻页时只需把上一次响应 cursor.next(向更新方向)或 cursor.previous(向更旧方向)透传回去即可,服务端会按第五节描述的规则处理方向与排序,客户端无需感知房间类型差异。
九、可用性前提与迁移注意
- 版本要求:该端点随
@rocket.chat/rest-typings与@rocket.chat/meteor的 minor 版本更新发布,使用时需确保服务端与类型包均已升级到包含本次变更的版本。 - 匿名读取是特性而非默认:匿名访问公开频道历史仅在系统设置
Accounts_AllowAnonymousRead开启时生效;未开启时匿名请求会停留在 401 层,不会进入房间访问判断。 - 参数严格性:
additionalProperties: false意味着所有扩展参数都会导致 400;next/previous互斥、游标必须为合法毫秒时间戳字符串,违反任一约束都会触发对应的校验错误。 - 统一替换路径:新接入历史消息功能的场景应优先使用
rooms.history,以同时获得跨房间类型支持、游标翻页与未读定位能力;既有的按类型端点仍可用于向后兼容场景。
以上分析与参数默认值均直接对照本仓库中的 变更集、路由与权限实现、核心加载逻辑 与 REST 类型定义,可作为深度阅读上述源码的导览起点。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00