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.
 

9.6 KiB

SMS 服务完整实现与技术栈合规性优化

日期: 2026-01-29
类型: 功能实现 + 代码优化
影响范围: server/app/services/sms_service.py, server/app/repositories/sms_repository.py, server/app/schemas/sms.py, server/app/api/v1/auth.py


变更概述

完成短信验证码服务的完整实现,并优化代码以符合 jointo-tech-stack 技术栈规范,包括依赖注入、异常处理、日志记录、时区处理和统一响应格式。


主要变更

1. Service 层优化 (sms_service.py)

1.1 依赖注入优化

问题: Service 层直接创建 Redis 客户端,违反依赖注入原则

修正前:

def __init__(self, session: AsyncSession):
    self.redis_client: Optional[Redis] = None
    self.http_client = httpx.AsyncClient(timeout=10.0)

async def _get_redis(self) -> Redis:
    if self.redis_client is None:
        self.redis_client = Redis.from_url(settings.REDIS_URL)
    return self.redis_client

修正后:

def __init__(self, session: AsyncSession, redis_client: Redis):
    """
    Args:
        session: 异步数据库会话
        redis_client: 异步 Redis 客户端(由依赖注入提供)
    """
    self.redis_client = redis_client
    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

1.2 时区处理修正

问题: 使用 datetime.utcnow() 而非 datetime.now(timezone.utc)

修正:

# 修正前
expires_at=datetime.utcnow() + timedelta(minutes=10)

# 修正后
from datetime import datetime, timezone
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10)

1.3 日志系统统一

问题: 使用 loguru 而非项目标准的 logging 模块

修正:

# 修正前
from loguru import logger

# 修正后
from app.core.logging import logger

1.4 增强错误处理和日志

Service 层方法增强:

async def send_verification_code(...) -> dict:
    try:
        # 业务逻辑
        logger.info(
            f"验证码已发送",
            extra={
                "phone": phone,
                "country_code": country_code,
                "purpose": purpose.to_string(),
                "ip_address": ip_address
            }
        )
        return result
    except (RateLimitError, ValidationError):
        raise
    except Exception as e:
        logger.error(f"发送验证码失败: {str(e)}", exc_info=True)
        raise ValidationError("发送验证码失败,请稍后重试")

1.5 资源管理优化

问题: 使用 close() 方法手动清理资源

修正: 实现异步上下文管理器

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()

1.6 测试验证码优化

修正: 统一测试验证码为 4 位

# 修正前
if code == "666666":

# 修正后
if code == "6666":

2. Repository 层优化 (sms_repository.py)

2.1 添加异常处理

所有数据库操作增加异常处理:

async def create(self, code: SmsVerificationCode) -> SmsVerificationCode:
    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("创建验证码失败")

2.2 时区处理统一

修正所有时间比较:

# 修正前
SmsVerificationCode.expires_at > datetime.utcnow()
code.updated_at = datetime.utcnow()

# 修正后
from datetime import datetime, timezone
SmsVerificationCode.expires_at > datetime.now(timezone.utc)
code.updated_at = datetime.now(timezone.utc)

2.3 软删除优化

批量软删除时使用统一时间戳:

async def delete_expired(self, cutoff_time: datetime) -> int:
    try:
        # ...
        count = 0
        now = datetime.now(timezone.utc)  # 统一时间戳
        for code in codes:
            code.deleted_at = now
            count += 1
        # ...
    except SQLAlchemyError as e:
        await self.session.rollback()
        logger.error(f"删除过期验证码失败: {str(e)}", exc_info=True)
        return 0

3. Schema 层优化 (sms.py)

3.1 添加 ConfigDict

所有 Schema 添加配置:

from pydantic import BaseModel, Field, field_validator, ConfigDict

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="用途")

3.2 响应 Schema 优化

统一字段命名:

class SmsCodeVerifyResponse(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    
    verified: bool = Field(..., description="验证是否成功")  # 修正前: success

4. API 路由层优化 (auth.py)

4.1 依赖注入更新

添加 Redis 依赖注入:

from redis.asyncio import Redis
from app.api.deps import get_redis

@router.post("/sms/send", ...)
async def send_sms_code(
    request: Request,
    body: SmsCodeSendRequest,
    session: AsyncSession = Depends(get_session),
    redis_client: Redis = Depends(get_redis)  # 新增
):
    sms_service = SmsService(session, redis_client)  # 传入 redis_client

4.2 统一响应格式

使用 ApiResponse 包装器:

from app.schemas.response import ApiResponse
from app.schemas.sms import SmsCodeSendResponse

@router.post(
    "/sms/send",
    response_model=ApiResponse[SmsCodeSendResponse],  # 统一响应格式
    summary="发送短信验证码"
)
async def send_sms_code(...):
    result = await sms_service.send_verification_code(...)
    
    return ApiResponse.success(
        data=SmsCodeSendResponse(
            message=result["message"],
            expires_in=result["expires_in"]
        )
    )

4.3 枚举转换优化

简化枚举转换逻辑:

# 修正前
purpose_map = {
    'login': SmsPurpose.LOGIN,
    'bind_phone': SmsPurpose.BIND_PHONE,
    'reset_password': SmsPurpose.RESET_PASSWORD
}
purpose_enum = purpose_map.get(request.purpose, SmsPurpose.LOGIN)

# 修正后
purpose_enum = SmsPurpose.from_string(body.purpose.value)

技术栈合规性检查

已符合规范

  • 异步编程(async/awaitAsyncSessionasyncpg
  • UUID v7 主键(generate_uuid
  • 枚举类型(IntEnum + SMALLINT
  • 仓储模式(Service/Repository 分层)
  • 时间戳字段(TIMESTAMPTZ + timezone.utc
  • 索引设计(单列、组合、条件索引)
  • 无物理外键
  • Redis 异步客户端
  • HTTP 异步客户端

新增合规项

  • 依赖注入(Redis 客户端由 FastAPI 提供)
  • 资源管理(异步上下文管理器)
  • 错误处理(try-except + 异常转换)
  • 日志记录(结构化日志 + extra 字段)
  • Schema 配置(ConfigDict + populate_by_name
  • 字段验证(@field_validator
  • 统一响应格式(ApiResponse + timestamp

文件变更清单

修改的文件

  1. server/app/services/sms_service.py

    • 优化依赖注入(Redis 客户端)
    • 修正时区处理
    • 增强错误处理和日志
    • 实现异步上下文管理器
    • 统一测试验证码
  2. server/app/repositories/sms_repository.py

    • 添加异常处理
    • 修正时区处理
    • 优化软删除逻辑
    • 添加日志记录
  3. server/app/schemas/sms.py

    • 添加 ConfigDict
    • 优化响应 Schema 字段命名
  4. server/app/api/v1/auth.py

    • 添加 Redis 依赖注入
    • 使用统一响应格式
    • 简化枚举转换逻辑

测试建议

单元测试

# 运行 SMS 服务单元测试
docker exec jointo-server-app pytest tests/unit/test_sms_service.py -v

# 运行 SMS Repository 单元测试
docker exec jointo-server-app pytest tests/unit/test_sms_repository.py -v

集成测试

# 运行 SMS API 集成测试
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=app/repositories/sms_repository \
  --cov-report=html

后续优化建议

1. 性能优化

  • 考虑使用 Redis Pipeline 批量操作
  • 添加验证码发送成功率监控
  • 优化数据库查询索引

2. 安全增强

  • 添加验证码尝试次数限制
  • 实现验证码加密存储
  • 添加异常 IP 黑名单机制

3. 功能扩展

  • 支持多短信服务商(腾讯云、华为云)
  • 添加短信模板管理
  • 实现短信发送队列

相关文档


变更作者: Kiro AI
审核状态: 待审核
文档版本: v1.0