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

1066 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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 的行还不存在,只能对条件本身求值(占位符已替换为该行的请求值):
```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 文案规范
好的失败提示 = **发生了什么** + **你该做什么**。服务端拼装规则:
```
<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` 会把无关字段的修改也视为冲突:
```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<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'` 直接报错):
```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` 检测同字段覆盖。