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>
253 lines
8.7 KiB
Python
253 lines
8.7 KiB
Python
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)
|