fix(definitions): F05 定义库保存原子化并引入 revision 冲突检测

- FormulaDefinition 增加 revision 字段(迁移 0005)
- 保存接口在单事务内锁定用户所属行序列化并发:配额(50/数据集)检查与写入原子化,
  不再可能产生第 51 条;唯一约束竞争返回 409
- 同名更新必须携带匹配的 revision,两个窗口不再相互静默覆盖;
  GET 列表返回当前 revision;保存增加 30/小时限流
- 审计与定义变更(保存/删除)同一事务提交,审计失败一并回滚
- 前端保存同名定义时携带已知 revision,并明示“同名覆盖”行为
- 测试:revision 边界(缺失/过期/正确/再次过期)、配额拒绝与释放、
  审计失败回滚;PostgreSQL 并发配额用例独立门禁(本机 SQLite 跳过)
This commit is contained in:
Jayway Liu
2026-09-26 01:37:55 +08:00
parent 9a5ab26014
commit 0aaa87e31a
6 changed files with 208 additions and 18 deletions
+15 -1
View File
@@ -31,6 +31,7 @@ type FormulaExpression =
type SavedFormulaDefinition = {
id: string;
definition: Record<string, unknown>;
revision: number;
updated_at: string;
};
@@ -802,10 +803,18 @@ export function FormulaExportDialog({
setBusy(true);
setError("");
try {
// 同名保存按服务端匹配规则覆盖旧版本;带上已知版本的 revision,
// 其他窗口先更新过则服务端返回 409,避免静默覆盖。
const normalizedName = (value: string) => value.trim().toLowerCase();
const existing = definitions.find(
(item) =>
normalizedName(String(item.definition.name)) ===
normalizedName(String(column.name)),
);
const saved = await api<SavedFormulaDefinition>(
`/datasets/${ds.id}/formula-definitions`,
"POST",
{ definition: column },
{ definition: column, revision: existing?.revision },
);
setDefinitions((items) =>
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
@@ -914,6 +923,11 @@ export function FormulaExportDialog({
</button>
</div>
)}
{definitions.length > 0 && !task && (
<p className="muted">
保存同名定义会覆盖原版本;若已在其他窗口被修改,保存将提示刷新后重试。
</p>
)}
<label className="check">
<input
type="checkbox"