fastapp-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapp_cli/__init__.py +3 -0
- fastapp_cli/create.py +157 -0
- fastapp_cli/main.py +33 -0
- fastapp_cli/naming.py +39 -0
- fastapp_cli/prompts.py +35 -0
- fastapp_cli/render.py +130 -0
- fastapp_cli/templates/__init__.py +1 -0
- fastapp_cli/templates/project/.env.development.example.j2 +21 -0
- fastapp_cli/templates/project/.env.example.j2 +29 -0
- fastapp_cli/templates/project/.env.j2 +27 -0
- fastapp_cli/templates/project/.gitignore +178 -0
- fastapp_cli/templates/project/.pre-commit-config.yaml.j2 +80 -0
- fastapp_cli/templates/project/.python-version.j2 +1 -0
- fastapp_cli/templates/project/Dockerfile.j2 +17 -0
- fastapp_cli/templates/project/Makefile.j2 +31 -0
- fastapp_cli/templates/project/README.md.j2 +68 -0
- fastapp_cli/templates/project/alembic/env.py.j2 +84 -0
- fastapp_cli/templates/project/alembic/script.py.mako +28 -0
- fastapp_cli/templates/project/alembic/versions/.gitkeep +0 -0
- fastapp_cli/templates/project/alembic.ini.j2 +50 -0
- fastapp_cli/templates/project/app/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/deps.py.j2 +33 -0
- fastapp_cli/templates/project/app/api/v1/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/v1/endpoints/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/api/v1/endpoints/health.py.j2 +33 -0
- fastapp_cli/templates/project/app/api/v1/endpoints/items.py.j2 +90 -0
- fastapp_cli/templates/project/app/api/v1/router.py.j2 +9 -0
- fastapp_cli/templates/project/app/core/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/core/celery_app.py.j2 +62 -0
- fastapp_cli/templates/project/app/core/config.py.j2 +105 -0
- fastapp_cli/templates/project/app/core/context_var.py.j2 +13 -0
- fastapp_cli/templates/project/app/core/database.py.j2 +50 -0
- fastapp_cli/templates/project/app/core/exceptions.py.j2 +175 -0
- fastapp_cli/templates/project/app/core/logging.py.j2 +125 -0
- fastapp_cli/templates/project/app/core/middleware.py.j2 +39 -0
- fastapp_cli/templates/project/app/crud/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/crud/base.py.j2 +229 -0
- fastapp_cli/templates/project/app/crud/item.py.j2 +10 -0
- fastapp_cli/templates/project/app/main.py.j2 +118 -0
- fastapp_cli/templates/project/app/models/__init__.py.j2 +10 -0
- fastapp_cli/templates/project/app/models/base.py.j2 +59 -0
- fastapp_cli/templates/project/app/models/item.py.j2 +22 -0
- fastapp_cli/templates/project/app/schemas/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/schemas/common.py.j2 +81 -0
- fastapp_cli/templates/project/app/schemas/item.py.j2 +35 -0
- fastapp_cli/templates/project/app/services/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/services/base.py.j2 +79 -0
- fastapp_cli/templates/project/app/services/item_service.py.j2 +10 -0
- fastapp_cli/templates/project/app/tasks/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/app/tasks/sample_tasks.py.j2 +28 -0
- fastapp_cli/templates/project/app/utils/__init__.py.j2 +1 -0
- fastapp_cli/templates/project/docs/SQLModel/345/256/232/344/271/211/347/244/272/344/276/213.md +400 -0
- fastapp_cli/templates/project/pm2.config.json.j2 +47 -0
- fastapp_cli/templates/project/pyproject.toml.j2 +195 -0
- fastapp_cli/templates/project/scripts/celery_beat.sh.j2 +9 -0
- fastapp_cli/templates/project/scripts/celery_flower.sh.j2 +22 -0
- fastapp_cli/templates/project/scripts/celery_worker.sh.j2 +15 -0
- fastapp_cli/templates/project/scripts/start.sh.j2 +17 -0
- fastapp_cli/templates/project/tests/api/test_health.py.j2 +15 -0
- fastapp_cli/templates/project/tests/api/test_items.py.j2 +61 -0
- fastapp_cli/templates/project/tests/conftest.py.j2 +61 -0
- fastapp_cli/templates/project/tests/services/test_item_service.py.j2 +44 -0
- fastapp_cli-0.1.0.dist-info/METADATA +102 -0
- fastapp_cli-0.1.0.dist-info/RECORD +67 -0
- fastapp_cli-0.1.0.dist-info/WHEEL +4 -0
- fastapp_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""应用配置管理.
|
|
2
|
+
|
|
3
|
+
基于 pydantic-settings 的统一配置中心,支持从环境变量 / .env 文件读取.
|
|
4
|
+
|
|
5
|
+
多环境配置加载顺序(后者覆盖前者):
|
|
6
|
+
1. .env 公共默认配置(入库 git)
|
|
7
|
+
2. .env.{APP_ENV} 当前环境差异配置(development / production / test)
|
|
8
|
+
3. 真实的系统环境变量 优先级最高
|
|
9
|
+
|
|
10
|
+
APP_ENV 的来源(按优先级从高到低):
|
|
11
|
+
- 系统环境变量 APP_ENV
|
|
12
|
+
- .env 文件中显式声明的 APP_ENV
|
|
13
|
+
- 默认值 "development"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from pydantic import MySQLDsn, ValidationInfo, field_validator
|
|
22
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
23
|
+
|
|
24
|
+
# 先从系统环境变量确定当前环境;.env 文件中的值无法用于决定加载哪个文件,
|
|
25
|
+
# 所以这一步只读系统变量,未设置时退回默认 development。
|
|
26
|
+
_APP_ENV = os.getenv("APP_ENV", "development")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Settings(BaseSettings):
|
|
30
|
+
"""应用配置.
|
|
31
|
+
|
|
32
|
+
所有环境变量均通过该类集中管理,避免散落在各处.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = SettingsConfigDict(env_file=[".env", f".env.{_APP_ENV}"], extra="ignore")
|
|
36
|
+
|
|
37
|
+
# ========== App ==========
|
|
38
|
+
APP_NAME: str = "{{ project_name }}"
|
|
39
|
+
APP_ENV: Literal["development", "production", "test"] = "development"
|
|
40
|
+
DEBUG: bool = False
|
|
41
|
+
API_V1_PREFIX: str = "/api/v1"
|
|
42
|
+
# CORS 配置
|
|
43
|
+
ALLOWED_ORIGINS: list[str] = ["*"]
|
|
44
|
+
|
|
45
|
+
# ========== Security ==========
|
|
46
|
+
# 认证占位:非生产环境直接返回该用户;生产环境需接入真实认证(见 app/api/deps.py)
|
|
47
|
+
MOCK_USER: str = "dev"
|
|
48
|
+
|
|
49
|
+
# ========== Server ==========
|
|
50
|
+
HOST: str = "0.0.0.0"
|
|
51
|
+
PORT: int = 8000
|
|
52
|
+
|
|
53
|
+
# ========== Database (MySQL) ==========
|
|
54
|
+
MYSQL_SERVER: str = "localhost"
|
|
55
|
+
MYSQL_PORT: int = 3306
|
|
56
|
+
MYSQL_USER: str = ""
|
|
57
|
+
MYSQL_PASSWORD: str = ""
|
|
58
|
+
MYSQL_DB: str = ""
|
|
59
|
+
DATABASE_URI: str | None = None
|
|
60
|
+
|
|
61
|
+
@field_validator("DATABASE_URI", mode="before")
|
|
62
|
+
@classmethod
|
|
63
|
+
def assemble_db_uri(cls, v: str | None, info: ValidationInfo) -> str:
|
|
64
|
+
"""当未显式指定 DATABASE_URI 时,自动拼装 MySQL 链接."""
|
|
65
|
+
if isinstance(v, str) and v:
|
|
66
|
+
return v
|
|
67
|
+
values = info.data
|
|
68
|
+
dsn = MySQLDsn.build(
|
|
69
|
+
scheme="mysql+pymysql",
|
|
70
|
+
username=values.get("MYSQL_USER"),
|
|
71
|
+
password=values.get("MYSQL_PASSWORD"),
|
|
72
|
+
host=values.get("MYSQL_SERVER", ""),
|
|
73
|
+
port=values.get("MYSQL_PORT"),
|
|
74
|
+
path=values.get("MYSQL_DB") or "",
|
|
75
|
+
query="charset=utf8mb4",
|
|
76
|
+
)
|
|
77
|
+
return str(dsn)
|
|
78
|
+
|
|
79
|
+
SQL_ECHO: bool = False
|
|
80
|
+
DB_POOL_SIZE: int = 10
|
|
81
|
+
DB_MAX_OVERFLOW: int = 20
|
|
82
|
+
|
|
83
|
+
# ========== Celery 配置 ==========
|
|
84
|
+
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
|
85
|
+
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
|
86
|
+
CELERY_TASK_DEFAULT_QUEUE: str = "default"
|
|
87
|
+
|
|
88
|
+
# ========== Flower ==========
|
|
89
|
+
FLOWER_HOST: str = "0.0.0.0"
|
|
90
|
+
FLOWER_PORT: int = 5555
|
|
91
|
+
# 基本认证,格式:user:password,多个用逗号分隔
|
|
92
|
+
FLOWER_BASIC_AUTH: str | None = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_production(self) -> bool:
|
|
96
|
+
"""是否为生产环境."""
|
|
97
|
+
return self.APP_ENV == "production"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_test(self) -> bool:
|
|
101
|
+
"""是否为测试环境."""
|
|
102
|
+
return self.APP_ENV == "test"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
settings = Settings()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""请求级上下文变量."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
|
|
7
|
+
# 请求级 trace_id 上下文变量,供日志、异常处理器、业务代码共享读取
|
|
8
|
+
trace_id_var: ContextVar[str] = ContextVar("trace_id")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_trace_id() -> str:
|
|
12
|
+
"""获取当前请求的 trace_id,无请求上下文时返回空串."""
|
|
13
|
+
return trace_id_var.get("")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""数据库引擎与 Session 管理."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlmodel import Session, SQLModel, create_engine
|
|
9
|
+
|
|
10
|
+
from app.core.config import settings
|
|
11
|
+
|
|
12
|
+
# SQLite(测试场景)不支持连接池参数,需要单独处理
|
|
13
|
+
_db_uri = str(settings.DATABASE_URI)
|
|
14
|
+
_engine_kwargs: dict[str, Any] = {
|
|
15
|
+
"echo": settings.SQL_ECHO,
|
|
16
|
+
"pool_pre_ping": True,
|
|
17
|
+
}
|
|
18
|
+
if _db_uri.startswith("sqlite"):
|
|
19
|
+
_engine_kwargs["connect_args"] = {"check_same_thread": False}
|
|
20
|
+
else:
|
|
21
|
+
_engine_kwargs["pool_size"] = settings.DB_POOL_SIZE
|
|
22
|
+
_engine_kwargs["max_overflow"] = settings.DB_MAX_OVERFLOW
|
|
23
|
+
# MySQL 长连接回收,避免 wait_timeout 踢出导致的 2006 错误
|
|
24
|
+
_engine_kwargs["pool_recycle"] = 3600
|
|
25
|
+
# MySQL/PostgreSQL 事务隔离级别,避免脏读
|
|
26
|
+
_engine_kwargs["isolation_level"] = "REPEATABLE READ"
|
|
27
|
+
# 连接超时:数据库未就绪时快速失败,供启动探测降级
|
|
28
|
+
_engine_kwargs["connect_args"] = {"connect_timeout": 3}
|
|
29
|
+
|
|
30
|
+
engine = create_engine(_db_uri, **_engine_kwargs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init_db() -> None:
|
|
34
|
+
"""初始化数据库,创建所有表.
|
|
35
|
+
|
|
36
|
+
生产环境请使用 alembic 迁移,此处仅用于本地 / 测试快速建表.
|
|
37
|
+
"""
|
|
38
|
+
# 导入 models 以便 SQLModel.metadata 能够注册所有表
|
|
39
|
+
from app import models # noqa: F401
|
|
40
|
+
|
|
41
|
+
SQLModel.metadata.create_all(engine)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_session() -> Generator[Session, None, None]:
|
|
45
|
+
"""FastAPI 依赖:获取数据库会话.
|
|
46
|
+
|
|
47
|
+
使用 yield 语法保证异常时 Session 正常关闭.
|
|
48
|
+
"""
|
|
49
|
+
with Session(engine) as session:
|
|
50
|
+
yield session
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""自定义异常与全局异常处理器.
|
|
2
|
+
|
|
3
|
+
使用示例::
|
|
4
|
+
|
|
5
|
+
from app.core.exceptions import NotFoundError, AppException
|
|
6
|
+
|
|
7
|
+
# 1. 使用预定义异常
|
|
8
|
+
if user is None:
|
|
9
|
+
raise NotFoundError(message="用户不存在")
|
|
10
|
+
|
|
11
|
+
# 2. 自定义业务异常(自定义业务码)
|
|
12
|
+
raise AppExceptionError(
|
|
13
|
+
message="余额不足",
|
|
14
|
+
code=10001,
|
|
15
|
+
status_code=400,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
返回体统一格式::
|
|
19
|
+
|
|
20
|
+
{"code": 10001, "message": "余额不足", "data": null}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from fastapi import FastAPI, Request, status
|
|
28
|
+
from fastapi.encoders import jsonable_encoder
|
|
29
|
+
from fastapi.exceptions import RequestValidationError
|
|
30
|
+
from fastapi.responses import JSONResponse
|
|
31
|
+
from loguru import logger
|
|
32
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
33
|
+
|
|
34
|
+
from app.core.context_var import get_trace_id
|
|
35
|
+
from app.core.middleware import TRACE_ID_HEADER
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AppExceptionError(Exception):
|
|
39
|
+
"""业务异常基类.
|
|
40
|
+
|
|
41
|
+
用于区分业务异常与系统异常. 所有业务异常统一继承此类.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
status_code: int = status.HTTP_400_BAD_REQUEST
|
|
45
|
+
code: int = 1
|
|
46
|
+
message: str = "业务异常"
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
message: str | None = None,
|
|
51
|
+
*,
|
|
52
|
+
code: int | None = None,
|
|
53
|
+
status_code: int | None = None,
|
|
54
|
+
data: Any = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""构造业务异常.
|
|
57
|
+
|
|
58
|
+
:param message: 异常信息
|
|
59
|
+
:param code: 业务状态码
|
|
60
|
+
:param status_code: HTTP 状态码
|
|
61
|
+
:param data: 附加数据
|
|
62
|
+
"""
|
|
63
|
+
if message is not None:
|
|
64
|
+
self.message = message
|
|
65
|
+
if code is not None:
|
|
66
|
+
self.code = code
|
|
67
|
+
if status_code is not None:
|
|
68
|
+
self.status_code = status_code
|
|
69
|
+
self.data = data
|
|
70
|
+
super().__init__(self.message)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NotFoundError(AppExceptionError):
|
|
74
|
+
"""资源不存在."""
|
|
75
|
+
|
|
76
|
+
status_code = status.HTTP_404_NOT_FOUND
|
|
77
|
+
code = 40400
|
|
78
|
+
message = "资源不存在"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class UnauthorizedError(AppExceptionError):
|
|
82
|
+
"""未认证."""
|
|
83
|
+
|
|
84
|
+
status_code = status.HTTP_401_UNAUTHORIZED
|
|
85
|
+
code = 40100
|
|
86
|
+
message = "未认证或凭证已失效"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ForbiddenError(AppExceptionError):
|
|
90
|
+
"""无权限."""
|
|
91
|
+
|
|
92
|
+
status_code = status.HTTP_403_FORBIDDEN
|
|
93
|
+
code = 40300
|
|
94
|
+
message = "无访问权限"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ConflictError(AppExceptionError):
|
|
98
|
+
"""资源冲突."""
|
|
99
|
+
|
|
100
|
+
status_code = status.HTTP_409_CONFLICT
|
|
101
|
+
code = 40900
|
|
102
|
+
message = "资源冲突"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _make_response(
|
|
106
|
+
*,
|
|
107
|
+
status_code: int,
|
|
108
|
+
code: int,
|
|
109
|
+
message: str,
|
|
110
|
+
data: Any = None,
|
|
111
|
+
) -> JSONResponse:
|
|
112
|
+
"""统一的错误返回体."""
|
|
113
|
+
trace_id = get_trace_id()
|
|
114
|
+
content: dict[str, Any] = {
|
|
115
|
+
"code": code,
|
|
116
|
+
"message": message,
|
|
117
|
+
"data": data,
|
|
118
|
+
"trace_id": trace_id or None,
|
|
119
|
+
}
|
|
120
|
+
headers = {TRACE_ID_HEADER: trace_id} if trace_id else None
|
|
121
|
+
return JSONResponse(
|
|
122
|
+
status_code=status_code,
|
|
123
|
+
content=jsonable_encoder(content),
|
|
124
|
+
headers=headers,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
129
|
+
"""注册全局异常处理器.
|
|
130
|
+
|
|
131
|
+
:param app: FastAPI 应用实例
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
@app.exception_handler(AppExceptionError)
|
|
135
|
+
async def handle_app_exception(_: Request, exc: AppExceptionError) -> JSONResponse:
|
|
136
|
+
logger.warning("AppException: code={} message={}", exc.code, exc.message)
|
|
137
|
+
return _make_response(
|
|
138
|
+
status_code=exc.status_code,
|
|
139
|
+
code=exc.code,
|
|
140
|
+
message=exc.message,
|
|
141
|
+
data=exc.data,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@app.exception_handler(RequestValidationError)
|
|
145
|
+
async def handle_validation_error(
|
|
146
|
+
_: Request,
|
|
147
|
+
exc: RequestValidationError,
|
|
148
|
+
) -> JSONResponse:
|
|
149
|
+
logger.warning("Validation error: {}", exc.errors())
|
|
150
|
+
return _make_response(
|
|
151
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
152
|
+
code=42200,
|
|
153
|
+
message="参数校验失败",
|
|
154
|
+
data=exc.errors(),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@app.exception_handler(StarletteHTTPException)
|
|
158
|
+
async def handle_http_exception(
|
|
159
|
+
_: Request,
|
|
160
|
+
exc: StarletteHTTPException,
|
|
161
|
+
) -> JSONResponse:
|
|
162
|
+
return _make_response(
|
|
163
|
+
status_code=exc.status_code,
|
|
164
|
+
code=exc.status_code * 100,
|
|
165
|
+
message=str(exc.detail),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@app.exception_handler(Exception)
|
|
169
|
+
async def handle_unknown_exception(_: Request, exc: Exception) -> JSONResponse:
|
|
170
|
+
logger.exception("Unhandled exception: {}", exc)
|
|
171
|
+
return _make_response(
|
|
172
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
173
|
+
code=50000,
|
|
174
|
+
message="服务器内部错误",
|
|
175
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""日志配置 - 基于 loguru."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from app.core.config import settings
|
|
15
|
+
from app.core.context_var import get_trace_id, trace_id_var
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def with_trace(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
19
|
+
"""包装函数,在执行时把当前请求的 trace_id 透传到上下文与日志.
|
|
20
|
+
|
|
21
|
+
用于 ``BackgroundTasks.add_task`` 等"脱离请求生命周期"的场景:
|
|
22
|
+
在调用 ``with_trace`` 的瞬间(仍处于请求上下文)抓取 trace_id,
|
|
23
|
+
在被包装函数真正执行时再写回 ``ContextVar`` 与 loguru extra。
|
|
24
|
+
|
|
25
|
+
:param func: 待包装的同步或异步函数
|
|
26
|
+
:return: 包装后的函数,签名与原函数一致
|
|
27
|
+
"""
|
|
28
|
+
trace_id = get_trace_id() or "-"
|
|
29
|
+
|
|
30
|
+
if inspect.iscoroutinefunction(func):
|
|
31
|
+
@functools.wraps(func)
|
|
32
|
+
async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
33
|
+
token = trace_id_var.set(trace_id)
|
|
34
|
+
try:
|
|
35
|
+
with logger.contextualize(trace_id=trace_id):
|
|
36
|
+
return await func(*args, **kwargs)
|
|
37
|
+
finally:
|
|
38
|
+
trace_id_var.reset(token)
|
|
39
|
+
|
|
40
|
+
return _async_wrapper
|
|
41
|
+
|
|
42
|
+
@functools.wraps(func)
|
|
43
|
+
def _sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
44
|
+
token = trace_id_var.set(trace_id)
|
|
45
|
+
try:
|
|
46
|
+
with logger.contextualize(trace_id=trace_id):
|
|
47
|
+
return func(*args, **kwargs)
|
|
48
|
+
finally:
|
|
49
|
+
trace_id_var.reset(token)
|
|
50
|
+
|
|
51
|
+
return _sync_wrapper
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InterceptHandler(logging.Handler):
|
|
55
|
+
"""拦截标准 logging 输出,统一转发到 loguru."""
|
|
56
|
+
|
|
57
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
58
|
+
"""重写 emit 方法."""
|
|
59
|
+
try:
|
|
60
|
+
level: str | int = logger.level(record.levelname).name
|
|
61
|
+
except ValueError:
|
|
62
|
+
level = record.levelno
|
|
63
|
+
|
|
64
|
+
# 读取当前请求上下文的 trace_id;logger.patch 中创建的 logger 实例不会自动继承
|
|
65
|
+
# contextualize 注入的 extra,需在 patch 回调里显式写入 record.extra。
|
|
66
|
+
trace_id = get_trace_id() or "-"
|
|
67
|
+
|
|
68
|
+
# 直接使用 record 自带的源信息(name/funcName/lineno),通过 logger.patch
|
|
69
|
+
# 强制覆盖 loguru 推断出的调用位置。这样无论标准 logging 内部经过多少层
|
|
70
|
+
# 调用(callHandlers/handle/emit 等),都能准确定位到真正的业务调用方。
|
|
71
|
+
def _patcher(r: dict) -> None:
|
|
72
|
+
r["name"] = record.name
|
|
73
|
+
r["function"] = record.funcName
|
|
74
|
+
r["line"] = record.lineno
|
|
75
|
+
r["extra"]["trace_id"] = trace_id
|
|
76
|
+
|
|
77
|
+
patched = logger.patch(_patcher) # type: ignore
|
|
78
|
+
patched.opt(depth=0, exception=record.exc_info).log(
|
|
79
|
+
level,
|
|
80
|
+
record.getMessage(),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def setup_logging() -> None:
|
|
85
|
+
"""初始化全局日志."""
|
|
86
|
+
# 移除默认 handler
|
|
87
|
+
logger.remove()
|
|
88
|
+
|
|
89
|
+
# 为 extra 字段提供默认值,避免在无请求上下文(启动 / 后台任务)时格式化报错
|
|
90
|
+
logger.configure(extra={"trace_id": "-"})
|
|
91
|
+
|
|
92
|
+
log_format = (
|
|
93
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
94
|
+
"<level>{level: <8}</level> | "
|
|
95
|
+
"<cyan>{extra[trace_id]}</cyan> | "
|
|
96
|
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
|
97
|
+
"<level>{message}</level>"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 控制台输出
|
|
101
|
+
logger.add(
|
|
102
|
+
sys.stdout,
|
|
103
|
+
format=log_format,
|
|
104
|
+
level="DEBUG" if settings.DEBUG else "INFO",
|
|
105
|
+
colorize=True,
|
|
106
|
+
backtrace=True,
|
|
107
|
+
diagnose=settings.DEBUG,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# 拦截第三方库的标准 logging
|
|
111
|
+
# 1. 替换 root logger 的 handler,确保所有未单独配置的 logger 都走 InterceptHandler
|
|
112
|
+
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
|
113
|
+
|
|
114
|
+
# 2. 清理所有已存在的 logger 的 handler,并启用 propagate,避免重复输出
|
|
115
|
+
# (特别是 SQLAlchemy 在 echo=True 时会给 sqlalchemy.engine.Engine 单独添加 handler)
|
|
116
|
+
for logger_name in list(logging.root.manager.loggerDict.keys()):
|
|
117
|
+
existing_logger = logging.getLogger(logger_name)
|
|
118
|
+
existing_logger.handlers = []
|
|
119
|
+
existing_logger.propagate = True
|
|
120
|
+
|
|
121
|
+
# 3. 显式为关注的第三方库 logger 设置级别(确保不会被各自默认级别屏蔽)
|
|
122
|
+
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi", "sqlalchemy.engine"):
|
|
123
|
+
logging_logger = logging.getLogger(name)
|
|
124
|
+
logging_logger.handlers = []
|
|
125
|
+
logging_logger.propagate = True
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""请求级中间件 - 注入 trace_id 与请求耗时日志."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import Request, Response
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
10
|
+
|
|
11
|
+
from app.core.context_var import trace_id_var
|
|
12
|
+
|
|
13
|
+
TRACE_ID_HEADER = "X-Trace-ID"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TraceIDMiddleware(BaseHTTPMiddleware):
|
|
17
|
+
"""注入 trace_id 并记录请求耗时.
|
|
18
|
+
|
|
19
|
+
- 优先复用上游传入的 ``X-Trace-ID``,否则自动生成 UUID4
|
|
20
|
+
- 写入 ``ContextVar`` 与 ``loguru.contextualize``,使日志自动携带 trace_id
|
|
21
|
+
- 响应头回写 ``X-Trace-ID``,便于前端/网关链路追踪
|
|
22
|
+
- 不捕获业务异常,统一交由全局 exception handler 处理
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
async def dispatch(
|
|
26
|
+
self,
|
|
27
|
+
request: Request,
|
|
28
|
+
call_next: RequestResponseEndpoint,
|
|
29
|
+
) -> Response:
|
|
30
|
+
trace_id = request.headers.get(TRACE_ID_HEADER) or uuid.uuid4().hex[:16]
|
|
31
|
+
token = trace_id_var.set(trace_id)
|
|
32
|
+
try:
|
|
33
|
+
with logger.contextualize(trace_id=trace_id):
|
|
34
|
+
logger.info("{method} {path}", method=request.method, path=request.url.path)
|
|
35
|
+
response = await call_next(request)
|
|
36
|
+
response.headers[TRACE_ID_HEADER] = trace_id
|
|
37
|
+
return response
|
|
38
|
+
finally:
|
|
39
|
+
trace_id_var.reset(token)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""app · crud"""
|