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)