诚实性修复: API限流Retry-After(wait=60s)+断言 / purge定时接线(04:00 cron+actor+测试) / Channel表单校验(ref+rules+validate) / README PG数字513

This commit is contained in:
seagull
2026-09-12 02:20:50 +08:00
parent b3f3095d53
commit 67540e8d30
40 changed files with 1805 additions and 31 deletions
+86 -6
View File
@@ -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)