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
+45 -16
View File
@@ -3,14 +3,17 @@ from pathlib import Path
import httpx
from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import FileResponse, JsonResponse
from django.utils import timezone
from .analysis import METRICS, context, dimensions
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
from .models import FormulaDefinition, FormulaExport
from .models import FormulaDefinition, FormulaExport, Membership
from .views import APIError, audit, body, dataset, endpoint, limit
MAX_DEFINITIONS_PER_DATASET = 50
def design_column(messages, ds, ctx):
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
@@ -198,6 +201,7 @@ def formula_definitions(request, dataset_id):
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"updated_at": item.updated_at.isoformat(),
}
for item in items
@@ -205,28 +209,52 @@ def formula_definitions(request, dataset_id):
}
)
definition = validate_column(body(request).get("definition"))
data = body(request)
definition = validate_column(data.get("definition"))
try:
ensure_available_metrics(ds, definition)
except ValueError as exc:
raise APIError(str(exc)) from exc
normalized_name = definition["name"].strip().casefold()
existing = FormulaDefinition.objects.filter(
dataset=ds, user=request.user, normalized_name=normalized_name
).first()
if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
raise APIError("每个数据集最多保存 50 个公式定义")
item, created = FormulaDefinition.objects.update_or_create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
defaults={"definition": definition},
)
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
limit(f"formula-definition:{request.user.pk}", 30, 3600)
with transaction.atomic():
# 锁定稳定的用户所属行序列化同一用户的并发保存,使配额检查与写入原子化。
Membership.objects.select_for_update().get(pk=request.member.pk)
existing = (
FormulaDefinition.objects.select_for_update()
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
.first()
)
if existing:
# 同名更新按 revision 做乐观并发控制,避免两个窗口相互静默覆盖。
if data.get("revision") != existing.revision:
raise APIError("定义已在其他窗口更新,请刷新后重试", 409)
existing.definition, existing.revision = definition, existing.revision + 1
existing.save(update_fields=["definition", "revision", "updated_at"])
item, created = existing, False
else:
if (
FormulaDefinition.objects.filter(dataset=ds, user=request.user).count()
>= MAX_DEFINITIONS_PER_DATASET
):
raise APIError(f"每个数据集最多保存 {MAX_DEFINITIONS_PER_DATASET} 个公式定义")
try:
item = FormulaDefinition.objects.create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
definition=definition,
)
except IntegrityError as exc:
raise APIError("定义保存发生并发冲突,请重试", 409) from exc
created = True
# 审计与定义变更在同一事务内提交;审计失败时变更一并回滚。
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
return JsonResponse(
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"updated_at": item.updated_at.isoformat(),
},
status=201 if created else 200,
@@ -242,8 +270,9 @@ def formula_definition_delete(request, dataset_id, definition_id):
if not item:
raise APIError("公式定义不存在", 404)
name = item.definition.get("name", "")
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
item.delete()
with transaction.atomic():
item.delete()
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
return JsonResponse({"deleted": True})
@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-25 17:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0004_formuladefinition'),
]
operations = [
migrations.AddField(
model_name='formuladefinition',
name='revision',
field=models.PositiveIntegerField(default=1),
),
]
+1
View File
@@ -121,6 +121,7 @@ class FormulaDefinition(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
normalized_name = models.CharField(max_length=320)
definition = models.JSONField()
revision = models.PositiveIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+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"
+6 -1
View File
@@ -168,16 +168,21 @@ def test_formula_definition_library_is_private_validated_and_audited(workspace):
saved = client.post(url, data={"definition": definition}, content_type="application/json")
assert saved.status_code == 201
definition_id = saved.json()["id"]
assert saved.json()["revision"] == 1
assert client.get(url).json()["items"][0]["definition"] == definition
updated = client.post(
url,
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}},
data={
"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"},
"revision": 1,
},
content_type="application/json",
)
assert updated.status_code == 200
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
assert updated.json()["definition"]["meaning"] == "更新说明"
assert updated.json()["revision"] == 2
other = Client()
other_user = ds.created_by.__class__.objects.create_user(
@@ -0,0 +1,123 @@
from unittest.mock import patch
import pytest
from django.db import connection
from finance.models import FormulaDefinition
pytestmark = pytest.mark.django_db
def column_definition(name="收入成本差"):
return {
"name": name,
"meaning": "实际收入减成本",
"scope": "所选分组与期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
def _post(client, url, definition, revision=None):
return client.post(
url,
data={"definition": definition, **({"revision": revision} if revision is not None else {})},
content_type="application/json",
)
def test_same_name_update_requires_matching_revision(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/formula-definitions"
saved = _post(client, url, column_definition())
assert saved.status_code == 201
item_id, revision = saved.json()["id"], saved.json()["revision"]
# 缺失或过期的 revision 都不能静默覆盖其他窗口的修改
assert _post(client, url, column_definition()).status_code == 409
assert _post(client, url, column_definition(), revision=999).status_code == 409
updated = _post(client, url, column_definition(), revision=revision)
assert updated.status_code == 200
assert updated.json()["revision"] == revision + 1
# 基于旧版本的再次更新必须被拒绝,两个标签页不互相静默丢失
stale = _post(client, url, column_definition(), revision=revision)
assert stale.status_code == 409
current = FormulaDefinition.objects.get(pk=item_id)
assert current.revision == revision + 1
# 列表接口暴露当前 revision 供前端携带
listed = client.get(url).json()["items"]
assert listed[0]["revision"] == revision + 1
def test_definition_quota_is_enforced_and_release_allows_new_save(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/formula-definitions"
for index in range(50):
FormulaDefinition.objects.create(
dataset=ds,
user=ds.created_by,
normalized_name=f"定义{index:02d}",
definition=column_definition(f"定义{index:02d}"),
)
rejected = _post(client, url, column_definition("第 51 个不同名定义"))
assert rejected.status_code == 400
assert "50" in rejected.json()["error"]
assert FormulaDefinition.objects.filter(dataset=ds).count() == 50
# 同名更新不受配额限制;删除后名额释放可再保存新名称
assert _post(client, url, column_definition("定义00"), revision=1).status_code == 200
FormulaDefinition.objects.filter(normalized_name="定义00").delete()
assert _post(client, url, column_definition("第 51 个不同名定义")).status_code == 201
def test_audit_failure_rolls_back_definition_change(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/formula-definitions"
with patch("finance.formula_views.audit", side_effect=RuntimeError("audit unavailable")):
with pytest.raises(RuntimeError, match="audit unavailable"):
_post(client, url, column_definition())
assert FormulaDefinition.objects.filter(dataset=ds).count() == 0
def test_concurrent_distinct_saves_cannot_exceed_quota_on_postgres(workspace):
if connection.vendor != "postgresql":
pytest.skip("并发配额依赖 PostgreSQL 行锁语义,生产库单独验证")
import threading
from django.test import Client
ds, member, *_ = workspace
user = ds.created_by
for index in range(49):
FormulaDefinition.objects.create(
dataset=ds,
user=user,
normalized_name=f"定义{index:02d}",
definition=column_definition(f"定义{index:02d}"),
)
barrier = threading.Barrier(4)
def save(index):
client = Client()
assert client.login(username=user.username, password="Finance-TestPassword991!")
barrier.wait()
_post(client, f"/api/datasets/{ds.pk}/formula-definitions", column_definition(f"并发{index}"))
threads = [threading.Thread(target=save, args=(index,)) for index in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert FormulaDefinition.objects.filter(dataset=ds).count() == 50