# FMS 通用保存接口(saveobjt)条件更新设计 > 状态:设计稿(已补齐首期实现约束) > 涉及:`fms-api` 通用保存链路、`fms-vue` 审批类动作、workflow 草稿插件式接入评估 --- ## 0. 一句话 给通用保存加「带条件的写入」——把「读 → 判断 → 写」的原子性交回给数据库, 让写入本身能表达「这一行必须还是我以为的样子,否则不写」; 再按表的业务粒度选择普通局部更新或字段冲突检测, 不把整行版本锁强加给所有表。 --- ## 1. 背景 ### 1.1 现状 `/data/saveobjt` 的定位方式只有主键: ```java // 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` 对象,声明「我这行是基于哪些旧值改的」,用于冲突检测: ```json { "b_id": "D001", "b_amount": 220.00, "$original": { "b_amount": 200.00 } } ``` 服务端将其生成: ```sql 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` 放,只是键是版本列: ```json { "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` 这段文字」: ```json { "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } } ``` 裸字符串一律按普通值参数化处理(现有行为不变)。表达式内容限定为:一个本表可写列, 可选的 `+` / `-`,以及一个带符号的整数或小数常量;不允许函数、子查询、其他表列、多个运算符或赋值。 服务端先解析成结构化表达式,再生成 `SET [列] = t.[列] + ?` 形式的 SQL,并校验列存在、不可写列和数值类型。 客户端不能通过请求指定锁提示(`UPDLOCK` / `HOLDLOCK`)或加锁顺序。锁完全由数据库按语句本身获取, 请求里没有任何锁相关参数。 ### 3.2 示例:只有新增 ```json [ { "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: ```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 示例:三种动作同在,条件各不相同 ```json [ { "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 where ` 判断目标行是否存在 不存在 → 普通保存失败(2001),不生成 guard 文案 update 带 `$original` 且原值不匹配 → 并发冲突(2003),不生成 guard 文案 存在且 original 通过 → 再逐条执行 `select 1 from
where and ()` insert:`select 1 where ()`,不带 ,见下 第一个明确返回空的 guard → 用它的 message ``` **insert 的回查不能带 ``**:insert 失败时那行根本不存在, `where ` 必然为空,结果永远落在**第一条** guard 上,定位就废了。 insert 的行还不存在,只能对条件本身求值(占位符已替换为该行的请求值): ```sql 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 错误码 现有常量: ```java 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 文案规范 好的失败提示 = **发生了什么** + **你该做什么**。服务端拼装规则: ``` ,本次操作未生效。 ``` - **``**:`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` 会把无关字段的修改也视为冲突: ```text 甲修改 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 聚合根 物化要落到某一**行**上。找不到天然聚合根时,说明数据模型缺一张表——**加它**: ```sql 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`): ```java if (!isSystemTable(tableName)) { List 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'` 直接报错): ```json { "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) ``` 额度类还需要「条件 + 增量同句」: ```sql 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 冗余必须能对账 物化 = 冗余 = 可能漂移。**这是代价,要认,然后用对账兜住:** ```sql 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 实在物化不了:悲观锁 ```sql 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 草稿插件的服务契约 插件不直接暴露数据库表给业务页面,而是提供稳定的领域方法: ```js export const workflowPlugin = { code: 'workflow', capabilities: { draftPersistence: 'saveobjt', publish: 'command', runtime: 'command', }, loadDraft, saveDraft, validateVersion, publishVersion, submit, act, } ``` 页面只依赖 `workflowPlugin`,不依赖 `saveObjectApi`,也不拼 `wf_*` 表块。插件内部的 `saveDraft` 才负责把领域草稿转换成保存请求;这样将来从 `saveobjt` 切回专用接口时,页面 无需改动。 建议的最小请求形状如下(示意,子表删除行必须是完整唯一键): ```json [ { "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 建表 ```sql /* 主表:报销单 */ 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 造数据 ```sql 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`: ```json "guards": { "updates": [ { "condition": "b_status = N'送审'", "message": "单据状态已变更,请刷新后重试" }, { "condition": "b_fee_count > 0", "message": "该单据没有任何费用明细,不能通过审批", "retryable": false } ] } ``` 生成 SQL(两条 AND 在一起,**仍然是一条语句**): ```sql 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 行 → 回查: ```sql 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` 检测同字段覆盖。