From 0aaa87e31afbb9dd74d5415e0db147d494f59146 Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sat, 26 Sep 2026 01:37:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(definitions):=20F05=20=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=BA=93=E4=BF=9D=E5=AD=98=E5=8E=9F=E5=AD=90=E5=8C=96=E5=B9=B6?= =?UTF-8?q?=E5=BC=95=E5=85=A5=20revision=20=E5=86=B2=E7=AA=81=E6=A3=80?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormulaDefinition 增加 revision 字段(迁移 0005) - 保存接口在单事务内锁定用户所属行序列化并发:配额(50/数据集)检查与写入原子化, 不再可能产生第 51 条;唯一约束竞争返回 409 - 同名更新必须携带匹配的 revision,两个窗口不再相互静默覆盖; GET 列表返回当前 revision;保存增加 30/小时限流 - 审计与定义变更(保存/删除)同一事务提交,审计失败一并回滚 - 前端保存同名定义时携带已知 revision,并明示“同名覆盖”行为 - 测试:revision 边界(缺失/过期/正确/再次过期)、配额拒绝与释放、 审计失败回滚;PostgreSQL 并发配额用例独立门禁(本机 SQLite 跳过) --- finance/formula_views.py | 61 ++++++--- .../0005_formuladefinition_revision.py | 18 +++ finance/models.py | 1 + frontend/src/dialogs.tsx | 16 ++- tests/test_assistant_api.py | 7 +- tests/test_formula_definitions_concurrency.py | 123 ++++++++++++++++++ 6 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 finance/migrations/0005_formuladefinition_revision.py create mode 100644 tests/test_formula_definitions_concurrency.py 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 && ( +

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

+ )}