先说结论
Odoo 的 cloud_storage_google 模块并不是简单把附件文件从数据库或本地磁盘“换个存储位置”。
从 /home/ubuntu/odoo-temp/addons/cloud_storage_google/models/ir_attachment.py 与 res_config_settings.py 看,它真正做的是:
- 把附件对象仍然留在 Odoo,二进制内容改为云端对象引用;
- 平时保存的是稳定对象 URL,但读写时发给前端的是短时签名 URL;
- 启用配置时就现场验证上传、下载、以及 Bucket CORS 管理权限;
- 停用前必须先迁移已有附件,不能直接拔掉提供方。
所以这套设计的重点不是“上云”,而是把 Odoo 附件读写改造成受控的临时授权访问。
不是所有附件逻辑都被替换,Odoo 仍然掌握对象元数据
很多人第一次看云附件,会以为:
- 文件既然已经在 GCS,那 Odoo 只剩一个外链。
其实没这么粗糙。
ir.attachment 仍然是附件的业务主对象:
- 它知道附件属于哪个模型、哪条记录;
- 知道附件类型是不是
cloud_storage; - 知道应该如何生成 blob 名称;
- 知道什么时候给前端下载地址,什么时候给上传地址。
也就是说,云端只托管字节流,不接管 Odoo 的业务语义。
这是很合理的边界:
- 存储容量、带宽、跨区域访问交给 GCS;
- 业务归属、权限流程、界面交互仍由 Odoo 控制。
为什么平时保存的是对象 URL,真正访问时却换成签名 URL?
源码里 _generate_cloud_storage_url() 会返回一个稳定格式:
https://storage.googleapis.com/<bucket>/<blob>
这说明附件本身保存的是一个可识别的对象位置。
但到了真正下载时,_generate_cloud_storage_download_info() 又不会直接把这个稳定 URL 原样交给前端,而是重新生成:
- method =
GET - 带过期时间的签名 URL
上传时 _generate_cloud_storage_upload_info() 也是同理:
- method =
PUT - 返回短时签名 URL
- 告诉前端成功状态预期是 200
这套设计的好处是什么?
因为稳定对象 URL 本身不是授权凭证。
如果 Odoo 直接把公开可访问链接塞给浏览器,你会立刻碰到几个问题:
- Bucket 很可能必须公开读,这通常不符合企业要求;
- 上传权限无法细粒度下发;
- 一旦链接长期有效,转发出去后几乎无法回收;
- Odoo 自己也很难表达“这次下载仅允许在短时间内完成”。
而短时签名 URL 刚好解决这个问题:
- 对象位置长期稳定;
- 访问授权临时发放;
- GET 与 PUT 权限分开控制;
- 不必把 Bucket 公开到互联网。
这不是“云存储技巧”,而是一个非常经典的应用层授权代理设计。
凭证为什么要缓存?因为服务账号对象创建本身就不便宜
get_cloud_storage_google_credential() 里有一个很容易被忽略的细节:
- 它按数据库名缓存
(account_info, credential); - 因为
from_service_account_info比较慢。
这说明模块作者不是把 GCS 当成偶发后台能力,而是当成高频附件读写基础设施。
如果每次生成签名 URL 都重新解析 service account JSON,再创建凭证对象,附件列表、预览、上传这些高频场景会很快把延迟堆起来。
所以源码这里是在做一个现实取舍:
- 凭证的权威配置仍然来自
ir.config_parameter; - 但运行期对象会缓存,提升吞吐。
这也提醒我们:配置变更不是零成本动作。尤其更换 service account 或 bucket 时,要考虑缓存更新与历史对象访问问题。
启用提供方时,Odoo 为什么要“当场试写、试读、试改 CORS”?
_setup_cloud_storage_provider() 是这个模块最值得学习的地方。
它没有停留在“参数保存成功”这个层面,而是直接做三类验证:
- 生成 PUT 签名 URL,实际上传一个空 blob,检查是否 200;
- 生成 GET 签名 URL,实际下载刚刚那个 blob,检查是否 200;
- 拿 full_control scope 调 Google Storage API,给 bucket 设置 CORS。
这一步非常重要
因为云存储最常见的坑,不是“字段填错”,而是:
- 服务账号有读权没写权;
- 有写权没读权;
- 对对象可以操作,但无权 patch bucket 元数据;
- 控制台看起来都配好了,浏览器预览却还是跨域失败。
Odoo 这里的思路很务实:
不要把错误留到用户上传第一份正式合同的时候才暴露。
而是应该在管理员点“启用”时就把权限链跑一遍。
这就是成熟基础设施模块和“能跑就行”脚本的差别。
为什么 CORS 在这里不是附属问题,而是核心配置的一部分?
源码注释直接写了:
- 需要配置 CORS 才能支持
.pdf预览和直接上传。
很多人理解附件上云时,只盯着后端权限,却忽略了浏览器是最终消费者之一。
如果没有合适的 CORS:
- 前端直传 PUT 会被浏览器拦掉;
- 浏览器预览 PDF 时,哪怕签名 URL 本身有效,也可能被跨域策略阻断;
- 用户看到的表象通常只是“附件打不开”或“上传失败”。
所以 Odoo 在 bucket 上补的是:
origin: ['*']method: ['GET', 'PUT']responseHeader: ['Content-Type']maxAgeSeconds与下载 URL 过期时长相呼应
这其实是在告诉你:
云附件不是纯后端能力,它天然横跨后端签名、对象权限、以及浏览器跨域模型。
为什么对象 URL 既支持 storage.googleapis.com,又能解析 storage.googleapis.com 模式?
_get_cloud_storage_google_info() 会用正则校验 URL 是否符合 Google Storage 地址模式,并从里边拆出:
- bucket_name
- blob_name
这意味着 Odoo 不是把 URL 当一段不可理解的字符串,而是把它视为可逆解析的资源定位符。
这样做有两个好处:
- 下载/上传签名时可以从已有 URL 反推对象信息;
- 停用、迁移、诊断时系统能知道这是不是 Google 附件。
这也是为什么 _check_cloud_storage_uninstallable() 会查找:
type = 'cloud_storage'url like 'https://storage.googleapis.com/%'
只要库里还有这类附件,就不允许直接停掉 Google provider。
为什么“停用前先迁移”是硬性限制?
这个校验我很赞同。
因为如果系统里还残留大量 Google Cloud Storage 附件,而你直接把 provider 切回本地或别的存储:
- 老附件 URL 仍然指向 GCS;
- 新附件生成规则却变了;
- 下载、预览、权限行为可能前后不一致;
- 最坏情况是用户以为附件还在,实际上新旧链路已经断裂。
所以源码明确抛错:
Some Google attachments are in use, please migrate cloud storages before disable the provider
这背后的思想是:存储提供方切换不是参数切换,而是数据迁移事件。
配置 bucket 时最容易误判的三类问题
1. 以为“能列 bucket”就等于“能给 Odoo 用”
不对。
Odoo 真正需要的是:
- 生成签名 URL 的服务账号能力;
- 对目标对象的 PUT/GET 能力;
- 对 bucket CORS 的 patch 能力。
会列目录,不代表能完成整条附件链路。
2. 以为长期对象 URL 就足够安全
也不对。
源码实际上把长期 URL 当成对象定位,不当成访问授权。真正授权发生在每次生成签名 URL 的那一刻。
3. 以为前端失败一定是后端 bug
很多时候不是。
如果签名 URL 正确,但 bucket CORS 没配好,浏览器仍然可能把请求挡掉。对业务用户来说,这看起来像 Odoo 附件坏了;对开发者来说,问题可能根本不在 Python 代码,而在对象存储的跨域策略。
这套设计适合什么场景?
我觉得特别适合这几类组织:
- 附件量大,数据库不想继续膨胀;
- 有大量 PDF、图片、合同、导出文件,需要对象存储承压;
- 希望浏览器能直传/直读,减轻 Odoo 应用层带宽;
- 但又不想把 bucket 做成公开资源库。
换句话说,这不是“便宜存文件”的方案,而是让 Odoo 附件访问模式更接近现代对象存储架构。
最后一句
看懂 cloud_storage_google 之后,你会发现它最重要的不是“支持 GCS”,而是它用一种很克制的方式处理了三层边界:
- Odoo 管业务元数据;
- GCS 管对象字节;
- 签名 URL + CORS 管临时访问。
把这三层分清楚,附件上云就会非常稳;分不清,项目就会在“明明已经存上去了,为什么还是打不开”这种问题里反复打转。
DISCUSSION
评论区