diff --git a/backend/.env.example b/backend/.env.example index bdb50cd1..397de69f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,13 +15,7 @@ REDIS_DATABASE=0 TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' # Opera Log OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b' -# App Admin -# OAuth2 -OAUTH2_GITHUB_CLIENT_ID='test' -OAUTH2_GITHUB_CLIENT_SECRET='test' -OAUTH2_LINUX_DO_CLIENT_ID='test' -OAUTH2_LINUX_DO_CLIENT_SECRET='test' -# App Task +# [ App ] task # Celery CELERY_BROKER_REDIS_DATABASE=1 # Rabbitmq @@ -29,3 +23,11 @@ CELERY_RABBITMQ_HOST='127.0.0.1' CELERY_RABBITMQ_PORT=5672 CELERY_RABBITMQ_USERNAME='guest' CELERY_RABBITMQ_PASSWORD='guest' +# [ Plugin ] oauth2 +OAUTH2_GITHUB_CLIENT_ID='test' +OAUTH2_GITHUB_CLIENT_SECRET='test' +OAUTH2_LINUX_DO_CLIENT_ID='test' +OAUTH2_LINUX_DO_CLIENT_SECRET='test' +# [ Plugin ] email +EMAIL_USERNAME='' +EMAIL_PASSWORD='' diff --git a/backend/app/admin/api/v1/sys/user.py b/backend/app/admin/api/v1/sys/user.py index f292fe91..eeae202d 100644 --- a/backend/app/admin/api/v1/sys/user.py +++ b/backend/app/admin/api/v1/sys/user.py @@ -133,6 +133,18 @@ async def update_user_avatar( return response_base.fail() +@router.put('/me/email', summary='更新当前用户邮箱', dependencies=[DependsJwtAuth]) +async def update_user_email( + request: Request, + captcha: Annotated[str, Body(embed=True, description='邮箱验证码')], + email: Annotated[str, Body(embed=True, description='用户邮箱')], +) -> ResponseModel: + count = await user_service.update_email(request=request, captcha=captcha, email=email) + if count > 0: + return response_base.success() + return response_base.fail() + + @router.delete( path='/{pk}', summary='删除用户', diff --git a/backend/app/admin/crud/crud_user.py b/backend/app/admin/crud/crud_user.py index ff49222f..ae5bab9e 100644 --- a/backend/app/admin/crud/crud_user.py +++ b/backend/app/admin/crud/crud_user.py @@ -139,6 +139,17 @@ async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> in """ return await self.update_model(db, user_id, {'avatar': avatar}) + async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int: + """ + 更新用户邮箱 + + :param db: 数据库会话 + :param user_id: 用户 ID + :param email: 邮箱 + :return: + """ + return await self.update_model(db, user_id, {'email': email}) + async def delete(self, db: AsyncSession, user_id: int) -> int: """ 删除用户 diff --git a/backend/app/admin/service/user_service.py b/backend/app/admin/service/user_service.py index 60521415..666cee27 100644 --- a/backend/app/admin/service/user_service.py +++ b/backend/app/admin/service/user_service.py @@ -18,6 +18,7 @@ ) from backend.common.enums import UserPermissionType from backend.common.exception import errors +from backend.common.response.response_code import CustomErrorCode from backend.common.security.jwt import get_token, jwt_decode, password_verify, superuser_verify from backend.core.conf import settings from backend.database.db import async_db_session @@ -206,7 +207,7 @@ async def reset_password(*, request: Request, pk: int, password: str) -> int: @staticmethod async def update_nickname(*, request: Request, nickname: str) -> int: """ - 更新用户昵称 + 更新当前用户昵称 :param request: FastAPI 请求对象 :param nickname: 用户昵称 @@ -225,7 +226,7 @@ async def update_nickname(*, request: Request, nickname: str) -> int: @staticmethod async def update_avatar(*, request: Request, avatar: str) -> int: """ - 更新用户头像 + 更新当前用户头像 :param request: FastAPI 请求对象 :param avatar: 头像地址 @@ -241,10 +242,36 @@ async def update_avatar(*, request: Request, avatar: str) -> int: await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count + @staticmethod + async def update_email(*, request: Request, captcha: str, email: str) -> int: + """ + 更新当前用户邮箱 + + :param request: FastAPI 请求对象 + :param captcha: 邮箱验证码 + :param email: 邮箱 + :return: + """ + async with async_db_session.begin() as db: + token = get_token(request) + token_payload = jwt_decode(token) + user = await user_dao.get(db, token_payload.id) + if not user: + raise errors.NotFoundError(msg='用户不存在') + captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}') + if not captcha_code: + raise errors.RequestError(msg='验证码已失效,请重新获取') + if captcha != captcha_code: + raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) + await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}') + count = await user_dao.update_email(db, token_payload.id, email) + await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') + return count + @staticmethod async def update_password(*, request: Request, obj: ResetPasswordParam) -> int: """ - 更新用户密码 + 更新当前用户密码 :param request: FastAPI 请求对象 :param obj: 密码重置参数 diff --git a/backend/core/conf.py b/backend/core/conf.py index b3816389..7fdb8e58 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -226,6 +226,20 @@ class Settings(BaseSettings): # 基础配置 OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback' + ################################################## + # [ Plugin ] email + ################################################## + # .env + EMAIL_USERNAME: str + EMAIL_PASSWORD: str + + # 基础配置 + EMAIL_HOST: str = 'smtp.qq.com' + EMAIL_PORT: int = 465 + EMAIL_SSL: bool = True + EMAIL_CAPTCHA_REDIS_PREFIX: str = 'fba:email:captcha' + EMAIL_CAPTCHA_EXPIRE_SECONDS: int = 60 * 3 # 3 分钟 + @model_validator(mode='before') @classmethod def check_env(cls, values: Any) -> Any: diff --git a/backend/plugin/config/crud/crud_config.py b/backend/plugin/config/crud/crud_config.py index 312965bb..043fdd89 100644 --- a/backend/plugin/config/crud/crud_config.py +++ b/backend/plugin/config/crud/crud_config.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +from typing import Sequence from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,6 +23,16 @@ async def get(self, db: AsyncSession, pk: int) -> Config | None: """ return await self.select_model_by_column(db, id=pk) + async def get_by_type(self, db: AsyncSession, type: str) -> Sequence[Config | None]: + """ + 通过键名获取参数配置 + + :param db: 数据库会话 + :param type: 参数配置类型 + :return: + """ + return await self.select_models(db, type=type) + async def get_by_key(self, db: AsyncSession, key: str) -> Config | None: """ 通过键名获取参数配置 diff --git a/backend/plugin/email/README.md b/backend/plugin/email/README.md new file mode 100644 index 00000000..0f85c635 --- /dev/null +++ b/backend/plugin/email/README.md @@ -0,0 +1,5 @@ +## 参数配置 + +默认使用本地电子邮件配置 + +支持通过 `config 插件` 动态配置电子邮件参数,当动态配置 `EMAIL_STATUS` 为 `1` 时,将自动应用动态配置 diff --git a/backend/plugin/email/__init__.py b/backend/plugin/email/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/email/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/email/api/__init__.py b/backend/plugin/email/api/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/email/api/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/email/api/router.py b/backend/plugin/email/api/router.py new file mode 100644 index 00000000..3c66c925 --- /dev/null +++ b/backend/plugin/email/api/router.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import APIRouter + +from backend.core.conf import settings +from backend.plugin.email.api.v1.email import router as email_router + +v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/emails', tags=['电子邮件']) + +v1.include_router(email_router) diff --git a/backend/plugin/email/api/v1/__init__.py b/backend/plugin/email/api/v1/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/email/api/v1/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/email/api/v1/email.py b/backend/plugin/email/api/v1/email.py new file mode 100644 index 00000000..f9c26648 --- /dev/null +++ b/backend/plugin/email/api/v1/email.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import random + +from typing import Annotated + +from fastapi import APIRouter, Body, Request + +from backend.common.response.response_schema import response_base +from backend.common.security.jwt import DependsJwtAuth +from backend.core.conf import settings +from backend.database.db import CurrentSession +from backend.database.redis import redis_client +from backend.plugin.email.utils.send import send_email + +router = APIRouter() + + +@router.post('/captcha', summary='发送电子邮件验证码', dependencies=[DependsJwtAuth]) +async def send_email_captcha( + request: Request, + db: CurrentSession, + recipients: Annotated[str | list[str], Body(embed=True, description='邮件接收者')], +): + code = ''.join([str(random.randint(1, 9)) for _ in range(6)]) + ip = request.state.ip + await redis_client.set( + f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ip}', + code, + ex=settings.EMAIL_CAPTCHA_EXPIRE_SECONDS, + ) + content = {'code': code, 'expired': int(settings.EMAIL_CAPTCHA_EXPIRE_SECONDS / 60)} + await send_email(db, recipients, 'FBA 验证码', content, 'captcha.html') + return response_base.success() diff --git a/backend/plugin/email/plugin.toml b/backend/plugin/email/plugin.toml new file mode 100644 index 00000000..20162fd7 --- /dev/null +++ b/backend/plugin/email/plugin.toml @@ -0,0 +1,8 @@ +[plugin] +summary = '电子邮件' +version = '0.0.1' +description = '发送电子邮件,例如验证码、通知等' +author = 'wu-clan' + +[app] +router = ['v1'] diff --git a/backend/plugin/email/requirements.txt b/backend/plugin/email/requirements.txt new file mode 100644 index 00000000..ea885df9 --- /dev/null +++ b/backend/plugin/email/requirements.txt @@ -0,0 +1 @@ +aiosmtplib diff --git a/backend/plugin/email/templates/captcha.html b/backend/plugin/email/templates/captcha.html new file mode 100644 index 00000000..35acb457 --- /dev/null +++ b/backend/plugin/email/templates/captcha.html @@ -0,0 +1,83 @@ + + + + + + 验证码 + + + + + + + +
+
+
验证码
+

您好,您正在进行绑定操作,请使用以下验证码:

+ {{code}} +

验证码有效期为 {{expired}}分钟,请勿泄露给他人。

+

+ 如有疑问,可通过【 + + 官网-互动 + + 】获取帮助,感谢您对 fba 的支持! +

+ +
+
+ + diff --git a/backend/plugin/email/utils/__init__.py b/backend/plugin/email/utils/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/email/utils/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/email/utils/send.py b/backend/plugin/email/utils/send.py new file mode 100644 index 00000000..03353071 --- /dev/null +++ b/backend/plugin/email/utils/send.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import os.path + +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiofiles + +from aiosmtplib import SMTP +from jinja2 import Template +from sqlalchemy import inspect +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.common.exception import errors +from backend.common.log import log +from backend.core.conf import settings +from backend.core.path_conf import PLUGIN_DIR +from backend.database.db import async_engine +from backend.plugin.config.crud.crud_config import config_dao +from backend.utils.serializers import select_list_serialize +from backend.utils.timezone import timezone + + +async def render_message(subject: str, from_header: str, content: str | dict, template: str | None) -> bytes: + """ + 渲染邮件内容 + + :param subject: 邮件内容主题 + :param from_header: 邮件来源 + :param content: 邮件内容 + :param template: 邮件内容模板 + :return: + """ + message = MIMEMultipart() + message['Subject'] = subject + message['From'] = from_header + message['date'] = timezone.now().strftime('%a, %d %b %Y %H:%M:%S %z') + + if template: + async with aiofiles.open(os.path.join(PLUGIN_DIR, 'email', 'templates', template), 'r', encoding='utf-8') as f: + html = Template(await f.read(), enable_async=True) + mail_body = MIMEText(await html.render_async(**content), 'html', 'utf-8') + else: + mail_body = MIMEText(content, 'plain', 'utf-8') + + message.attach(mail_body) + + return message.as_bytes() + + +async def send_email( + db: AsyncSession, + recipients: str | list[str], + subject: str, + content: str | dict, + template: str | None = None, +): + """ + 发送电子邮件 + + :param db: 数据库会话 + :param recipients: 邮件接收者 + :param subject: 邮件内容主题 + :param content: 邮件内容 + :param template: 邮件内容模板 + :return: + """ + # 动态配置 + dynamic_email_config = None + + # 检查 config 插件配置 + def get_config_table(conn): + inspector = inspect(conn) + return inspector.has_table('sys_config', schema=None) + + async with async_engine.begin() as coon: + exists = await coon.run_sync(get_config_table) + + if exists: + dynamic_email_config = await config_dao.get_by_type(db, 'email') + + try: + # 动态配置发送 + if dynamic_email_config: + configs = {d['key']: d for d in select_list_serialize(dynamic_email_config)} + if configs.get('EMAIL_STATUS'): + if len(dynamic_email_config) < 6: + raise errors.NotFoundError(msg='缺少邮件动态配置,请检查系统参数配置-邮件配置') + smtp_client = SMTP( + hostname=configs.get('EMAIL_HOST'), + port=configs.get('EMAIL_PORT'), + use_tls=configs.get('EMAIL_SSL') == '1', + ) + message = await render_message(subject, configs.get('EMAIL_USERNAME'), content, template) # type: ignore + async with smtp_client: + await smtp_client.login(configs.get('EMAIL_USERNAME'), configs.get('EMAIL_PASSWORD')) # type: ignore + await smtp_client.sendmail(configs.get('EMAIL_USERNAME'), recipients, message) # type: ignore + + # 本地配置发送 + message = await render_message(subject, settings.EMAIL_USERNAME, content, template) + smtp_client = SMTP( + hostname=settings.EMAIL_HOST, + port=settings.EMAIL_PORT, + use_tls=settings.EMAIL_SSL, + ) + async with smtp_client: + await smtp_client.login(settings.EMAIL_USERNAME, settings.EMAIL_PASSWORD) + await smtp_client.sendmail(settings.EMAIL_USERNAME, recipients, message) + except Exception as e: + log.error(f'电子邮件发送失败:{e}')