132 lines
4.3 KiB
Python
132 lines
4.3 KiB
Python
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, req.code, req.scene.value)
|
|
if not ok:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="验证码错误或已过期",
|
|
)
|
|
return SmsResponse(message="验证成功", success=True) |