审计链

Odoo 安全序列为什么不是“过账后编号固定”就结束:integrity hash、chain gap 与 restricted move 审计链讲透

很多人以为 Odoo 的会计不可篡改只等于“posted 后别改编号”,但 `/home/ubuntu/odoo-temp/addons/account/models/account_move.py` 里的 `_get_integrity_hash_fields()`、`_get_chains_to_hash()` 和 `_hash_moves()` 表明,真正的重点是把同一序列链上的 move 串成可校验的 hash chain,并在 gap、未对账与 restricted 边界上做强约束。

会计
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多人提到 Odoo 会计“防篡改”时,脑子里会立刻浮现两个词:

  • posted 之后不能乱改
  • 编号不能断号

这些理解不算错,但还是太表层了。

如果你去看 /home/ubuntu/odoo-temp/addons/account/models/account_move.py 里这几个方法:

  • account.move._get_integrity_hash_fields()
  • account.move._is_move_restricted()
  • account.move._get_chain_info()
  • account.move._get_chains_to_hash()
  • account.move._hash_moves()
  • account.move._calculate_hashes()

你会发现 Odoo 真正要构造的不是“过账后编号固定”这么简单的一条规则,而是一条按序列前缀、按 journal、按前序 hash 串起来的审计链

Odoo 的不可篡改设计重点不在“某一张单不能改”,而在“同一链上的单据必须能证明自己前后相连、字段未被悄悄重写”。

这就是 integrity hash 的真正意义。


一、为什么光有 posted 还不够

posted 只表达一件事:

  • 这张分录已经从草稿变成正式会计事实。

但从审计角度看,仅仅“正式”还不够。

还需要回答:

  • 这张正式分录后来有没有被改过关键字段?
  • 如果改过,系统能不能看出来?
  • 它和前一张、后一张同序列分录之间有没有断裂?
  • 某些编号是不是被插空、跳号、补号?

也就是说,posted 解决的是“状态边界”,而 hash chain 解决的是“时间链可信度”。


二、先别急着算 hash,Odoo 先判断这张 move 是否属于受限链

_get_move_hash_domain()_is_move_restricted() 先定义了一个边界:

  • move 必须是 state = 'posted'
  • 默认还要 restrict_mode_hash_table = True
  • 或者在 force_hash=True 的上下文里强制纳入

这说明 Odoo 的 hash 机制不是对所有分录一刀切,而是先回答:

这张 move 是否属于需要进入不可篡改链的那一类正式凭证。

这一步非常关键,因为它把“会计可追溯性要求”从普通业务编辑逻辑中独立出来了。

不是所有草稿、不是所有临时状态,都必须立刻进 hash chain; 但一旦进入受限范围,它就开始承担更强的审计要求。


三、hash 的对象不是单张 move,而是同一 journal + sequence_prefix 的 chain

_get_chain_info() 里有一个特别核心的约束:

  • 同一条链要求属于同一个 journal_id
  • 同一个 sequence_prefix

这意味着 Odoo 不是把所有会计分录混在一起算一个全局 hash,而是按编号链组织审计单位。

这样做非常合理,因为实际审计上,最关心的是:

  • 这个 journal 的序列有没有断;
  • 这个前缀下的编号有没有被插改;
  • 某一段编号链是否保持连贯。

所以 hash 的真实语义不是“每张单自己有个签名”,而是:

每张单都带着前一张单的影子,从而形成一整段顺序不可随便重写的链。


四、为什么 Odoo 特别在意 last hashed move

_get_chain_info() 会去找:

  • 当前链上最后一张已哈希的 move
  • 以及它的 inalterable_hash

然后只对后续未哈希、且编号不超过当前最后目标 move 的那段记录做处理。

这背后的设计非常精细。

它要解决的是:

  • 已经 secure 过的历史链不能反复重算;
  • 但新加入链尾的 move 必须接在旧 hash 后面;
  • 如果 journal 是后来才开启 restricted mode,是否要把更早的未哈希 move 补进来,也是一个单独选择。

所以 hash 不是一次性全表扫描,而是增量续链

这和区块链思路很像,但实现上更贴近会计序列审计场景。


五、为什么 gap 会被当成严重问题

_get_chain_info() / _get_chains_to_hash() 会显式检查 gap:

  • 如果首尾 sequence number 与记录数量对不上
  • 就认为存在 gap

然后在 raise_if_gap=True 时直接报错。

这说明在 Odoo 看来,序列 gap 不是“编号有点难看”,而是:

审计链可能已被破坏,系统无法再证明这段链是连续且完整的。

也就是说,gap 的问题不只是 UI 排序问题,而是可信性问题。

你可以理解为:

  • 少了一张;
  • 或中间插过东西;
  • 或有已编号单据后来被删、作废、重排但没有正确修复链。

所以当系统说 gap 是错误,不是在吹毛求疵,而是在保护整段 hash chain 的可证明性。


六、为什么未对账的 bank statement line 也会阻止某些链上锁

_get_chain_info() 里还会检测 unreconciled

  • 只要待哈希的 moves 里关联到 account.bank.statement.line
  • is_reconciled = False
  • 就会把它视为 warning

_get_chains_to_hash() 在默认行为下会因此报错:

“All entries have to be reconciled.”

这点非常有代表性。

说明 Odoo 认为某些和银行流水直接挂钩的会计事实,如果尚未完成 reconciliation,就不应贸然固化为不可篡改链的一部分。

也就是说,系统保护的不只是分录字段本身,还保护它所处流程的完成度


七、真正参与 hash 的字段不是全部字段,而是精心挑过的字段

_get_integrity_hash_fields() 根据 hash version 返回字段:

  • v1: date, journal_id, company_id
  • v2 / v3 / v4: 再加上 name

_get_integrity_hash_fields_and_subfields() 还会把 line 上的 integrity fields 一起纳入。

这说明 Odoo 没打算把所有数据库列都塞进 hash,而是只选择那些足以表达会计身份与金额结构的关键字段。

这是一种非常实际的做法。

如果把所有字段都哈希进去:

  • 很多纯展示、纯辅助字段改动也会导致 hash 失效;
  • 审计价值不一定提升;
  • 维护成本却会显著上升。

所以 Odoo 选的是“足以证明关键会计事实”的字段集,而不是“数据库快照签名”。


八、_calculate_hashes() 真正做的是前序 hash + 当前记录序列化

_calculate_hashes() 会:

  1. 取上一条 hash,作为 previous_hash
  2. 收集当前 move 的关键字段
  3. 再收集每条 move line 的关键字段
  4. json.dumps(..., sort_keys=True, separators=(',', ':')) 序列化
  5. previous_hash + current_record 喂给 sha256

这一步最核心的含义是:

当前 hash 不是只由当前记录决定,而是同时依赖前一条链的 hash。

所以只要链上某一张历史分录被改过,后面整段 hash 都会失去一致性。

这正是“链式不可篡改”的来源。

不是靠权限提示,不是靠前端禁用按钮,而是靠后续每一条都承认前一条存在且未被改写


九、为什么 _hash_moves() 还会发消息、甚至可能激活安全组

_hash_moves() 在写入 inalterable_hash 后,还会:

  • 给这些 move 批量记 message:This journal entry has been secured.
  • 如果链中有 journal 本身未启用 hash on post,还可能激活 account_secured 相关组

这说明 Odoo 把“secure”视为一个明确事件,而不只是后台悄悄打标。

这对审计和实施都很有用:

  • 用户能看到这张分录已经进入 secure 状态;
  • 权限侧也能随之调整。

换句话说,hash chain 不只是算法,也是一种产品状态。


十、实施里最常见的误解

误解 1:只要 posted 就等于不可篡改

不是。posted 是状态,hash chain 是审计保障层。

误解 2:只要编号连续就够了

也不够。还要保证关键字段与前序链一致。

误解 3:gap 只是显示问题

不是。gap 会破坏链可证明性。

误解 4:hash 是按单张分录独立算的

不是。它依赖 previous hash,是链式结构。


十一、排查 secure / inalterable 问题时最有效的顺序

如果你遇到:

  • move 不能 secure;
  • 提示 gap;
  • 提示 unreconciled;
  • 开了 restricted mode 但还有未哈希条目;

建议优先按这个顺序查:

1)确认 move 是否属于 restricted 范围

先看 _is_move_restricted() 逻辑。

2)看 sequence_prefix 和 journal 是否形成预期中的链

链分组不对,后面全会乱。

3)查 last hashed move 之后是否存在 gap

这通常是最致命的问题。

4)查相关银行流水是否已 reconciled

未完成流程会阻止 secure。

5)最后再看 hash version 和字段集差异

版本升级或报告对比时才尤其重要。


结论

Odoo 会计不可篡改的核心,从来不只是“posted 后别改”或“编号别断号”。

它真正建的是一条由以下要素组成的审计链:

  • 只纳入受限范围内的正式 move;
  • 按 journal + sequence_prefix 分链;
  • 以上一条 hash 串接下一条;
  • 检查 gap 和未完成对账;
  • 最终把 move 标记为 secured。

所以 integrity hash 的意义,不是给单张分录盖个章,而是证明一整段会计序列在时间上连续、在结构上可校验、在审计上不易被悄悄改写。

这比“编号固定”要严格得多,也更接近真正的会计防篡改要求。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。