From 434a4683ea2c5e17ef9e50a3016406e6279357f6 Mon Sep 17 00:00:00 2001 From: baozaotumao Date: Tue, 2 Jun 2026 00:37:51 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20backend=20=E6=9E=B6=E6=9E=84=E5=9F=BA?= =?UTF-8?q?=E5=BA=A7=EF=BC=88copier=20=E9=A2=84=E7=94=9F=E6=88=90=EF=BC=8C?= =?UTF-8?q?=E4=B8=8E=E5=8A=9F=E8=83=BD=E6=97=A0=E5=85=B3=E7=9A=84=E6=A8=AA?= =?UTF-8?q?=E5=88=87=E5=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个生成的 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 条路由就位。 --- template/backend/src/application/__init__.py | 0 .../src/application/services/__init__.py | 0 template/backend/src/core/deps.py | 9 +++ template/backend/src/core/logging.py.jinja | 20 ++++++- template/backend/src/domain/__init__.py | 0 template/backend/src/exceptions/__init__.py | 0 template/backend/src/exceptions/base.py | 23 ++++++++ template/backend/src/exceptions/handlers.py | 41 ++++++++++++++ .../backend/src/infrastructure/__init__.py | 0 .../backend/src/infrastructure/db/__init__.py | 0 .../src/infrastructure/db/base.py.jinja | 55 +++++++++++++++++++ template/backend/src/main.py.jinja | 25 +++++++-- template/backend/src/middleware/__init__.py | 0 template/backend/src/middleware/access_log.py | 26 +++++++++ template/backend/src/middleware/request_id.py | 22 ++++++++ template/backend/src/routers/__init__.py | 0 template/backend/src/routers/health.py | 15 +++++ template/backend/src/routers/ws.py.jinja | 23 ++++++++ template/backend/src/utils/__init__.py | 0 template/backend/src/utils/files.py | 26 +++++++++ template/backend/src/utils/ids.py | 6 ++ 21 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 template/backend/src/application/__init__.py create mode 100644 template/backend/src/application/services/__init__.py create mode 100644 template/backend/src/core/deps.py create mode 100644 template/backend/src/domain/__init__.py create mode 100644 template/backend/src/exceptions/__init__.py create mode 100644 template/backend/src/exceptions/base.py create mode 100644 template/backend/src/exceptions/handlers.py create mode 100644 template/backend/src/infrastructure/__init__.py create mode 100644 template/backend/src/infrastructure/db/__init__.py create mode 100644 template/backend/src/infrastructure/db/base.py.jinja create mode 100644 template/backend/src/middleware/__init__.py create mode 100644 template/backend/src/middleware/access_log.py create mode 100644 template/backend/src/middleware/request_id.py create mode 100644 template/backend/src/routers/__init__.py create mode 100644 template/backend/src/routers/health.py create mode 100644 template/backend/src/routers/ws.py.jinja create mode 100644 template/backend/src/utils/__init__.py create mode 100644 template/backend/src/utils/files.py create mode 100644 template/backend/src/utils/ids.py diff --git a/template/backend/src/application/__init__.py b/template/backend/src/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/application/services/__init__.py b/template/backend/src/application/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/core/deps.py b/template/backend/src/core/deps.py new file mode 100644 index 0000000..f0e666b --- /dev/null +++ b/template/backend/src/core/deps.py @@ -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"] diff --git a/template/backend/src/core/logging.py.jinja b/template/backend/src/core/logging.py.jinja index d8f2d66..4bee08a 100644 --- a/template/backend/src/core/logging.py.jinja +++ b/template/backend/src/core/logging.py.jinja @@ -1,12 +1,26 @@ import sys +from pathlib import Path from loguru import logger -def configure_logging(level: str = "INFO") -> None: - """配置 loguru:结构化日志、单一 stderr sink。 +def configure_logging( + 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.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", + ) diff --git a/template/backend/src/domain/__init__.py b/template/backend/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/exceptions/__init__.py b/template/backend/src/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/exceptions/base.py b/template/backend/src/exceptions/base.py new file mode 100644 index 0000000..4e058c1 --- /dev/null +++ b/template/backend/src/exceptions/base.py @@ -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) diff --git a/template/backend/src/exceptions/handlers.py b/template/backend/src/exceptions/handlers.py new file mode 100644 index 0000000..820e0be --- /dev/null +++ b/template/backend/src/exceptions/handlers.py @@ -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) diff --git a/template/backend/src/infrastructure/__init__.py b/template/backend/src/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/infrastructure/db/__init__.py b/template/backend/src/infrastructure/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/infrastructure/db/base.py.jinja b/template/backend/src/infrastructure/db/base.py.jinja new file mode 100644 index 0000000..cb1b3d8 --- /dev/null +++ b/template/backend/src/infrastructure/db/base.py.jinja @@ -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 diff --git a/template/backend/src/main.py.jinja b/template/backend/src/main.py.jinja index e3138ab..2ac0bc4 100644 --- a/template/backend/src/main.py.jinja +++ b/template/backend/src/main.py.jinja @@ -3,9 +3,13 @@ from loguru import logger from .core.config import get_settings 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() -configure_logging(settings.log_level) +configure_logging(settings.log_level, settings.work_dir) app = FastAPI( title="{{ project_name }} Backend", @@ -14,14 +18,25 @@ app = FastAPI( 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]: - return {"status": "ok"} +# 统一异常 → 标准错误响应 +register_exception_handlers(app) + +# 路由 +app.include_router(health.router) +app.include_router(ws.router) if __name__ == "__main__": import uvicorn 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, + ) diff --git a/template/backend/src/middleware/__init__.py b/template/backend/src/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/middleware/access_log.py b/template/backend/src/middleware/access_log.py new file mode 100644 index 0000000..1956dcb --- /dev/null +++ b/template/backend/src/middleware/access_log.py @@ -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 diff --git a/template/backend/src/middleware/request_id.py b/template/backend/src/middleware/request_id.py new file mode 100644 index 0000000..e8df8c0 --- /dev/null +++ b/template/backend/src/middleware/request_id.py @@ -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 diff --git a/template/backend/src/routers/__init__.py b/template/backend/src/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/routers/health.py b/template/backend/src/routers/health.py new file mode 100644 index 0000000..b14fb82 --- /dev/null +++ b/template/backend/src/routers/health.py @@ -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"} diff --git a/template/backend/src/routers/ws.py.jinja b/template/backend/src/routers/ws.py.jinja new file mode 100644 index 0000000..064ccb6 --- /dev/null +++ b/template/backend/src/routers/ws.py.jinja @@ -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) diff --git a/template/backend/src/utils/__init__.py b/template/backend/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/utils/files.py b/template/backend/src/utils/files.py new file mode 100644 index 0000000..6e02884 --- /dev/null +++ b/template/backend/src/utils/files.py @@ -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 diff --git a/template/backend/src/utils/ids.py b/template/backend/src/utils/ids.py new file mode 100644 index 0000000..d4c8734 --- /dev/null +++ b/template/backend/src/utils/ids.py @@ -0,0 +1,6 @@ +import uuid + + +def new_uuid() -> str: + """生成新的 UUID4 字符串(用于 request_id / session_id 等)。""" + return str(uuid.uuid4())