from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.dependencies import get_db from app.schemas.sms import SmsResponse, SmsScene, SmsSendRequest, SmsVerifyRequest from app.services.auth import get_user_by_phone from app.services.sms import generate_and_send_sms, verify_sms_code router = APIRouter(prefix="/sms", tags=["短信验证码"]) def _validate_captcha_token(captcha_token: str | None) -> None: if settings.SMS_MOCK: return if not (req_captcha := captcha_token): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="需要验证码", ) from app.services.auth import decode_access_token token_sub = decode_access_token(req_captcha) if not token_sub or not token_sub.startswith("captcha:"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="验证码无效", ) async def _validate_sms_scene_phone( *, db: AsyncSession, phone: str, scene: SmsScene, ) -> None: """ 按短信来源场景做手机号注册状态前置校验。 规则: 1. login:必须是已注册手机号,否则拦截发送。 2. register:必须是未注册手机号,否则拦截发送。 3. common:通用场景,暂不校验手机号注册状态。 4. set_password:保留旧场景,按通用场景处理,暂不校验手机号注册状态。 5. 其他未确认场景:理论上会被 SmsScene 枚举拦截;这里额外兜底。 """ if scene == SmsScene.login: user = await get_user_by_phone(db, phone) if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="手机号未注册,请先注册", ) return if scene == SmsScene.register: user = await get_user_by_phone(db, phone) if user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="该手机号已注册,请直接登录", ) return if scene in {SmsScene.common, SmsScene.set_password}: return raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="未确认短信场景,禁止发送验证码", ) @router.post( "/send", response_model=SmsResponse, summary="发送短信验证码", description=( "客户端发送短信验证码。" "scene=register 用于注册,发送前会校验手机号未注册;" "scene=login 用于短信登录,发送前会校验手机号已注册;" "scene=common 用于通用短信场景,暂不校验手机号注册状态;" "scene=set_password 用于设置密码,保留旧场景,暂按通用场景处理。" "正式环境会按配置校验图形验证码。" ), ) async def send_sms_code( req: SmsSendRequest, db: AsyncSession = Depends(get_db), ): _validate_captcha_token(req.captcha_token) await _validate_sms_scene_phone( db=db, phone=req.phone, scene=req.scene, ) try: ok, code = await generate_and_send_sms(req.phone, req.scene.value) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) from exc if not ok: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="短信发送失败,请稍后重试", ) message = f"验证码已发送:{code}" if settings.SMS_MOCK else "验证码已发送" return SmsResponse(message=message, success=True) @router.post( "/verify", response_model=SmsResponse, summary="校验短信验证码", description="校验指定手机号、场景下的短信验证码。业务接口一般会内部校验,本接口主要用于前端调试或单独校验。", ) async def verify_sms(req: SmsVerifyRequest): ok = await verify_sms_code(req.phone.strip(), req.code.strip(), req.scene.value) if not ok: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期", ) return SmsResponse(message="验证成功", success=True)