Files
life-echo/api/app/features/auth/router.py
Sully 53e0065e3e refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
2026-05-22 13:44:50 +08:00

485 lines
14 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.
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)