Files
meijiaka-zy/python-api/app/api/v1/auth.py
T
小鱼开发 b597d715c8 fix: 认证流程修复 + alembic 迁移补全 + 前端僵尸代码清理
后端:
- 修复 get_current_user 未校验 is_active,被封禁用户仍可用旧 Token
- auth.py 捕获 ValueError 转 HTTPException(验证码错误、账号被封、Token 无效等不再返回 500)
- 修正 SMS 每日上限注释(3次 → 10次)
- 修复迁移脚本外键引用错误:users.id → mjk_users.id
- 新建积分系统 4 张表的迁移(mjk_user_points/batches/transactions/recharge_orders)
- pyproject.toml 补充 alembic + psycopg2-binary 依赖
- ruff 格式修复(import 排序等)

前端:
- 修复 doRefreshToken 成功后不持久化新 Token 的严重 bug
- 修复应用重启后 SSE 不自动重连(收不到踢人通知)
- 修复 App.tsx handleLogout 未 await
- client.ts 统一从 utils/env 导入 isTauri,默认 base URL 兜底 localhost:8000
- 清理 ~20 个未使用的 hooks/utils/api 模块/组件导出
- 修复所有 ESLint 警告(206 → 0)和 TSC 错误
- 测试通过(5/5)

其他:
- 更新 requirements.lock 和 uv.lock
2026-05-08 11:10:48 +08:00

175 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
认证模块 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.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,
):
"""
发送短信验证码
开发阶段验证码直接打印到日志,生产环境接入短信服务商。
每个手机号每日最多发送 10 次。
"""
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 = None,
):
"""
手机号验证码登录
流程:
1. 校验验证码
2. 获取或创建用户
3. 踢掉旧设备(SSE 推送)
4. 创建/覆盖设备记录
5. 签发双 TokenAccess + Refresh
"""
# 获取客户端 IP
client_ip = None
if http_request:
client_ip = 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,
)
)