1066 lines
55 KiB
Markdown
1066 lines
55 KiB
Markdown
# 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` 检测同字段覆盖。
|