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:
baozaotumao
2026-06-02 00:37:51 -07:00
parent 48bacdbed6
commit 434a4683ea
21 changed files with 283 additions and 8 deletions
+9
View File
@@ -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"]
+17 -3
View File
@@ -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:
"""配置 logurustderr(人类可读)+ {WORK_DIR}/logs/backend.logJSON 行)。
生产可改 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",
)
+23
View File
@@ -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
+20 -5
View File
@@ -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
+15
View File
@@ -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"}
+23
View File
@@ -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)
+26
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import uuid
def new_uuid() -> str:
"""生成新的 UUID4 字符串(用于 request_id / session_id 等)。"""
return str(uuid.uuid4())