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.
Files changed (67) hide show
  1. fastapp_cli/__init__.py +3 -0
  2. fastapp_cli/create.py +157 -0
  3. fastapp_cli/main.py +33 -0
  4. fastapp_cli/naming.py +39 -0
  5. fastapp_cli/prompts.py +35 -0
  6. fastapp_cli/render.py +130 -0
  7. fastapp_cli/templates/__init__.py +1 -0
  8. fastapp_cli/templates/project/.env.development.example.j2 +21 -0
  9. fastapp_cli/templates/project/.env.example.j2 +29 -0
  10. fastapp_cli/templates/project/.env.j2 +27 -0
  11. fastapp_cli/templates/project/.gitignore +178 -0
  12. fastapp_cli/templates/project/.pre-commit-config.yaml.j2 +80 -0
  13. fastapp_cli/templates/project/.python-version.j2 +1 -0
  14. fastapp_cli/templates/project/Dockerfile.j2 +17 -0
  15. fastapp_cli/templates/project/Makefile.j2 +31 -0
  16. fastapp_cli/templates/project/README.md.j2 +68 -0
  17. fastapp_cli/templates/project/alembic/env.py.j2 +84 -0
  18. fastapp_cli/templates/project/alembic/script.py.mako +28 -0
  19. fastapp_cli/templates/project/alembic/versions/.gitkeep +0 -0
  20. fastapp_cli/templates/project/alembic.ini.j2 +50 -0
  21. fastapp_cli/templates/project/app/__init__.py.j2 +1 -0
  22. fastapp_cli/templates/project/app/api/__init__.py.j2 +1 -0
  23. fastapp_cli/templates/project/app/api/deps.py.j2 +33 -0
  24. fastapp_cli/templates/project/app/api/v1/__init__.py.j2 +1 -0
  25. fastapp_cli/templates/project/app/api/v1/endpoints/__init__.py.j2 +1 -0
  26. fastapp_cli/templates/project/app/api/v1/endpoints/health.py.j2 +33 -0
  27. fastapp_cli/templates/project/app/api/v1/endpoints/items.py.j2 +90 -0
  28. fastapp_cli/templates/project/app/api/v1/router.py.j2 +9 -0
  29. fastapp_cli/templates/project/app/core/__init__.py.j2 +1 -0
  30. fastapp_cli/templates/project/app/core/celery_app.py.j2 +62 -0
  31. fastapp_cli/templates/project/app/core/config.py.j2 +105 -0
  32. fastapp_cli/templates/project/app/core/context_var.py.j2 +13 -0
  33. fastapp_cli/templates/project/app/core/database.py.j2 +50 -0
  34. fastapp_cli/templates/project/app/core/exceptions.py.j2 +175 -0
  35. fastapp_cli/templates/project/app/core/logging.py.j2 +125 -0
  36. fastapp_cli/templates/project/app/core/middleware.py.j2 +39 -0
  37. fastapp_cli/templates/project/app/crud/__init__.py.j2 +1 -0
  38. fastapp_cli/templates/project/app/crud/base.py.j2 +229 -0
  39. fastapp_cli/templates/project/app/crud/item.py.j2 +10 -0
  40. fastapp_cli/templates/project/app/main.py.j2 +118 -0
  41. fastapp_cli/templates/project/app/models/__init__.py.j2 +10 -0
  42. fastapp_cli/templates/project/app/models/base.py.j2 +59 -0
  43. fastapp_cli/templates/project/app/models/item.py.j2 +22 -0
  44. fastapp_cli/templates/project/app/schemas/__init__.py.j2 +1 -0
  45. fastapp_cli/templates/project/app/schemas/common.py.j2 +81 -0
  46. fastapp_cli/templates/project/app/schemas/item.py.j2 +35 -0
  47. fastapp_cli/templates/project/app/services/__init__.py.j2 +1 -0
  48. fastapp_cli/templates/project/app/services/base.py.j2 +79 -0
  49. fastapp_cli/templates/project/app/services/item_service.py.j2 +10 -0
  50. fastapp_cli/templates/project/app/tasks/__init__.py.j2 +1 -0
  51. fastapp_cli/templates/project/app/tasks/sample_tasks.py.j2 +28 -0
  52. fastapp_cli/templates/project/app/utils/__init__.py.j2 +1 -0
  53. fastapp_cli/templates/project/docs/SQLModel/345/256/232/344/271/211/347/244/272/344/276/213.md +400 -0
  54. fastapp_cli/templates/project/pm2.config.json.j2 +47 -0
  55. fastapp_cli/templates/project/pyproject.toml.j2 +195 -0
  56. fastapp_cli/templates/project/scripts/celery_beat.sh.j2 +9 -0
  57. fastapp_cli/templates/project/scripts/celery_flower.sh.j2 +22 -0
  58. fastapp_cli/templates/project/scripts/celery_worker.sh.j2 +15 -0
  59. fastapp_cli/templates/project/scripts/start.sh.j2 +17 -0
  60. fastapp_cli/templates/project/tests/api/test_health.py.j2 +15 -0
  61. fastapp_cli/templates/project/tests/api/test_items.py.j2 +61 -0
  62. fastapp_cli/templates/project/tests/conftest.py.j2 +61 -0
  63. fastapp_cli/templates/project/tests/services/test_item_service.py.j2 +44 -0
  64. fastapp_cli-0.1.0.dist-info/METADATA +102 -0
  65. fastapp_cli-0.1.0.dist-info/RECORD +67 -0
  66. fastapp_cli-0.1.0.dist-info/WHEEL +4 -0
  67. 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"""