动态表单最危险的发布事故,不是"按钮点不动",而是按钮显示发布成功后,业务入口拿到了不可用的新版本:发起页填不了新增必填项,审批页却能修改申请内容,旧单详情又被新版字段规则拦住。只看设计器预览,甚至只用一张新单做冒烟测试,都可能漏掉这些问题。
本文用一组固定的虚构申请单做发布验收。甲公司 orgA 的 expense_request 当前为 v1,旧单 R100/v1 已提交,草稿 D200/v1 仍开放;乙公司 orgB 的同名表单也是 v1。甲公司候选 v2 新增必填成本中心和明细。我们先故意放进三处配置错误,确认发布被拦截且当前指针不变;修复后再发布,检查旧单回显、旧草稿冲突、新单 R101/v2 与乙公司隔离。Java 11 示例可直接运行,SQL 使用 MySQL 8.x 的独立教学表;它们是公开教学示例,不是某个产品的真实代码。
目录
- 先明确发布事故和验收目标
- 固定四份样本与验收矩阵
- 发布前怎样检查字段与视图兼容
- 发布事务怎样验证回滚与并发
- 发布后怎样回归旧单与新单
- [Java 示例与十五项断言](#Java 示例与十五项断言)
- [MySQL 样本、发布操作与核对查询](#MySQL 样本、发布操作与核对查询)
- 上线门槛、回退边界与参考资料
一、先明确发布事故和验收目标
候选 v2 的字段集合是 applicant、purpose、costCenter、lines。其中申请人由服务端提供,另外三项要由用户在发起页填写。为了测试验收机制,初始 v2 配置故意有三处缺陷:
| 缺陷 | 具体表现 | 若未拦截会怎样 | 正确验收结果 |
|---|---|---|---|
| 发起视图漏项 | costCenter 必填,却不在可填写集合 |
新申请无法满足服务端必填规则 | 阻止发布,报告缺失字段 |
| 审批视图越界 | purpose 被设成可写 |
审批人可能改写原申请事实 | 阻止发布,审批业务字段只读 |
| 详情视图错引 | 引用不存在的 legacyCode |
详情页可能报错或静默丢字段 | 阻止发布,指出无效引用 |
这三项都属于发布阻断项 ,不是可以上线后再看的普通提醒。预期结果是 v2 仍为 DRAFT,甲公司的当前发布版仍为 v1,R100 和 D200 不受影响。若系统只展示一张警告列表,用户仍可按"继续发布",这次验收就没有形成真正的门槛。
把配置修好以后,成功也要有可验证结果:新建入口读到 v2,旧单仍能按 v1 展示;D200/v1 直接提交应得到版本冲突且不生成半张 R101;用户确认刷新并补齐成本中心、明细后,R101 才绑定 v2;乙公司仍是 v1。发布验收同时包含允许路径 和拒绝路径。

图1:三处故意设置的错误必须阻止 v2 发布,甲公司指针与旧版数据保持不变。
二、固定四份样本与验收矩阵
发布之前先建立可重复的样本,避免每次临时找一张单据随手点开。样本应覆盖不同时间点和组织边界:
| 样本 | 发布前 | v2 成功发布后 | 应保存的证据 |
|---|---|---|---|
R100,甲公司旧单 |
v1 已提交,申请人 u07、用途"差旅" |
仍绑定 v1,详情标签仍是"用途" | 单据版本、详情结果、历史字段值 |
D200,甲公司旧草稿 |
v1 打开,状态 OPEN |
未确认刷新时提交被拒,状态仍开放 | 冲突响应、无新增正式单据 |
R101,甲公司新单 |
尚不存在 | 确认刷新后按 v2 提交,含 costCenter=C10 和明细 |
v2 版本号、字段值、唯一正式记录 |
B100,乙公司旧单 |
orgB 的 v1 已提交 |
乙公司当前版仍 v1,详情不变 | 组织与版本查询、接口隔离结果 |
验收按三层安排:先做静态配置检查 ,证明候选定义和视图可以组成可用版本;再做事务检查 ,证明指针与版本状态一起生效或一起回滚;最后做运行回归,用旧单、旧草稿和新单检查真实接口及数据库副作用。三层分别回答"能不能发""是否原子生效""生效后业务有没有乱"。
表单定义版本与草稿修订号不要混淆。D200 的 form_version=1 表示它是按 v1 打开的,不会因为页面编辑一次就自动变成 2。改版影响新提交的规则,但旧单据的历史解释仍由提交时绑定的版本决定。预期结果要写进验收记录;没有明确输入和预期,发布后"我试了一下好像没问题"无法复查。

图2:每一层使用固定样本和明确结果,不把设计器预览当作完整验收。
三、发布前怎样检查字段与视图兼容
发布门槛先看定义自身:字段编码不能重复;必填字段必须存在于定义中;类型、长度、枚举和明细子表规则必须可由服务端执行;定义 JSON 与视图 JSON 必须可解析。结构校验可以借助 JSON Schema 的 required、属性类型和额外字段限制,但它无法代替"谁填写这个字段、哪个场景可编辑"的业务检查。JSON Schema 对象规则
接着检查视图与定义的关系。发起视图的可写集合,至少要覆盖所有由用户负责的必填字段 。申请人虽必填,却由服务端会话生成,不要求用户填写;成本中心与明细由用户提供,因此缺一项就不能发布。审批视图可以显示业务字段,但不能把它们列为可写;审批决定应由独立命令保存。详情视图引用的字段必须存在于该版本定义中,不能出现 legacyCode 这类悬空引用。
"兼容"也要说清对象。v2 新增必填成本中心,对新建申请 可以是合理改动,对已经打开的 D200/v1 却不是无感兼容。本文选用严格策略:发布后旧草稿直接提交被拒,用户明确确认刷新、补齐新字段后再提交。已提交的 R100/v1 继续按 v1 读取,不因 v2 新增必填而被重新验证。若产品要允许短期旧版草稿继续提交,也必须把宽限期、服务端版本判断和停止条件写成另一条明确策略,不能由缓存偶然决定。
最后检查发布范围。表单编码可以在不同组织重复,甲公司发布 expense_request/v2 不能更新乙公司同名资产。发布请求携带的预期旧版本必须与数据库中的甲公司当前指针一致;若另一个管理员已发布过新版,当前请求应返回冲突,而不是按旧页面覆盖指针。
四、发布事务怎样验证回滚与并发
静态检查通过后,发布仍不是一次普通的"改状态"操作。发布事务至少要锁定甲公司表单资产,核对当前指针为 v1,锁定候选 v2 并确认仍是通过检查的 DRAFT,然后把 v2 标记已发布、把资产指针切到 v2。两次写入任何一次失败都必须回滚,不能留下"v2 标记已发布但新建入口仍指向 v1",更不能留下"指针已指向 v2,但定义还不可用"。MySQL InnoDB 的锁定读取与事务提供了实现这一顺序的基础。MySQL 锁定读取
验收时应主动制造第二步写入失败:先在事务内把候选状态改为 PUBLISHED,在切指针之前触发失败并回滚,再查询版本状态与资产指针,必须仍是 DRAFT/v1。随后运行成功事务,查询得到 PUBLISHED/v2。教学 Java 程序用异常和状态恢复演示同一个判断;真正跨进程和数据库故障只能用数据库事务测试,不能靠 synchronized 或内存恢复来保证。
两位管理员同时发布也是验收项。条件更新带 expectedVersion=1,影响行数不是 1 就作为冲突回滚。发布操作还应有审计记录,记录组织、表单编码、候选版本、预期旧版、操作者、时间和结果;但日志不能代替数据库事实。即使日志写了"成功",仍要查当前指针与版本状态是否一致。

图3:模拟第二次写入失败后整体回滚,再验证一次正常的 v1 到 v2 原子切换。
五、发布后怎样回归旧单与新单
正式发布以后,不要只用一个新建页面验证。先打开 R100:详情应使用它记录的 form_version=1,显示 v1 的"用途:差旅",不出现空的"成本中心必填"。接着从旧页面直接提交 D200/v1:接口应返回明确的版本冲突,例如 FORM_VERSION_STALE,草稿仍是 OPEN,数据库中此时没有 R101。这一步要查副作用,不能只看前端弹窗。
用户明确刷新 D200 后,页面按 v2 展示成本中心和明细。缺 costCenter 的提交仍要被 v2 规则拒绝;补齐 costCenter=C10 和至少一行明细后生成唯一的 R101/v2。再检查审批页业务字段只读、详情页按 v2 展示,以及乙公司 B100/v1 与当前发布指针均未改变。这样一组回归覆盖了旧单读、新稿写、旧稿拒绝、跨组织隔离四条不同路径。
若某个环节失败,不能简单把 v2 文件覆盖为 v1。已经提交的 R101/v2 必须继续能按 v2 解释。修复方案可以是暂停新建入口、把未来新单路由回仍可用的 v1(前提是业务允许),或发布修正后的 v3;具体回退动作应在上线前演练。历史版本本身保持只读,不原位修改已提交单据的版本号。

图4:旧单、旧草稿、新单和乙公司样本分别验证历史解释、拒绝路径、新规则和组织边界。
六、Java 示例与十五项断言
下面的 Java 11 程序固定 v1 与故意出错的候选 v2。它先找出三个阻断项,证明无效版本不能发布;把配置修好后模拟一次写入失败,再完成发布和业务回归。保存为 Stage9FormReleaseAcceptanceExample.java,执行 java Stage9FormReleaseAcceptanceExample.java。代码是单进程规则演示:历史单与草稿用内存对象表示,实际系统需以事务、版本化数据和接口测试落实。
java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Runnable, synthetic release acceptance contract. Java 11. */
public final class Stage9FormReleaseAcceptanceExample {
static final class Revision {
final int number;
String status;
final Set<String> fields, required, serverSupplied;
final Set<String> startWritable, approveWritable, detailVisible;
final String purposeLabel;
Revision(int number, String status, Set<String> fields, Set<String> required,
Set<String> serverSupplied, Set<String> startWritable,
Set<String> approveWritable, Set<String> detailVisible,
String purposeLabel) {
this.number = number; this.status = status;
this.fields = Set.copyOf(fields); this.required = Set.copyOf(required);
this.serverSupplied = Set.copyOf(serverSupplied);
this.startWritable = Set.copyOf(startWritable);
this.approveWritable = Set.copyOf(approveWritable);
this.detailVisible = Set.copyOf(detailVisible);
this.purposeLabel = purposeLabel;
}
}
static final class Draft {
int version = 1;
String status = "OPEN";
}
static final class Submission {
final int version;
final Map<String, Object> data;
Submission(int version, Map<String, Object> data) {
this.version = version; this.data = Map.copyOf(data);
}
}
private final Map<Integer, Revision> aVersions = new LinkedHashMap<>();
private final Map<String, Submission> submissions = new LinkedHashMap<>();
private final Draft d200 = new Draft();
private int aActive = 1, bActive = 1;
private static final Set<String> V1 = Set.of("applicant", "purpose");
private static final Set<String> V2 = Set.of(
"applicant", "purpose", "costCenter", "lines");
Stage9FormReleaseAcceptanceExample() {
aVersions.put(1, new Revision(1, "PUBLISHED", V1, V1,
Set.of("applicant"), Set.of("purpose"), Set.of(), V1, "用途"));
aVersions.put(2, new Revision(2, "DRAFT", V2, V2,
Set.of("applicant"), Set.of("purpose", "lines"),
Set.of("purpose"), Set.of("applicant", "purpose", "legacyCode"),
"申请事由"));
submissions.put("R100", new Submission(1,
Map.of("applicant", "u07", "purpose", "差旅")));
}
List<String> preflight(Revision r) {
List<String> errors = new ArrayList<>();
if (!r.fields.containsAll(r.required)) errors.add("REQUIRED_UNKNOWN");
Set<String> mustInput = new java.util.HashSet<>(r.required);
mustInput.removeAll(r.serverSupplied);
if (!r.startWritable.containsAll(mustInput)) errors.add("START_MISSING_REQUIRED");
if (!r.fields.containsAll(r.startWritable)) errors.add("START_UNKNOWN_FIELD");
if (!r.approveWritable.isEmpty()) errors.add("APPROVE_WRITES_BUSINESS");
if (!r.fields.containsAll(r.detailVisible)) errors.add("DETAIL_UNKNOWN_FIELD");
return errors;
}
synchronized void publish(int expected, boolean failAfterMarkingRevision) {
Revision next = aVersions.get(2);
if (aActive != expected || next == null || !"DRAFT".equals(next.status))
throw new IllegalArgumentException("PUBLISH_CONFLICT");
if (!preflight(next).isEmpty())
throw new IllegalArgumentException("PREFLIGHT_FAILED");
// Simulates a database transaction. Real code must use InnoDB rollback.
int oldPointer = aActive;
String oldStatus = next.status;
try {
next.status = "PUBLISHED";
if (failAfterMarkingRevision)
throw new IllegalStateException("INJECTED_WRITE_FAILURE");
aActive = 2;
} catch (RuntimeException error) {
next.status = oldStatus;
aActive = oldPointer;
throw error;
}
}
synchronized Submission submitD200(Map<String, Object> input) {
if (!"OPEN".equals(d200.status))
throw new IllegalArgumentException("DRAFT_CLOSED");
if (d200.version != aActive)
throw new IllegalArgumentException("FORM_VERSION_STALE");
Revision r = aVersions.get(d200.version);
if (!r.fields.containsAll(input.keySet()) || !input.keySet().containsAll(r.required)
|| !(input.get("lines") instanceof List)
|| ((List<?>) input.get("lines")).isEmpty())
throw new IllegalArgumentException("INVALID_INPUT");
Submission saved = new Submission(d200.version, input);
submissions.put("R101", saved);
d200.status = "SUBMITTED";
return saved;
}
void refreshAfterUserConfirms() {
if (!"OPEN".equals(d200.status))
throw new IllegalArgumentException("DRAFT_CLOSED");
d200.version = aActive;
}
String oldPurposeLabel() {
Submission old = submissions.get("R100");
return aVersions.get(old.version).purposeLabel;
}
private static int passed;
private static void check(String name, boolean ok) {
if (!ok) throw new AssertionError(name);
passed++; System.out.println(name + " PASS");
}
private static boolean fails(Runnable action, String code) {
try { action.run(); return false; }
catch (RuntimeException error) { return code.equals(error.getMessage()); }
}
public static void main(String[] args) {
Stage9FormReleaseAcceptanceExample app = new Stage9FormReleaseAcceptanceExample();
Revision candidate = app.aVersions.get(2);
check("C01 three preflight blockers found", app.preflight(candidate).size() == 3);
check("C02 invalid release rejected", fails(() -> app.publish(1, false),
"PREFLIGHT_FAILED") && app.aActive == 1
&& "DRAFT".equals(candidate.status));
app.aVersions.put(2, new Revision(2, "DRAFT", V2, V2,
Set.of("applicant"), Set.of("purpose", "costCenter", "lines"),
Set.of(), V2, "申请事由"));
check("C03 corrected candidate passes preflight",
app.preflight(app.aVersions.get(2)).isEmpty());
check("C04 stale expected version rejected", fails(() -> app.publish(0, false),
"PUBLISH_CONFLICT"));
check("C05 injected failure rolls back both writes", fails(() -> app.publish(1, true),
"INJECTED_WRITE_FAILURE") && app.aActive == 1
&& "DRAFT".equals(app.aVersions.get(2).status));
app.publish(1, false);
check("C06 A points to published v2", app.aActive == 2
&& "PUBLISHED".equals(app.aVersions.get(2).status));
check("C07 B remains v1", app.bActive == 1);
check("C08 R100 still binds v1", app.submissions.get("R100").version == 1);
check("C09 old label remains 用途", "用途".equals(app.oldPurposeLabel()));
check("C10 stale D200 rejected without result", fails(() -> app.submitD200(
Map.of("applicant", "u08", "purpose", "采购")), "FORM_VERSION_STALE")
&& !app.submissions.containsKey("R101")
&& "OPEN".equals(app.d200.status));
app.refreshAfterUserConfirms();
check("C11 confirmed refresh selects v2", app.d200.version == 2);
check("C12 missing cost center rejected", fails(() -> app.submitD200(
Map.of("applicant", "u08", "purpose", "采购",
"lines", List.of("设备"))), "INVALID_INPUT"));
Submission current = app.submitD200(Map.of("applicant", "u08",
"purpose", "采购", "costCenter", "C10", "lines", List.of("设备")));
check("C13 R101 binds v2", current.version == 2
&& "SUBMITTED".equals(app.d200.status));
check("C14 approval view cannot write business fields",
app.aVersions.get(2).approveWritable.isEmpty());
check("C15 duplicate publication is rejected", fails(() -> app.publish(1, false),
"PUBLISH_CONFLICT") && app.aActive == 2);
System.out.println("TOTAL " + passed + "/15 PASS");
}
}
末行应是 TOTAL 15/15 PASS。C01/C02 证明错误候选被拦住且状态不变;C05 检查模拟故障后的两处写入都回退;C06/C07 核对甲、乙组织版本分离;C08/C09 检查旧单版本与标签;C10 检查旧草稿被拒而没有生成 R101;C11---C13 检查明确刷新与 v2 提交;C14/C15 检查审批字段边界和重复发布冲突。示例只演示 lines 非空,真实发布检查还须验证明细行结构、类型与金额规则。
七、MySQL 样本、发布操作与核对查询
下面使用独立教学库中的 MySQL 8.x 表。为突出发布验收,版本配置合并在一张表中;正式单据通过 (org_id, form_code, form_version) 指向具体版本。资产指针没有直接做反向外键,以免与版本归属形成循环,因此指针是否指向已发布版本 要在发布事务和验收查询里明确检查。MySQL 外键约束
sql
CREATE TABLE qa_form_asset (
org_id VARCHAR(20) NOT NULL,
form_code VARCHAR(40) NOT NULL,
active_version INT NOT NULL,
PRIMARY KEY (org_id, form_code)
) ENGINE=InnoDB;
CREATE TABLE qa_form_revision (
org_id VARCHAR(20) NOT NULL,
form_code VARCHAR(40) NOT NULL,
version_no INT NOT NULL,
status VARCHAR(16) NOT NULL,
field_codes JSON NOT NULL,
required_codes JSON NOT NULL,
server_supplied JSON NOT NULL,
start_writable JSON NOT NULL,
approve_writable JSON NOT NULL,
detail_visible JSON NOT NULL,
purpose_label VARCHAR(40) NOT NULL,
PRIMARY KEY (org_id, form_code, version_no),
FOREIGN KEY (org_id, form_code)
REFERENCES qa_form_asset(org_id, form_code)
) ENGINE=InnoDB;
CREATE TABLE qa_form_draft (
org_id VARCHAR(20) NOT NULL,
draft_id VARCHAR(24) NOT NULL,
form_code VARCHAR(40) NOT NULL,
form_version INT NOT NULL,
status VARCHAR(16) NOT NULL,
PRIMARY KEY (org_id, draft_id),
FOREIGN KEY (org_id, form_code, form_version)
REFERENCES qa_form_revision(org_id, form_code, version_no)
) ENGINE=InnoDB;
CREATE TABLE qa_form_submission (
org_id VARCHAR(20) NOT NULL,
request_id VARCHAR(24) NOT NULL,
form_code VARCHAR(40) NOT NULL,
form_version INT NOT NULL,
payload JSON NOT NULL,
PRIMARY KEY (org_id, request_id),
FOREIGN KEY (org_id, form_code, form_version)
REFERENCES qa_form_revision(org_id, form_code, version_no)
) ENGINE=InnoDB;
插入甲、乙公司资产,甲公司 v1、出错的 v2、乙公司 v1,以及旧单与旧草稿。以下数据使用 JSON 数组表达字段集合,便于把阻断项查出来;实际项目仍要检查字段类型、子表、默认值与视图组件等完整定义。
sql
INSERT INTO qa_form_asset VALUES
('orgA','expense_request',1), ('orgB','expense_request',1);
INSERT INTO qa_form_revision VALUES
('orgA','expense_request',1,'PUBLISHED',
'["applicant","purpose"]','["applicant","purpose"]',
'["applicant"]','["purpose"]','[]',
'["applicant","purpose"]','用途'),
('orgA','expense_request',2,'DRAFT',
'["applicant","purpose","costCenter","lines"]',
'["applicant","purpose","costCenter","lines"]',
'["applicant"]','["purpose","lines"]','["purpose"]',
'["applicant","purpose","legacyCode"]','申请事由'),
('orgB','expense_request',1,'PUBLISHED',
'["applicant","purpose"]','["applicant","purpose"]',
'["applicant"]','["purpose"]','[]',
'["applicant","purpose"]','用途');
INSERT INTO qa_form_draft VALUES
('orgA','D200','expense_request',1,'OPEN');
INSERT INTO qa_form_submission VALUES
('orgA','R100','expense_request',1,
'{"applicant":"u07","purpose":"差旅"}'),
('orgB','B100','expense_request',1,
'{"applicant":"u09","purpose":"差旅"}');
先执行阻断项查询。前两列应分别得到 0 和 1:发起视图缺成本中心,审批视图可写业务字段。JSON_TABLE 把详情字段数组展开,第三个查询应返回 legacyCode。这些是针对本例的可复查检查;实际发布门槛应对全部必填字段和所有视图引用 做集合遍历,不能把 costCenter 写死在通用校验器中。MySQL JSON_TABLE
sql
SELECT JSON_CONTAINS(start_writable, JSON_QUOTE('costCenter')) AS can_fill_center,
JSON_LENGTH(approve_writable) AS approval_writable_count
FROM qa_form_revision
WHERE org_id='orgA' AND form_code='expense_request' AND version_no=2;
-- 0 / 1:发布应被阻断。
SELECT jt.field_code AS unknown_detail_field
FROM qa_form_revision r,
JSON_TABLE(r.detail_visible, '$[*]'
COLUMNS(field_code VARCHAR(40) PATH '$')) AS jt
WHERE r.org_id='orgA' AND r.form_code='expense_request'
AND r.version_no=2
AND JSON_CONTAINS(r.field_codes, JSON_QUOTE(jt.field_code))=0;
-- legacyCode:发布应被阻断。
修复候选 v2,再做一次预检,应得到 1 / 0,详情未知字段查询应为 0 行。这里的更新发生在 v2 仍为草稿时,不能拿同一条语句去原位修改已经发布并被业务单据引用的版本。
sql
UPDATE qa_form_revision
SET start_writable='["purpose","costCenter","lines"]',
approve_writable='[]',
detail_visible='["applicant","purpose","costCenter","lines"]'
WHERE org_id='orgA' AND form_code='expense_request'
AND version_no=2 AND status='DRAFT';
-- 影响行数必须为 1,然后重跑前述两条预检。
接下来故意做一次回滚演练 。应用服务要在资产锁定后核对旧版为 1、候选仍为草稿且配置检查已通过。下面的 ROLLBACK 模拟"候选状态已更新、指针尚未切换时失败";查询应仍得到 v1 指针与 v2 草稿。别把手工逐句执行的 SQL 注释误当成自动断言,影响行数和预检结果都需要应用检查。
sql
START TRANSACTION;
SELECT active_version FROM qa_form_asset
WHERE org_id='orgA' AND form_code='expense_request' FOR UPDATE;
-- 必须为 1。
SELECT status FROM qa_form_revision
WHERE org_id='orgA' AND form_code='expense_request'
AND version_no=2 FOR UPDATE;
-- 必须为 DRAFT。
UPDATE qa_form_revision SET status='PUBLISHED'
WHERE org_id='orgA' AND form_code='expense_request'
AND version_no=2 AND status='DRAFT';
ROLLBACK;
SELECT active_version FROM qa_form_asset
WHERE org_id='orgA' AND form_code='expense_request';
-- 1。
SELECT status FROM qa_form_revision
WHERE org_id='orgA' AND form_code='expense_request' AND version_no=2;
-- DRAFT。
成功发布再执行一次完整事务;应用在每次更新后检查影响行数为 1,任何冲突或异常回滚。发布后 D200 仍绑定 v1,应先通过接口用它发起一次旧版提交,核对响应为 FORM_VERSION_STALE;再查草稿仍开放且没有 R101。用户明确刷新并补齐字段后,才能执行成功提交。下面最后一段插入的是成功提交后的教学结果 ,不能绕过接口中的用户确认、授权和字段校验。MySQL 事务提交与回滚
sql
START TRANSACTION;
SELECT active_version FROM qa_form_asset
WHERE org_id='orgA' AND form_code='expense_request' FOR UPDATE;
SELECT status FROM qa_form_revision
WHERE org_id='orgA' AND form_code='expense_request'
AND version_no=2 FOR UPDATE;
-- 再核对 1 / DRAFT,候选配置仍通过预检。
UPDATE qa_form_revision SET status='PUBLISHED'
WHERE org_id='orgA' AND form_code='expense_request'
AND version_no=2 AND status='DRAFT';
UPDATE qa_form_asset SET active_version=2
WHERE org_id='orgA' AND form_code='expense_request'
AND active_version=1;
-- 两次 UPDATE 各影响 1 行,否则 ROLLBACK。
COMMIT;
SELECT d.draft_id, d.form_version, a.active_version, d.status
FROM qa_form_draft d JOIN qa_form_asset a
ON a.org_id=d.org_id AND a.form_code=d.form_code
WHERE d.org_id='orgA' AND d.draft_id='D200';
-- D200 / 1 / 2 / OPEN:直接提交应得到版本冲突。
SELECT COUNT(*) AS new_submissions FROM qa_form_submission
WHERE org_id='orgA' AND request_id='R101';
-- 0:过期草稿的拒绝路径没有生成单据。
sql
START TRANSACTION;
SELECT active_version FROM qa_form_asset
WHERE org_id='orgA' AND form_code='expense_request' FOR UPDATE;
-- 必须为 2,且用户已确认刷新并补齐 v2 字段。
UPDATE qa_form_draft SET form_version=2
WHERE org_id='orgA' AND draft_id='D200'
AND form_version=1 AND status='OPEN';
INSERT INTO qa_form_submission VALUES
('orgA','R101','expense_request',2,
'{"applicant":"u08","purpose":"采购","costCenter":"C10",
"lines":[{"item":"设备","amount":300}]}');
UPDATE qa_form_draft SET status='SUBMITTED'
WHERE org_id='orgA' AND draft_id='D200'
AND form_version=2 AND status='OPEN';
-- 两次 UPDATE 各影响 1 行;INSERT 唯一键不能冲突,否则 ROLLBACK。
COMMIT;
最终用几条查询核对状态、历史引用与组织范围。只看 active_version=2 不足以证明发布成功;它还可能指向未发布定义,或旧单无法读取。异常查询返回 0 行是必要条件,但接口回归仍需独立完成。
sql
SELECT a.org_id, a.form_code, a.active_version,
r.status AS active_status
FROM qa_form_asset a LEFT JOIN qa_form_revision r
ON r.org_id=a.org_id AND r.form_code=a.form_code
AND r.version_no=a.active_version
ORDER BY a.org_id;
-- orgA / expense_request / 2 / PUBLISHED
-- orgB / expense_request / 1 / PUBLISHED。
SELECT s.org_id, s.request_id, s.form_version,
r.purpose_label,
JSON_UNQUOTE(JSON_EXTRACT(s.payload,'$.purpose')) AS purpose_value
FROM qa_form_submission s JOIN qa_form_revision r
ON r.org_id=s.org_id AND r.form_code=s.form_code
AND r.version_no=s.form_version
ORDER BY s.org_id, s.request_id;
-- orgA/R100/1/用途/差旅;orgA/R101/2/申请事由/采购;orgB/B100/1/用途/差旅。
SELECT s.org_id, s.request_id
FROM qa_form_submission s LEFT JOIN qa_form_revision r
ON r.org_id=s.org_id AND r.form_code=s.form_code
AND r.version_no=s.form_version
WHERE r.version_no IS NULL;
-- 预期 0 行:没有缺失的历史版本引用。

图5:资产指针、版本状态、业务提交和异常查询共同证明发布结果。
八、上线门槛、回退边界与参考资料
上线门槛至少包括三份证据:预检报告没有阻断项;失败注入时发布事务可回滚;旧单、旧稿、新单和另一组织的回归结果与数据库查询一致。对缓存和多服务实例,还应检查发布指针变更后新建请求是否读取一致版本,提交请求是否再次在服务端核对版本。若只在前端预览时检查一次,提交时可能已经发生版本切换。
上线后的异常监测要盯住可解释的具体信号:发布指针找不到 PUBLISHED 定义、历史单据引用缺失版本、旧草稿过期拒绝量异常、提交数据缺少当前版必填字段、跨组织查询返回不该出现的单据。日志记录业务编号、组织、候选版本和错误原因,但不把真实申请内容或敏感字段写进日志。故障回退时先保护已提交单据和历史定义,优先停止错误的新建入口或发布修正版本;不要覆盖旧版定义"让页面暂时能打开"。
这套方法可以迁移到其他动态配置发布:先准备固定的正反样本,用静态校验阻止错误配置;再用事务和故障演练证明切换原子性;最后用真实读写路径与查询核对用户可见结果。能说清"失败后哪些数据没有改变",才算真正验过发布。
参考资料: