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:
@@ -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()),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user