Files
workspace/code/fms/FMS通用保存接口条件更新设计.md
T
2026-09-28 17:13:37 +08:00

55 KiB
Raw Blame History

FMS 通用保存接口(saveobjt)条件更新设计

状态:设计稿(已补齐首期实现约束) 涉及:fms-api 通用保存链路、fms-vue 审批类动作、workflow 草稿插件式接入评估


0. 一句话

给通用保存加「带条件的写入」——把「读 → 判断 → 写」的原子性交回给数据库, 让写入本身能表达「这一行必须还是我以为的样子,否则不写」; 再按表的业务粒度选择普通局部更新或字段冲突检测, 不把整行版本锁强加给所有表。


1. 背景

1.1 现状

/data/saveobjt 的定位方式只有主键:

// DbUtils.update —— fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java
String whereSql = keyColumns.stream()
        .map(column -> quoteColumn(column) + " = ?")
        .collect(Collectors.joining(" AND "));
String sql = "UPDATE " + table.quotedName()
        + " SET " + setSql
        + " WHERE " + whereSql;

也就是说,它是盲写:只要主键存在,写什么都行。

由此带来三个问题:

问题 表现
并发脏写 两人同时修改同一字段或提交整单快照,后一个覆盖前一个
重复提交 / 网络重试 请求发出、响应丢失,用户再点一次 → 写两遍
前置条件无处安放 「只有送审的才能通过」只能由调用方自己判断,判断与写入之间有窗口

这三个问题不是一回事,guards 只直接解决第三个;并发覆盖和重复执行要由不同层级的机制处理(见 §1.3)。

1.2 旧系统的教训(不适用新系统的审批)

g3hd 平台已有版本锁能力(/saveobject_Lock → BatchTable_Lock, SET ..., b_version = b_version + 1 WHERE pk = ? AND b_version = ?),但:

  • saveobjtlockApi 在整个前端没有任何一处调用
  • 审批中心 shcenter/index.js 走的是无锁的 saveobjtApi

所以旧系统的问题不是「加了锁但不够」,而是**「锁有却没用,审批是纯盲写」**。

新系统不是这样——审批动作已经不经过 saveobjt:

层 位置 现状
前端 ApprovalDetailDrawer.vue 调 actWorkflowTaskApi 走 /api/workflow/task/act,不是 saveobjtApi
后端 WorkflowRuntimeService UPDLOCK, ROWLOCK 锁行 + b_row_version 递增 + b_idempotency_key / b_request_id 幂等,全部副作用在同一事务;业务状态由 projectBusinessStatus 回写业务表

即「审批盲写」这个动机在新系统已经不成立。guards 的真实战场是另一处:

业务明细的增删改 vs 审批投影出来的业务状态。

审批由工作流服务改主表状态(它持有那一行的写锁),明细由 saveobjt 写—— 两边争的是同一行,但明细这一侧目前没有任何前置条件。§3.2 / §3.3 的 demo 就是这个场景。

1.3 三个问题与分层机制

问题 机制 本设计
并发脏写(后写覆盖先写) 局部更新、字段原值比较、整行版本锁三选一 按表 / 动作选择,不全局强制整行 version
重复提交 / 网络重试 幂等键:请求带 request_id,服务端查重 不做,建议配套
前置条件无处安放 guards(本文档) ✅

guards 解决不了前两条——同一个请求原样重放两次,guard 两次都可能成立。 这两条在项目里都有现成先例可抄:wf_instance / wf_node_run 已经在用 b_row_version + UPDLOCK,wf_action.b_request_id 已经在做动作去重 (WorkflowRuntimeService 类注释 §12.1 / §12.2 / §12.3)。

建议后续按同一思路补进 saveobjt。这些机制是互补关系,不是替代: 局部更新减少无关字段冲突,字段原值比较防同字段覆盖, 整行版本锁用于确实需要整单一致的操作,幂等键防重复执行,guards 防非法状态转移。

1.4 推荐的并发分层

层级 适用对象 默认机制 是否允许并行修改不同字段
普通表 / 普通字段 字典、配置、互不关联的资料字段 只更新请求中提交的字段,不启用整行 version 允许;不同字段互不覆盖
关键字段 金额、数量、名称等不允许被别人静默覆盖的字段 行内 $original 原值比较,比较提交的字段而不是整行 允许;同一字段被改过则失败
单据的跨表条件 表头状态 + 明细增删改 guards 子查询把「状态判断」和「明细写入」压进同一条语句 允许;状态已变则失败
整单操作 状态迁移、整单导入、不可拆分的批处理 b_row_version 整行版本锁(同样写在 $original 里) 不允许,冲突即失败

该分层解决了整行 version 的误冲突:甲改金额、乙改备注时,普通局部更新可以同时成功; 甲乙同时改金额时,携带该字段 $original 的请求只有一个成功。 单据的「审批 vs 明细」靠 guard 的子查询与写入同语句完成,不引入额外的锁策略(§11)。


2. 目标与非目标

2.1 目标

  1. 写入可以携带前置条件,条件不成立即失败并整体回滚
  2. 通用 SQL 层不理解业务语义;条件由调用方以 guards 声明,服务端只负责拼装与回查
  3. 失败能给出准确、可直接展示给用户的提示
  4. 普通表向后兼容:不传条件的调用方行为完全不变
  5. 普通字段允许无关字段并行修改,同一字段可按需做原值比较

2.2 非目标

  • 不做 SQL 注入防护。内部系统、全信任环境,条件允许是裸 SQL 片段(已确认)
  • 不靠裸 guards 保证跨行 / 聚合条件的并发安全(见 §7、§8);现算的判据要先物化
  • 不提供单据级串行保证——guards 保证「写入那一刻条件成立」,不保证整个事务期间状态不变(§11)
  • 不替代流程引擎,不接管流转计算
  • 不把整行乐观锁作为全局默认——仅对整单操作或明确要求整单一致的动作启用
  • 本次不实现请求幂等(§1.3)——按 workflow 现有 request_id 方案后续补入

2.3 本次实现的硬约束

以下不是"后续优化",而是实现前必须确定的行为:

  1. 目标不存在不属于 guard 失败:更新 / 删除命中 0 行时,先区分目标行不存在与 guard 不成立;目标不存在沿用普通保存失败(2001),只有目标存在但 guard 不成立才返回 2002。
  2. 带 guards 的删除只允许唯一定位:本次 guards.deletes 的每行必须只包含完整 key_field(包括复合键),命中 0 或 1 行;按非唯一条件批量删除的行,以及 delete_target / 依赖删除旁路,不能携带 guards.deletes。
  3. insert 的请求值一律参数化:guard 占位符替换为 ?,值加入 PreparedStatement 参数,不拼 SQL 字面量。guard 自身仍是调用方提供的 SQL 条件片段。
  4. 隔离级别和并发用例是上线门槛:确认数据库实际隔离级别,并完成 §10 的冲突、非冲突、锁等待和死锁用例后,才能启用跨表 guards。
  5. guards 只对本次实现的唯一定位路径生效:delete_target / 依赖删除这类旁路拿不到 guards,携带即拒绝(§4.1),绝不能让它静默失效。
  6. 整行 version 不作为普通编辑默认机制:普通更新只提交变化字段;需要检测同字段覆盖时,用 $original 声明字段原值,只有整单动作才在 $original 里声明 b_row_version(§3.1)。

3. 请求格式

3.1 表块新增字段

在现有表块(table / key_field / inserts / updates / deletes)之上新增:

字段 类型 说明
guards 对象 前置条件,按动作分开:inserts / updates / deletes 各一份
guards.<动作>[].condition string SQL 片段。{列名} 是受控占位符;insert 从当前请求行取值并绑定为 ?,update / delete 解析为目标表别名下的列引用
guards.<动作>[].refs string[] 声明的引用表(见 §3.4)
guards.<动作>[].message string 条件不成立时给用户的话。由写条件的人负责描述
guards.<动作>[].retryable bool 默认 true。为 false 时文案不追加「请刷新后重试」(§5.2)

updates 的行可以带可选的 $original 对象,声明「我这行是基于哪些旧值改的」,用于冲突检测:

{
  "b_id": "D001",
  "b_amount": 220.00,
  "$original": { "b_amount": 200.00 }
}

服务端将其生成:

UPDATE t
SET [b_amount] = ?
FROM dbo.demo_order AS t
WHERE t.[b_id] = ?
  AND t.[b_amount] = ?;

规则:

  • $original 只比较调用方明确声明的字段;不传 $original 表示该字段接受普通局部更新, 不因为同一行其他字段变化而失败。
  • $original 不是 guard,失败统一返回并发冲突码 2003(§5.1),不使用 guard 的业务文案。
  • $original 是保留元字段,不会进入 SET;原值为 null 时生成 IS NULL,不能使用 = NULL。
  • $ 前缀 = 保留元字段。本期只有两个:$original(WHERE 侧的原值)与 $expr(SET 侧的表达式,见下)。 业务列一律 b_ 前缀,不可能与元字段撞名。
  • 为什么叫 $original 而不是 $expected:这里放的是改动前的值。 「期望值」在中文语境里会被读成「希望变成的值」——而那恰恰是 b_amount: 220.00 本身,方向正好相反。 元字段平时没人细看,等排查并发问题时被名字带偏,代价很高。

整行版本锁也写在 $original 里。整单策略若启用版本锁,同样是往 $original 放,只是键是版本列:

{
  "b_id": "D001",
  "b_status": "审核通过",
  "$original": { "b_row_version": 7 }
}

服务端将其转换为 WHERE t.[b_row_version] = ?,参数为 7,成功后执行 SET [b_row_version] = t.[b_row_version] + 1。两种写法共用同一个元字段,区别在于键:

$original 的内容 语义 冲突粒度
业务字段 { "b_amount": 200.00 } 字段原值比较 字段级,不同字段可并行修改
版本列 { "b_row_version": 7 } 整行版本锁 整行,任一场变更都冲突

两者可以写在同一个 $original 里(WHERE 条件 AND 在一起)。版本列由调用方在整单动作中显式带上—— 后端不维护「哪些表是受保护表」的策略表,所以也不存在绕过策略的问题; 普通编辑不要加它,否则会把无关字段的修改也判成冲突(§7.3)。

为什么按动作分开:三种动作的前置条件通常不同——

动作 典型前置条件
inserts 主表未审核,才能新增明细
updates 只有草拟状态才能改;已核销的不能改
deletes 主表未审核、且该明细未核销,才能删

共用一个 guards 会让条件串味。而且三种动作的失败文案本来就不一样 ("不能新增费用" / "不能修改费用" / "不能删除费用")—— 所以即便是同一条条件,分开写也是对的。

同一动作内多行怎么办:guards 定义的是条件模板,{列名} 让它按行实例化。 批量新增时每行的条件可以不同({b_order_id} 取各自的值)—— 所以不需要把 guards 下沉到行级。

占位符只识别完整的 {合法列名} token,不在 SQL 字符串字面量、注释或其他标识符内部做文本替换。 update / delete 生成 SQL 时固定使用目标表别名 t,例如 {b_order_id} 解析为 t.[b_order_id]; insert 解析为 ? 并按出现顺序加入参数。insert 行缺少被引用列时直接返回参数错误,不能静默当成空值。 占位符值为 null 时绑定 SQL NULL,条件需要匹配空值的调用方必须显式写 is null / is not null。

updates 的行内值新增表达式支持(用于账本字段,见 §8.5)。 表达式必须用显式包装对象,不能裸写字符串——否则服务端无法区分 「这是一个表达式」和「用户真的输入了 b_fee_count + 1 这段文字」:

{ "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } }

裸字符串一律按普通值参数化处理(现有行为不变)。表达式内容限定为:一个本表可写列, 可选的 + / -,以及一个带符号的整数或小数常量;不允许函数、子查询、其他表列、多个运算符或赋值。 服务端先解析成结构化表达式,再生成 SET [列] = t.[列] + ? 形式的 SQL,并校验列存在、不可写列和数值类型。

客户端不能通过请求指定锁提示(UPDLOCK / HOLDLOCK)或加锁顺序。锁完全由数据库按语句本身获取, 请求里没有任何锁相关参数。

3.2 示例:只有新增

[
  {
    "table": "demo_order_fee",
    "key_field": "b_id",
    "guards": {
      "inserts": [
        {
          "condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
          "refs": ["demo_order"],
          "message": "该单据已审核通过,不能新增费用"
        }
      ]
    },
    "inserts": [
      { "b_id": "F010", "b_order_id": "D001", "b_fee_name": "停车费", "b_amount": 30.00 }
    ]
  }
]

生成的 SQL:

INSERT INTO demo_order_fee (b_id, b_order_id, b_fee_name, b_amount)
SELECT ?, ?, ?, ?
 WHERE NOT EXISTS (SELECT 1 FROM dbo.demo_order p
                    WHERE p.b_id = ? AND p.b_status = N'审核通过');

参数按出现顺序绑定为 F010、D001、停车费、30.00、D001。 实际实现不得把这些值拼成 SQL 字面量;guard 中业务常量(如 N'审核通过')仍由调用方写在条件片段中。

3.3 示例:三种动作同在,条件各不相同

[
  {
    "table": "demo_order_fee",
    "key_field": "b_id",
    "guards": {
      "inserts": [
        {
          "condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
          "refs": ["demo_order"],
          "message": "该单据已审核通过,不能新增费用"
        }
      ],
      "updates": [
        {
          "condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
          "refs": ["demo_order"],
          "message": "该单据已审核通过,不能修改费用"
        },
        {
          "condition": "isnull(b_writeoff,'0') <> '1'",
          "message": "该费用已核销,不能修改"
        }
      ],
      "deletes": [
        {
          "condition": "not exists (select 1 from dbo.demo_order p where p.b_id = {b_order_id} and p.b_status = N'审核通过')",
          "refs": ["demo_order"],
          "message": "该单据已审核通过,不能删除费用"
        },
        {
          "condition": "isnull(b_writeoff,'0') <> '1'",
          "message": "该费用已核销,不能删除"
        }
      ]
    },
    "inserts": [ { "b_id": "F010", "b_order_id": "D001", "b_fee_name": "停车费",  "b_amount": 30.00 } ],
    "updates": [ { "b_id": "F001", "b_fee_name": "出租车费", "b_amount": 220.00 } ],
    "deletes": [ { "b_id": "F002" } ]
  }
]

注意「主表未审核」这条故意写了三遍——因为三种动作的失败文案不同。 条件可以相同,文案必须分开。

3.4 refs 的定位

它是给人看的依赖标注,不是给机器判的约束。

我们特意不禁止 guard 用子查询(跨表条件在「引用的行会被争抢」时可以正确判断状态, 但 guards 保证的是「写入那一刻条件成立」,不是「整个事务期间状态不变」——见 §11)。 代价是「这个条件依赖哪张表」埋在了 SQL 字符串里——而依赖谁决定了失效点,失效点决定并发是否安全。 refs 把它挪到明处,评审时可以一眼核对。

服务端能校验的很少:

能力 能否做到
声明的表存在 ✓
声明完整(有没有漏写) ✗ 条件是一段自由 SQL,看不出来
声明真实(有没有乱写) ✗ 同上

最小可用规则:condition 里出现 from / join → refs 必填非空,否则拒绝。 这条保证「跨表条件一定有声明」。

若要升级成硬校验,两条路:

  • 粗:从 condition 文本里抓表名(from xxx / join xxx / dbo.xxx)比对。便宜,够挡漏声明
  • 准:SET SHOWPLAN_XML ON 让数据库只解析不执行,从执行计划 XML 里取真实引用对象再比对。准确,但要解析 XML、单独走会话

取舍建议:「出现 from / join 就必填 refs」这条弱规则本次先不做—— 字符串字面量里出现 from 会误伤,也拦不住乱写,性价比低于它带来的噪音。 refs 先只当给人看的注释,靠评审时按上面这张表核对; 真要机器校验就直接上 SHOWPLAN_XML,不要停在半吊子。

refs 不提供任何并发保证。 声明正确的条件照样可能在并发下漏 (例如数别人的行)。并发安全仍然只靠 §7 的三条判据 + 并发用例。

3.5 兼容性

不传 guards 时,生成的 SQL 与现在完全一致。现有调用方不需要改动。


4. 服务端行为

4.1 SQL 拼装

动作 无 guards(现状) 有 guards
update UPDATE t SET ... WHERE pk = ? UPDATE t SET ... WHERE pk = ? AND (guards.updates 各条)
delete DELETE t FROM table AS t WHERE <行内列全等> DELETE t FROM table AS t WHERE <完整唯一键条件> AND (guards.deletes 各条)
insert INSERT INTO t (...) VALUES (...) INSERT INTO t (...) SELECT ?, ... WHERE (guards.inserts 各条)

每个动作只拼自己那份 guards,互不影响。

五条硬性规则:

  1. 条件一律加括号——AND 优先级高于 OR,不加括号会改变语义

  2. {列名} = 当前行的这一列。服务端按动作决定怎么落地:

    动作 落地方式 原因
    insert 取请求里该行的值,绑定为 SELECT ? 参数 行还不存在,没有列可引用
    update / delete 直接写成目标别名 t 下的列引用(t.[列]) 行已存在,WHERE 本就能引用被更新的行

    因此 update / delete 的条件可以引用请求行里没带的列, 而 insert 的条件只能引用请求行里有的列。

  3. insert 用 INSERT ... SELECT ... WHERE(T-SQL 允许无 FROM 的 SELECT)

  4. 执行顺序保持不变(updates → 依赖删除 → deletes → inserts)—— 因此"哪个动作先失败"是确定的,错误提示也是确定的

  5. guards.deletes 只走唯一定位的 DbUtils 删除路径。saveTable 里删除有三条路: 无旁路声明时走 DbUtils.delete(guards 生效); delete_target 非空走 DataDeleteService.deleteWithinTransaction、 有依赖声明走 DataDeleteService.deleteDependents——这两条拿不到 guards。 因此带 guards.deletes 时禁止这两条旁路,直接报参数错误,绝不能让它静默失效。 删除行还必须只携带完整 key_field(复合键要齐全),批量条件删除带 guard 没有合理语义 (部分行满足时该删哪些)且 0 行时无法归因。

    典型用法是删子表明细时校验主表状态(主表已审核就不许删明细), 再叠加"该费用已生成发票就不许删"这类引用检查。 依赖删除的语义是「删本表时连带删引用它的行」,方向是父 → 子; 删子表不会触发它,所以这个场景与旁路天然不冲突。

所有 guards 结构、删除旁路互斥关系、唯一定位约束,都必须在执行 updates / deletes / inserts 之前完成校验; 参数错误不能出现"前几行已写入、随后才拒绝"的现象。

4.2 失败判定

沿用现有的 affectedRows != 1 检查(DbUtils.executeWrite 已有)。 带 guards 的 delete 已限定为"删除行只含完整唯一键"的逐行删除,因此同样要求影响行数为 1; 普通 delete(无 guards)继续保留现有的批量条件语义,不改变兼容性。

影响行数 含义 处理
1 正常 继续
0 $original 不匹配、guard 不成立,或目标行不存在 按 §4.3 顺序区分原因
> 1 条件写宽了(或 key_field 不是唯一键) 系统异常,不是业务提示

4.3 0 行时逐个回查,定位是哪条不成立

0 行(某动作的第 i 行)按以下顺序处理:
       update / delete:先 `select 1 from <table> where <key>` 判断目标行是否存在
         不存在 → 普通保存失败(2001),不生成 guard 文案
         update 带 `$original` 且原值不匹配 → 并发冲突(2003),不生成 guard 文案
         存在且 original 通过 → 再逐条执行 `select 1 from <table> where <key> and (<guard.condition>)`
       insert:`select 1 where (<guard.condition>)`,不带 <key>,见下
       第一个明确返回空的 guard → 用它的 message

insert 的回查不能带 <key>:insert 失败时那行根本不存在, where <key> 必然为空,结果永远落在第一条 guard 上,定位就废了。 insert 的行还不存在,只能对条件本身求值(占位符已替换为该行的请求值):

SELECT 1 WHERE (NOT EXISTS (SELECT 1 FROM dbo.demo_order p
                             WHERE p.b_id = ? AND p.b_status = N'审核通过'));

参数 ? 绑定当前 insert 行的 b_order_id,与实际写入语句使用同一类型。

  • 只回查当前失败动作的那一份(guards.updates / guards.deletes / guards.inserts),不是三份全查
  • 只在失败路径执行,成本可忽略
  • 走同一个连接(在抛异常之前),看到的是本事务视图
  • 顺序即优先级
  • 目标行存在但全部 guard 回查成立仍为 0 行(并发窗口)→ 用通用话术,不伪造某条 guard
  • 结论是 best-effort(回查期间数据可能又变),仅用于文案

SaveGuardException 携带的 action 用于区分是哪一种动作失败—— 前端可以据此给出更贴合的提示("改不动" vs "删不掉")。

4.4 异常与回滚

抛专门的 SaveGuardException(新增),携带 table / action / index / guard / refs。 DbUtils 负责报告 guard 及影响行数,DataSaveService.executeRows 在捕获后补充当前行的 index、 并保持原始异常作为 cause;不能依赖 DbUtils 自己推断批次行号。

为什么不用 SQLException:guard 失败是可识别的业务竞态,不应被包装成普通数据库错误。 executeRows 必须显式 catch SaveGuardException 并补充行号;其他 RuntimeException 才原样穿过 → 到 DataSaveService.save 的 catch (SQLException | RuntimeException) → 回滚 → 重抛 → 由全局处理器转成干净文案。 事务框架本身不需要改,但异常包装和全局 handler 必须新增测试。

回滚范围:整个 save() 是一个事务,任一行失败全部回滚。 文案里必须体现这一点(见 §5.2)。


5. 错误码与文案

5.1 错误码

现有常量:

public static final int SUCCESS_CODE        = 0;
public static final int SAVE_ERROR_CODE     = 2001;
public static final int BUSINESS_ERROR_CODE = 1000;

新增 GUARD_ERROR_CODE = 2002,用于 guard 不成立。 前端据此与普通保存失败区分(§6.3:展示后重新拉取当前状态)。 新增 CONFLICT_ERROR_CODE = 2003,用于 $original 里的字段原值或 b_row_version 不匹配; 它表示并发编辑冲突,不使用某条 guard 的业务文案。 是否提示「请刷新后重试」不能一律挂,由 guard 的 retryable 决定(§5.2)。

5.2 文案规范

好的失败提示 = 发生了什么 + 你该做什么。服务端拼装规则:

<message>,本次操作未生效。<retryable 为真时追加「请刷新后重试。」>
  • <message>:guards.<动作>[].message;未提供时用通用话术「该数据已被更新或不满足操作条件」
  • 「本次操作未生效」固定不变:整个请求是一个事务,用户会以为「存了一半」,这句不能省
  • 「请刷新后重试」不固定,由 retryable(默认 true)决定—— 两类原因的后续动作不同,写死一句就会误导:
原因类型 完整文案 刷新有用吗
状态已变(retryable: true) 该单据已被处理或状态已变更,本次操作未生效,请刷新后重试。 ✓
终态条件(retryable: false) 该费用已核销,本次操作未生效。 ✗ 不要让用户去刷

「已核销」「没有任何费用明细」这类刷新也改变不了的条件,写 guard 时务必置 retryable: false。

5.3 日志

  • WARN 级别,不打堆栈——这是正常业务竞态,不是系统故障,用 ERROR 会刷满假告警
  • condition 原文、refs、table / action / index 全部进日志与 details
  • 不进用户可见文案——b_state = '送审' 对业务人员没有意义

5.4 现状对照

当前 0 行会一路包成:

权限保存失败(改动未落库):保存失败: 修改记录数异常: 0

三层前缀,最后落在一句数据库计数上。改造后应为:

该单据已被处理或状态已变更,本次操作未生效,请刷新后重试。

6. 前端约定

  1. 条件生成收敛到一个函数(如 buildActionRequest(action, row)),不要散落在各页面自己拼。 guards 是 opt-in —— 漏传就是不保护,没有服务端策略兜底,收敛到一处才能避免遗漏。
  2. 收到 2002 → 直接展示 message,不要再加前缀。 现在的写法是 'XX失败:' + error.message,会导致双重前缀。
  3. 收到 2003 → 按并发冲突处理:重新拉取当前行并标记冲突字段,不自动覆盖用户输入,也不自动重试。
  4. 展示 2002 后自动重新拉取当前状态。 用户的本能是再点一次;不刷新他会反复失败。
  5. 按钮可用性仍由前端预判,但它只是「建议」——最终以服务端 guard 为准。 算错了只是显示不准,点下去会被拦。
  6. 不要自动重试。自动重试会把判定再跑一遍,可能又成功,而用户不知情。
  7. guards 全程 opt-in,没有后端策略兜底——不传就是不保护,服务端不会替调用方补条件。 前端必须收敛到 buildActionRequest(action, row),避免状态提示和 $original 散落。
  8. 需要防同字段覆盖时提交 $original,只对变化字段提供旧值;不要为了防止一个字段冲突而提交整行快照。 整单动作才在 $original 里带 b_row_version,普通编辑不要加(§3.1)。
  9. http.js 做不到「去掉前缀」。业务错误走的是成功拦截器(HTTP 200 + code != 0), 那里只抛 Error;前缀是 40+ 个调用点自己拼的(Message.error('保存失败:' + ...))。 http.js 能做的只是对 2002 打 toasted 并弹完整文案——但只有检查 error.toasted 的调用点(views/approval/*、部分 workflow 页面)不会被双弹。 改造要按调用点收敛,不要指望一刀切。

7. 并发安全的判据(核心)

条件本身不保证并发安全。对于只要求"执行时状态合法"的普通 guard,必须同时满足:

  1. 有行承载——判据是存下来的,不是现算的;
  2. 会被争抢——在"使条件变假"的操作里,那一行会被加写锁;
  3. 判定与写入在同一条语句里。

满足这三条,guard 就能保证「写入那一刻条件成立」。 它不保证整个事务期间状态不变——那是单据级串行,本设计不做(§11)。

7.1 对照表

判据形式 有行承载 会被争抢 仅靠 guard
本表栏位 b_status ✓ ✓ ✓
别的表栏位,且那行会被改(如主表状态) ✓ ✓ ✓(写入那一刻状态已变则拒绝)
别的表栏位,但那行没人改 ✓ ✗ ✗ 需补业务锁点
count(*) / sum() ✗ ✗ ✗ 先物化到一行
JOIN / 视图 / 现算的公式 ✗ ✗ ✗ 转成物化字段或专用服务
唯一索引 — — ✓(另一条路)

注意:跨表本身不是问题。「子表新增时校验主表状态」是常见用法,guard 足够; 它拦的是「状态已变更后仍写入」,不提供「审批与明细严格串行」的顺序保证(§11)。

7.2 冲突的真实表现

SQL Server 下的条件写是靠锁实现的 CAS,不是无锁的乐观并发。冲突时:

第二个请求阻塞等待第一个提交 → 等到了才发现不匹配 → 失败

即「转一会儿圈,然后报错」,不是「立刻同时失败」。前端提示要能配合这个体验。

跨表 guard 和多表保存还可能产生相反的加锁顺序:请求 A 先锁子表再锁主表, 请求 B 先锁主表再锁子表,就会出现死锁(1205),这不能当作 guard 不成立。 服务端应按固定的表名顺序获取写锁;无法统一排序时,捕获 1205 后回滚并在服务端有限重试一次, 前端不做盲目重试。锁等待超时(1222)返回独立的"系统繁忙,请稍后重试"错误,不返回 2002。

7.3 普通字段不要默认整行 version

整行 b_row_version 会把无关字段的修改也视为冲突:

甲修改 b_amount,版本 5 -> 6
乙修改 b_remark,仍带版本 5 -> 被拒绝

对于普通资料字段,saveobjt 已经是按提交字段生成 SET,应保留这种局部更新语义。 只有需要防止同字段覆盖时才传 $original,把字段旧值加入 WHERE; 金额、状态、明细账本等业务关键字段则由 guard 或专用服务保护。

整行 version 适合整单提交、批量导入、不可拆分的状态迁移,不适合作为所有普通编辑的默认锁。


8. 不能处理的情况与对策

8.1 判据分类

类型 例子 处理
状态类(单行) b_status = '送审' guard
跨表但会被抢 主表状态 guard(子查询与写入同一条语句)
聚合类 会签人数、核销金额、库存 物化(§8.2)
同字段并发编辑 两人同时改 b_amount $original 字段原值比较或专用服务
无关字段并发编辑 一人改金额、一人改备注 局部更新,允许并行
权限类 是不是待审人 权限 / 数据范围,不放进 guard
复杂综合规则 无定式 悲观锁 + 服务端方法

8.2 物化:把「数出来的」变成「记下来的」

判据:这个约束的真假,能不能由一行的一个字段表达?不能就物化。

约束 现算(会漏) 物化(安全)
所有明细都核销才能关闭 not exists(明细 where 未核销) 表头.b_all_writeoff = 1
明细条数 > 0 count(*) > 0 表头.b_fee_count > 0
表头金额 = 明细合计 sum(明细) 公式字段(FMS 已有)
会签人数 count(已通过) wf_node_run.b_completed_count
预算 / 额度 sum(已用) <= 预算 额度行.b_used + 本次 <= b_total
一人一节点只批一次 not exists(...) 唯一索引

「虚拟」不是问题,「没存」才是。 算完存回栏位即可用——FMS 的计算公式正是这么做的。

8.3 聚合根

物化要落到某一行上。找不到天然聚合根时,说明数据模型缺一张表——加它:

create table dbo.b_expense_budget (
    b_company varchar(50)   not null,
    b_year    int           not null,
    b_total   decimal(18,2) not null,
    b_used    decimal(18,2) not null default 0,
    primary key (b_company, b_year)
);

关键动作是加一行,不是加一个查询。

8.4 入口收敛

做法 强度 问题
调用方在同请求里一起写 弱 绕过此入口的写入(导入、其他模块、手工 SQL)会让账本漂移
服务端统一入口拦截 强 需动服务端;可统一注入账本更新等副作用
数据库触发器 最强 本项目明确不建触发器(审计列由写入方维护)

推荐第二种。项目内已有后端拦截先例(公式保存,FormulaConfigService):

if (!isSystemTable(tableName)) {
    List<ModuleConfig> byTable = findFormulaConfigsBySaveTable(connection, tableName);
    if (!byTable.isEmpty()) {
        throw new BusinessException(
                "表 " + tableName + " 属于带计算公式的模块("
                        + byTable.get(0).moduleId()
                        + "),保存必须携带 moduleCode 并由公式保存处理器校验"
        );
    }
}

即:这张表有约束 → 绕过统一入口就拒绝。

客户端只提交业务变更和可选 $original,不提交任何锁或策略参数—— 表里有没有 b_row_version、要不要用它,都是调用方按动作自己决定的事。

8.5 账本字段需要表达式值

账本若只能用 CAS 写法(guard: b_fee_count = 2 + set: 3), 两人同时给同一单加费用会产生假冲突——两笔费用互不影响,却有一方失败。

表达式值可以消除(必须用 { "$expr": ... } 显式包装,见 §3.1—— 裸字符串按普通值参数化,会变成 SET b_fee_count = 'b_fee_count + 1' 直接报错):

{ "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } }
-- → UPDATE t SET [b_fee_count] = t.[b_fee_count] + ? FROM demo_order AS t(参数为 1)

额度类还需要「条件 + 增量同句」:

UPDATE b_expense_budget SET b_used = b_used + ?
 WHERE b_company = ? AND b_year = ? AND b_used + ? <= b_total

表达式必须在公式重算之后、审计列补充之前解析;表达式只能引用当前保存表,不能跨表或引用 guard。 表达式参数与普通值参数统一使用 PreparedStatement 绑定,不能把常量拼入 SQL。

这是物化方案的必要配套,不是可选项。

8.6 冗余必须能对账

物化 = 冗余 = 可能漂移。这是代价,要认,然后用对账兜住:

select o.b_id, o.b_fee_count, count(f.b_id) as real_count
  from dbo.demo_order o
  left join dbo.demo_order_fee f on f.b_order_id = o.b_id
 group by o.b_id, o.b_fee_count
having o.b_fee_count <> count(f.b_id);

做成运维页面或定时任务。账本能被信任的前提是它能被核对。

8.7 实在物化不了:悲观锁

select ... from dbo.demo_order with (UPDLOCK, HOLDLOCK) where b_id = ?
-- 读 → 判 → 写,同一事务

局限:只对「经过这段代码」的路径有效。手工 SQL、其他模块、导入照样绕过。 它是应用层约定,不是数据层保证。


9. 改动清单

文件 改动 说明
utils/DbUtils.java update / delete 加带 guards 的重载 拼 WHERE,条件加括号
utils/DbUtils.java insert 支持参数化的 INSERT ... SELECT ... WHERE guard 占位符和值都走参数绑定
utils/DbUtils.java executeDelete 补行数校验 带 guards 时必须恰好影响 1 行;无 guards 保持批量删除兼容
utils/DbUtils.java update 的 SET 支持结构化表达式值 账本自增(§8.5),解析后参数化
service/data/DataSaveService.java saveTable 按动作解析 guards.inserts / guards.updates / guards.deletes,分别透传给 executeRows 保持现有执行顺序
service/data/DataSaveService.java 0 行时先判断目标行存在,再逐个回查该动作的 guards 不把"主键不存在"误报成业务 guard
service/data/DataSaveService.java delete_target / dependent_deletes 与 guards.deletes 互斥 本次拒绝旁路静默绕过
service/data/DataSaveService.java guard token、动作结构、占位符列和唯一定位规则校验 参数错误在写入前失败
exception/SaveGuardException.java 新增 携带 table / action / index / guard / refs
exception/GlobalExceptionHandler.java 新增 handler 返回 2002 + 干净文案;日志 WARN 不打堆栈
utils/ApiResponse.java 新增 GUARD_ERROR_CODE = 2002、CONFLICT_ERROR_CODE = 2003 guard 失败与字段 / 版本冲突分开
utils/DbUtils.java guard 占位符值统一绑定 PreparedStatement 参数 不拼 SQL 字面量,避免类型和 NULL 语义错误
utils/DbUtils.java update 支持 $original 原值比较(含 b_row_version 整行模式) 只比较声明的字段,不把无关字段误判为冲突;版本列要求表上存在可写的 b_row_version
service/data/DataDeleteService.java 不改动 带 guards 的删除禁止走 delete_target / 依赖删除旁路;删除服务本身不接收 guards
service/data/DataSaveService.java 0 行回查时 insert 不带 key §4.3
fms-vue/src/services/http.js 2002 打 toasted + 弹完整文案 只是去重,不能替调用点去掉前缀;真正要改的是那些拼前缀的调用点(§6.7)
前端审批类动作 抽 buildActionRequest(action, row) 条件与文案集中生成
测试 新增 DataSaveServiceGuardTests §10 的 10 类用例:并发 / 0 行 / 兼容性 / 多表回滚 / 表达式值 / 删除语义 / 数据库锁行为 / 跨表时序 / 字段冲突 / 局部更新

10. 测试要求

  1. 并发用例(必须)——按业务不变量设计冲突和非冲突两类用例,不能统一断言"只有一个成功": 同一状态迁移应一成一败;两个独立明细新增可以都成功;跨表 guard 要验证实际锁顺序和最终状态。
  2. 0 行用例——主键不存在(返回 2001 且不带 guard 文案)、guard 不成立、多个 guard 中第 2 条不成立(验证回查定位准确)
  3. 兼容性用例——不传 guards 的现有请求,SQL 与行为不变
  4. 多表事务——第 2 个表块失败时,第 1 个表块的写入确实被回滚
  5. 表达式值——b_fee_count + 1 并发两次,结果应为 +2(无假冲突)
  6. 删除语义——带 guards 的删除只能命中唯一键;批量条件、delete_target、依赖删除均应在写入前拒绝。
  7. 数据库行为——在实际隔离级别下验证锁等待、1205 死锁、1222 超时的回滚和错误码,不把它们误判为 2002。
  8. 跨表 guard 的时序——明细新增 vs 审批并发,断言先提交者成功、后提交者被 guard 拦下, 且返回的是 2002(不是 2001);不要求两者按固定顺序串行。
  9. 字段冲突——两人改不同字段时都成功;两人改同一字段时,带 $original 的请求一成一败并返回 2003; $original 含 b_row_version 时退化为整行语义,任一场变更都冲突。
  10. 局部更新兼容性——不带 $original 的不同字段更新不互相覆盖;公式、审计列和账本字段仍按服务端规则处理。

11. 边界与不做

  • 不禁止 guard 使用子查询。跨表条件在「引用的行会被争抢」时可以正确判断状态; 静态无法判定的部分靠 refs 声明 + 并发用例覆盖
  • 不做行级 guards。guards 到「表块 × 动作」这一层为止; 同一动作内多行的差异由 {列名} 占位符实例化表达(见 §3.1)
  • 不做单据级串行(原 §4.0 保存策略 / 聚合根锁,已移除)。guards 保证的是 「写入那一刻条件成立」——判定与写入压在同一条语句里,因此不存在「先读后写」的窗口; 它不保证整个事务期间状态不变,也不保证两个事务按某个固定顺序完成。 需要严格提交顺序的场景走专用服务(§8.7),不在通用保存里解决。
  • 快照隔离下 insert 的 guard 可能读到旧值。INSERT ... SELECT ... WHERE (子查询) 的子查询 在 RCSI / SNAPSHOT 下读语句快照、不加锁,可能读到已被别人改写的旧状态。 UPDATE 不受影响(写操作会重读最新提交版本)。这是 §12 第 1 条要求先确认隔离级别的原因。
  • 不改现有调用方。不传 guards 行为不变
  • 不建触发器,遵循现有约定
  • 不把整行乐观锁作为普通编辑默认机制——整单提交、批量导入和明确要求整单一致的动作可以启用 b_row_version(同样是写在 $original 里);普通编辑使用局部更新或字段级 $original。
  • 本次不做请求幂等(§1.3)——后续按 workflow 的 b_request_id 方案补入 saveobjt
  • 不把 wf_task / wf_cc 登记成数据模块——现有设计有意如此,避免给通用保存开后门
  • 核销状态暂不纳入本机制。核销是「按金额、可部分、多行」的模型, 与审核状态的粒度不同,应走专用接口(旧系统即如此)

12. 未决问题

# 问题 状态 说明
1 数据库隔离级别 前置条件,动手前必须确认 代码未设置。RCSI 下 UPDATE ... WHERE <谓词> 的谓词是在语句快照上求值、还是加锁后重算,与 SNAPSHOT(抛 3960 更新冲突)行为不同——这决定本方案的 CAS 前提是否成立。先 DBCC USEROPTIONS 确认,再用 §10 第 1 条的并发用例实测
2 死锁 实现约束 多表请求 + guard 子查询按固定表名顺序获取锁;仍发生 1205 时回滚并由服务端有限重试一次,不向前端自动重试
3 锁等待超时 实现约束 save 连接设置 3~5 秒 LOCK_TIMEOUT,1222 返回独立的系统繁忙错误;连接归还连接池前恢复原设置,避免污染后续请求
4 多行动作中途失败时的回查失真 已定 回查只用于生成 best-effort 文案;以当前事务视图为准,若目标行存在但所有 guard 均成立,使用通用并发失败文案,不声称具体哪条条件失败
5 refs 是否强制登记 已定:本次不做 弱规则(from / join 就必填)性价比低,见 §3.4
6 表达式值的语法边界 已定 用 { "$expr": "..." } 显式包装(§3.1 / §8.5);内容限定为「本表列 ± 字面量」
7 $original 冲突码 已定 字段原值或 b_row_version 不匹配统一返回 CONFLICT_ERROR_CODE = 2003,不走 guard 文案
8 元字段命名 已定 原值统一用 $original;整行版本锁并入 $original 的 b_row_version 键,不再单列 $expected_row_version(§3.1)
9 不做单据级串行 已定 已移除原 §4.0 保存策略 / 聚合根锁。guards 只保证「写入那一刻条件成立」,需要严格顺序的场景走专用服务(§11)

(已定,不再讨论:guards 粒度 = 表块级,按动作分开; 不做成行级,因为同一动作内多行的差异由 {列名} 实例化即可表达。)


13. 推荐落地顺序

不要一次把所有并发能力铺到全部模块,建议按下面顺序逐步启用:

  1. 先落地 guards:确认数据库隔离级别(§12 第 1 条),加入按动作条件、2002 文案、 0 行准确归因和删除约束;普通表可继续兼容无 guards 请求。
  2. 加入 $original:只对确实需要防同字段覆盖的字段启用,返回 2003;整行 b_row_version 只用于整单动作,不作为普通编辑默认值。
  3. 加入账本表达式和对账任务:金额、数量、明细数使用数据库增量,定期核对物化字段与明细真实数据。
  4. 最后补整单 version 与 request_id:只用于整单提交、批量导入和可重试的后台动作。

每一步都必须保留同一模块的并发回归用例,确认新策略不会改变普通字段局部更新的兼容行为。


14. Workflow 插件式接入评估

14.1 结论

可以把 workflow 的前端调用入口做成插件,并让插件把「草稿配置持久化」编排成一个 saveobjt 请求;但不能把 workflow 后端实现整体搬到 fms-vue/src/services,也不能让前端 直接用 saveobjt 改运行时表。

这里要区分两件事:

层 可以插件化的内容 不能下沉到前端的内容
前端 services 请求构造、草稿 diff、多个表块合并、错误码映射、能力发现 权限判定、事务、锁、幂等的最终保证
saveobjt draft 配置表的原子增删改、guards、$original 冲突检测 流程校验、版本发布、节点推进、消息副作用
workflow 后端 配置策略适配器、发布和运行命令 不能被浏览器代码替代

因此,所谓“移动到前端 services”应理解为移动调用适配层,不是移动业务权威。 前端插件只是 saveobjt 和现有 workflow command API 的统一外观;数据库权限、审计列和最终 约束仍由服务端负责。

14.2 接口分类和迁移边界

现有接口 / 能力 插件方案 原因
definition/save 可改为 saveobjt 草稿配置适配器,但要保留定义编码校验和创建初始版本的后端策略 新建定义会同时创建 wf_version,不能让两次独立请求代替一个事务
version/draft/save 首选迁移对象:一个 saveobjt 请求更新版本、删除旧节点/连线/规则并插入新快照 这是纯配置快照写入,但必须有 draft guard 和并发冲突检测
version/validate 保留后端命令;前端可先做同构校验 需要读取模块字段白名单,前端校验不能成为最终规则
version/publish / version/retire 保留后端命令 发布校验、checksum、不可变版本和状态迁移必须在同一事务内完成
version/copy 保留后端命令 需要分配新版本号并复制三张子表,存在并发唯一性问题
binding/save/delete 默认保留后端命令;仅在增加 workflow 专用 save policy 后再评估 绑定要求已发布版本、优先级唯一,并会创建动作权限点
runtime/submit、task/action、claim、transfer、add-approver、withdraw、cancel、urge 禁止迁移为 saveobjt 直写 这些动作会锁定实例/任务,写审计,推进节点,投影业务状态,写 outbox,并依赖 request_id 幂等
todo/done/graph/detail/cc 查询 可以继续由插件封装现有查询 API 查询不改变运行状态
cc/read 默认保留专用接口 需要校验当前用户是抄送接收人;不能用无权限的通用更新替代

运行时表 wf_instance、wf_node_run、wf_task、wf_action、wf_cc、wf_outbox 不得登记为 普通 saveobjt 数据模块。前端传入这些表时,服务端应在保存入口直接拒绝,而不是依赖插件代码 “自觉不调用”。这条是安全边界,不是 UI 约定。

14.3 草稿插件的服务契约

插件不直接暴露数据库表给业务页面,而是提供稳定的领域方法:

export const workflowPlugin = {
  code: 'workflow',
  capabilities: {
    draftPersistence: 'saveobjt',
    publish: 'command',
    runtime: 'command',
  },
  loadDraft,
  saveDraft,
  validateVersion,
  publishVersion,
  submit,
  act,
}

页面只依赖 workflowPlugin,不依赖 saveObjectApi,也不拼 wf_* 表块。插件内部的 saveDraft 才负责把领域草稿转换成保存请求;这样将来从 saveobjt 切回专用接口时,页面 无需改动。

建议的最小请求形状如下(示意,子表删除行必须是完整唯一键):

[
  {
    "table": "wf_version",
    "key_field": "b_id",
    "updates": [
      {
        "b_id": "expense_v1",
        "b_canvas_json": "{...}",
        "$original": { "b_row_version": 7 }
      }
    ],
    "guards": {
      "updates": [
        {
          "condition": "b_status = 'draft'",
          "message": "流程草稿已发布或停用,不能继续修改",
          "retryable": false
        }
      ]
    }
  },
  {
    "table": "wf_node",
    "key_field": "b_id",
    "deletes": [{ "b_id": "old_node_1" }],
    "inserts": [{ "b_id": "new_node_1", "b_version_id": "expense_v1", "...": "..." }],
    "guards": {
      "deletes": [
        {
          "condition": "exists (select 1 from dbo.wf_version v where v.b_id = {b_version_id} and v.b_status = 'draft')",
          "refs": ["wf_version"],
          "message": "流程草稿已发布,不能删除节点",
          "retryable": false
        }
      ],
      "inserts": [
        {
          "condition": "exists (select 1 from dbo.wf_version v where v.b_id = {b_version_id} and v.b_status = 'draft')",
          "refs": ["wf_version"],
          "message": "流程草稿已发布,不能新增节点",
          "retryable": false
        }
      ]
    }
  }
]

上例要求 wf_version 有可比较的 b_row_version。当前运行表已有该列,但配置版本表还没有; 若启用草稿插件,应给 wf_version 增加版本列,并在每次草稿保存成功后递增。否则只能保护 “仍是 draft”,不能防止两个设计器互相覆盖整个快照。节点、连线和审批人规则的删除与插入 必须和版本行在同一 saveobjt 请求中完成,不能拆成多个请求。

14.4 服务端前置条件

草稿插件上线前必须增加 workflow 专用保存策略(可以是 DataSaveService 内的白名单适配器, 不要求新增一套 HTTP 协议):

  1. 只允许 wf_definition、wf_version、wf_node、wf_edge、wf_actor_rule 的 draft 写入; wf_binding 先继续走专用服务,运行时表一律拒绝。
  2. 校验表块之间的 b_version_id、节点/连线引用和主版本存在性;不能只依赖外键或前端。
  3. wf_version.b_status = 'draft' 是必需 guard;已发布版本不得通过普通局部更新修改配置。
  4. 由服务端继续补审计列、校验当前用户的 workflow 管理权限,并统一返回 2002/2003。
  5. 发布前仍必须调用后端 validator;saveDraft 成功不等于流程可发布。
  6. 记录插件写入的 request id,至少能定位版本、操作者和保存结果;可重试动作仍使用后端幂等键。

未满足以上条件时,前端应继续调用 version/draft/save,不要为了“插件化”而绕过已有 WorkflowConfigService。

14.5 推荐迁移顺序和验收

  1. 先新增 workflowPlugin 外观,内部仍调用现有 /workflow/**,保证页面调用面先稳定。
  2. 给 wf_version 增加 b_row_version,实现草稿保存的 saveobjt 适配器和表白名单;保留旧接口作为回滚开关。
  3. 用并发设计器、已发布版本、半成品节点引用、重复提交和事务回滚用例验证后,再切换默认实现。
  4. 最后再评估 definition/save;publish、runtime 和 cc/read 不纳入本次迁移。

验收必须至少包括:两个设计器同时保存时一成一败并返回 2003;发布与草稿保存并发时不能修改已发布版本; 节点删除/插入任一步失败时版本和全部子表都回滚;前端无法通过 saveobjt 写入 wf_task 或 wf_instance; 运行时审批的锁、幂等、审计和业务状态投影回归用例全部通过。


附录 A:Demo(可直接执行)

A.1 建表

/* 主表:报销单 */
create table dbo.demo_order (
    b_id         varchar(50)    not null primary key,
    b_no         nvarchar(50)   null,                        -- 单号
    b_amount     decimal(18,2)  not null default 0,          -- 累计金额
    b_fee_count  int            not null default 0,          -- 明细条数(账本字段)
    b_status     varchar(20)    not null default N'草拟',    -- 草拟/送审/审核通过/审核驳回
    b_updated_by varchar(50)    null,
    b_updated_at datetime2      null
);

/* 子表:费用明细 */
create table dbo.demo_order_fee (
    b_id         varchar(50)    not null primary key,
    b_order_id   varchar(50)    not null,                    -- → demo_order.b_id
    b_fee_name   nvarchar(100)  null,
    b_amount     decimal(18,2)  not null default 0,
    b_writeoff   varchar(10)    null,                        -- 核销标志:0/1(§3.3 示例用到)
    b_updated_at datetime2      null                         -- 审计列由服务端补(§1.6)
);

A.2 造数据

insert into dbo.demo_order (b_id, b_no, b_amount, b_fee_count, b_status) values
  ('D001', N'BX-2026-001', 1200.00, 2, N'草拟'),
  ('D002', N'BX-2026-002',  800.00, 1, N'审核通过'),   -- 已审核,用来演示拦截
  ('D003', N'BX-2026-003', 5000.00, 2, N'送审'),       -- 用来演示并发
  ('D004', N'BX-2026-004',    0.00, 0, N'送审');       -- 无明细,用来演示多 guard

insert into dbo.demo_order_fee (b_id, b_order_id, b_fee_name, b_amount) values
  ('F001', 'D001', N'打车费',   200.00),
  ('F002', 'D001', N'餐费',    1000.00),
  ('F003', 'D002', N'住宿费',   800.00),
  ('F004', 'D003', N'机票',    4000.00),
  ('F005', 'D003', N'机场大巴', 1000.00);

A.3 四个场景

场景 请求要点 生成 SQL 的影响行数 结果 提示
A 给 D001(草拟)加费用 1 通过 —
B 给 D002(已审核)加费用 0 拦下 该单据已审核通过,不能新增费用
C 给 D003 加费用 vs 审批 D003 — 看谁先提交 审批先提交 → 加费用被 guard 拦下(2002);加费用先提交 → 审批按新状态处理
D 审批 D004(送审但无明细) 0 拦下 该单据没有任何费用明细,不能通过审批

场景 A / B 用的是 guards.inserts,场景 D 用的是 guards.updates:

"guards": {
  "updates": [
    { "condition": "b_status = N'送审'", "message": "单据状态已变更,请刷新后重试" },
    { "condition": "b_fee_count > 0",    "message": "该单据没有任何费用明细,不能通过审批",
      "retryable": false }
  ]
}

生成 SQL(两条 AND 在一起,仍然是一条语句):

UPDATE demo_order SET b_status = N'审核通过'
 WHERE b_id = 'D004'
   AND (b_status = N'送审')
   AND (b_fee_count > 0);

b_fee_count = 0 → 0 行 → 回查:

SELECT 1 FROM demo_order WHERE b_id = 'D004' AND (b_status = N'送审');   -- 有行 ⇒ 通过
SELECT 1 FROM demo_order WHERE b_id = 'D004' AND (b_fee_count > 0);      -- 空 ⇒ 就是它

→ 提示准确到第二条。若两条拼成一个大字符串,只能给「操作失败,请刷新」—— 而这条刷新也没用(它确实没明细)。


附录 B:核心结论

并发安全的前提是「有一个东西在被争抢」。

存下来的判据有行、能被锁,所以可以做 guard;判定与写入压进同一条语句,才不会留下读与写之间的窗口。 现算的判据没有行、锁不住,所以只能看不能判;数出来的东西没有归属——给它造一个归属,这就是物化。 普通字段则不必整行加锁:按字段局部更新,必要时用 $original 检测同字段覆盖。