feat: backend 架构基座(copier 预生成,与功能无关的横切层)
每个生成的 backend 第一天即有完整分层结构 + 能跑的横切基础设施:
- 空层包:domain / application/services / infrastructure/db(待功能填)
- routers:health(存活/就绪) + ws(WebSocket 代理骨架)
- middleware:request_id(注入+loguru 上下文)+ access_log
- exceptions:AppException 基类 + 统一 handler(标准错误响应)
- infrastructure/db/base:engine/session/Base(SQLite WAL+busy_timeout / PG 连接池)+ get_db
- utils:ids + files(work_dir/会话目录/防穿越)
- core/logging:加文件 sink 到 {WORK_DIR}/logs(运行时建,gitignore)
- main.py:装配中间件/异常/路由
- 运行时目录(logs、{uuid} 临时)由代码创建,不入库
验证:sqlite/postgresql 两配置 ruff+mypy 全过,import 成功,7 条路由就位。
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
"""FastAPI 依赖注入装配点。
|
||||||
|
|
||||||
|
业务 service / repository 的依赖在此组装:路由依赖端口,端口实现在边界注入。
|
||||||
|
功能开发时把 get_xxx_repo / get_xxx_service 加在这里(见 .claude/rules/backend.md)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..infrastructure.db.base import get_db
|
||||||
|
|
||||||
|
__all__ = ["get_db"]
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str = "INFO") -> None:
|
def configure_logging(
|
||||||
"""配置 loguru:结构化日志、单一 stderr sink。
|
level: str = "INFO", work_dir: str = "~/{{ project_slug }}"
|
||||||
|
) -> None:
|
||||||
|
"""配置 loguru:stderr(人类可读)+ {WORK_DIR}/logs/backend.log(JSON 行)。
|
||||||
|
|
||||||
生产可改 serialize=True 输出 JSON、或加文件 sink(见 .claude/rules/observability.md)。
|
日志目录在运行时按需创建(不提交 git)。详见 .claude/rules/observability.md。
|
||||||
"""
|
"""
|
||||||
logger.remove()
|
logger.remove()
|
||||||
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
|
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
|
||||||
|
|
||||||
|
log_dir = Path(work_dir).expanduser() / "logs"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.add(
|
||||||
|
log_dir / "backend.log",
|
||||||
|
level=level.upper(),
|
||||||
|
serialize=True, # JSON 每行一条
|
||||||
|
rotation="100 MB",
|
||||||
|
retention="14 days",
|
||||||
|
compression="zip",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class AppException(Exception): # noqa: N818 # 规则约定名为 AppException(见 backend.md)
|
||||||
|
"""业务异常基类。
|
||||||
|
|
||||||
|
`code` 对应错误码契约(见 .claude/rules/error-codes.md),前端按 code 分支处理。
|
||||||
|
禁止在路由层直接 raise HTTPException——抛 AppException 子类,
|
||||||
|
由 error_handler 统一转译。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str, status_code: int = 500) -> None:
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidInputError(AppException):
|
||||||
|
def __init__(self, message: str = "输入校验失败") -> None:
|
||||||
|
super().__init__("INVALID_INPUT", message, status_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
class InternalError(AppException):
|
||||||
|
def __init__(self, message: str = "服务器内部错误") -> None:
|
||||||
|
super().__init__("INTERNAL_ERROR", message, status_code=500)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .base import AppException
|
||||||
|
|
||||||
|
|
||||||
|
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
logger.warning("AppException | code={} msg={}", exc.code, exc.message)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"error": {
|
||||||
|
"code": exc.code,
|
||||||
|
"message": exc.message,
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
logger.exception("Unhandled exception")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={
|
||||||
|
"error": {
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": "服务器内部错误",
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
# handler 形参类型为具体异常,与 Starlette 宽签名不完全一致,忽略该告警
|
||||||
|
app.add_exception_handler(AppException, app_exception_handler) # type: ignore[arg-type]
|
||||||
|
app.add_exception_handler(Exception, global_exception_handler)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
{% if database == 'sqlite' -%}
|
||||||
|
from sqlalchemy import event
|
||||||
|
{% endif -%}
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from ...core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""所有 ORM 模型的声明基类。"""
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=settings.env == "dev",
|
||||||
|
{%- if database == 'sqlite' %}
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
{%- elif database == 'postgresql' %}
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
{%- endif %}
|
||||||
|
)
|
||||||
|
{% if database == 'sqlite' %}
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _set_sqlite_pragma(dbapi_conn: object, _: object) -> None:
|
||||||
|
cursor = dbapi_conn.cursor() # type: ignore[attr-defined]
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL") # 读写不互斥
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000") # 锁等待 5s 而非立即报错
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
{% endif %}
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""请求级会话:提交/回滚由本依赖统一管理。"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -3,9 +3,13 @@ from loguru import logger
|
|||||||
|
|
||||||
from .core.config import get_settings
|
from .core.config import get_settings
|
||||||
from .core.logging import configure_logging
|
from .core.logging import configure_logging
|
||||||
|
from .exceptions.handlers import register_exception_handlers
|
||||||
|
from .middleware.access_log import AccessLogMiddleware
|
||||||
|
from .middleware.request_id import RequestIdMiddleware
|
||||||
|
from .routers import health, ws
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level, settings.work_dir)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="{{ project_name }} Backend",
|
title="{{ project_name }} Backend",
|
||||||
@@ -14,14 +18,25 @@ app = FastAPI(
|
|||||||
openapi_url="/openapi.json" if settings.enable_api_docs else None,
|
openapi_url="/openapi.json" if settings.enable_api_docs else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 中间件(后添加的先执行):先打 request_id,再记访问日志
|
||||||
|
app.add_middleware(AccessLogMiddleware)
|
||||||
|
app.add_middleware(RequestIdMiddleware)
|
||||||
|
|
||||||
@app.get("/health")
|
# 统一异常 → 标准错误响应
|
||||||
async def health() -> dict[str, str]:
|
register_exception_handlers(app)
|
||||||
return {"status": "ok"}
|
|
||||||
|
# 路由
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(ws.router)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
logger.info("Starting backend | port={}", settings.port)
|
logger.info("Starting backend | port={}", settings.port)
|
||||||
uvicorn.run("src.main:app", host="0.0.0.0", port=settings.port, reload=True)
|
uvicorn.run(
|
||||||
|
"src.main:app",
|
||||||
|
host="0.0.0.0", # noqa: S104 容器内绑定 0.0.0.0 属预期
|
||||||
|
port=settings.port,
|
||||||
|
reload=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""结构化记录每个 HTTP 请求:method / path / status / 耗时。"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||||
|
) -> Response:
|
||||||
|
start = time.perf_counter()
|
||||||
|
response = await call_next(request)
|
||||||
|
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||||
|
logger.info(
|
||||||
|
"access | method={} path={} status={} duration_ms={}",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
duration_ms,
|
||||||
|
)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from ..utils.ids import new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""为每个请求注入 X-Request-Id,并绑定到 loguru 上下文,贯穿全链路日志。"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||||
|
) -> Response:
|
||||||
|
request_id = request.headers.get("X-Request-Id") or new_uuid()
|
||||||
|
request.state.request_id = request_id
|
||||||
|
with logger.contextualize(request_id=request_id):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-Id"] = request_id
|
||||||
|
return response
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
"""存活探针。"""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready")
|
||||||
|
async def ready() -> dict[str, str]:
|
||||||
|
"""就绪探针:可在此扩展依赖检查(DB 连通等)。"""
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/{session_id}")
|
||||||
|
async def ws_endpoint(ws: WebSocket, session_id: str) -> None:
|
||||||
|
"""WebSocket 接入点(骨架)。
|
||||||
|
|
||||||
|
架构定位:Backend 是 WS 代理层{% if include_agent %},转发 Browser ↔ Agent{% endif %}。
|
||||||
|
功能开发时在此接入会话服务{% if include_agent %} / Agent 转发{% endif %},
|
||||||
|
见 .claude/rules/backend.md、communication-protocol.md。
|
||||||
|
"""
|
||||||
|
await ws.accept()
|
||||||
|
logger.info("ws connected | session_id={}", session_id)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await ws.receive_json()
|
||||||
|
# TODO: 校验信封 → 分发命令 → 回推事件(见 communication-catalog.md)
|
||||||
|
await ws.send_json({"type": "ack", "received": msg.get("type")})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("ws disconnected | session_id={}", session_id)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def expand(path: str) -> Path:
|
||||||
|
"""展开 ~ 与环境变量,返回绝对 Path。"""
|
||||||
|
return Path(path).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> Path:
|
||||||
|
"""确保目录存在(含父级),返回该目录。"""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def session_dir(work_dir: str, session_id: str) -> Path:
|
||||||
|
"""某会话的独立工作目录 {work_dir}/{session_id}/,不存在则创建。"""
|
||||||
|
return ensure_dir(expand(work_dir) / session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_within(work_dir: str, candidate: str) -> Path:
|
||||||
|
"""校验 candidate 解析后仍在 work_dir 内(防路径穿越),否则抛 ValueError。"""
|
||||||
|
root = expand(work_dir)
|
||||||
|
target = (root / candidate).resolve()
|
||||||
|
if not target.is_relative_to(root):
|
||||||
|
raise ValueError(f"路径越界: {candidate}")
|
||||||
|
return target
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
"""生成新的 UUID4 字符串(用于 request_id / session_id 等)。"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
Reference in New Issue
Block a user