"""FastAPI app: 纯 /v1 JSON API(2026-05-15 切换 — 详见 DESIGN §7.9)。 设计要点: - 所有路由 `/v1/*` 前缀,响应 JSON;模板 / HTMX / 服务端 markdown 渲染全删 - SSE 事件 payload 是 JSON dict 而非 HTML 片段(`event: ` + `data: `) - Auth: PLATFORM_KEY → JWT 兑换(§7 D' 过渡形态,见 web/auth.py);OIDC 替换时只动 /v1/auth/login 内部 - 所有 /v1/tasks* 路由 Depends(require_user),按 user_id 隔离数据 - 豁免:/healthz、/docs、/openapi.json、/、/v1/auth/login、/static/* - CORS allow_origins=["*"] 本地宽松;真发布按 platform 域名收紧 - `GET /` 302 → /static/dev.html(本地 dev SPA) 本文件只剩两件事(2026-07-23 拆分后): - `create_app` 工厂:装配 auth 依赖 → 注册 11 个 router 模块(web/routers/)+ admin - `lifespan` 编排:线程池接管 / 孤儿收割 / 6 类后台协程起停 / 优雅 drain (协程本体在 web/background.py + scheduler_runner.py + wechat_runner.py) """ from __future__ import annotations import asyncio import mimetypes import os from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from core import __version__ from .admin import register_admin_routes from .auth import ( REFRESHED_TOKEN_HEADER, TOKEN_EXPIRES_HEADER, AuthConfig, make_require_admin, make_require_user, ) from .background import ( cancel_and_wait, drain_inflight, init_sandbox, reap_stale_runs, start_disk_scanner, start_proc_sweeper, start_stats_logger, start_toolfail_scanner, ) from .broker import broker from .routers.asr import register_asr_routes from .routers.authroutes import register_auth_routes from .routers.external_systems import register_external_system_routes from .routers.files import register_file_routes from .routers.kb import register_kb_routes from .routers.messages import register_message_routes from .routers.misc import register_misc_routes from .routers.models import register_model_routes from .routers.schedules import register_schedule_routes from .routers.skills_memory import register_skill_memory_routes from .routers.software_nodes import register_software_node_routes from .routers.tasks import register_task_routes from .routers.wechat import register_wechat_routes from .scheduler_runner import start_scheduler from .software_followups import ( requeue_stale_analysis_followups, start_software_followup_dispatcher, ) from .static_files import NoCacheStaticFiles from .wechat_runner import start_wechat_inbound # ────────────────────── App 工厂 ────────────────────── # web/static 目录路径 — /static 静态挂载用,dev.html 也放这 _STATIC_DIR = Path(__file__).parent / "static" def create_app() -> FastAPI: # fail-fast:env 缺失直接抛,不裸跑无密 auth_cfg = AuthConfig.from_env() require_user = make_require_user(auth_cfg) require_admin = make_require_admin(auth_cfg) @asynccontextmanager async def lifespan(app: FastAPI): loop = asyncio.get_running_loop() broker.bind_loop(loop) # ── 接管默认线程池 executor(§8.4)────────────────────────────── # run 走 asyncio.to_thread(用 loop 默认 executor);默认是匿名的,读不到大小、 # 不可调。显式建一个同尺寸(复刻 Python 默认 min(32, cpu+4))接管,好处:① 监控 # 能读 max_workers 判断有没有排队 ② 并发不够时改 ZCBOT_RUN_MAX_WORKERS 调大不改码。 # 注:run 与 disk scan / pptx 转换 / reaper 共享此池(同原默认行为);真要隔离 # 长任务再另开 run 专用池,那是后话。 run_max_workers = int( os.getenv("ZCBOT_RUN_MAX_WORKERS") or min(32, (os.cpu_count() or 1) + 4) ) run_executor = ThreadPoolExecutor( max_workers=run_max_workers, thread_name_prefix="run" ) loop.set_default_executor(run_executor) app.state.run_executor = run_executor app.state.run_max_workers = run_max_workers print(f"[startup] run executor: max_workers={run_max_workers} " f"(override via ZCBOT_RUN_MAX_WORKERS)") from core.agent_builder import load_config _cfg = load_config() # 优雅 drain 状态(SIGTERM / systemctl restart 兜底,见下方 finally): # draining 置位后 POST /messages 返 503;inflight 登记在跑的 BG run task, # 关停时 await 它们收尾。inflight 同时给 create_task 持强引用,防被 GC 中途回收。 app.state.draining = asyncio.Event() app.state.inflight = {} # dict[asyncio.Task, UUID(task_id)] _shutdown_cfg = _cfg.get("shutdown") or {} drain_timeout = int(_shutdown_cfg.get("drain_timeout_seconds") or 90) cancel_grace = int(_shutdown_cfg.get("cancel_grace_seconds") or 15) # 启动钩子 + 后台协程群(本体见 web/background.py 等;None=该项未启用) reap_stale_runs() requeued_followups = requeue_stale_analysis_followups() if requeued_followups: print(f"[startup] requeued {requeued_followups} software followup(s)") disk_scanner_task = start_disk_scanner(_cfg) stats_logger_task = start_stats_logger(app, run_max_workers) toolfail_task = start_toolfail_scanner() scheduler_task = start_scheduler(app, _cfg) wechat_task, wechat_stop = start_wechat_inbound(app) sandbox_reaper_task = init_sandbox(app, _cfg) proc_sweeper_task = start_proc_sweeper(_cfg) software_followup_task = start_software_followup_dispatcher(app) try: yield finally: # 先拒新 run + drain in-flight(细节见 background.drain_inflight) app.state.draining.set() await cancel_and_wait(software_followup_task) await drain_inflight(app, drain_timeout, cancel_grace) await cancel_and_wait(disk_scanner_task) await cancel_and_wait(stats_logger_task) await cancel_and_wait(toolfail_task) await cancel_and_wait(scheduler_task) if wechat_task is not None: wechat_stop.set() await cancel_and_wait(wechat_task) await cancel_and_wait(sandbox_reaper_task) await cancel_and_wait(proc_sweeper_task) pool = getattr(app.state, "sandbox_pool", None) if pool is not None: try: pool.shutdown_all() except Exception as e: print(f"[shutdown] sandbox shutdown_all error: {type(e).__name__}: {e}") # broker 收尾(redis 版停 pubsub reader + 断连;local no-op)。放在 drain # 之后 —— drain 期间 run 还要 emit/close。 try: await broker.shutdown() except Exception as e: print(f"[shutdown] broker shutdown error: {type(e).__name__}: {e}") # 自动标题等辅助调用不属于主 run,不参与协作式 cancel;给短调用一个 # 有界收尾窗口,避免部署时丢掉刚生成但尚未落库的标题。 aux = list(app.state.aux_tasks) if aux: _, pending_aux = await asyncio.wait(aux, timeout=10) for task in pending_aux: task.cancel() if pending_aux: await asyncio.gather(*pending_aux, return_exceptions=True) # drain 已 await inflight 收尾、run 线程退完;非阻塞关池(进程在退出,保守清理) run_executor.shutdown(wait=False) app = FastAPI( title="zcbot api", version=__version__, description=( "zcbot 后端 — /v1 JSON API + SSE。Auth: PLATFORM_KEY → JWT(§7 D' 过渡)。" "本地 dev SPA: /static/dev.html。" ), lifespan=lifespan, ) # 自动标题等短辅助调用:与主 run 分开持强引用,避免 asyncio task 被 GC; # 不计入 inflight(不会占用单活 run / drain 的协作式 cancel 语义)。 app.state.aux_tasks = set() app.add_middleware( CORSMiddleware, allow_origins=["*"], # 本地宽松,部署 platform 时按域名收紧 allow_credentials=False, allow_methods=["*"], allow_headers=["*"], # 滑动续签的新 token 走响应头带回;expose 才能让浏览器 fetch 读到(默认不暴露自定义头) expose_headers=[REFRESHED_TOKEN_HEADER, TOKEN_EXPIRES_HEADER], ) if _STATIC_DIR.is_dir(): # Windows 上 mimetypes 偶尔把 .js 判成 text/plain,会令