很多团队以为企业版移动推送是一个后台开关:启用以后,消息来了,手机自然会响。
但 enterprise/mail_mobile 的实现说明,事情远没这么简单。Odoo 真正在处理的是一整条链:
- 这台设备有没有被登记进 OCN;
- 当前消息是不是应该进入移动额外通知;
- 这条通知属于普通消息、私聊,还是
@mention; - payload 能不能在 FCM 4KB 限制里保留足够上下文;
- 用户点开以后,是回到 App,还是退回浏览器。
所以这篇文章的重点不是“怎么开推送”,而是:
Odoo 企业版 Mobile Push 本质上是在统一消息通知流水线里,额外挂了一条面向移动端的路由层。
一、第一步根本不是发消息,而是把设备登记成“可投递目标”
入口在 enterprise/mail_mobile/models/res_config_settings.py。
这里有两个特别关键的方法:
get_fcm_project_id()register_device(fcm_token, device_name, fcm_token_old=None)
前者不是单纯读配置,而是在启用 mail_mobile.enable_ocn 后,必要时主动去 OCN 服务注册当前数据库实例,拿回 odoo_ocn.project_id。换句话说,系统要先把“这套 Odoo 实例是谁”说清楚,后面才有资格发推送。
后者则更直接:移动端提交 FCM token 后,Odoo 会把设备信息送去 OCN 服务,并把返回值写到 partner.ocn_token。
这就决定了一个常被忽略的事实:
- 移动推送面向的并不是 user,而是 partner 维度的可投递身份;
- 没有
ocn_token,后面的推送逻辑根本不会把你当成移动端接收者。
所以排查移动消息问题时,第一步通常不是去看消息有没有创建,而是看目标 partner 有没有完成设备注册。
二、移动推送不是独立消息中心,而是 mail.thread 上的额外通知分支
enterprise/mail_mobile/models/mail_thread.py 覆盖了 _notify_thread()。
它的做法很克制:
- 先调用
super()._notify_thread(...)完成原本的 mail 通知编排; - 再在“不是 scheduled_date 延后通知”的情况下,调用
_notify_thread_by_ocn(...)。
这说明移动推送不是旁路系统,而是建立在原始 recipients 编排结果之上的额外投递。
这个设计特别重要,因为它天然继承了 Odoo 原有的消息判断能力:
- 谁本来就该收到通知;
- 谁只是 follower;
- 哪些消息是延迟发送,不能立刻打到手机上。
也正因为它挂在统一通知流水线上,移动推送才能和桌面 bus、Discuss 线程、邮件通知并存,而不是互相打架。
三、真正的“路由”发生在 _notify_thread_by_ocn() 和 _notify_by_ocn_send()
_notify_thread_by_ocn() 先检查两个硬条件:
odoo_ocn.project_id已存在;mail_mobile.enable_ocn已启用。
两者缺一,直接退出。这是企业实现里很典型的“宁可不推,也不做半配置推送”。
真正把消息发出去的是 _notify_by_ocn_send()。这里有三个关键动作。
1. 先筛出真正有移动设备的接收者
方法会对 partner 再过滤一遍,只保留 ocn_token != False 的接收者。
所以某个用户收不到推送,不一定是通知流程没走到,也可能只是这个 partner 当前没有任何已注册设备。
2. 再把同一批接收者拆成两类 token
源码会调用 _at_mention_analyser() 扫描 HTML body,把 <a ... data-oe-model="res.partner" data-oe-id="...">@名字</a> 这样的 mention 链接解析出来。
随后接收者被拆成两组:
- 普通接收者:走默认 payload;
- 被
@mention的接收者:单独走一个android_channel_id = 'AtMention'的 payload。
这不是 UI 小细节,而是消息优先级设计。
对协同办公来说,@mention 的语义和“你关注的线程里有人说话了”完全不同。Odoo 不只是多发一条通知,而是显式把两类消息放进不同 Android channel,方便移动端做不同提醒策略。
3. 最后才调用 OCN 服务批量发送
每个 chunk 都通过 /iap/ocn/send 发出去。也就是说,Odoo 服务器本身并不直接跟 FCM 打交道,而是先把路由结果和 payload 交给 OCN。
这层抽象让数据库实例不用自己管理厂商推送细节,但代价是:如果 OCN 配置、实例注册或 partner token 任一环节出问题,你前面的消息逻辑都可能看起来“正常”,最后却没有推送落地。
四、Discuss 会话为什么要重写 subject、type 和 model
如果消息来自 discuss.channel,enterprise/mail_mobile/models/discuss_channel.py 会继续覆写 _notify_by_ocn_prepare_payload()。
它没有推翻父类,而是在父类生成基础 payload 后,按频道类型做细分:
chat:subject = author_nametype = 'chat'android_channel_id = 'DirectMessage'channel:subject = #频道名 - 作者名android_channel_id = 'ChannelMessage'- 其他:保留
#record_name风格
更关键的一点是,源码最后把 payload['model'] 强制写成了 mail.channel,并注明:
移动 App 仍使用旧的
mail.channel模型名,iOS 侧暂时不能改。
这句话透露了一个非常实际的边界:
- 服务端模型可能已经演进;
- 但为了移动端兼容,payload 仍要维持旧契约。
所以你看到的 subject、type、model 并不只是展示字段,而是客户端能否正确打开会话的协议字段。
五、为什么 _notify_by_ocn_prepare_payload() 要死盯 4KB
在 mail_thread.py 里,payload 默认至少会带:
android_channel_idauthor_namemodelres_iddb_idsubject
然后才尝试把消息正文塞进 body。塞之前源码先计算字节长度,并明确写了注释:FCM data payload 上限是 4000 bytes。
于是 Odoo 做了几件很务实的事:
- 把 HTML 正文转成纯文本;
- 再拼上 tracking message;
- 最后按剩余空间裁掉
body。
这意味着移动推送的目标从来不是“把整条消息原封不动搬到通知里”,而是:
在严格的体积限制下,优先保留能让用户识别上下文并点进去的最小信息集。
这也是为什么很多团队会误解“通知怎么被截断了”。它不是 bug,而是 FCM 载荷预算下的主动取舍。
六、深链不是直接给原链接,而是先包一层 Firebase Dynamic Link
另一个容易被忽略的点,是 _notify_get_action_link()。
它先调用父类拿到原始链接,然后在满足条件时把链接包装成:
https://redirect-url.email/?link=...&apn=com.odoo.mobile&afl=...&ibi=com.odoo.mobile&ifl=...
也就是 Firebase Dynamic Links 风格的跳转链接。
这么做的目的很明确:
- 手机上装了 Odoo App,尽量直接回到 App;
- 没装 App,至少还能回到普通浏览器页面。
但这里有两个安全/兼容边界同样值得注意:
1. 带敏感 token 的链接不会被包出去
源码里有一个 BLACK_LIST_PARAM,包括:
access_tokenauth_signup_token
如果 action link 里带这些参数,就直接返回原始链接,不再交给第三方跳转服务,以免敏感参数泄露到 Firebase 这类外部服务。
2. 管理员可以彻底关闭 dynamic link 重写
mail_mobile.disable_redirect_firebase_dynamic_link 为真时,也直接返回原始链接。
这很适合某些企业环境:
- 内网跳转要求严格;
- Firebase 跳转在当地网络环境不稳定;
- 安全团队希望所有链接都保持站点直链。
所以 deep link 在 Odoo 里不是“必须用 Firebase”,而是一个可关闭、带黑名单过滤的增强层。
七、最容易踩坑的三个误解
1. “只要启用了 Push,谁都能收到”
不对。至少还要同时满足:
- 实例已向 OCN 注册;
- partner 有
ocn_token; - 消息进入了额外通知接收者集合;
- 当前消息不是只做 scheduled queue。
2. “@mention 只是正文里多了个名字”
不对。源码把它当成一类单独路由的移动通知,甚至给了不同 Android channel。
3. “通知正文被截断说明推送坏了”
也不对。更常见的原因是 payload 故意在 4KB 预算里裁剪,只保留最必要的上下文。
八、实战排查顺序
如果企业客户反馈“Discuss 手机通知不稳定”,我会按这个顺序查:
- 看
mail_mobile.enable_ocn与odoo_ocn.project_id是否已生效; - 看目标 partner 上是否有
ocn_token; - 看消息是否真的进入
_notify_thread_by_ocn(); - 看正文里是否包含
@mention,从而触发不同 channel; - 看 payload 是否因为内容过长被裁掉了关键上下文;
- 看 deep link 是否因为黑名单参数或配置被退回原始浏览器链接。
这个顺序比“先问手机有没有开通知权限”更接近 Odoo 源码真实链路。
九、结论
Odoo 企业版 Mobile Push 的价值,不在于“帮你把消息弹到手机上”,而在于它把移动端通知做成了一条可控路由:
- 前面用设备注册与
ocn_token确认谁可达; - 中间用
mail.thread与discuss.channel细分消息语义; - 后面在 4KB payload 和 dynamic link 回退之间,尽量保证用户还能点回正确线程。
所以它从来不是一个简单开关,而是一套围绕投递目标、通知优先级、客户端兼容与安全回退组织起来的移动协同机制。
DISCUSSION
评论区