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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
207 lines
5.8 KiB
Python
Executable File
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, ""
|