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:
Yaojia Wang
2026-06-18 11:38:28 +02:00
commit d3dc620a71
74 changed files with 12960 additions and 0 deletions

View File

View 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()

View 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
View 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()

View 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

View File

View 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"}

View File

@@ -0,0 +1,33 @@
"""长任务轮询端点 GET /jobs/:idARCH §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,
}

View File

@@ -0,0 +1 @@
"""API 边界 Pydantic schemassnake_case前端经 OpenAPI 消费)。"""

View File

@@ -0,0 +1 @@
"""安全工具凭据加解密与掩码ARCH §4.7)。"""

View File

@@ -0,0 +1 @@
"""API 服务层:凭据存取与提供商探测(依赖接口,便于测试注入替身)。"""