feat(交易记录): 新增交易记录管理页面与API接口 feat(上传): 添加统一上传接口支持自动识别文件类型 feat(用户管理): 为用户模型添加备注字段并更新相关接口 feat(邮件): 实现SMTP邮件发送功能并添加测试脚本 feat(短信): 增强短信服务配置灵活性与日志记录 fix(发票): 修复发票列表时间筛选功能 fix(nginx): 调整上传大小限制与超时配置 docs: 添加多个功能模块的说明文档 docs(估值): 补充估值计算流程与API提交数据说明 chore: 更新依赖与Docker镜像版本
201 lines
7.9 KiB
Python
201 lines
7.9 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
|
|
|
|
|
|
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:
|
|
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 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(...)
|