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)