import uuid from pathlib import Path from django.contrib.auth import authenticate from django.db import transaction from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, parser_classes, permission_classes from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.common.api import get_current_team from .models import LoginSession, TeamMember, User, UserPreference from .serializers import ( LoginSerializer, LoginSessionSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserPreferenceSerializer, UserSerializer, ) def auth_payload(user, team, token): return { "token": token.key, "user": UserSerializer(user).data, "team": TeamSerializer(team).data, } def _client_ip(request): forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "") if forwarded: return forwarded.split(",")[0].strip() return request.META.get("REMOTE_ADDR") or None def record_login_session(request, user): """登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。""" try: LoginSession.objects.create( user=user, user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400], ip_address=_client_ip(request), ) except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录 pass @api_view(["POST"]) @permission_classes([]) def register(request): serializer = RegisterSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.save() token, _ = Token.objects.get_or_create(user=data["user"]) record_login_session(request, data["user"]) return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED) @api_view(["POST"]) @permission_classes([]) def login(request): serializer = LoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = authenticate( request, username=serializer.validated_data["username"], password=serializer.validated_data["password"], ) if user is None or user.is_disabled: return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST) team = get_current_team(user) token, _ = Token.objects.get_or_create(user=user) record_login_session(request, user) return Response(auth_payload(user, team, token)) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def logout(request): Token.objects.filter(user=request.user).delete() return Response(status=status.HTTP_204_NO_CONTENT) @api_view(["GET", "PATCH"]) @permission_classes([IsAuthenticated]) def me(request): user = request.user if request.method == "PATCH": if "name" in request.data: user.first_name = str(request.data.get("name") or "").strip() if "phone" in request.data: user.phone = str(request.data.get("phone") or "").strip()[:32] email = str(request.data.get("email") or "").strip() if email: user.email = email user.save(update_fields=["first_name", "phone", "email"]) team = get_current_team(user) return Response( { "user": UserSerializer(user).data, "team": TeamSerializer(team).data, } ) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def change_password(request): user = request.user old_password = str(request.data.get("old_password") or "") new_password = str(request.data.get("new_password") or "").strip() if not user.check_password(old_password): return Response({"old_password": ["原密码不正确"]}, status=status.HTTP_400_BAD_REQUEST) if len(new_password) < 8: return Response({"new_password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST) user.set_password(new_password) user.save(update_fields=["password"]) Token.objects.filter(user=user).delete() token, _ = Token.objects.get_or_create(user=user) return Response({"token": token.key}) @api_view(["POST", "DELETE"]) @parser_classes([MultiPartParser, FormParser]) @permission_classes([IsAuthenticated]) def update_avatar(request): from apps.assets.storage import TosStorage # DELETE = 恢复默认头像(清空 avatar_url,前端回退到首字母占位) if request.method == "DELETE": user = request.user user.avatar_url = "" user.save(update_fields=["avatar_url"]) return Response(UserSerializer(user).data) upload = request.FILES.get("file") if upload is None: return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST) user = request.user suffix = Path(upload.name).suffix.lower() or ".png" object_key = f"users/{user.id}/avatar/{uuid.uuid4()}{suffix}" storage = TosStorage() storage.upload_fileobj( fileobj=upload.file, object_key=object_key, content_type=upload.content_type or "image/png", ) # 头像直接存可访问的预签名 URL(长有效期);后续如需永久化可改为读时签发 user.avatar_url = storage.presigned_get_url(object_key=object_key, expires_in=7 * 24 * 3600) user.save(update_fields=["avatar_url"]) return Response(UserSerializer(user).data) def normalize_member_role(role): if role == "super": return TeamMember.Role.OWNER if role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN, TeamMember.Role.MEMBER, TeamMember.Role.VIEWER}: return role return TeamMember.Role.MEMBER def can_manage_team(user, team): member = TeamMember.objects.filter(team=team, user=user, status=TeamMember.Status.ACTIVE).first() return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN}) @api_view(["GET", "POST"]) @permission_classes([IsAuthenticated]) def team_members(request): team = get_current_team(request.user) if request.method == "GET": members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at") return Response(TeamMemberSerializer(members, many=True).data) if not can_manage_team(request.user, team): return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN) username = str(request.data.get("username") or "").strip() password = str(request.data.get("password") or "").strip() if not username: return Response({"username": ["This field is required."]}, status=status.HTTP_400_BAD_REQUEST) if len(password) < 8: return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST) if User.objects.filter(username=username).exists(): return Response({"username": ["username already exists"]}, status=status.HTTP_400_BAD_REQUEST) email = str(request.data.get("email") or "").strip() or f"{username}@airshelf.local" role = normalize_member_role(request.data.get("role")) if role == TeamMember.Role.OWNER: role = TeamMember.Role.ADMIN with transaction.atomic(): user = User.objects.create_user(username=username, password=password, email=email) user.first_name = str(request.data.get("name") or "").strip() user.save(update_fields=["first_name"]) member = TeamMember.objects.create( team=team, user=user, role=role, monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0, ) return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED) @api_view(["PATCH", "DELETE"]) @permission_classes([IsAuthenticated]) def team_member_detail(request, member_id): team = get_current_team(request.user) if not can_manage_team(request.user, team): return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN) member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first() if member is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) if member.user_id == team.owner_id: return Response({"detail": "team owner cannot be changed"}, status=status.HTTP_400_BAD_REQUEST) if request.method == "DELETE": user = member.user member.delete() if not TeamMember.objects.filter(user=user).exists(): user.status = User.Status.DISABLED user.save(update_fields=["status"]) Token.objects.filter(user=user).delete() return Response(status=status.HTTP_204_NO_CONTENT) role = request.data.get("role") if role: member.role = normalize_member_role(role) if member.role == TeamMember.Role.OWNER: member.role = TeamMember.Role.ADMIN if "monthly_credit_limit" in request.data or "monthly" in request.data: member.monthly_credit_limit = request.data.get("monthly_credit_limit", request.data.get("monthly")) or 0 name = str(request.data.get("name") or "").strip() if name: member.user.first_name = name member.user.save(update_fields=["first_name"]) member.save(update_fields=["role", "monthly_credit_limit", "updated_at"]) return Response(TeamMemberSerializer(member).data) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def team_member_password(request, member_id): team = get_current_team(request.user) if not can_manage_team(request.user, team): return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN) member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first() if member is None: return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) if member.user_id == team.owner_id: return Response({"detail": "team owner password cannot be reset here"}, status=status.HTTP_400_BAD_REQUEST) password = str(request.data.get("password") or "").strip() if len(password) < 8: return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST) member.user.set_password(password) member.user.save(update_fields=["password"]) Token.objects.filter(user=member.user).delete() return Response(status=status.HTTP_204_NO_CONTENT) @api_view(["GET", "PUT", "PATCH"]) @permission_classes([IsAuthenticated]) def preferences(request): """用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化。""" pref, _ = UserPreference.objects.get_or_create(user=request.user) if request.method in ("PUT", "PATCH"): serializer = UserPreferenceSerializer(pref, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() pref.refresh_from_db() return Response(UserPreferenceSerializer(pref).data) @api_view(["GET"]) @permission_classes([IsAuthenticated]) def login_sessions(request): """在用设备:返回未下线的登录会话(最近 20 条)。""" sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20] current_ip = _client_ip(request) current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400] data = LoginSessionSerializer(sessions, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data return Response(data) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def revoke_login_session(request, session_id): """下线单个设备会话。""" from django.utils import timezone updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update( revoked_at=timezone.now() ) return Response({"revoked": updated}) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def revoke_other_sessions(request): """下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。""" from django.utils import timezone LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).update(revoked_at=timezone.now()) Token.objects.filter(user=request.user).delete() token, _ = Token.objects.get_or_create(user=request.user) record_login_session(request, request.user) return Response({"token": token.key})