Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@ 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
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=''
12 changes: 12 additions & 0 deletions backend/app/admin/api/v1/sys/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='删除用户',
Expand Down
11 changes: 11 additions & 0 deletions backend/app/admin/crud/crud_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
删除用户
Expand Down
33 changes: 30 additions & 3 deletions backend/app/admin/service/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: 用户昵称
Expand All @@ -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: 头像地址
Expand All @@ -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: 密码重置参数
Expand Down
14 changes: 14 additions & 0 deletions backend/core/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions backend/plugin/config/crud/crud_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
"""
通过键名获取参数配置
Expand Down
5 changes: 5 additions & 0 deletions backend/plugin/email/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 参数配置

默认使用本地电子邮件配置

支持通过 `config 插件` 动态配置电子邮件参数,当动态配置 `EMAIL_STATUS` 为 `1` 时,将自动应用动态配置
2 changes: 2 additions & 0 deletions backend/plugin/email/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
2 changes: 2 additions & 0 deletions backend/plugin/email/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
10 changes: 10 additions & 0 deletions backend/plugin/email/api/router.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions backend/plugin/email/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
34 changes: 34 additions & 0 deletions backend/plugin/email/api/v1/email.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 8 additions & 0 deletions backend/plugin/email/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[plugin]
summary = '电子邮件'
version = '0.0.1'
description = '发送电子邮件,例如验证码、通知等'
author = 'wu-clan'

[app]
router = ['v1']
1 change: 1 addition & 0 deletions backend/plugin/email/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
aiosmtplib
83 changes: 83 additions & 0 deletions backend/plugin/email/templates/captcha.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证码</title>
<style>
body {
margin: 0;
padding: 0;
background-color: #f5f7fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

.container {
max-width: 600px;
margin: 0 auto;
padding: 24px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}

.title {
font-size: 20px;
font-weight: bold;
color: #333;
text-align: center;
}

.code {
display: inline-block;
font-size: 28px;
font-weight: bold;
letter-spacing: 4px;
color: #2f54eb;
background-color: #f0f3ff;
padding: 12px 24px;
border-radius: 6px;
margin: 16px 0;
}

.tips {
font-size: 14px;
color: #666;
text-align: center;
line-height: 1.6;
}

.footer {
font-size: 12px;
color: #999;
text-align: center;
margin-top: 20px;
}
</style>
</head>
<body>
<table width="100%" cellpadding="0" cellspacing="0" bgcolor="#f5f7fa">
<tr>
<td align="center" style="padding: 40px 0;">
<div class="container">
<div class="title">验证码</div>
<p style="margin-top:20px;">您好,您正在进行绑定操作,请使用以下验证码:</p>
<span class="code">{{code}}</span>
<p class="tips">验证码有效期为 <strong>{{expired}}分钟</strong>,请勿泄露给他人。</p>
<p class="tips">
如有疑问,可通过【
<a href="https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/config.html#captcha-login-expire-seconds">
官网-互动
</a>
】获取帮助,感谢您对 fba 的支持!
</p>
<div class="footer">
如果这不是您的操作,请忽略此邮件。<br>
本邮件由系统自动发送,请勿直接回复。
</div>
</div>
</td>
</tr>
</table>
</body>
</html>
2 changes: 2 additions & 0 deletions backend/plugin/email/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
Loading