诚实性修复: 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
+44
View File
@@ -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
+7 -1
View File
@@ -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)()
+24 -1
View File
@@ -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
+2
View File
@@ -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/",
}
+11
View File
@@ -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,
+86
View File
@@ -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"
+105
View File
@@ -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)
+26
View File
@@ -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
+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)
+5
View File
@@ -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"])
+1
View File
@@ -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):
+4
View File
@@ -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 # 仅在创建时返回一次明文密钥!
+13
View File
@@ -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>