├── .github └── FUNDING.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .ruff.toml ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── .env.example ├── __init__.py ├── alembic.ini ├── alembic │ ├── README │ ├── env.py │ └── script.py.mako ├── app │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── router.py │ │ │ └── v1 │ │ │ │ ├── __init__.py │ │ │ │ ├── auth │ │ │ │ ├── __init__.py │ │ │ │ ├── auth.py │ │ │ │ └── captcha.py │ │ │ │ └── user.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ └── crud_user.py │ │ ├── model │ │ │ ├── __init__.py │ │ │ └── user.py │ │ ├── schema │ │ │ ├── __init__.py │ │ │ ├── captcha.py │ │ │ ├── token.py │ │ │ └── user.py │ │ └── service │ │ │ ├── __init__.py │ │ │ ├── auth_service.py │ │ │ └── user_service.py │ └── router.py ├── common │ ├── __init__.py │ ├── dataclasses.py │ ├── enums.py │ ├── exception │ │ ├── __init__.py │ │ ├── errors.py │ │ └── exception_handler.py │ ├── log.py │ ├── model.py │ ├── pagination.py │ ├── response │ │ ├── __init__.py │ │ ├── response_code.py │ │ └── response_schema.py │ ├── schema.py │ └── security │ │ ├── __init__.py │ │ └── jwt.py ├── core │ ├── __init__.py │ ├── conf.py │ ├── path_conf.py │ └── registrar.py ├── database │ ├── __init__.py │ ├── db.py │ └── redis.py ├── main.py ├── middleware │ ├── __init__.py │ └── access_middle.py ├── tests │ └── __init__.py └── utils │ ├── __init__.py │ ├── demo_site.py │ ├── health_check.py │ ├── openapi.py │ ├── re_verify.py │ ├── serializers.py │ └── timezone.py ├── deploy ├── docker-compose │ ├── .env.server │ └── docker-compose.yml ├── fastapi_server.conf ├── gunicorn.conf.py ├── nginx.conf └── supervisor.conf ├── pre-commit.sh ├── pyproject.toml ├── requirements.txt └── uv.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://wu-clan.github.io/sponsor/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | backend/.env 4 | .venv/ 5 | venv/ 6 | backend/alembic/versions/ 7 | *.log 8 | .ruff_cache/ 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-toml 9 | 10 | - repo: https://github.com/charliermarsh/ruff-pre-commit 11 | rev: v0.11.4 12 | hooks: 13 | - id: ruff 14 | args: 15 | - '--config' 16 | - '.ruff.toml' 17 | - '--fix' 18 | - '--unsafe-fixes' 19 | - id: ruff-format 20 | 21 | - repo: https://github.com/astral-sh/uv-pre-commit 22 | rev: 0.6.14 23 | hooks: 24 | - id: uv-lock 25 | - id: uv-export 26 | args: 27 | - '-o' 28 | - 'requirements.txt' 29 | - '--no-hashes' 30 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | unsafe-fixes = true 3 | cache-dir = ".ruff_cache" 4 | target-version = "py310" 5 | 6 | [lint] 7 | select = [ 8 | "E", 9 | "F", 10 | "W505", 11 | "SIM101", 12 | "SIM114", 13 | "PGH004", 14 | "PLE1142", 15 | "RUF100", 16 | "I002", 17 | "F404", 18 | "TC", 19 | "UP007" 20 | ] 21 | preview = true 22 | 23 | [lint.isort] 24 | lines-between-types = 1 25 | order-by-type = true 26 | 27 | [lint.per-file-ignores] 28 | "**/api/v1/*.py" = ["TC"] 29 | "**/model/*.py" = ["TC003"] 30 | "**/model/__init__.py" = ["F401"] 31 | 32 | [format] 33 | preview = true 34 | quote-style = "single" 35 | docstring-code-format = true 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /fsm 4 | 5 | COPY . . 6 | 7 | RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ 8 | && sed -i 's|security.debian.org/debian-security|mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources 9 | 10 | RUN apt-get update \ 11 | && apt-get install -y --no-install-recommends gcc python3-dev supervisor \ 12 | && rm -rf /var/lib/apt/lists/* \ 13 | # 某些包可能存在同步不及时导致安装失败的情况,可更改为官方源:https://pypi.org/simple 14 | && pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple \ 15 | && pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple \ 16 | && pip install gunicorn wait-for-it -i https://mirrors.aliyun.com/pypi/simple 17 | 18 | ENV TZ="Asia/Shanghai" 19 | 20 | RUN mkdir -p /var/log/fastapi_server \ 21 | && mkdir -p /var/log/supervisor \ 22 | && mkdir -p /etc/supervisor/conf.d 23 | 24 | COPY deploy/supervisor.conf /etc/supervisor/supervisord.conf 25 | 26 | COPY deploy/fastapi_server.conf /etc/supervisor/conf.d/ 27 | 28 | EXPOSE 8001 29 | 30 | CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8001"] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 wu-clan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI SQLAlchemy MySQL 2 | 3 | ## 本地开发 4 | 5 | * Python 3.10+ 6 | * Mysql 8.0+ 7 | * Redis 推荐最新稳定版 8 | 9 | 1. 安装依赖项 10 | 11 | ```shell 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | 2. 创建一个数据库 `fsm`, 选择 `utf8mb4` 编码 16 | 3. 安装启动 Redis 17 | 4. 进入 backend 目录 18 | 19 | ```shell 20 | cd backend 21 | ``` 22 | 23 | 5. 创建一个 `.env` 文件 24 | 25 | ```shell 26 | touch .env 27 | cp .env.example .env 28 | ``` 29 | 30 | 6. 按需修改配置文件 `core/conf.py` 和 `.env` 31 | 7. 数据库迁移 [alembic](https://alembic.sqlalchemy.org/en/latest/tutorial.html) 32 | 33 | ```shell 34 | # 生成迁移文件 35 | alembic revision --autogenerate 36 | 37 | # 执行迁移 38 | alembic upgrade head 39 | ``` 40 | 41 | 8. 启动 fastapi 服务 42 | 43 | ```shell 44 | # 帮助 45 | fastapi --help 46 | 47 | # 开发模式 48 | fastapi dev main.py 49 | ``` 50 | 51 | 9. 浏览器访问: http://127.0.0.1:8000/docs 52 | 53 | --- 54 | 55 | ### Docker 56 | 57 | 1. 进入 `docker-compose.yml` 文件所在目录,创建环境变量文件 `.env` 58 | 59 | ```shell 60 | cd deploy/docker-compose/ 61 | 62 | cp .env.server ../../backend/.env 63 | ``` 64 | 65 | 2. 执行一键启动命令 66 | 67 | ```shell 68 | # 根据情况使用 sudo 69 | docker-compose up -d --build 70 | ``` 71 | 72 | 3. 等待命令自动完成 73 | 4. 浏览器访问:http://127.0.0.1:8000/docs 74 | 75 | ## 赞助 76 | 77 | 如果此项目能够帮助到你,你可以赞助作者一些咖啡豆表示鼓励:[:coffee: Sponsor :coffee:](https://wu-clan.github.io/sponsor/) 78 | 79 | ## 许可证 80 | 81 | 本项目根据 MIT 许可证的条款进行许可 82 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # Env: dev、pro 2 | ENVIRONMENT='dev' 3 | # Database 4 | DATABASE_HOST='127.0.0.1' 5 | DATABASE_PORT=3306 6 | DATABASE_USER='root' 7 | DATABASE_PASSWORD='123456' 8 | # Redis 9 | REDIS_HOST='127.0.0.1' 10 | REDIS_PORT=6379 11 | REDIS_PASSWORD='' 12 | REDIS_DATABASE=0 13 | # Token 14 | TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' 15 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(hour).2d-%%(minute).2d_%%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" 39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | # output_encoding = utf-8 52 | 53 | sqlalchemy.url = driver://user:pass@localhost/dbname 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /backend/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. 2 | -------------------------------------------------------------------------------- /backend/alembic/env.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # ruff: noqa: E402 4 | import asyncio 5 | import os 6 | import sys 7 | from logging.config import fileConfig 8 | 9 | from sqlalchemy import engine_from_config 10 | from sqlalchemy import pool 11 | from sqlalchemy.ext.asyncio import AsyncEngine 12 | 13 | from alembic import context 14 | 15 | sys.path.append('../') 16 | 17 | from backend.core import path_conf 18 | 19 | if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR): 20 | os.makedirs(path_conf.ALEMBIC_VERSION_DIR) 21 | 22 | # this is the Alembic Config object, which provides 23 | # access to the values within the .ini file in use. 24 | config = context.config 25 | 26 | # Interpret the config file for Python logging. 27 | # This line sets up loggers basically. 28 | fileConfig(config.config_file_name) 29 | 30 | # add your model's MetaData object here 31 | # for 'autogenerate' support 32 | # https://alembic.sqlalchemy.org/en/latest/autogenerate.html#autogenerating-multiple-metadata-collections 33 | from backend.app.admin.model import MappedBase 34 | 35 | target_metadata = [ 36 | MappedBase.metadata, 37 | ] 38 | 39 | # other values from the config, defined by the needs of env.py, 40 | from backend.database.db import SQLALCHEMY_DATABASE_URL 41 | 42 | config.set_main_option('sqlalchemy.url', SQLALCHEMY_DATABASE_URL) 43 | 44 | 45 | def run_migrations_offline(): 46 | """Run migrations in 'offline' mode. 47 | 48 | This configures the context with just a URL 49 | and not an Engine, though an Engine is acceptable 50 | here as well. By skipping the Engine creation 51 | we don't even need a DBAPI to be available. 52 | 53 | Calls to context.execute() here emit the given string to the 54 | script output. 55 | 56 | """ 57 | url = config.get_main_option('sqlalchemy.url') 58 | context.configure( 59 | url=url, 60 | target_metadata=target_metadata, # type: ignore 61 | literal_binds=True, 62 | dialect_opts={'paramstyle': 'named'}, 63 | ) 64 | 65 | with context.begin_transaction(): 66 | context.run_migrations() 67 | 68 | 69 | def do_run_migrations(connection): 70 | context.configure(connection=connection, target_metadata=target_metadata) # type: ignore 71 | 72 | with context.begin_transaction(): 73 | context.run_migrations() 74 | 75 | 76 | async def run_migrations_online(): 77 | """Run migrations in 'online' mode. 78 | 79 | In this scenario we need to create an Engine 80 | and associate a connection with the context. 81 | 82 | """ 83 | connectable = AsyncEngine( 84 | engine_from_config( 85 | config.get_section(config.config_ini_section), 86 | prefix='sqlalchemy.', 87 | poolclass=pool.NullPool, 88 | future=True, 89 | ) 90 | ) 91 | 92 | async with connectable.connect() as connection: 93 | await connection.run_sync(do_run_migrations) 94 | 95 | 96 | if context.is_offline_mode(): 97 | run_migrations_offline() 98 | else: 99 | asyncio.run(run_migrations_online()) 100 | -------------------------------------------------------------------------------- /backend/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/api/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/api/router.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import APIRouter 4 | 5 | from backend.app.admin.api.v1.auth import router as auth_router 6 | from backend.app.admin.api.v1.user import router as user_router 7 | from backend.core.conf import settings 8 | 9 | v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) 10 | 11 | v1.include_router(auth_router) 12 | v1.include_router(user_router, prefix='/users', tags=['用户']) 13 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/auth/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import APIRouter 4 | 5 | from backend.app.admin.api.v1.auth.auth import router as auth_router 6 | from backend.app.admin.api.v1.auth.captcha import router as captcha_router 7 | 8 | router = APIRouter(prefix='/auth') 9 | 10 | router.include_router(auth_router, tags=['授权']) 11 | router.include_router(captcha_router, prefix='/captcha', tags=['验证码']) 12 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/auth/auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from fastapi import APIRouter, Depends, Request 5 | from fastapi.security import OAuth2PasswordRequestForm 6 | 7 | from backend.app.admin.service.auth_service import auth_service 8 | from backend.common.security.jwt import DependsJwtAuth 9 | from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel 10 | from backend.app.admin.schema.token import GetSwaggerToken, GetLoginToken 11 | from backend.app.admin.schema.user import AuthLoginParam 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷进行 swagger 认证') 17 | async def swagger_login(form_data: OAuth2PasswordRequestForm = Depends()) -> GetSwaggerToken: 18 | token, user = await auth_service.swagger_login(form_data=form_data) 19 | return GetSwaggerToken(access_token=token, user=user) # type: ignore 20 | 21 | 22 | @router.post('/login', summary='验证码登录') 23 | async def user_login(request: Request, obj: AuthLoginParam) -> ResponseSchemaModel[GetLoginToken]: 24 | data = await auth_service.login(request=request, obj=obj) 25 | return response_base.success(data=data) 26 | 27 | 28 | @router.post('/logout', summary='用户登出', dependencies=[DependsJwtAuth]) 29 | async def user_logout() -> ResponseModel: 30 | return response_base.success() 31 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/auth/captcha.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fast_captcha import img_captcha 4 | from fastapi import APIRouter, Depends, Request 5 | from fastapi_limiter.depends import RateLimiter 6 | from starlette.concurrency import run_in_threadpool 7 | 8 | from backend.app.admin.schema.captcha import GetCaptchaDetail 9 | from backend.common.response.response_schema import ResponseSchemaModel, response_base 10 | from backend.core.conf import settings 11 | from backend.database.db import uuid4_str 12 | from backend.database.redis import redis_client 13 | 14 | router = APIRouter() 15 | 16 | 17 | @router.get( 18 | '', 19 | summary='获取登录验证码', 20 | dependencies=[Depends(RateLimiter(times=5, seconds=10))], 21 | ) 22 | async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]: 23 | """ 24 | 此接口可能存在性能损耗,尽管是异步接口,但是验证码生成是IO密集型任务,使用线程池尽量减少性能损耗 25 | """ 26 | img_type: str = 'base64' 27 | img, code = await run_in_threadpool(img_captcha, img_byte=img_type) 28 | uuid = uuid4_str() 29 | request.app.state.captcha_uuid = uuid 30 | await redis_client.set( 31 | f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}', 32 | code, 33 | ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS, 34 | ) 35 | data = GetCaptchaDetail(image_type=img_type, image=img) 36 | return response_base.success(data=data) 37 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from typing import Annotated 4 | 5 | from fastapi import APIRouter, Query 6 | 7 | from backend.common.security.jwt import CurrentUser, DependsJwtAuth 8 | from backend.common.pagination import paging_data, DependsPagination, PageData 9 | from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel 10 | from backend.database.db import CurrentSession 11 | from backend.app.admin.schema.user import ( 12 | RegisterUserParam, 13 | GetUserInfoDetail, 14 | ResetPassword, 15 | UpdateUserParam, 16 | AvatarParam, 17 | ) 18 | from backend.app.admin.service.user_service import UserService 19 | 20 | router = APIRouter() 21 | 22 | 23 | @router.post('/register', summary='用户注册') 24 | async def user_register(obj: RegisterUserParam) -> ResponseModel: 25 | await UserService.register(obj=obj) 26 | return response_base.success() 27 | 28 | 29 | @router.post('/password/reset', summary='密码重置', dependencies=[DependsJwtAuth]) 30 | async def password_reset(obj: ResetPassword) -> ResponseModel: 31 | count = await UserService.pwd_reset(obj=obj) 32 | if count > 0: 33 | return response_base.success() 34 | return response_base.fail() 35 | 36 | 37 | @router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth]) 38 | async def get_user(username: str) -> ResponseSchemaModel[GetUserInfoDetail]: 39 | data = await UserService.get_userinfo(username=username) 40 | return response_base.success(data=data) 41 | 42 | 43 | @router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth]) 44 | async def update_userinfo(username: str, obj: UpdateUserParam) -> ResponseModel: 45 | count = await UserService.update(username=username, obj=obj) 46 | if count > 0: 47 | return response_base.success() 48 | return response_base.fail() 49 | 50 | 51 | @router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth]) 52 | async def update_avatar(username: str, avatar: AvatarParam) -> ResponseModel: 53 | count = await UserService.update_avatar(username=username, avatar=avatar) 54 | if count > 0: 55 | return response_base.success() 56 | return response_base.fail() 57 | 58 | 59 | @router.get( 60 | '', 61 | summary='(模糊条件)分页获取所有用户', 62 | dependencies=[ 63 | DependsJwtAuth, 64 | DependsPagination, 65 | ], 66 | ) 67 | async def get_all_users( 68 | db: CurrentSession, 69 | username: Annotated[str | None, Query()] = None, 70 | phone: Annotated[str | None, Query()] = None, 71 | status: Annotated[int | None, Query()] = None, 72 | ) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]: 73 | user_select = await UserService.get_select(username=username, phone=phone, status=status) 74 | page_data = await paging_data(db, user_select) 75 | return response_base.success(data=page_data) 76 | 77 | 78 | @router.delete( 79 | path='/{username}', 80 | summary='用户注销', 81 | description='用户注销 != 用户登出,注销之后用户将从数据库删除', 82 | dependencies=[DependsJwtAuth], 83 | ) 84 | async def delete_user(current_user: CurrentUser, username: str) -> ResponseModel: 85 | count = await UserService.delete(current_user=current_user, username=username) 86 | if count > 0: 87 | return response_base.success() 88 | return response_base.fail() 89 | -------------------------------------------------------------------------------- /backend/app/admin/crud/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/crud/crud_user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime 4 | 5 | import bcrypt 6 | from sqlalchemy import select, update, desc, and_ 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | from sqlalchemy.sql import Select 9 | from sqlalchemy_crud_plus import CRUDPlus 10 | 11 | from backend.app.admin.model import User 12 | from backend.app.admin.schema.user import RegisterUserParam, UpdateUserParam, AvatarParam 13 | from backend.common.security.jwt import get_hash_password 14 | 15 | 16 | class CRUDUser(CRUDPlus[User]): 17 | async def get(self, db: AsyncSession, user_id: int) -> User | None: 18 | """ 19 | 获取用户 20 | 21 | :param db: 22 | :param user_id: 23 | :return: 24 | """ 25 | return await self.select_model(db, user_id) 26 | 27 | async def get_by_username(self, db: AsyncSession, username: str) -> User | None: 28 | """ 29 | 通过 username 获取用户 30 | 31 | :param db: 32 | :param username: 33 | :return: 34 | """ 35 | return await self.select_model_by_column(db, username=username) 36 | 37 | async def update_login_time(self, db: AsyncSession, username: str, login_time: datetime) -> int: 38 | user = await db.execute( 39 | update(self.model).where(self.model.username == username).values(last_login_time=login_time) 40 | ) 41 | return user.rowcount 42 | 43 | async def create(self, db: AsyncSession, obj: RegisterUserParam) -> None: 44 | """ 45 | 创建用户 46 | 47 | :param db: 48 | :param obj: 49 | :return: 50 | """ 51 | salt = bcrypt.gensalt() 52 | obj.password = get_hash_password(obj.password, salt) 53 | dict_obj = obj.model_dump() 54 | dict_obj.update({'salt': salt}) 55 | new_user = self.model(**dict_obj) 56 | db.add(new_user) 57 | 58 | async def update_userinfo(self, db: AsyncSession, input_user: int, obj: UpdateUserParam) -> int: 59 | """ 60 | 更新用户信息 61 | 62 | :param db: 63 | :param input_user: 64 | :param obj: 65 | :return: 66 | """ 67 | return await self.update_model(db, input_user, obj) 68 | 69 | async def update_avatar(self, db: AsyncSession, input_user: int, avatar: AvatarParam) -> int: 70 | """ 71 | 更新用户头像 72 | 73 | :param db: 74 | :param input_user: 75 | :param avatar: 76 | :return: 77 | """ 78 | return await self.update_model(db, input_user, {'avatar': avatar.url}) 79 | 80 | async def delete(self, db: AsyncSession, user_id: int) -> int: 81 | """ 82 | 删除用户 83 | 84 | :param db: 85 | :param user_id: 86 | :return: 87 | """ 88 | return await self.delete_model(db, user_id) 89 | 90 | async def check_email(self, db: AsyncSession, email: str) -> User: 91 | """ 92 | 检查邮箱是否存在 93 | 94 | :param db: 95 | :param email: 96 | :return: 97 | """ 98 | return await self.select_model_by_column(db, email=email) 99 | 100 | async def reset_password(self, db: AsyncSession, pk: int, new_pwd: str) -> int: 101 | """ 102 | 重置用户密码 103 | 104 | :param db: 105 | :param pk: 106 | :param new_pwd: 107 | :return: 108 | """ 109 | return await self.update_model(db, pk, {'password': new_pwd}) 110 | 111 | async def get_list(self, username: str = None, phone: str = None, status: int = None) -> Select: 112 | """ 113 | 获取用户列表 114 | 115 | :param username: 116 | :param phone: 117 | :param status: 118 | :return: 119 | """ 120 | stmt = select(self.model).order_by(desc(self.model.join_time)) 121 | 122 | filters = [] 123 | if username: 124 | filters.append(self.model.username.like(f'%{username}%')) 125 | if phone: 126 | filters.append(self.model.phone.like(f'%{phone}%')) 127 | if status is not None: 128 | filters.append(self.model.status == status) 129 | 130 | if filters: 131 | stmt = stmt.where(and_(*filters)) 132 | 133 | return stmt 134 | 135 | 136 | user_dao: CRUDUser = CRUDUser(User) 137 | -------------------------------------------------------------------------------- /backend/app/admin/model/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from backend.common.model import MappedBase # noqa: I 4 | from backend.app.admin.model.user import User 5 | -------------------------------------------------------------------------------- /backend/app/admin/model/user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime 4 | 5 | from sqlalchemy import String, VARBINARY 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | 8 | from backend.common.model import DataClassBase, id_key 9 | from backend.database.db import uuid4_str 10 | from backend.utils.timezone import timezone 11 | 12 | 13 | class User(DataClassBase): 14 | """用户表""" 15 | 16 | __tablename__ = 'sys_user' 17 | 18 | id: Mapped[id_key] = mapped_column(init=False) 19 | uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True) 20 | username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名') 21 | password: Mapped[str] = mapped_column(String(255), comment='密码') 22 | salt: Mapped[bytes | None] = mapped_column(VARBINARY(255), comment='加密盐') 23 | email: Mapped[str] = mapped_column(String(50), unique=True, index=True, comment='邮箱') 24 | status: Mapped[int] = mapped_column(default=1, comment='用户账号状态(0停用 1正常)') 25 | is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)') 26 | avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像') 27 | phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号') 28 | join_time: Mapped[datetime] = mapped_column(init=False, default_factory=timezone.now, comment='注册时间') 29 | last_login_time: Mapped[datetime | None] = mapped_column(init=False, onupdate=timezone.now, comment='上次登录') 30 | -------------------------------------------------------------------------------- /backend/app/admin/schema/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-practices/fastapi_sqlalchemy_mysql/00b2dd99e27580d8c3db40ccc12946def9e3ec3d/backend/app/admin/schema/__init__.py -------------------------------------------------------------------------------- /backend/app/admin/schema/captcha.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from pydantic import Field 4 | 5 | from backend.common.schema import SchemaBase 6 | 7 | 8 | class GetCaptchaDetail(SchemaBase): 9 | image_type: str = Field(description='图片类型') 10 | image: str = Field(description='图片内容') 11 | -------------------------------------------------------------------------------- /backend/app/admin/schema/token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from backend.common.schema import SchemaBase 5 | from backend.app.admin.schema.user import GetUserInfoDetail 6 | 7 | 8 | class GetSwaggerToken(SchemaBase): 9 | access_token: str 10 | token_type: str = 'Bearer' 11 | user: GetUserInfoDetail 12 | 13 | 14 | class GetLoginToken(GetSwaggerToken): 15 | access_token_type: str = 'Bearer' 16 | -------------------------------------------------------------------------------- /backend/app/admin/schema/user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime 4 | 5 | from pydantic import Field, EmailStr, ConfigDict, HttpUrl 6 | 7 | from backend.common.schema import SchemaBase, CustomPhoneNumber 8 | 9 | 10 | class AuthSchemaBase(SchemaBase): 11 | username: str = Field(description='用户名') 12 | password: str = Field(description='密码') 13 | 14 | 15 | class AuthLoginParam(AuthSchemaBase): 16 | captcha: str = Field(description='验证码') 17 | 18 | 19 | class RegisterUserParam(AuthSchemaBase): 20 | email: EmailStr = Field(examples=['user@example.com'], description='邮箱') 21 | 22 | 23 | class UpdateUserParam(SchemaBase): 24 | username: str = Field(description='用户名') 25 | email: EmailStr = Field(examples=['user@example.com'], description='邮箱') 26 | phone: CustomPhoneNumber | None = Field(None, description='手机号') 27 | 28 | 29 | class AvatarParam(SchemaBase): 30 | url: HttpUrl = Field(..., description='头像 http 地址') 31 | 32 | 33 | class GetUserInfoDetail(UpdateUserParam): 34 | model_config = ConfigDict(from_attributes=True) 35 | 36 | id: int = Field(description='用户 ID') 37 | uuid: str = Field(description='用户 UUID') 38 | avatar: str | None = Field(None, description='头像') 39 | status: int = Field(description='状态') 40 | is_superuser: bool = Field(description='是否超级管理员') 41 | join_time: datetime = Field(description='加入时间') 42 | last_login_time: datetime | None = Field(None, description='最后登录时间') 43 | 44 | 45 | class ResetPassword(SchemaBase): 46 | username: str = Field(description='用户名') 47 | old_password: str = Field(description='旧密码') 48 | new_password: str = Field(description='新密码') 49 | confirm_password: str = Field(description='确认密码') 50 | -------------------------------------------------------------------------------- /backend/app/admin/service/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/service/auth_service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import Request 4 | from fastapi.security import OAuth2PasswordRequestForm 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from backend.app.admin.crud.crud_user import user_dao 8 | from backend.app.admin.model import User 9 | from backend.app.admin.schema.token import GetLoginToken 10 | from backend.app.admin.schema.user import AuthLoginParam 11 | from backend.common.exception import errors 12 | from backend.common.response.response_code import CustomErrorCode 13 | from backend.common.security.jwt import password_verify, create_access_token 14 | from backend.core.conf import settings 15 | from backend.database.db import async_db_session 16 | from backend.database.redis import redis_client 17 | from backend.utils.timezone import timezone 18 | 19 | 20 | class AuthService: 21 | @staticmethod 22 | async def user_verify(db: AsyncSession, username: str, password: str) -> User: 23 | user = await user_dao.get_by_username(db, username) 24 | if not user: 25 | raise errors.NotFoundError(msg='用户名或密码有误') 26 | elif not password_verify(password, user.password): 27 | raise errors.AuthorizationError(msg='用户名或密码有误') 28 | elif not user.status: 29 | raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') 30 | return user 31 | 32 | async def swagger_login(self, *, form_data: OAuth2PasswordRequestForm) -> tuple[str, User]: 33 | async with async_db_session() as db: 34 | user = await self.user_verify(db, form_data.username, form_data.password) 35 | await user_dao.update_login_time(db, user.username, login_time=timezone.now()) 36 | token = create_access_token(str(user.id)) 37 | return token, user 38 | 39 | async def login(self, *, request: Request, obj: AuthLoginParam) -> GetLoginToken: 40 | async with async_db_session() as db: 41 | user = await self.user_verify(db, obj.username, obj.password) 42 | try: 43 | captcha_uuid = request.app.state.captcha_uuid 44 | redis_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{captcha_uuid}') 45 | if not redis_code: 46 | raise errors.ForbiddenError(msg='验证码失效,请重新获取') 47 | except AttributeError: 48 | raise errors.ForbiddenError(msg='验证码失效,请重新获取') 49 | if redis_code.lower() != obj.captcha.lower(): 50 | raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) 51 | await user_dao.update_login_time(db, user.username, login_time=timezone.now()) 52 | token = create_access_token(str(user.id)) 53 | data = GetLoginToken(access_token=token, user=user) 54 | return data 55 | 56 | 57 | auth_service: AuthService = AuthService() 58 | -------------------------------------------------------------------------------- /backend/app/admin/service/user_service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from sqlalchemy import Select 4 | 5 | from backend.common.exception import errors 6 | from backend.common.security.jwt import superuser_verify, password_verify, get_hash_password 7 | from backend.app.admin.crud.crud_user import user_dao 8 | from backend.database.db import async_db_session 9 | from backend.app.admin.model import User 10 | from backend.app.admin.schema.user import RegisterUserParam, ResetPassword, UpdateUserParam, AvatarParam 11 | 12 | 13 | class UserService: 14 | @staticmethod 15 | async def register(*, obj: RegisterUserParam) -> None: 16 | async with async_db_session.begin() as db: 17 | if not obj.password: 18 | raise errors.ForbiddenError(msg='密码为空') 19 | username = await user_dao.get_by_username(db, obj.username) 20 | if username: 21 | raise errors.ForbiddenError(msg='用户已注册') 22 | email = await user_dao.check_email(db, obj.email) 23 | if email: 24 | raise errors.ForbiddenError(msg='邮箱已注册') 25 | await user_dao.create(db, obj) 26 | 27 | @staticmethod 28 | async def pwd_reset(*, obj: ResetPassword) -> int: 29 | async with async_db_session.begin() as db: 30 | user = await user_dao.get_by_username(db, obj.username) 31 | if not password_verify(obj.old_password, user.password): 32 | raise errors.ForbiddenError(msg='原密码错误') 33 | np1 = obj.new_password 34 | np2 = obj.confirm_password 35 | if np1 != np2: 36 | raise errors.ForbiddenError(msg='密码输入不一致') 37 | new_pwd = get_hash_password(obj.new_password, user.salt) 38 | count = await user_dao.reset_password(db, user.id, new_pwd) 39 | return count 40 | 41 | @staticmethod 42 | async def get_userinfo(*, username: str) -> User: 43 | async with async_db_session() as db: 44 | user = await user_dao.get_by_username(db, username) 45 | if not user: 46 | raise errors.NotFoundError(msg='用户不存在') 47 | return user 48 | 49 | @staticmethod 50 | async def update(*, username: str, obj: UpdateUserParam) -> int: 51 | async with async_db_session.begin() as db: 52 | input_user = await user_dao.get_by_username(db, username=username) 53 | if not input_user: 54 | raise errors.NotFoundError(msg='用户不存在') 55 | superuser_verify(input_user) 56 | if input_user.username != obj.username: 57 | _username = await user_dao.get_by_username(db, obj.username) 58 | if _username: 59 | raise errors.ForbiddenError(msg='用户名已注册') 60 | if input_user.email != obj.email: 61 | email = await user_dao.check_email(db, obj.email) 62 | if email: 63 | raise errors.ForbiddenError(msg='邮箱已注册') 64 | count = await user_dao.update_userinfo(db, input_user.id, obj) 65 | return count 66 | 67 | @staticmethod 68 | async def update_avatar(*, username: str, avatar: AvatarParam) -> int: 69 | async with async_db_session.begin() as db: 70 | input_user = await user_dao.get_by_username(db, username) 71 | if not input_user: 72 | raise errors.NotFoundError(msg='用户不存在') 73 | count = await user_dao.update_avatar(db, input_user.id, avatar) 74 | return count 75 | 76 | @staticmethod 77 | async def get_select(*, username: str = None, phone: str = None, status: int = None) -> Select: 78 | return await user_dao.get_list(username=username, phone=phone, status=status) 79 | 80 | @staticmethod 81 | async def delete(*, current_user: User, username: str) -> int: 82 | async with async_db_session.begin() as db: 83 | superuser_verify(current_user) 84 | input_user = await user_dao.get_by_username(db, username) 85 | if not input_user: 86 | raise errors.NotFoundError(msg='用户不存在') 87 | count = await user_dao.delete(db, input_user.id) 88 | return count 89 | -------------------------------------------------------------------------------- /backend/app/router.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import APIRouter 4 | 5 | from backend.app.admin.api.router import v1 as admin_v1 6 | 7 | route = APIRouter() 8 | 9 | route.include_router(admin_v1) 10 | -------------------------------------------------------------------------------- /backend/common/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/dataclasses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/enums.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from enum import Enum, IntEnum as SourceIntEnum 4 | from typing import Type 5 | 6 | 7 | class _EnumBase: 8 | @classmethod 9 | def get_member_keys(cls: Type[Enum]) -> list[str]: 10 | return [name for name in cls.__members__.keys()] 11 | 12 | @classmethod 13 | def get_member_values(cls: Type[Enum]) -> list: 14 | return [item.value for item in cls.__members__.values()] 15 | 16 | 17 | class IntEnum(_EnumBase, SourceIntEnum): 18 | """整型枚举""" 19 | 20 | pass 21 | 22 | 23 | class StrEnum(_EnumBase, str, Enum): 24 | """字符串枚举""" 25 | 26 | pass 27 | -------------------------------------------------------------------------------- /backend/common/exception/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/exception/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from typing import Any 4 | 5 | from fastapi import HTTPException 6 | from starlette.background import BackgroundTask 7 | 8 | from backend.common.response.response_code import CustomErrorCode, StandardResponseCode 9 | 10 | 11 | class BaseExceptionMixin(Exception): 12 | """基础异常混入类""" 13 | 14 | code: int 15 | 16 | def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None): 17 | self.msg = msg 18 | self.data = data 19 | # The original background task: https://www.starlette.io/background/ 20 | self.background = background 21 | 22 | 23 | class HTTPError(HTTPException): 24 | """HTTP 异常""" 25 | 26 | def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None): 27 | super().__init__(status_code=code, detail=msg, headers=headers) 28 | 29 | 30 | class CustomError(BaseExceptionMixin): 31 | """自定义异常""" 32 | 33 | def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None): 34 | self.code = error.code 35 | super().__init__(msg=error.msg, data=data, background=background) 36 | 37 | 38 | class RequestError(BaseExceptionMixin): 39 | """请求异常""" 40 | 41 | code = StandardResponseCode.HTTP_400 42 | 43 | def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None): 44 | super().__init__(msg=msg, data=data, background=background) 45 | 46 | 47 | class ForbiddenError(BaseExceptionMixin): 48 | """禁止访问异常""" 49 | 50 | code = StandardResponseCode.HTTP_403 51 | 52 | def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None): 53 | super().__init__(msg=msg, data=data, background=background) 54 | 55 | 56 | class NotFoundError(BaseExceptionMixin): 57 | """资源不存在异常""" 58 | 59 | code = StandardResponseCode.HTTP_404 60 | 61 | def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None): 62 | super().__init__(msg=msg, data=data, background=background) 63 | 64 | 65 | class ServerError(BaseExceptionMixin): 66 | """服务器异常""" 67 | 68 | code = StandardResponseCode.HTTP_500 69 | 70 | def __init__( 71 | self, *, msg: str = 'Internal Server Error', data: Any = None, background: BackgroundTask | None = None 72 | ): 73 | super().__init__(msg=msg, data=data, background=background) 74 | 75 | 76 | class GatewayError(BaseExceptionMixin): 77 | """网关异常""" 78 | 79 | code = StandardResponseCode.HTTP_502 80 | 81 | def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None): 82 | super().__init__(msg=msg, data=data, background=background) 83 | 84 | 85 | class AuthorizationError(BaseExceptionMixin): 86 | """授权异常""" 87 | 88 | code = StandardResponseCode.HTTP_401 89 | 90 | def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None): 91 | super().__init__(msg=msg, data=data, background=background) 92 | 93 | 94 | class TokenError(HTTPError): 95 | """Token 异常""" 96 | 97 | code = StandardResponseCode.HTTP_401 98 | 99 | def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None): 100 | super().__init__(code=self.code, msg=msg, headers=headers or {'WWW-Authenticate': 'Bearer'}) 101 | -------------------------------------------------------------------------------- /backend/common/exception/exception_handler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import FastAPI, Request 4 | from fastapi.exceptions import RequestValidationError 5 | from pydantic import ValidationError 6 | from starlette.exceptions import HTTPException 7 | from starlette.middleware.cors import CORSMiddleware 8 | from uvicorn.protocols.http.h11_impl import STATUS_PHRASES 9 | 10 | from backend.common.exception.errors import BaseExceptionMixin 11 | from backend.common.response.response_code import CustomResponseCode, StandardResponseCode 12 | from backend.common.response.response_schema import response_base 13 | from backend.common.schema import CUSTOM_VALIDATION_ERROR_MESSAGES 14 | from backend.core.conf import settings 15 | from backend.utils.serializers import MsgSpecJSONResponse 16 | 17 | 18 | def _get_exception_code(status_code: int): 19 | """ 20 | 获取返回状态码, OpenAPI, Uvicorn... 可用状态码基于 RFC 定义, 详细代码见下方链接 21 | 22 | `python 状态码标准支持 `__ 24 | 25 | `IANA 状态码注册表 `__ 26 | 27 | :param status_code: 28 | :return: 29 | """ 30 | try: 31 | STATUS_PHRASES[status_code] 32 | except Exception: 33 | code = StandardResponseCode.HTTP_400 34 | else: 35 | code = status_code 36 | return code 37 | 38 | 39 | async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError): 40 | """ 41 | 数据验证异常处理 42 | 43 | :param e: 44 | :return: 45 | """ 46 | errors = [] 47 | for error in e.errors(): 48 | custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type']) 49 | if custom_message: 50 | ctx = error.get('ctx') 51 | if not ctx: 52 | error['msg'] = custom_message 53 | else: 54 | error['msg'] = custom_message.format(**ctx) 55 | ctx_error = ctx.get('error') 56 | if ctx_error: 57 | error['ctx']['error'] = ( 58 | ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None 59 | ) 60 | errors.append(error) 61 | error = errors[0] 62 | if error.get('type') == 'json_invalid': 63 | message = 'json解析失败' 64 | else: 65 | error_input = error.get('input') 66 | field = str(error.get('loc')[-1]) 67 | error_msg = error.get('msg') 68 | message = f'{field} {error_msg},输入:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg 69 | msg = f'请求参数非法: {message}' 70 | data = {'errors': errors} if settings.ENVIRONMENT == 'dev' else None 71 | content = { 72 | 'code': StandardResponseCode.HTTP_422, 73 | 'msg': msg, 74 | 'data': data, 75 | } 76 | return MsgSpecJSONResponse(status_code=422, content=content) 77 | 78 | 79 | def register_exception(app: FastAPI): 80 | @app.exception_handler(HTTPException) 81 | async def http_exception_handler(request: Request, exc: HTTPException): 82 | """ 83 | 全局HTTP异常处理 84 | 85 | :param request: 86 | :param exc: 87 | :return: 88 | """ 89 | if settings.ENVIRONMENT == 'dev': 90 | content = { 91 | 'code': exc.status_code, 92 | 'msg': exc.detail, 93 | 'data': None, 94 | } 95 | else: 96 | res = response_base.fail(res=CustomResponseCode.HTTP_400) 97 | content = res.model_dump() 98 | return MsgSpecJSONResponse( 99 | status_code=_get_exception_code(exc.status_code), 100 | content=content, 101 | headers=exc.headers, 102 | ) 103 | 104 | @app.exception_handler(RequestValidationError) 105 | async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError): 106 | """ 107 | fastapi 数据验证异常处理 108 | 109 | :param request: 110 | :param exc: 111 | :return: 112 | """ 113 | return await _validation_exception_handler(request, exc) 114 | 115 | @app.exception_handler(ValidationError) 116 | async def pydantic_validation_exception_handler(request: Request, exc: ValidationError): 117 | """ 118 | pydantic 数据验证异常处理 119 | 120 | :param request: 121 | :param exc: 122 | :return: 123 | """ 124 | return await _validation_exception_handler(request, exc) 125 | 126 | @app.exception_handler(AssertionError) 127 | async def assertion_error_handler(request: Request, exc: AssertionError): 128 | """ 129 | 断言错误处理 130 | 131 | :param request: 132 | :param exc: 133 | :return: 134 | """ 135 | if settings.ENVIRONMENT == 'dev': 136 | content = { 137 | 'code': StandardResponseCode.HTTP_500, 138 | 'msg': str(''.join(exc.args) if exc.args else exc.__doc__), 139 | 'data': None, 140 | } 141 | else: 142 | res = response_base.fail(res=CustomResponseCode.HTTP_500) 143 | content = res.model_dump() 144 | return MsgSpecJSONResponse( 145 | status_code=StandardResponseCode.HTTP_500, 146 | content=content, 147 | ) 148 | 149 | @app.exception_handler(BaseExceptionMixin) 150 | async def custom_exception_handler(request: Request, exc: BaseExceptionMixin): 151 | """ 152 | 全局自定义异常处理 153 | 154 | :param request: 155 | :param exc: 156 | :return: 157 | """ 158 | content = { 159 | 'code': exc.code, 160 | 'msg': str(exc.msg), 161 | 'data': exc.data if exc.data else None, 162 | } 163 | return MsgSpecJSONResponse( 164 | status_code=_get_exception_code(exc.code), 165 | content=content, 166 | background=exc.background, 167 | ) 168 | 169 | @app.exception_handler(Exception) 170 | async def all_unknown_exception_handler(request: Request, exc: Exception): 171 | """ 172 | 全局未知异常处理 173 | 174 | :param request: 175 | :param exc: 176 | :return: 177 | """ 178 | if settings.ENVIRONMENT == 'dev': 179 | content = { 180 | 'code': StandardResponseCode.HTTP_500, 181 | 'msg': str(exc), 182 | 'data': None, 183 | } 184 | else: 185 | res = response_base.fail(res=CustomResponseCode.HTTP_500) 186 | content = res.model_dump() 187 | return MsgSpecJSONResponse( 188 | status_code=StandardResponseCode.HTTP_500, 189 | content=content, 190 | ) 191 | 192 | if settings.MIDDLEWARE_CORS: 193 | 194 | @app.exception_handler(StandardResponseCode.HTTP_500) 195 | async def cors_custom_code_500_exception_handler(request, exc): 196 | """ 197 | 跨域自定义 500 异常处理 198 | 199 | `Related issue `_ 200 | `Solution `_ 201 | 202 | :param request: 203 | :param exc: 204 | :return: 205 | """ 206 | if isinstance(exc, BaseExceptionMixin): 207 | content = { 208 | 'code': exc.code, 209 | 'msg': exc.msg, 210 | 'data': exc.data, 211 | } 212 | else: 213 | if settings.ENVIRONMENT == 'dev': 214 | content = { 215 | 'code': StandardResponseCode.HTTP_500, 216 | 'msg': str(exc), 217 | 'data': None, 218 | } 219 | else: 220 | res = response_base.fail(res=CustomResponseCode.HTTP_500) 221 | content = res.model_dump() 222 | response = MsgSpecJSONResponse( 223 | status_code=exc.code if isinstance(exc, BaseExceptionMixin) else StandardResponseCode.HTTP_500, 224 | content=content, 225 | background=exc.background if isinstance(exc, BaseExceptionMixin) else None, 226 | ) 227 | origin = request.headers.get('origin') 228 | if origin: 229 | cors = CORSMiddleware( 230 | app=app, 231 | allow_origins=settings.CORS_ALLOWED_ORIGINS, 232 | allow_credentials=True, 233 | allow_methods=['*'], 234 | allow_headers=['*'], 235 | expose_headers=settings.CORS_EXPOSE_HEADERS, 236 | ) 237 | response.headers.update(cors.simple_headers) 238 | has_cookie = 'cookie' in request.headers 239 | if cors.allow_all_origins and has_cookie: 240 | response.headers['Access-Control-Allow-Origin'] = origin 241 | elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin): 242 | response.headers['Access-Control-Allow-Origin'] = origin 243 | response.headers.add_vary_header('Origin') 244 | return response 245 | -------------------------------------------------------------------------------- /backend/common/log.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import inspect 4 | import logging 5 | import os 6 | import sys 7 | 8 | from loguru import logger 9 | 10 | from backend.core import path_conf 11 | from backend.core.conf import settings 12 | 13 | 14 | class InterceptHandler(logging.Handler): 15 | """ 16 | 日志拦截处理器,用于将标准库的日志重定向到 loguru 17 | 18 | 参考:https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging 19 | """ 20 | 21 | def emit(self, record: logging.LogRecord): 22 | # 获取对应的 Loguru 级别(如果存在) 23 | try: 24 | level = logger.level(record.levelname).name 25 | except ValueError: 26 | level = record.levelno 27 | 28 | # 查找记录日志消息的调用者 29 | frame, depth = inspect.currentframe(), 0 30 | while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): 31 | frame = frame.f_back 32 | depth += 1 33 | 34 | logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) 35 | 36 | 37 | def setup_logging() -> None: 38 | """ 39 | 设置日志处理器 40 | 41 | 参考: 42 | - https://github.com/benoitc/gunicorn/issues/1572#issuecomment-638391953 43 | - https://github.com/pawamoy/pawamoy.github.io/issues/17 44 | """ 45 | # 设置根日志处理器和级别 46 | logging.root.handlers = [InterceptHandler()] 47 | logging.root.setLevel(settings.LOG_STD_LEVEL) 48 | 49 | # 配置日志传播规则 50 | for name in logging.root.manager.loggerDict.keys(): 51 | logging.getLogger(name).handlers = [] 52 | if 'uvicorn.access' in name or 'watchfiles.main' in name: 53 | logging.getLogger(name).propagate = False 54 | else: 55 | logging.getLogger(name).propagate = True 56 | 57 | # Debug log handlers 58 | # logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}') 59 | 60 | # 配置 loguru 处理器 61 | logger.remove() # 移除默认处理器 62 | logger.configure( 63 | handlers=[ 64 | { 65 | 'sink': sys.stdout, 66 | 'level': settings.LOG_STD_LEVEL, 67 | 'format': settings.LOG_STD_FORMAT, 68 | } 69 | ] 70 | ) 71 | 72 | 73 | def set_custom_logfile(): 74 | """设置自定义日志文件""" 75 | log_path = path_conf.LOG_DIR 76 | if not os.path.exists(log_path): 77 | os.mkdir(log_path) 78 | 79 | # 日志文件 80 | log_access_file = os.path.join(log_path, settings.LOG_ACCESS_FILENAME) 81 | log_error_file = os.path.join(log_path, settings.LOG_ERROR_FILENAME) 82 | 83 | # 日志文件通用配置 84 | # https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add 85 | log_config = { 86 | 'format': settings.LOG_FILE_FORMAT, 87 | 'enqueue': True, 88 | 'rotation': '5 MB', 89 | 'retention': '7 days', 90 | 'compression': 'tar.gz', 91 | } 92 | 93 | # 标准输出文件 94 | logger.add( 95 | str(log_access_file), 96 | level=settings.LOG_ACCESS_FILE_LEVEL, 97 | filter=lambda record: record['level'].no <= 25, 98 | backtrace=False, 99 | diagnose=False, 100 | **log_config, 101 | ) 102 | 103 | # 标准错误文件 104 | logger.add( 105 | str(log_error_file), 106 | level=settings.LOG_ERROR_FILE_LEVEL, 107 | filter=lambda record: record['level'].no >= 30, 108 | backtrace=True, 109 | diagnose=True, 110 | **log_config, 111 | ) 112 | 113 | 114 | log = logger 115 | -------------------------------------------------------------------------------- /backend/common/model.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime 4 | from typing import Annotated 5 | 6 | from sqlalchemy.ext.asyncio import AsyncAttrs 7 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column 8 | 9 | from backend.utils.timezone import timezone 10 | 11 | # 通用 Mapped 类型主键, 需手动添加,参考以下使用方式 12 | # MappedBase -> id: Mapped[id_key] 13 | # DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False) 14 | id_key = Annotated[ 15 | int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键id') 16 | ] 17 | 18 | 19 | # Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki `__ 20 | class UserMixin(MappedAsDataclass): 21 | """用户 Mixin 数据类""" 22 | 23 | created_by: Mapped[int] = mapped_column(sort_order=998, comment='创建者') 24 | updated_by: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='修改者') 25 | 26 | 27 | class DateTimeMixin(MappedAsDataclass): 28 | """日期时间 Mixin 数据类""" 29 | 30 | created_time: Mapped[datetime] = mapped_column( 31 | init=False, default_factory=timezone.now, sort_order=999, comment='创建时间' 32 | ) 33 | updated_time: Mapped[datetime | None] = mapped_column( 34 | init=False, onupdate=timezone.now, sort_order=999, comment='更新时间' 35 | ) 36 | 37 | 38 | class MappedBase(AsyncAttrs, DeclarativeBase): 39 | """ 40 | 声明式基类, 作为所有基类或数据模型类的父类而存在 41 | 42 | `AsyncAttrs `__ 43 | `DeclarativeBase `__ 44 | `mapped_column() `__ 45 | """ 46 | 47 | @declared_attr.directive 48 | def __tablename__(cls) -> str: 49 | return cls.__name__.lower() 50 | 51 | 52 | class DataClassBase(MappedAsDataclass, MappedBase): 53 | """ 54 | 声明性数据类基类, 它将带有数据类集成, 允许使用更高级配置, 但你必须注意它的一些特性, 尤其是和 DeclarativeBase 一起使用时 55 | 56 | `MappedAsDataclass `__ 57 | """ # noqa: E501 58 | 59 | __abstract__ = True 60 | 61 | 62 | class Base(DataClassBase, DateTimeMixin): 63 | """ 64 | 声明性 Mixin 数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构, 你可以简单的理解它为含有基础表结构的数据类基类 65 | """ # noqa: E501 66 | 67 | __abstract__ = True 68 | -------------------------------------------------------------------------------- /backend/common/pagination.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from __future__ import annotations 4 | 5 | from math import ceil 6 | from typing import TYPE_CHECKING, Generic, Sequence, TypeVar 7 | 8 | from fastapi import Depends, Query 9 | from fastapi_pagination import pagination_ctx 10 | from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams 11 | from fastapi_pagination.ext.sqlalchemy import paginate 12 | from fastapi_pagination.links.bases import create_links 13 | from pydantic import BaseModel, Field 14 | 15 | if TYPE_CHECKING: 16 | from sqlalchemy import Select 17 | from sqlalchemy.ext.asyncio import AsyncSession 18 | 19 | T = TypeVar('T') 20 | SchemaT = TypeVar('SchemaT') 21 | 22 | 23 | class _CustomPageParams(BaseModel, AbstractParams): 24 | page: int = Query(1, ge=1, description='Page number') 25 | size: int = Query(20, gt=0, le=100, description='Page size') # 默认 20 条记录 26 | 27 | def to_raw_params(self) -> RawParams: 28 | return RawParams( 29 | limit=self.size, 30 | offset=self.size * (self.page - 1), 31 | ) 32 | 33 | 34 | class _Links(BaseModel): 35 | first: str = Field(..., description='首页链接') 36 | last: str = Field(..., description='尾页链接') 37 | self: str = Field(..., description='当前页链接') 38 | next: str | None = Field(None, description='下一页链接') 39 | prev: str | None = Field(None, description='上一页链接') 40 | 41 | 42 | class _PageDetails(BaseModel): 43 | items: list = Field([], description='当前页数据') 44 | total: int = Field(..., description='总条数') 45 | page: int = Field(..., description='当前页') 46 | size: int = Field(..., description='每页数量') 47 | total_pages: int = Field(..., description='总页数') 48 | links: _Links 49 | 50 | 51 | class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]): 52 | __params_type__ = _CustomPageParams 53 | 54 | @classmethod 55 | def create( 56 | cls, 57 | items: list, 58 | total: int, 59 | params: _CustomPageParams, 60 | ) -> _CustomPage[T]: 61 | page = params.page 62 | size = params.size 63 | total_pages = ceil(total / params.size) 64 | links = create_links( 65 | first={'page': 1, 'size': size}, 66 | last={'page': f'{ceil(total / params.size)}', 'size': size} if total > 0 else {'page': 1, 'size': size}, 67 | next={'page': f'{page + 1}', 'size': size} if (page + 1) <= total_pages else None, 68 | prev={'page': f'{page - 1}', 'size': size} if (page - 1) >= 1 else None, 69 | ).model_dump() 70 | 71 | return cls( 72 | items=items, 73 | total=total, 74 | page=params.page, 75 | size=params.size, 76 | total_pages=total_pages, 77 | links=links, # type: ignore 78 | ) 79 | 80 | 81 | class PageData(_PageDetails, Generic[SchemaT]): 82 | """ 83 | 包含 data schema 的统一返回模型,适用于分页接口 84 | 85 | E.g. :: 86 | 87 | @router.get('/test', response_model=ResponseSchemaModel[PageData[GetApiDetail]]) 88 | def test(): 89 | return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...)) 90 | 91 | 92 | @router.get('/test') 93 | def test() -> ResponseSchemaModel[PageData[GetApiDetail]]: 94 | return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...)) 95 | 96 | 97 | @router.get('/test') 98 | def test() -> ResponseSchemaModel[PageData[GetApiDetail]]: 99 | res = CustomResponseCode.HTTP_200 100 | return ResponseSchemaModel[PageData[GetApiDetail]](code=res.code, msg=res.msg, data=GetApiDetail(...)) 101 | """ 102 | 103 | items: Sequence[SchemaT] 104 | 105 | 106 | async def paging_data(db: AsyncSession, select: Select) -> dict: 107 | """ 108 | 基于 SQLAlchemy 创建分页数据 109 | 110 | :param db: 111 | :param select: 112 | :return: 113 | """ 114 | paginated_data: _CustomPage = await paginate(db, select) 115 | page_data = paginated_data.model_dump() 116 | return page_data 117 | 118 | 119 | # 分页依赖注入 120 | DependsPagination = Depends(pagination_ctx(_CustomPage)) 121 | -------------------------------------------------------------------------------- /backend/common/response/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/response/response_code.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import dataclasses 4 | 5 | from enum import Enum 6 | 7 | 8 | class CustomCodeBase(Enum): 9 | """自定义状态码基类""" 10 | 11 | @property 12 | def code(self): 13 | """ 14 | 获取状态码 15 | """ 16 | return self.value[0] 17 | 18 | @property 19 | def msg(self): 20 | """ 21 | 获取状态码信息 22 | """ 23 | return self.value[1] 24 | 25 | 26 | class CustomResponseCode(CustomCodeBase): 27 | """自定义响应状态码""" 28 | 29 | HTTP_200 = (200, '请求成功') 30 | HTTP_201 = (201, '新建请求成功') 31 | HTTP_202 = (202, '请求已接受,但处理尚未完成') 32 | HTTP_204 = (204, '请求成功,但没有返回内容') 33 | HTTP_400 = (400, '请求错误') 34 | HTTP_401 = (401, '未经授权') 35 | HTTP_403 = (403, '禁止访问') 36 | HTTP_404 = (404, '请求的资源不存在') 37 | HTTP_410 = (410, '请求的资源已永久删除') 38 | HTTP_422 = (422, '请求参数非法') 39 | HTTP_425 = (425, '无法执行请求,由于服务器无法满足要求') 40 | HTTP_429 = (429, '请求过多,服务器限制') 41 | HTTP_500 = (500, '服务器内部错误') 42 | HTTP_502 = (502, '网关错误') 43 | HTTP_503 = (503, '服务器暂时无法处理请求') 44 | HTTP_504 = (504, '网关超时') 45 | 46 | 47 | class CustomErrorCode(CustomCodeBase): 48 | """自定义错误状态码""" 49 | 50 | CAPTCHA_ERROR = (40001, '验证码错误') 51 | 52 | 53 | @dataclasses.dataclass 54 | class CustomResponse: 55 | """ 56 | 提供开放式响应状态码,而不是枚举,如果你想自定义响应信息,这可能很有用 57 | """ 58 | 59 | code: int 60 | msg: str 61 | 62 | 63 | class StandardResponseCode: 64 | """标准响应状态码""" 65 | 66 | """ 67 | HTTP codes 68 | See HTTP Status Code Registry: 69 | https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml 70 | 71 | And RFC 2324 - https://tools.ietf.org/html/rfc2324 72 | """ 73 | HTTP_100 = 100 # CONTINUE: 继续 74 | HTTP_101 = 101 # SWITCHING_PROTOCOLS: 协议切换 75 | HTTP_102 = 102 # PROCESSING: 处理中 76 | HTTP_103 = 103 # EARLY_HINTS: 提示信息 77 | HTTP_200 = 200 # OK: 请求成功 78 | HTTP_201 = 201 # CREATED: 已创建 79 | HTTP_202 = 202 # ACCEPTED: 已接受 80 | HTTP_203 = 203 # NON_AUTHORITATIVE_INFORMATION: 非权威信息 81 | HTTP_204 = 204 # NO_CONTENT: 无内容 82 | HTTP_205 = 205 # RESET_CONTENT: 重置内容 83 | HTTP_206 = 206 # PARTIAL_CONTENT: 部分内容 84 | HTTP_207 = 207 # MULTI_STATUS: 多状态 85 | HTTP_208 = 208 # ALREADY_REPORTED: 已报告 86 | HTTP_226 = 226 # IM_USED: 使用了 87 | HTTP_300 = 300 # MULTIPLE_CHOICES: 多种选择 88 | HTTP_301 = 301 # MOVED_PERMANENTLY: 永久移动 89 | HTTP_302 = 302 # FOUND: 临时移动 90 | HTTP_303 = 303 # SEE_OTHER: 查看其他位置 91 | HTTP_304 = 304 # NOT_MODIFIED: 未修改 92 | HTTP_305 = 305 # USE_PROXY: 使用代理 93 | HTTP_307 = 307 # TEMPORARY_REDIRECT: 临时重定向 94 | HTTP_308 = 308 # PERMANENT_REDIRECT: 永久重定向 95 | HTTP_400 = 400 # BAD_REQUEST: 请求错误 96 | HTTP_401 = 401 # UNAUTHORIZED: 未授权 97 | HTTP_402 = 402 # PAYMENT_REQUIRED: 需要付款 98 | HTTP_403 = 403 # FORBIDDEN: 禁止访问 99 | HTTP_404 = 404 # NOT_FOUND: 未找到 100 | HTTP_405 = 405 # METHOD_NOT_ALLOWED: 方法不允许 101 | HTTP_406 = 406 # NOT_ACCEPTABLE: 不可接受 102 | HTTP_407 = 407 # PROXY_AUTHENTICATION_REQUIRED: 需要代理身份验证 103 | HTTP_408 = 408 # REQUEST_TIMEOUT: 请求超时 104 | HTTP_409 = 409 # CONFLICT: 冲突 105 | HTTP_410 = 410 # GONE: 已删除 106 | HTTP_411 = 411 # LENGTH_REQUIRED: 需要内容长度 107 | HTTP_412 = 412 # PRECONDITION_FAILED: 先决条件失败 108 | HTTP_413 = 413 # REQUEST_ENTITY_TOO_LARGE: 请求实体过大 109 | HTTP_414 = 414 # REQUEST_URI_TOO_LONG: 请求 URI 过长 110 | HTTP_415 = 415 # UNSUPPORTED_MEDIA_TYPE: 不支持的媒体类型 111 | HTTP_416 = 416 # REQUESTED_RANGE_NOT_SATISFIABLE: 请求范围不符合要求 112 | HTTP_417 = 417 # EXPECTATION_FAILED: 期望失败 113 | HTTP_418 = 418 # UNUSED: 闲置 114 | HTTP_421 = 421 # MISDIRECTED_REQUEST: 被错导的请求 115 | HTTP_422 = 422 # UNPROCESSABLE_CONTENT: 无法处理的实体 116 | HTTP_423 = 423 # LOCKED: 已锁定 117 | HTTP_424 = 424 # FAILED_DEPENDENCY: 依赖失败 118 | HTTP_425 = 425 # TOO_EARLY: 太早 119 | HTTP_426 = 426 # UPGRADE_REQUIRED: 需要升级 120 | HTTP_427 = 427 # UNASSIGNED: 未分配 121 | HTTP_428 = 428 # PRECONDITION_REQUIRED: 需要先决条件 122 | HTTP_429 = 429 # TOO_MANY_REQUESTS: 请求过多 123 | HTTP_430 = 430 # Unassigned: 未分配 124 | HTTP_431 = 431 # REQUEST_HEADER_FIELDS_TOO_LARGE: 请求头字段太大 125 | HTTP_451 = 451 # UNAVAILABLE_FOR_LEGAL_REASONS: 由于法律原因不可用 126 | HTTP_500 = 500 # INTERNAL_SERVER_ERROR: 服务器内部错误 127 | HTTP_501 = 501 # NOT_IMPLEMENTED: 未实现 128 | HTTP_502 = 502 # BAD_GATEWAY: 错误的网关 129 | HTTP_503 = 503 # SERVICE_UNAVAILABLE: 服务不可用 130 | HTTP_504 = 504 # GATEWAY_TIMEOUT: 网关超时 131 | HTTP_505 = 505 # HTTP_VERSION_NOT_SUPPORTED: HTTP 版本不支持 132 | HTTP_506 = 506 # VARIANT_ALSO_NEGOTIATES: 变体也会协商 133 | HTTP_507 = 507 # INSUFFICIENT_STORAGE: 存储空间不足 134 | HTTP_508 = 508 # LOOP_DETECTED: 检测到循环 135 | HTTP_509 = 509 # UNASSIGNED: 未分配 136 | HTTP_510 = 510 # NOT_EXTENDED: 未扩展 137 | HTTP_511 = 511 # NETWORK_AUTHENTICATION_REQUIRED: 需要网络身份验证 138 | 139 | """ 140 | WebSocket codes 141 | https://www.iana.org/assignments/websocket/websocket.xml#close-code-number 142 | https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent 143 | """ 144 | WS_1000 = 1000 # NORMAL_CLOSURE: 正常闭合 145 | WS_1001 = 1001 # GOING_AWAY: 正在离开 146 | WS_1002 = 1002 # PROTOCOL_ERROR: 协议错误 147 | WS_1003 = 1003 # UNSUPPORTED_DATA: 不支持的数据类型 148 | WS_1005 = 1005 # NO_STATUS_RCVD: 没有接收到状态 149 | WS_1006 = 1006 # ABNORMAL_CLOSURE: 异常关闭 150 | WS_1007 = 1007 # INVALID_FRAME_PAYLOAD_DATA: 无效的帧负载数据 151 | WS_1008 = 1008 # POLICY_VIOLATION: 策略违规 152 | WS_1009 = 1009 # MESSAGE_TOO_BIG: 消息太大 153 | WS_1010 = 1010 # MANDATORY_EXT: 必需的扩展 154 | WS_1011 = 1011 # INTERNAL_ERROR: 内部错误 155 | WS_1012 = 1012 # SERVICE_RESTART: 服务重启 156 | WS_1013 = 1013 # TRY_AGAIN_LATER: 请稍后重试 157 | WS_1014 = 1014 # BAD_GATEWAY: 错误的网关 158 | WS_1015 = 1015 # TLS_HANDSHAKE: TLS握手错误 159 | WS_3000 = 3000 # UNAUTHORIZED: 未经授权 160 | WS_3003 = 3003 # FORBIDDEN: 禁止访问 161 | -------------------------------------------------------------------------------- /backend/common/response/response_schema.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from typing import Any, Generic, TypeVar 4 | 5 | from fastapi import Response 6 | from pydantic import BaseModel, Field 7 | 8 | from backend.common.response.response_code import CustomResponse, CustomResponseCode 9 | from backend.utils.serializers import MsgSpecJSONResponse 10 | 11 | SchemaT = TypeVar('SchemaT') 12 | 13 | 14 | class ResponseModel(BaseModel): 15 | """ 16 | 不包含返回数据 schema 的通用型统一返回模型 17 | 18 | 示例:: 19 | 20 | @router.get('/test', response_model=ResponseModel) 21 | def test(): 22 | return ResponseModel(data={'test': 'test'}) 23 | 24 | 25 | @router.get('/test') 26 | def test() -> ResponseModel: 27 | return ResponseModel(data={'test': 'test'}) 28 | 29 | 30 | @router.get('/test') 31 | def test() -> ResponseModel: 32 | res = CustomResponseCode.HTTP_200 33 | return ResponseModel(code=res.code, msg=res.msg, data={'test': 'test'}) 34 | """ 35 | 36 | code: int = Field(CustomResponseCode.HTTP_200.code, description='返回状态码') 37 | msg: str = Field(CustomResponseCode.HTTP_200.msg, description='返回信息') 38 | data: Any | None = Field(None, description='返回数据') 39 | 40 | 41 | class ResponseSchemaModel(ResponseModel, Generic[SchemaT]): 42 | """ 43 | 包含返回数据 schema 的通用型统一返回模型,仅适用于非分页接口 44 | 45 | 示例:: 46 | 47 | @router.get('/test', response_model=ResponseSchemaModel[GetApiDetail]) 48 | def test(): 49 | return ResponseSchemaModel[GetApiDetail](data=GetApiDetail(...)) 50 | 51 | 52 | @router.get('/test') 53 | def test() -> ResponseSchemaModel[GetApiDetail]: 54 | return ResponseSchemaModel[GetApiDetail](data=GetApiDetail(...)) 55 | 56 | 57 | @router.get('/test') 58 | def test() -> ResponseSchemaModel[GetApiDetail]: 59 | res = CustomResponseCode.HTTP_200 60 | return ResponseSchemaModel[GetApiDetail](code=res.code, msg=res.msg, data=GetApiDetail(...)) 61 | """ 62 | 63 | data: SchemaT 64 | 65 | 66 | class ResponseBase: 67 | """统一返回方法""" 68 | 69 | @staticmethod 70 | def __response( 71 | *, res: CustomResponseCode | CustomResponse = None, data: Any | None = None 72 | ) -> ResponseModel | ResponseSchemaModel: 73 | """ 74 | 请求返回通用方法 75 | 76 | :param res: 返回信息 77 | :param data: 返回数据 78 | :return: 79 | """ 80 | return ResponseModel(code=res.code, msg=res.msg, data=data) 81 | 82 | def success( 83 | self, 84 | *, 85 | res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, 86 | data: Any | None = None, 87 | ) -> ResponseModel | ResponseSchemaModel: 88 | """ 89 | 成功响应 90 | 91 | :param res: 返回信息 92 | :param data: 返回数据 93 | :return: 94 | """ 95 | return self.__response(res=res, data=data) 96 | 97 | def fail( 98 | self, 99 | *, 100 | res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_400, 101 | data: Any = None, 102 | ) -> ResponseModel | ResponseSchemaModel: 103 | """ 104 | 失败响应 105 | 106 | :param res: 返回信息 107 | :param data: 返回数据 108 | :return: 109 | """ 110 | return self.__response(res=res, data=data) 111 | 112 | @staticmethod 113 | def fast_success( 114 | *, 115 | res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, 116 | data: Any | None = None, 117 | ) -> Response: 118 | """ 119 | 此方法是为了提高接口响应速度而创建的,在解析较大 json 时有显著性能提升,但将丢失 pydantic 解析和验证 120 | 121 | .. warning:: 122 | 123 | 使用此返回方法时,不能指定接口参数 response_model 和箭头返回类型 124 | 125 | :param res: 返回信息 126 | :param data: 返回数据 127 | :return: 128 | """ 129 | return MsgSpecJSONResponse({'code': res.code, 'msg': res.msg, 'data': data}) 130 | 131 | 132 | response_base: ResponseBase = ResponseBase() 133 | -------------------------------------------------------------------------------- /backend/common/schema.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from datetime import datetime 4 | from typing import Annotated 5 | 6 | from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email 7 | 8 | from backend.core.conf import settings 9 | 10 | # 自定义验证错误信息,参考: 11 | # https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 12 | # https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232 13 | CUSTOM_VALIDATION_ERROR_MESSAGES = { 14 | 'no_such_attribute': "对象没有属性 '{attribute}'", 15 | 'json_invalid': '无效的 JSON: {error}', 16 | 'json_type': 'JSON 输入应为字符串、字节或字节数组', 17 | 'recursion_loop': '递归错误 - 检测到循环引用', 18 | 'model_type': '输入应为有效的字典或 {class_name} 的实例', 19 | 'model_attributes_type': '输入应为有效的字典或可提取字段的对象', 20 | 'dataclass_exact_type': '输入应为 {class_name} 的实例', 21 | 'dataclass_type': '输入应为字典或 {class_name} 的实例', 22 | 'missing': '字段为必填项', 23 | 'frozen_field': '字段已冻结', 24 | 'frozen_instance': '实例已冻结', 25 | 'extra_forbidden': '不允许额外的输入', 26 | 'invalid_key': '键应为字符串', 27 | 'get_attribute_error': '提取属性时出错: {error}', 28 | 'none_required': '输入应为 None', 29 | 'enum': '输入应为 {expected}', 30 | 'greater_than': '输入应大于 {gt}', 31 | 'greater_than_equal': '输入应大于或等于 {ge}', 32 | 'less_than': '输入应小于 {lt}', 33 | 'less_than_equal': '输入应小于或等于 {le}', 34 | 'finite_number': '输入应为有限数字', 35 | 'too_short': '{field_type} 在验证后应至少有 {min_length} 个项目,而不是 {actual_length}', 36 | 'too_long': '{field_type} 在验证后最多应有 {max_length} 个项目,而不是 {actual_length}', 37 | 'string_type': '输入应为有效的字符串', 38 | 'string_sub_type': '输入应为字符串,而不是 str 子类的实例', 39 | 'string_unicode': '输入应为有效的字符串,无法将原始数据解析为 Unicode 字符串', 40 | 'string_pattern_mismatch': "字符串应匹配模式 '{pattern}'", 41 | 'string_too_short': '字符串应至少有 {min_length} 个字符', 42 | 'string_too_long': '字符串最多应有 {max_length} 个字符', 43 | 'dict_type': '输入应为有效的字典', 44 | 'mapping_type': '输入应为有效的映射,错误: {error}', 45 | 'iterable_type': '输入应为可迭代对象', 46 | 'iteration_error': '迭代对象时出错,错误: {error}', 47 | 'list_type': '输入应为有效的列表', 48 | 'tuple_type': '输入应为有效的元组', 49 | 'set_type': '输入应为有效的集合', 50 | 'bool_type': '输入应为有效的布尔值', 51 | 'bool_parsing': '输入应为有效的布尔值,无法解释输入', 52 | 'int_type': '输入应为有效的整数', 53 | 'int_parsing': '输入应为有效的整数,无法将字符串解析为整数', 54 | 'int_parsing_size': '无法将输入字符串解析为整数,超出最大大小', 55 | 'int_from_float': '输入应为有效的整数,得到一个带有小数部分的数字', 56 | 'multiple_of': '输入应为 {multiple_of} 的倍数', 57 | 'float_type': '输入应为有效的数字', 58 | 'float_parsing': '输入应为有效的数字,无法将字符串解析为数字', 59 | 'bytes_type': '输入应为有效的字节', 60 | 'bytes_too_short': '数据应至少有 {min_length} 个字节', 61 | 'bytes_too_long': '数据最多应有 {max_length} 个字节', 62 | 'value_error': '值错误,{error}', 63 | 'assertion_error': '断言失败,{error}', 64 | 'literal_error': '输入应为 {expected}', 65 | 'date_type': '输入应为有效的日期', 66 | 'date_parsing': '输入应为 YYYY-MM-DD 格式的有效日期,{error}', 67 | 'date_from_datetime_parsing': '输入应为有效的日期或日期时间,{error}', 68 | 'date_from_datetime_inexact': '提供给日期的日期时间应具有零时间 - 例如为精确日期', 69 | 'date_past': '日期应为过去的时间', 70 | 'date_future': '日期应为未来的时间', 71 | 'time_type': '输入应为有效的时间', 72 | 'time_parsing': '输入应为有效的时间格式,{error}', 73 | 'datetime_type': '输入应为有效的日期时间', 74 | 'datetime_parsing': '输入应为有效的日期时间,{error}', 75 | 'datetime_object_invalid': '无效的日期时间对象,得到 {error}', 76 | 'datetime_past': '输入应为过去的时间', 77 | 'datetime_future': '输入应为未来的时间', 78 | 'timezone_naive': '输入不应包含时区信息', 79 | 'timezone_aware': '输入应包含时区信息', 80 | 'timezone_offset': '需要时区偏移为 {tz_expected},实际得到 {tz_actual}', 81 | 'time_delta_type': '输入应为有效的时间差', 82 | 'time_delta_parsing': '输入应为有效的时间差,{error}', 83 | 'frozen_set_type': '输入应为有效的冻结集合', 84 | 'is_instance_of': '输入应为 {class} 的实例', 85 | 'is_subclass_of': '输入应为 {class} 的子类', 86 | 'callable_type': '输入应为可调用对象', 87 | 'union_tag_invalid': "使用 {discriminator} 找到的输入标签 '{tag}' 与任何预期标签不匹配: {expected_tags}", 88 | 'union_tag_not_found': '无法使用区分器 {discriminator} 提取标签', 89 | 'arguments_type': '参数必须是元组、列表或字典', 90 | 'missing_argument': '缺少必需参数', 91 | 'unexpected_keyword_argument': '意外的关键字参数', 92 | 'missing_keyword_only_argument': '缺少必需的关键字专用参数', 93 | 'unexpected_positional_argument': '意外的位置参数', 94 | 'missing_positional_only_argument': '缺少必需的位置专用参数', 95 | 'multiple_argument_values': '为参数提供了多个值', 96 | 'url_type': 'URL 输入应为字符串或 URL', 97 | 'url_parsing': '输入应为有效的 URL,{error}', 98 | 'url_syntax_violation': '输入违反了严格的 URL 语法规则,{error}', 99 | 'url_too_long': 'URL 最多应有 {max_length} 个字符', 100 | 'url_scheme': 'URL 方案应为 {expected_schemes}', 101 | 'uuid_type': 'UUID 输入应为字符串、字节或 UUID 对象', 102 | 'uuid_parsing': '输入应为有效的 UUID,{error}', 103 | 'uuid_version': '预期 UUID 版本为 {expected_version}', 104 | 'decimal_type': '十进制输入应为整数、浮点数、字符串或 Decimal 对象', 105 | 'decimal_parsing': '输入应为有效的十进制数', 106 | 'decimal_max_digits': '十进制输入总共应不超过 {max_digits} 位数字', 107 | 'decimal_max_places': '十进制输入应不超过 {decimal_places} 位小数', 108 | 'decimal_whole_digits': '十进制输入在小数点前应不超过 {whole_digits} 位数字', 109 | } 110 | 111 | CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')] 112 | 113 | 114 | class CustomEmailStr(EmailStr): 115 | """自定义邮箱类型""" 116 | 117 | @classmethod 118 | def _validate(cls, __input_value: str) -> str: 119 | return None if __input_value == '' else validate_email(__input_value)[1] 120 | 121 | 122 | class SchemaBase(BaseModel): 123 | """基础模型配置""" 124 | 125 | model_config = ConfigDict( 126 | use_enum_values=True, 127 | json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)}, 128 | ) 129 | -------------------------------------------------------------------------------- /backend/common/security/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/security/jwt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from typing import Annotated 4 | 5 | from fastapi import Depends, Request 6 | from fastapi.security import OAuth2PasswordBearer 7 | from fastapi.security.utils import get_authorization_scheme_param 8 | from jose import ExpiredSignatureError, JWTError, jwt 9 | from pwdlib import PasswordHash 10 | from pwdlib.hashers.bcrypt import BcryptHasher 11 | 12 | from backend.app.admin.model import User 13 | from backend.common.exception.errors import AuthorizationError, TokenError 14 | from backend.core.conf import settings 15 | from backend.database.db import CurrentSession 16 | 17 | oauth2_schema = OAuth2PasswordBearer(tokenUrl=settings.TOKEN_URL_SWAGGER) 18 | 19 | password_hash = PasswordHash((BcryptHasher(),)) 20 | 21 | 22 | def get_hash_password(password: str, salt: bytes | None) -> str: 23 | """ 24 | Encrypt passwords using the hash algorithm 25 | 26 | :param password: 27 | :param salt: 28 | :return: 29 | """ 30 | return password_hash.hash(password, salt=salt) 31 | 32 | 33 | def password_verify(plain_password: str, hashed_password: str) -> bool: 34 | """ 35 | Password verification 36 | 37 | :param plain_password: The password to verify 38 | :param hashed_password: The hash ciphers to compare 39 | :return: 40 | """ 41 | return password_hash.verify(plain_password, hashed_password) 42 | 43 | 44 | def create_access_token(sub: str) -> str: 45 | """ 46 | Generate encryption token 47 | 48 | :param sub: The subject/userid of the JWT 49 | :return: 50 | """ 51 | to_encode = {'sub': sub} 52 | access_token = jwt.encode(to_encode, settings.TOKEN_SECRET_KEY, settings.TOKEN_ALGORITHM) 53 | return access_token 54 | 55 | 56 | def get_token(request: Request) -> str: 57 | """ 58 | Get token for request header 59 | 60 | :return: 61 | """ 62 | authorization = request.headers.get('Authorization') 63 | scheme, token = get_authorization_scheme_param(authorization) 64 | if not authorization or scheme.lower() != 'bearer': 65 | raise TokenError(msg='Token 无效') 66 | return token 67 | 68 | 69 | def jwt_decode(token: str) -> int: 70 | """ 71 | Decode token 72 | 73 | :param token: 74 | :return: 75 | """ 76 | try: 77 | payload = jwt.decode(token, settings.TOKEN_SECRET_KEY, algorithms=[settings.TOKEN_ALGORITHM]) 78 | user_id = int(payload.get('sub')) 79 | if not user_id: 80 | raise TokenError(msg='Token 无效') 81 | except ExpiredSignatureError: 82 | raise TokenError(msg='Token 已过期') 83 | except (JWTError, Exception): 84 | raise TokenError(msg='Token 无效') 85 | return user_id 86 | 87 | 88 | async def get_current_user(db: CurrentSession, token: str = Depends(oauth2_schema)) -> User: 89 | """ 90 | 通过 token 获取当前用户 91 | 92 | :param db: 93 | :param token: 94 | :return: 95 | """ 96 | user_id = jwt_decode(token) 97 | from backend.app.admin.crud.crud_user import user_dao 98 | 99 | user = await user_dao.get(db, user_id) 100 | if not user: 101 | raise TokenError(msg='Token 无效') 102 | if not user.status: 103 | raise AuthorizationError(msg='用户已被锁定,请联系系统管理员') 104 | return user 105 | 106 | 107 | def superuser_verify(user: User): 108 | """ 109 | 验证当前用户是否为超级用户 110 | 111 | :param user: 112 | :return: 113 | """ 114 | superuser = user.is_superuser 115 | if not superuser: 116 | raise AuthorizationError 117 | return superuser 118 | 119 | 120 | # 用户依赖注入 121 | CurrentUser = Annotated[User, Depends(get_current_user)] 122 | # 权限依赖注入 123 | DependsJwtAuth = Depends(get_current_user) 124 | -------------------------------------------------------------------------------- /backend/core/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/core/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from functools import lru_cache 4 | from typing import Literal 5 | 6 | from pydantic import model_validator 7 | from pydantic_settings import BaseSettings, SettingsConfigDict 8 | 9 | from backend.core.path_conf import BASE_PATH 10 | 11 | 12 | class Settings(BaseSettings): 13 | """全局配置""" 14 | 15 | model_config = SettingsConfigDict( 16 | env_file=f'{BASE_PATH}/.env', 17 | env_file_encoding='utf-8', 18 | extra='ignore', 19 | case_sensitive=True, 20 | ) 21 | 22 | # .env 环境 23 | ENVIRONMENT: Literal['dev', 'pro'] 24 | 25 | # .env 数据库 26 | DATABASE_HOST: str 27 | DATABASE_PORT: int 28 | DATABASE_USER: str 29 | DATABASE_PASSWORD: str 30 | 31 | # .env Redis 32 | REDIS_HOST: str 33 | REDIS_PORT: int 34 | REDIS_PASSWORD: str 35 | REDIS_DATABASE: int 36 | 37 | # .env Token 38 | TOKEN_SECRET_KEY: str # 密钥 secrets.token_urlsafe(32) 39 | # FastAPI 40 | FASTAPI_API_V1_PATH: str = '/api/v1' 41 | FASTAPI_TITLE: str = 'FastAPI' 42 | FASTAPI_VERSION: str = '0.0.1' 43 | FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture' 44 | FASTAPI_DOCS_URL: str = '/docs' 45 | FASTAPI_REDOC_URL: str = '/redoc' 46 | FASTAPI_OPENAPI_URL: str | None = '/openapi' 47 | FASTAPI_STATIC_FILES: bool = False 48 | 49 | # MYSQL 50 | DATABASE_ECHO: bool = False 51 | DATABASE_POOL_ECHO: bool = False 52 | DATABASE_SCHEMA: str = 'fsm' 53 | DATABASE_CHARSET: str = 'utf8mb4' 54 | 55 | # Redis 56 | REDIS_TIMEOUT: int = 10 57 | 58 | # Token 59 | TOKEN_ALGORITHM: str = 'HS256' # 算法 60 | TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1 # 过期时间,单位:秒 61 | TOKEN_URL_SWAGGER: str = f'{FASTAPI_API_V1_PATH}/auth/login/swagger' 62 | 63 | # Log 64 | LOG_STD_LEVEL: str = 'INFO' 65 | LOG_ACCESS_FILE_LEVEL: str = 'INFO' 66 | LOG_ERROR_FILE_LEVEL: str = 'ERROR' 67 | LOG_STD_FORMAT: str = '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}' 68 | LOG_FILE_FORMAT: str = '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}' 69 | LOG_ACCESS_FILENAME: str = 'fba_access.log' 70 | LOG_ERROR_FILENAME: str = 'fba_error.log' 71 | 72 | # CORS 73 | CORS_ALLOWED_ORIGINS: list[str] = [ 74 | 'http://127.0.0.1:8000', 75 | ] 76 | CORS_EXPOSE_HEADERS: list[str] = [ 77 | '*', 78 | ] 79 | 80 | # Captcha 81 | CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha' 82 | CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 过期时间,单位:秒 83 | 84 | # 中间件 85 | MIDDLEWARE_CORS: bool = True 86 | MIDDLEWARE_ACCESS: bool = True 87 | 88 | # DateTime 89 | DATETIME_TIMEZONE: str = 'Asia/Shanghai' 90 | DATETIME_FORMAT: str = '%Y-%m-%d %H:%M:%S' 91 | 92 | # Request limiter 93 | REQUEST_LIMITER_REDIS_PREFIX: str = 'fba:limiter' 94 | 95 | # Demo mode (Only GET, OPTIONS requests are allowed) 96 | DEMO_MODE: bool = False 97 | DEMO_MODE_EXCLUDE: set[tuple[str, str]] = { 98 | ('POST', f'{FASTAPI_API_V1_PATH}/auth/login'), 99 | ('POST', f'{FASTAPI_API_V1_PATH}/auth/logout'), 100 | ('GET', f'{FASTAPI_API_V1_PATH}/auth/captcha'), 101 | } 102 | 103 | @model_validator(mode='before') 104 | @classmethod 105 | def validator_api_url(cls, values): 106 | if values['ENVIRONMENT'] == 'pro': 107 | values['FASTAPI_OPENAPI_URL'] = None 108 | values['FASTAPI_STATIC_FILES'] = False 109 | return values 110 | 111 | 112 | @lru_cache 113 | def get_settings(): 114 | """读取配置优化写法""" 115 | return Settings() 116 | 117 | 118 | settings = get_settings() 119 | -------------------------------------------------------------------------------- /backend/core/path_conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from pathlib import Path 4 | 5 | # 项目根目录 6 | BASE_PATH = Path(__file__).resolve().parent.parent 7 | 8 | # alembic 迁移文件存放路径 9 | ALEMBIC_VERSION_DIR = BASE_PATH / 'alembic' / 'versions' 10 | 11 | # 日志文件路径 12 | LOG_DIR = BASE_PATH / 'log' 13 | 14 | # 静态资源目录 15 | STATIC_DIR = BASE_PATH / 'static' 16 | -------------------------------------------------------------------------------- /backend/core/registrar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import os.path 4 | from contextlib import asynccontextmanager 5 | 6 | from fastapi import FastAPI, Depends 7 | from fastapi_limiter import FastAPILimiter 8 | from fastapi_pagination import add_pagination 9 | 10 | from backend.app.router import route 11 | from backend.common.exception.exception_handler import register_exception 12 | from backend.common.log import setup_logging, set_custom_logfile 13 | from backend.core.path_conf import STATIC_DIR 14 | from backend.database.redis import redis_client 15 | from backend.core.conf import settings 16 | from backend.database.db import create_table 17 | from backend.utils.demo_site import demo_site 18 | from backend.utils.health_check import http_limit_callback, ensure_unique_route_names 19 | from backend.utils.openapi import simplify_operation_ids 20 | 21 | 22 | @asynccontextmanager 23 | async def register_init(app: FastAPI): 24 | """ 25 | 启动初始化 26 | 27 | :return: 28 | """ 29 | # 创建数据库表 30 | await create_table() 31 | # 连接 redis 32 | await redis_client.open() 33 | # 初始化 limiter 34 | await FastAPILimiter.init( 35 | redis_client, 36 | prefix=settings.REQUEST_LIMITER_REDIS_PREFIX, 37 | http_callback=http_limit_callback, 38 | ) 39 | 40 | yield 41 | 42 | # 关闭 redis 连接 43 | await redis_client.close() 44 | # 关闭 limiter 45 | await FastAPILimiter.close() 46 | 47 | 48 | def register_app(): 49 | # FastAPI 50 | app = FastAPI( 51 | title=settings.FASTAPI_TITLE, 52 | version=settings.FASTAPI_VERSION, 53 | description=settings.FASTAPI_DESCRIPTION, 54 | docs_url=settings.FASTAPI_DOCS_URL, 55 | redoc_url=settings.FASTAPI_REDOC_URL, 56 | openapi_url=settings.FASTAPI_OPENAPI_URL, 57 | lifespan=register_init, 58 | ) 59 | 60 | # 注册组件 61 | register_logger() 62 | register_static_file(app) 63 | register_middleware(app) 64 | register_router(app) 65 | register_page(app) 66 | register_exception(app) 67 | 68 | return app 69 | 70 | 71 | def register_logger() -> None: 72 | """ 73 | 系统日志 74 | 75 | :return: 76 | """ 77 | setup_logging() 78 | set_custom_logfile() 79 | 80 | 81 | def register_static_file(app: FastAPI): 82 | """ 83 | 静态文件交互开发模式, 生产将自动关闭,生产必须使用 nginx 静态资源服务 84 | 85 | :param app: 86 | :return: 87 | """ 88 | if settings.FASTAPI_STATIC_FILES: 89 | from fastapi.staticfiles import StaticFiles 90 | 91 | if not os.path.exists(STATIC_DIR): 92 | os.makedirs(STATIC_DIR) 93 | 94 | app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') 95 | 96 | 97 | def register_middleware(app) -> None: 98 | # 接口访问日志 99 | if settings.MIDDLEWARE_ACCESS: 100 | from backend.middleware.access_middle import AccessMiddleware 101 | 102 | app.add_middleware(AccessMiddleware) 103 | # 跨域 104 | if settings.MIDDLEWARE_CORS: 105 | from starlette.middleware.cors import CORSMiddleware 106 | 107 | app.add_middleware( 108 | CORSMiddleware, 109 | allow_origins=['*'], 110 | allow_credentials=True, 111 | allow_methods=['*'], 112 | allow_headers=['*'], 113 | ) 114 | 115 | 116 | def register_router(app: FastAPI): 117 | """ 118 | 路由 119 | 120 | :param app: FastAPI 121 | :return: 122 | """ 123 | dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None 124 | 125 | # API 126 | app.include_router(route, dependencies=dependencies) 127 | 128 | # Extra 129 | ensure_unique_route_names(app) 130 | simplify_operation_ids(app) 131 | 132 | 133 | def register_page(app: FastAPI): 134 | """ 135 | 分页查询 136 | 137 | :param app: 138 | :return: 139 | """ 140 | add_pagination(app) 141 | -------------------------------------------------------------------------------- /backend/database/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/database/db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | 5 | from typing import Annotated 6 | from uuid import uuid4 7 | 8 | from fastapi import Depends 9 | from sqlalchemy import URL 10 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine, AsyncEngine 11 | 12 | from backend.common.log import log 13 | from backend.common.model import MappedBase 14 | from backend.core.conf import settings 15 | 16 | 17 | def create_async_engine_and_session(url: str | URL) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: 18 | try: 19 | # 数据库引擎 20 | engine = create_async_engine( 21 | url, 22 | echo=settings.DATABASE_ECHO, 23 | echo_pool=settings.DATABASE_POOL_ECHO, 24 | future=True, 25 | # 中等并发 26 | pool_size=10, # 低:- 高:+ 27 | max_overflow=20, # 低:- 高:+ 28 | pool_timeout=30, # 低:+ 高:- 29 | pool_recycle=3600, # 低:+ 高:- 30 | pool_pre_ping=True, # 低:False 高:True 31 | pool_use_lifo=False, # 低:False 高:True 32 | ) 33 | except Exception as e: 34 | log.error('❌ 数据库链接失败 {}', e) 35 | sys.exit() 36 | else: 37 | db_session = async_sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) 38 | return engine, db_session 39 | 40 | 41 | async def get_db(): 42 | """session 生成器""" 43 | async with async_db_session() as session: 44 | yield session 45 | 46 | 47 | async def create_table() -> None: 48 | """创建数据库表""" 49 | async with async_engine.begin() as coon: 50 | await coon.run_sync(MappedBase.metadata.create_all) 51 | 52 | 53 | def uuid4_str() -> str: 54 | """数据库引擎 UUID 类型兼容性解决方案""" 55 | return str(uuid4()) 56 | 57 | 58 | SQLALCHEMY_DATABASE_URL = ( 59 | f'mysql+asyncmy://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:' 60 | f'{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}?charset={settings.DATABASE_CHARSET}' 61 | ) 62 | 63 | async_engine, async_db_session = create_async_engine_and_session(SQLALCHEMY_DATABASE_URL) 64 | # Session Annotated 65 | CurrentSession = Annotated[AsyncSession, Depends(get_db)] 66 | -------------------------------------------------------------------------------- /backend/database/redis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | 5 | from redis.asyncio import Redis 6 | from redis.exceptions import AuthenticationError, TimeoutError 7 | 8 | from backend.common.log import log 9 | from backend.core.conf import settings 10 | 11 | 12 | class RedisCli(Redis): 13 | def __init__(self): 14 | super(RedisCli, self).__init__( 15 | host=settings.REDIS_HOST, 16 | port=settings.REDIS_PORT, 17 | password=settings.REDIS_PASSWORD, 18 | db=settings.REDIS_DATABASE, 19 | socket_timeout=settings.REDIS_TIMEOUT, 20 | decode_responses=True, # 转码 utf-8 21 | ) 22 | 23 | async def open(self): 24 | """ 25 | 触发初始化连接 26 | 27 | :return: 28 | """ 29 | try: 30 | await self.ping() 31 | except TimeoutError: 32 | log.error('❌ 数据库 redis 连接超时') 33 | sys.exit() 34 | except AuthenticationError: 35 | log.error('❌ 数据库 redis 连接认证失败') 36 | sys.exit() 37 | except Exception as e: 38 | log.error('❌ 数据库 redis 连接异常 {}', e) 39 | sys.exit() 40 | 41 | async def delete_prefix(self, prefix: str, exclude: str | list = None): 42 | """ 43 | 删除指定前缀的所有key 44 | 45 | :param prefix: 46 | :param exclude: 47 | :return: 48 | """ 49 | keys = [] 50 | async for key in self.scan_iter(match=f'{prefix}*'): 51 | if isinstance(exclude, str): 52 | if key != exclude: 53 | keys.append(key) 54 | elif isinstance(exclude, list): 55 | if key not in exclude: 56 | keys.append(key) 57 | else: 58 | keys.append(key) 59 | if keys: 60 | await self.delete(*keys) 61 | 62 | 63 | # 创建 redis 客户端单例 64 | redis_client: RedisCli = RedisCli() 65 | -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from pathlib import Path 4 | 5 | import uvicorn 6 | 7 | from backend.core.registrar import register_app 8 | 9 | app = register_app() 10 | 11 | 12 | if __name__ == '__main__': 13 | try: 14 | config = uvicorn.Config(app=f'{Path(__file__).stem}:app', reload=True) 15 | server = uvicorn.Server(config) 16 | server.run() 17 | except Exception as e: 18 | raise e 19 | -------------------------------------------------------------------------------- /backend/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/middleware/access_middle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import Request, Response 4 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 5 | 6 | from backend.common.log import log 7 | from backend.utils.timezone import timezone 8 | 9 | 10 | class AccessMiddleware(BaseHTTPMiddleware): 11 | """请求日志中间件""" 12 | 13 | async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: 14 | start_time = timezone.now() 15 | response = await call_next(request) 16 | end_time = timezone.now() 17 | log.info( 18 | f'{request.client.host: <15} | {request.method: <8} | {response.status_code: <6} | ' 19 | f'{request.url.path} | {round((end_time - start_time).total_seconds(), 3) * 1000.0}ms' 20 | ) 21 | return response 22 | -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-practices/fastapi_sqlalchemy_mysql/00b2dd99e27580d8c3db40ccc12946def9e3ec3d/backend/tests/__init__.py -------------------------------------------------------------------------------- /backend/utils/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/utils/demo_site.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import Request 4 | 5 | from backend.common.exception import errors 6 | from backend.core.conf import settings 7 | 8 | 9 | async def demo_site(request: Request): 10 | """演示站点""" 11 | 12 | method = request.method 13 | path = request.url.path 14 | if ( 15 | settings.DEMO_MODE 16 | and method != 'GET' 17 | and method != 'OPTIONS' 18 | and (method, path) not in settings.DEMO_MODE_EXCLUDE 19 | ): 20 | raise errors.ForbiddenError(msg='演示环境下禁止执行此操作') 21 | -------------------------------------------------------------------------------- /backend/utils/health_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from math import ceil 4 | 5 | from fastapi import FastAPI, Request, Response 6 | from fastapi.routing import APIRoute 7 | 8 | from backend.common.exception import errors 9 | 10 | 11 | def ensure_unique_route_names(app: FastAPI) -> None: 12 | """ 13 | 检查路由名称是否唯一 14 | 15 | :param app: 16 | :return: 17 | """ 18 | temp_routes = set() 19 | for route in app.routes: 20 | if isinstance(route, APIRoute): 21 | if route.name in temp_routes: 22 | raise ValueError(f'Non-unique route name: {route.name}') 23 | temp_routes.add(route.name) 24 | 25 | 26 | async def http_limit_callback(request: Request, response: Response, expire: int): 27 | """ 28 | 请求限制时的默认回调函数 29 | 30 | :param request: 31 | :param response: 32 | :param expire: 剩余毫秒 33 | :return: 34 | """ 35 | expires = ceil(expire / 1000) 36 | raise errors.HTTPError(code=429, msg='请求过于频繁,请稍后重试', headers={'Retry-After': str(expires)}) 37 | -------------------------------------------------------------------------------- /backend/utils/openapi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import FastAPI 4 | from fastapi.routing import APIRoute 5 | 6 | 7 | def simplify_operation_ids(app: FastAPI) -> None: 8 | """ 9 | 简化操作 ID,以便生成的客户端具有更简单的 api 函数名称 10 | 11 | :param app: 12 | :return: 13 | """ 14 | for route in app.routes: 15 | if isinstance(route, APIRoute): 16 | route.operation_id = route.name 17 | -------------------------------------------------------------------------------- /backend/utils/re_verify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import re 4 | 5 | 6 | def search_string(pattern: str, text: str) -> bool: 7 | """ 8 | 全字段正则匹配 9 | 10 | :param pattern: 正则表达式模式 11 | :param text: 待匹配的文本 12 | :return: 13 | """ 14 | if not pattern or not text: 15 | return False 16 | 17 | result = re.search(pattern, text) 18 | return result is not None 19 | 20 | 21 | def match_string(pattern: str, text: str) -> bool: 22 | """ 23 | 从字段开头正则匹配 24 | 25 | :param pattern: 正则表达式模式 26 | :param text: 待匹配的文本 27 | :return: 28 | """ 29 | if not pattern or not text: 30 | return False 31 | 32 | result = re.match(pattern, text) 33 | return result is not None 34 | 35 | 36 | def is_phone(text: str) -> bool: 37 | """ 38 | 检查手机号码格式 39 | 40 | :param text: 待检查的手机号码 41 | :return: 42 | """ 43 | if not text: 44 | return False 45 | 46 | phone_pattern = r'^1[3-9]\d{9}$' 47 | return match_string(phone_pattern, text) 48 | -------------------------------------------------------------------------------- /backend/utils/serializers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from decimal import Decimal 4 | from typing import Any, Sequence, TypeVar 5 | 6 | from fastapi.encoders import decimal_encoder 7 | from msgspec import json 8 | from sqlalchemy import Row, RowMapping 9 | from sqlalchemy.orm import ColumnProperty, SynonymProperty, class_mapper 10 | from starlette.responses import JSONResponse 11 | 12 | RowData = Row | RowMapping | Any 13 | 14 | R = TypeVar('R', bound=RowData) 15 | 16 | 17 | def select_columns_serialize(row: R) -> dict[str, Any]: 18 | """ 19 | 序列化 SQLAlchemy 查询表的列,不包含关联列 20 | 21 | :param row: SQLAlchemy 查询结果行 22 | :return: 23 | """ 24 | result = {} 25 | for column in row.__table__.columns.keys(): 26 | value = getattr(row, column) 27 | if isinstance(value, Decimal): 28 | value = decimal_encoder(value) 29 | result[column] = value 30 | return result 31 | 32 | 33 | def select_list_serialize(row: Sequence[R]) -> list[dict[str, Any]]: 34 | """ 35 | 序列化 SQLAlchemy 查询列表 36 | 37 | :param row: SQLAlchemy 查询结果列表 38 | :return: 39 | """ 40 | return [select_columns_serialize(item) for item in row] 41 | 42 | 43 | def select_as_dict(row: R, use_alias: bool = False) -> dict[str, Any]: 44 | """ 45 | 将 SQLAlchemy 查询结果转换为字典,可以包含关联数据 46 | 47 | :param row: SQLAlchemy 查询结果行 48 | :param use_alias: 是否使用别名作为列名 49 | :return: 50 | """ 51 | if not use_alias: 52 | result = row.__dict__ 53 | if '_sa_instance_state' in result: 54 | del result['_sa_instance_state'] 55 | else: 56 | result = {} 57 | mapper = class_mapper(row.__class__) # type: ignore 58 | for prop in mapper.iterate_properties: 59 | if isinstance(prop, (ColumnProperty, SynonymProperty)): 60 | key = prop.key 61 | result[key] = getattr(row, key) 62 | 63 | return result 64 | 65 | 66 | class MsgSpecJSONResponse(JSONResponse): 67 | """ 68 | 使用高性能的 msgspec 库将数据序列化为 JSON 的响应类 69 | """ 70 | 71 | def render(self, content: Any) -> bytes: 72 | return json.encode(content) 73 | -------------------------------------------------------------------------------- /backend/utils/timezone.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import zoneinfo 4 | 5 | from datetime import datetime 6 | from datetime import timezone as datetime_timezone 7 | 8 | from backend.core.conf import settings 9 | 10 | 11 | class TimeZone: 12 | def __init__(self, tz: str = settings.DATETIME_TIMEZONE) -> None: 13 | """ 14 | 初始化时区转换器 15 | 16 | :param tz: 时区名称,默认为 settings.DATETIME_TIMEZONE 17 | :return: 18 | """ 19 | self.tz_info = zoneinfo.ZoneInfo(tz) 20 | 21 | def now(self) -> datetime: 22 | """获取当前时区时间""" 23 | return datetime.now(self.tz_info) 24 | 25 | def f_datetime(self, dt: datetime) -> datetime: 26 | """ 27 | 将 datetime 对象转换为当前时区时间 28 | 29 | :param dt: 需要转换的 datetime 对象 30 | :return: 31 | """ 32 | return dt.astimezone(self.tz_info) 33 | 34 | def f_str(self, date_str: str, format_str: str = settings.DATETIME_FORMAT) -> datetime: 35 | """ 36 | 将时间字符串转换为当前时区的 datetime 对象 37 | 38 | :param date_str: 时间字符串 39 | :param format_str: 时间格式字符串,默认为 settings.DATETIME_FORMAT 40 | :return: 41 | """ 42 | return datetime.strptime(date_str, format_str).replace(tzinfo=self.tz_info) 43 | 44 | @staticmethod 45 | def t_str(dt: datetime, format_str: str = settings.DATETIME_FORMAT) -> str: 46 | """ 47 | 将 datetime 对象转换为指定格式的时间字符串 48 | 49 | :param dt: datetime 对象 50 | :param format_str: 时间格式字符串,默认为 settings.DATETIME_FORMAT 51 | :return: 52 | """ 53 | return dt.strftime(format_str) 54 | 55 | @staticmethod 56 | def f_utc(dt: datetime) -> datetime: 57 | """ 58 | 将 datetime 对象转换为 UTC (GMT) 时区时间 59 | 60 | :param dt: 需要转换的 datetime 对象 61 | :return: 62 | """ 63 | return dt.astimezone(datetime_timezone.utc) 64 | 65 | 66 | timezone: TimeZone = TimeZone() 67 | -------------------------------------------------------------------------------- /deploy/docker-compose/.env.server: -------------------------------------------------------------------------------- 1 | # Env: dev、pro 2 | ENVIRONMENT='dev' 3 | # MySQL 4 | DATABASE_HOST='fsm_mysql' 5 | DATABASE_PORT=3306 6 | DATABASE_USER='root' 7 | DATABASE_PASSWORD='123456' 8 | # Redis 9 | REDIS_HOST='fsm_redis' 10 | REDIS_PORT=6379 11 | REDIS_PASSWORD='' 12 | REDIS_DATABASE=0 13 | # Token 14 | TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' 15 | -------------------------------------------------------------------------------- /deploy/docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fsm_server: 3 | build: 4 | context: ../../ 5 | dockerfile: Dockerfile 6 | image: fsm_server:latest 7 | container_name: fsm_server 8 | restart: always 9 | depends_on: 10 | - fsm_mysql 11 | - fsm_redis 12 | volumes: 13 | - fsm_static:/fsm/backend/static 14 | networks: 15 | - fsm_network 16 | command: 17 | - bash 18 | - -c 19 | - | 20 | wait-for-it -s fsm_mysql:3306 -s fsm_redis:6379 -t 300 21 | supervisord -c /etc/supervisor/supervisord.conf 22 | supervisorctl restart 23 | 24 | fsm_mysql: 25 | image: mysql:8.0.29 26 | ports: 27 | - "3306:3306" 28 | container_name: fsm_mysql 29 | restart: always 30 | environment: 31 | MYSQL_DATABASE: fsm 32 | MYSQL_ROOT_PASSWORD: 123456 33 | TZ: Asia/Shanghai 34 | volumes: 35 | - fsm_mysql:/var/lib/mysql 36 | networks: 37 | - fsm_network 38 | command: 39 | --default-authentication-plugin=mysql_native_password 40 | --character-set-server=utf8mb4 41 | --collation-server=utf8mb4_general_ci 42 | --lower_case_table_names=1 43 | 44 | fsm_redis: 45 | image: redis:6.2.7 46 | ports: 47 | - "6379:6379" 48 | container_name: fsm_redis 49 | restart: always 50 | environment: 51 | - TZ=Asia/Shanghai 52 | volumes: 53 | - fsm_redis:/var/lib/redis 54 | networks: 55 | - fsm_network 56 | 57 | fsm_nginx: 58 | image: nginx:stable 59 | ports: 60 | - "8000:80" 61 | container_name: fsm_nginx 62 | restart: always 63 | depends_on: 64 | - fsm_server 65 | volumes: 66 | - ../nginx.conf:/etc/nginx/conf.d/default.conf:ro 67 | - fsm_static:/www/fsm_server/backend/static 68 | networks: 69 | - fsm_network 70 | 71 | networks: 72 | fsm_network: 73 | name: fsm_network 74 | driver: bridge 75 | ipam: 76 | driver: default 77 | config: 78 | - subnet: 172.10.10.0/24 79 | 80 | volumes: 81 | fsm_mysql: 82 | name: fsm_mysql 83 | fsm_redis: 84 | name: fsm_redis 85 | fsm_static: 86 | name: fsm_static 87 | -------------------------------------------------------------------------------- /deploy/fastapi_server.conf: -------------------------------------------------------------------------------- 1 | [program:fastapi_server] 2 | directory=/fsm 3 | command=/usr/local/bin/gunicorn -c /fsm/deploy/gunicorn.conf.py main:app 4 | user=root 5 | autostart=true 6 | autorestart=true 7 | startretries=5 8 | redirect_stderr=true 9 | stdout_logfile=/var/log/fastapi_server/fsm_server.log 10 | -------------------------------------------------------------------------------- /deploy/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | # fmt: off 2 | # 监听内网端口 3 | bind = '0.0.0.0:8001' 4 | 5 | # 工作目录 6 | chdir = '/fsm/backend/' 7 | 8 | # 并行工作进程数 9 | workers = 1 10 | 11 | # 监听队列 12 | backlog = 512 13 | 14 | # 超时时间 15 | timeout = 120 16 | 17 | # 设置守护进程,将进程交给 supervisor 管理;如果设置为 True 时,supervisor 启动日志为: 18 | # gave up: fastapi_server entered FATAL state, too many start retries too quickly 19 | # 则需要将此改为: False 20 | daemon = False 21 | 22 | # 工作模式协程 23 | worker_class = 'uvicorn.workers.UvicornWorker' 24 | 25 | # 设置最大并发量 26 | worker_connections = 2000 27 | 28 | # 设置进程文件目录 29 | pidfile = '/fsm/gunicorn.pid' 30 | 31 | # 设置访问日志和错误信息日志路径 32 | accesslog = '/var/log/fastapi_server/gunicorn_access.log' 33 | errorlog = '/var/log/fastapi_server/gunicorn_error.log' 34 | 35 | # 设置这个值为true 才会把打印信息记录到错误日志里 36 | capture_output = True 37 | 38 | # 设置日志记录水平 39 | loglevel = 'debug' 40 | 41 | # python程序 42 | pythonpath = '/usr/local/lib/python3.10/site-packages' 43 | 44 | # 启动 gunicorn -c gunicorn.conf.py main:app 45 | -------------------------------------------------------------------------------- /deploy/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | server_name 127.0.0.1; 5 | 6 | root /fsm; 7 | 8 | client_max_body_size 5M; 9 | client_body_buffer_size 5M; 10 | 11 | gzip on; 12 | gzip_comp_level 2; 13 | gzip_types text/plain text/css text/javascript application/javascript application/x-javascript application/xml application/x-httpd-php image/jpeg image/gif image/png; 14 | gzip_vary on; 15 | 16 | keepalive_timeout 300; 17 | 18 | location / { 19 | proxy_pass http://fsm_server:8001; 20 | 21 | proxy_set_header Host $http_host; 22 | proxy_set_header X-Real-IP $remote_addr; 23 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 24 | proxy_set_header X-Forwarded-Proto $scheme; 25 | proxy_connect_timeout 300s; 26 | proxy_send_timeout 300s; 27 | proxy_read_timeout 300s; 28 | } 29 | 30 | location /static { 31 | alias /www/fsm_server/backend/static; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /deploy/supervisor.conf: -------------------------------------------------------------------------------- 1 | ; Sample supervisor config file. 2 | ; 3 | ; For more information on the config file, please see: 4 | ; http://supervisord.org/configuration.html 5 | ; 6 | ; Notes: 7 | ; - Shell expansion ("~" or "$HOME") is not supported. Environment 8 | ; variables can be expanded using this syntax: "%(ENV_HOME)s". 9 | ; - Quotes around values are not supported, except in the case of 10 | ; the environment= options as shown below. 11 | ; - Comments must have a leading space: "a=b ;comment" not "a=b;comment". 12 | ; - Command will be truncated if it looks like a config file comment, e.g. 13 | ; "command=bash -c 'foo ; bar'" will truncate to "command=bash -c 'foo ". 14 | ; 15 | ; Warning: 16 | ; Paths throughout this example file use /tmp because it is available on most 17 | ; systems. You will likely need to change these to locations more appropriate 18 | ; for your system. Some systems periodically delete older files in /tmp. 19 | ; Notably, if the socket file defined in the [unix_http_server] section below 20 | ; is deleted, supervisorctl will be unable to connect to supervisord. 21 | 22 | [unix_http_server] 23 | file=/tmp/supervisor.sock ; the path to the socket file 24 | ;chmod=0700 ; socket file mode (default 0700) 25 | ;chown=nobody:nogroup ; socket file uid:gid owner 26 | ;username=user ; default is no username (open server) 27 | ;password=123 ; default is no password (open server) 28 | 29 | ; Security Warning: 30 | ; The inet HTTP server is not enabled by default. The inet HTTP server is 31 | ; enabled by uncommenting the [inet_http_server] section below. The inet 32 | ; HTTP server is intended for use within a trusted environment only. It 33 | ; should only be bound to localhost or only accessible from within an 34 | ; isolated, trusted network. The inet HTTP server does not support any 35 | ; form of encryption. The inet HTTP server does not use authentication 36 | ; by default (see the username= and password= options to add authentication). 37 | ; Never expose the inet HTTP server to the public internet. 38 | 39 | ;[inet_http_server] ; inet (TCP) server disabled by default 40 | ;port=127.0.0.1:9001 ; ip_address:port specifier, *:port for all iface 41 | ;username=user ; default is no username (open server) 42 | ;password=123 ; default is no password (open server) 43 | 44 | [supervisord] 45 | logfile=/var/log/supervisor/supervisord.log ; main log file; default $CWD/supervisord.log 46 | logfile_maxbytes=50MB ; max main logfile bytes b4 rotation; default 50MB 47 | logfile_backups=10 ; # of main logfile backups; 0 means none, default 10 48 | loglevel=info ; log level; default info; others: debug,warn,trace 49 | pidfile=/tmp/supervisord.pid ; supervisord pidfile; default supervisord.pid 50 | nodaemon=true ; start in foreground if true; default false 51 | silent=false ; no logs to stdout if true; default false 52 | minfds=1024 ; min. avail startup file descriptors; default 1024 53 | minprocs=200 ; min. avail process descriptors;default 200 54 | ;umask=022 ; process file creation umask; default 022 55 | user=root ; setuid to this UNIX account at startup; recommended if root 56 | ;identifier=supervisor ; supervisord identifier, default is 'supervisor' 57 | ;directory=/tmp ; default is not to cd during start 58 | ;nocleanup=true ; don't clean up tempfiles at start; default false 59 | ;childlogdir=/tmp ; 'AUTO' child log dir, default $TEMP 60 | ;environment=KEY="value" ; key value pairs to add to environment 61 | ;strip_ansi=false ; strip ansi escape codes in logs; def. false 62 | 63 | ; The rpcinterface:supervisor section must remain in the config file for 64 | ; RPC (supervisorctl/web interface) to work. Additional interfaces may be 65 | ; added by defining them in separate [rpcinterface:x] sections. 66 | 67 | [rpcinterface:supervisor] 68 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 69 | 70 | ; The supervisorctl section configures how supervisorctl will connect to 71 | ; supervisord. configure it match the settings in either the unix_http_server 72 | ; or inet_http_server section. 73 | 74 | [supervisorctl] 75 | serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket 76 | ;serverurl=http://127.0.0.1:9001 ; use an http:// url to specify an inet socket 77 | ;username=chris ; should be same as in [*_http_server] if set 78 | ;password=123 ; should be same as in [*_http_server] if set 79 | ;prompt=mysupervisor ; cmd line prompt (default "supervisor") 80 | ;history_file=~/.sc_history ; use readline history if available 81 | 82 | ; The sample program section below shows all possible program subsection values. 83 | ; Create one or more 'real' program: sections to be able to control them under 84 | ; supervisor. 85 | 86 | ;[program:theprogramname] 87 | ;command=/bin/cat ; the program (relative uses PATH, can take args) 88 | ;process_name=%(program_name)s ; process_name expr (default %(program_name)s) 89 | ;numprocs=1 ; number of processes copies to start (def 1) 90 | ;directory=/tmp ; directory to cwd to before exec (def no cwd) 91 | ;umask=022 ; umask for process (default None) 92 | ;priority=999 ; the relative start priority (default 999) 93 | ;autostart=true ; start at supervisord start (default: true) 94 | ;startsecs=1 ; # of secs prog must stay up to be running (def. 1) 95 | ;startretries=3 ; max # of serial start failures when starting (default 3) 96 | ;autorestart=unexpected ; when to restart if exited after running (def: unexpected) 97 | ;exitcodes=0 ; 'expected' exit codes used with autorestart (default 0) 98 | ;stopsignal=QUIT ; signal used to kill process (default TERM) 99 | ;stopwaitsecs=10 ; max num secs to wait b4 SIGKILL (default 10) 100 | ;stopasgroup=false ; send stop signal to the UNIX process group (default false) 101 | ;killasgroup=false ; SIGKILL the UNIX process group (def false) 102 | ;user=root ; setuid to this UNIX account to run the program 103 | ;redirect_stderr=true ; redirect proc stderr to stdout (default false) 104 | ;stdout_logfile=/a/path ; stdout log path, NONE for none; default AUTO 105 | ;stdout_logfile_maxbytes=1MB ; max # logfile bytes b4 rotation (default 50MB) 106 | ;stdout_logfile_backups=10 ; # of stdout logfile backups (0 means none, default 10) 107 | ;stdout_capture_maxbytes=1MB ; number of bytes in 'capturemode' (default 0) 108 | ;stdout_events_enabled=false ; emit events on stdout writes (default false) 109 | ;stdout_syslog=false ; send stdout to syslog with process name (default false) 110 | ;stderr_logfile=/a/path ; stderr log path, NONE for none; default AUTO 111 | ;stderr_logfile_maxbytes=1MB ; max # logfile bytes b4 rotation (default 50MB) 112 | ;stderr_logfile_backups=10 ; # of stderr logfile backups (0 means none, default 10) 113 | ;stderr_capture_maxbytes=1MB ; number of bytes in 'capturemode' (default 0) 114 | ;stderr_events_enabled=false ; emit events on stderr writes (default false) 115 | ;stderr_syslog=false ; send stderr to syslog with process name (default false) 116 | ;environment=A="1",B="2" ; process environment additions (def no adds) 117 | ;serverurl=AUTO ; override serverurl computation (childutils) 118 | 119 | ; The sample eventlistener section below shows all possible eventlistener 120 | ; subsection values. Create one or more 'real' eventlistener: sections to be 121 | ; able to handle event notifications sent by supervisord. 122 | 123 | ;[eventlistener:theeventlistenername] 124 | ;command=/bin/eventlistener ; the program (relative uses PATH, can take args) 125 | ;process_name=%(program_name)s ; process_name expr (default %(program_name)s) 126 | ;numprocs=1 ; number of processes copies to start (def 1) 127 | ;events=EVENT ; event notif. types to subscribe to (req'd) 128 | ;buffer_size=10 ; event buffer queue size (default 10) 129 | ;directory=/tmp ; directory to cwd to before exec (def no cwd) 130 | ;umask=022 ; umask for process (default None) 131 | ;priority=-1 ; the relative start priority (default -1) 132 | ;autostart=true ; start at supervisord start (default: true) 133 | ;startsecs=1 ; # of secs prog must stay up to be running (def. 1) 134 | ;startretries=3 ; max # of serial start failures when starting (default 3) 135 | ;autorestart=unexpected ; autorestart if exited after running (def: unexpected) 136 | ;exitcodes=0 ; 'expected' exit codes used with autorestart (default 0) 137 | ;stopsignal=QUIT ; signal used to kill process (default TERM) 138 | ;stopwaitsecs=10 ; max num secs to wait b4 SIGKILL (default 10) 139 | ;stopasgroup=false ; send stop signal to the UNIX process group (default false) 140 | ;killasgroup=false ; SIGKILL the UNIX process group (def false) 141 | ;user=chrism ; setuid to this UNIX account to run the program 142 | ;redirect_stderr=false ; redirect_stderr=true is not allowed for eventlisteners 143 | 144 | ;[group:thegroupname] 145 | ;programs=progname1,progname2 ; each refers to 'x' in [program:x] definitions 146 | ;priority=999 ; the relative start priority (default 999) 147 | 148 | ; The [include] section can just contain the "files" setting. This 149 | ; setting can list multiple files (separated by whitespace or 150 | ; newlines). It can also contain wildcards. The filenames are 151 | ; interpreted as relative to this file. Included files *cannot* 152 | ; include files themselves. 153 | 154 | [include] 155 | files = /etc/supervisor/conf.d/*.conf 156 | -------------------------------------------------------------------------------- /pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pre-commit run --all-files --verbose --show-diff-on-failure 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastapi_sqlalchemy_mysql" 3 | version = "0.0.1" 4 | description = "fastapi sqlalcehmy with mysql" 5 | authors = [ 6 | {name = "Wu Clan", email = "jianhengwu0407@gmail.com"}, 7 | ] 8 | dependencies = [ 9 | "alembic==1.13.1", 10 | "asyncmy==0.2.9", 11 | "bcrypt==4.1.3", 12 | "cryptography==42.0.7", 13 | "email_validator==2.1.1", 14 | "fast-captcha==0.2.1", 15 | "fastapi[all]==0.111.0", 16 | "fastapi-limiter==0.1.6", 17 | "fastapi-pagination==0.12.24", 18 | "loguru==0.7.2", 19 | "path==16.14.0", 20 | "python-jose==3.3.0", 21 | "python-multipart==0.0.9", 22 | "redis[hiredis]==5.0.4", 23 | "SQLAlchemy==2.0.30", 24 | "tzdata==2024.1", 25 | "sqlalchemy-crud-plus>=1.6.0", 26 | "pwdlib>=0.2.1", 27 | "msgspec>=0.18.6", 28 | ] 29 | requires-python = ">=3.10" 30 | readme = "README.md" 31 | license = {text = "MIT"} 32 | 33 | [dependency-groups] 34 | lint = [ 35 | "pre-commit>=4.0.0", 36 | ] 37 | server = [ 38 | "gunicorn>=21.2.0", 39 | "wait-for-it>=2.2.2", 40 | ] 41 | 42 | [tool.uv] 43 | package = false 44 | python-downloads = "manual" 45 | default-groups = ["lint"] 46 | 47 | [[tool.uv.index]] 48 | name = "aliyun" 49 | url = "https://mirrors.aliyun.com/pypi/simple" 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export -o requirements.txt --no-hashes 3 | alembic==1.13.1 4 | # via fastapi-sqlalchemy-mysql 5 | annotated-types==0.7.0 6 | # via pydantic 7 | anyio==4.9.0 8 | # via 9 | # httpx 10 | # starlette 11 | # watchfiles 12 | async-timeout==5.0.1 ; python_full_version < '3.11.3' 13 | # via redis 14 | asyncmy==0.2.9 15 | # via fastapi-sqlalchemy-mysql 16 | bcrypt==4.1.3 17 | # via fastapi-sqlalchemy-mysql 18 | certifi==2025.1.31 19 | # via 20 | # httpcore 21 | # httpx 22 | cffi==1.17.1 ; platform_python_implementation != 'PyPy' 23 | # via cryptography 24 | cfgv==3.4.0 25 | # via pre-commit 26 | click==8.1.8 27 | # via 28 | # rich-toolkit 29 | # typer 30 | # uvicorn 31 | colorama==0.4.6 ; sys_platform == 'win32' 32 | # via 33 | # click 34 | # loguru 35 | # uvicorn 36 | cryptography==42.0.7 37 | # via fastapi-sqlalchemy-mysql 38 | distlib==0.3.9 39 | # via virtualenv 40 | dnspython==2.7.0 41 | # via email-validator 42 | ecdsa==0.19.1 43 | # via python-jose 44 | email-validator==2.1.1 45 | # via 46 | # fastapi 47 | # fastapi-sqlalchemy-mysql 48 | exceptiongroup==1.2.2 ; python_full_version < '3.11' 49 | # via anyio 50 | fast-captcha==0.2.1 51 | # via fastapi-sqlalchemy-mysql 52 | fastapi==0.111.0 53 | # via 54 | # fastapi-limiter 55 | # fastapi-pagination 56 | # fastapi-sqlalchemy-mysql 57 | fastapi-cli==0.0.7 58 | # via fastapi 59 | fastapi-limiter==0.1.6 60 | # via fastapi-sqlalchemy-mysql 61 | fastapi-pagination==0.12.24 62 | # via fastapi-sqlalchemy-mysql 63 | filelock==3.18.0 64 | # via virtualenv 65 | greenlet==3.1.1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' 66 | # via sqlalchemy 67 | h11==0.14.0 68 | # via 69 | # httpcore 70 | # uvicorn 71 | hiredis==3.1.0 72 | # via redis 73 | httpcore==1.0.7 74 | # via httpx 75 | httptools==0.6.4 76 | # via uvicorn 77 | httpx==0.28.1 78 | # via fastapi 79 | identify==2.6.9 80 | # via pre-commit 81 | idna==3.10 82 | # via 83 | # anyio 84 | # email-validator 85 | # httpx 86 | itsdangerous==2.2.0 87 | # via fastapi 88 | jinja2==3.1.6 89 | # via fastapi 90 | loguru==0.7.2 91 | # via fastapi-sqlalchemy-mysql 92 | mako==1.3.9 93 | # via alembic 94 | markdown-it-py==3.0.0 95 | # via rich 96 | markupsafe==3.0.2 97 | # via 98 | # jinja2 99 | # mako 100 | mdurl==0.1.2 101 | # via markdown-it-py 102 | msgspec==0.19.0 103 | # via fastapi-sqlalchemy-mysql 104 | nodeenv==1.9.1 105 | # via pre-commit 106 | orjson==3.10.16 107 | # via fastapi 108 | path==16.14.0 109 | # via fastapi-sqlalchemy-mysql 110 | pillow==9.5.0 111 | # via fast-captcha 112 | platformdirs==4.3.7 113 | # via virtualenv 114 | pre-commit==4.2.0 115 | pwdlib==0.2.1 116 | # via fastapi-sqlalchemy-mysql 117 | pyasn1==0.6.1 118 | # via 119 | # python-jose 120 | # rsa 121 | pycparser==2.22 ; platform_python_implementation != 'PyPy' 122 | # via cffi 123 | pydantic==2.11.3 124 | # via 125 | # fastapi 126 | # fastapi-pagination 127 | # pydantic-extra-types 128 | # pydantic-settings 129 | # sqlalchemy-crud-plus 130 | pydantic-core==2.33.1 131 | # via pydantic 132 | pydantic-extra-types==2.10.3 133 | # via fastapi 134 | pydantic-settings==2.8.1 135 | # via fastapi 136 | pygments==2.19.1 137 | # via rich 138 | python-dotenv==1.1.0 139 | # via 140 | # pydantic-settings 141 | # uvicorn 142 | python-jose==3.3.0 143 | # via fastapi-sqlalchemy-mysql 144 | python-multipart==0.0.9 145 | # via 146 | # fastapi 147 | # fastapi-sqlalchemy-mysql 148 | pyyaml==6.0.2 149 | # via 150 | # fastapi 151 | # pre-commit 152 | # uvicorn 153 | redis==5.0.4 154 | # via 155 | # fastapi-limiter 156 | # fastapi-sqlalchemy-mysql 157 | rich==14.0.0 158 | # via 159 | # rich-toolkit 160 | # typer 161 | rich-toolkit==0.14.1 162 | # via fastapi-cli 163 | rsa==4.9 164 | # via python-jose 165 | shellingham==1.5.4 166 | # via typer 167 | six==1.17.0 168 | # via ecdsa 169 | sniffio==1.3.1 170 | # via anyio 171 | sqlalchemy==2.0.30 172 | # via 173 | # alembic 174 | # fastapi-sqlalchemy-mysql 175 | # sqlalchemy-crud-plus 176 | sqlalchemy-crud-plus==1.6.0 177 | # via fastapi-sqlalchemy-mysql 178 | starlette==0.37.2 179 | # via fastapi 180 | typer==0.15.2 181 | # via fastapi-cli 182 | typing-extensions==4.13.1 183 | # via 184 | # alembic 185 | # anyio 186 | # fastapi 187 | # fastapi-pagination 188 | # pydantic 189 | # pydantic-core 190 | # pydantic-extra-types 191 | # rich 192 | # rich-toolkit 193 | # sqlalchemy 194 | # typer 195 | # typing-inspection 196 | # uvicorn 197 | typing-inspection==0.4.0 198 | # via pydantic 199 | tzdata==2024.1 200 | # via fastapi-sqlalchemy-mysql 201 | ujson==5.10.0 202 | # via fastapi 203 | uvicorn==0.34.0 204 | # via 205 | # fastapi 206 | # fastapi-cli 207 | uvloop==0.21.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' 208 | # via uvicorn 209 | virtualenv==20.30.0 210 | # via pre-commit 211 | watchfiles==1.0.5 212 | # via uvicorn 213 | websockets==15.0.1 214 | # via uvicorn 215 | win32-setctime==1.2.0 ; sys_platform == 'win32' 216 | # via loguru 217 | --------------------------------------------------------------------------------