You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
35 KiB
35 KiB
短信验证码服务
文档版本:v3.0
最后更新:2026-01-29
目录
服务概述
短信验证码服务负责发送和验证手机验证码,支持用户登录、绑定手机号等场景。
职责
- 发送短信验证码
- 验证验证码有效性
- 防止恶意刷取验证码
- 管理验证码生命周期
技术选型
- 短信服务商:阿里云短信服务(可扩展腾讯云、华为云等)
- 验证码存储:PostgreSQL 17(sms_verification_codes 表,UUID v7 主键)
- 限流缓存:Redis(异步客户端)
- HTTP 客户端:httpx(异步)
核心功能
1. 发送验证码
- 生成 6 位随机数字验证码
- 调用短信服务商 API 发送
- 存储验证码到数据库
- 设置 10 分钟有效期
2. 验证验证码
- 查询未验证且未过期的验证码
- 验证码匹配后标记为已验证
- 验证码使用后立即失效
- 测试环境: 支持万能验证码
6666用于开发测试
3. 防刷机制
- IP 限流:1分钟内最多 3 次
- 手机号限流:1分钟内最多 1 次,1小时内最多 5 次
- 使用 Redis 实现分布式限流
4. 定时清理
- 每小时清理过期验证码
- 保留最近 24 小时的记录用于审计
服务实现
SmsService 类
# app/services/sms_service.py
from typing import Optional
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlmodel.ext.asyncio.session import AsyncSession
from redis.asyncio import Redis
import httpx
import random
import json
from app.models.sms import SmsVerificationCode, SmsPurpose
from app.repositories.sms_repository import SmsRepository
from app.core.exceptions import ValidationError, RateLimitError
from app.core.config import settings
from app.core.logging import logger
class SmsService:
"""短信验证码服务 - 全异步实现"""
def __init__(self, session: AsyncSession, redis_client: Redis):
"""
初始化短信服务
Args:
session: 异步数据库会话
redis_client: 异步 Redis 客户端(由依赖注入提供)
Note:
- redis_client 为可选参数,如果不提供则跳过限流检查
- 测试环境支持万能验证码 6666
"""
self.repository = SmsRepository(session)
self.session = session
self.redis_client = redis_client
# 异步 HTTP 客户端(懒加载)
self._http_client: Optional[httpx.AsyncClient] = None
@property
def http_client(self) -> httpx.AsyncClient:
"""懒加载 HTTP 客户端"""
if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=10.0)
return self._http_client
async def send_verification_code(
self,
phone: str,
country_code: str = '+86',
purpose: SmsPurpose = SmsPurpose.LOGIN,
ip_address: Optional[str] = None
) -> dict:
"""
发送验证码
Args:
phone: 手机号
country_code: 国家代码
purpose: 用途枚举
ip_address: 客户端 IP 地址
Returns:
包含消息和过期时间的字典
Raises:
RateLimitError: 超过发送频率限制
ValidationError: 短信发送失败
"""
try:
# 防刷检查
await self._check_rate_limit(phone, country_code, ip_address)
# 生成 6 位验证码
code = self._generate_code()
# 调用短信服务商(异步)
await self._send_sms(phone, code)
# 存储验证码
verification_code = SmsVerificationCode(
phone=phone,
country_code=country_code,
code=code,
purpose=purpose.value, # 存储 SMALLINT
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
ip_address=ip_address
)
await self.repository.create(verification_code)
logger.info(
f"验证码已发送",
extra={
"phone": phone,
"country_code": country_code,
"purpose": purpose.to_string(),
"ip_address": ip_address
}
)
return {
"message": "验证码已发送",
"expires_in": 600
}
except (RateLimitError, ValidationError):
raise
except Exception as e:
logger.error(f"发送验证码失败: {str(e)}", exc_info=True)
raise ValidationError("发送验证码失败,请稍后重试")
async def verify_code(
self,
phone: str,
country_code: str,
code: str,
purpose: SmsPurpose = SmsPurpose.LOGIN
) -> bool:
"""
验证验证码
Args:
phone: 手机号
country_code: 国家代码
code: 验证码
purpose: 用途枚举
Returns:
验证成功返回 True
Raises:
ValidationError: 验证码不存在、已过期或错误
"""
try:
# 查找未验证且未过期的验证码
verification_code = await self.repository.get_valid_code(
phone=phone,
country_code=country_code,
purpose=purpose.value # 使用 SMALLINT 查询
)
if not verification_code:
raise ValidationError("验证码不存在或已过期")
# 验证码匹配
if verification_code.code != code:
logger.warning(
f"验证码错误",
extra={
"phone": phone,
"country_code": country_code,
"purpose": purpose.to_string()
}
)
raise ValidationError("验证码错误")
# 标记为已验证
await self.repository.mark_as_verified(verification_code.id)
logger.info(
f"验证码验证成功",
extra={
"phone": phone,
"country_code": country_code,
"purpose": purpose.to_string()
}
)
return True
except ValidationError:
raise
except Exception as e:
logger.error(f"验证验证码失败: {str(e)}", exc_info=True)
raise ValidationError("验证失败,请稍后重试")
async def clean_expired_codes(self) -> int:
"""
清理过期验证码(保留最近 24 小时)
Returns:
清理的记录数
"""
try:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
count = await self.repository.delete_expired(cutoff_time)
logger.info(f"清理过期验证码完成,共清理 {count} 条记录")
return count
except Exception as e:
logger.error(f"清理过期验证码失败: {str(e)}", exc_info=True)
return 0
# ==================== 私有方法 ====================
def _generate_code(self) -> str:
"""生成 6 位随机数字验证码"""
return ''.join([str(random.randint(0, 9)) for _ in range(6)])
async def _check_rate_limit(
self,
phone: str,
country_code: str,
ip_address: Optional[str]
) -> None:
"""防刷检查(异步 Redis)"""
# 检查 IP 限流(1分钟内最多 3 次)
if ip_address:
ip_key = f"sms:ip:{ip_address}"
ip_count = await self.redis_client.get(ip_key)
if ip_count and int(ip_count) >= 3:
raise RateLimitError("发送过于频繁,请稍后再试")
# 检查手机号限流(1分钟内最多 1 次)
phone_key_1min = f"sms:phone:{country_code}:{phone}:1min"
phone_count_1min = await self.redis_client.get(phone_key_1min)
if phone_count_1min:
raise RateLimitError("验证码已发送,请稍后再试")
# 检查手机号限流(1小时内最多 5 次)
phone_key_1hour = f"sms:phone:{country_code}:{phone}:1hour"
phone_count_1hour = await self.redis_client.get(phone_key_1hour)
if phone_count_1hour and int(phone_count_1hour) >= 5:
raise RateLimitError("今日发送次数已达上限")
# 更新限流计数(异步)
if ip_address:
await self.redis_client.incr(ip_key)
await self.redis_client.expire(ip_key, 60) # 1分钟过期
await self.redis_client.set(phone_key_1min, 1, ex=60) # 1分钟过期
current_count = await self.redis_client.incr(phone_key_1hour)
if current_count == 1:
await self.redis_client.expire(phone_key_1hour, 3600) # 1小时过期
async def _send_sms(self, phone: str, code: str) -> None:
"""
调用阿里云短信服务(异步 HTTP)
Args:
phone: 手机号
code: 验证码
Raises:
ValidationError: 短信发送失败
"""
url = "https://dysmsapi.aliyuncs.com/"
params = {
"PhoneNumbers": phone,
"SignName": settings.SMS_SIGN_NAME,
"TemplateCode": settings.SMS_TEMPLATE_CODE,
"TemplateParam": json.dumps({"code": code}),
"AccessKeyId": settings.SMS_ACCESS_KEY_ID,
# ... 其他阿里云签名参数
}
try:
response = await self.http_client.post(url, params=params)
response_data = response.json()
if response_data.get("Code") != "OK":
logger.error(
f"阿里云短信发送失败",
extra={
"phone": phone,
"error_code": response_data.get("Code"),
"error_message": response_data.get("Message")
}
)
raise ValidationError(f"短信发送失败:{response_data.get('Message')}")
except httpx.HTTPError as e:
logger.error(f"HTTP 请求失败: {str(e)}", exc_info=True)
raise ValidationError(f"短信发送失败:{str(e)}")
async def __aenter__(self):
"""异步上下文管理器入口"""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""异步上下文管理器退出,清理资源"""
if self._http_client is not None:
await self._http_client.aclose()
API 接口
API 路由层实现
# app/api/v1/auth.py
from fastapi import APIRouter, Depends, Request
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.deps import get_session, get_redis_client
from app.services.sms_service import SmsService
from app.schemas.sms import (
SmsCodeSendRequest,
SmsCodeVerifyRequest,
SmsCodeSendResponse
)
from app.schemas.response import ApiResponse
from app.models.sms import SmsPurpose
router = APIRouter()
@router.post(
"/sms/send",
response_model=ApiResponse[SmsCodeSendResponse],
summary="发送短信验证码"
)
async def send_sms_code(
request: Request,
body: SmsCodeSendRequest,
session: AsyncSession = Depends(get_session),
redis_client = Depends(get_redis_client)
):
"""
发送短信验证码
- **phone**: 手机号(11位)
- **countryCode**: 国家代码(默认 +86)
- **purpose**: 用途(login/bind_phone/reset_password)
"""
service = SmsService(session, redis_client)
# 获取客户端 IP
ip_address = request.client.host if request.client else None
# 转换字符串枚举为 IntEnum
purpose_enum = SmsPurpose.from_string(body.purpose.value)
result = await service.send_verification_code(
phone=body.phone,
country_code=body.country_code,
purpose=purpose_enum,
ip_address=ip_address
)
return ApiResponse.success(
data=SmsCodeSendResponse(
message=result["message"],
expires_in=result["expires_in"]
)
)
@router.post(
"/sms/verify",
response_model=ApiResponse[dict],
summary="验证短信验证码"
)
async def verify_sms_code(
body: SmsCodeVerifyRequest,
session: AsyncSession = Depends(get_session),
redis_client = Depends(get_redis_client)
):
"""
验证短信验证码
- **phone**: 手机号(11位)
- **countryCode**: 国家代码(默认 +86)
- **code**: 验证码(6位)
- **purpose**: 用途(login/bind_phone/reset_password)
"""
service = SmsService(session, redis_client)
# 转换字符串枚举为 IntEnum
purpose_enum = SmsPurpose.from_string(body.purpose.value)
await service.verify_code(
phone=body.phone,
country_code=body.country_code,
code=body.code,
purpose=purpose_enum
)
return ApiResponse.success(
data={"verified": True},
message="验证成功"
)
依赖注入配置
# app/core/deps.py
from typing import AsyncGenerator
from redis.asyncio import Redis
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import async_session_maker
from app.core.config import settings
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话"""
async with async_session_maker() as session:
yield session
async def get_redis_client() -> AsyncGenerator[Redis, None]:
"""获取 Redis 客户端"""
client = Redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True
)
try:
yield client
finally:
await client.close()
1. 发送验证码
POST /api/v1/auth/sms/send
请求体:
{
"phone": "13800138000",
"country_code": "+86",
"purpose": "login"
}
响应:
{
"code": 200,
"message": "Success",
"data": {
"message": "验证码已发送",
"expiresIn": 600
},
"timestamp": "2026-01-29T10:30:00Z"
}
错误响应:
{
"code": 429,
"message": "发送过于频繁,请稍后再试",
"data": null,
"timestamp": "2026-01-29T10:30:00Z"
}
2. 验证验证码
POST /api/v1/auth/sms/verify
请求体:
{
"phone": "13800138000",
"countryCode": "+86",
"code": "123456",
"purpose": "login"
}
响应:
{
"code": 200,
"message": "验证成功",
"data": {
"verified": true
},
"timestamp": "2026-01-29T10:30:00Z"
}
防刷策略
限流规则
| 维度 | 时间窗口 | 最大次数 | Redis Key 格式 |
|---|---|---|---|
| IP | 1 分钟 | 3 次 | sms:ip:{ip_address} |
| 手机号 | 1 分钟 | 1 次 | sms:phone:{country_code}:{phone}:1min |
| 手机号 | 1 小时 | 5 次 | sms:phone:{country_code}:{phone}:1hour |
实现原理
使用 Redis 的 INCR 和 EXPIRE 命令实现分布式限流:
# 伪代码
key = f"sms:phone:{country_code}:{phone}:1min"
count = redis.incr(key)
if count == 1:
redis.expire(key, 60)
if count > 1:
raise RateLimitError("发送过于频繁")
数据模型
sms_verification_codes 表结构
CREATE TABLE sms_verification_codes (
-- 主键(UUID v7)
id UUID PRIMARY KEY,
-- 基本字段
phone VARCHAR(20) NOT NULL,
country_code VARCHAR(10) NOT NULL DEFAULT '+86',
code VARCHAR(10) NOT NULL,
-- 用途枚举(SMALLINT)
-- 1: login, 2: bind_phone, 3: reset_password
purpose SMALLINT NOT NULL DEFAULT 1 CHECK (purpose IN (1, 2, 3)),
-- 验证状态
expires_at TIMESTAMPTZ NOT NULL,
verified BOOLEAN DEFAULT false,
ip_address INET,
-- 时间戳
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- 单列索引
CREATE INDEX idx_sms_codes_phone ON sms_verification_codes(phone);
CREATE INDEX idx_sms_codes_purpose ON sms_verification_codes(purpose);
CREATE INDEX idx_sms_codes_ip ON sms_verification_codes(ip_address);
-- 组合索引
CREATE INDEX idx_sms_codes_phone_purpose ON sms_verification_codes(
phone, country_code, purpose, verified, expires_at
);
-- 条件索引(仅索引未验证的记录)
CREATE INDEX idx_sms_codes_active ON sms_verification_codes(expires_at)
WHERE verified = false AND deleted_at IS NULL;
-- 注释
COMMENT ON TABLE sms_verification_codes IS '短信验证码表';
COMMENT ON COLUMN sms_verification_codes.purpose IS '用途:1=登录,2=绑定手机,3=重置密码';
SmsVerificationCode 模型
# app/models/sms.py
from enum import IntEnum
from typing import Optional
from datetime import datetime
from uuid import UUID
from sqlmodel import SQLModel, Field, Column, Index
from sqlalchemy import SmallInteger, text
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, INET
from app.utils.id_generator import generate_uuid
class SmsPurpose(IntEnum):
"""短信用途枚举(使用 SMALLINT 存储)"""
LOGIN = 1
BIND_PHONE = 2
RESET_PASSWORD = 3
@classmethod
def from_string(cls, value: str) -> "SmsPurpose":
"""从字符串转换为枚举值"""
mapping = {
"login": cls.LOGIN,
"bind_phone": cls.BIND_PHONE,
"reset_password": cls.RESET_PASSWORD,
}
return mapping.get(value.lower(), cls.LOGIN)
def to_string(self) -> str:
"""转换为字符串"""
mapping = {
self.LOGIN: "login",
self.BIND_PHONE: "bind_phone",
self.RESET_PASSWORD: "reset_password",
}
return mapping[self]
class SmsVerificationCode(SQLModel, table=True):
"""短信验证码表"""
__tablename__ = "sms_verification_codes"
# 主键(UUID v7)
id: UUID = Field(
sa_column=Column(
PG_UUID(as_uuid=True),
primary_key=True,
default=generate_uuid
)
)
# 基本字段
phone: str = Field(max_length=20, index=True)
country_code: str = Field(default="+86", max_length=10)
code: str = Field(max_length=10)
# 用途枚举(SMALLINT)
purpose: int = Field(
sa_column=Column(
SmallInteger,
nullable=False,
default=SmsPurpose.LOGIN,
index=True
),
description="用途:1=登录,2=绑定手机,3=重置密码"
)
# 验证状态
expires_at: datetime = Field(nullable=False)
verified: bool = Field(default=False)
ip_address: Optional[str] = Field(
default=None,
sa_column=Column(INET, nullable=True, index=True)
)
# 时间戳
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
deleted_at: Optional[datetime] = Field(default=None)
# 表级索引
__table_args__ = (
Index(
'idx_sms_codes_phone_purpose',
'phone', 'country_code', 'purpose', 'verified', 'expires_at'
),
Index(
'idx_sms_codes_active',
'expires_at',
postgresql_where=text('verified = false AND deleted_at IS NULL')
),
)
环境变量配置
# 阿里云短信服务
SMS_ACCESS_KEY_ID=your_access_key_id
SMS_ACCESS_KEY_SECRET=your_access_key_secret
SMS_SIGN_NAME=道研组
SMS_TEMPLATE_CODE=SMS_123456789
# Redis
REDIS_URL=redis://localhost:6379/0
相关文档
架构层次
Model 层(app/models/sms.py)
定义数据库模型和枚举类型:
SmsPurposeIntEnum(SMALLINT 存储)SmsVerificationCodeSQLModel
Schema 层(app/schemas/sms.py)
定义 API 请求/响应模型:
from pydantic import BaseModel, Field, field_validator, ConfigDict
from enum import Enum
class SmsPurposeEnum(str, Enum):
"""API 层用途枚举(字符串)"""
LOGIN = "login"
BIND_PHONE = "bind_phone"
RESET_PASSWORD = "reset_password"
class SmsCodeSendRequest(BaseModel):
"""发送验证码请求"""
model_config = ConfigDict(populate_by_name=True)
phone: str = Field(..., min_length=11, max_length=11, description="手机号")
country_code: str = Field(default="+86", alias="countryCode", description="国家代码")
purpose: SmsPurposeEnum = Field(default=SmsPurposeEnum.LOGIN, description="用途")
@field_validator("phone")
@classmethod
def validate_phone(cls, v: str) -> str:
"""验证手机号格式"""
if not v.isdigit():
raise ValueError("手机号必须为纯数字")
return v
class SmsCodeVerifyRequest(BaseModel):
"""验证验证码请求"""
model_config = ConfigDict(populate_by_name=True)
phone: str = Field(..., min_length=11, max_length=11, description="手机号")
country_code: str = Field(default="+86", alias="countryCode", description="国家代码")
code: str = Field(..., min_length=6, max_length=6, description="验证码")
purpose: SmsPurposeEnum = Field(default=SmsPurposeEnum.LOGIN, description="用途")
@field_validator("code")
@classmethod
def validate_code(cls, v: str) -> str:
"""验证验证码格式"""
if not v.isdigit():
raise ValueError("验证码必须为纯数字")
return v
class SmsCodeSendResponse(BaseModel):
"""发送验证码响应"""
model_config = ConfigDict(populate_by_name=True)
message: str = Field(..., description="提示消息")
expires_in: int = Field(..., alias="expiresIn", description="过期时间(秒)")
Repository 层(app/repositories/sms_repository.py)
数据访问层:
from typing import Optional
from datetime import datetime, timezone
from uuid import UUID
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from app.models.sms import SmsVerificationCode
from app.core.exceptions import DatabaseError
from app.core.logging import logger
class SmsRepository:
"""短信验证码数据访问层"""
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, code: SmsVerificationCode) -> SmsVerificationCode:
"""
创建验证码
Args:
code: 验证码对象
Returns:
创建的验证码对象
Raises:
DatabaseError: 数据库操作失败
"""
try:
self.session.add(code)
await self.session.commit()
await self.session.refresh(code)
return code
except SQLAlchemyError as e:
await self.session.rollback()
logger.error(f"创建验证码失败: {str(e)}", exc_info=True)
raise DatabaseError("创建验证码失败")
async def get_valid_code(
self,
phone: str,
country_code: str,
purpose: int
) -> Optional[SmsVerificationCode]:
"""
获取有效的验证码
Args:
phone: 手机号
country_code: 国家代码
purpose: 用途(SMALLINT)
Returns:
验证码对象或 None
"""
try:
query = select(SmsVerificationCode).where(
SmsVerificationCode.phone == phone,
SmsVerificationCode.country_code == country_code,
SmsVerificationCode.purpose == purpose,
SmsVerificationCode.verified == False,
SmsVerificationCode.expires_at > datetime.now(timezone.utc),
SmsVerificationCode.deleted_at.is_(None)
).order_by(SmsVerificationCode.created_at.desc())
result = await self.session.execute(query)
return result.scalar_one_or_none()
except SQLAlchemyError as e:
logger.error(f"查询验证码失败: {str(e)}", exc_info=True)
raise DatabaseError("查询验证码失败")
async def mark_as_verified(self, code_id: UUID) -> None:
"""
标记为已验证
Args:
code_id: 验证码 ID
Raises:
DatabaseError: 数据库操作失败
"""
try:
code = await self.session.get(SmsVerificationCode, code_id)
if code:
code.verified = True
code.updated_at = datetime.now(timezone.utc)
await self.session.commit()
except SQLAlchemyError as e:
await self.session.rollback()
logger.error(f"标记验证码失败: {str(e)}", exc_info=True)
raise DatabaseError("标记验证码失败")
async def delete_expired(self, cutoff_time: datetime) -> int:
"""
删除过期验证码(软删除)
Args:
cutoff_time: 截止时间
Returns:
删除的记录数
"""
try:
query = select(SmsVerificationCode).where(
SmsVerificationCode.expires_at < cutoff_time,
SmsVerificationCode.deleted_at.is_(None)
)
result = await self.session.execute(query)
codes = result.scalars().all()
count = 0
now = datetime.now(timezone.utc)
for code in codes:
code.deleted_at = now
count += 1
await self.session.commit()
return count
except SQLAlchemyError as e:
await self.session.rollback()
logger.error(f"删除过期验证码失败: {str(e)}", exc_info=True)
return 0
测试规范
单元测试
# tests/unit/test_sms_service.py
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.sms_service import SmsService
from app.models.sms import SmsVerificationCode, SmsPurpose
from app.core.exceptions import ValidationError, RateLimitError
@pytest.fixture
def mock_session():
"""模拟数据库会话"""
return AsyncMock()
@pytest.fixture
def mock_redis():
"""模拟 Redis 客户端"""
redis = AsyncMock()
redis.get = AsyncMock(return_value=None)
redis.incr = AsyncMock(return_value=1)
redis.set = AsyncMock()
redis.expire = AsyncMock()
return redis
@pytest.fixture
def sms_service(mock_session, mock_redis):
"""创建短信服务实例"""
return SmsService(mock_session, mock_redis)
class TestSmsService:
"""短信服务单元测试"""
@pytest.mark.asyncio
async def test_generate_code(self, sms_service):
"""测试生成验证码"""
code = sms_service._generate_code()
assert len(code) == 6
assert code.isdigit()
@pytest.mark.asyncio
async def test_send_verification_code_success(self, sms_service, mock_redis):
"""测试发送验证码成功"""
with patch.object(sms_service, '_send_sms', new_callable=AsyncMock):
with patch.object(sms_service.repository, 'create', new_callable=AsyncMock) as mock_create:
mock_create.return_value = SmsVerificationCode(
phone="13800138000",
country_code="+86",
code="123456",
purpose=SmsPurpose.LOGIN.value,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10)
)
result = await sms_service.send_verification_code(
phone="13800138000",
country_code="+86",
purpose=SmsPurpose.LOGIN,
ip_address="127.0.0.1"
)
assert result["message"] == "验证码已发送"
assert result["expires_in"] == 600
mock_create.assert_called_once()
@pytest.mark.asyncio
async def test_send_verification_code_rate_limit_ip(self, sms_service, mock_redis):
"""测试 IP 限流"""
mock_redis.get = AsyncMock(return_value="3")
with pytest.raises(RateLimitError, match="发送过于频繁"):
await sms_service.send_verification_code(
phone="13800138000",
country_code="+86",
purpose=SmsPurpose.LOGIN,
ip_address="127.0.0.1"
)
@pytest.mark.asyncio
async def test_verify_code_success(self, sms_service):
"""测试验证验证码成功"""
mock_code = SmsVerificationCode(
phone="13800138000",
country_code="+86",
code="123456",
purpose=SmsPurpose.LOGIN.value,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
verified=False
)
with patch.object(sms_service.repository, 'get_valid_code', new_callable=AsyncMock) as mock_get:
with patch.object(sms_service.repository, 'mark_as_verified', new_callable=AsyncMock):
mock_get.return_value = mock_code
result = await sms_service.verify_code(
phone="13800138000",
country_code="+86",
code="123456",
purpose=SmsPurpose.LOGIN
)
assert result is True
@pytest.mark.asyncio
async def test_verify_code_invalid(self, sms_service):
"""测试验证码错误"""
mock_code = SmsVerificationCode(
phone="13800138000",
country_code="+86",
code="123456",
purpose=SmsPurpose.LOGIN.value,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
verified=False
)
with patch.object(sms_service.repository, 'get_valid_code', new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_code
with pytest.raises(ValidationError, match="验证码错误"):
await sms_service.verify_code(
phone="13800138000",
country_code="+86",
code="654321", # 错误的验证码
purpose=SmsPurpose.LOGIN
)
@pytest.mark.asyncio
async def test_verify_code_expired(self, sms_service):
"""测试验证码过期"""
with patch.object(sms_service.repository, 'get_valid_code', new_callable=AsyncMock) as mock_get:
mock_get.return_value = None
with pytest.raises(ValidationError, match="验证码不存在或已过期"):
await sms_service.verify_code(
phone="13800138000",
country_code="+86",
code="123456",
purpose=SmsPurpose.LOGIN
)
集成测试
# tests/integration/test_sms_api.py
import pytest
from httpx import AsyncClient
from datetime import datetime, timedelta, timezone
from app.main import app
from app.models.sms import SmsVerificationCode, SmsPurpose
@pytest.mark.asyncio
async def test_send_sms_code_success(client: AsyncClient, db_session, redis_client):
"""测试发送验证码 API"""
response = await client.post(
"/api/v1/auth/sms/send",
json={
"phone": "13800138000",
"countryCode": "+86",
"purpose": "login"
}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["message"] == "验证码已发送"
assert data["data"]["expiresIn"] == 600
@pytest.mark.asyncio
async def test_send_sms_code_rate_limit(client: AsyncClient, redis_client):
"""测试发送频率限制"""
# 第一次发送成功
response1 = await client.post(
"/api/v1/auth/sms/send",
json={
"phone": "13800138001",
"countryCode": "+86",
"purpose": "login"
}
)
assert response1.status_code == 200
# 第二次发送被限流
response2 = await client.post(
"/api/v1/auth/sms/send",
json={
"phone": "13800138001",
"countryCode": "+86",
"purpose": "login"
}
)
assert response2.status_code == 429
data = response2.json()
assert "频繁" in data["message"]
@pytest.mark.asyncio
async def test_verify_sms_code_success(client: AsyncClient, db_session):
"""测试验证验证码 API"""
# 先创建一个验证码
code = SmsVerificationCode(
phone="13800138002",
country_code="+86",
code="123456",
purpose=SmsPurpose.LOGIN.value,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
verified=False
)
db_session.add(code)
await db_session.commit()
# 验证验证码
response = await client.post(
"/api/v1/auth/sms/verify",
json={
"phone": "13800138002",
"countryCode": "+86",
"code": "123456",
"purpose": "login"
}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["verified"] is True
@pytest.mark.asyncio
async def test_verify_sms_code_invalid(client: AsyncClient, db_session):
"""测试验证错误的验证码"""
# 先创建一个验证码
code = SmsVerificationCode(
phone="13800138003",
country_code="+86",
code="123456",
purpose=SmsPurpose.LOGIN.value,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
verified=False
)
db_session.add(code)
await db_session.commit()
# 验证错误的验证码
response = await client.post(
"/api/v1/auth/sms/verify",
json={
"phone": "13800138003",
"countryCode": "+86",
"code": "654321", # 错误的验证码
"purpose": "login"
}
)
assert response.status_code == 400
data = response.json()
assert "错误" in data["message"]
运行测试
# 运行所有短信服务测试
docker exec jointo-server-app pytest tests/unit/test_sms_service.py -v
# 运行集成测试
docker exec jointo-server-app pytest tests/integration/test_sms_api.py -v
# 运行测试并生成覆盖率报告
docker exec jointo-server-app pytest tests/ -v --cov=app/services/sms_service --cov-report=html
文档版本:v3.0
最后更新:2026-01-29