先说结论
在 Odoo Discuss 里,未读数并不是“这个频道里新来了多少条消息”这么简单。
官方源码真正维护的是一套成员级阅读状态,核心字段至少有这几个:
seen_message_id:我最后真正看到哪条消息fetched_message_id:客户端已经拉到哪条消息new_message_separator:界面上的“新消息分隔线”应该放在哪message_unread_counter:对这个成员来说,还算未读的消息数last_interest_dt/unpin_dt:这个会话还应不应该继续被置顶显示
一句话说:
Odoo Discuss 不是按频道统一算未读,而是按“成员自己的阅读游标”来算。
这也是为什么同一个频道里,不同人的未读数、置顶状态、分隔线位置都可能不一样。
源码从哪里看
这套逻辑最直接的入口,在官方源码:
addons/mail/models/discuss/discuss_channel_member.py
这个模型不是“频道本身”,而是某个成员在某个频道里的那一份状态。
这点非常关键。
因为协同系统里真正需要保存的,不只是“这个群里有什么消息”,还包括:
- 我读到哪里了
- 我是不是把它静音了
- 我有没有把它取消置顶
- 我是否还对这个会话保持兴趣
这些都不是频道公共属性,而是用户私有状态。
为什么 Odoo 要把阅读状态放在 discuss.channel.member
很多 IM 产品刚上手时,大家直觉会把“未读数”理解成频道自己的字段。
但 Odoo 没这样做。
因为一个频道可以有很多成员,而每个人的阅读进度都不同。
所以源码里把下面这些状态放在 discuss.channel.member 上:
seen_message_idfetched_message_idnew_message_separatormessage_unread_countercustom_notificationsmute_until_dtunpin_dtlast_interest_dt
这意味着 Odoo 从模型层就承认了一件事:
讨论频道是共享空间,但阅读体验是每个成员各自维护的。
这比“群里有多少未读”要精细得多,也更符合真实协作场景。
message_unread_counter 为什么不是简单的“新消息总数”
在 discuss_channel_member.py 里,_compute_message_unread() 的 SQL 很值得看。
它并不是按“消息创建时间晚于上次打开时间”来算,而是按:
- 消息属于当前
discuss.channel message_type不能是notification或user_notification- 消息
id >= new_message_separator
也就是说,Odoo 的未读数更接近:
从“新消息分隔线”往后的、真正算聊天内容的消息数。
这立刻解释了几个常见现象:
1. 系统通知不一定算进未读
像某些纯通知型消息,源码明确排除了。
2. 未读数和“最后看见哪条”不是一个字段直接减出来
它依赖的是 new_message_separator,而不是单纯的 seen_message_id。
3. 你把分隔线往后推,未读数就会跟着变化
因为它本质上是“从哪开始算新消息”。
所以未读数不是总量,而是带有界面语义的计数。
seen_message_id 和 new_message_separator 为什么要分开
这是很多人第一次读源码时最容易忽略的设计。
直觉上,好像只要一个“最后已读消息 ID”就够了。
但 Odoo 还是把它拆成两个概念:
seen_message_id
表示:
- 我最后真正读到哪条消息
- 这个字段更像“阅读游标”
new_message_separator
表示:
- UI 上“新消息从哪里开始”
- 这个字段更像“界面断点”
在 _mark_as_read() 里,Odoo 会先 _set_last_seen_message(last_message),再 _set_new_message_separator(last_message.id + 1)。
这说明官方设计并不是简单复用一个字段,而是把:
- 阅读事实
- 界面呈现
刻意分开。
这很聪明。
因为协同产品里的“我看到了哪”与“UI 应该从哪画一条新旧分界线”,虽然相关,但并不完全等价。
为什么 Odoo 要额外记录 fetched_message_id
fetched_message_id 容易被忽略,但它其实很有产品意义。
它表示的是:
- 客户端至少已经拉取到哪条消息
这和“用户认真读过”不是一回事。
比如一个聊天窗口被打开了,前端可能已经把消息取回来了,但用户未必真正滚动到最底部,也未必完成“已读”动作。
所以 Odoo 把它拆开:
fetched_message_id:数据层面拿到了seen_message_id:阅读层面确认看到了
这正是一个成熟协同系统该有的区分。
置顶为什么也不是一个简单布尔值
很多人会以为“是否置顶”只要一个 is_pinned 字段就好了。
但源码里真正存的是:
unpin_dtlast_interest_dt- 频道自己的
last_interest_dt
然后通过 _compute_is_pinned() 动态算出 is_pinned。
判断逻辑大意是:
- 如果从未取消置顶,那就算 pinned
- 如果成员最近还有兴趣事件,且时间晚于取消置顶时间,那又会恢复成 pinned
- 如果频道最近也有值得关注的事件,同样可能重新变成 pinned
这背后体现的不是“我点没点图钉”,而是:
Odoo 在区分“手工取消置顶”与“这个频道后来又重新变得重要”。
这非常像真实办公协作:
- 某个群你昨天不想看了
- 但今天又有关键互动发生
- 它就值得重新回到你视野里
为什么会有 last_interest_dt
last_interest_dt 的注释写得很直白:
它记录“最近一次有趣事件”的时间,比如:
- 创建
- 加入
- 置顶
看起来这只是体验细节,实际上它是会话排序和浮现机制的一部分。
也就是说,Odoo 并不是只按最后一条消息排序会话,而是在成员层面维护一个“这条会话对我最近还有没有价值”的信号。
所以 Discuss 的会话列表,本质上并不是简单消息流,而是协同优先级列表。
静音也不是“完全不收消息”
custom_notifications 和 mute_until_dt 也很值得一起看。
源码里支持:
all:所有消息mentions:只关心提及no_notif:不接收提醒
再加上 mute_until_dt,说明 Odoo 在通知设计上不是粗暴的“开 / 关”,而是允许:
- 永久偏好
- 临时静音
- 只保留高优先级提醒
这对办公协同尤其重要。
因为工作群最常见的问题不是“消息太少”,而是“消息太多”。
Odoo 的思路很清楚:
读取状态、未读计数、通知策略,是同一套成员状态模型的不同侧面。
这套设计对业务协同有什么现实意义
如果你把 Discuss 仅仅当聊天工具,就会觉得这些字段太复杂。
但一旦把它看成协同中枢,这套设计就很合理:
1. 每个人都能维护自己的阅读上下文
同一个频道,不同岗位的人关注点不一样。
2. 界面能稳定表现“哪里开始是新的”
这能降低来回切换上下文的成本。
3. 重要频道可以被再次浮现
不是所有取消置顶都意味着永久沉底。
4. 通知不会粗暴一刀切
只提及、临时静音、完全关闭,都有明确支撑。
做 Discuss 定制时最容易犯的错
1. 只看 seen_message_id,忽略 new_message_separator
结果你算出来的“未读”跟前端看到的不一致。
2. 把置顶当成一个静态布尔开关
结果会把官方那套“兴趣恢复”逻辑整没了。
3. 误把 fetched 当 seen
客户端取到了,不代表用户真的看完了。
4. 忽略 message_type 的过滤
这样你会把不该算进聊天未读的系统消息也算进去。
一句话记忆法
Odoo Discuss 的未读数,本质上不是频道消息总量,而是“某个成员从自己的新消息分隔线之后,还有多少真正算聊天内容的消息没读”。
理解这一句,你就能看懂 Discuss 列表、分隔线、静音和置顶为什么会以现在这种方式联动。
DISCUSSION
评论区