feat: 站点管理接口

GET/POST /api/admin/sites、PATCH /api/admin/sites/<pk> 登录成员可用;
DELETE 仅 admin,走软删(is_active=False),物理清除留待未来管理命令
分批删(防大站级联删除锁表),CASCADE 仅完整性兜底。域名归一去协议/
端口/路径、小写、去空去重;错误响应统一信封(EnvelopeMixin)。
软删后 collect 上报 403 not_allowed(Task7 联动,测试锁定)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 14:29:18 +08:00
parent f22786d746
commit 16207ae309
3 changed files with 196 additions and 0 deletions
+3
View File
@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from tracker.api.collect import CollectBatchView from tracker.api.collect import CollectBatchView
from tracker.api.auth import LoginView, RefreshView, MeView, UserCreateView from tracker.api.auth import LoginView, RefreshView, MeView, UserCreateView
from tracker.api.sites import SiteListView, SiteDetailView
urlpatterns = [ urlpatterns = [
path("api/collect/batch", CollectBatchView.as_view()), path("api/collect/batch", CollectBatchView.as_view()),
@@ -11,4 +12,6 @@ urlpatterns += [
path("api/admin/auth/refresh", RefreshView.as_view()), path("api/admin/auth/refresh", RefreshView.as_view()),
path("api/admin/auth/me", MeView.as_view()), path("api/admin/auth/me", MeView.as_view()),
path("api/admin/users", UserCreateView.as_view()), path("api/admin/users", UserCreateView.as_view()),
path("api/admin/sites", SiteListView.as_view()),
path("api/admin/sites/<int:pk>", SiteDetailView.as_view()),
] ]
+84
View File
@@ -0,0 +1,84 @@
from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAdminUser
from tracker.api.envelope import EnvelopeMixin, ok
from tracker.models import Site
def _norm_domains(raw) -> list[str]:
"""域名归一:去协议/端口/路径、转小写、去空去重。"""
out = []
for d in raw or []:
d = str(d).strip().lower()
for pre in ("https://", "http://"):
if d.startswith(pre):
d = d[len(pre):]
d = d.split("/")[0].split(":")[0]
if d and d not in out:
out.append(d)
return out
def _serialize(site: Site) -> dict:
return {"id": site.pk, "name": site.name, "site_id": site.site_id,
"domains": site.domains, "allow_no_origin": site.allow_no_origin,
"is_active": site.is_active, "created_at": site.created_at.isoformat()}
class SiteListView(EnvelopeMixin, APIView):
def get(self, request):
return ok([_serialize(s) for s in Site.objects.order_by("-created_at")])
def post(self, request):
data = request.data if isinstance(request.data, dict) else {} # JSON 数组等畸形 body → 空表(.get 会 500)
name = data.get("name")
if not isinstance(name, str) or not name.strip():
return Response({"code": 400, "data": None, "message": "invalid_name"}, status=400)
domains = _norm_domains(data.get("domains"))
if not domains:
return Response({"code": 400, "data": None, "message": "invalid_domains"}, status=400)
site = Site.objects.create(name=name.strip()[:100], domains=domains,
allow_no_origin=bool(data.get("allow_no_origin")))
return ok(_serialize(site), "created")
class SiteDetailView(EnvelopeMixin, APIView):
def get_permissions(self):
# DELETE(软删)仅 admin;GET/PATCH 登录成员即可(settings 默认 IsAuthenticated)。
# 在 initial() 阶段判权限:403 先于 404,且不泄露站点存在性。
if self.request.method == "DELETE":
return [IsAdminUser()]
return super().get_permissions()
def _get(self, request, pk):
return get_object_or_404(Site, pk=pk)
def patch(self, request, pk):
site = self._get(request, pk)
data = request.data if isinstance(request.data, dict) else {}
if "name" in data:
name = data["name"]
if not isinstance(name, str) or not name.strip():
return Response({"code": 400, "data": None, "message": "invalid_name"}, status=400)
site.name = name.strip()[:100]
if "domains" in data:
domains = _norm_domains(data["domains"])
if not domains:
return Response({"code": 400, "data": None, "message": "invalid_domains"}, status=400)
site.domains = domains
if "allow_no_origin" in data:
site.allow_no_origin = bool(data["allow_no_origin"])
if "is_active" in data:
site.is_active = bool(data["is_active"])
site.save()
return ok(_serialize(site))
def delete(self, request, pk):
# 软删:默认停用即可(上报侧 collect 见 is_active=False 即 403,Task7 联动)。
# 物理清除走未来管理命令分批删——大站单语句级联删除有锁表/binlog 风暴风险,
# CASCADE 仅作完整性兜底,不在请求路径触发。
site = self._get(request, pk)
site.is_active = False
site.save(update_fields=["is_active", "updated_at"])
return ok(_serialize(site), "deactivated")
+109
View File
@@ -0,0 +1,109 @@
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from tracker.models import Site
pytestmark = pytest.mark.django_db
User = get_user_model()
@pytest.fixture
def client(): # 覆盖 pytest-django 的 Django Client——认证用例需要 credentials()/format="json"
return APIClient()
@pytest.fixture
def admin_user():
return User.objects.create_user(username="admin", password="adminpass123", is_staff=True)
@pytest.fixture
def member_user():
return User.objects.create_user(username="member", password="memberpass123")
def _auth(client, user):
from rest_framework_simplejwt.tokens import RefreshToken
token = RefreshToken.for_user(user)
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
def test_sites_require_auth(client):
assert client.get("/api/admin/sites").status_code == 401
def test_create_site_normalizes_domains(client, member_user):
_auth(client, member_user)
r = client.post("/api/admin/sites",
{"name": "官网", "domains": ["https://Example.com/", "example.com", " bad.com ",
"http://Sub.Example.com:8443/path?q=1"]}, format="json")
assert r.status_code == 200 and r.json()["code"] == 200
d = r.json()["data"]
assert d["domains"] == ["example.com", "bad.com", "sub.example.com"] # 去协议/端口/路径/去重/strip/小写
assert d["site_id"].startswith("site_") and d["is_active"] is True
assert Site.objects.filter(site_id=d["site_id"]).exists() # 真落库
def test_create_site_validates(client, member_user):
_auth(client, member_user)
assert client.post("/api/admin/sites", {"name": "x", "domains": []},
format="json").status_code == 400
assert client.post("/api/admin/sites", {"domains": ["a.com"]},
format="json").status_code == 400
assert client.post("/api/admin/sites", ["arr"], format="json").status_code == 400 # 畸形 body 不 500
def test_list_and_patch(client, member_user):
_auth(client, member_user)
site = Site.objects.create(name="a", domains=["a.com"])
r = client.get("/api/admin/sites")
assert len(r.json()["data"]) == 1 and r.json()["data"][0]["id"] == site.pk
r = client.patch(f"/api/admin/sites/{site.pk}",
{"domains": ["new.com"], "is_active": False}, format="json")
assert r.status_code == 200
site.refresh_from_db()
assert site.domains == ["new.com"] and site.is_active is False
# name/allow_no_origin 也可更新
r = client.patch(f"/api/admin/sites/{site.pk}",
{"name": " 新名 ", "allow_no_origin": True}, format="json")
site.refresh_from_db()
assert site.name == "新名" and site.allow_no_origin is True
def test_patch_validates_and_404_envelope(client, member_user):
_auth(client, member_user)
site = Site.objects.create(name="a", domains=["a.com"])
assert client.patch(f"/api/admin/sites/{site.pk}",
{"domains": []}, format="json").status_code == 400
assert client.patch(f"/api/admin/sites/{site.pk}",
{"name": " "}, format="json").status_code == 400
r = client.patch("/api/admin/sites/99999", {"name": "x"}, format="json")
assert r.status_code == 404
body = r.json() # 404 同样走信封(Task17 前端单条解析路径)
assert body["code"] == 404 and body["data"] is None and body["message"]
def test_delete_member_403_admin_ok(client, member_user, admin_user):
_auth(client, member_user)
site = Site.objects.create(name="a", domains=["a.com"])
r = client.delete(f"/api/admin/sites/{site.pk}")
assert r.status_code == 403 and r.json()["code"] == 403 # 权限拒绝也带信封
_auth(client, admin_user)
r = client.delete(f"/api/admin/sites/{site.pk}")
assert r.status_code == 200
site.refresh_from_db()
assert site.is_active is False # 软删:行还在
assert Site.objects.filter(pk=site.pk).exists()
def test_delete_soft_keeps_collect_rejects(client, admin_user):
"""软删与 collect 联动:is_active=False 后上报 403 not_allowed(Task7 口径)。"""
import json
_auth(client, admin_user)
site = Site.objects.create(name="a", domains=["a.com"], allow_no_origin=True)
assert client.delete(f"/api/admin/sites/{site.pk}").status_code == 200
r = client.post("/api/collect/batch",
data=json.dumps({"site_id": site.site_id, "visitor_id": "v1",
"session_id": "s1", "events": []}),
content_type="text/plain", HTTP_ORIGIN="https://a.com")
assert r.status_code == 403 and r.json()["message"] == "not_allowed"