Files
yingqing/core/backend/apps/accounts/views.py
T
seaislee1209andClaude Opus 4.8 443023a1a9 feat(admin): Phase 0 平台超管基础 — is_platform_admin + IsPlatformAdmin + create_platform_admin(admin/admin123) + AdminAuditLog + Admin 后台外壳与路由 gating
后端:User.is_platform_admin + migration;权限类 IsPlatformAdmin;管理命令建 admin/admin123(幂等);
AdminAuditLog 模型 + log_admin_action() helper;me/login 对无团队超管优雅返回 team=null;UserSerializer 暴露标志。
前端:routes/admin 后台外壳(分组侧栏 + 概览 + 占位)、/admin 路由解析与 gating(超管直落、非超管纠回)、
侧栏平台入口、admin-page.css(仅 token)、IconKitSvg 补图标。
测试:accounts 11/11 单测过;无头 e2e _admin-p0.mjs 全断言过 + 0 console error;tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:49:34 +08:00

412 lines
17 KiB
Python

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 Invitation, LoginSession, TeamMember, User, UserPreference
from .serializers import (
InvitationSerializer,
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 if team is not None else None,
}
def current_team_or_none(user):
"""平台超管可不属于任何团队;此时取团队返回 None(而非抛 PermissionDenied)。
普通用户无团队仍属异常,但后台账号体系下登录/资料接口不应因此 500。"""
from rest_framework.exceptions import PermissionDenied
try:
return get_current_team(user)
except PermissionDenied:
return None
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 = current_team_or_none(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 = current_team_or_none(user)
return Response(
{
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data if team is not None else None,
}
)
@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(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_invitations(request):
"""团队邀请码:GET 列本团队邀请;POST 生成新码。权限=超管/团管(复用 can_manage_team)。"""
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
if request.method == "GET":
invites = team.invitations.select_related("used_by").order_by("-created_at")
return Response(InvitationSerializer(invites, many=True).data)
role = normalize_member_role(request.data.get("role"))
if role == TeamMember.Role.OWNER:
role = TeamMember.Role.ADMIN # 邀请不允许 OWNER(Team.owner 唯一)
invite = Invitation.objects.create(
team=team,
role=role,
email=str(request.data.get("email") or "").strip(),
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,
created_by=request.user,
)
return Response(InvitationSerializer(invite).data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_invitation(request, invite_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)
invite = Invitation.objects.filter(team=team, id=invite_id).first()
if invite is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if invite.status == Invitation.Status.PENDING:
invite.status = Invitation.Status.REVOKED
invite.save(update_fields=["status", "updated_at"])
return Response(InvitationSerializer(invite).data)
@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})