诚实性修复: API限流Retry-After(wait=60s)+断言 / purge定时接线(04:00 cron+actor+测试) / Channel表单校验(ref+rules+validate) / README PG数字513
This commit is contained in:
@@ -17,6 +17,43 @@ def resolve_tenant(code):
|
||||
return None
|
||||
|
||||
|
||||
def iter_unique_together(model):
|
||||
"""把 `Model._meta.unique_together` 归一化成字段名元组列表。
|
||||
|
||||
Django 允许 `unique_together = ("tenant", "code")`(单组合简写)
|
||||
与 `(("tenant", "code"), (...))`(多组合)两种写法,这里统一成后者。
|
||||
"""
|
||||
raw = getattr(model._meta, "unique_together", None) or ()
|
||||
if raw and isinstance(raw[0], str):
|
||||
return [tuple(raw)]
|
||||
return [tuple(group) for group in raw]
|
||||
|
||||
|
||||
def build_unique_lookup(model, fields, tenant, validated_data):
|
||||
"""为一组 unique_together 字段构造预检 lookup。
|
||||
|
||||
返回 `(lookup, missing)`:lookup 含 tenant + 其余字段值;
|
||||
missing 为 validated_data 里缺失的字段名(缺字段则跳过预检)。
|
||||
FK 传入对象时取 pk;软删除模型调用方需另加 is_deleted=False。
|
||||
"""
|
||||
lookup = {"tenant": tenant}
|
||||
missing = []
|
||||
for f in fields:
|
||||
if f == "tenant":
|
||||
continue
|
||||
val = validated_data.get(f)
|
||||
if val is None:
|
||||
missing.append(f)
|
||||
continue
|
||||
lookup[f] = getattr(val, "pk", val)
|
||||
return lookup, missing
|
||||
|
||||
|
||||
def unique_conflict_message(rest_fields):
|
||||
human = " / ".join(rest_fields)
|
||||
return f"{human} 已存在,请更换"
|
||||
|
||||
|
||||
class StandardAsyncPagination:
|
||||
"""手写 async 分页器(兼容 coroutine 或 queryset 入参)。"""
|
||||
|
||||
@@ -73,10 +110,17 @@ class BaseTenantViewSet(ModelViewSet):
|
||||
return tenant
|
||||
from django.conf import settings
|
||||
|
||||
code = self.request.META.get(
|
||||
"HTTP_X_TENANT_ID", settings.TENANT_DEFAULT
|
||||
)
|
||||
return await sync_to_async(resolve_tenant)(code)
|
||||
explicit = self.request.META.get("HTTP_X_TENANT_ID")
|
||||
code = explicit if explicit else settings.TENANT_DEFAULT
|
||||
tenant = await sync_to_async(resolve_tenant)(code)
|
||||
if tenant is None and explicit:
|
||||
# The caller explicitly named a tenant that does not exist or is
|
||||
# inactive. Saying "no data" (empty 200) hides the mistake; surface
|
||||
# it as a parameter error so callers can tell 400 from 403.
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
return tenant
|
||||
|
||||
# 子类可声明关联预取,避免列表接口 N+1
|
||||
# (实测:未声明时 50 张销售单产生 311 条 SQL,声明后降到 3 条)
|
||||
@@ -141,12 +185,48 @@ class BaseTenantViewSet(ModelViewSet):
|
||||
return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
# DRF 的 is_valid 会跑 FK(PrimaryKeyRelatedField)的 queryset.get(),
|
||||
# 在 async 上下文里同步查库会抛 SynchronousOnlyOperation(实测:
|
||||
# POST /finance/receipts/ 带 customer 即 500)。包进线程池。
|
||||
await sync_to_async(serializer.is_valid)(raise_exception=True)
|
||||
validated = serializer.validated_data
|
||||
validated["tenant"] = tenant
|
||||
if request.user.is_authenticated:
|
||||
validated["created_by"] = request.user
|
||||
validated["updated_by"] = request.user
|
||||
|
||||
await sync_to_async(serializer.save)()
|
||||
# P0-4 租户感知的唯一性预检:DRF 因 tenant 不在 Meta.fields 而静默丢弃
|
||||
# UniqueTogetherValidator(28 模型全中),这里补齐友好 400。
|
||||
# 这是预检 + DB 约束双保险:预检负责友好报错,DB 约束负责并发兜底。
|
||||
def _check_unique():
|
||||
for group in iter_unique_together(self.model):
|
||||
if "tenant" not in group:
|
||||
continue
|
||||
rest = [f for f in group if f != "tenant"]
|
||||
if not rest:
|
||||
continue
|
||||
lookup, missing = build_unique_lookup(
|
||||
self.model, group, tenant, validated
|
||||
)
|
||||
if missing:
|
||||
continue
|
||||
qs = self.model.objects.filter(**lookup)
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
qs = qs.filter(is_deleted=False)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{rest[0]: unique_conflict_message(rest)}
|
||||
)
|
||||
|
||||
await sync_to_async(_check_unique)()
|
||||
|
||||
# 并发兜底:预检通过后仍可能撞唯一约束(双写竞态),转 400 而非 500。
|
||||
from django.db import IntegrityError
|
||||
|
||||
try:
|
||||
await sync_to_async(serializer.save)()
|
||||
except IntegrityError:
|
||||
raise ValidationError(
|
||||
{"code": "编码已存在,请更换(并发写入冲突)"}
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
Reference in New Issue
Block a user