""" 认证模块 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 ( CheckPasswordResponse, MobileLoginRequest, PasswordLoginRequest, RefreshTokenRequest, ResetPasswordRequest, SendSmsCodeRequest, SetPasswordRequest, TokenResponse, ) from app.schemas.common import ApiResponse, success_response from app.schemas.user import UpdateNicknameRequest, UserProfileResponse from app.services.auth_service import ( login_with_password, login_with_sms, logout, refresh_access_token, reset_password_with_sms, 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("/login-password", response_model=ApiResponse[TokenResponse]) async def login_password( request: PasswordLoginRequest, db: AsyncSession = Depends(get_db), http_request: Request = None, ): """ 手机号密码登录 流程与验证码登录一致,只是校验方式改为密码。 """ 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_password( db, mobile=request.mobile, password=request.password, 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.get("/has-password", response_model=ApiResponse[CheckPasswordResponse]) async def check_has_password( current_user: User = Depends(get_current_user), ): """检查当前用户是否已设置密码""" return success_response( data=CheckPasswordResponse(has_password=bool(current_user.password_hash)), message="查询成功", ) @router.post("/set-password", response_model=ApiResponse[dict]) async def set_password( request: SetPasswordRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 设置或修改密码 - 首次设置密码:old_password 可不传 - 修改密码:必须传 old_password 校验 """ from app.core.security import hash_password, verify_password user = await user_crud.get(db, id=current_user.id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") # 如果已有密码,必须提供旧密码 if user.password_hash: if not request.old_password: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="修改密码需要提供旧密码", ) if not verify_password(request.old_password, user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误", ) # 更新密码 new_hash = hash_password(request.new_password) await user_crud.update_password(db, user_id=user.id, password_hash=new_hash) return success_response(data={}, message="密码设置成功") @router.post("/reset-password", response_model=ApiResponse[dict]) async def reset_password( request: ResetPasswordRequest, db: AsyncSession = Depends(get_db), ): """ 短信验证码重置密码 无需登录,通过短信验证码验证身份后直接重置密码。 """ try: await reset_password_with_sms( db, mobile=request.mobile, code=request.code, new_password=request.new_password, ) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) return success_response(data={}, 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, ) ) @router.patch("/me", response_model=ApiResponse[UserProfileResponse]) async def update_me( request: UpdateNicknameRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """修改当前用户信息(仅支持修改昵称)""" # current_user 来自 get_current_user 的会话,和当前 db 不是同一个会话 # 需要重新查询到当前会话中才能正确提交 user = await user_crud.get(db, id=current_user.id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") user.nickname = request.nickname.strip() await db.commit() await db.refresh(user) return success_response( data=UserProfileResponse( id=user.id, mobile=user.mobile, nickname=user.nickname, avatar=user.avatar_url or "", status=user.status, source=user.source, last_login_at=user.last_login_at, created_at=user.created_at, ), message="昵称修改成功", )