Files
workspace/code/fms/FMS通用保存接口使用说明.md
2026-09-27 21:59:13 +08:00

643 lines
25 KiB
Markdown
Raw Permalink 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通用保存接口条件更新设计》(机制与取舍)
> 本文只讲**怎么用**:请求怎么写、服务端生成什么 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)。