Files
xiaoxia-saas/packages/application/auth/verification_code_service.py
xiaoxia 269723ced2
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m57s
CI/CD Pipeline / Unit Tests (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
feat(#558): 微信登录 + 绑定手机号邮箱完整后端实现 (#670)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 09:21:01 +08:00

207 lines
5.8 KiB
Python
Executable File

"""
验证码服务
- 生成验证码
- 校验验证码
- 频控(60s 冷却 + 每日上限)
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Optional
from packages.domain.verification_code import VerificationCode
from packages.ports.verification_code_repository import VerificationCodeRepository
logger = logging.getLogger(__name__)
# 频控参数
RESEND_COOLDOWN_SECONDS = 60 # 重发冷却时间
DAILY_LIMIT = 10 # 每日发送上限
MAX_ATTEMPTS = 5 # 单验证码最大尝试次数
DEFAULT_TTL_SECONDS = 300 # 默认有效期 5 分钟
# 验证码类型
CODE_TYPE_EMAIL_BIND = "email_bind"
CODE_TYPE_PHONE_BIND = "phone_bind"
CODE_TYPE_EMAIL_LOGIN = "email_login"
CODE_TYPE_PHONE_LOGIN = "phone_login"
CODE_TYPE_RESET_PASSWORD = "reset_password"
VALID_CODE_TYPES = {
CODE_TYPE_EMAIL_BIND,
CODE_TYPE_PHONE_BIND,
CODE_TYPE_EMAIL_LOGIN,
CODE_TYPE_PHONE_LOGIN,
CODE_TYPE_RESET_PASSWORD,
}
class VerificationCodeService:
"""验证码服务"""
def __init__(
self,
repo: VerificationCodeRepository,
resend_cooldown: int = RESEND_COOLDOWN_SECONDS,
daily_limit: int = DAILY_LIMIT,
max_attempts: int = MAX_ATTEMPTS,
default_ttl: int = DEFAULT_TTL_SECONDS,
):
self.repo = repo
self.resend_cooldown = resend_cooldown
self.daily_limit = daily_limit
self.max_attempts = max_attempts
self.default_ttl = default_ttl
def generate(
self,
recipient: str,
code_type: str,
ttl_seconds: int | None = None,
custom_code: str | None = None,
) -> tuple[Optional[VerificationCode], Optional[str]]:
"""
生成验证码
Returns:
(验证码实体, 错误信息)
"""
recipient = recipient.strip()
# 参数校验
if not recipient:
return None, "接收方不能为空"
if code_type not in VALID_CODE_TYPES:
return None, f"无效的验证码类型: {code_type}"
# 频控检查
can_send, wait_seconds = self._check_rate_limit(recipient, code_type)
if not can_send:
if wait_seconds > 0:
return None, f"发送太频繁,请 {wait_seconds} 秒后再试"
return None, "今日发送次数已达上限"
# 生成并保存
code = VerificationCode.create(
recipient=recipient,
code_type=code_type,
ttl_seconds=ttl_seconds or self.default_ttl,
custom_code=custom_code,
)
self.repo.save(code)
return code, None
def verify(
self,
recipient: str,
code_type: str,
code_value: str,
consume: bool = True,
) -> tuple[bool, Optional[str]]:
"""
校验验证码
Args:
recipient: 接收方(邮箱/手机号)
code_type: 验证码类型
code_value: 用户输入的验证码
consume: 校验成功后是否标记为已使用
Returns:
(是否通过, 错误信息)
"""
recipient = recipient.strip()
code_value = code_value.strip()
if not recipient or not code_value:
return False, "参数不完整"
# 查找最新的验证码
latest = self.repo.find_latest(recipient, code_type)
if not latest:
return False, "验证码不存在或已过期"
# 增加尝试次数
latest.increment_attempts()
self.repo.save(latest)
# 检查是否已使用
if latest.is_used:
return False, "验证码已使用,请重新获取"
# 检查是否过期
if latest.is_expired:
return False, "验证码已过期,请重新获取"
# 检查尝试次数
if latest.attempts > self.max_attempts:
return False, "验证次数过多,请重新获取验证码"
# 校验验证码
if latest.code != code_value:
return False, "验证码错误"
# 校验通过,标记为已使用
if consume:
latest.mark_used()
self.repo.save(latest)
return True, None
def _check_rate_limit(self, recipient: str, code_type: str) -> tuple[bool, int]:
"""
频控检查
Returns:
(是否允许发送, 需等待秒数)
"""
# 检查冷却时间
latest = self.repo.find_latest(recipient, code_type)
if latest:
elapsed = (datetime.now(timezone.utc) - latest.created_at).total_seconds()
if elapsed < self.resend_cooldown:
wait = int(self.resend_cooldown - elapsed)
return False, wait
# 检查每日上限
today_count = self.repo.count_today(recipient, code_type)
if today_count >= self.daily_limit:
return False, 0
return True, 0
def validate_phone(phone: str) -> tuple[bool, str]:
"""校验手机号格式(中国大陆手机号)"""
phone = phone.strip()
if not phone:
return False, "手机号不能为空"
# 支持 +86 前缀或纯 11 位
pattern = r"^(\+86)?1[3-9]\d{9}$"
if not re.match(pattern, phone):
return False, "手机号格式不正确"
return True, ""
def normalize_phone(phone: str) -> str:
"""标准化手机号(去掉 +86 前缀,统一存储格式)"""
phone = phone.strip()
if phone.startswith("+86"):
phone = phone[3:]
return phone
def validate_email(email: str) -> tuple[bool, str]:
"""校验邮箱格式"""
email = email.strip()
if not email:
return False, "邮箱不能为空"
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, email):
return False, "邮箱格式不正确"
return True, ""