25 KiB
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):
- 删除行必须只携带完整主键。复合键要齐全,不能混入业务列做成批量条件——
批量删除带 guard 时「部分行满足该删哪些」没有合理语义,0 行时也无从归因到具体行。
"deletes": [ { "b_id": "F004" } ] // ✓ 只有主键 - 不能同时走删除旁路。带
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]
}
要点:
- 只给变化的字段带原值(
diffRow已经算出了差异) - 原值过
toSubmitRow与提交值同口径 - 排除主键(主键不参与原值比较)
- 公式字段、审计字段(
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. 边界(用了也拦不住的情况)
- 不提供单据级串行。guards 保证的是「写入那一刻条件成立」,不保证整个事务期间状态不变,也不保证两个事务按固定顺序完成。需要严格顺序的场景走专用服务。
- 快照隔离下 insert 的 guard 可能读到旧值。
INSERT ... SELECT ... WHERE (子查询)的子查询在 RCSI / SNAPSHOT 下读语句快照、不加锁,可能放过已被别人改写的状态。UPDATE不受影响。上线前先确认数据库隔离级别。 - guards 不解决重复提交。同一个请求原样重放两次,guard 两次都可能成立。幂等要另做(§1.3)。
- 删除的 guards 只走唯一定位路径。删除行必须只带完整主键,且不能与
delete_target/dependent_deletes同用(§3.4);删除服务本身不支持 guards。 - 跨行 / 聚合条件要先物化。
count(*)、sum()这类现算的判据没有行可锁,guard 拦不住;要先物化成表上的一个字段(设计 §8.2)。