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 的探针脚本可照抄(未入库,按越界规则删除)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingupstream: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