""" 认证模块 API ============ 采用"手机号 + JWT + 单设备登录"的认证方案。 核心接口: - /login: 手机号验证码登录(签发双 Token + SSE 踢人) - /refresh: Refresh Token 轮换(换取新 Token 对) - /logout: 登出(删除设备记录) - /send-code: 发送短信验证码 - /me: 获取当前用户信息 """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from app.api.deps import get_current_user from app.crud import user as user_crud from app.db.session import AsyncSession, get_db from app.models.user import User from app.schemas.auth import ( MobileLoginRequest, RefreshTokenRequest, SendSmsCodeRequest, TokenResponse, ) from app.schemas.common import ApiResponse, success_response from app.schemas.user import UserProfileResponse from app.services.auth_service import ( login_with_sms, logout, refresh_access_token, send_sms_code, ) router = APIRouter() @router.post("/send-code", response_model=ApiResponse[dict]) async def send_code( request: SendSmsCodeRequest, db: AsyncSession = Depends(get_db), ): """ 发送短信验证码 开发阶段验证码直接打印到日志,生产环境接入短信服务商。 每个手机号每日最多发送 10 次。 """ # 校验用户是否存在 user = await user_crud.get_by_mobile(db, mobile=request.mobile) if user is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在", ) try: await send_sms_code(request.mobile) except ValueError as e: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e), ) return success_response( data={"expire_minutes": 5}, message="验证码已发送", ) @router.post("/login", response_model=ApiResponse[TokenResponse]) async def login( request: MobileLoginRequest, db: AsyncSession = Depends(get_db), http_request: Request = None, ): """ 手机号验证码登录 流程: 1. 校验验证码 2. 获取或创建用户 3. 踢掉旧设备(SSE 推送) 4. 创建/覆盖设备记录 5. 签发双 Token(Access + Refresh) """ # 获取客户端 IP(优先从 Nginx 转发头读取真实公网 IP) client_ip = None if http_request: xff = http_request.headers.get("x-forwarded-for") if xff: client_ip = xff.split(",")[0].strip() else: xri = http_request.headers.get("x-real-ip") client_ip = xri or (http_request.client.host if http_request.client else None) try: result = await login_with_sms( db, mobile=request.mobile, code=request.code, device_id=request.device_id, device_name=request.device_name, os_info=request.os_info, app_version=request.app_version, ip=client_ip, ) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) return success_response( data=TokenResponse( access_token=result["access_token"], refresh_token=result["refresh_token"], user=result["user"], ), message="登录成功", ) @router.post("/refresh", response_model=ApiResponse[dict]) async def refresh_token( request: RefreshTokenRequest, db: AsyncSession = Depends(get_db), ): """ 用 Refresh Token 换取新的 Token 对(Token 轮换) 每次刷新都会生成全新的 Access Token 和 Refresh Token, 同时更新设备记录中的 refresh_token_hash。 """ try: result = await refresh_access_token(db, refresh_token=request.refresh_token) except ValueError as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e), headers={"WWW-Authenticate": "Bearer"}, ) return success_response( data={ "access_token": result["access_token"], "refresh_token": result["refresh_token"], }, message="Token 刷新成功", ) @router.post("/logout", response_model=ApiResponse[dict]) async def do_logout( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 用户登出 删除设备记录,注销 SSE 连接,使当前设备的 Token 失效。 """ await logout(db, user_id=current_user.id) return success_response( data={}, message="登出成功", ) @router.get("/me", response_model=ApiResponse[UserProfileResponse]) async def get_me( current_user: User = Depends(get_current_user), ): """获取当前登录用户信息""" return success_response( data=UserProfileResponse( id=current_user.id, mobile=current_user.mobile, nickname=current_user.nickname, avatar=current_user.avatar_url or "", status=current_user.status, source=current_user.source, last_login_at=current_user.last_login_at, created_at=current_user.created_at, ) )