架构解释图确定性渲染一次返回码如何穿过事务归属、安全代理与嵌套写入摘要一个接口引擎先更新库存表再写流水表。校验没过脚本返回失败库存却已经落库。这不是回滚失效那次更新压根没参加这次请求的事务。表单引擎和嵌套接口引擎在拿不到事务参数时会各自开一个事务并各自提交真正决定收场的是事务归属和第三个位置参数 V8.DbTrans。✦01现象外层返回失败前面那次写入还在一个接口引擎先更新库存表再写入一条流水记录。库存更新返回成功流水写入因为唯一键冲突返回非 1脚本走到末尾返回Code 为 0和一句错误提示。调用方收到失败回头再查库存数量已经变了。返回码、日志、HTTP 状态都正常看起来只有回滚没生效。可平台确实执行了回滚动作只是它回收的范围比写脚本的人以为的小得多。单看脚本失败分支没有写错非 1 就返回失败逻辑清楚。单看平台返回码也确实按失败处理非 1 会触发一次回滚。对不上的是两者管的写入不是同一批回滚发生在某一层事务里而那次更新已经不在这一层。现象定位失败是失败的回滚也是回滚的只是回滚覆盖不到那次已经自行提交的写入。把它当成“平台没回滚”会一直往错误的方向找。✦02直觉方案为什么不够Code 不是全局开关第一反应通常是返回码语义没生效。但平台的判定很直接返回DosResult或带 Code 的对象时Code 等于 1 提交其它值回滚返回对象却没有 Code同样按回滚处理。这几条写在官方服务端 V8 文档里与当前实现一致。第二反应是“一个接口引擎只有一个事务”。恰恰相反嵌套调用可以各自拥有事务表单引擎和嵌套的接口引擎在拿不到事务参数时都会自己开一个并在自己这一层提交。外层引擎的事务里只有它自己发起的语句没有表单引擎代它执行的那些。表单引擎自己开的事务在执行返回前就结束了外层之后再回滚也回收不到。只有把事务对象顺着调用链传下去这些写入才会落进同一个事务。结论Code 决定的是“持有事务的那一层”怎么收场不是“这一次请求”怎么收场。事务有几个收场就有几次返回值只影响最外层那一个。✦03调用链一次请求里事务到底归谁接口引擎从 HTTP 进入平台后先取租户主库会话再决定事务。当前实现的写法很朴素有传入事务就复用没有就新开一个执行结束后的清理再按“是否由本次调用拥有”决定释放。引擎把事务包成安全代理再交给脚本所以脚本里的 V8.DbTrans 不是真实事务对象。代理会拦下 Commit、Rollback、Close 三种调用各记一条诊断日志脚本无法自己提前收场。架构解释图确定性渲染一次请求里的五段入口、归属判定、安全代理、跨表写入、单次收场易混点安全代理是一个可传递的入口不是可管理的事务句柄。它能被当成第三个参数交出去但不能被收场收场只有一次机会且只在拥有它的那一层。✦04源码事实三处守卫决定怎么收场提交和回滚被同一个条件挡住本次调用不拥有事务时既不提交也不回滚。这条判断在接口引擎里出现三次——Code 等于 1 的提交、Code 非 1 的回滚、以及抛出异常时的回滚三处都带同一个归属检查。返回可识别的 DosResult 或带 Code 的对象Code 等于 1 提交其它值回滚。返回普通对象但没有 Code按回滚处理避免忘记带状态就误提交。返回字符串、数字、数组、布尔值或 null且脚本没有抛异常默认提交。显式传入了外层事务提交与回滚都由外层调用者决定。表单引擎的修改路径把这条规则写得更直白函数入口先记一个“是否外部事务”随后执行的 UPDATE 显式跑在同一个事务上收尾时只有“不是外部事务”才提交或回滚。异常路径同样带这个前置条件。架构解释图确定性渲染归属与返回码的四种组合以及各自的真实收场源码边界事务归属在函数入口就确定了有外部事务就是外部事务没有就自己开。后面所有判断都只是复用它不会中途改变。✦05第三个位置参数把跨表写入并进同一事务修正并不复杂把 V8.DbTrans 作为第三个位置参数传给每一次表单引擎调用和嵌套的接口引擎调用。当前接口签名本身就支持这件事更新、删除、按条件更新、批量方法与接口引擎调用都接受第三个事务参数。// 有问题的写法两次写入各自开事务、各自提交 var stock V8.FormEngine.UptFormData(Shop_Stock, { Id: stockId, Qty: left }); if (stock.Code ! 1) return { Code: 0, Msg: 库存扣减失败 }; var log V8.FormEngine.AddFormData(Shop_StockLog, { StockId: stockId, Change: -1 }); if (log.Code ! 1) return { Code: 0, Msg: 流水写入失败 }; return { Code: 1 }; // 第二次失败时第一次扣减已经自行提交外层回滚带不走它// 修正写法两次写入都显式加入当前接口引擎的事务 var stock V8.FormEngine.UptFormData(Shop_Stock, { Id: stockId, Qty: left }, V8.DbTrans); if (stock.Code ! 1) return { Code: 0, Msg: 库存扣减失败 }; var log V8.FormEngine.AddFormData(Shop_StockLog, { StockId: stockId, Change: -1 }, V8.DbTrans); if (log.Code ! 1) return { Code: 0, Msg: 流水写入失败 }; // 只有走到这里才提交上面任何一次提前 return 都会带两次写入一起回滚 return { Code: 1, Data: { StockId: stockId } };两段代码的差别不在长短而在事务数量前者是三个事务各自收场后者是一个事务一次收场。事务参数一旦传下去写入就只是排队真正决定命运的只有最外层那一次返回。最小改动不需要 try/catch也不需要自己补回滚调用。要做的是把同一个事务对象传下去并让每一条提前返回都带上非 1 的状态码。✦06聚焦验证40 条源码断言与本地事务契约文中的全部实现性描述都绑定到当前工作区源码的具体行号。我用一个探针脚本逐行读取实现文件断言 40 处关键结构确实出现在断言行上全部一致才返回 0任何一行被改动探针会直接指出期望值与实际值。事务对象本身的行为另跑了一组本地聚焦测试安全代理把提交后回调注册到框架持有的事务上、回滚时清空回调、重复提交真实事务会抛异常V8 返回值的两种写法与优先级也在同一组用例里被断言。两条命令的原始输出都留在证据目录。# 逐行断言文章引用的 40 处实现结构退出码 0 才继续 python -X utf8 verify-tx-boundary.py --out evidence/tx-boundary-probe.json # 事务代理与 V8 返回码语义的聚焦测试 dotnet test Microi.Server/Microi.Tests/Microi.Tests.csproj -c Release \ --filter FullyQualifiedName~DbTransAfterCommitTests|FullyQualifiedName~V8ResultAssignmentTests本地验证截图源码断言探针与事务契约测试的真实输出指标源码事实40 条断言在断言行上逐条命中退出码 0。本地验证事务契约与返回值语义的聚焦测试通过退出码 0。未验证真实数据库上跨表回滚的锁等待与提交时序需要连库环境才能观测。证据等级这组验证不连接数据库证明的是事务归属规则与返回码语义本身不是线上行为。把本地通过当成生产结论是另一类误判。✦07失败与恢复边界四种收场方式把边界摊开收场只有四种整批提交、整批回滚、局部已提交、事务尚未收场。第四种最隐蔽通常来自有提前返回却没带状态码的分支事务悬在那里由外层清理决定命运。脚本抛出异常走回滚分支返回失败与堆栈信息。返回 Code 为非 1回滚并把该返回值原样交给调用方。返回带字段但没有 Code 的对象按回滚处理避免误提交。不返回任何值或返回标量默认提交即使中间某次写入已经失败。表单后端事件还有一道额外门只有调用方把 InvokeType 标成 Client提交前与提交后事件才会执行。默认的 Server 调用不触发表单事件写在事件里的 Code 校验在那种调用下等于不存在。// 表单事件里的跨表写入同样要带 V8.DbTrans失败必须返回非 1 var r V8.FormEngine.UptFormData(Other_Table, { Id: V8.Form.Id, Locked: 1 }, V8.DbTrans); if (r.Code ! 1) { return { Code: 0, Msg: 锁定失败主表提交一并回滚 }; } return { Code: 1 };恢复边界想被回滚的写入必须带事务参数想中止的提前返回必须带状态码。两件事缺任何一件都会得到同一个结果局部已提交而且不报错。✦08生产取舍默认提交是有意的默认提交不是疏忽而是兼容选择。平台上有大量只做查询或只做单次写入的脚本如果强行要求每次返回都写 Code历史代码会成片失败。于是规则变成看得懂返回码就按返回码看不懂就提交。代价是安全性从“默认拒绝”变成“默认接受”。跨表写入、扣减与流水、主表与明细、状态机跳转这类场景必须自己显式传递事务参数并保证每一条提前返回都带状态码。同一业务动作涉及多张表时统一使用第三个位置参数传入同一个 V8.DbTrans。把失败收口写成一个统一出口禁止在中间分支裸 return 或只 return 一句提示语。需要表单后端事件参与校验的调用方显式标注 Client 模式不需要时不要打开它。取舍平台把事务生命周期的所有权收在框架手里换来脚本永远无法误提交代价是每一次跨表写入都要自己声明加入哪个事务。Microi吾码AI 把这条规则放在官方服务端 V8 文档的第一个示例里正是因为漏掉它是这类故障最常见的起点。文中已标注的概念图由AI生成源码与实测证据均来自当前工作区。
