From 16207ae309ecc985be679084efb7c5e199ff5721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 14:29:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AB=99=E7=82=B9=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/POST /api/admin/sites、PATCH /api/admin/sites/ 登录成员可用; DELETE 仅 admin,走软删(is_active=False),物理清除留待未来管理命令 分批删(防大站级联删除锁表),CASCADE 仅完整性兜底。域名归一去协议/ 端口/路径、小写、去空去重;错误响应统一信封(EnvelopeMixin)。 软删后 collect 上报 403 not_allowed(Task7 联动,测试锁定)。 Co-Authored-By: Claude Code --- server/config/urls.py | 3 + server/tracker/api/sites.py | 84 ++++++++++++++++++++++ server/tracker/tests/test_sites.py | 109 +++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 server/tracker/api/sites.py create mode 100644 server/tracker/tests/test_sites.py diff --git a/server/config/urls.py b/server/config/urls.py index bfb01c5..781f956 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -1,6 +1,7 @@ from django.urls import path from tracker.api.collect import CollectBatchView from tracker.api.auth import LoginView, RefreshView, MeView, UserCreateView +from tracker.api.sites import SiteListView, SiteDetailView urlpatterns = [ path("api/collect/batch", CollectBatchView.as_view()), @@ -11,4 +12,6 @@ urlpatterns += [ path("api/admin/auth/refresh", RefreshView.as_view()), path("api/admin/auth/me", MeView.as_view()), path("api/admin/users", UserCreateView.as_view()), + path("api/admin/sites", SiteListView.as_view()), + path("api/admin/sites/", SiteDetailView.as_view()), ] diff --git a/server/tracker/api/sites.py b/server/tracker/api/sites.py new file mode 100644 index 0000000..63334fa --- /dev/null +++ b/server/tracker/api/sites.py @@ -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") diff --git a/server/tracker/tests/test_sites.py b/server/tracker/tests/test_sites.py new file mode 100644 index 0000000..eeaf729 --- /dev/null +++ b/server/tracker/tests/test_sites.py @@ -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"