fix(definitions): F05 定义库保存原子化并引入 revision 冲突检测
- FormulaDefinition 增加 revision 字段(迁移 0005) - 保存接口在单事务内锁定用户所属行序列化并发:配额(50/数据集)检查与写入原子化, 不再可能产生第 51 条;唯一约束竞争返回 409 - 同名更新必须携带匹配的 revision,两个窗口不再相互静默覆盖; GET 列表返回当前 revision;保存增加 30/小时限流 - 审计与定义变更(保存/删除)同一事务提交,审计失败一并回滚 - 前端保存同名定义时携带已知 revision,并明示“同名覆盖”行为 - 测试:revision 边界(缺失/过期/正确/再次过期)、配额拒绝与释放、 审计失败回滚;PostgreSQL 并发配额用例独立门禁(本机 SQLite 跳过)
This commit is contained in:
+45
-16
@@ -3,14 +3,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
from django.http import FileResponse, JsonResponse
|
from django.http import FileResponse, JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .analysis import METRICS, context, dimensions
|
from .analysis import METRICS, context, dimensions
|
||||||
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
|
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
|
from .views import APIError, audit, body, dataset, endpoint, limit
|
||||||
|
|
||||||
|
MAX_DEFINITIONS_PER_DATASET = 50
|
||||||
|
|
||||||
|
|
||||||
def design_column(messages, ds, ctx):
|
def design_column(messages, ds, ctx):
|
||||||
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
|
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),
|
"id": str(item.pk),
|
||||||
"definition": item.definition,
|
"definition": item.definition,
|
||||||
|
"revision": item.revision,
|
||||||
"updated_at": item.updated_at.isoformat(),
|
"updated_at": item.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
for item in items
|
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:
|
try:
|
||||||
ensure_available_metrics(ds, definition)
|
ensure_available_metrics(ds, definition)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise APIError(str(exc)) from exc
|
raise APIError(str(exc)) from exc
|
||||||
normalized_name = definition["name"].strip().casefold()
|
normalized_name = definition["name"].strip().casefold()
|
||||||
existing = FormulaDefinition.objects.filter(
|
limit(f"formula-definition:{request.user.pk}", 30, 3600)
|
||||||
dataset=ds, user=request.user, normalized_name=normalized_name
|
with transaction.atomic():
|
||||||
).first()
|
# 锁定稳定的用户所属行序列化同一用户的并发保存,使配额检查与写入原子化。
|
||||||
if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
|
Membership.objects.select_for_update().get(pk=request.member.pk)
|
||||||
raise APIError("每个数据集最多保存 50 个公式定义")
|
existing = (
|
||||||
item, created = FormulaDefinition.objects.update_or_create(
|
FormulaDefinition.objects.select_for_update()
|
||||||
dataset=ds,
|
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
|
||||||
user=request.user,
|
.first()
|
||||||
normalized_name=normalized_name,
|
)
|
||||||
defaults={"definition": definition},
|
if existing:
|
||||||
)
|
# 同名更新按 revision 做乐观并发控制,避免两个窗口相互静默覆盖。
|
||||||
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
|
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(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"id": str(item.pk),
|
"id": str(item.pk),
|
||||||
"definition": item.definition,
|
"definition": item.definition,
|
||||||
|
"revision": item.revision,
|
||||||
"updated_at": item.updated_at.isoformat(),
|
"updated_at": item.updated_at.isoformat(),
|
||||||
},
|
},
|
||||||
status=201 if created else 200,
|
status=201 if created else 200,
|
||||||
@@ -242,8 +270,9 @@ def formula_definition_delete(request, dataset_id, definition_id):
|
|||||||
if not item:
|
if not item:
|
||||||
raise APIError("公式定义不存在", 404)
|
raise APIError("公式定义不存在", 404)
|
||||||
name = item.definition.get("name", "")
|
name = item.definition.get("name", "")
|
||||||
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
|
with transaction.atomic():
|
||||||
item.delete()
|
item.delete()
|
||||||
|
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
|
||||||
return JsonResponse({"deleted": True})
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -121,6 +121,7 @@ class FormulaDefinition(models.Model):
|
|||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
normalized_name = models.CharField(max_length=320)
|
normalized_name = models.CharField(max_length=320)
|
||||||
definition = models.JSONField()
|
definition = models.JSONField()
|
||||||
|
revision = models.PositiveIntegerField(default=1)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type FormulaExpression =
|
|||||||
type SavedFormulaDefinition = {
|
type SavedFormulaDefinition = {
|
||||||
id: string;
|
id: string;
|
||||||
definition: Record<string, unknown>;
|
definition: Record<string, unknown>;
|
||||||
|
revision: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -802,10 +803,18 @@ export function FormulaExportDialog({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
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>(
|
const saved = await api<SavedFormulaDefinition>(
|
||||||
`/datasets/${ds.id}/formula-definitions`,
|
`/datasets/${ds.id}/formula-definitions`,
|
||||||
"POST",
|
"POST",
|
||||||
{ definition: column },
|
{ definition: column, revision: existing?.revision },
|
||||||
);
|
);
|
||||||
setDefinitions((items) =>
|
setDefinitions((items) =>
|
||||||
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
|
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
|
||||||
@@ -914,6 +923,11 @@ export function FormulaExportDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{definitions.length > 0 && !task && (
|
||||||
|
<p className="muted">
|
||||||
|
保存同名定义会覆盖原版本;若已在其他窗口被修改,保存将提示刷新后重试。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<label className="check">
|
<label className="check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -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")
|
saved = client.post(url, data={"definition": definition}, content_type="application/json")
|
||||||
assert saved.status_code == 201
|
assert saved.status_code == 201
|
||||||
definition_id = saved.json()["id"]
|
definition_id = saved.json()["id"]
|
||||||
|
assert saved.json()["revision"] == 1
|
||||||
assert client.get(url).json()["items"][0]["definition"] == definition
|
assert client.get(url).json()["items"][0]["definition"] == definition
|
||||||
|
|
||||||
updated = client.post(
|
updated = client.post(
|
||||||
url,
|
url,
|
||||||
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}},
|
data={
|
||||||
|
"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"},
|
||||||
|
"revision": 1,
|
||||||
|
},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
|
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
|
||||||
assert updated.json()["definition"]["meaning"] == "更新说明"
|
assert updated.json()["definition"]["meaning"] == "更新说明"
|
||||||
|
assert updated.json()["revision"] == 2
|
||||||
|
|
||||||
other = Client()
|
other = Client()
|
||||||
other_user = ds.created_by.__class__.objects.create_user(
|
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
|
||||||
Reference in New Issue
Block a user