feat(user system): modifies the email address.

This commit is contained in:
Timebomb2018
2026-02-25 11:29:42 +08:00
parent 0b9cc0f068
commit 12ba3d473e
6 changed files with 363 additions and 2 deletions

View File

@@ -0,0 +1,88 @@
import smtplib
import re
import asyncio
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from email.utils import formataddr
from concurrent.futures import ThreadPoolExecutor
from app.core.config import settings
from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException
from app.core.logging_config import get_business_logger
business_logger = get_business_logger()
def _send_email_sync(to_email: str, subject: str, html_content: str, text_content: str = None):
"""同步发送邮件"""
smtp_server = settings.SMTP_SERVER
smtp_port = settings.SMTP_PORT
smtp_user = settings.SMTP_USER
smtp_password = settings.SMTP_PASSWORD
if not smtp_server or not smtp_user or not smtp_password:
raise BusinessException("邮件服务未配置", code=BizCode.SERVICE_UNAVAILABLE)
msg = MIMEMultipart('alternative')
msg['Subject'] = Header(subject, "utf-8")
from_name = "MemoryBear系统"
msg['From'] = formataddr((Header(from_name, 'utf-8').encode(), smtp_user))
msg['To'] = Header(to_email, "utf-8")
if not text_content:
text_content = html_content.replace('<br>', '\n').replace('<p>', '\n').replace('</p>', '\n')
text_content = re.sub(r'<.*?>', '', text_content)
text_part = MIMEText(text_content, 'plain', 'utf-8')
msg.attach(text_part)
html_part = MIMEText(html_content, 'html', 'utf-8')
msg.attach(html_part)
if smtp_port == 465:
with smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=10) as server:
server.login(smtp_user, smtp_password)
server.send_message(msg)
else:
with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as server:
server.starttls()
server.login(smtp_user, smtp_password)
server.send_message(msg)
async def send_email(to_email: str, subject: str, html_content: str, text_content: str = None):
"""异步发送邮件"""
to_email = to_email.strip()
if not to_email or not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', to_email):
err_msg = f"收件人邮箱格式无效: {to_email}"
business_logger.error(err_msg)
raise BusinessException(err_msg, code=BizCode.INVALID_PARAMETER)
try:
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as executor:
await loop.run_in_executor(
executor,
_send_email_sync,
to_email,
subject,
html_content,
text_content
)
business_logger.info(f"邮件发送成功: {to_email}")
except smtplib.SMTPAuthenticationError:
err_msg = "SMTP认证失败请检查SMTP账号/密码是否正确"
business_logger.error(f"邮件发送失败: {to_email} - {err_msg}")
raise BusinessException(err_msg, code=BizCode.UNAUTHORIZED)
except smtplib.SMTPConnectError:
err_msg = "SMTP服务器连接失败请检查服务器地址/端口是否正确"
business_logger.error(f"邮件发送失败: {to_email} - {err_msg}")
raise BusinessException(err_msg, code=BizCode.SERVICE_UNAVAILABLE)
except TimeoutError:
err_msg = "邮件发送超时请检查SMTP服务器配置"
business_logger.error(f"邮件发送失败: {to_email} - {err_msg}")
raise BusinessException(err_msg, code=BizCode.BAD_REQUEST)
except Exception as e:
business_logger.error(f"邮件发送失败: {to_email} - {str(e)}")
raise BusinessException(f"邮件发送失败: {str(e)}", code=BizCode.SERVICE_UNAVAILABLE)

View File

@@ -1,13 +1,18 @@
import datetime
import json
import secrets
import string
from pydantic import EmailStr
from sqlalchemy.orm import Session
import uuid
from app.aioRedis import aio_redis_set, aio_redis_get, aio_redis_delete
from app.models.user_model import User
from app.repositories import user_repository
from app.schemas.user_schema import UserCreate
from app.schemas.tenant_schema import TenantCreate
from app.services.email_service import send_email
from app.services.tenant_service import TenantService
from app.services.session_service import SessionService
from app.core.security import get_password_hash, verify_password
@@ -563,3 +568,175 @@ def generate_random_password(length: int = 12) -> str:
secrets.SystemRandom().shuffle(password)
return ''.join(password)
def generate_email_code() -> str:
"""生成6位数字验证码"""
return ''.join([str(secrets.randbelow(10)) for _ in range(6)])
async def send_email_code_method(db: Session, email: EmailStr, user_id: uuid.UUID):
"""发送邮箱验证码"""
business_logger.info(f"发送邮箱验证码: email={email}")
# 检查发送间隔
rate_limit_key = f"email_code_rate:{user_id}"
last_send = await aio_redis_get(rate_limit_key)
if last_send:
raise BusinessException("请稍后再试验证码发送间隔为1分钟", code=BizCode.RATE_LIMITED)
# 检查新邮箱是否已被使用
existing_user = user_repository.get_user_by_email(db=db, email=email)
if existing_user and existing_user.id != user_id:
raise BusinessException("邮箱已被使用", code=BizCode.DUPLICATE_NAME)
if existing_user and existing_user.id == user_id:
raise BusinessException("新邮箱与当前邮箱相同", code=BizCode.DUPLICATE_NAME)
# 生成验证码
code = generate_email_code()
# 存储到 Redis5分钟过期
cache_key = f"email_code:{user_id}:{email}"
await aio_redis_set(cache_key, json.dumps(code), expire=300)
# 发送邮件
await send_email(
email,
"邮箱验证码",
f'<p>您的验证码是:<strong>{code}</strong></p><p>验证码在5分钟内有效。</p>'
)
# 设置发送间隔限制60秒
await aio_redis_set(rate_limit_key, "1", expire=60)
business_logger.info(f"邮箱验证码已发送: {email}")
async def verify_and_change_email(db: Session, user_id: uuid.UUID, new_email: EmailStr, code: str) -> User:
"""验证验证码并修改邮箱"""
business_logger.info(f"验证并修改邮箱: user_id={user_id}, new_email={new_email}")
db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
if not db_user:
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
# 验证验证码
cache_key = f"email_code:{user_id}:{new_email}"
cached_code = await aio_redis_get(cache_key)
if not cached_code:
raise BusinessException("验证码已过期", code=BizCode.VALIDATION_FAILED)
if json.loads(cached_code) != code:
raise BusinessException("验证码错误", code=BizCode.VALIDATION_FAILED)
# 修改邮箱
db_user.email = new_email
db.commit()
db.refresh(db_user)
# 删除验证码
await aio_redis_delete(cache_key)
# 使所有旧 tokens 失效
# await SessionService.invalidate_all_user_tokens(str(user_id))
business_logger.info(f"用户邮箱修改成功: {db_user.username}, new_email={new_email}")
return db_user
# def generate_email_token(user_id: str, old_email: str, new_email: str) -> str:
# """生成邮箱修改token"""
# payload = {
# "user_id": user_id,
# "old_email": old_email,
# "new_email": new_email,
# "exp": datetime.datetime.now(datetime.timezone.utc) + timedelta(hours=24)
# }
# return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
#
#
# def verify_email_token(token: str) -> dict:
# """验证邮箱修改token"""
# try:
# payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# return payload
# except jwt.ExpiredSignatureError:
# raise BusinessException("链接已过期", code=BizCode.VALIDATION_FAILED)
# except jwt.InvalidTokenError:
# raise BusinessException("无效的链接", code=BizCode.VALIDATION_FAILED)
#
#
# async def request_change_email(db: Session, user_id: uuid.UUID, new_email: EmailStr, current_user: User):
# """请求修改邮箱,发送验证邮件"""
# business_logger.info(f"用户请求修改邮箱: user_id={user_id}, new_email={new_email}")
#
# if current_user.id != user_id:
# raise PermissionDeniedException("只能修改自己的邮箱")
#
# db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
# if not db_user:
# raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
#
# if db_user.email == new_email:
# raise BusinessException("新邮箱与当前邮箱相同", code=BizCode.VALIDATION_FAILED)
#
# existing_user = user_repository.get_user_by_email(db=db, email=new_email)
# if existing_user and existing_user.id != user_id:
# raise BusinessException("邮箱已被使用", code=BizCode.DUPLICATE_NAME)
#
# token = generate_email_token(str(user_id), db_user.email, new_email)
#
# # 发送确认邮件到旧邮箱
# old_email_link = f"{settings.BASE_URL}/api/users/email/confirm-email-change?token={token}"
# await send_email(
# db_user.email,
# "确认修改邮箱",
# f'<p>请点击以下链接确认修改邮箱:</p><a href="{old_email_link}">确认修改</a>'
# )
#
# business_logger.info(f"邮箱修改确认邮件已发送到旧邮箱: {db_user.email}")
#
#
# async def confirm_email_change(db: Session, token: str):
# """确认修改邮箱(旧邮箱确认)"""
# payload = verify_email_token(token)
# user_id = uuid.UUID(payload["user_id"])
# new_email = payload["new_email"]
#
# db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
# if not db_user:
# raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
#
# # 发送激活邮件到新邮箱
# activate_link = f"{settings.BASE_URL}/api/users/email/activate-new-email?token={token}"
# await send_email(
# new_email,
# "激活新邮箱",
# f'<p>请点击以下链接激活新邮箱:</p><a href="{activate_link}">激活邮箱</a>'
# )
#
# business_logger.info(f"新邮箱激活邮件已发送: {new_email}")
#
#
# async def activate_new_email(db: Session, token: str) -> User:
# """激活新邮箱"""
# payload = verify_email_token(token)
# user_id = uuid.UUID(payload["user_id"])
# new_email = payload["new_email"]
#
# db_user = user_repository.get_user_by_id(db=db, user_id=user_id)
# if not db_user:
# raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
#
# db_user.email = new_email
# db.commit()
# db.refresh(db_user)
#
# # 使所有旧 tokens 失效
# await SessionService.invalidate_all_user_tokens(str(user_id))
#
# business_logger.info(f"用户邮箱修改成功: {db_user.username}, new_email={new_email}")
# return db_user