Skip to content

批量写(multi: true)路径上 ctx.previous 恒为空 —— 15 个读 previous 的 hook 在该路径全部空转,知识文章可被批量写成「已发布但从未复核」 #779

Description

@yinlianghui

发现于 #769(PR #776)的实测过程。#769 是 view label 与 filter 一致性问题,这一条是跨对象的 hook 语义缺陷,越界,单独记录。

事实(pinned 17.0.0-rc.2,真 object schema + 真 hook + sys_fetch_previous_update 内建的忠实复刻)

引擎内建 sys_fetch_previous_update(priority: 5,beforeUpdate,object: '*',由 kernel 服务的 registerAuditHooks() 装载)是 ctx.previousbeforeUpdate 上的唯一来源 —— 引擎主体只在 afterUpdate 分支里赋 hookContext.previous。而这个内建的第一行是:

if (hookCtx.input?.id && !hookCtx.previous) { … }

multi: true 的批量更新没有 input.id(目标由 where 圈定),所以该内建取不到任何东西,ctx.previous 保持 undefined。用一个 priority 299 的探针 hook 观测(它排在 knowledge_article_publish_timestamps 的 300 之前):

=== single-row update (category only) ===
  [prev-fetch] input.id="crm_knowledge_article-…-1" previous=undefined
  [knowledge-hook sees] previous.status="published" input={"id":"…","category":"api"}
  => last_reviewed_at refreshed: true

=== MULTI update (category only, every published row) ===
  [prev-fetch] input.id=undefined previous=undefined
  [knowledge-hook sees] previous.status=undefined input={"category":"troubleshooting"}
  => B.category=troubleshooting  last_reviewed_at refreshed: false

=== MULTI update writing last_reviewed_at: null ===
  [prev-fetch] input.id=undefined previous=undefined
  [knowledge-hook sees] previous.status=undefined input={"last_reviewed_at":null}
  => A: last_reviewed_at=null
  => B: last_reviewed_at=null

单行路径一切正常;批量路径上 hook 拿到的 previous 是空的。

影响面

src/objects/*.hook.ts 里有 15 个文件ctx.previous。它们几乎都用 previous?.x 的可选链写法,所以在批量路径上不会抛错 —— 而是静默地什么都不做,或者走进「当作插入/当作首次转换」的分支。已确认的两种具体后果(均在 crm_knowledge_article 上实测):

  1. 批量编辑不会把文章标记为已复核。 knowledge_article_publish_timestamps 的注释写着「On any subsequent edit while published, refreshes last_reviewed_at so admin "stale article" reports work」—— 在批量路径上这句不成立。
  2. 批量写能把已发布文章的 last_reviewed_at 写成 null,readonly: true 不拦(单行路径上 hook 会补戳盖掉,批量路径上不会)。于是数据里出现「已发布 + 从未复核」的行 —— 而批量导入正是一家公司把既有知识库搬进 CRM 的常规方式,这批行恰恰是最需要复核的。

第 2 点是 #769 最终没有给 stale_articles 加 180 天窗口的直接依据(见 PR #776 的实测第 2 条):$lt 不命中空值,而 view filter 语法写不出「早于窗口为空」的析取,加窗会把这批导入行一条不剩地藏掉。

其余 14 个 hook 尚未逐个核对,但凡是靠 previous 判断状态迁移的(campaign.hookprevious?.status === 'completed'contract.hookprevious?.status === 'activated' 守卫、case.hookbecameClosedopportunity 的赢单/丢单捕获等)都在同一条船上:批量路径上迁移判定退化为「没有迁移」或「首次迁移」。contract.hook 那条尤其值得先看 —— 它是守卫语义(if (event === 'beforeUpdate' && previous?.status === 'activated')),守卫在批量路径上失效的方向是放行。

归属

引擎侧的行为(input.id 缺失时不取 previous)大概率是平台的设计取舍而非 bug —— 批量路径要为 N 行取 N 份 previous,代价与语义都需要平台定夺;引擎自己在 needsPriorRecord(updateSchema) 为真时确实会取 priorRows(用于校验规则),只是没有把它喂给 hook。所以这一条可能需要 upstream 配合,本仓能先做的是:

  • 逐个核对 15 个 hook 在 previous === undefined 下的行为,把「静默不做」改成显式、可预期的语义;
  • 或者在本仓约定「批量写不经过业务 hook 语义」,并把依赖 previous 的不变量挪到别处(校验规则 / flow)。

两条路都是行为决策,需要先定夺再动手。

复现

在 worktree 里跑(需要 @objectstack/objectql + driver-memory):绑定一个 sys_fetch_previous_update 的复刻(priority 5 / beforeUpdate / object *)与真实 knowledge_article.hook,插入一条 status: 'published' 的文章,然后
api.object('crm_knowledge_article').update({ last_reviewed_at: null }, { where: { status: 'published' }, multi: true }),读回即为 null。PR #776 的探针脚本可照抄(未入库,按越界规则删除)。

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpm:blockedupstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions