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)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备, 已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免「在用设备」列表里同设备重复堆叠)。""" try: user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400] ip_address = _client_ip(request) existing = ( LoginSession.objects.filter( user=user, user_agent=user_agent, ip_address=ip_address, revoked_at__isnull=True ) .order_by("-last_seen_at") .first() ) if existing: existing.save(update_fields=["last_seen_at"]) # auto_now 刷新最近活跃时间 else: LoginSession.objects.create(user=user, user_agent=user_agent, ip_address=ip_address) 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}) def _month_charged_by_user(team): """本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。""" from django.db.models import Sum from django.utils import timezone from apps.billing.models import CreditLedger month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) rows = ( CreditLedger.objects.filter( team=team, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start, ) .values("user_id") .annotate(total=Sum("amount")) ) return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None} @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") charged_map = _month_charged_by_user(team) return Response( TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).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 台)。 去重规则:同一台电脑(UA)+ 同一 IP 只算一台,取该设备最近一次会话展示(兼容历史已堆叠的重复行)。""" queryset = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).order_by("-last_seen_at") seen: set = set() unique = [] for session in queryset: key = (session.user_agent, session.ip_address) if key in seen: continue seen.add(key) unique.append(session) if len(unique) >= 20: break current_ip = _client_ip(request) current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400] data = LoginSessionSerializer(unique, 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): """下线单个设备:把同一台设备(UA + IP)下的所有未下线会话一并下线, 否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。""" from django.utils import timezone target = LoginSession.objects.filter(user=request.user, id=session_id).first() if not target: return Response({"revoked": 0}) updated = LoginSession.objects.filter( user=request.user, user_agent=target.user_agent, ip_address=target.ip_address, 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})