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:
9
apps/api/Dockerfile
Normal file
9
apps/api/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock* ./
|
||||
COPY packages ./packages
|
||||
COPY apps/api ./apps/api
|
||||
RUN uv sync --no-dev --frozen || uv sync --no-dev
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
29
apps/api/pyproject.toml
Normal file
29
apps/api/pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[project]
|
||||
name = "ww-api"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.111",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"structlog>=24.1",
|
||||
"cryptography>=43",
|
||||
"ww-shared",
|
||||
"ww-config",
|
||||
"ww-db",
|
||||
"ww-llm-gateway",
|
||||
"ww-core",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["ww_api"]
|
||||
|
||||
[tool.uv.sources]
|
||||
ww-shared = { workspace = true }
|
||||
ww-config = { workspace = true }
|
||||
ww-db = { workspace = true }
|
||||
ww-llm-gateway = { workspace = true }
|
||||
ww-core = { workspace = true }
|
||||
44
apps/api/tests/conftest.py
Normal file
44
apps/api/tests/conftest.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""端点测试 fixtures:用内存替身覆盖依赖,注入有效加密 key(无 DB/无网络)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_providers import FakeCredentialStore, FakeProviderProbe
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enc_key() -> str:
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> FakeCredentialStore:
|
||||
return FakeCredentialStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe() -> FakeProviderProbe:
|
||||
return FakeProviderProbe()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(enc_key: str, store: FakeCredentialStore, probe: FakeProviderProbe) -> TestClient:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear() # 让新 env 生效
|
||||
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.provider_deps import (
|
||||
get_credential_store,
|
||||
get_provider_probe,
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_credential_store] = lambda: store
|
||||
app.dependency_overrides[get_provider_probe] = lambda: probe
|
||||
return TestClient(app)
|
||||
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