配置 SSOT(TOML + .env) 统一错误契约 Auth 与事务边界 Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client 可观测性(OpenTelemetry + LGTM)
485 lines
14 KiB
Python
485 lines
14 KiB
Python
import time
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, File, UploadFile, status
|
||
from fastapi.responses import FileResponse
|
||
|
||
from app.core.config import settings
|
||
from app.core.cos_url_keys import (
|
||
avatar_url_for_api_response,
|
||
best_effort_delete_cos_object_for_url,
|
||
)
|
||
from app.core.deps_types import CurrentUserDep
|
||
from app.core.errors import BadRequestError, NotFoundError
|
||
from app.core.logging import get_logger
|
||
from app.core.openapi import error_responses
|
||
from app.features.auth.deps import get_auth_service
|
||
from app.features.auth.preset_avatars import (
|
||
avatar_url_for_preset_filename,
|
||
list_preset_items,
|
||
preset_file_path,
|
||
preset_filename_for_id,
|
||
safe_avatar_upload_path,
|
||
)
|
||
from app.features.auth.schemas import (
|
||
AvatarPresetItem,
|
||
ChangePasswordRequest,
|
||
ChangePhoneRequest,
|
||
LoginRequest,
|
||
MockSmsLoginRequest,
|
||
RefreshTokenRequest,
|
||
RegisterRequest,
|
||
ResetPasswordRequest,
|
||
SendSmsRequest,
|
||
SetAvatarPresetRequest,
|
||
SmsLoginRequest,
|
||
SmsRegisterRequest,
|
||
TokenResponse,
|
||
UpdateNicknameRequest,
|
||
UserResponse,
|
||
)
|
||
from app.features.auth.service import AuthService
|
||
from app.features.user.models import User
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
router = APIRouter(
|
||
prefix="/api/auth",
|
||
tags=["auth"],
|
||
responses=error_responses(401),
|
||
)
|
||
|
||
AVATAR_DIR = Path("uploads/avatars")
|
||
|
||
# ── helpers ──────────────────────────────────────────────────
|
||
|
||
def _user_response(user: User) -> UserResponse:
|
||
raw_lang = getattr(user, "language_preference", "zh")
|
||
lang = str(raw_lang).strip().lower() if isinstance(raw_lang, str) else "zh"
|
||
if lang not in ("zh", "en"):
|
||
lang = "zh"
|
||
return UserResponse(
|
||
id=user.id,
|
||
phone=user.phone,
|
||
email=user.email,
|
||
nickname=user.nickname,
|
||
avatar_url=avatar_url_for_api_response(user.avatar_url),
|
||
subscription_type=user.subscription_type,
|
||
created_at=user.created_at.isoformat(),
|
||
language_preference=lang,
|
||
)
|
||
|
||
|
||
def _check_terms(agreed: bool) -> None:
|
||
if not agreed:
|
||
raise BadRequestError("请先阅读并同意用户协议和隐私政策")
|
||
|
||
|
||
def _mock_sms_login_route_enabled() -> bool:
|
||
env = (settings.app_environment or "").lower().strip()
|
||
if env == "production":
|
||
return False
|
||
return bool(settings.mock_sms_login_enabled)
|
||
|
||
|
||
# ── registration & login ─────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/register",
|
||
response_model=TokenResponse,
|
||
status_code=status.HTTP_201_CREATED,
|
||
summary="手机号密码注册",
|
||
responses=error_responses(400, descriptions={400: "手机号/邮箱已注册或参数错误"}),
|
||
)
|
||
async def register(
|
||
request: RegisterRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.register(
|
||
phone=request.phone,
|
||
password=request.password,
|
||
nickname=request.nickname,
|
||
email=request.email,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/login",
|
||
response_model=TokenResponse,
|
||
summary="手机号密码登录",
|
||
responses=error_responses(401, descriptions={401: "手机号或密码错误"}),
|
||
)
|
||
async def login(
|
||
request: LoginRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.login(
|
||
phone=request.phone,
|
||
password=request.password,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/refresh",
|
||
response_model=TokenResponse,
|
||
summary="刷新访问令牌",
|
||
responses=error_responses(
|
||
401,
|
||
descriptions={
|
||
401: "刷新令牌无效/已过期;已轮换 token 被重复使用时会吊销全部会话(REFRESH_TOKEN_REUSE)"
|
||
},
|
||
),
|
||
)
|
||
async def refresh_token(
|
||
request: RefreshTokenRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
result = await service.refresh_tokens(
|
||
refresh_token=request.refresh_token,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
# ── logout ────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/logout",
|
||
status_code=status.HTTP_200_OK,
|
||
summary="登出当前设备",
|
||
)
|
||
async def logout(
|
||
request: RefreshTokenRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
await service.logout(request.refresh_token, current_user.id)
|
||
return {"message": "登出成功"}
|
||
|
||
|
||
@router.post(
|
||
"/logout/all",
|
||
summary="登出所有设备",
|
||
)
|
||
async def logout_all_devices(
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
count = await service.logout_all(current_user.id)
|
||
return {"message": f"已登出所有设备,共撤销 {count} 个令牌"}
|
||
|
||
|
||
# ── user profile ──────────────────────────────────────────────
|
||
|
||
|
||
@router.get(
|
||
"/me",
|
||
response_model=UserResponse,
|
||
summary="获取当前用户信息",
|
||
)
|
||
async def get_me(
|
||
current_user: CurrentUserDep,
|
||
):
|
||
return _user_response(current_user)
|
||
|
||
|
||
@router.put(
|
||
"/me/nickname",
|
||
response_model=UserResponse,
|
||
summary="修改昵称",
|
||
)
|
||
async def update_nickname(
|
||
request: UpdateNicknameRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
user = await service.update_nickname(current_user.id, request.nickname)
|
||
return _user_response(user)
|
||
|
||
|
||
# ── avatar ────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/me/avatar",
|
||
response_model=UserResponse,
|
||
summary="上传头像",
|
||
responses=error_responses(400, descriptions={400: "文件类型或大小不符合要求"}),
|
||
)
|
||
async def upload_avatar(
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
file: UploadFile = File(...),
|
||
):
|
||
file_content = await file.read()
|
||
logger.debug(
|
||
"上传头像: user_id={} filename={} content_type={} size={}",
|
||
current_user.id,
|
||
file.filename,
|
||
file.content_type,
|
||
len(file_content),
|
||
)
|
||
user = await service.upload_avatar(
|
||
current_user.id,
|
||
file_content,
|
||
file.content_type or "",
|
||
old_avatar_url=current_user.avatar_url,
|
||
)
|
||
return _user_response(user)
|
||
|
||
|
||
@router.get(
|
||
"/avatar-presets",
|
||
response_model=list[AvatarPresetItem],
|
||
summary="预设头像列表",
|
||
)
|
||
async def list_avatar_presets():
|
||
return [
|
||
AvatarPresetItem(id=item_id, url=item_url)
|
||
for item_id, item_url in list_preset_items()
|
||
]
|
||
|
||
|
||
@router.put(
|
||
"/me/avatar/preset",
|
||
response_model=UserResponse,
|
||
summary="使用预设头像",
|
||
responses=error_responses(400, descriptions={400: "无效的预设编号"}),
|
||
)
|
||
async def set_avatar_preset(
|
||
request: SetAvatarPresetRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
filename = preset_filename_for_id(request.preset_id)
|
||
if filename is None:
|
||
raise BadRequestError("无效的预设头像编号")
|
||
path = preset_file_path(filename)
|
||
if path is None or not path.exists():
|
||
raise BadRequestError("预设头像不可用")
|
||
best_effort_delete_cos_object_for_url(current_user.avatar_url)
|
||
|
||
avatar_url = f"{avatar_url_for_preset_filename(filename)}?v={time.time_ns()}"
|
||
user = await service.update_avatar_url(current_user.id, avatar_url)
|
||
return _user_response(user)
|
||
|
||
|
||
@router.get(
|
||
"/avatar-presets/{filename}",
|
||
summary="获取预设头像图片",
|
||
responses=error_responses(404, descriptions={404: "预设不存在"}),
|
||
)
|
||
async def get_avatar_preset(filename: str):
|
||
path = preset_file_path(filename)
|
||
if path is None or not path.exists():
|
||
raise NotFoundError("预设头像不存在")
|
||
return FileResponse(path, media_type="image/png")
|
||
|
||
|
||
@router.get(
|
||
"/avatars/{filename}",
|
||
summary="获取头像图片",
|
||
responses=error_responses(404, descriptions={404: "头像不存在"}),
|
||
)
|
||
async def get_avatar(filename: str):
|
||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||
file_path = safe_avatar_upload_path(filename, AVATAR_DIR)
|
||
if file_path is None or not file_path.exists():
|
||
raise NotFoundError("头像不存在")
|
||
return FileResponse(file_path, media_type="image/jpeg")
|
||
|
||
|
||
# ── SMS verification ──────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/sms/send",
|
||
summary="发送短信验证码",
|
||
responses=error_responses(
|
||
400,
|
||
429,
|
||
502,
|
||
503,
|
||
descriptions={
|
||
400: "手机号格式或用途不合法",
|
||
429: "发送过于频繁(RATE_LIMITED)",
|
||
502: "短信服务商调用失败(PROVIDER_ERROR,可重试)",
|
||
503: "短信服务未配置或不可用(SERVICE_UNAVAILABLE)",
|
||
},
|
||
),
|
||
)
|
||
async def send_sms_code(
|
||
request: SendSmsRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
if not request.phone.isdigit():
|
||
raise BadRequestError("手机号格式不正确")
|
||
|
||
valid_purposes = ["register", "login", "reset_password", "change_phone"]
|
||
if request.purpose not in valid_purposes:
|
||
raise BadRequestError(f"无效的用途,必须是: {', '.join(valid_purposes)}")
|
||
|
||
_success, message, expires_in = await service.send_sms_code(
|
||
phone=request.phone,
|
||
purpose=request.purpose,
|
||
ip_address=None,
|
||
)
|
||
|
||
return {"message": message, "expires_in": expires_in}
|
||
|
||
|
||
@router.post(
|
||
"/login/sms",
|
||
response_model=TokenResponse,
|
||
summary="短信验证码登录(新用户自动注册)",
|
||
responses=error_responses(400, descriptions={400: "验证码错误"}),
|
||
)
|
||
async def login_with_sms(
|
||
request: SmsLoginRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.login_with_sms(
|
||
phone=request.phone,
|
||
code=request.code,
|
||
nickname=request.nickname,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/mock/sms-login",
|
||
response_model=TokenResponse,
|
||
summary="[评测] Mock 短信登录(跳过验证码)",
|
||
description=(
|
||
"需 config deploy.mock_sms_login_enabled=true 且 APP_ENV 非 production。"
|
||
"供 Eval Web 等内网工具联调,勿在生产环境开启。"
|
||
),
|
||
responses=error_responses(404, descriptions={404: "未启用或生产环境已禁用"}),
|
||
)
|
||
async def mock_sms_login_route(
|
||
request: MockSmsLoginRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
if not _mock_sms_login_route_enabled():
|
||
raise NotFoundError("Not Found")
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.mock_sms_login(
|
||
phone=request.phone,
|
||
nickname=request.nickname,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/register/sms",
|
||
response_model=TokenResponse,
|
||
status_code=status.HTTP_201_CREATED,
|
||
summary="短信验证码注册",
|
||
responses=error_responses(400, descriptions={400: "验证码错误或手机号/邮箱已注册"}),
|
||
)
|
||
async def register_with_sms(
|
||
request: SmsRegisterRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.register_with_sms(
|
||
phone=request.phone,
|
||
code=request.code,
|
||
password=request.password,
|
||
nickname=request.nickname,
|
||
email=request.email,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
# ── password & phone management ───────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/password/reset",
|
||
summary="通过短信验证码重置密码",
|
||
responses=error_responses(
|
||
400,
|
||
404,
|
||
descriptions={
|
||
400: "验证码错误",
|
||
404: "用户不存在",
|
||
},
|
||
),
|
||
)
|
||
async def reset_password(
|
||
request: ResetPasswordRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
await service.reset_password(
|
||
phone=request.phone,
|
||
code=request.code,
|
||
new_password=request.new_password,
|
||
)
|
||
return {"message": "密码重置成功"}
|
||
|
||
|
||
@router.post(
|
||
"/password/change",
|
||
summary="修改密码(需旧密码)",
|
||
responses=error_responses(400, descriptions={400: "旧密码错误"}),
|
||
)
|
||
async def change_password(
|
||
request: ChangePasswordRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
await service.change_password(
|
||
user_id=current_user.id,
|
||
old_password=request.old_password,
|
||
new_password=request.new_password,
|
||
)
|
||
return {"message": "密码修改成功"}
|
||
|
||
|
||
@router.post(
|
||
"/phone/change",
|
||
response_model=UserResponse,
|
||
summary="更换手机号",
|
||
responses=error_responses(400, descriptions={400: "验证码错误或手机号已被占用"}),
|
||
)
|
||
async def change_phone(
|
||
request: ChangePhoneRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
user = await service.change_phone(
|
||
user_id=current_user.id,
|
||
new_phone=request.new_phone,
|
||
code=request.code,
|
||
)
|
||
return _user_response(user)
|