Files
shaguabijia-app-server/app/api/v1/auth.py
T
马润林 1aafc28621 feat: 正式 App 后端登录模块 v0.1.0
引入 JWT 认证、极光一键登录、短信 mock 登录与用户表,并补充技术实施文档与部署配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:37:18 +08:00

131 lines
4.8 KiB
Python

"""认证相关 endpoint。
路由前缀 `/api/v1/auth`,包含:
POST /jverify-login 极光一键登录(loginToken → 手机号 → 注册即登录 → 签 JWT)
POST /sms/send 发短信验证码(mock 阶段任意 6 位通过)
POST /sms/login 手机号 + 验证码登录
POST /refresh 用 refresh_token 换新的 token 对
GET /me 当前登录用户(Bearer access_token)
POST /logout 客户端登出(目前服务端无状态,仅返回 ok。后续加 jti 黑名单)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.core.security import TokenError, decode_token, issue_token_pair
from app.core.sms import SmsError, send_code, verify_code
from app.crud import user as crud_user
from app.schemas.auth import (
JverifyLoginRequest,
LogoutResponse,
RefreshRequest,
SmsLoginRequest,
SmsSendRequest,
SmsSendResponse,
TokenPair,
TokenWithUser,
UserOut,
)
logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(db, user) -> TokenWithUser:
"""登录类接口共用的响应组装。"""
tokens = issue_token_pair(user.id)
return TokenWithUser(
**tokens,
user=UserOut.model_validate(user),
)
# ===================== 极光一键登录 =====================
@router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录")
def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
logger.info(
"jverify_login operator=%s token_len=%d",
req.operator or "-",
len(req.login_token),
)
try:
phone = verify_and_get_phone(req.login_token)
except JiguangError as e:
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
user = crud_user.upsert_user_for_login(db, phone=phone, register_channel="jverify")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
return _login_response(db, user)
# ===================== 短信登录 =====================
@router.post("/sms/send", response_model=SmsSendResponse, summary="发送短信验证码 (mock)")
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
try:
cooldown = send_code(req.phone)
except SmsError as e:
raise HTTPException(status_code=429, detail=str(e)) from e
from app.core.config import settings # 局部 import 避免循环
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
@router.post("/sms/login", response_model=TokenWithUser, summary="手机号+验证码登录")
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
user = crud_user.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
return _login_response(db, user)
# ===================== Refresh =====================
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
def refresh(req: RefreshRequest, db: DbSession) -> TokenPair:
try:
payload = decode_token(req.refresh_token, expected_type="refresh")
except TokenError as e:
raise HTTPException(status_code=401, detail=str(e)) from e
user_id = int(payload["sub"])
user = crud_user.get_user_by_id(db, user_id)
if user is None or user.status != "active":
raise HTTPException(status_code=401, detail="user not found or disabled")
return TokenPair(**issue_token_pair(user.id))
# ===================== 当前用户 =====================
@router.get("/me", response_model=UserOut, summary="获取当前登录用户")
def me(user: CurrentUser) -> UserOut:
return UserOut.model_validate(user)
# ===================== Logout =====================
@router.post("/logout", response_model=LogoutResponse, summary="登出(占位,客户端清 token)")
def logout(user: CurrentUser) -> LogoutResponse:
# 当前服务端无状态,真正的 token 失效靠客户端删 token。
# TODO: 加 jti 黑名单表后,这里把 access_token 的 jti 写黑名单
logger.info("logout user_id=%d", user.id)
return LogoutResponse(ok=True)