通用执行路径驱动全部生成器("加生成器=加一份声明"):
- @llm: ww_agents +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/
glossary/opening/fine-outline,只声明 tier)+ build_outline_chapter_context
- @backend: ww_skills GeneratorTool 描述符 + TOOLBOX(11) + get_tool;3 通用端点
GET /skills/toolbox · POST .../skills/{tool_key}/generate(预览不写库,仅记账) ·
POST .../ingest(复用 continuity 409 + partition_writes 白名单);纯 context 派发
- @frontend: 工具箱落地页 RSC + 声明驱动 GeneratorRunner + lib/toolbox 纯函数
+ LeftNav「工具箱」+ ⌘K nav-toolbox/action-gen-*;legacy 3 跳现页
- @qa: tests/test_t6_toolbox_e2e.py 5 用例真 pg + mock 网关零 token,无端点 bug
- P2 收尾: 限流→decisions.md 记延后(单用户原型);noopener/Committable 早已修
守不变量 #2(只声明 tier)/#3(预览不写库,入库经验收 gate)/#9(缓存前缀)。无 DB 迁移。
门禁绿: 后端 ruff/format/mypy 195/alembic 无漂移/pytest 583;前端 lint/tsc/vitest 279/build。
spec 回写 PRODUCT_SPEC §7 + ARCHITECTURE §7.2 端点表。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
5.0 KiB
Python
126 lines
5.0 KiB
Python
"""FastAPI 应用入口(ARCH §7)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import AsyncIterator
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse
|
||
from ww_config import get_settings
|
||
from ww_core.domain.job_repo import SqlJobRepo
|
||
from ww_db import get_sessionmaker
|
||
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
|
||
|
||
from ww_api.logging_config import configure_logging, get_logger
|
||
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware
|
||
from ww_api.routers import (
|
||
foreshadow,
|
||
generation,
|
||
health,
|
||
jobs,
|
||
kimi_oauth,
|
||
outline,
|
||
projects,
|
||
rules,
|
||
settings_providers,
|
||
style,
|
||
toolbox,
|
||
)
|
||
from ww_api.security.credentials import _fernet
|
||
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]:
|
||
# 加密 key 启动校验:缺失/非法则**快速失败**(不带病启动接流量)。`_fernet` 缺/非法抛
|
||
# CredentialKeyError;此处让其冒泡使进程启动失败(P0-2),而非首次凭据读写才 500。
|
||
_fernet(get_settings().credential_enc_key.get_secret_value())
|
||
# 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas)。
|
||
async with get_sessionmaker()() as session:
|
||
await seed_stub_user(session)
|
||
# zombie reaper(M4-d / §7.4 缓解):进程重启会丢未跑完的 BackgroundTask,把残留
|
||
# status=running 的 job 标 failed(让用户看到失败可重试,而非进度条永转)。自 commit、幂等。
|
||
async with get_sessionmaker()() as session:
|
||
reaped = await SqlJobRepo(session).reap_zombies()
|
||
if reaped:
|
||
log.info("job_zombies_reaped", count=reaped)
|
||
yield
|
||
|
||
|
||
def create_app() -> FastAPI:
|
||
app = FastAPI(title="网文创作工作流 API", version="0.0.0", lifespan=_lifespan)
|
||
# CORS:前端(Next.js)与 API 分端口/跨源,浏览器端 `api.POST/PUT` 需 CORS 放行
|
||
# (RSC 服务端取数同源、无需 CORS,故此前同进程测试从未暴露此缺口)。原型单用户、
|
||
# 本地开发:放行 localhost:3000;可经 env `CORS_ORIGINS`(逗号分隔)覆盖。
|
||
settings = get_settings()
|
||
# `allow_credentials=True` 与通配 origin `"*"` 并存会被浏览器拒(且语义危险)——启动断言
|
||
# cors_origins 不含通配,强制显式白名单(P2 收窄)。
|
||
assert "*" not in settings.cors_origins, (
|
||
"cors_origins 不能含通配 '*'(与 allow_credentials=True 不兼容);请配置显式来源白名单"
|
||
)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.cors_origins,
|
||
allow_credentials=True,
|
||
# 收窄方法/头白名单(避免 `*` 放行任意方法/头,P2)。
|
||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
|
||
)
|
||
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.exception_handler(Exception)
|
||
async def _unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||
# 兜底:任何未被 AppError 捕获的异常 → 统一 INTERNAL 信封(不回显原始异常给客户端),
|
||
# 但服务端记完整上下文 + request_id 便于端到端排查(P0-2)。
|
||
request_id = getattr(request.state, "request_id", None)
|
||
log.error(
|
||
"unhandled_exception",
|
||
request_id=request_id,
|
||
error_type=type(exc).__name__,
|
||
exc_info=exc,
|
||
)
|
||
envelope = ErrorEnvelope(
|
||
error=ErrorBody(
|
||
code=ErrorCode.INTERNAL,
|
||
message="服务器内部错误",
|
||
request_id=request_id,
|
||
)
|
||
)
|
||
return JSONResponse(status_code=500, content=envelope.model_dump())
|
||
|
||
app.include_router(health.router)
|
||
app.include_router(jobs.router)
|
||
app.include_router(projects.router)
|
||
app.include_router(foreshadow.router)
|
||
app.include_router(outline.router)
|
||
app.include_router(rules.router)
|
||
app.include_router(style.router)
|
||
app.include_router(generation.router)
|
||
app.include_router(generation.skills_router)
|
||
app.include_router(toolbox.router)
|
||
app.include_router(settings_providers.router)
|
||
app.include_router(kimi_oauth.router)
|
||
return app
|
||
|
||
|
||
app = create_app()
|