Rocket.Chat v1 REST API 新增 POST /v1/chat.readThread:按线程粒度标记已读,替代 DDP readThreads 方法
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
本文围绕 Rocket.Chat 的一次 API 变更(.changeset/rest-chat-read-thread.md)展开:新增POST /v1/chat.readThread端点,允许调用方通过单个tmid将某一条线程标记为已读——将其从自己的订阅未读列表中移除,并触发beforeReadMessages/afterReadMessages回调;同时原 DDP 方法readThreads被标记弃用(保留至 9.0.0)。读完本文,你可以直接使用这条 REST 路由完成线程级已读同步,并理解它与POST /v1/subscriptions.read的语义边界、以及与底层readThread实现之间的完整调用链。
端点概览
该端点随@rocket.chat/meteor的 minor 版本引入,核心语义(见变更说明 rest-chat-read-thread.md)为:
- 请求:
POST /v1/chat.readThread,Body 为{ tmid }(tmid即线程首条消息的_id); - 行为:将单条线程标记为已读,作用于调用者自身的订阅——把
tmid从该订阅的未读线程列表中清除,并运行beforeReadMessages/afterReadMessages回调; - 定位:它是 DDP 方法
readThreads的 REST 替代,旧方法保留注册但会输出弃用日志,日志指向新路由,计划保留至 9.0.0; - 与
POST /v1/subscriptions.read不同:后者接受{ rid, readThreads? },作用于整个房间(整屋已读或整屋线程已读),无法精确指定某一条线程。
端点在 apps/meteor/server/api/v1/chat.ts 中通过API.v1.post('chat.readThread', ...)注册,声明authRequired: true,即必须携带有效的 Bearer Token 访问。
请求体与响应契约(源码级定义)
请求体由 AJV 校验。在 chat.ts 中定义了类型与 Schema:
type ChatReadThread = { tmid: string; }; const ChatReadThreadSchema = { /* tmid: 字符串,必填 */ }; const isChatReadThreadProps = ajv.compile<ChatReadThread>(ChatReadThreadSchema);响应契约同样由 AJV 编译(chat.ts#L269-L280):
| 状态码 | 契约 |
|---|---|
| 200 | { "success": true },且additionalProperties: false(严格模式,不允许多余字段) |
| 400 | validateBadRequestErrorResponse(Body 不符合{ tmid: string }时) |
| 401 | validateUnauthorizedErrorResponse(未认证或 Token 无效) |
一个可直接复制的调用示例(tmid为线程首条消息 ID):
curl -X POST "https://<instance>/api/v1/chat.readThread" \ -H "Authorization: Bearer <API_TOKEN>" \ -H "Content-Type: application/json" \ -d '{"tmid":"<thread_message_id>"}' # 成功返回 200: {"success":true}服务端处理流程:从参数校验到权限检查
action()的处理链(chat.ts#L282-L311)按顺序执行以下检查,每一步都有明确的错误码,便于集成方做幂等与故障定位:
- 功能开关:
settings.get('Threads_enabled')为false时抛出error-not-allowed("Threads Disabled")。线程功能可以在服务器端整体关闭,端点随之不可用; - 线程存在性:
Messages.findOneById(tmid, { projection: { rid: 1 } })仅取出rid;tmid不存在(或无rid)时抛出error-invalid-message("Invalid Message")。注意这里对"线程不存在"的语义是报错,而不是静默成功; - 用户与房间并行加载:
Promise.all([Users.findOneById(this.userId), Rooms.findOneById(thread.rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } })]);房间不存在抛出error-room-does-not-exist; - 访问权限:
!user || !(await canAccessRoomAsync(room, user))时抛出error-not-allowed。canAccessRoomAsync复用了与其他消息 API 一致的房间级授权逻辑,保证只有能访问该房间的用户才能执行标记已读; - 前置回调 + 核心操作:先
callbacks.run('beforeReadMessages', room._id, user._id),再执行readThread({ user, room, tmid }); - 返回
API.v1.success(),即上文 200 契约中的{ success: true }。
核心实现:readThread 如何清除未读状态
真正的状态变更位于 apps/meteor/server/lib/messaging/threads/functions.ts,readThread被 REST 端点与 DDP 旧方法共同复用(见下文弃用一节),保证两条入口的行为一致:
export const readThread = async ({ user, room, tmid }) => { const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { tunread: 1 } }); if (!sub) return; // if the thread being marked as read is the last one unread also clear the unread subscription flag const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid); const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId( room._id, user._id, tmid, clearAlert, ); if (removeUnreadThreadResponse.modifiedCount) { void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, user._id); } await NotificationQueue.clearQueueByUserId(user._id); callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); };从源码可以读出四个关键细节:
- 仅作用于调用者本人的订阅:查找订阅用的
room._id + user._id,不会把其他用户的未读线程清掉——这正是"marks a single thread as readfor the caller"(变更文档原话)的实现依据; tunread数组与"最后一未读线程"优化:clearAlert在"被读线程是本订阅最后一个未读线程,且确实出现在tunread中"时为true,此时removeUnreadThreadByRoomIdAndUserId会顺带清除订阅级别的未读提示标志,避免残留角标;- 客户端实时刷新:若数据库实际发生了修改(
modifiedCount > 0),会通过notifyOnSubscriptionChangedByRoomIdAndUserId推送订阅变更通知,客户端侧边栏/线程未读数随之更新; - 通知队列与后置回调:
NotificationQueue.clearQueueByUserId(user._id)清空该用户的本地通知队列;afterReadMessages通过callbacks.runAsync异步执行,不阻塞响应返回。注意两个回调的对称性:beforeReadMessages在 handler 中以run(同步)方式先行执行,afterReadMessages则在readThread内部以runAsync(异步)方式收尾,与变更文档中"running thebeforeReadMessages/afterReadMessagescallbacks"的描述完全对应。
从源码结构看,afterReadMessages回调在 EE 层还有具体实现(如 ee/server/hooks/messages/afterReadMessages.ts),已读事件可被扩展用于已读回执等能力,这是把"标记已读"做成回调链而非私有写库的原因之一。
与 POST /v1/subscriptions.read 的语义边界
变更文档专门指出:POST /v1/subscriptions.read不覆盖单线程已读场景。对照 apps/meteor/server/api/v1/subscriptions.ts 的实现可以验证这一点:
async function action() { const { readThreads = false } = this.bodyParams; const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; const room = await Rooms.findOneById(roomId); if (!room) { throw new Error('error-invalid-subscription'); } await readMessages(room, this.userId, readThreads); return API.v1.success(); }| 维度 | POST /v1/chat.readThread | POST /v1/subscriptions.read |
|---|---|---|
| 地址参数 | tmid(线程首条消息 ID) | rid(或roomId) |
| 可选参数 | 无 | readThreads?(布尔,默认false) |
| 作用粒度 | 单条线程 | 整个房间(含房间内全部线程) |
| 适用场景 | 用户打开/离开某一条线程时精确同步已读 | 用户进入房间时"整屋已读" |
因此选型建议是:在 UI 中用户打开线程面板、线程阅读完毕等场景用chat.readThread做精确更新;用户进入房间、清空房间未读的场景才用subscriptions.read。用后者"顺便"读线程会把整房间的未读一并清除,粒度不对。
弃用路径:DDP readThreads 到 REST 路由的迁移
旧的 DDP 方法readThreads仍被注册,但每次调用都会先经过弃用日志(apps/meteor/server/meteor-methods/messages/readThreads.ts):
async readThreads(tmid) { methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/chat.readThread'); check(tmid, String); if (!Meteor.userId() || !settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'getThreadMessages' }); } // ... 查找线程/房间、canAccessRoomAsync 校验后: await callbacks.run('beforeReadMessages', thread.rid, user?._id); if (user?._id) { await readThread({ user: user as IUser, room, tmid }); } },methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/chat.readThread')即变更文档所说的"deprecation log pointing at the new route until 9.0.0":日志声明了移除版本(9.0.0)与迁移目标(/v1/chat.readThread)。两条入口在业务逻辑上是等价的——同样校验Threads_enabled、同样做canAccessRoomAsync授权、同样先跑beforeReadMessages再调用同一个readThread函数——迁移时的主要工作是把客户端的Meteor.call('readThreads', tmid)替换为携带 Bearer Token 的 HTTP POST,并调整错误处理:REST 侧的失败以 HTTP 状态码(400/401)和error-code表达,而非 DDP 异常。
小结与使用前提
- 端点:
POST /v1/chat.readThread,Body{ tmid },成功返回{ success: true }(严格 Schema,禁止额外字段); - 前提条件:实例开启
Threads_enabled设置;调用者拥有线程所在房间的访问权限;只修改调用者本人的订阅未读状态; - 效果:从订阅
tunread移除该线程(最后一个未读线程时顺带清除未读角标)、推送订阅变更通知、清空用户通知队列、触发afterReadMessages异步回调; - 关键源码位置:路由与校验 apps/meteor/server/api/v1/chat.ts、核心状态变更 apps/meteor/server/lib/messaging/threads/functions.ts、弃用 DDP 方法 apps/meteor/server/meteor-methods/messages/readThreads.ts、房间级对照端点 apps/meteor/server/api/v1/subscriptions.ts。
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考