很多人第一次接触 Odoo 企业版 Sign,对它的理解都很自然:
- 发起签署请求;
- 系统发一封邮件;
- 对方点一个链接;
- 签完就结束。
如果只看界面,这个理解当然成立。
但你一旦认真读 /home/ubuntu/odoo-temp/enterprise/sign,就会发现官方真正要解决的远不止“生成一个可点击 URL”。它要同时处理的是:
- 实名一对一签署 与 共享链接签署 不是同一套安全模型;
- 邮件中的签署链接不能永久有效;
- 签署人如果被改派,旧链接不能继续用;
- shared 请求里有人拒签时,不能把整条共享入口直接毁掉;
- 最终的签署结果要能产出可回溯、难篡改的证据链。
所以 Odoo 企业版 Sign 的关键,不是“把 PDF 发出去”,而是“把签署访问、状态流转与审计证据绑成一条可控链路”。
一、先分清两种请求:sent 与 shared 根本不是同一类链接
sign.request 的 state 里,除了常见的:
sentsignedcanceledexpired
还专门有一个:
shared
这不是普通状态名,而是在告诉你:
sent
表示系统知道“这封请求是发给谁的”,每个 sign.request.item 都对应一个明确 signer。
shared
表示这是共享签署入口,不预先绑定具体签署人,或者至少不是按普通一对一邮件那样走。
这个差异在 controller 里体现得很明显。
在 controllers/main.py 的 /sign/document/mail/<request_id>/<token> 路由里:
- 系统先找到
sign.request - 再用 token 匹配当前
sign.request.item - 若请求已过有效期则 404
- 但真正的过期签名校验只对
state != 'shared'的请求执行
源码写得很直白:
if sign_request.state != 'shared' and not current_request_item._validate_expiry(...):
这说明官方从一开始就没把 shared 链接和实名 mail link 当成一回事。
实名请求靠“签署人项 + 邮件时效签名”双重收口;共享请求则走另一套更宽但也更受控的路径。
二、为什么邮件签署链接还要再带 timestamp + exp,而不是只靠 access token
很多系统做到 access_token 就收工了。
但 sign.request.item 里还有:
_generate_expiry_link_timestamp()_generate_expiry_signature()_validate_expiry()
生成邮件链接时,_get_sign_and_cancel_links() 会把 URL 参数拼成:
timestampexp
而 exp 不是随便的随机串,而是:
- 用
sign_expiration这个用途名; - 结合
timestamp与sign_request_item_id; - 通过 HMAC 算出来的签名值。
再配合 controller 校验逻辑,最终效果是:
1)链接不是永久有效
即使 token 没变,只要时间超过配置值(默认取 sign.link_expiry_duration,源码里默认 360 小时),链接也会失效。
2)链接不能随便改时间戳续命
因为 exp 要和 timestamp + item_id 一起重新算,用户自己改 URL 里的时间没有用。
3)链接不是只跟请求绑定,而是跟具体 signer item 绑定
即使同一个 sign.request 下有多个签署人,也不是一条通用链接打天下。
这比“有 token 就能签”更稳。
Odoo 在这里防的不是一般的未登录访问,而是“老邮件长期有效”“链接被转发后长期复用”“用户自行篡改 URL 延寿”这类签署链常见风险。
三、为什么旧邮件链接缺少时间参数时,官方宁可 404
sign_document_from_mail() 里还有一段很值得注意:
- 如果请求本来应该校验过期;
- 但 URL 里缺了
timestamp或exp; - 则不是展示“链接过期”页,而是直接
deleted_sign_request404。
源码注释写得很清楚:
The sign request should be evaluated but the timestamp has been removed from the parameter.
这个处理很讲究。
因为如果有人拿到一条原本需要时效校验的链接,然后手工删掉时间参数,系统如果还给出友好兜底,很容易让人继续试探别的绕过方式。
官方这里选择:
- 参数不完整,不按“普通过期”处理;
- 直接当成不合法入口。
这类设计在签署系统里是对的。
因为“过期”是合法但失效;“少参数”更像篡改后的非法访问尝试。
四、为什么改派签署人后必须轮换 access_token
sign.request.item.write() 里有一段非常关键的安全动作:
- 如果
partner_id被改成了另一个人; - 且这条签署项之前已经发过邮件;
- 在写入完成后,系统会重置新的
access_token; - 同时把
is_mail_sent重新置为False。
官方还在写入前做了一堆限制:
- 不是
sent状态不能改派; - 整个请求不是
sent状态不能改派; - role 不允许变更联系人时不能改派;
shared请求也不能按这条路径改派。
为什么 token 轮换这么重要?
因为如果只改收件人,不换 token,就意味着:
- 老收件人邮箱里的旧链接依然可用;
- 新收件人拿到的是同一条访问凭证;
- 谁先点进去,系统都难区分。
所以官方做法非常正确:
签署对象一旦换人,旧 token 立即作废,签署链路重新发放。
这不是体验细节,而是安全边界。
五、shared 请求为什么在拒签时要“复制一份再取消”,而不是直接改原请求
sign.request.item._refuse() 里最有意思的一段,是 _refuse_shared()。
对于普通 sent 请求,拒签流程比较直观:
- 写拒签日志;
- 当前请求项置为取消;
- 在原请求上发消息;
- 整个请求进入拒签处理。
但对 shared 请求,官方不是直接把原请求改坏,而是:
- 先把原请求
copy_data()一份; - 新副本仍置成
shared; - 在这份副本上把当前请求项记成取消;
- 再把拒签者身份绑定到这份副本;
- 原共享请求得以继续保留给其他潜在签署人使用。
这套逻辑初看有点绕,但其实特别合理。
因为 shared 链接的语义不是“某一个具体人必须签”,而更像:
- 一条公开但受控的签署入口;
- 允许不同具体人进入并决定签或拒。
如果某个用户拒签就把原共享请求直接取消,那剩余用户就再也没有入口了。
所以官方的做法本质上是在做:
共享入口保活,个体拒签留痕。
这是 shared 与 sent 两条链最本质的差别之一。
六、共享入口如何转成实名请求:不是直接把 shared 改成 sent,而是复制新请求
controller 里的 /sign/send_public/<request_id>/<token> 也说明了这一点。
当 shared 请求要识别出具体公共用户时,系统会:
- 根据 name / email 找或建 partner;
- 用原 shared 请求为蓝本复制一个新请求;
- 新请求改成
state='sent'; - 新建带具体 partner 的
request_item_ids; - 返回新请求 ID、新请求 token、新 signer access token。
这说明 shared 入口在官方设计里更像一个“模板化入口壳”,而不是最终受法律与审计约束的实名请求本体。
实名化发生时,官方倾向于:
- 复制出一条真正的 sent 请求
- 再在这个副本上走后续签署、邮件、拒签、证书链
这样 shared 请求本身就不会背太多状态负担。
七、签署日志为什么不是普通日志表,而是哈希串起来的证据链
sign.log 是整个模块里最值得重视的审计模型之一。
它不只是记录:
- create
- open
- save
- sign
- refuse
- cancel
- update
更关键的是 create() 里会生成 log_hash,而 _get_or_check_hash() 的逻辑是:
当 action = create
- 取模板原始 PDF 的二进制内容作为第一段基础体;
- 计算首个 hash。
当 action = sign
- 取同一请求上一条
create/sign的 hash 作为previous_hash; - 再把本次动作相关字段与当前 signer 可见的 item values 串起来;
- 继续做 sha256。
也就是一条很典型的链式结构:
- 第一笔锚定原始 PDF
- 后续每次签署都接在上一笔 hash 后面
这就是为什么 sign.log.write() 和删除都被禁止。
官方不是只想保留“操作记录”,而是想保留:
从原始文档到每一步签署动作都能顺序校验的不可篡改轨迹。
八、完成件为什么还要塞进 certificate 与 final_log_hash
sign.request._generate_completed_documents() 与 sign.completed.document._generate_completed_document() 连起来看,会发现官方在完成件阶段做了两件事:
- 生成签完的 PDF;
- 再额外生成一份 Certificate of completion。
其中 sign.completed.document 在渲染最终 PDF 时,会取:
final_log_hash = record.sign_request_id._get_final_signature_log_hash()
而 sign.request._get_final_signature_log_hash() 会从 sign.log 里取最后一笔 create/sign 的 log_hash。
如果启用了 certificate_reference,这个 final hash 就会参与最终完成件渲染。
这说明官方不是把日志和 PDF 各存各的,而是在尝试把“最终文件”与“审计链末端哈希”关联起来。
再加上 _handle_log_download() 能导出证书 PDF,整个设计就完整了:
- 有签完的业务文件;
- 有证书;
- 有链式日志;
- 最终完成件还能嵌入最终日志哈希。
这已经不是普通协同工具的“签完给你发个 PDF”,而是明显朝着证据化链路去的。
九、为什么 signed 请求不能删,只能归档
sign.request 上还有一条非常重要的约束:
if any(r.state == 'signed' for r in self):
raise UserError(... archive them instead.)
也就是:
- 已签完的请求不能删除;
- 只能归档。
这和上面的日志不可改、日志不可删、完成件可下载,放在一起看才完整。
因为官方显然把已签请求当成:
- 有法律效力或审计价值的对象;
- 生命周期可以退出日常界面;
- 但不应被普通业务动作彻底抹掉。
同理,shared 且过期的请求可以被 autovacuum 清理,是因为它们更像入口壳;而真正 signed 的请求则进入更强保护区。
十、最容易误解的 6 个点
误区 1:邮件里有 token 就够了
不够。
实名邮件链接还叠加了 timestamp + HMAC exp 的过期校验。
误区 2:shared 和 sent 只是两个展示状态
不是。
它们对应的是两套不同的身份绑定与拒签处理逻辑。
误区 3:换签署人只需要改 partner
不行。
源码明确会轮换 token,并要求重新发送访问入口。
误区 4:shared 请求里有人拒签,就说明整条共享请求该取消
也不对。
官方会复制副本留痕,尽量不破坏原共享入口。
误区 5:签署日志只是审计展示
远不止。
它本质上是把原始 PDF 与后续签署动作串成了哈希链。
误区 6:签署完成后删掉请求也没关系,反正 PDF 还在
不行。
源码明确禁止删除 signed 请求,说明“请求对象 + 日志 + 完成件”是一整套证据对象。
十一、二开时最值得抄的 5 条经验
如果你在 Odoo 里做任何“外部签署 / 外部确认 / 安全链接访问”能力,sign 模块最值得抄的是这五条:
- 访问 token 不等于长期有效凭证,邮件链接要再叠加时效签名。
- 共享入口与实名请求分层建模,不要混成一个状态机。
- 参与人一旦改派,旧入口必须立即失效。
- 拒签要区分“影响单个参与者”还是“销毁整个共享入口”。
- 审计日志不要只做展示,要能跟最终完成件形成证据链关联。
这五条每一条都能直接减少真实实施里的争议和风险。
结语
Odoo 企业版 Sign 真正做得深的地方,从来不是“能发链接签 PDF”,而是把:
- 访问入口
- 过期校验
- token 轮换
- 拒签分流
- 完成件证书
- 日志哈希链
串成了一条比较完整的签署安全与审计链。
所以这套源码真正解决的,不是“怎么把合同发出去签”,而是“怎么让签署入口、签署动作与签后证据在同一条可验证链路里闭环”。
理解了这一点,你以后再看门户签署、供应商确认、公共表单签字、外部客户拒签时,就不会只问“链接发没发出去”,而会先问:
- 这条链接多久失效?
- 改派后旧人还能不能点?
- 共享入口被拒签一次后还能不能继续?
- 最终 PDF 与日志证据怎么挂上?
这些,才是 sign 模块真正值钱的地方。
DISCUSSION
评论区