diff --git a/backend/app/task/README.md b/backend/app/task/README.md index c05c319a..c6cd03f7 100644 --- a/backend/app/task/README.md +++ b/backend/app/task/README.md @@ -1,6 +1,7 @@ ## 任务介绍 -当前任务使用 Celery 实现,实施方案请查看 [#225](https://github.com/orgs/fastapi-practices/discussions/225) +当前任务使用 Celery +实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi_best_architecture/discussions/225) ## 添加任务 diff --git a/backend/common/log.py b/backend/common/log.py index 56afe2f8..0dcc469d 100644 --- a/backend/common/log.py +++ b/backend/common/log.py @@ -100,7 +100,7 @@ def set_customize_logfile(): 'retention': '15 days', 'compression': 'tar.gz', 'enqueue': True, - 'format': settings.LOG_LOGURU_FORMAT, + 'format': settings.LOG_FILE_FORMAT, } # stdout file diff --git a/backend/common/security/jwt.py b/backend/common/security/jwt.py index 2fcc4bb1..abdf7cc9 100644 --- a/backend/common/security/jwt.py +++ b/backend/common/security/jwt.py @@ -7,13 +7,17 @@ from fastapi.security import HTTPBearer from fastapi.security.utils import get_authorization_scheme_param from jose import ExpiredSignatureError, JWTError, jwt from passlib.context import CryptContext +from pydantic_core import from_json from sqlalchemy.ext.asyncio import AsyncSession from backend.app.admin.model import User +from backend.app.admin.schema.user import CurrentUserIns from backend.common.dataclasses import AccessToken, NewToken, RefreshToken from backend.common.exception.errors import AuthorizationError, TokenError from backend.core.conf import settings +from backend.database.db_mysql import async_db_session from backend.database.db_redis import redis_client +from backend.utils.serializers import select_as_dict from backend.utils.timezone import timezone pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') @@ -151,21 +155,6 @@ def jwt_decode(token: str) -> int: return user_id -async def jwt_authentication(token: str) -> int: - """ - JWT authentication - - :param token: - :return: - """ - user_id = jwt_decode(token) - key = f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token}' - token_verify = await redis_client.get(key) - if not token_verify: - raise TokenError(msg='Token 已过期') - return user_id - - async def get_current_user(db: AsyncSession, pk: int) -> User: """ Get the current user through token @@ -204,3 +193,32 @@ def superuser_verify(request: Request) -> bool: if not superuser or not request.user.is_staff: raise AuthorizationError return superuser + + +async def jwt_authentication(token: str) -> CurrentUserIns: + """ + JWT authentication + + :param token: + :return: + """ + user_id = jwt_decode(token) + key = f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token}' + token_verify = await redis_client.get(key) + if not token_verify: + raise TokenError(msg='Token 已过期') + cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') + if not cache_user: + async with async_db_session() as db: + current_user = await get_current_user(db, user_id) + user = CurrentUserIns(**select_as_dict(current_user)) + await redis_client.setex( + f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}', + settings.JWT_USER_REDIS_EXPIRE_SECONDS, + user.model_dump_json(), + ) + else: + # TODO: 在恰当的时机,应替换为使用 model_validate_json + # https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing + user = CurrentUserIns.model_validate(from_json(cache_user, allow_partial=True)) + return user diff --git a/backend/common/socketio/__init__.py b/backend/common/socketio/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/common/socketio/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/common/socketio/action.py b/backend/common/socketio/action.py new file mode 100644 index 00000000..9007f74e --- /dev/null +++ b/backend/common/socketio/action.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from backend.common.socketio.server import sio + + +async def task_notification(msg: str): + """ + 任务通知 + + :param msg: + :return: + """ + await sio.emit('task_notification', {'msg': msg}) diff --git a/backend/common/socketio/server.py b/backend/common/socketio/server.py new file mode 100644 index 00000000..50b6644b --- /dev/null +++ b/backend/common/socketio/server.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import socketio + +from backend.common.log import log +from backend.common.security.jwt import jwt_authentication +from backend.core.conf import settings + +sio = socketio.AsyncServer( + async_mode='asgi', + cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS, + cors_credentials=True, + namespaces=['/ws'], +) + + +@sio.event +async def connect(sid, environ, auth): + if not auth: + print('ws 连接失败:无授权') + return False + + token = auth.get('token') + if not token: + print('ws 连接失败:无 token 授权') + return False + + if token == 'internal': + return True + + try: + await jwt_authentication(token) + except Exception as e: + log.info(f'ws 连接失败:{e}') + return False + + return True + + +@sio.event +async def disconnect(sid): + pass diff --git a/backend/core/conf.py b/backend/core/conf.py index e3735d25..10ef7db5 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -43,13 +43,14 @@ class Settings(BaseSettings): FASTAPI_DOCS_URL: str | None = f'{FASTAPI_API_V1_PATH}/docs' FASTAPI_REDOCS_URL: str | None = f'{FASTAPI_API_V1_PATH}/redocs' FASTAPI_OPENAPI_URL: str | None = f'{FASTAPI_API_V1_PATH}/openapi' - FASTAPI_STATIC_FILES: bool = False + FASTAPI_STATIC_FILES: bool = True @model_validator(mode='before') @classmethod def validate_openapi_url(cls, values): if values['ENVIRONMENT'] == 'pro': values['OPENAPI_URL'] = None + values['FASTAPI_STATIC_FILES'] = False return values # MySQL @@ -101,7 +102,7 @@ class Settings(BaseSettings): '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | ' ' {correlation_id} | {message}' ) - LOG_LOGURU_FORMAT: str = ( + LOG_FILE_FORMAT: str = ( '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | ' ' {correlation_id} | {message}' ) @@ -121,6 +122,7 @@ class Settings(BaseSettings): # CORS CORS_ALLOWED_ORIGINS: list[str] = [ + 'http://127.0.0.1:8000', 'http://localhost:5173', # 前端地址,末尾不要带 '/' ] CORS_EXPOSE_HEADERS: list[str] = [ diff --git a/backend/core/registrar.py b/backend/core/registrar.py index a6c5c5fb..cc7eb777 100644 --- a/backend/core/registrar.py +++ b/backend/core/registrar.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- from contextlib import asynccontextmanager +import socketio + from asgi_correlation_id import CorrelationIdMiddleware from fastapi import Depends, FastAPI from fastapi_limiter import FastAPILimiter @@ -61,6 +63,9 @@ def register_app(): lifespan=register_init, ) + # socketio + register_socket_app(app) + # 日志 register_logger() @@ -94,18 +99,14 @@ def register_logger() -> None: def register_static_file(app: FastAPI): """ - 静态文件交互开发模式, 生产使用 nginx 静态资源服务 + 静态文件交互开发模式, 生产将自动关闭,生产必须使用 nginx 静态资源服务 :param app: :return: """ if settings.FASTAPI_STATIC_FILES: - import os - from fastapi.staticfiles import StaticFiles - if not os.path.exists(STATIC_DIR): - os.mkdir(STATIC_DIR) app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') @@ -170,3 +171,21 @@ def register_page(app: FastAPI): :return: """ add_pagination(app) + + +def register_socket_app(app: FastAPI): + """ + socket 应用 + + :param app: + :return: + """ + from backend.common.socketio.server import sio + + socket_app = socketio.ASGIApp( + socketio_server=sio, + other_asgi_app=app, + # 切勿删除此配置:https://github.com/pyropy/fastapi-socketio/issues/51 + socketio_path='/ws/socket.io', + ) + app.mount('/ws', socket_app) diff --git a/backend/middleware/jwt_auth_middleware.py b/backend/middleware/jwt_auth_middleware.py index aa5fff29..cd208020 100644 --- a/backend/middleware/jwt_auth_middleware.py +++ b/backend/middleware/jwt_auth_middleware.py @@ -4,18 +4,15 @@ from typing import Any from fastapi import Request, Response from fastapi.security.utils import get_authorization_scheme_param -from pydantic_core import from_json from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError from starlette.requests import HTTPConnection from backend.app.admin.schema.user import CurrentUserIns from backend.common.exception.errors import TokenError from backend.common.log import log -from backend.common.security import jwt +from backend.common.security.jwt import jwt_authentication from backend.core.conf import settings -from backend.database.db_mysql import async_db_session -from backend.database.db_redis import redis_client -from backend.utils.serializers import MsgSpecJSONResponse, select_as_dict +from backend.utils.serializers import MsgSpecJSONResponse class _AuthenticationError(AuthenticationError): @@ -48,21 +45,7 @@ class JwtAuthMiddleware(AuthenticationBackend): return try: - sub = await jwt.jwt_authentication(token) - cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{sub}') - if not cache_user: - async with async_db_session() as db: - current_user = await jwt.get_current_user(db, sub) - user = CurrentUserIns(**select_as_dict(current_user)) - await redis_client.setex( - f'{settings.JWT_USER_REDIS_PREFIX}:{sub}', - settings.JWT_USER_REDIS_EXPIRE_SECONDS, - user.model_dump_json(), - ) - else: - # TODO: 在恰当的时机,应替换为使用 model_validate_json - # https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing - user = CurrentUserIns.model_validate(from_json(cache_user, allow_partial=True)) + user = await jwt_authentication(token) except TokenError as exc: raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers) except Exception as e: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 56535556..08e73d67 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ # https://github.com/celery/celery/issues/7874 "celery-aio-pool==0.1.0rc6", "asgi-correlation-id>=4.3.3", + "python-socketio[asyncio]>=5.11.4", ] [tool.uv] diff --git a/backend/requirements.txt b/backend/requirements.txt index 310d6ae3..43363652 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,6 +11,7 @@ async-timeout==4.0.3 ; python_full_version < '3.11.3' asyncmy==0.2.9 attrs==24.2.0 bcrypt==4.0.1 +bidict==0.23.1 billiard==4.2.1 casbin==1.34.0 casbin-async-sqlalchemy-adapter==1.4.0 @@ -82,8 +83,10 @@ pytest==7.2.2 pytest-pretty==1.2.0 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 +python-engineio==4.9.1 python-jose==3.3.0 python-multipart==0.0.12 +python-socketio==5.11.4 pytz==2024.2 pyyaml==6.0.2 redis==5.1.0 @@ -92,6 +95,7 @@ rsa==4.9 ruff==0.6.9 setuptools==75.1.0 shellingham==1.5.4 +simple-websocket==1.1.0 simpleeval==1.0.0 six==1.16.0 sniffio==1.3.1 @@ -116,4 +120,5 @@ watchfiles==0.24.0 wcwidth==0.2.13 websockets==13.1 win32-setctime==1.1.0 ; sys_platform == 'win32' +wsproto==1.2.0 xdbsearchip==1.0.2 diff --git a/backend/utils/trace_id.py b/backend/utils/trace_id.py index 80d2293c..a2df0451 100644 --- a/backend/utils/trace_id.py +++ b/backend/utils/trace_id.py @@ -6,4 +6,4 @@ from backend.core.conf import settings def get_request_trace_id(request: Request) -> str: - return request.headers.get(settings.TRACE_ID_REQUEST_HEADER_KEY) or '-' + return request.headers.get(settings.TRACE_ID_REQUEST_HEADER_KEY) or settings.LOG_CID_DEFAULT_VALUE diff --git a/backend/uv.lock b/backend/uv.lock index e141b1d7..a9537b1d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -147,6 +147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930 }, ] +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764 }, +] + [[package]] name = "billiard" version = "4.2.1" @@ -498,6 +507,7 @@ dependencies = [ { name = "pytest" }, { name = "pytest-pretty" }, { name = "python-jose" }, + { name = "python-socketio" }, { name = "redis", extra = ["hiredis"] }, { name = "sqlalchemy" }, { name = "sqlalchemy-crud-plus" }, @@ -547,6 +557,7 @@ requires-dist = [ { name = "pytest", specifier = "==7.2.2" }, { name = "pytest-pretty", specifier = "==1.2.0" }, { name = "python-jose", specifier = "==3.3.0" }, + { name = "python-socketio", extras = ["asyncio"], specifier = ">=5.11.4" }, { name = "redis", extras = ["hiredis"], specifier = "==5.1.0" }, { name = "sqlalchemy", specifier = "==2.0.30" }, { name = "sqlalchemy-crud-plus", specifier = "==1.3.0" }, @@ -1370,6 +1381,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] +[[package]] +name = "python-engineio" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/01/94faf505820f1fb94133a456dad87a76df589f6999de563229a342e412fa/python_engineio-4.9.1.tar.gz", hash = "sha256:7631cf5563086076611e494c643b3fa93dd3a854634b5488be0bba0ef9b99709", size = 89549 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/38/4642c75241686c9cf05d23c50b9ffbd760507292f12fdfb04adc2ab5d34a/python_engineio-4.9.1-py3-none-any.whl", hash = "sha256:f995e702b21f6b9ebde4e2000cd2ad0112ba0e5116ec8d22fe3515e76ba9dddd", size = 57686 }, +] + [[package]] name = "python-jose" version = "3.3.0" @@ -1393,6 +1416,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/0b/c316262244abea7481f95f1e91d7575f3dfcf6455d56d1ffe9839c582eb1/python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf", size = 23246 }, ] +[[package]] +name = "python-socketio" +version = "5.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/31/4ba0d9d86c645ba335d645f49167ca58b0874ca0e421682f97964e8adb42/python_socketio-5.11.4.tar.gz", hash = "sha256:8b0b8ff2964b2957c865835e936310190639c00310a47d77321a594d1665355e", size = 118982 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/9a/52b94c8c9516e07844d3da3d0da3e68649f172aeeace8d7a1becca9e6111/python_socketio-5.11.4-py3-none-any.whl", hash = "sha256:42efaa3e3e0b166fc72a527488a13caaac2cefc76174252486503bd496284945", size = 76246 }, +] + [[package]] name = "pytz" version = "2024.2" @@ -1523,6 +1559,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842 }, +] + [[package]] name = "simpleeval" version = "1.0.0" @@ -1954,6 +2002,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/e6/a7d828fef907843b2a5773ebff47fb79ac0c1c88d60c0ca9530ee941e248/win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad", size = 3604 }, ] +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 }, +] + [[package]] name = "xdbsearchip" version = "1.0.2"