# -*- coding: utf-8 -*- """FastAPI 应用工厂。 本应用即唯一服务端:全部端点在 web/routers/ 下按业务域注册。 `api_prefix` 用于把整套路由挂到指定前缀下(便于契约测试与反向代理)。 """ import asyncio from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles import auth_system from core.config import APP_VERSION from core.security import init_secret_key from web.errors import install_error_handlers from web.middleware import ResponseHygieneMiddleware from web.routers import account as account_router from web.routers import admin as admin_router from web.routers import admin_site as admin_site_router from web.routers import auth as auth_router from web.routers import auth_status as auth_status_router from web.routers import delivery as delivery_router from web.routers import download as download_router from web.routers import index as index_router from web.routers import music as music_router from web.routers import podcast as podcast_router from web.routers import register as register_router from web.routers import site as site_router from web.routers import stream as stream_router from web.routers import system as system_router from web.routers import tasks as tasks_router from web.routers import user as user_router def _make_lifespan(start_monitor: bool): """构造 lifespan(是否启动任务监控线程由 create_app 参数决定)。""" @asynccontextmanager async def _lifespan(app: FastAPI): """应用生命周期:建表 → 启动任务监控 → 关闭时释放连接池与后台 loop。 启动必须建表。全新部署时宿主机的 data/ 往往是个空目录(compose 用 `./data:/app/data` 挂载,会盖掉镜像内的任何内容),此时若只等首次查询 让 sqlite3 自动建库,得到的是一张 **0 表空库**——随后所有涉库端点报 `no such table: users`(实测登录端点直接 500,且错误被吞成"请稍后重试", 极难定位)。init_db 全程 `CREATE TABLE IF NOT EXISTS` + 幂等迁移, 对已有库重复执行无副作用,故放启动期最稳妥。 任务监控线程也必须在此启动:Flask 单体时代由模块级 `threading.Thread( target=_task_monitor_loop).start()` 拉起(基线 main.py:4594),移除 Flask 迁到 FastAPI 时该启动点丢失,此后监控循环从未运行——心跳超时不再暂停 离线设备、超时任务不再取消、过期任务与下载缓存不再回收、账本孤儿对账 不再执行(预扣未退款的账永久悬挂)。详见 services.tasks.start_task_monitor。 """ await asyncio.to_thread(auth_system.init_db) if start_monitor: from services.tasks import start_task_monitor await asyncio.to_thread(start_task_monitor) yield try: from core import aiohttp_compat, async_bridge, http_client await http_client.client.close() await aiohttp_compat.close() # 停掉 sync↔async 桥的后台事件循环(daemon 线程不调也能随进程退出, # 但显式关闭更干净) async_bridge.shutdown() except Exception: pass return _lifespan def _init_audio_secret() -> None: """注入音频签名密钥(与 auth_system 同源,令直链 token 与服务端一致)。""" try: import auth_system init_secret_key(auth_system.get_secret_key()) except Exception: pass def create_app(api_prefix: str = '', *, start_monitor: bool = True) -> FastAPI: """创建 FastAPI 应用。 api_prefix:路由挂载前缀。 - ''(默认):直接服务前端使用的根路径(生产形态) - '/api/v1':挂在子前缀下(便于契约测试与反向代理) start_monitor:是否随应用启动任务监控线程。生产恒为 True; 测试若以 `with TestClient(app)` 方式进入 lifespan,可传 False 以避免后台线程清扫用例的临时目录。 """ _init_audio_secret() app = FastAPI( title='云村小助手API服务', version=APP_VERSION, docs_url='/api/v1/docs', redoc_url=None, openapi_url='/api/v1/openapi.json', lifespan=_make_lifespan(start_monitor), ) # 响应侧统一处理:安全头 + 音频直链签名保护 + 版本指纹抹除 app.add_middleware(ResponseHygieneMiddleware) # 静态资源(/static/site_auth.js 等)——与 Flask static_folder 同目录 _static_dir = Path(__file__).resolve().parent.parent / 'static' if _static_dir.is_dir(): app.mount('/static', StaticFiles(directory=str(_static_dir)), name='static') # React 构建产物(vite build → frontend/dist,hash 指纹资源挂 /assets) _dist_dir = Path(__file__).resolve().parent.parent / 'frontend' / 'dist' if (_dist_dir / 'assets').is_dir(): app.mount( '/assets', StaticFiles(directory=str(_dist_dir / 'assets')), name='assets', ) # 业务路由:统一挂到 api_prefix(默认空前缀,即前端使用的根路径) app.include_router(system_router.router, prefix=api_prefix, tags=['system']) app.include_router(site_router.router, prefix=api_prefix, tags=['site']) app.include_router(music_router.router, prefix=api_prefix, tags=['music']) app.include_router(account_router.router, prefix=api_prefix, tags=['account']) app.include_router(podcast_router.router, prefix=api_prefix, tags=['podcast']) app.include_router(tasks_router.router, prefix=api_prefix, tags=['tasks']) app.include_router(delivery_router.router, prefix=api_prefix, tags=['delivery']) app.include_router(stream_router.router, prefix=api_prefix, tags=['stream']) app.include_router(auth_status_router.router, prefix=api_prefix, tags=['auth']) app.include_router(auth_router.router, prefix=api_prefix, tags=['auth']) app.include_router(user_router.router, prefix=api_prefix, tags=['user']) app.include_router(admin_router.router, prefix=api_prefix, tags=['admin']) app.include_router(register_router.router, prefix=api_prefix, tags=['auth']) app.include_router(admin_site_router.router, prefix=api_prefix, tags=['admin']) app.include_router(download_router.router, prefix=api_prefix, tags=['download']) # 首页(根路径,不挂前缀) app.include_router(index_router.router, tags=['index']) install_error_handlers(app) return app