├── .github └── FUNDING.yml ├── backend ├── __init__.py ├── app │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── v1 │ │ │ │ ├── __init__.py │ │ │ │ ├── auth │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── captcha.py │ │ │ │ │ └── auth.py │ │ │ │ └── user.py │ │ │ └── router.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ └── crud_user.py │ │ ├── schema │ │ │ ├── __init__.py │ │ │ ├── captcha.py │ │ │ ├── token.py │ │ │ └── user.py │ │ ├── service │ │ │ ├── __init__.py │ │ │ ├── auth_service.py │ │ │ └── user_service.py │ │ └── model │ │ │ ├── __init__.py │ │ │ └── user.py │ └── router.py ├── common │ ├── __init__.py │ ├── dataclasses.py │ ├── exception │ │ ├── __init__.py │ │ ├── errors.py │ │ └── exception_handler.py │ ├── response │ │ ├── __init__.py │ │ ├── response_schema.py │ │ └── response_code.py │ ├── security │ │ ├── __init__.py │ │ └── jwt.py │ ├── model.py │ ├── enums.py │ ├── crud.py │ ├── log.py │ ├── pagination.py │ └── schema.py ├── core │ ├── __init__.py │ ├── path_conf.py │ ├── conf.py │ └── registrar.py ├── database │ ├── __init__.py │ ├── db.py │ └── redis.py ├── utils │ ├── __init__.py │ ├── openapi.py │ ├── serializers.py │ ├── demo_site.py │ ├── re_verify.py │ ├── health_check.py │ └── timezone.py ├── middleware │ ├── __init__.py │ └── access_middle.py ├── pyproject.toml ├── migration.py ├── .env.example └── main.py ├── pre-commit.sh ├── .gitignore ├── deploy ├── fastapi_server.conf ├── docker-compose │ ├── .env.server │ └── docker-compose.yml ├── gunicorn.conf.py ├── nginx.conf └── supervisor.conf ├── .ruff.toml ├── .pre-commit-config.yaml ├── LICENSE ├── Dockerfile ├── pyproject.toml ├── README.md └── requirements.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://wu-clan.github.io/sponsor/ 2 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/core/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/database/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/utils/__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/common/dataclasses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/crud/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/schema/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/app/admin/service/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/exception/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/response/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /backend/common/security/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pre-commit run --all-files --verbose --show-diff-on-failure 4 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.aerich] 2 | tortoise_orm = "migration.TORTOISE_ORM" 3 | location = "./migrations" 4 | src_folder = "./." 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | .env 4 | .venv/ 5 | venv/ 6 | backend/log/ 7 | backend/migrations/ 8 | .ruff_cache/ 9 | .pdm-python 10 | -------------------------------------------------------------------------------- /backend/app/admin/model/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from backend.app.admin.model import user 4 | 5 | # 新增 model 后,在下面引入文件,而不是 model 类 6 | models = [ 7 | user, 8 | ] 9 | -------------------------------------------------------------------------------- /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/migration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | 5 | sys.path.append('../') 6 | 7 | from backend.database.db import mysql_config 8 | 9 | TORTOISE_ORM = { 10 | 'connections': mysql_config['connections'], 11 | 'apps': mysql_config['apps'], 12 | } 13 | -------------------------------------------------------------------------------- /deploy/fastapi_server.conf: -------------------------------------------------------------------------------- 1 | [program:fastapi_server] 2 | directory=/ftm 3 | command=/usr/local/bin/gunicorn -c /ftm/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/ftm_server.log 10 | -------------------------------------------------------------------------------- /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/.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/core/path_conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from pathlib import Path 5 | 6 | # 获取项目根目录 7 | # 或使用绝对路径,指到backend目录为止:BasePath = D:\git_project\FastAutoTest\backend 8 | BASE_PATH = Path(__file__).resolve().parent.parent 9 | 10 | # 日志文件路径 11 | LOG_DIR = BASE_PATH / 'log' 12 | 13 | # 静态资源目录 14 | STATIC_DIR = BASE_PATH / 'static' 15 | -------------------------------------------------------------------------------- /deploy/docker-compose/.env.server: -------------------------------------------------------------------------------- 1 | # Env: dev、pro 2 | ENVIRONMENT='dev' 3 | # MySQL 4 | DATABASE_HOST='ftm_mysql' 5 | DATABASE_PORT=3306 6 | DATABASE_USER='root' 7 | DATABASE_PASSWORD='123456' 8 | # Redis 9 | REDIS_HOST='ftm_redis' 10 | REDIS_PORT=6379 11 | REDIS_PASSWORD='' 12 | REDIS_DATABASE=0 13 | # Token 14 | TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' 15 | -------------------------------------------------------------------------------- /backend/app/admin/schema/token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from backend.app.admin.schema.user import GetUserInfoDetail 4 | from backend.common.schema import SchemaBase 5 | 6 | 7 | class GetSwaggerToken(SchemaBase): 8 | access_token: str 9 | token_type: str = 'Bearer' 10 | user: GetUserInfoDetail 11 | 12 | 13 | class GetLoginToken(GetSwaggerToken): 14 | access_token_type: str = 'Bearer' 15 | -------------------------------------------------------------------------------- /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/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/utils/serializers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from typing import Any 4 | 5 | from msgspec import json 6 | from starlette.responses import JSONResponse 7 | 8 | 9 | class MsgSpecJSONResponse(JSONResponse): 10 | """ 11 | JSON response using the high-performance msgspec library to serialize data to JSON. 12 | """ 13 | 14 | def render(self, content: Any) -> bytes: 15 | return json.encode(content) 16 | -------------------------------------------------------------------------------- /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/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/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/common/model.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from tortoise import Model, fields 4 | 5 | 6 | class UserMixin: 7 | """ 8 | 用户 Mixin 数据类 9 | """ 10 | 11 | created_by = fields.BigIntField(null=False, verbose_name='创建者') 12 | updated_by = fields.BigIntField(null=True, verbose_name='修改者') 13 | 14 | 15 | class Base(Model): 16 | """ 17 | 基本模型 18 | """ 19 | 20 | created_time = fields.DatetimeField(auto_now_add=True, verbose_name='创建时间') 21 | updated_time = fields.DatetimeField(auto_now=True, verbose_name='更新时间') 22 | 23 | class Meta: 24 | table = '' 25 | abstract = True 26 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /backend/common/enums.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from enum import Enum 4 | from enum import IntEnum as SourceIntEnum 5 | from typing import Type 6 | 7 | 8 | class _EnumBase: 9 | @classmethod 10 | def get_member_keys(cls: Type[Enum]) -> list[str]: 11 | return [name for name in cls.__members__.keys()] 12 | 13 | @classmethod 14 | def get_member_values(cls: Type[Enum]) -> list: 15 | return [item.value for item in cls.__members__.values()] 16 | 17 | 18 | class IntEnum(_EnumBase, SourceIntEnum): 19 | """整型枚举""" 20 | 21 | pass 22 | 23 | 24 | class StrEnum(_EnumBase, str, Enum): 25 | """字符串枚举""" 26 | 27 | pass 28 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /deploy/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | # fmt: off 2 | # 监听内网端口 3 | bind = '0.0.0.0:8001' 4 | 5 | # 工作目录 6 | chdir = '/ftm/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 = '/ftm/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 | -------------------------------------------------------------------------------- /backend/database/db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from backend.app.admin.model import models 4 | from backend.core.conf import settings 5 | 6 | mysql_config = { 7 | 'connections': { 8 | 'default': { 9 | 'engine': 'tortoise.backends.mysql', 10 | 'credentials': { 11 | 'host': f'{settings.DATABASE_HOST}', 12 | 'port': settings.DATABASE_PORT, 13 | 'user': f'{settings.DATABASE_USER}', 14 | 'password': f'{settings.DATABASE_PASSWORD}', 15 | 'database': f'{settings.DATABASE_SCHEMA}', 16 | 'charset': f'{settings.DATABASE_ENCODING}', 17 | 'echo': settings.DATABASE_ECHO, 18 | }, 19 | }, 20 | }, 21 | 'apps': { 22 | 'ftm': { 23 | 'models': [*models], 24 | 'default_connection': 'default', 25 | }, 26 | }, 27 | 'use_tz': False, 28 | 'timezone': settings.DATABASE_TIMEZONE, 29 | } 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 /ftm; 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://ftm_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 /var/www/ftm_server/backend/static; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 xiaowu 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 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /ftm 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 | -------------------------------------------------------------------------------- /backend/app/admin/model/user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from uuid import uuid4 4 | 5 | from tortoise import Model, fields 6 | 7 | 8 | class User(Model): 9 | """ 10 | 用户类 11 | """ 12 | 13 | id = fields.BigIntField(pk=True, index=True, description='主键id') 14 | uuid = fields.CharField(max_length=36, default=str(uuid4()), unique=True, description='用户UID') 15 | username = fields.CharField(max_length=32, unique=True, description='用户名') 16 | password = fields.CharField(max_length=255, description='密码') 17 | salt = fields.BinaryField(comment='加密盐') 18 | email = fields.CharField(max_length=64, unique=True, description='邮箱') 19 | status = fields.SmallIntField(default=1, description='是否激活') 20 | is_superuser = fields.BooleanField(default=False, description='是否超级管理员') 21 | avatar = fields.CharField(max_length=256, null=True, description='头像') 22 | phone = fields.CharField(max_length=16, null=True, description='手机号') 23 | join_time = fields.DatetimeField(auto_now_add=True, description='注册时间') 24 | last_login_time = fields.DatetimeField(null=True, description='上次登录时间') 25 | 26 | class Meta: 27 | table = 'user' 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastapi_tortoise_mysql" 3 | version = "0.0.1" 4 | description = "fastapi tortoise-orm with mysql" 5 | authors = [ 6 | { name = "Wu Clan", email = "jianhengwu0407@gmail.com" }, 7 | ] 8 | dependencies = [ 9 | "aerich==0.7.2", 10 | "asgiref==3.8.1", 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 | "pwdlib==0.2.1", 20 | "path==16.14.0", 21 | "python-jose==3.3.0", 22 | "python-multipart==0.0.9", 23 | "redis[hiredis]==5.0.4", 24 | "tortoise-orm[asyncmy]==0.24.0", 25 | "tzdata==2025.1", 26 | "msgspec>=0.18.6", 27 | ] 28 | requires-python = ">=3.10" 29 | readme = "README.md" 30 | license = { text = "MIT" } 31 | 32 | [dependency-groups] 33 | lint = [ 34 | "pre-commit>=4.0.0", 35 | ] 36 | server = [ 37 | "gunicorn>=21.2.0", 38 | "wait-for-it>=2.2.2", 39 | ] 40 | 41 | [tool.uv] 42 | package = false 43 | python-downloads = "manual" 44 | default-groups = ["lint"] 45 | 46 | [[tool.uv.index]] 47 | name = "aliyun" 48 | url = "https://mirrors.aliyun.com/pypi/simple" 49 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/auth/captcha.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from uuid import uuid4 4 | 5 | from fast_captcha import img_captcha 6 | from fastapi import APIRouter, Depends, Request 7 | from fastapi_limiter.depends import RateLimiter 8 | from starlette.concurrency import run_in_threadpool 9 | 10 | from backend.app.admin.schema.captcha import GetCaptchaDetail 11 | from backend.common.response.response_schema import ResponseSchemaModel, response_base 12 | from backend.core.conf import settings 13 | from backend.database.redis import redis_client 14 | 15 | router = APIRouter() 16 | 17 | 18 | @router.get( 19 | '', 20 | summary='获取登录验证码', 21 | dependencies=[Depends(RateLimiter(times=5, seconds=10))], 22 | ) 23 | async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]: 24 | """ 25 | 此接口可能存在性能损耗,尽管是异步接口,但是验证码生成是IO密集型任务,使用线程池尽量减少性能损耗 26 | """ 27 | img_type: str = 'base64' 28 | img, code = await run_in_threadpool(img_captcha, img_byte=img_type) 29 | uuid = str(uuid4()) 30 | request.app.state.captcha_uuid = uuid 31 | await redis_client.set( 32 | f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}', 33 | code, 34 | ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS, 35 | ) 36 | data = GetCaptchaDetail(image_type=img_type, image=img) 37 | return response_base.success(data=data) 38 | -------------------------------------------------------------------------------- /backend/app/admin/api/v1/auth/auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import APIRouter, Depends, Request 4 | from fastapi.security import OAuth2PasswordRequestForm 5 | 6 | from backend.app.admin.schema.token import GetLoginToken, GetSwaggerToken 7 | from backend.app.admin.schema.user import AuthLoginParam 8 | from backend.app.admin.service.auth_service import auth_service 9 | from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base 10 | from backend.common.security.jwt import DependsJwtAuth 11 | 12 | router = APIRouter() 13 | 14 | 15 | @router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷进行 swagger 认证') 16 | async def swagger_login(form_data: OAuth2PasswordRequestForm = Depends()) -> GetSwaggerToken: 17 | token, user = await auth_service.swagger_login(form_data=form_data) 18 | return GetSwaggerToken(access_token=token, user=user) # type: ignore 19 | 20 | 21 | @router.post('/login', summary='验证码登录') 22 | async def user_login(request: Request, obj: AuthLoginParam) -> ResponseSchemaModel[GetLoginToken]: 23 | data = await auth_service.login(request=request, obj=obj) 24 | return response_base.success(data=data) 25 | 26 | 27 | @router.post('/logout', summary='用户登出', dependencies=[DependsJwtAuth]) 28 | async def user_logout() -> ResponseModel: 29 | return response_base.success() 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Tortoise Architecture 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. 创建一个数据库 `ftm`, 选择 `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. 数据库迁移 32 | 33 | ```shell 34 | # 初始化数据库,生成迁移文件 35 | aerich init-db 36 | 37 | # 执行迁移 38 | aerich upgrade 39 | 40 | # 当更新数据库 model 后,执行下面两个命令进行迁移 41 | aerich migrate 42 | aerich upgrade 43 | ``` 44 | 45 | 8. 启动 fastapi 服务 46 | 47 | ```shell 48 | # 帮助 49 | fastapi --help 50 | 51 | # 开发模式 52 | fastapi dev main.py 53 | ``` 54 | 55 | 9. 浏览器访问: http://127.0.0.1:8000/docs 56 | 57 | --- 58 | 59 | ### 2: docker 60 | 61 | 1. 进入 `docker-compose.yml` 文件所在目录,创建环境变量文件 `.env` 62 | 63 | ```shell 64 | dcd deploy/docker-compose/ 65 | 66 | cp .env.server ../../../backend/.env 67 | ``` 68 | 69 | 2. 执行一键启动命令 70 | 71 | ```shell 72 | docker-compose up -d --build 73 | ``` 74 | 75 | 3. 等待命令自动完成 76 | 4. 浏览器访问:http://127.0.0.1:8000/docs 77 | 78 | ## 赞助 79 | 80 | 如果此项目能够帮助到你,你可以赞助作者一些咖啡豆表示鼓励:[:coffee: Sponsor :coffee:](https://wu-clan.github.io/sponsor/) 81 | 82 | ## 许可证 83 | 84 | 本项目根据 MIT 许可证的条款进行许可 85 | -------------------------------------------------------------------------------- /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 ConfigDict, EmailStr, Field, HttpUrl 6 | 7 | from backend.common.schema import CustomPhoneNumber, SchemaBase 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']) 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/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 | -------------------------------------------------------------------------------- /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/common/crud.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from typing import Any, Dict, Generic, Type, TypeVar 4 | 5 | from pydantic import BaseModel 6 | from tortoise import Model 7 | 8 | ModelT = TypeVar('ModelT', bound=Model) 9 | CreateSchemaT = TypeVar('CreateSchemaT', bound=BaseModel) 10 | UpdateSchemaT = TypeVar('UpdateSchemaT', bound=BaseModel) 11 | 12 | 13 | class CRUDBase(Generic[ModelT]): 14 | def __init__(self, model: Type[ModelT]): 15 | self.model = model 16 | 17 | async def get(self, pk: int) -> ModelT: 18 | return await self.model.filter(id=pk).first() 19 | 20 | async def get_or_none(self, pk: int) -> ModelT | None: 21 | return await self.model.get_or_none(id=pk) 22 | 23 | async def get_all(self) -> list[Type[ModelT]]: 24 | return await self.model.all().order_by('-id') 25 | 26 | async def get_values(self, pk: int, *args: str, **kwargs: str) -> list[dict[str, Any]] | dict[str, Any]: 27 | return await self.model.get(id=pk).values(*args, **kwargs) 28 | 29 | async def get_values_list(self, pk: int, *fields: str, flat: bool = False) -> list[Any] | tuple: 30 | return await self.model.get(id=pk).values_list(*fields, flat=flat) 31 | 32 | async def create_model(self, obj_in: CreateSchemaT, **kwargs) -> ModelT: 33 | if kwargs: 34 | model = self.model(**obj_in.model_dump(), **kwargs) 35 | await model.save() 36 | else: 37 | model = await self.model.create(**obj_in.model_dump()) 38 | await model.save() 39 | return model 40 | 41 | async def update_model(self, pk: int, obj_in: UpdateSchemaT | Dict[str, Any], **kwargs) -> int: 42 | if isinstance(obj_in, dict): 43 | update_data = obj_in 44 | else: 45 | update_data = obj_in.model_dump() 46 | if kwargs: 47 | update_data.update(**kwargs) 48 | count = await self.model.filter(id=pk).update(**update_data) 49 | return count 50 | 51 | async def delete_model(self, pk: int) -> int: 52 | count = await self.model.filter(id=pk).delete() 53 | return count 54 | -------------------------------------------------------------------------------- /deploy/docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ftm_server: 3 | build: 4 | context: ../../ 5 | dockerfile: Dockerfile 6 | image: ftm_server:latest 7 | container_name: ftm_server 8 | restart: always 9 | depends_on: 10 | - ftm_mysql 11 | - ftm_redis 12 | volumes: 13 | - ftm_static:/ftm/backend/static 14 | networks: 15 | - ftm_network 16 | command: 17 | - bash 18 | - -c 19 | - | 20 | wait-for-it -s ftm_mysql:3306 -s ftm_redis:6379 -t 300 21 | supervisord -c /etc/supervisor/supervisord.conf 22 | supervisorctl restart 23 | 24 | ftm_mysql: 25 | image: mysql:8.0.29 26 | ports: 27 | - "3306:3306" 28 | container_name: ftm_mysql 29 | restart: always 30 | environment: 31 | MYSQL_DATABASE: ftm 32 | MYSQL_ROOT_PASSWORD: 123456 33 | TZ: Asia/Shanghai 34 | volumes: 35 | - ftm_mysql:/var/lib/mysql 36 | networks: 37 | - ftm_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 | ftm_redis: 45 | image: redis:6.2.7 46 | ports: 47 | - "6379:6379" 48 | container_name: ftm_redis 49 | restart: always 50 | environment: 51 | - TZ=Asia/Shanghai 52 | volumes: 53 | - ftm_redis:/var/lib/redis 54 | networks: 55 | - ftm_network 56 | 57 | ftm_nginx: 58 | image: nginx:stable 59 | ports: 60 | - "8000:80" 61 | container_name: ftm_nginx 62 | restart: always 63 | depends_on: 64 | - ftm_server 65 | volumes: 66 | - ../nginx.conf:/etc/nginx/conf.d/default.conf:ro 67 | - ftm_static:/var/www/ftm_server/backend/static 68 | networks: 69 | - ftm_network 70 | 71 | networks: 72 | ftm_network: 73 | name: ftm_network 74 | driver: bridge 75 | ipam: 76 | driver: default 77 | config: 78 | - subnet: 172.10.10.0/24 79 | 80 | volumes: 81 | ftm_mysql: 82 | name: ftm_mysql 83 | ftm_redis: 84 | name: ftm_redis 85 | ftm_static: 86 | name: ftm_static 87 | -------------------------------------------------------------------------------- /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 | 6 | from backend.app.admin.crud.crud_user import user_dao 7 | from backend.app.admin.model.user import User 8 | from backend.app.admin.schema.token import GetLoginToken 9 | from backend.app.admin.schema.user import AuthLoginParam 10 | from backend.common.exception import errors 11 | from backend.common.response.response_code import CustomErrorCode 12 | from backend.common.security.jwt import create_access_token, password_verify 13 | from backend.core.conf import settings 14 | from backend.database.redis import redis_client 15 | from backend.utils.timezone import timezone 16 | 17 | 18 | class AuthService: 19 | @staticmethod 20 | async def user_verify(username: str, password: str) -> User: 21 | user = await user_dao.get_by_username(username) 22 | if not user: 23 | raise errors.NotFoundError(msg='用户名或密码有误') 24 | elif not password_verify(password, user.password): 25 | raise errors.AuthorizationError(msg='用户名或密码有误') 26 | elif not user.status: 27 | raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') 28 | return user 29 | 30 | async def swagger_login(self, *, form_data: OAuth2PasswordRequestForm) -> tuple[str, User]: 31 | user = await self.user_verify(form_data.username, form_data.password) 32 | await user_dao.update_login_time(user.username, login_time=timezone.now()) 33 | token = create_access_token(str(user.id)) 34 | return token, user 35 | 36 | async def login(self, *, request: Request, obj: AuthLoginParam) -> GetLoginToken: 37 | user = await self.user_verify(obj.username, obj.password) 38 | try: 39 | captcha_uuid = request.app.state.captcha_uuid 40 | redis_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{captcha_uuid}') 41 | if not redis_code: 42 | raise errors.ForbiddenError(msg='验证码失效,请重新获取') 43 | except AttributeError: 44 | raise errors.ForbiddenError(msg='验证码失效,请重新获取') 45 | if redis_code.lower() != obj.captcha.lower(): 46 | raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) 47 | await user_dao.update_login_time(user.username, login_time=timezone.now()) 48 | token = create_access_token(str(user.id)) 49 | data = GetLoginToken(access_token=token, user=user) 50 | return data 51 | 52 | 53 | auth_service: AuthService = AuthService() 54 | -------------------------------------------------------------------------------- /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 tortoise.queryset import QuerySet 7 | from tortoise.expressions import Q 8 | from tortoise.transactions import atomic 9 | 10 | from backend.app.admin.model.user import User 11 | from backend.app.admin.schema.user import AvatarParam, RegisterUserParam, UpdateUserParam 12 | from backend.common.crud import CRUDBase 13 | from backend.common.security.jwt import get_hash_password 14 | 15 | 16 | class CRUDUser(CRUDBase[User]): 17 | async def get_by_id(self, pk: int) -> User: 18 | return await self.get(pk) 19 | 20 | async def get_by_username(self, name: str) -> User: 21 | return await self.model.filter(username=name).first() 22 | 23 | @atomic() 24 | async def update_login_time(self, username: str, login_time: datetime) -> int: 25 | return await self.model.filter(username=username).update(last_login_time=login_time) 26 | 27 | async def check_email(self, email: str) -> bool: 28 | return await self.model.filter(email=email).exists() 29 | 30 | @atomic() 31 | async def register(self, obj: RegisterUserParam) -> None: 32 | salt = bcrypt.gensalt() 33 | obj.password = get_hash_password(obj.password, salt) 34 | dict_obj = obj.model_dump() 35 | dict_obj.update({'salt': salt}) 36 | new_user = self.model(**dict_obj) 37 | await new_user.save() 38 | 39 | @atomic() 40 | async def reset_password(self, pk: int, new_pwd: str) -> int: 41 | return await self.model.filter(id=pk).update(password=new_pwd) 42 | 43 | @atomic() 44 | async def update_userinfo(self, input_user: int, obj_in: UpdateUserParam) -> int: 45 | return await self.update_model(input_user, obj_in) 46 | 47 | @atomic() 48 | async def update_avatar(self, input_user: int, avatar: AvatarParam) -> int: 49 | return await self.update_model(input_user, {'avatar': avatar.url}) 50 | 51 | async def get_list(self, username: str = None, phone: str = None, status: int = None) -> QuerySet: 52 | where_list = [] 53 | if username: 54 | where_list.append(Q(username=username)) 55 | if phone: 56 | where_list.append(Q(phone=phone)) 57 | if status: 58 | where_list.append(Q(status=status)) 59 | return self.model.filter(Q(*where_list)).order_by('-id').all() 60 | 61 | @atomic() 62 | async def delete(self, pk: int) -> int: 63 | return await self.delete_model(pk) 64 | 65 | 66 | user_dao: CRUDUser = CRUDUser(User) 67 | -------------------------------------------------------------------------------- /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.app.admin.schema.user import ( 8 | RegisterUserParam, 9 | ResetPassword, 10 | UpdateUserParam, 11 | GetUserInfoDetail, 12 | AvatarParam, 13 | ) 14 | from backend.app.admin.service.user_service import UserService 15 | from backend.common.pagination import DependsPagination, paging_data, PageData 16 | from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel 17 | from backend.common.security.jwt import CurrentUser, DependsJwtAuth 18 | 19 | router = APIRouter() 20 | 21 | 22 | @router.post('/register', summary='用户注册') 23 | async def create_user(obj: RegisterUserParam) -> ResponseModel: 24 | await UserService.register(obj=obj) 25 | return response_base.success() 26 | 27 | 28 | @router.post('/password/reset', summary='密码重置', dependencies=[DependsJwtAuth]) 29 | async def password_reset(obj: ResetPassword) -> ResponseModel: 30 | count = await UserService.pwd_reset(obj=obj) 31 | if count > 0: 32 | return response_base.success() 33 | return response_base.fail() 34 | 35 | 36 | @router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth]) 37 | async def get_user(username: str) -> ResponseSchemaModel[GetUserInfoDetail]: 38 | data = await UserService.get_userinfo(username=username) 39 | return response_base.success(data=data) 40 | 41 | 42 | @router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth]) 43 | async def update_userinfo(username: str, obj: UpdateUserParam) -> ResponseModel: 44 | count = await UserService.update(username=username, obj=obj) 45 | if count > 0: 46 | return response_base.success() 47 | return response_base.fail() 48 | 49 | 50 | @router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth]) 51 | async def update_avatar(username: str, avatar: AvatarParam) -> ResponseModel: 52 | count = await UserService.update_avatar(username=username, avatar=avatar) 53 | if count > 0: 54 | return response_base.success() 55 | return response_base.fail() 56 | 57 | 58 | @router.get( 59 | '', 60 | summary='(模糊条件)分页获取所有用户', 61 | dependencies=[ 62 | DependsJwtAuth, 63 | DependsPagination, 64 | ], 65 | ) 66 | async def get_all_users( 67 | username: Annotated[str | None, Query()] = None, 68 | phone: Annotated[str | None, Query()] = None, 69 | status: Annotated[int | None, Query()] = None, 70 | ) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]: 71 | user_queryset = await UserService.get_list(username=username, phone=phone, status=status) 72 | page_data = await paging_data(user_queryset) 73 | return response_base.success(data=page_data) 74 | 75 | 76 | @router.delete( 77 | path='/{username}', 78 | summary='用户注销', 79 | description='用户注销 != 用户登出,注销之后用户将从数据库删除', 80 | dependencies=[DependsJwtAuth], 81 | ) 82 | async def delete_user(current_user: CurrentUser, username: str) -> ResponseModel: 83 | count = await UserService.delete(current_user=current_user, username=username) 84 | if count > 0: 85 | return response_base.success() 86 | return response_base.fail() 87 | -------------------------------------------------------------------------------- /backend/app/admin/service/user_service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from tortoise.queryset import QuerySet 4 | from backend.app.admin.crud.crud_user import user_dao 5 | from backend.app.admin.model.user import User 6 | from backend.app.admin.schema.user import RegisterUserParam, ResetPassword, UpdateUserParam, AvatarParam 7 | from backend.common.exception import errors 8 | from backend.common.security.jwt import get_hash_password, password_verify, superuser_verify 9 | 10 | 11 | class UserService: 12 | @staticmethod 13 | async def register(*, obj: RegisterUserParam) -> None: 14 | if not obj.password: 15 | raise errors.ForbiddenError(msg='密码为空') 16 | username = await user_dao.get_by_username(name=obj.username) 17 | if username: 18 | raise errors.ForbiddenError(msg='用户名已注册') 19 | email = await user_dao.check_email(obj.email) 20 | if email: 21 | raise errors.ForbiddenError(msg='邮箱已注册') 22 | await user_dao.register(obj) 23 | 24 | @staticmethod 25 | async def pwd_reset(*, obj: ResetPassword) -> int: 26 | user = await user_dao.get_by_username(obj.username) 27 | if not password_verify(obj.old_password, user.password): 28 | raise errors.ForbiddenError(msg='原密码错误') 29 | np1 = obj.new_password 30 | np2 = obj.confirm_password 31 | if np1 != np2: 32 | raise errors.ForbiddenError(msg='密码输入不一致') 33 | new_pwd = get_hash_password(obj.new_password, user.salt) 34 | count = await user_dao.reset_password(user.id, new_pwd) 35 | return count 36 | 37 | @staticmethod 38 | async def get_userinfo(*, username: str) -> User: 39 | user = await user_dao.get_by_username(username) 40 | if not user: 41 | raise errors.NotFoundError(msg='用户不存在') 42 | return user 43 | 44 | @staticmethod 45 | async def update(*, username: str, obj: UpdateUserParam) -> int: 46 | input_user = await user_dao.get_by_username(username) 47 | if not input_user: 48 | raise errors.NotFoundError(msg='用户不存在') 49 | superuser_verify(input_user) 50 | if input_user.username != obj.username: 51 | _username = await user_dao.get_by_username(obj.username) 52 | if _username: 53 | raise errors.ForbiddenError(msg='用户名已注册') 54 | if input_user.email != obj.email: 55 | email = await user_dao.check_email(obj.email) 56 | if email: 57 | raise errors.ForbiddenError(msg='邮箱已注册') 58 | count = await user_dao.update_userinfo(input_user.id, obj) 59 | return count 60 | 61 | @staticmethod 62 | async def update_avatar(*, username: str, avatar: AvatarParam) -> int: 63 | input_user = await user_dao.get_by_username(username) 64 | if not input_user: 65 | raise errors.NotFoundError(msg='用户不存在') 66 | count = await user_dao.update_avatar(input_user.id, avatar) 67 | return count 68 | 69 | @staticmethod 70 | async def get_list(*, username: str = None, phone: str = None, status: int = None) -> QuerySet: 71 | return await user_dao.get_list(username=username, phone=phone, status=status) 72 | 73 | @staticmethod 74 | async def delete(*, username: str, current_user: User) -> int: 75 | superuser_verify(current_user) 76 | input_user = await user_dao.get_by_username(username) 77 | if not input_user: 78 | raise errors.NotFoundError(msg='用户不存在') 79 | count = await user_dao.delete(input_user.id) 80 | return count 81 | -------------------------------------------------------------------------------- /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/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/security/jwt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from fastapi import Depends, Request 4 | from fastapi.security import OAuth2PasswordBearer 5 | from fastapi.security.utils import get_authorization_scheme_param 6 | from jose import ExpiredSignatureError, JWTError, jwt 7 | from pwdlib import PasswordHash 8 | from pwdlib.hashers.bcrypt import BcryptHasher 9 | from typing_extensions import Annotated 10 | 11 | from backend.app.admin.model.user import User 12 | from backend.common.exception.errors import AuthorizationError, TokenError 13 | from backend.core.conf import settings 14 | 15 | oauth2_schema = OAuth2PasswordBearer(tokenUrl=settings.TOKEN_URL_SWAGGER) 16 | 17 | password_hash = PasswordHash((BcryptHasher(),)) 18 | 19 | 20 | def get_hash_password(password: str, salt: bytes | None) -> str: 21 | """ 22 | Encrypt passwords using the hash algorithm 23 | 24 | :param password: 25 | :param salt: 26 | :return: 27 | """ 28 | return password_hash.hash(password, salt=salt) 29 | 30 | 31 | def password_verify(plain_password: str, hashed_password: str) -> bool: 32 | """ 33 | Password verification 34 | 35 | :param plain_password: The password to verify 36 | :param hashed_password: The hash ciphers to compare 37 | :return: 38 | """ 39 | return password_hash.verify(plain_password, hashed_password) 40 | 41 | 42 | def create_access_token(sub: str) -> str: 43 | """ 44 | Generate encryption token 45 | 46 | :param sub: The subject/userid of the JWT 47 | :return: 48 | """ 49 | to_encode = {'sub': sub} 50 | access_token = jwt.encode(to_encode, settings.TOKEN_SECRET_KEY, settings.TOKEN_ALGORITHM) 51 | return access_token 52 | 53 | 54 | def get_token(request: Request) -> str: 55 | """ 56 | Get token for request header 57 | 58 | :return: 59 | """ 60 | authorization = request.headers.get('Authorization') 61 | scheme, token = get_authorization_scheme_param(authorization) 62 | if not authorization or scheme.lower() != 'bearer': 63 | raise TokenError(msg='Token 无效') 64 | return token 65 | 66 | 67 | def jwt_decode(token: str) -> int: 68 | """ 69 | Decode token 70 | 71 | :param token: 72 | :return: 73 | """ 74 | try: 75 | payload = jwt.decode(token, settings.TOKEN_SECRET_KEY, algorithms=[settings.TOKEN_ALGORITHM]) 76 | user_id = int(payload.get('sub')) 77 | if not user_id: 78 | raise TokenError(msg='Token 无效') 79 | except ExpiredSignatureError: 80 | raise TokenError(msg='Token 已过期') 81 | except (JWTError, Exception): 82 | raise TokenError(msg='Token 无效') 83 | return user_id 84 | 85 | 86 | async def get_current_user(token: str = Depends(oauth2_schema)) -> User: 87 | """ 88 | 通过 token 获取当前用户 89 | 90 | :param token: 91 | :return: 92 | """ 93 | user_id = jwt_decode(token) 94 | from backend.app.admin.crud.crud_user import user_dao 95 | 96 | user = await user_dao.get_by_id(user_id) 97 | if not user: 98 | raise TokenError(msg='Token 无效') 99 | if not user.status: 100 | raise AuthorizationError(msg='用户已被锁定,请联系系统管理员') 101 | return user 102 | 103 | 104 | def superuser_verify(user: User): 105 | """ 106 | 验证当前用户是否为超级用户 107 | 108 | :param user: 109 | :return: 110 | """ 111 | superuser = user.is_superuser 112 | if not superuser: 113 | raise AuthorizationError 114 | return superuser 115 | 116 | 117 | # 用户依赖注入 118 | CurrentUser = Annotated[User, Depends(get_current_user)] 119 | # 权限依赖注入 120 | DependsJwtAuth = Depends(get_current_user) 121 | -------------------------------------------------------------------------------- /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 | 40 | # FastAPI 41 | FASTAPI_API_V1_PATH: str = '/api/v1' 42 | FASTAPI_TITLE: str = 'FastAPI' 43 | FASTAPI_VERSION: str = '0.0.1' 44 | FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture' 45 | FASTAPI_DOCS_URL: str = '/docs' 46 | FASTAPI_REDOC_URL: str = '/redoc' 47 | FASTAPI_OPENAPI_URL: str | None = '/openapi' 48 | FASTAPI_STATIC_FILES: bool = False 49 | 50 | # DATABASE 51 | DATABASE_ECHO: bool = False 52 | DATABASE_SCHEMA: str = 'ftm' 53 | DATABASE_ENCODING: str = 'utf8mb4' 54 | DATABASE_TIMEZONE: str = 'Asia/Shanghai' 55 | 56 | # Redis 57 | REDIS_TIMEOUT: int = 10 58 | 59 | # Token 60 | TOKEN_ALGORITHM: str = 'HS256' # 算法 61 | TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1 # 过期时间,单位:秒 62 | TOKEN_URL_SWAGGER: str = f'{FASTAPI_API_V1_PATH}/auth/login/swagger' 63 | 64 | # Log 65 | LOG_STD_LEVEL: str = 'INFO' 66 | LOG_ACCESS_FILE_LEVEL: str = 'INFO' 67 | LOG_ERROR_FILE_LEVEL: str = 'ERROR' 68 | LOG_STD_FORMAT: str = '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}' 69 | LOG_FILE_FORMAT: str = '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}' 70 | LOG_ACCESS_FILENAME: str = 'fba_access.log' 71 | LOG_ERROR_FILENAME: str = 'fba_error.log' 72 | 73 | # CORS 74 | CORS_ALLOWED_ORIGINS: list[str] = [ 75 | 'http://127.0.0.1:8000', 76 | ] 77 | CORS_EXPOSE_HEADERS: list[str] = [ 78 | '*', 79 | ] 80 | 81 | # Captcha 82 | CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha' 83 | CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 过期时间,单位:秒 84 | 85 | # 中间件 86 | MIDDLEWARE_CORS: bool = True 87 | MIDDLEWARE_ACCESS: bool = True 88 | 89 | # DateTime 90 | DATETIME_TIMEZONE: str = 'Asia/Shanghai' 91 | DATETIME_FORMAT: str = '%Y-%m-%d %H:%M:%S' 92 | 93 | # Limiter 94 | LIMITER_REDIS_PREFIX: str = 'fba_limiter' 95 | 96 | # Demo mode (Only GET, OPTIONS requests are allowed) 97 | DEMO_MODE: bool = False 98 | DEMO_MODE_EXCLUDE: set[tuple[str, str]] = { 99 | ('POST', f'{FASTAPI_API_V1_PATH}/auth/login'), 100 | ('POST', f'{FASTAPI_API_V1_PATH}/auth/logout'), 101 | ('GET', f'{FASTAPI_API_V1_PATH}/auth/captcha'), 102 | } 103 | 104 | @model_validator(mode='before') 105 | @classmethod 106 | def validator_api_url(cls, values): 107 | if values['ENVIRONMENT'] == 'pro': 108 | values['FASTAPI_OPENAPI_URL'] = None 109 | values['FASTAPI_STATIC_FILES'] = False 110 | return values 111 | 112 | 113 | @lru_cache 114 | def get_settings(): 115 | """读取配置优化写法""" 116 | return Settings() 117 | 118 | 119 | settings = get_settings() 120 | -------------------------------------------------------------------------------- /backend/core/registrar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import os 4 | 5 | from contextlib import asynccontextmanager 6 | 7 | from fastapi import Depends, FastAPI 8 | from fastapi_limiter import FastAPILimiter 9 | from fastapi_pagination import add_pagination 10 | from tortoise.contrib.fastapi import RegisterTortoise 11 | 12 | from backend.app.router import route 13 | from backend.common.exception.exception_handler import register_exception 14 | from backend.common.log import setup_logging, set_custom_logfile 15 | from backend.core.conf import settings 16 | from backend.core.path_conf import STATIC_DIR 17 | from backend.database.db import mysql_config 18 | from backend.database.redis import redis_client 19 | from backend.utils.demo_site import demo_site 20 | from backend.utils.health_check import ensure_unique_route_names, http_limit_callback 21 | from backend.utils.openapi import simplify_operation_ids 22 | 23 | 24 | @asynccontextmanager 25 | async def register_init(app: FastAPI): 26 | """ 27 | 启动初始化 28 | 29 | :return: 30 | """ 31 | # 连接redis 32 | await redis_client.open() 33 | # 初始化 limiter 34 | await FastAPILimiter.init( 35 | redis_client, 36 | prefix=settings.LIMITER_REDIS_PREFIX, 37 | http_callback=http_limit_callback, 38 | ) 39 | # 连接 db 40 | async with RegisterTortoise( 41 | app=app, 42 | config=mysql_config, 43 | generate_schemas=True, 44 | ): 45 | yield 46 | 47 | yield 48 | 49 | # 关闭redis连接 50 | await redis_client.close() 51 | # 关闭 limiter 52 | await FastAPILimiter.close() 53 | 54 | 55 | def register_app(): 56 | # FastAPI 57 | app = FastAPI( 58 | title=settings.FASTAPI_TITLE, 59 | version=settings.FASTAPI_VERSION, 60 | description=settings.FASTAPI_DESCRIPTION, 61 | docs_url=settings.FASTAPI_DOCS_URL, 62 | redoc_url=settings.FASTAPI_REDOC_URL, 63 | openapi_url=settings.FASTAPI_OPENAPI_URL, 64 | lifespan=register_init, 65 | ) 66 | 67 | # 注册组件 68 | register_logger() 69 | register_static_file(app) 70 | register_middleware(app) 71 | register_router(app) 72 | register_page(app) 73 | register_exception(app) 74 | 75 | return app 76 | 77 | 78 | def register_logger() -> None: 79 | """ 80 | 系统日志 81 | 82 | :return: 83 | """ 84 | setup_logging() 85 | set_custom_logfile() 86 | 87 | 88 | def register_static_file(app: FastAPI): 89 | """ 90 | 静态文件交互开发模式, 生产使用 nginx 静态资源服务 91 | 92 | :param app: 93 | :return: 94 | """ 95 | if settings.FASTAPI_STATIC_FILES: 96 | from fastapi.staticfiles import StaticFiles 97 | 98 | if not os.path.exists(STATIC_DIR): 99 | os.makedirs(STATIC_DIR) 100 | 101 | app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') 102 | 103 | 104 | def register_middleware(app) -> None: 105 | # 接口访问日志 106 | if settings.MIDDLEWARE_ACCESS: 107 | from backend.middleware.access_middle import AccessMiddleware 108 | 109 | app.add_middleware(AccessMiddleware) 110 | # 跨域 111 | if settings.MIDDLEWARE_CORS: 112 | from starlette.middleware.cors import CORSMiddleware 113 | 114 | app.add_middleware( 115 | CORSMiddleware, 116 | allow_origins=['*'], 117 | allow_credentials=True, 118 | allow_methods=['*'], 119 | allow_headers=['*'], 120 | ) 121 | 122 | 123 | def register_router(app: FastAPI): 124 | """ 125 | 路由 126 | 127 | :param app: FastAPI 128 | :return: 129 | """ 130 | dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None 131 | 132 | # API 133 | app.include_router(route, dependencies=dependencies) 134 | 135 | # Extra 136 | ensure_unique_route_names(app) 137 | simplify_operation_ids(app) 138 | 139 | 140 | def register_page(app: FastAPI): 141 | """ 142 | 分页查询 143 | 144 | :param app: 145 | :return: 146 | """ 147 | add_pagination(app) 148 | -------------------------------------------------------------------------------- /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/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.tortoise import paginate 12 | from fastapi_pagination.links.bases import create_links 13 | from pydantic import BaseModel, Field 14 | 15 | 16 | if TYPE_CHECKING: 17 | from backend.common.crud import ModelT 18 | from tortoise.queryset import QuerySet 19 | 20 | T = TypeVar('T') 21 | SchemaT = TypeVar('SchemaT') 22 | 23 | 24 | class _CustomPageParams(BaseModel, AbstractParams): 25 | page: int = Query(1, ge=1, description='Page number') 26 | size: int = Query(20, gt=0, le=100, description='Page size') # 默认 20 条记录 27 | 28 | def to_raw_params(self) -> RawParams: 29 | return RawParams( 30 | limit=self.size, 31 | offset=self.size * (self.page - 1), 32 | ) 33 | 34 | 35 | class _Links(BaseModel): 36 | first: str = Field(..., description='首页链接') 37 | last: str = Field(..., description='尾页链接') 38 | self: str = Field(..., description='当前页链接') 39 | next: str | None = Field(None, description='下一页链接') 40 | prev: str | None = Field(None, description='上一页链接') 41 | 42 | 43 | class _PageDetails(BaseModel): 44 | items: list = Field([], description='当前页数据') 45 | total: int = Field(..., description='总条数') 46 | page: int = Field(..., description='当前页') 47 | size: int = Field(..., description='每页数量') 48 | total_pages: int = Field(..., description='总页数') 49 | links: _Links 50 | 51 | 52 | class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]): 53 | __params_type__ = _CustomPageParams 54 | 55 | @classmethod 56 | def create( 57 | cls, 58 | items: list, 59 | total: int, 60 | params: _CustomPageParams, 61 | ) -> _CustomPage[T]: 62 | page = params.page 63 | size = params.size 64 | total_pages = ceil(total / params.size) 65 | links = create_links( 66 | first={'page': 1, 'size': size}, 67 | last={'page': f'{ceil(total / params.size)}', 'size': size} if total > 0 else {'page': 1, 'size': size}, 68 | next={'page': f'{page + 1}', 'size': size} if (page + 1) <= total_pages else None, 69 | prev={'page': f'{page - 1}', 'size': size} if (page - 1) >= 1 else None, 70 | ).model_dump() 71 | 72 | return cls( 73 | items=items, 74 | total=total, 75 | page=params.page, 76 | size=params.size, 77 | total_pages=total_pages, 78 | links=links, # type: ignore 79 | ) 80 | 81 | 82 | class PageData(_PageDetails, Generic[SchemaT]): 83 | """ 84 | 包含 data schema 的统一返回模型,适用于分页接口 85 | 86 | E.g. :: 87 | 88 | @router.get('/test', response_model=ResponseSchemaModel[PageData[GetApiDetail]]) 89 | def test(): 90 | return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...)) 91 | 92 | 93 | @router.get('/test') 94 | def test() -> ResponseSchemaModel[PageData[GetApiDetail]]: 95 | return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...)) 96 | 97 | 98 | @router.get('/test') 99 | def test() -> ResponseSchemaModel[PageData[GetApiDetail]]: 100 | res = CustomResponseCode.HTTP_200 101 | return ResponseSchemaModel[PageData[GetApiDetail]](code=res.code, msg=res.msg, data=GetApiDetail(...)) 102 | """ 103 | 104 | items: Sequence[SchemaT] 105 | 106 | 107 | async def paging_data(query_set: QuerySet | type[ModelT]) -> dict: 108 | """ 109 | 基于 SQLAlchemy 创建分页数据 110 | 111 | :param query_set: 112 | :return: 113 | """ 114 | paginated_data: _CustomPage = await paginate(query_set) 115 | page_data = paginated_data.model_dump() 116 | return page_data 117 | 118 | 119 | # 分页依赖注入 120 | DependsPagination = Depends(pagination_ctx(_CustomPage)) 121 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export -o requirements.txt --no-hashes 3 | aerich==0.7.2 4 | # via fastapi-tortoise-mysql 5 | aiosqlite==0.20.0 6 | # via tortoise-orm 7 | annotated-types==0.7.0 8 | # via pydantic 9 | anyio==4.9.0 10 | # via 11 | # httpx 12 | # starlette 13 | # watchfiles 14 | asgiref==3.8.1 15 | # via fastapi-tortoise-mysql 16 | async-timeout==5.0.1 ; python_full_version < '3.11.3' 17 | # via redis 18 | asyncmy==0.2.10 19 | # via tortoise-orm 20 | bcrypt==4.1.3 21 | # via fastapi-tortoise-mysql 22 | certifi==2025.1.31 23 | # via 24 | # httpcore 25 | # httpx 26 | cffi==1.17.1 ; platform_python_implementation != 'PyPy' 27 | # via cryptography 28 | cfgv==3.4.0 29 | # via pre-commit 30 | click==8.1.8 31 | # via 32 | # aerich 33 | # rich-toolkit 34 | # typer 35 | # uvicorn 36 | colorama==0.4.6 ; sys_platform == 'win32' 37 | # via 38 | # click 39 | # loguru 40 | # uvicorn 41 | cryptography==42.0.7 42 | # via fastapi-tortoise-mysql 43 | dictdiffer==0.9.0 44 | # via aerich 45 | distlib==0.3.9 46 | # via virtualenv 47 | dnspython==2.7.0 48 | # via email-validator 49 | ecdsa==0.19.1 50 | # via python-jose 51 | email-validator==2.1.1 52 | # via 53 | # fastapi 54 | # fastapi-tortoise-mysql 55 | exceptiongroup==1.2.2 ; python_full_version < '3.11' 56 | # via anyio 57 | fast-captcha==0.2.1 58 | # via fastapi-tortoise-mysql 59 | fastapi==0.111.0 60 | # via 61 | # fastapi-limiter 62 | # fastapi-pagination 63 | # fastapi-tortoise-mysql 64 | fastapi-cli==0.0.7 65 | # via fastapi 66 | fastapi-limiter==0.1.6 67 | # via fastapi-tortoise-mysql 68 | fastapi-pagination==0.12.24 69 | # via fastapi-tortoise-mysql 70 | filelock==3.18.0 71 | # via virtualenv 72 | h11==0.14.0 73 | # via 74 | # httpcore 75 | # uvicorn 76 | hiredis==3.1.0 77 | # via redis 78 | httpcore==1.0.7 79 | # via httpx 80 | httptools==0.6.4 81 | # via uvicorn 82 | httpx==0.28.1 83 | # via fastapi 84 | identify==2.6.9 85 | # via pre-commit 86 | idna==3.10 87 | # via 88 | # anyio 89 | # email-validator 90 | # httpx 91 | iso8601==2.1.0 92 | # via tortoise-orm 93 | itsdangerous==2.2.0 94 | # via fastapi 95 | jinja2==3.1.6 96 | # via fastapi 97 | loguru==0.7.2 98 | # via fastapi-tortoise-mysql 99 | markdown-it-py==3.0.0 100 | # via rich 101 | markupsafe==3.0.2 102 | # via jinja2 103 | mdurl==0.1.2 104 | # via markdown-it-py 105 | msgspec==0.19.0 106 | # via fastapi-tortoise-mysql 107 | nodeenv==1.9.1 108 | # via pre-commit 109 | orjson==3.10.16 110 | # via fastapi 111 | path==16.14.0 112 | # via fastapi-tortoise-mysql 113 | pillow==9.5.0 114 | # via fast-captcha 115 | platformdirs==4.3.7 116 | # via virtualenv 117 | pre-commit==4.2.0 118 | pwdlib==0.2.1 119 | # via fastapi-tortoise-mysql 120 | pyasn1==0.6.1 121 | # via 122 | # python-jose 123 | # rsa 124 | pycparser==2.22 ; platform_python_implementation != 'PyPy' 125 | # via cffi 126 | pydantic==2.11.3 127 | # via 128 | # aerich 129 | # fastapi 130 | # fastapi-pagination 131 | # pydantic-extra-types 132 | # pydantic-settings 133 | pydantic-core==2.33.1 134 | # via pydantic 135 | pydantic-extra-types==2.10.3 136 | # via fastapi 137 | pydantic-settings==2.8.1 138 | # via fastapi 139 | pygments==2.19.1 140 | # via rich 141 | pypika-tortoise==0.5.0 142 | # via tortoise-orm 143 | python-dotenv==1.1.0 144 | # via 145 | # pydantic-settings 146 | # uvicorn 147 | python-jose==3.3.0 148 | # via fastapi-tortoise-mysql 149 | python-multipart==0.0.9 150 | # via 151 | # fastapi 152 | # fastapi-tortoise-mysql 153 | pytz==2025.2 154 | # via tortoise-orm 155 | pyyaml==6.0.2 156 | # via 157 | # fastapi 158 | # pre-commit 159 | # uvicorn 160 | redis==5.0.4 161 | # via 162 | # fastapi-limiter 163 | # fastapi-tortoise-mysql 164 | rich==14.0.0 165 | # via 166 | # rich-toolkit 167 | # typer 168 | rich-toolkit==0.14.1 169 | # via fastapi-cli 170 | rsa==4.9 171 | # via python-jose 172 | shellingham==1.5.4 173 | # via typer 174 | six==1.17.0 175 | # via ecdsa 176 | sniffio==1.3.1 177 | # via anyio 178 | starlette==0.37.2 179 | # via fastapi 180 | tomlkit==0.13.2 181 | # via aerich 182 | tortoise-orm==0.24.0 183 | # via 184 | # aerich 185 | # fastapi-tortoise-mysql 186 | typer==0.15.2 187 | # via fastapi-cli 188 | typing-extensions==4.13.2 189 | # via 190 | # aiosqlite 191 | # anyio 192 | # asgiref 193 | # fastapi 194 | # fastapi-pagination 195 | # pydantic 196 | # pydantic-core 197 | # pydantic-extra-types 198 | # rich 199 | # rich-toolkit 200 | # typer 201 | # typing-inspection 202 | # uvicorn 203 | typing-inspection==0.4.0 204 | # via pydantic 205 | tzdata==2025.1 206 | # via fastapi-tortoise-mysql 207 | ujson==5.10.0 208 | # via fastapi 209 | uvicorn==0.34.0 210 | # via 211 | # fastapi 212 | # fastapi-cli 213 | uvloop==0.21.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' 214 | # via uvicorn 215 | virtualenv==20.30.0 216 | # via pre-commit 217 | watchfiles==1.0.5 218 | # via uvicorn 219 | websockets==15.0.1 220 | # via uvicorn 221 | win32-setctime==1.2.0 ; sys_platform == 'win32' 222 | # via loguru 223 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------