先说结论
Google Calendar 同步在 Odoo 里,真正难的不是 OAuth 授权页,而是如何避免双写、漏写、重复创建和错误改写别人事件。
从 /home/ubuntu/odoo-temp/addons/google_calendar/models/google_sync.py、calendar.py、calendar_recurrence_rule.py 与 res_users.py 来看,这个模块最关键的不是“能不能调到 Google API”,而是四条边界:
- 只有数据库事务真正提交后,才允许把变更推到 Google。
- 增量同步依赖 sync token,失效时必须退回 full sync。
- 循环事件不是简单复制,而是有独立的 recurrence 建模与 ID 生成规则。
- 不是 organizer 的用户,不能随便改受限事件,否则会制造重复或 403。
为什么 Odoo 坚持 post-commit 同步
google_sync.py 最值得先看的不是 API 调用,而是 after_commit 装饰器。
源码注释写得很明确:
- Google API 请求要在当前事务结束后再发;
- 这样只有 Odoo 数据库里的变更真的落盘,才会推送给 Google;
- 尤其是事件创建,否则 Odoo 事务崩了但 Google 已经建了事件,就会出现重复。
这条规则非常重要。
很多系统接第三方日历时,会在保存事件的同一个事务里同步远端,看上去“实时”,但一旦本地事务后面失败,远端已经写成功,就会产生经典脏数据。
Odoo 在这里的选择很成熟:
宁可同步稍晚一步,也不要让本地失败、远端成功。
所以 Calendar 同步首先是一套事务边界设计,不是一个 OAuth 小功能。
need_sync + google_id 说明同步不是一次动作,而是持续对象关系
抽象模型 google.calendar.sync 上有两个关键字段:
google_idneed_sync
这两个字段放在一起,传递了一个非常清楚的思想:
google_id表示这个 Odoo 对象已经和 Google 某个对象建立映射;need_sync表示它当前还有待推送的本地变更。
于是同一个事件可能经历:
- 本地新建,待插入 Google;
- 本地更新,待 patch 到 Google;
- 本地归档,待删除 Google;
- Google 改动后,再反向写回 Odoo。
换句话说,Odoo 不是在“调用一次同步接口”,而是在维护一份长期存在的双边映射状态。
sync token 不是优化项,而是增量同步的锚点
res_users.py 的 _sync_request() 里,是否 full sync 取决于:
full_sync = not bool(self.sudo().google_calendar_sync_token)
也就是说,google_calendar_sync_token 不是可有可无的缓存,而是:
- 有 token:按增量拉;
- 没 token:全量重拉;
- token 失效:捕获
InvalidSyncToken后重新 full sync。
这就解释了为什么有些团队会发现:
- 平时同步很快;
- 某次异常后突然大量重扫;
- 看起来像“Google 又抽风了”。
其实不是。 很多时候是增量锚点失效后,系统主动回退到全量校正。
这反而是稳健设计,因为继续相信一枚已经失效的 sync token,才真的危险。
循环事件为什么总是最容易把同步搞乱
在 Odoo 里,循环事件不是“主事件 + N 条普通子事件”那么简单。
calendar.recurrence 被单独继承了 google.calendar.sync,说明 recurrence 本身就是一个要同步的对象。
更关键的是 _get_event_google_id() 的规则:
- recurrence 自身有一个
google_id; - 每个实例事件的 Google id 由
recurrence_id + 起始时间组合生成; - 如果是全天事件,用日期;
- 如果是非全天事件,用 UTC 紧凑时间戳。
这意味着:
循环实例的身份,不是任意可改的本地主键,而是与 recurrence 骨架和起始时间绑定。
所以一旦你改的是基准时间、RRULE、follow_recurrence 之类的骨架字段,Odoo 很可能不是“原地微调”,而是要重建整组实例。
为什么 recurrence 修改时会出现“先删后建”的感觉
calendar_recurrence_rule.py 里有几段非常关键:
- 如果同步事件后来变成 recurrence,要把原 Google 单事件删掉;
- 某些情况下需要保留一个 inactive copy,带着旧
google_id,这样下一轮同步才能正确删远端对象; - 如果基准事件时间变了,旧实例会被归档或重算,再重新应用 recurrence。
这解释了实战里一个非常常见的困惑:
“我只是改了循环规则,为什么看起来像整串事件被删了又重建?”
因为在 Google 与 Odoo 双方都要保持 ID 可解释的前提下,某些变更本来就不适合原地 patch。
guest readonly 为什么不能硬改
calendar.py 里 _check_modify_event_permission() 明确限制:
- 如果事件
guests_readonly = True; - 且当前用户不是 organizer;
- 又确实修改了需要同步到 Google 的字段;
- 就直接抛
ValidationError。
而 calendar_attendee.py 里又专门处理了 attendee 状态同步:
- 不是 organizer 的事件,需要切换到 owner 用户上下文去同步;
- 因为 Google 对非 organizer 的 patch 限制很严格;
- 注释甚至直说,哪怕只传
start、end等必须字段,也可能收到Forbidden for non-organizer。
这类限制非常真实。 Google Calendar 不是协作文档,不是每个参与者都能安全改全部字段。
所以 Odoo 在这里的目标不是“谁都能编辑”,而是尽量在不制造重复事件与 403 的前提下保留最少可改能力。
all-day recurrence 为何还有专门的重复创建补丁
_handle_allday_recurrences_edge_case() 专门处理一个很细的坑:
- 新建“全天循环事件”时;
- 首条事件可能先被当成单事件同步出去;
- 之后 recurrence 又再生成一份;
- 结果重复。
因此源码会在这个 edge case 下主动把部分记录 need_sync = False,阻断第一次错误同步。
这类代码特别说明一点:
Calendar 同步的稳定,不靠理想化模型,而靠承认边界条件真的会咬人。
Google 更新回流 Odoo 时,为什么要比较 write_date
_sync_google2odoo() 里采用的是“last updated wins”,但不是盲信 Google。
它会:
- 先保存 Odoo 本地记录当前的
write_date; - 再比较 Google event 的
updated时间; - 只有 Google 较新时才反向写入。
这一步是在避免两边同时改时,旧数据把新数据覆盖掉。
当然它也不是绝对完美,源码自己都提醒: 如果 Google 服务器时间与 Odoo 服务器时间差得离谱,仍然可能有争议。 但在大多数正常环境里,这已经是很务实的冲突策略了。
实施时最该记住的 5 条经验
1. 别把 OAuth 当成同步稳定性的主体
OAuth 只是拿到票,稳定性靠的是 post-commit、sync token 和对象映射。
2. 看到大量重拉,不一定是 bug
很可能是 sync token 失效后的 full sync 回退。
3. 循环事件不要想当然地“只改一条字段”
一旦碰到基准时间或规则重写,系统可能必须重建整串事件。
4. 非 organizer 修改失败,是权限边界,不是前端小问题
Google 本来就限制这类操作,Odoo 只是把这条边界显式化。
5. 重复事件很多时候是事务或 recurrence 身份建模问题
不要只盯着 API 返回值,要看本地 need_sync、google_id、recurrence 是否被重新建模。
最后一句
Google Calendar 同步最容易被低估的地方,在于它看起来像一个“连接器”,实际上却是一套跨事务、跨身份、跨对象生命周期的协同协议。
把它当成“授权成功就万事大吉”,一定会踩坑; 把它当成“对象映射 + 增量锚点 + recurrence 重写 + organizer 权限边界”的组合,很多复杂现象才会 suddenly make sense。
DISCUSSION
评论区