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
|
||||
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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user