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

25 KiB
Raw Blame History

FMS 通用保存接口(saveobjt)使用说明

配套文档:《FMS通用保存接口条件更新设计》(机制与取舍) 本文只讲怎么用:请求怎么写、服务端生成什么 SQL、各种情况下返回什么。 示例表沿用设计文档附录 A 的 demo_order(报销单主表)/ demo_order_fee(费用明细)。


0. 一句话

saveobjt 原本是按主键盲写。现在可以给写入附加两类东西:

附加物 作用 位置
guards 业务前置条件("只有草拟状态才能改") 表块级,按动作分开
$original 并发冲突检测("改的时候它还得是我看到的那个值") 更新行内

两者都是 opt-in。不传,行为与以前完全一致。


1. 请求格式速查

[
  {
    "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 基本用法

{
  "b_id": "D001",
  "b_amount": 220.00,
  "$original": { "b_amount": 200.00 }
}

生成(实际语句):

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:
    { "b_id": "F001", "b_fee_name": "打车费", "$original": { "b_fee_name": null } }
    
    ... WHERE t.[b_id] = ? AND t.[b_fee_name] IS NULL
    
  • 字段名必须是表里真实存在的列,否则在写库之前就报参数错误(不会写一半再失败)。
  • inserts 行不支持 $original(新增的行没有原值可比),传了报参数错误。

2.3 整行版本锁

$original 里放 b_row_version 即切换为整行语义:

{
  "b_id": "D001",
  "b_status": "审核通过",
  "$original": { "b_row_version": 7 }
}
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 基本用法

{
  "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 } ]
}

生成:

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 行时也无从归因到具体行。
    "deletes": [ { "b_id": "F004" } ]   // ✓ 只有主键
    
  2. 不能同时走删除旁路。带 guards.deletes 时,delete_target 非空 或 声明了 dependent_deletes 都会直接报错——那两条路走 DataDeleteService,拿不到 guards,混用会让条件被静默忽略。

删子表明细时不会触发依赖删除:依赖删除的语义是「删本表时连带删引用它的行」, 方向是父 → 子(删主表才连带删子表)。所以「删明细时校验主表状态」与旁路天然不冲突。

另外条件里不允许注释(-- / /* */),有注释直接报参数错误。


4. $expr:账本自增

账本字段(b_fee_count / b_amount 这类汇总值)不能用"读旧值 → 算新值 → 写新值"的方式更新:

  • 直接写新值 → 并发时静默丢失一次更新(账本停在错的值且不报错)
  • 用 $original 做 CAS → 会产生假冲突(两笔费用互不影响,却有一方失败)

表达式值把「读 → 算 → 写」三步压成数据库端的一步,两个问题一起解决(完整推演见 §6.9):

{ "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } }
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 普通编辑,什么都不带

[{ "table": "demo_order_fee", "key_field": "b_id",
   "updates": [ { "b_id": "F001", "b_fee_name": "打车费(往返)" } ] }]
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):

[{ "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 不匹配 →

{
  "code": 2003,
  "message": "该数据已被其他操作修改,本次操作未生效,请刷新后重试。",
  "data": { "table": "demo_order", "action": "update", "index": 0, "fields": ["b_amount"] }
}

fields 告诉前端是哪个字段冲突,用于重新拉取后高亮。

6.3 甲改金额、乙改备注 → 都成功

// 甲
{ "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

{ "b_id": "F001", "b_fee_name": "打车费", "$original": { "b_fee_name": null } }
... WHERE t.[b_id] = ? AND t.[b_fee_name] IS NULL

传 null 生成 IS NULL。不要用空字符串 "" 表示"原来是空"——库里很可能是 NULL,= '' 匹配不到,会误报 2003。

6.5 明细新增:主表已审核则拦下

D002 是"审核通过":

{
  "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 →

{
  "code": 2002,
  "message": "该单据已审核通过,不能新增费用,本次操作未生效。请刷新后重试。",
  "data": { "table": "demo_order_fee", "action": "insert", "index": 0 }
}

同样是这条 guard,给 D001(草拟)加费用 → 1 行 → 成功。

6.6 多条 guard:定位到具体是哪条

D004 是"送审"但没有任何明细:

{
  "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": "审核通过" } ]
}
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 条不成立 →

{ "code": 2002,
  "message": "该单据没有任何费用明细,不能通过审批,本次操作未生效。",
  "data": { "table": "demo_order", "action": "update", "index": 0 } }

注意 retryable: false 去掉了"请刷新后重试"——这个条件刷新一万次也还是没有明细。

6.7 guard + $original 组合

明细行既要求主表状态,又要求本行金额没被别人改过:

{
  "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 } } ]
}
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,送审中),要求主表未审核、且该费用没生成过发票:

{
  "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] 去库里取:

"deletes": [ { "b_id": "F004" } ]

生成:

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 会静默丢一次更新

{ "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

[{ "table": "demo_order", "key_field": "b_id",
   "updates": [ { "b_id": "D001", "b_fee_count": { "$expr": "b_fee_count + 1" } } ] }]
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,也建议定期核对——历史脏数据、或绕过统一入口的写入仍会造成漂移:

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

{ "b_id": "F999", "b_amount": 100.00, "$original": { "b_amount": 90.00 } }

→ 0 行 → 回查发现目标行根本不在 →

{ "code": 2001, "message": "保存失败: 目标行不存在",
  "data": { "table": "demo_order_fee", "action": "update", "index": 0, "cause": "目标行不存在" } }

不会误报成 guard 失败,也不会带 guard 文案。

6.11 多行中途失败 → 整体回滚

"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。所以原值也要过一遍同样的转换:

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 怎么组织

条件文案是业务语义,收敛到一个函数,不要散在页面里拼:

// 例:某模块的费用明细动作
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)。