后端: - accounts: 团队级月限额持久化(Team.monthly_credit_limit 三态 + PATCH/GET /api/auth/team/settings/,刷新不丢,PMC#12);头像上传 500→502 可读错误; 设备下线真失效(删并重建 token,旧 token 401) - assets: 审核类目加 model_portrait,消除三视图"无需审核"误报 - ai/projects/products: 模特已有三视图复用、产品三视图同步回商品库 (metadata.view=three_view)、真人模特三视图回写 前端: - 商品图删除判断改用真实图片数;团队成员弹窗禁点外部关闭 - 图片预览骨架铺满占位;平台套图左侧栏折叠改导航同款;收件箱长文换行对齐 - 团队月限额改真落库(乐观更新+失败回滚) 测试:新增 TeamSettingsTests(5 条),accounts 全套 36 tests 通过;前端 build 0 error Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
541 lines
24 KiB
Python
541 lines
24 KiB
Python
import uuid
|
|
from decimal import Decimal, InvalidOperation
|
|
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, Team, TeamMember, User, UserPreference
|
|
from .serializers import (
|
|
InvitationSerializer,
|
|
LoginSerializer,
|
|
LoginSessionSerializer,
|
|
RegisterSerializer,
|
|
TeamMemberSerializer,
|
|
TeamSerializer,
|
|
UserPreferenceSerializer,
|
|
UserSerializer,
|
|
)
|
|
|
|
|
|
# 子账号默认月度限额(0=不限,只留给主账号/超管)
|
|
DEFAULT_MEMBER_MONTHLY_LIMIT = 100
|
|
|
|
|
|
def member_role(user, team):
|
|
"""当前用户在当前团队的成员角色(owner/admin/member);无团队成员关系返回 ""。
|
|
前端据此决定「团队」「消费」等仅主账号(超管)可见的页面是否展示(PMC#3)。"""
|
|
if team is None:
|
|
return ""
|
|
m = TeamMember.objects.filter(team=team, user=user, status=TeamMember.Status.ACTIVE).first()
|
|
return m.role if m else ""
|
|
|
|
|
|
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,
|
|
"role": member_role(user, team),
|
|
}
|
|
|
|
|
|
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 validate_invite(request):
|
|
"""免登录验码:注册页「填码 → 验过才展开表单」用。
|
|
返回 {valid, kind, team_name?, role?};无效返回 {valid:false, detail}(始终 200,不暴露是否存在以外的细节)。"""
|
|
from django.utils import timezone
|
|
|
|
code = str(request.data.get("code") or request.data.get("invite_code") or "").strip().upper()
|
|
if not code:
|
|
return Response({"valid": False, "detail": "请填写邀请码"})
|
|
inv = Invitation.objects.select_related("team").filter(code=code).first()
|
|
if inv is None:
|
|
return Response({"valid": False, "detail": "邀请码不存在"})
|
|
if inv.status != Invitation.Status.PENDING:
|
|
return Response({"valid": False, "detail": "邀请码已被使用或已撤销"})
|
|
if inv.expires_at <= timezone.now():
|
|
return Response({"valid": False, "detail": "邀请码已过期"})
|
|
return Response(
|
|
{
|
|
"valid": True,
|
|
"kind": inv.kind,
|
|
"role": inv.role,
|
|
# join_team 回团队名(只读展示);create_team 无团队
|
|
"team_name": inv.team.name if inv.team_id else None,
|
|
}
|
|
)
|
|
|
|
|
|
@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)
|
|
# 团队被平台超管停用:其成员一律不准登录(平台超管无团队,不受影响)
|
|
if team is not None and team.status == Team.Status.DISABLED:
|
|
return Response({"detail": "团队已停用,请联系平台管理员"}, status=status.HTTP_400_BAD_REQUEST)
|
|
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,
|
|
"role": member_role(user, team),
|
|
}
|
|
)
|
|
|
|
|
|
@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 or "").suffix.lower() or ".png"
|
|
object_key = f"users/{user.id}/avatar/{uuid.uuid4()}{suffix}"
|
|
# 对象存储上传是唯一会抛异常的环节(凭证/网络/桶不可达)。不捕获则裸抛 500,
|
|
# 前端只看到「服务器错误 500」查不到真因(与全站「raise_for_status 吞 body」同类坑)。
|
|
# 捕获后回 502 + 人话,既不掩盖问题(日志仍有栈)又让前端 toast 可读。
|
|
try:
|
|
storage = TosStorage()
|
|
storage.upload_fileobj(
|
|
fileobj=upload.file,
|
|
object_key=object_key,
|
|
content_type=upload.content_type or "image/png",
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — 对象存储故障归一成可读错误,不裸 500
|
|
import logging
|
|
|
|
logging.getLogger(__name__).exception("avatar upload to TOS failed: %s", exc)
|
|
return Response(
|
|
{"detail": "头像上传失败,请稍后重试(对象存储不可用)"},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
# 桶公读,存稳定的虚拟主机式直链(全站资产同款 public_url)——不存预签名 URL:
|
|
# 预签名 URL 带一长串 SigV4 查询参数(常 >300 字符),会超出 avatar_url 这个 URLField
|
|
# 的默认 max_length=200,MySQL 严格模式下 save 抛 DataError(Data too long)→ 500;
|
|
# 且预签名 1h/7d 必过期。直链短、稳定、可缓存,既修 500 又一劳永逸。
|
|
user.avatar_url = storage.public_url(object_key=object_key)
|
|
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 _charged_by_user(team, since=None):
|
|
"""本团队每个成员在 since 之后的消费(CHARGE 流水)合计:{user_id: Decimal}。since=None 即累计全量。"""
|
|
from django.db.models import Sum
|
|
|
|
from apps.billing.models import CreditLedger
|
|
|
|
qs = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE)
|
|
if since is not None:
|
|
qs = qs.filter(created_at__gte=since)
|
|
rows = qs.values("user_id").annotate(total=Sum("amount"))
|
|
return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None}
|
|
|
|
|
|
def _month_charged_by_user(team):
|
|
"""本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。"""
|
|
from django.utils import timezone
|
|
|
|
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
return _charged_by_user(team, since=month_start)
|
|
|
|
|
|
def _normalize_limit(raw, default=None):
|
|
"""额度入参归一:前端 -1 / 空 = 不限 → 存 0(0=不限);其余取数值。default 用于 create 缺省。"""
|
|
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
return default
|
|
try:
|
|
val = Decimal(str(raw))
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return default
|
|
return Decimal("0") if val < 0 else val
|
|
|
|
|
|
@api_view(["GET", "POST"])
|
|
@permission_classes([IsAuthenticated])
|
|
def team_members(request):
|
|
team = get_current_team(request.user)
|
|
if request.method == "GET":
|
|
from django.utils import timezone
|
|
|
|
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
|
|
day_start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
ctx = {
|
|
"charged_map": _month_charged_by_user(team),
|
|
"day_charged_map": _charged_by_user(team, since=day_start),
|
|
"total_charged_map": _charged_by_user(team, since=None),
|
|
}
|
|
return Response(TeamMemberSerializer(members, many=True, context=ctx).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,
|
|
# 三档额度(0=不限);月度默认 100(0=不限只留给主账号/超管)—— 避免新成员不设限就无上限地花团队池(PMC#3)。
|
|
daily_credit_limit=_normalize_limit(request.data.get("daily_credit_limit") or request.data.get("daily"), default=0),
|
|
monthly_credit_limit=_normalize_limit(request.data.get("monthly_credit_limit") or request.data.get("monthly"), default=DEFAULT_MEMBER_MONTHLY_LIMIT),
|
|
total_credit_limit=_normalize_limit(request.data.get("total_credit_limit") or request.data.get("total"), default=0),
|
|
)
|
|
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
@api_view(["GET", "PATCH"])
|
|
@permission_classes([IsAuthenticated])
|
|
def team_settings(request):
|
|
"""团队级设置:目前承载「设置月限额」(团队页超管设置,刷新后持久化 · PMC#12)。
|
|
GET 任意成员可读;PATCH 仅超管/团管(can_manage_team)。
|
|
monthly_credit_limit 三态:null=未设置(按成员累加)· -1=不限 · >=0=固定上限。"""
|
|
team = get_current_team(request.user)
|
|
if request.method == "GET":
|
|
return Response(TeamSerializer(team).data)
|
|
|
|
if not can_manage_team(request.user, team):
|
|
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
if "monthly_credit_limit" in request.data:
|
|
raw = request.data.get("monthly_credit_limit")
|
|
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
team.monthly_credit_limit = None # 清空 = 回到「未设置(按成员累加)」
|
|
else:
|
|
try:
|
|
team.monthly_credit_limit = Decimal(str(raw))
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return Response({"monthly_credit_limit": ["invalid number"]}, status=status.HTTP_400_BAD_REQUEST)
|
|
team.save(update_fields=["monthly_credit_limit", "updated_at"])
|
|
return Response(TeamSerializer(team).data)
|
|
|
|
|
|
@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,
|
|
kind=Invitation.Kind.JOIN_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 DEFAULT_MEMBER_MONTHLY_LIMIT,
|
|
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
|
|
update_fields = ["role", "updated_at"]
|
|
if "daily_credit_limit" in request.data or "daily" in request.data:
|
|
member.daily_credit_limit = _normalize_limit(request.data.get("daily_credit_limit", request.data.get("daily")), default=0)
|
|
update_fields.append("daily_credit_limit")
|
|
if "monthly_credit_limit" in request.data or "monthly" in request.data:
|
|
member.monthly_credit_limit = _normalize_limit(request.data.get("monthly_credit_limit", request.data.get("monthly")), default=0)
|
|
update_fields.append("monthly_credit_limit")
|
|
if "total_credit_limit" in request.data or "total" in request.data:
|
|
member.total_credit_limit = _normalize_limit(request.data.get("total_credit_limit", request.data.get("total")), default=0)
|
|
update_fields.append("total_credit_limit")
|
|
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=update_fields)
|
|
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)下的所有未下线会话一并下线,
|
|
否则去重展示的一台设备点「下线」后,底层其它重复会话仍存活会再次冒出来。
|
|
|
|
关键:DRF TokenAuthentication 是「一用户一 token」(authtoken 共享一行),
|
|
只标记 LoginSession.revoked_at 根本不动 token —— 被下线的设备仍拿着有效 token,
|
|
继续访问畅通无阻(bug:提示「设备已下线」但实际没下线)。单 token 体系下无法
|
|
只吊销某一台,唯一能真正下线的做法 = 旋转 token(删旧 + 发新),令所有端旧 token
|
|
立即失效,再把新 token 发回当前设备(同 revoke_other_sessions 的思路)。
|
|
被下线的目标设备下次请求即 401,真正离线。"""
|
|
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())
|
|
# 真正吊销:旋转用户 token(令被下线设备旧 token 立即失效),再发新 token 给当前设备。
|
|
Token.objects.filter(user=request.user).delete()
|
|
token, _ = Token.objects.get_or_create(user=request.user)
|
|
# 刷新当前设备这条会话(旋转后当前设备等价于「重新登录」),避免它被自己下线后又冒出来
|
|
record_login_session(request, request.user)
|
|
return Response({"revoked": updated, "token": token.key})
|
|
|
|
|
|
@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})
|