# 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)。