诚实性修复: API限流Retry-After(wait=60s)+断言 / purge定时接线(04:00 cron+actor+测试) / Channel表单校验(ref+rules+validate) / README PG数字513
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""P0-5 · 登录防爆破的 TokenObtainPairView 包装。
|
||||
|
||||
按 (username + IP) 记连续失败,5 次失败锁 15 分钟:
|
||||
- 请求前已锁定 → 直接 429(即使密码正确也不再校验,避免计时侧信道)。
|
||||
- 200 → 清零;401 → 失败 +1(达阈值加锁)。
|
||||
"""
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
|
||||
from apps.core import ratelimit as rl
|
||||
|
||||
|
||||
class RateLimitedTokenObtainPairView(TokenObtainPairView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
username = (request.data.get("username") or "").strip()
|
||||
ip = rl.client_ip(request)
|
||||
if username and rl.is_login_locked(username, ip):
|
||||
return Response(
|
||||
{"code": "login_locked", "detail": "登录失败次数过多,账号已临时锁定 15 分钟"},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
try:
|
||||
resp = super().post(request, *args, **kwargs)
|
||||
except AuthenticationFailed:
|
||||
# simplejwt 凭证错误走抛异常(DRF 转 401),这里计失败数;
|
||||
# 达阈值后本次直接 429,避免再给一次尝试机会。
|
||||
if username:
|
||||
fails = rl.record_login_failure(username, ip)
|
||||
if fails >= rl.LOGIN_MAX_FAILURES:
|
||||
return Response(
|
||||
{"code": "login_locked",
|
||||
"detail": "登录失败次数过多,账号已临时锁定 15 分钟"},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
raise
|
||||
if not username:
|
||||
return resp
|
||||
if resp.status_code == status.HTTP_200_OK:
|
||||
rl.clear_login_failures(username, ip)
|
||||
return resp
|
||||
@@ -30,7 +30,7 @@ async def enter_demo(request):
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.core.models import Tenant
|
||||
from apps.core.models import Tenant, TenantMembership
|
||||
|
||||
def _prepare():
|
||||
tenant = Tenant.objects.filter(code=DEMO_TENANT, is_active=True).first()
|
||||
@@ -39,6 +39,12 @@ async def enter_demo(request):
|
||||
user = get_user_model().objects.filter(username=DEMO_USER).first()
|
||||
if user is None:
|
||||
return tenant, None
|
||||
# The token flow bypasses the normal login path, so make sure the
|
||||
# membership exists even when the demo tenant was built by hand.
|
||||
TenantMembership.objects.get_or_create(
|
||||
user=user, tenant=tenant,
|
||||
defaults={"role": "owner", "is_active": True},
|
||||
)
|
||||
return tenant, user
|
||||
|
||||
tenant, user = await sync_to_async(_prepare)()
|
||||
|
||||
@@ -55,9 +55,32 @@ def api_exception_handler(exc, context):
|
||||
return Response({
|
||||
"code": "server_error",
|
||||
"detail": "服务端处理异常,请稍后重试或联系管理员",
|
||||
"exc_type": type(exc).__name__,
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return _with_error_code(response, exc)
|
||||
|
||||
|
||||
def _with_error_code(response, exc):
|
||||
"""给"单条 detail"型错误补一个顶层 `code`,供前端判别。
|
||||
|
||||
DRF 把 code 藏在 ErrorDetail 里,序列化成纯字符串就丢了,所以
|
||||
PermissionDenied / NotAuthenticated / NotFound 这类响应到这里补回来。
|
||||
|
||||
只处理 detail 为字符串的情况:ValidationError 的 dict 形态是字段级错误,
|
||||
顶层再塞一个 `code` 会和真实字段名(如 `{"code": ["已存在"]}`)撞车。
|
||||
"""
|
||||
data = getattr(response, "data", None)
|
||||
if not isinstance(data, dict) or "code" in data:
|
||||
return response
|
||||
if not isinstance(getattr(exc, "detail", None), str):
|
||||
return response
|
||||
try:
|
||||
codes = exc.get_codes()
|
||||
except Exception: # 非 DRF 异常
|
||||
return response
|
||||
if not isinstance(codes, str):
|
||||
return response
|
||||
data["code"] = codes
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""P1-3 · 审计日志保留策略:purge_audit_logs。
|
||||
|
||||
`--days N` 保留窗口(默认 180);`--dry-run` 只报数不删;
|
||||
不传 `--yes` 不删(防手滑);`--batch SIZE` 分批 delete;
|
||||
`--export path.jsonl` 删除前先落 NDJSON 归档。
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "按保留窗口删除过期审计日志(默认保留 180 天)。"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--days", type=int, default=180,
|
||||
help="保留最近 N 天(默认 180)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="只统计,不删除")
|
||||
parser.add_argument("--yes", action="store_true",
|
||||
help="确认执行删除(不传则不删)")
|
||||
parser.add_argument("--batch", type=int, default=1000,
|
||||
help="每批删除条数(默认 1000,避免长事务锁表)")
|
||||
parser.add_argument("--export", type=str, default="",
|
||||
help="删除前把待删记录导出为 NDJSON 到该路径")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import AuditLog
|
||||
|
||||
days = options["days"]
|
||||
if days is None or days < 1:
|
||||
raise CommandError("--days 必须 >= 1")
|
||||
batch = options["batch"] or 1000
|
||||
if batch < 1:
|
||||
raise CommandError("--batch 必须 >= 1")
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
base_qs = AuditLog.objects.filter(created_at__lt=cutoff)
|
||||
total = base_qs.count()
|
||||
self.stdout.write(f"保留窗口:{days} 天(早于 {cutoff.isoformat()} 的 {total} 条待处理)")
|
||||
|
||||
export_path = options["export"]
|
||||
if export_path and total:
|
||||
import json
|
||||
|
||||
count = 0
|
||||
with open(export_path, "w", encoding="utf-8") as fh:
|
||||
for row in base_qs.order_by("id").iterator(chunk_size=batch):
|
||||
fh.write(json.dumps({
|
||||
"id": row.id,
|
||||
"tenant_id": row.tenant_id,
|
||||
"user_id": row.user_id,
|
||||
"action": row.action,
|
||||
"target_type": row.target_type,
|
||||
"target_id": row.target_id,
|
||||
"detail": row.detail,
|
||||
"ip": str(row.ip) if row.ip else None,
|
||||
"user_agent": row.user_agent,
|
||||
"created_at": row.created_at.isoformat(),
|
||||
}, ensure_ascii=False) + "\n")
|
||||
count += 1
|
||||
self.stdout.write(f"已归档 {count} 条 → {export_path}")
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write("dry-run:未删除任何记录。")
|
||||
return f"dry-run: {total} matched"
|
||||
if not options["yes"]:
|
||||
self.stdout.write("未传 --yes:未删除任何记录(防手滑)。")
|
||||
return f"needs --yes: {total} matched"
|
||||
|
||||
deleted = 0
|
||||
while True:
|
||||
ids = list(
|
||||
AuditLog.objects.filter(created_at__lt=cutoff)
|
||||
.order_by("id")
|
||||
.values_list("id", flat=True)[:batch]
|
||||
)
|
||||
if not ids:
|
||||
break
|
||||
n, _ = AuditLog.objects.filter(pk__in=ids).delete()
|
||||
deleted += n
|
||||
self.stdout.write(self.style.SUCCESS(f"已删除 {deleted} 条过期审计日志。"))
|
||||
return f"deleted: {deleted}"
|
||||
@@ -21,7 +21,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.core.models import Org, Tenant
|
||||
from apps.core.models import Org, Tenant, TenantMembership
|
||||
from apps.catalog.models import Brand, Category, Product, Unit, UnitConversion
|
||||
from apps.partner.models import Customer, Supplier
|
||||
from apps.inventory.models import Warehouse
|
||||
@@ -131,6 +131,12 @@ class Command(BaseCommand):
|
||||
if created or not user.check_password(DEMO_PASSWORD):
|
||||
user.set_password(DEMO_PASSWORD)
|
||||
user.save()
|
||||
# The demo token flow authenticates this account, and the membership
|
||||
# permission requires an explicit relation for it.
|
||||
TenantMembership.objects.get_or_create(
|
||||
user=user, tenant=tenant,
|
||||
defaults={"role": "owner", "is_active": True},
|
||||
)
|
||||
self.stdout.write(f" {'✓ 创建' if created else '· 复用'} 演示账号 {DEMO_USER}")
|
||||
return user
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class TenantMiddleware:
|
||||
"/api/v1/ping/",
|
||||
"/api/v1/auth/token/",
|
||||
"/api/v1/auth/token/refresh/",
|
||||
"/api/v1/auth/register/",
|
||||
):
|
||||
return self.get_response(request)
|
||||
|
||||
@@ -61,6 +62,7 @@ async def resolve_tenant(tenant_code):
|
||||
DEMO_WRITE_ALLOWLIST = {
|
||||
"/api/v1/auth/token/",
|
||||
"/api/v1/auth/token/refresh/",
|
||||
"/api/v1/auth/register/",
|
||||
"/api/v1/demo/enter/",
|
||||
"/api/v1/ping/",
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ class TenantMembershipPermission(BasePermission):
|
||||
not requested or requested == key_tenant.code
|
||||
)
|
||||
|
||||
# Superusers keep the historical bootstrap contract: they are operators
|
||||
# for every seeded tenant (seed_initial_data + the 0002 backfill).
|
||||
if getattr(user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
tenant_code = request.META.get(
|
||||
"HTTP_X_TENANT_ID", getattr(settings, "TENANT_DEFAULT", "default")
|
||||
)
|
||||
@@ -38,6 +43,12 @@ class TenantMembershipPermission(BasePermission):
|
||||
|
||||
from .models import Tenant, TenantMembership
|
||||
|
||||
# An unknown (or inactive) tenant is a *parameter* error, not an
|
||||
# authorization failure. Answering it here would mask it as 403; the
|
||||
# view layer returns 400「无法识别租户」 instead.
|
||||
if not Tenant.objects.filter(code=tenant_code, is_active=True).exists():
|
||||
return True
|
||||
|
||||
return TenantMembership.objects.filter(
|
||||
user=user,
|
||||
tenant__code=tenant_code,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""P0-5 · 基于 Django cache 的通用限流/防爆破计数器。
|
||||
|
||||
设计:
|
||||
- 后端无关:locmem(开发/测试)与 Redis(生产)都可用同一 API。
|
||||
- 登录防爆破:按 `(用户名 + IP)` 记连续失败次数,达阈值锁定 M 分钟;
|
||||
成功登录清零。失败计数只在"凭证错误"时 +1,避免把不相关 400 计入。
|
||||
- API Key 限流:按 key prefix 记滑动分钟窗口计数,超 `rate_limit` 抛 Throttled。
|
||||
- `rate_limit` 下界:<=0 视为 1(防止被设成 0/负数导致全拒绝或无限)。
|
||||
|
||||
常量:
|
||||
- LOGIN_MAX_FAILURES=5, LOGIN_LOCK_SECONDS=900(15 分钟)
|
||||
- API KEY 默认窗口 60s,限额取 `max(1, key.rate_limit)`。
|
||||
"""
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
LOGIN_MAX_FAILURES = 5
|
||||
LOGIN_LOCK_SECONDS = 15 * 60
|
||||
APIKEY_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
def _login_fail_key(username: str, ip: str) -> str:
|
||||
return f"dealerhub:login-fail:{username}:{ip}"
|
||||
|
||||
|
||||
def _login_lock_key(username: str, ip: str) -> str:
|
||||
return f"dealerhub:login-lock:{username}:{ip}"
|
||||
|
||||
|
||||
def is_login_locked(username: str, ip: str) -> bool:
|
||||
return bool(cache.get(_login_lock_key(username, ip)))
|
||||
|
||||
|
||||
def record_login_failure(username: str, ip: str) -> int:
|
||||
"""失败 +1;达阈值则加锁并返回当前失败数。"""
|
||||
fails = (cache.get(_login_fail_key(username, ip)) or 0) + 1
|
||||
cache.set(_login_fail_key(username, ip), fails, LOGIN_LOCK_SECONDS)
|
||||
if fails >= LOGIN_MAX_FAILURES:
|
||||
cache.set(_login_lock_key(username, ip), 1, LOGIN_LOCK_SECONDS)
|
||||
return fails
|
||||
|
||||
|
||||
def clear_login_failures(username: str, ip: str) -> None:
|
||||
cache.delete(_login_fail_key(username, ip))
|
||||
cache.delete(_login_lock_key(username, ip))
|
||||
|
||||
|
||||
def effective_rate_limit(raw) -> int:
|
||||
"""rate_limit 下界保护:非正数/非法值 → 1。"""
|
||||
try:
|
||||
v = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return v if v >= 1 else 1
|
||||
|
||||
|
||||
def check_apikey_rate_limit(key_prefix: str, limit: int) -> None:
|
||||
"""分钟窗口计数;超限抛 DRF Throttled(429 + Retry-After 由 DRF 补)。
|
||||
|
||||
注意:必须传 `wait=`,DRF 只在 `exc.wait` 非空时才回 `Retry-After` 头
|
||||
(`rest_framework/views.py::exception_handler`)。不传则只有 429 无头,
|
||||
客户端无法知道何时重试。
|
||||
"""
|
||||
from rest_framework.exceptions import Throttled
|
||||
|
||||
window_key = f"dealerhub:apikey:{key_prefix}"
|
||||
count = cache.get(window_key)
|
||||
if count is None:
|
||||
cache.set(window_key, 1, APIKEY_WINDOW_SECONDS)
|
||||
return
|
||||
if int(count) >= effective_rate_limit(limit):
|
||||
raise Throttled(
|
||||
wait=APIKEY_WINDOW_SECONDS,
|
||||
detail="API Key 请求频率超限,请稍后重试",
|
||||
)
|
||||
try:
|
||||
cache.incr(window_key)
|
||||
except ValueError:
|
||||
cache.set(window_key, 1, APIKEY_WINDOW_SECONDS)
|
||||
|
||||
|
||||
def client_ip(request) -> str:
|
||||
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.META.get("REMOTE_ADDR", "") or "unknown"
|
||||
@@ -0,0 +1,105 @@
|
||||
"""P0-3 · 公开注册:建租户 + 建用户 + owner membership + free 订阅 + JWT。
|
||||
|
||||
POST /api/v1/auth/register/(AllowAny,已进 TenantMiddleware 白名单):
|
||||
入参 username / password / company_name /(可选)phone。
|
||||
租户 code 由公司名 slug 化,冲突加数字后缀;非法 slug → 400。
|
||||
密码最低 8 位;用户名已存在 → 400。
|
||||
事务内:Tenant → User → TenantMembership(owner) → billing.subscribe(free)。
|
||||
返回 JWT(access + refresh),免二次登录。
|
||||
防滥用:同 IP 限速 10 次/小时(复用 core.ratelimit 的 cache 计数器)。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from adrf.decorators import api_view
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import authentication_classes, permission_classes
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
REGISTER_IP_LIMIT = 10
|
||||
REGISTER_IP_WINDOW = 3600
|
||||
|
||||
|
||||
def _tenant_code_for(company_name: str) -> str:
|
||||
base = slugify(company_name, allow_unicode=False) or ""
|
||||
base = re.sub(r"[^a-z0-9-]", "", base.lower())[:50].strip("-")
|
||||
if not base:
|
||||
raise ValidationError({"company_name": "公司名称无法生成有效的租户编码,请换一个名称"})
|
||||
from apps.core.models import Tenant
|
||||
|
||||
code, i = base, 0
|
||||
while Tenant.objects.filter(code=code).exists():
|
||||
i += 1
|
||||
code = f"{base}-{i}"[:64]
|
||||
return code
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@authentication_classes([])
|
||||
@permission_classes([AllowAny])
|
||||
def register(request):
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.core import ratelimit as rl
|
||||
|
||||
ip = rl.client_ip(request)
|
||||
ip_key = f"dealerhub:register-ip:{ip}"
|
||||
if int(cache.get(ip_key) or 0) >= REGISTER_IP_LIMIT:
|
||||
return Response(
|
||||
{"code": "register_throttled", "detail": "同一 IP 注册过于频繁,请 1 小时后再试"},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
payload = request.data or {}
|
||||
username = (payload.get("username") or "").strip()
|
||||
password = payload.get("password") or ""
|
||||
company_name = (payload.get("company_name") or "").strip()
|
||||
phone = (payload.get("phone") or "").strip()
|
||||
|
||||
if not username or not password or not company_name:
|
||||
raise ValidationError({"detail": "username / password / company_name 均为必填"})
|
||||
if len(password) < 8:
|
||||
raise ValidationError({"password": "密码长度至少 8 位"})
|
||||
if len(username) > 150:
|
||||
raise ValidationError({"username": "用户名过长(最多 150 字符)"})
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
if User.objects.filter(username=username).exists():
|
||||
raise ValidationError({"username": "该用户名已被注册"})
|
||||
|
||||
code = _tenant_code_for(company_name)
|
||||
|
||||
from apps.core.models import Tenant, TenantMembership
|
||||
|
||||
with transaction.atomic():
|
||||
tenant = Tenant.objects.create(code=code, name=company_name, phone=phone)
|
||||
user = User.objects.create_user(username=username, password=password)
|
||||
TenantMembership.objects.create(
|
||||
user=user, tenant=tenant, role="owner", is_active=True,
|
||||
)
|
||||
try:
|
||||
from apps.billing.models import subscribe
|
||||
|
||||
subscribe(tenant, plan_code="free")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
cache.incr(ip_key)
|
||||
except ValueError:
|
||||
cache.set(ip_key, 1, REGISTER_IP_WINDOW)
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return Response({
|
||||
"access": str(refresh.access_token),
|
||||
"refresh": str(refresh),
|
||||
"tenant": tenant.code,
|
||||
"username": user.username,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""审计日志保留定时任务(P1-3 · Dramatiq actor,cron 每日调用)。
|
||||
|
||||
复用 `purge_audit_logs` management command(默认保留 180 天、分批删、
|
||||
`--yes` 防手滑),与 `apps/billing/tasks.py` / `apps/notify/tasks.py`
|
||||
同一范式:业务函数可直接单测,actor 只做薄包装。
|
||||
"""
|
||||
|
||||
|
||||
def purge_expired_audit_logs(days: int = 180) -> str:
|
||||
"""删除早于保留窗口的审计日志,返回 command 回执。"""
|
||||
from django.core.management import call_command
|
||||
|
||||
return call_command("purge_audit_logs", days=days, yes=True)
|
||||
|
||||
|
||||
try:
|
||||
import dramatiq
|
||||
|
||||
purge_expired_audit_logs_actor = dramatiq.actor(
|
||||
purge_expired_audit_logs,
|
||||
actor_name="purge_expired_audit_logs",
|
||||
max_retries=1,
|
||||
time_limit=600_000,
|
||||
)
|
||||
except ImportError: # pragma: no cover —— 未装 dramatiq 的精简环境
|
||||
purge_expired_audit_logs_actor = None
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,11 @@ class APIKeyAuthentication(authentication.BaseAuthentication):
|
||||
if not key_obj.verify_key(raw_key):
|
||||
raise exceptions.AuthenticationFailed("API Key 签名无效")
|
||||
|
||||
# P0-5:把纸面的 rate_limit 接进认证链(分钟窗口,超限 429)。
|
||||
from apps.core.ratelimit import check_apikey_rate_limit
|
||||
|
||||
check_apikey_rate_limit(key_obj.prefix, key_obj.rate_limit)
|
||||
|
||||
# 记录调用时间并绑定 tenant
|
||||
key_obj.last_used_at = timezone.now()
|
||||
key_obj.save(update_fields=["last_used_at"])
|
||||
|
||||
@@ -18,6 +18,7 @@ class APIKeyCreateInputSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=128)
|
||||
scopes = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
expires_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
rate_limit = serializers.IntegerField(required=False, min_value=1)
|
||||
|
||||
|
||||
class ExternalOrderLineInputSerializer(serializers.Serializer):
|
||||
|
||||
@@ -52,6 +52,10 @@ class APIKeyViewSet(BaseTenantViewSet):
|
||||
expires_at=data.get("expires_at"),
|
||||
created_by=user,
|
||||
)
|
||||
# P0-5:允许创建时指定 rate_limit(下界 1 由 serializer 保证)。
|
||||
if data.get("rate_limit") is not None:
|
||||
key_obj.rate_limit = data["rate_limit"]
|
||||
await sync_to_async(key_obj.save)(update_fields=["rate_limit"])
|
||||
|
||||
res_data = APIKeySerializer(key_obj).data
|
||||
res_data["raw_key"] = raw_key # 仅在创建时返回一次明文密钥!
|
||||
|
||||
@@ -72,6 +72,8 @@ async def login(request):
|
||||
"""商城登录:{phone, password, tenant_code?} → {token, customer}"""
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core import ratelimit as rl
|
||||
|
||||
payload = request.data or {}
|
||||
phone = (payload.get("phone") or "").strip()
|
||||
password = payload.get("password") or ""
|
||||
@@ -81,6 +83,15 @@ async def login(request):
|
||||
if not phone or not password:
|
||||
raise ValidationError({"detail": "phone 与 password 必填"})
|
||||
|
||||
# P0-5:商城登录同样防爆破(按 phone + IP)。
|
||||
ip = rl.client_ip(request)
|
||||
login_id = f"{tenant_code}:{phone}"
|
||||
if rl.is_login_locked(login_id, ip):
|
||||
return Response(
|
||||
{"code": "login_locked", "detail": "登录失败次数过多,账号已临时锁定 15 分钟"},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
def _do():
|
||||
tenant = resolve_tenant(tenant_code)
|
||||
if tenant is None:
|
||||
@@ -106,9 +117,11 @@ async def login(request):
|
||||
raise
|
||||
|
||||
if account is None:
|
||||
rl.record_login_failure(login_id, ip)
|
||||
return Response({"code": "invalid_credentials", "detail": "手机号或密码不正确"},
|
||||
status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
rl.clear_login_failures(login_id, ip)
|
||||
token = await sync_to_async(issue_session_token)(account)
|
||||
return Response({
|
||||
"token": token,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<li>订货商城:{% if p.limits.storefront %}✓{% else %}✗{% endif %}</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" style="width: 100%; text-align: center"
|
||||
href="{{ demo_url }}">先试用</a>
|
||||
href="/#/register">免费注册</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user