20260927215912

This commit is contained in:
oneao committed 2026-09-27 21:59:13 +08:00
1 parent 27e49e2e45
commit f22e67fc85
294 files changed
+29247 -3576

No files matched your search

@@ -0,0 +1,642 @@
# FMS 通用保存接口(saveobjt)使用说明
> 配套文档:《FMS通用保存接口条件更新设计》(机制与取舍)
> 本文只讲**怎么用**:请求怎么写、服务端生成什么 SQL、各种情况下返回什么。
> 示例表沿用设计文档附录 A 的 `demo_order`(报销单主表)/ `demo_order_fee`(费用明细)。
---
## 0. 一句话
`saveobjt` 原本是**按主键盲写**。现在可以给写入附加两类东西:
| 附加物 | 作用 | 位置 |
| --- | --- | --- |
| `guards` | 业务前置条件("只有草拟状态才能改") | 表块级,按动作分开 |
| `$original` | 并发冲突检测("改的时候它还得是我看到的那个值") | 更新行内 |
**两者都是 opt-in。不传,行为与以前完全一致。**
---
## 1. 请求格式速查
```jsonc
[
{
"table": "demo_order_fee", // 必填
"key_field": "b_id", // 必填,复合键用逗号分隔
"guards": { // 可选
"inserts": [ { "condition": "...", "message": "...", "retryable": true, "refs": [] } ],
"updates": [ { "condition": "...", "message": "..." } ],
"deletes": [ { "condition": "...", "message": "..." } ] // 支持,但有两条约束,见 §3.4
},
"inserts": [ { ... } ], // 原有
"updates": [ { "b_id": "...", "b_amount": 220.00, "$original": { "b_amount": 200.00 } } ],
"deletes": [ { ... } ] // 原有,不带 guards
}
]
```
### 1.1 元字段一览
| 元字段 | 位置 | 作用 |
| --- | --- | --- |
| `$original` | **updates 行内** | 声明改动前的原值,进 `WHERE` 不进 `SET` |
| `$expr` | updates 行的**某个值**上 | 表达式值(账本自增),见 §4 |
| `guards` | 表块 | 业务前置条件 |
`$` 前缀 = 保留元字段,业务列一律 `b_` 前缀,不可能撞名。
审计列(`b_created_by` / `b_created_at` / `b_updated_by` / `b_updated_at`)**由服务端补**,
前端不要传、传了也会被丢弃(核心表结构设计 §1.6)。所以示例 SQL 里看不到它们,实际语句会有。
---
## 2. `$original`:字段级冲突检测
### 2.1 基本用法
```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] = ?;
-- 参数:220.00(新值), 'D001'(主键), 200.00(原值)
```
命中 1 行 → 成功;命中 0 行 → 服务端回查后返回 2003。
### 2.2 规则
- **只对变化字段带原值**,不要提交整行快照。带得越多,越容易把无关字段的改动误判成冲突。
- 原值为 `null` 生成 `IS NULL`,**不能**写 `= NULL`:
```json
{ "b_id": "F001", "b_fee_name": "打车费", "$original": { "b_fee_name": null } }
```
```sql
... WHERE t.[b_id] = ? AND t.[b_fee_name] IS NULL
```
- 字段名必须是表里真实存在的列,否则在写库**之前**就报参数错误(不会写一半再失败)。
- `inserts` 行**不支持** `$original`(新增的行没有原值可比),传了报参数错误。
### 2.3 整行版本锁
`$original` 里放 `b_row_version` 即切换为整行语义:
```json
{
"b_id": "D001",
"b_status": "审核通过",
"$original": { "b_row_version": 7 }
}
```
```sql
UPDATE t SET [b_status] = ?, [b_row_version] = t.[b_row_version] + 1
FROM dbo.demo_order AS t
WHERE t.[b_id] = ? AND t.[b_row_version] = ?;
```
| `$original` 内容 | 粒度 |
| --- | --- |
| 业务字段 `{ "b_amount": 200.00 }` | 字段级,不同字段可并行修改 |
| 版本列 `{ "b_row_version": 7 }` | 整行,任一字段变更都冲突 |
要求目标表存在**可写**的 `b_row_version` 列(不能是 `rowversion` / `timestamp` 类型)。
版本列由服务端 `+1`,请求里在行上直接带 `b_row_version` 不生效。
**只用于整单提交 / 批量导入 / 不可拆分的状态迁移**——普通编辑用它会把无关字段的修改也判成冲突。
---
## 3. `guards`:业务前置条件
### 3.1 基本用法
```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": "D002", "b_fee_name": "停车费", "b_amount": 30.00 } ]
}
```
生成:
```sql
INSERT INTO dbo.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','D002',N'停车费',30.00, 'D002'(占位符取自请求行)
```
**判定与写入在同一条语句里**——这是它能防并发的原因,不要拆成"先查再写"。
### 3.2 占位符 `{列名}` 的落地方式
| 动作 | 占位符变成 | 原因 |
| --- | --- | --- |
| inserts | `?`,值**取自当前请求行** | 行还不存在,没有列可引用 |
| updates | `t.[列]` | 行已存在,可直接引用 |
推论:**insert 的条件只能引用请求行里有的列**;update 的条件可以引用请求行里没带的列(用库里的当前值比)。
insert 行缺少被引用的列 → 报参数错误,不会静默当空值。
### 3.3 `message` 与 `retryable`
失败文案由服务端拼成:`{message},本次操作未生效。{请刷新后重试。}`
| retryable | 文案 | 适用 |
| --- | --- | --- |
| `true`(默认) | `该单据已审核通过,不能新增费用,本次操作未生效。请刷新后重试。` | 状态随时会变,刷新有用 |
| `false` | `该费用已核销,本次操作未生效。` | **刷新也改变不了**的终态条件 |
把"请刷新后重试"挂在不该挂的地方,会误导用户反复刷新。写条件时务必判断。
没写 `message` 时用通用话术「该数据已被更新或不满足操作条件」。
### 3.4 deletes 的两条约束
`guards.deletes` **支持**,但有两条硬约束,违反了报参数错误(`1000`):
1. **删除行必须只携带完整主键**。复合键要齐全,不能混入业务列做成批量条件——
批量删除带 guard 时「部分行满足该删哪些」没有合理语义,0 行时也无从归因到具体行。
```json
"deletes": [ { "b_id": "F004" } ] // ✓ 只有主键
```
2. **不能同时走删除旁路**。带 `guards.deletes` 时,`delete_target` 非空 或 声明了 `dependent_deletes`
都会直接报错——那两条路走 `DataDeleteService`,拿不到 guards,混用会让条件**被静默忽略**。
> 删子表明细时不会触发依赖删除:依赖删除的语义是「删本表时连带删**引用它**的行」,
> 方向是父 → 子(删主表才连带删子表)。所以「删明细时校验主表状态」与旁路天然不冲突。
另外条件里**不允许注释**(`--` / `/* */`),有注释直接报参数错误。
---
## 4. `$expr`:账本自增
账本字段(`b_fee_count` / `b_amount` 这类**汇总值**)不能用"读旧值 → 算新值 → 写新值"的方式更新:
- **直接写新值** → 并发时**静默丢失一次更新**(账本停在错的值且不报错)
- **用 `$original` 做 CAS** → 会产生**假冲突**(两笔费用互不影响,却有一方失败)
表达式值把「读 → 算 → 写」三步压成数据库端的一步,两个问题一起解决(完整推演见 §6.9):
```json
{ "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } }
```
```sql
UPDATE t SET [b_fee_count] = t.[b_fee_count] + ?
FROM dbo.demo_order AS t
WHERE t.[b_id] = ?;
```
必须**显式包装**。裸字符串 `"b_fee_count + 1"` 会被当普通值,直接报类型错。
限制:只允许 `被赋值的列 ± 数值常量`,列必须是数值类型且可写。不支持函数、子查询、引用其他列、多个运算符。
**什么时候需要它**:新值是**依赖旧值算出来**的(累加 / 递减)。整块替换的字段(比如把备注从"差旅"改成"差旅(补录)")直接写新值就行,不需要 `$expr`,详见 §6.9。
---
## 5. 错误码
| code | 含义 | 触发条件 | `data` |
| --- | --- | --- | --- |
| `0` | 成功 | — | `null` |
| `1000` | 参数错误 | 结构不合法、占位符列名不存在、不支持的用法 | — |
| `2001` | 普通保存失败 | **目标行不存在**(或原 SQL 异常) | `table`/`action`/`index`/`cause` |
| `2002` | guard 不成立 | 目标行在,但条件不满足 | `table`/`action`/`index` |
| `2003` | 并发冲突 | `$original` 原值或 `b_row_version` 不匹配 | `table`/`action`/`index`/`fields` |
**2001 vs 2002 的区分是服务端回查得出的**,不是猜的:0 行时先查目标行在不在,不在就是 2001。
---
## 6. 各种情况的 Demo
以下都用 `demo_order` / `demo_order_fee`,初始数据见设计文档附录 A.2。
### 6.1 普通编辑,什么都不带
```json
[{ "table": "demo_order_fee", "key_field": "b_id",
"updates": [ { "b_id": "F001", "b_fee_name": "打车费(往返)" } ] }]
```
```sql
UPDATE dbo.demo_order_fee SET [b_fee_name] = ? WHERE [b_id] = ?
```
→ `{"code": 0, "message": "保存成功", "data": null}`
这就是**老行为**,不带 guards / `$original` / `$expr` 的行走的还是原来的 SQL 生成路径。
### 6.2 防同字段覆盖
甲、乙同时打开 D001 的金额编辑(都是 1200.00):
```json
[{ "table": "demo_order", "key_field": "b_id",
"updates": [ { "b_id": "D001", "b_amount": 1500.00,
"$original": { "b_amount": 1200.00 } } ] }]
```
甲先提交 → 1 行 → 成功。
乙后提交 → `WHERE b_amount = 1200.00` 已不成立 → 0 行 → 回查:目标行在、`b_amount` 不匹配 →
```json
{
"code": 2003,
"message": "该数据已被其他操作修改,本次操作未生效,请刷新后重试。",
"data": { "table": "demo_order", "action": "update", "index": 0, "fields": ["b_amount"] }
}
```
`fields` 告诉前端**是哪个字段**冲突,用于重新拉取后高亮。
### 6.3 甲改金额、乙改备注 → 都成功
```json
// 甲
{ "b_id": "D001", "b_amount": 1500.00, "$original": { "b_amount": 1200.00 } }
// 乙
{ "b_id": "D001", "b_no": "BX-2026-001-R", "$original": { "b_no": "BX-2026-001" } }
```
各自只对自己改的字段做原值比较,互不干扰 → **两个请求都成功**。
这正是"只对变化字段带原值"的意义;如果两边都提交整行快照,就会互相误伤。
### 6.4 原值是 NULL
```json
{ "b_id": "F001", "b_fee_name": "打车费", "$original": { "b_fee_name": null } }
```
```sql
... WHERE t.[b_id] = ? AND t.[b_fee_name] IS NULL
```
传 `null` 生成 `IS NULL`。**不要用空字符串 `""` 表示"原来是空"**——库里很可能是 `NULL`,`= ''` 匹配不到,会误报 2003。
### 6.5 明细新增:主表已审核则拦下
D002 是"审核通过":
```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": "D002", "b_fee_name": "停车费", "b_amount": 30.00 } ]
}
```
→ 0 行 → 回查定位到这条 guard →
```json
{
"code": 2002,
"message": "该单据已审核通过,不能新增费用,本次操作未生效。请刷新后重试。",
"data": { "table": "demo_order_fee", "action": "insert", "index": 0 }
}
```
同样是这条 guard,给 D001(草拟)加费用 → 1 行 → 成功。
### 6.6 多条 guard:定位到具体是哪条
D004 是"送审"但没有任何明细:
```json
{
"table": "demo_order", "key_field": "b_id",
"guards": { "updates": [
{ "condition": "{b_status} = N'送审'", "message": "单据状态已变更,请刷新后重试" },
{ "condition": "{b_fee_count} > 0",
"message": "该单据没有任何费用明细,不能通过审批", "retryable": false }
] },
"updates": [ { "b_id": "D004", "b_status": "审核通过" } ]
}
```
```sql
UPDATE t SET [b_status] = ? FROM dbo.demo_order AS t
WHERE t.[b_id] = ? AND (t.[b_status] = N'送审') AND (t.[b_fee_count] > 0)
```
→ 0 行 → 回查:第 1 条成立、第 2 条不成立 →
```json
{ "code": 2002,
"message": "该单据没有任何费用明细,不能通过审批,本次操作未生效。",
"data": { "table": "demo_order", "action": "update", "index": 0 } }
```
注意 `retryable: false` 去掉了"请刷新后重试"——这个条件刷新一万次也还是没有明细。
### 6.7 guard + `$original` 组合
明细行既要求主表状态,又要求本行金额没被别人改过:
```json
{
"table": "demo_order_fee", "key_field": "b_id",
"guards": { "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": "该单据已审核通过,不能修改费用" }
] },
"updates": [ { "b_id": "F004", "b_order_id": "D003", "b_amount": 4500.00,
"$original": { "b_amount": 4000.00 } } ]
}
```
```sql
UPDATE t SET [b_amount] = ? FROM dbo.demo_order_fee AS t
WHERE t.[b_id] = ? AND t.[b_amount] = ?
AND (not exists (select 1 from dbo.demo_order p
where p.b_id = t.[b_order_id] and p.b_status = N'审核通过'))
```
0 行时**按固定顺序归因**:目标行不存在 → 2001;`$original` 不匹配 → 2003;再逐条回查 guard → 2002。
### 6.8 删除明细:主表已审核 / 已生成发票则拦下
删 F004(属于 D003,送审中),要求主表未审核、且该费用没生成过发票:
```json
{
"table": "demo_order_fee", "key_field": "b_id",
"guards": { "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": "not exists (select 1 from dbo.demo_invoice d where d.b_fee_id = {b_id})",
"refs": ["demo_invoice"], "message": "该费用已生成发票,不能删除", "retryable": false }
] },
"deletes": [ { "b_id": "F004", "b_order_id": "D003" } ]
}
```
> ⚠️ 上面这行**不对**:`deletes` 里多带了 `b_order_id`,会触发 §3.4 的唯一定位校验。
> 正确写法是只给主键,条件里的 `{b_order_id}` 由服务端解析成 `t.[b_order_id]` 去库里取:
> ```json
> "deletes": [ { "b_id": "F004" } ]
> ```
生成:
```sql
DELETE t FROM dbo.demo_order_fee AS t
WHERE t.[b_id] = ?
AND (not exists (select 1 from dbo.demo_order p
where p.b_id = t.[b_order_id] and p.b_status = N'审核通过'))
AND (not exists (select 1 from dbo.demo_invoice d where d.b_fee_id = t.[b_id]))
```
- 主表是"送审"且没发票 → 1 行 → 删除成功
- 主表已审核 → 0 行 → 回查定位到第 1 条 → `2002`,"该单据已审核通过,不能删除费用,本次操作未生效。请刷新后重试。"
- 已生成发票 → 第 1 条通过、第 2 条不成立 → `2002`,"该费用已生成发票,不能删除,本次操作未生效。"(`retryable: false`,刷新也没用)
**为什么 `{b_order_id}` 不用写在 deletes 行里**:delete 的占位符解析成 `t.[列]`,用的是**库里那一行的当前值**,不需要请求方重复传一遍。
### 6.9 账本自增:为什么不能直接写 3
D001 有 2 条明细,`b_fee_count = 2`。甲加机票、乙加餐费,**同时**提交。
#### 直接写 3 会静默丢一次更新
```json
{ "b_id": "D001", "b_fee_count": 3 }
```
| 时刻 | 甲 | 乙 | 库里 `b_fee_count` |
| --- | --- | --- | --- |
| t0 | | | **2** |
| t1 | 读到 2,算 2+1 = **3** | 读到 2,算 2+1 = **3** | 2 |
| t2 | 写入 3 | | **3** |
| t3 | | 写入 3 | **3** ← 应该是 4 |
两条明细都插进去了(明细表实际 4 条),但 `b_fee_count` 停在 3——**账本跟明细对不上**,
而且**没有任何报错**,甲和乙都看到"保存成功"。
这就是丢失更新:各自的 `2 + 1 = 3` 单看都对,合并起来就错了。
> 类比:账户余额 100,两笔并发存款各 50。都"读 100 → 算 150 → 写 150",
> 结果是 150 而不是 200。必须是 `balance = balance + 50`。
#### 三种写法对比
| 写法 | 两次并发结果 | 问题 |
| --- | --- | --- |
| `b_fee_count: 3` | **3(错)** | 静默错,账本漂移,没人知道 |
| `b_fee_count: 3` + `$original: { "b_fee_count": 2 }` | 3(对),**乙方 2003 失败** | 假冲突:甲乙各加一笔、互不影响,却有一方要重试 |
| `b_fee_count: { "$expr": "b_fee_count + 1" }` | **4(对),都成功** | 要求列是数值类型且可写 |
#### 用 `$expr`
```json
[{ "table": "demo_order", "key_field": "b_id",
"updates": [ { "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } } ] }]
```
```sql
UPDATE t SET [b_fee_count] = t.[b_fee_count] + ? FROM dbo.demo_order AS t WHERE t.[b_id] = ?
```
两次执行各自在**当前值**上加 1,谁也不覆盖谁 → 结果 4,两人都成功。
#### 什么时候可以直接写新值
判断标准是:**新值是不是依赖旧值算出来的**。
- **依赖**(累加 / 递减)→ 账本字段,用 `$expr`
- **不依赖**(整块替换)→ 直接写。比如把备注从"差旅"改成"差旅(补录)",
不存在"累加丢失"的问题,加 `$original` 也只是防止别人同时改了它
#### 账本还要能对账
即使用了 `$expr`,也建议定期核对——历史脏数据、或绕过统一入口的写入仍会造成漂移:
```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);
```
**账本能被信任的前提是它能被核对。**
### 6.10 目标行不存在 → 2001
```json
{ "b_id": "F999", "b_amount": 100.00, "$original": { "b_amount": 90.00 } }
```
→ 0 行 → 回查发现目标行根本不在 →
```json
{ "code": 2001, "message": "保存失败: 目标行不存在",
"data": { "table": "demo_order_fee", "action": "update", "index": 0, "cause": "目标行不存在" } }
```
**不会误报成 guard 失败**,也不会带 guard 文案。
### 6.11 多行中途失败 → 整体回滚
```json
"updates": [
{ "b_id": "F001", "b_amount": 300.00 }, // 能成
{ "b_id": "F004", "b_amount": 9999.00, "$original": { "b_amount": 1.00 } } // 原值不对
]
```
第 2 行失败 → **整个请求回滚**,第 1 行的改动也不落库。异常 `data.index = 1` 指向失败的那一行。
### 6.12 参数错误:写库前就拒绝
以下都是 `code: 1000`,**一行都不会写**:
| 写法 | 报错 |
| --- | --- |
| `"guards": { "deletes": [...] }` 同时又传了 `delete_target` | 带 guards.deletes 时不能使用 delete_target,两条路径互斥 |
| `"guards": { "deletes": [...] }` 同时又传了 `dependent_deletes` | 带 guards.deletes 时不能同时声明 dependent_deletes,两条路径互斥 |
| deletes 行多带业务列:`{ "b_id": "F004", "b_order_id": "D003" }` | 带 guards 的删除行必须只携带完整主键 |
| `"guards": { "inserts": "..." }`(不是数组) | guards.inserts 必须是数组 |
| `"guards": { "insert": [...] }`(拼错) | guards 不支持的动作: insert |
| 条件里写 `{b_not_exist}` | guard 占位符字段不存在 |
| 条件里带 `--` 注释 | guard condition 不允许包含注释 |
| insert 行带 `$original` | insert 行不支持 $original |
| `$original: { "b_not_exist": 1 }` | $original 字段不存在 |
| `{ "b_fee_count": "b_fee_count + 1" }` 裸字符串 | (按普通值绑定,报类型错) |
| `{ "b_fee_count": { "$expr": "b_amount + 1" } }` | 表达式只能引用被赋值的列 |
参数错误前置是为了不出现"前几行已写入、第 N 行才报错"。
### 6.13 并发窗口:三种都不是
目标行在、`$original` 也匹配、所有 guard 回查都成立,但更新仍是 0 行——说明回查期间数据又变了。
这种情况**不声称某条 guard 失败**,统一给 2003 通用冲突文案。归因是 best-effort,最坏情况是文案不够精确,**不影响数据正确性**。
---
## 7. 前端集成
### 7.1 错误码怎么处理
| code | 处理 |
| --- | --- |
| `2002` | `http.js` 已自动弹服务端拼好的完整文案并打 `toasted`。**调用点不要再拼前缀**(`Message.error('保存失败:' + ...)` 会造成双重前缀);检查 `error.toasted` 的调用点不会再弹一次。**展示后自动重新拉取当前状态**——用户的本能是再点一次,不刷新会反复失败。 |
| `2003` | `http.js` **不自动弹**。按并发冲突处理:重新拉取当前行、按 `data.fields` 标记冲突字段、保留用户输入,**不自动覆盖也不自动重试**。 |
| `2001` | 普通保存失败,沿用现有提示。 |
### 7.2 `$original` 怎么生成
**必须用与提交值同一个口径**,否则会误报 2003。这是最容易踩的坑。
以 `views/base/othercompany/detail.vue` 为例:表单里时间字段保持本地墙钟串、空串表示空,
`toSubmitRow()` 才把它们转成 UTC 串和 `null`。所以原值也要过一遍同样的转换:
```js
function buildSaveData() {
const { isNew: creating, row } = saveCtx
if (!row) return null
const request = { table: saveTableName(), key_field: keyField.value }
if (creating) {
request.inserts = [toSubmitRow(row)]
return [request]
}
const changes = diffRow({
current: row,
original: maindata_org.value,
primaryKey: keyField.value,
})
if (!changes.hasChanges) return null
// 原值必须走与提交值相同的口径转换(UTC / '' → null / checkbox → 0/1):
// 界面口径的旧值跟库里的值比不上,必然误报 2003
const submitted = toSubmitRow(changes.updates[0])
const original = toSubmitRow(maindata_org.value)
// 只给「本次提交的字段」带原值:主键不参与比较,无关字段带了只会误伤
const expected = {}
for (const field of Object.keys(submitted)) {
if (field !== keyField.value) expected[field] = original[field]
}
request.updates = [{ ...submitted, $original: expected }]
return [request]
}
```
要点:
1. 只给**变化的字段**带原值(`diffRow` 已经算出了差异)
2. 原值过 `toSubmitRow` 与提交值同口径
3. 排除主键(主键不参与原值比较)
4. 公式字段、审计字段(`b_updated_at` 等)、账本字段**不要**带——服务端会改写它们,带了必然误冲突
> `diffRow` 目前还没有自动产出 `$original`(它的 `original` 参数是界面口径,直接拿去用会踩上面的坑)。
> 要自动生成得先把口径统一,不能简单把 `original[key]` 塞进去。
### 7.3 guards 怎么组织
条件文案是业务语义,**收敛到一个函数**,不要散在页面里拼:
```js
// 例:某模块的费用明细动作
export function buildActionRequest(action, row, order) {
const guard = {
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: `该单据已审核通过,不能${ACTION_TEXT[action]}费用`,
}
return {
table: 'demo_order_fee',
key_field: 'b_id',
guards: { [`${action}s`]: [guard] },
[`${action}s`]: [row],
}
}
```
**为什么必须收敛**:guards 是纯 opt-in,服务端不会替调用方补条件。漏传就等于放弃保护,而且**不会有任何提示**。散在页面里迟早会漏。
---
## 8. 边界(用了也拦不住的情况)
1. **不提供单据级串行**。guards 保证的是「写入那一刻条件成立」,不保证整个事务期间状态不变,也不保证两个事务按固定顺序完成。需要严格顺序的场景走专用服务。
2. **快照隔离下 insert 的 guard 可能读到旧值**。`INSERT ... SELECT ... WHERE (子查询)` 的子查询在 RCSI / SNAPSHOT 下读语句快照、不加锁,可能放过已被别人改写的状态。`UPDATE` 不受影响。**上线前先确认数据库隔离级别。**
3. **guards 不解决重复提交**。同一个请求原样重放两次,guard 两次都可能成立。幂等要另做(§1.3)。
4. **删除的 guards 只走唯一定位路径**。删除行必须只带完整主键,且不能与 `delete_target` / `dependent_deletes` 同用(§3.4);删除服务本身不支持 guards。
5. **跨行 / 聚合条件要先物化**。`count(*)`、`sum()` 这类现算的判据没有行可锁,guard 拦不住;要先物化成表上的一个字段(设计 §8.2)。