import re import secrets import string from app.services.mailer import send_email from app.services.redis_client import get_redis EMAIL_CODE_TTL_SEC = 600 EMAIL_CODE_PURPOSES = ("register", "login", "reset") _EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$") def normalize_email(email: str) -> str: return (email or "").strip().lower() def is_valid_email(email: str) -> bool: text = normalize_email(email) return bool(text) and len(text) <= 191 and _EMAIL_RE.match(text) is not None def _key(purpose: str, email: str) -> str: return f"gsfdc:email_code:{purpose}:{normalize_email(email)}" def _rate_key(purpose: str, email: str) -> str: return f"gsfdc:email_code_rate:{purpose}:{normalize_email(email)}" def _new_code() -> str: return "".join(secrets.choice(string.digits) for _ in range(6)) def issue_email_code(*, email: str, purpose: str) -> None: if purpose not in EMAIL_CODE_PURPOSES: raise ValueError("验证码用途不正确") if not is_valid_email(email): raise ValueError("邮箱格式不正确") normalized = normalize_email(email) redis = get_redis() if redis.get(_rate_key(purpose, normalized)): raise ValueError("发送过于频繁,请稍后再试") code = _new_code() redis.set(_key(purpose, normalized), code, ex=EMAIL_CODE_TTL_SEC) redis.set(_rate_key(purpose, normalized), "1", ex=60) labels = {"register": "注册", "login": "登录", "reset": "重置密码"} subject = f"甘肃省房地产价格评估行业协会 · {labels[purpose]}验证码" body = ( f"您的{labels[purpose]}验证码是:{code}\n" f"有效期 {EMAIL_CODE_TTL_SEC // 60} 分钟。如非本人操作请忽略本邮件。\n" ) try: send_email(to=normalized, subject=subject, body=body) except Exception: # 发信失败时仍保留验证码,便于本地/测试校验路径;生产日志可见失败。 import logging logging.getLogger("gsfdc.email_code").exception("邮箱验证码发信失败") def consume_email_code(*, email: str, purpose: str, code: str) -> bool: if purpose not in EMAIL_CODE_PURPOSES: return False if not is_valid_email(email) or not isinstance(code, str): return False normalized = normalize_email(email) text = code.strip() if not text: return False # 仅在码匹配时删除;输错不消耗,避免一次错码废掉仍有效的验证码。 redis = get_redis() key = _key(purpose, normalized) deleted = redis.eval( "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", 1, key, text, ) return bool(deleted)