from fastapi import APIRouter, HTTPException, status, Depends from pydantic import BaseModel, Field from typing import Optional import time from app.services.sms_client import sms_client from app.services.rate_limiter import PhoneRateLimiter from app.services.sms_store import store from app.core.dependency import DependAuth from app.log import logger from app.schemas.app_user import AppUserInfoOut, AppUserJWTOut class SendCodeRequest(BaseModel): phone: str = Field(...) class SendReportRequest(BaseModel): phone: str = Field(...) class VerifyCodeRequest(BaseModel): phone: str = Field(...) code: str = Field(...) class SendResponse(BaseModel): status: str = Field(..., description="发送状态") message: str = Field(..., description="说明") request_id: Optional[str] = Field(None, description="请求ID") class VerifyResponse(BaseModel): status: str = Field(..., description="验证状态") message: str = Field(..., description="说明") class SMSLoginResponse(BaseModel): user: AppUserInfoOut token: AppUserJWTOut rate_limiter = PhoneRateLimiter(60) router = APIRouter(tags=["短信服务"]) @router.post("/send-code", response_model=SendResponse, summary="验证码发送") async def send_code(payload: SendCodeRequest) -> SendResponse: """发送验证码短信 Args: payload: 请求体,含手机号与验证码 Returns: 发送结果响应 """ ok, reason = store.allow_send(payload.phone) if not ok: raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(reason)) try: code = store.generate_code() store.set_code(payload.phone, code) res = sms_client.send_code(payload.phone, code) code = res.get("Code") or res.get("ResponseCode") rid = res.get("RequestId") or res.get("MessageId") if code == "OK": logger.info("sms.send_code success phone={} request_id={}", payload.phone, rid) return SendResponse(status="OK", message="sent", request_id=str(rid) if rid else None) msg = res.get("Message") or res.get("ResponseDescription") or "error" logger.warning("sms.send_code fail phone={} code={} msg={}", payload.phone, code, msg) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(msg)) except HTTPException: raise except Exception as e: logger.error("sms.send_code exception err={}", repr(e)) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="短信服务异常") @router.post("/send-report", response_model=SendResponse, summary="报告通知发送", dependencies=[DependAuth]) async def send_report(payload: SendReportRequest) -> SendResponse: """发送报告通知短信 Args: payload: 请求体,含手机号 Returns: 发送结果响应 """ ok, reason = store.allow_send(payload.phone) if not ok: raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(reason)) try: res = sms_client.send_report(payload.phone) code = res.get("Code") or res.get("ResponseCode") rid = res.get("RequestId") or res.get("MessageId") if code == "OK": logger.info("sms.send_report success phone={} request_id={}", payload.phone, rid) return SendResponse(status="OK", message="sent", request_id=str(rid) if rid else None) msg = res.get("Message") or res.get("ResponseDescription") or "error" logger.warning("sms.send_report fail phone={} code={} msg={}", payload.phone, code, msg) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(msg)) except HTTPException: raise except Exception as e: logger.error("sms.send_report exception err={}", repr(e)) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="短信服务异常") @router.post("/verify-code", summary="验证码验证", response_model=VerifyResponse) async def verify_code(payload: VerifyCodeRequest) -> VerifyResponse: """验证验证码 Args: payload: 请求体,含手机号与验证码 Returns: 验证结果字典 """ ok, reason = store.can_verify(payload.phone) if not ok: raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason)) record = store.get_code(payload.phone) if not record: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期") code, expires_at = record if time.time() > expires_at: store.clear_code(payload.phone) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期") if payload.code != code: count, locked = store.record_verify_failure(payload.phone) if locked: raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="尝试次数过多,已锁定") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误") store.clear_code(payload.phone) store.reset_failures(payload.phone) logger.info("sms.verify_code success phone={}", payload.phone) return VerifyResponse(status="OK", message="verified") class SMSLoginRequest(BaseModel): phone_number: str = Field(...) verification_code: str = Field(...) device_id: Optional[str] = Field(None) @router.post("/login", summary="短信验证码登录", response_model=SMSLoginResponse) async def sms_login(payload: SMSLoginRequest) -> SMSLoginResponse: ok, reason = store.can_verify(payload.phone_number) if not ok: raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=str(reason)) record = store.get_code(payload.phone_number) if not record: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期") code, expires_at = record if time.time() > expires_at: store.clear_code(payload.phone_number) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码过期") if payload.verification_code != code: count, locked = store.record_verify_failure(payload.phone_number) if locked: raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="尝试次数过多,已锁定") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="验证码错误") from app.controllers.app_user import app_user_controller from app.schemas.app_user import AppUserRegisterSchema, AppUserInfoOut, AppUserJWTOut from app.utils.app_user_jwt import create_app_user_access_token, ACCESS_TOKEN_EXPIRE_MINUTES user = await app_user_controller.get_user_by_phone(payload.phone_number) if user is None: user = await app_user_controller.register(AppUserRegisterSchema(phone=payload.phone_number)) await app_user_controller.update_last_login(user.id) access_token = create_app_user_access_token(user_id=user.id, phone=user.phone) store.clear_code(payload.phone_number) store.reset_failures(payload.phone_number) logger.info("sms.login success phone={}", payload.phone_number) user_info = AppUserInfoOut( id=user.id, phone=user.phone, nickname=user.nickname, avatar=user.avatar, company_name=user.company_name, company_address=user.company_address, company_contact=user.company_contact, company_phone=user.company_phone, company_email=user.company_email, is_active=user.is_active, last_login=user.last_login, created_at=user.created_at, updated_at=user.updated_at, ) token_out = AppUserJWTOut(access_token=access_token, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES) return SMSLoginResponse(user=user_info, token=token_out) class VerifyCodeRequest(BaseModel): phone: str = Field(...) code: str = Field(...)