feat: JWT 登录与用户接口

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 14:01:59 +08:00
parent 77a7f568b2
commit 754ae1d39d
3 changed files with 167 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
from django.contrib.auth import get_user_model
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.throttling import ScopedRateThrottle
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
def _ok(data, message="ok"):
return Response({"code": 200, "data": data, "message": message})
class _EnvelopeMixin:
"""错误信封统一收口(Task7 同款裁剪,无 ACAO——管理台与 API 同源,跨域由 nginx 层面不存在)。
simplejwt 认证失败 401 / 权限 403 / 限流 429 等由 DRF 异常处理器产出的响应
body 是 {detail},管理台前端(Task17)按 {code,data,message} 信封对接——
在此统一转换,视图自身的 _ok/4xx 响应不受影响(只动含 detail 的 dict)。
"""
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if response.status_code >= 400 and isinstance(response.data, dict) and "detail" in response.data:
response.data = {"code": response.status_code, "data": None,
"message": response.data["detail"]}
return response
class LoginView(_EnvelopeMixin, TokenObtainPairView):
"""登录。JWT 取舍(M1 决策):access 12h / refresh 24h、无黑名单轮换——
token 泄露唯一应急是换 SECRET_KEY(全体登出),README-deploy 写明。"""
throttle_classes = [ScopedRateThrottle]
throttle_scope = "login" # 10/min/IP 防爆破(settings 已配)
def post(self, request, *args, **kwargs):
resp = super().post(request, *args, **kwargs) # 失败由 simplejwt 返回 401(信封由 mixin 补)
if resp.status_code == 200:
return _ok({"access": resp.data["access"], "refresh": resp.data["refresh"]})
return resp
class RefreshView(_EnvelopeMixin, TokenRefreshView):
permission_classes = [AllowAny] # TokenRefreshView 自带 AllowAny,显式声明表意
def post(self, request, *args, **kwargs):
resp = super().post(request, *args, **kwargs)
if resp.status_code == 200: # 成功同样走信封——与 LoginView 对齐,前端只需一种解析路径
return _ok({"access": resp.data["access"]})
return resp
class MeView(_EnvelopeMixin, APIView):
def get(self, request):
return _ok({"username": request.user.username,
"is_admin": request.user.is_staff})
class UserCreateView(_EnvelopeMixin, APIView):
permission_classes = [IsAdminUser] # is_staff
def post(self, request):
username = (request.data.get("username") or "").strip()
password = request.data.get("password") or ""
# 用户名长度上限对齐 Django User.username(max_length=150)——create_user 不跑
# full_clean,超长会直奔 MySQL DataError 500,故入口拦截
if not username or len(username) > 150 or len(password) < 8:
return Response({"code": 400, "data": None, "message": "invalid_input"}, status=400)
User = get_user_model()
if User.objects.filter(username=username).exists():
return Response({"code": 409, "data": None, "message": "username_exists"}, status=409)
User.objects.create_user(username=username, password=password, is_staff=False)
return _ok({"username": username}, "created")