diff --git a/finance/formula_views.py b/finance/formula_views.py index afcf4f8..9ad469f 100644 --- a/finance/formula_views.py +++ b/finance/formula_views.py @@ -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}) diff --git a/finance/migrations/0005_formuladefinition_revision.py b/finance/migrations/0005_formuladefinition_revision.py new file mode 100644 index 0000000..40be6c8 --- /dev/null +++ b/finance/migrations/0005_formuladefinition_revision.py @@ -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), + ), + ] diff --git a/finance/models.py b/finance/models.py index 1de5920..e84caf7 100644 --- a/finance/models.py +++ b/finance/models.py @@ -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) diff --git a/frontend/src/dialogs.tsx b/frontend/src/dialogs.tsx index 21329b1..6e5e96b 100644 --- a/frontend/src/dialogs.tsx +++ b/frontend/src/dialogs.tsx @@ -31,6 +31,7 @@ type FormulaExpression = type SavedFormulaDefinition = { id: string; definition: Record; + 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( `/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({ )} + {definitions.length > 0 && !task && ( +

+ 保存同名定义会覆盖原版本;若已在其他窗口被修改,保存将提示刷新后重试。 +

+ )}