Skip to content

Commit 1e3c751

Browse files
committed
Merge branch 'master' into i18
2 parents 34eb41c + 4500dd0 commit 1e3c751

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+775
-246
lines changed

backend/.env.example

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,19 @@ REDIS_DATABASE=0
1515
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
1616
# Opera Log
1717
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
18-
# App Admin
19-
# OAuth2
20-
OAUTH2_GITHUB_CLIENT_ID='test'
21-
OAUTH2_GITHUB_CLIENT_SECRET='test'
22-
OAUTH2_LINUX_DO_CLIENT_ID='test'
23-
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
24-
# App Task
18+
# [ App ] task
2519
# Celery
2620
CELERY_BROKER_REDIS_DATABASE=1
2721
# Rabbitmq
2822
CELERY_RABBITMQ_HOST='127.0.0.1'
2923
CELERY_RABBITMQ_PORT=5672
3024
CELERY_RABBITMQ_USERNAME='guest'
3125
CELERY_RABBITMQ_PASSWORD='guest'
26+
# [ Plugin ] oauth2
27+
OAUTH2_GITHUB_CLIENT_ID='test'
28+
OAUTH2_GITHUB_CLIENT_SECRET='test'
29+
OAUTH2_LINUX_DO_CLIENT_ID='test'
30+
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
31+
# [ Plugin ] email
32+
EMAIL_USERNAME=''
33+
EMAIL_PASSWORD=''

backend/app/admin/api/v1/sys/user.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ async def update_user_avatar(
133133
return response_base.fail()
134134

135135

136+
@router.put('/me/email', summary='更新当前用户邮箱', dependencies=[DependsJwtAuth])
137+
async def update_user_email(
138+
request: Request,
139+
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
140+
email: Annotated[str, Body(embed=True, description='用户邮箱')],
141+
) -> ResponseModel:
142+
count = await user_service.update_email(request=request, captcha=captcha, email=email)
143+
if count > 0:
144+
return response_base.success()
145+
return response_base.fail()
146+
147+
136148
@router.delete(
137149
path='/{pk}',
138150
summary='删除用户',

backend/app/admin/crud/crud_opera_log.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,21 @@ async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
3737
创建操作日志
3838
3939
:param db: 数据库会话
40-
:param obj: 创建操作日志参数
40+
:param obj: 操作日志创建参数
4141
:return:
4242
"""
4343
await self.create_model(db, obj)
4444

45+
async def bulk_create(self, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
46+
"""
47+
批量创建操作日志
48+
49+
:param db: 数据库会话
50+
:param objs: 操作日志创建参数列表
51+
:return:
52+
"""
53+
await self.create_models(db, objs)
54+
4555
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
4656
"""
4757
批量删除操作日志

backend/app/admin/crud/crud_user.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> in
139139
"""
140140
return await self.update_model(db, user_id, {'avatar': avatar})
141141

142+
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
143+
"""
144+
更新用户邮箱
145+
146+
:param db: 数据库会话
147+
:param user_id: 用户 ID
148+
:param email: 邮箱
149+
:return:
150+
"""
151+
return await self.update_model(db, user_id, {'email': email})
152+
142153
async def delete(self, db: AsyncSession, user_id: int) -> int:
143154
"""
144155
删除用户

backend/app/admin/service/opera_log_service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ async def create(*, obj: CreateOperaLogParam) -> None:
3333
async with async_db_session.begin() as db:
3434
await opera_log_dao.create(db, obj)
3535

36+
@staticmethod
37+
async def bulk_create(*, objs: list[CreateOperaLogParam]) -> None:
38+
"""
39+
批量创建操作日志
40+
41+
:param objs: 操作日志创建参数列表
42+
:return:
43+
"""
44+
async with async_db_session.begin() as db:
45+
await opera_log_dao.bulk_create(db, objs)
46+
3647
@staticmethod
3748
async def delete(*, obj: DeleteOperaLogParam) -> int:
3849
"""

backend/app/admin/service/user_service.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from backend.common.enums import UserPermissionType
2020
from backend.common.exception import errors
21+
from backend.common.response.response_code import CustomErrorCode
2122
from backend.common.security.jwt import get_token, jwt_decode, password_verify, superuser_verify
2223
from backend.core.conf import settings
2324
from backend.database.db import async_db_session
@@ -206,7 +207,7 @@ async def reset_password(*, request: Request, pk: int, password: str) -> int:
206207
@staticmethod
207208
async def update_nickname(*, request: Request, nickname: str) -> int:
208209
"""
209-
更新用户昵称
210+
更新当前用户昵称
210211
211212
:param request: FastAPI 请求对象
212213
:param nickname: 用户昵称
@@ -225,7 +226,7 @@ async def update_nickname(*, request: Request, nickname: str) -> int:
225226
@staticmethod
226227
async def update_avatar(*, request: Request, avatar: str) -> int:
227228
"""
228-
更新用户头像
229+
更新当前用户头像
229230
230231
:param request: FastAPI 请求对象
231232
:param avatar: 头像地址
@@ -241,10 +242,36 @@ async def update_avatar(*, request: Request, avatar: str) -> int:
241242
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
242243
return count
243244

245+
@staticmethod
246+
async def update_email(*, request: Request, captcha: str, email: str) -> int:
247+
"""
248+
更新当前用户邮箱
249+
250+
:param request: FastAPI 请求对象
251+
:param captcha: 邮箱验证码
252+
:param email: 邮箱
253+
:return:
254+
"""
255+
async with async_db_session.begin() as db:
256+
token = get_token(request)
257+
token_payload = jwt_decode(token)
258+
user = await user_dao.get(db, token_payload.id)
259+
if not user:
260+
raise errors.NotFoundError(msg='用户不存在')
261+
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}')
262+
if not captcha_code:
263+
raise errors.RequestError(msg='验证码已失效,请重新获取')
264+
if captcha != captcha_code:
265+
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
266+
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}')
267+
count = await user_dao.update_email(db, token_payload.id, email)
268+
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
269+
return count
270+
244271
@staticmethod
245272
async def update_password(*, request: Request, obj: ResetPasswordParam) -> int:
246273
"""
247-
更新用户密码
274+
更新当前用户密码
248275
249276
:param request: FastAPI 请求对象
250277
:param obj: 密码重置参数

backend/common/exception/exception_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation
5252
if not ctx:
5353
error['msg'] = custom_message
5454
else:
55-
error['msg'] = custom_message.format(**ctx)
5655
ctx_error = ctx.get('error')
5756
if ctx_error:
57+
error['msg'] = custom_message.format(**ctx)
5858
error['ctx']['error'] = (
5959
ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
6060
)

backend/common/log.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44
import logging
55
import os
6+
import re
67
import sys
78

89
from asgi_correlation_id import correlation_id
@@ -36,6 +37,18 @@ def emit(self, record: logging.LogRecord):
3637
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
3738

3839

40+
def default_formatter(record):
41+
"""默认日志格式化程序"""
42+
43+
# 重写 sqlalchemy echo 输出
44+
# https://github.com/sqlalchemy/sqlalchemy/discussions/12791
45+
record_name = record['name'] or ''
46+
if record_name.startswith('sqlalchemy'):
47+
record['message'] = re.sub(r'\s+', ' ', record['message']).strip()
48+
49+
return settings.LOG_FORMAT if settings.LOG_FORMAT.endswith('\n') else f'{settings.LOG_FORMAT}\n'
50+
51+
3952
def setup_logging() -> None:
4053
"""
4154
设置日志处理器
@@ -48,9 +61,11 @@ def setup_logging() -> None:
4861
logging.root.handlers = [InterceptHandler()]
4962
logging.root.setLevel(settings.LOG_STD_LEVEL)
5063

51-
# 配置日志传播规则
5264
for name in logging.root.manager.loggerDict.keys():
65+
# 清空所有默认日志处理器
5366
logging.getLogger(name).handlers = []
67+
68+
# 配置日志传播规则
5469
if 'uvicorn.access' in name or 'watchfiles.main' in name:
5570
logging.getLogger(name).propagate = False
5671
else:
@@ -59,22 +74,24 @@ def setup_logging() -> None:
5974
# Debug log handlers
6075
# logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}')
6176

77+
# 移除 loguru 默认处理器
78+
logger.remove()
79+
6280
# correlation_id 过滤器
6381
# https://github.com/snok/asgi-correlation-id/issues/7
6482
def correlation_id_filter(record):
6583
cid = correlation_id.get(settings.TRACE_ID_LOG_DEFAULT_VALUE)
66-
record['correlation_id'] = cid[: settings.TRACE_ID_LOG_UUID_LENGTH]
84+
record['correlation_id'] = cid[: settings.TRACE_ID_LOG_LENGTH]
6785
return record
6886

6987
# 配置 loguru 处理器
70-
logger.remove() # 移除默认处理器
7188
logger.configure(
7289
handlers=[
7390
{
7491
'sink': sys.stdout,
7592
'level': settings.LOG_STD_LEVEL,
93+
'format': default_formatter,
7694
'filter': lambda record: correlation_id_filter(record),
77-
'format': settings.LOG_STD_FORMAT,
7895
}
7996
]
8097
)
@@ -100,7 +117,7 @@ def compression(filepath):
100117
# 日志文件通用配置
101118
# https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add
102119
log_config = {
103-
'format': settings.LOG_FILE_FORMAT,
120+
'format': default_formatter,
104121
'enqueue': True,
105122
'rotation': '00:00',
106123
'retention': '7 days',
@@ -110,7 +127,7 @@ def compression(filepath):
110127
# 标准输出文件
111128
logger.add(
112129
str(log_access_file),
113-
level=settings.LOG_ACCESS_FILE_LEVEL,
130+
level=settings.LOG_FILE_ACCESS_LEVEL,
114131
filter=lambda record: record['level'].no <= 25,
115132
backtrace=False,
116133
diagnose=False,
@@ -120,7 +137,7 @@ def compression(filepath):
120137
# 标准错误文件
121138
logger.add(
122139
str(log_error_file),
123-
level=settings.LOG_ERROR_FILE_LEVEL,
140+
level=settings.LOG_FILE_ERROR_LEVEL,
124141
filter=lambda record: record['level'].no >= 30,
125142
backtrace=True,
126143
diagnose=True,

backend/common/queue.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import asyncio
4+
5+
from asyncio import Queue
6+
7+
8+
async def batch_dequeue(queue: Queue, max_items: int, timeout: float) -> list:
9+
"""
10+
从异步队列中获取多个项目
11+
12+
:param queue: 用于获取项目的 `asyncio.Queue` 队列
13+
:param max_items: 从队列中获取的最大项目数量
14+
:param timeout: 总的等待超时时间(秒)
15+
:return:
16+
"""
17+
items = []
18+
19+
async def collector():
20+
while len(items) < max_items:
21+
item = await queue.get()
22+
items.append(item)
23+
24+
try:
25+
await asyncio.wait_for(collector(), timeout=timeout)
26+
except asyncio.TimeoutError:
27+
pass
28+
29+
return items

backend/common/schema.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
77

8-
from backend.core.conf import settings
8+
from backend.utils.timezone import timezone
99

1010
# 自定义验证错误信息,参考:
1111
# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
@@ -124,5 +124,9 @@ class SchemaBase(BaseModel):
124124

125125
model_config = ConfigDict(
126126
use_enum_values=True,
127-
json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)},
127+
json_encoders={
128+
datetime: lambda x: timezone.to_str(timezone.from_datetime(x))
129+
if x.tzinfo is not None
130+
else timezone.to_str(x)
131+
},
128132
)

0 commit comments

Comments
 (0)