- 添加SMS_BYPASS_CODE配置允许特定验证码绕过验证 - 实现角色与API权限的自动同步功能 - 更新评估模型的时间字段为可空 - 移除前端PC路由配置 - 更新Docker镜像版本至v2.6 - 切换开发环境API基础地址
223 lines
8.8 KiB
Python
223 lines
8.8 KiB
Python
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
|
|
from app.schemas.base import BasicResponse, Success
|
|
|
|
|
|
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=BasicResponse[dict], summary="验证码发送")
|
|
async def send_code(payload: SendCodeRequest) -> BasicResponse[dict]:
|
|
"""发送验证码短信
|
|
|
|
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:
|
|
otp = store.generate_code()
|
|
store.set_code(payload.phone, otp)
|
|
from app.settings import settings
|
|
if settings.SMS_DEBUG_LOG_CODE:
|
|
logger.info("sms.code generated phone={} code={}", payload.phone, otp)
|
|
res = sms_client.send_code(payload.phone, otp)
|
|
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 Success(
|
|
data={
|
|
"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=BasicResponse[dict], summary="报告通知发送", dependencies=[DependAuth])
|
|
async def send_report(payload: SendReportRequest) -> BasicResponse[dict]:
|
|
"""发送报告通知短信
|
|
|
|
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 Success(
|
|
data={
|
|
"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=BasicResponse[dict])
|
|
async def verify_code(payload: VerifyCodeRequest) -> BasicResponse[dict]:
|
|
"""验证验证码
|
|
|
|
Args:
|
|
payload: 请求体,含手机号与验证码
|
|
|
|
Returns:
|
|
验证结果字典
|
|
"""
|
|
from app.settings import settings
|
|
if settings.SMS_BYPASS_CODE and payload.code == settings.SMS_BYPASS_CODE:
|
|
logger.info("sms.verify_code bypass phone={}", payload.phone)
|
|
return Success(data={"status": "OK", "message": "verified"})
|
|
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 Success(data={"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=BasicResponse[dict])
|
|
async def sms_login(payload: SMSLoginRequest) -> BasicResponse[dict]:
|
|
from app.settings import settings
|
|
bypass = settings.SMS_BYPASS_CODE and payload.verification_code == settings.SMS_BYPASS_CODE
|
|
if not bypass:
|
|
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)
|
|
if not bypass:
|
|
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=getattr(user, "alias", None),
|
|
avatar=None,
|
|
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,
|
|
remaining_quota=user.remaining_quota,
|
|
)
|
|
token_out = AppUserJWTOut(access_token=access_token, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
return Success(data={"user": user_info.model_dump(), "token": token_out.model_dump()})
|
|
class VerifyCodeRequest(BaseModel):
|
|
phone: str = Field(...)
|
|
code: str = Field(...)
|