aifeilong 13b1ca2b9b Namespace API routes by app and flatten package layout.
Move Django apps out of apps/ and mount them under auth/, common/, content/, and cms/ so endpoint paths map directly to the owning app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 08:42:37 +08:00

253 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from django.contrib.auth import authenticate
from django.contrib.auth.models import User
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions, serializers, status, viewsets
from rest_framework.authtoken.models import Token
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Member
from .phone import normalize_phone
from .serializers import (
MemberPublicSerializer,
MemberSerializer,
StaffUserSerializer,
)
def _validation_detail(exc):
detail = getattr(exc, "detail", None)
if isinstance(detail, (list, tuple)) and detail:
return str(detail[0])
if isinstance(detail, dict):
for value in detail.values():
if isinstance(value, (list, tuple)) and value:
return str(value[0])
return str(value)
return str(detail or exc)
class IsStaff(permissions.BasePermission):
def has_permission(self, request, view):
return bool(request.user and request.user.is_authenticated and request.user.is_staff)
class LoginView(APIView):
"""后台登录,返回 Token。"""
permission_classes = [permissions.AllowAny]
def post(self, request):
username = request.data.get("username") or request.data.get("account")
password = request.data.get("password") or request.data.get("pwd")
user = authenticate(username=username, password=password)
if not user or not user.is_staff:
return Response(
{"detail": "用户名或密码错误,或无后台权限"},
status=status.HTTP_400_BAD_REQUEST,
)
token, _ = Token.objects.get_or_create(user=user)
return Response(
{
"token": token.key,
"username": user.username,
"roles": ["admin"] if user.is_superuser else ["staff"],
}
)
class MeView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
user = request.user
return Response(
{
"username": user.username,
"roles": ["admin"] if user.is_superuser else ["staff"],
"is_staff": user.is_staff,
}
)
class RegisterView(APIView):
"""仅 staff 可创建其他 staff(替代开放注册)。"""
permission_classes = [IsStaff]
def post(self, request):
serializer = StaffUserSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
token, _ = Token.objects.get_or_create(user=user)
return Response(
{
"token": token.key,
"username": user.username,
"roles": ["admin"] if user.is_superuser else ["staff"],
},
status=status.HTTP_201_CREATED,
)
class StaffUserViewSet(viewsets.ModelViewSet):
"""系统人员:Django User 且 is_staff=True。"""
serializer_class = StaffUserSerializer
permission_classes = [IsStaff]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["is_active", "is_superuser"]
def get_queryset(self):
return User.objects.filter(is_staff=True).order_by("id")
def perform_destroy(self, instance):
# 禁止删除自己;软禁用更安全,这里改为 is_active=False
if instance.pk == self.request.user.pk:
raise PermissionDenied("不能删除当前登录账号")
instance.is_active = False
instance.save(update_fields=["is_active"])
class MemberViewSet(viewsets.ModelViewSet):
"""前台用户管理。"""
queryset = Member.objects.all()
serializer_class = MemberSerializer
permission_classes = [IsStaff]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["is_vip", "is_disabled"]
def get_queryset(self):
qs = super().get_queryset()
q = self.request.query_params.get("search") or self.request.query_params.get("q")
if q:
qs = qs.filter(Q(name__icontains=q) | Q(phone__icontains=q))
return qs
@action(detail=False, methods=["post"])
def batch_vip(self, request):
ids = request.data.get("ids") or []
is_vip = bool(request.data.get("is_vip"))
if not ids:
return Response({"detail": "ids 必填"}, status=status.HTTP_400_BAD_REQUEST)
updated = Member.objects.filter(id__in=ids).update(is_vip=is_vip)
return Response({"updated": updated, "is_vip": is_vip})
def _member_login_response(member):
if not member.user_id:
return None
token, _ = Token.objects.get_or_create(user=member.user)
member.last_login_at = timezone.now()
member.save(update_fields=["last_login_at", "updated_at"])
return {
"token": token.key,
"member": MemberPublicSerializer(member).data,
}
class MemberLoginView(APIView):
"""前台会员登录(手机号 + 密码)。"""
permission_classes = [permissions.AllowAny]
def post(self, request):
raw_phone = request.data.get("phone") or request.data.get("username") or ""
password = request.data.get("password") or request.data.get("pwd") or ""
try:
phone = normalize_phone(raw_phone, required=True)
except serializers.ValidationError as exc:
return Response(
{"detail": _validation_detail(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
if not password:
return Response(
{"detail": "请输入手机号和密码"},
status=status.HTTP_400_BAD_REQUEST,
)
member = Member.objects.filter(phone=phone).select_related("user").first()
# 兼容尚未迁移的国内 11 位旧数据
if not member and phone.startswith("+86") and len(phone) == 14:
member = (
Member.objects.filter(phone=phone[3:])
.select_related("user")
.first()
)
if not member or member.is_disabled or not member.user_id:
return Response(
{"detail": "手机号或密码错误"},
status=status.HTTP_400_BAD_REQUEST,
)
user = authenticate(username=member.user.username, password=password)
if not user or user.is_staff:
return Response(
{"detail": "手机号或密码错误"},
status=status.HTTP_400_BAD_REQUEST,
)
payload = _member_login_response(member)
return Response(payload)
class MemberRegisterView(APIView):
"""前台会员注册。"""
permission_classes = [permissions.AllowAny]
@transaction.atomic
def post(self, request):
try:
phone = normalize_phone(request.data.get("phone"), required=True)
except serializers.ValidationError as exc:
return Response(
{"detail": _validation_detail(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
data = {
"name": (request.data.get("name") or "").strip(),
"phone": phone,
"organize": (request.data.get("organize") or "").strip(),
"password": request.data.get("password") or "",
"is_vip": False,
"is_disabled": False,
}
if not data["name"]:
return Response({"detail": "请输入姓名"}, status=status.HTTP_400_BAD_REQUEST)
if len(data["password"]) < 6:
return Response(
{"detail": "密码至少 6 位"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = MemberSerializer(data=data)
serializer.is_valid(raise_exception=True)
member = serializer.save()
payload = _member_login_response(member)
return Response(payload, status=status.HTTP_201_CREATED)
class MemberMeView(APIView):
"""当前前台会员信息。"""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
user = request.user
if user.is_staff:
return Response(
{"detail": "当前为后台账号,请使用前台会员登录"},
status=status.HTTP_403_FORBIDDEN,
)
member = getattr(user, "member_profile", None)
if not member or member.is_disabled:
return Response(
{"detail": "会员不存在或已禁用"},
status=status.HTTP_403_FORBIDDEN,
)
return Response(MemberPublicSerializer(member).data)