80 lines
2.7 KiB
Python
80 lines
2.7 KiB
Python
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)
|