"""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, ) 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(settings_providers.router) app.include_router(kimi_oauth.router) return app app = create_app()