feat: Phase 0 — monorepo 骨架 + 全表迁移 + FastAPI/Next 骨架 + CI
- uv(workspace) + pnpm monorepo;docker-compose(pg+api+web) - SQLAlchemy 16 MVP 表 + Alembic 初版迁移(无漂移,users stub) - FastAPI 骨架:统一错误信封(带 request_id) + structlog + /jobs/:id + OpenAPI - Next.js 骨架:纸感主题 token + OpenAPI→TS 客户端代码生成(gen:api) - CI(ruff/mypy/pytest + pg service + alembic 漂移校验) - 四份设计规格(PRODUCT/UX/ARCHITECTURE/DEV_PLAN) + CLAUDE.md
This commit is contained in:
0
apps/api/ww_api/__init__.py
Normal file
0
apps/api/ww_api/__init__.py
Normal file
15
apps/api/ww_api/export_openapi.py
Normal file
15
apps/api/ww_api/export_openapi.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""导出 OpenAPI 到 stdout(前端 gen:api 离线消费,CI 无需起服务)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ww_api.main import app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(json.dumps(app.openapi(), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
apps/api/ww_api/logging_config.py
Normal file
34
apps/api/ww_api/logging_config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""structlog 结构化日志(ARCH §9.3)。dev 走彩色 console,其余 JSON。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from structlog.typing import Processor
|
||||
from ww_config import get_settings
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
settings = get_settings()
|
||||
shared: list[Processor] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
]
|
||||
renderer = (
|
||||
structlog.processors.JSONRenderer()
|
||||
if settings.log_json
|
||||
else structlog.dev.ConsoleRenderer()
|
||||
)
|
||||
processors: list[Processor] = [*shared, renderer]
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str = "ww") -> structlog.stdlib.BoundLogger:
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(name)
|
||||
return logger
|
||||
55
apps/api/ww_api/main.py
Normal file
55
apps/api/ww_api/main.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""FastAPI 应用入口(ARCH §7)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from ww_db import get_sessionmaker
|
||||
from ww_shared import AppError, ErrorBody, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import configure_logging, get_logger
|
||||
from ww_api.middleware import request_id_middleware
|
||||
from ww_api.routers import health, jobs, projects, settings_providers
|
||||
from ww_api.services.project_deps import seed_stub_user
|
||||
|
||||
configure_logging()
|
||||
log = get_logger("ww.api")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas)。
|
||||
async with get_sessionmaker()() as session:
|
||||
await seed_stub_user(session)
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="网文创作工作流 API", version="0.0.0", lifespan=_lifespan)
|
||||
app.middleware("http")(request_id_middleware)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
log.warning("app_error", code=exc.code, message=exc.message)
|
||||
envelope = ErrorEnvelope(
|
||||
error=ErrorBody(
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details=exc.details,
|
||||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
return JSONResponse(status_code=exc.http_status, content=envelope.model_dump())
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(settings_providers.router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
28
apps/api/ww_api/middleware.py
Normal file
28
apps/api/ww_api/middleware.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""request_id 关联中间件(ARCH §9.3 / CLAUDE.md)。
|
||||
|
||||
每请求生成/透传 request_id,绑入 structlog contextvars,并回写响应头,
|
||||
错误信封也带上同一 id,便于端到端 grep。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
REQUEST_ID_HEADER = "x-request-id"
|
||||
|
||||
|
||||
async def request_id_middleware(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||
request.state.request_id = request_id
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
return response
|
||||
0
apps/api/ww_api/routers/__init__.py
Normal file
0
apps/api/ww_api/routers/__init__.py
Normal file
17
apps/api/ww_api/routers/health.py
Normal file
17
apps/api/ww_api/routers/health.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""根路由 + 健康检查。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def root() -> dict[str, str]:
|
||||
return {"service": "ww-api", "status": "ok"}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
33
apps/api/ww_api/routers/jobs.py
Normal file
33
apps/api/ww_api/routers/jobs.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""长任务轮询端点 GET /jobs/:id(ARCH §7.4)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db import get_session
|
||||
from ww_db.models import Job
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
async def get_job(
|
||||
job_id: uuid.UUID,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> dict[str, object]:
|
||||
job = (await session.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
|
||||
if job is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
|
||||
return {
|
||||
"id": str(job.id),
|
||||
"kind": job.kind,
|
||||
"status": job.status,
|
||||
"progress": job.progress,
|
||||
"result": job.result,
|
||||
"error": job.error,
|
||||
}
|
||||
1
apps/api/ww_api/schemas/__init__.py
Normal file
1
apps/api/ww_api/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API 边界 Pydantic schemas(snake_case;前端经 OpenAPI 消费)。"""
|
||||
1
apps/api/ww_api/security/__init__.py
Normal file
1
apps/api/ww_api/security/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""安全工具:凭据加解密与掩码(ARCH §4.7)。"""
|
||||
1
apps/api/ww_api/services/__init__.py
Normal file
1
apps/api/ww_api/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API 服务层:凭据存取与提供商探测(依赖接口,便于测试注入替身)。"""
|
||||
Reference in New Issue
Block a user