Archived
为什么:全仓没有任何启动期调用 init_db()——web/app.py 的 lifespan 在 yield
之前不做事(只在关停时清连接池),create_app 也只注入音频密钥。后果是全新部署
(宿主机 data/ 是空目录,compose 用 ./data:/app/data 挂载会盖掉镜像内内容)
启动后,第一次查询只是让 sqlite3 建出一个 **0 表空库**,随后所有涉库端点报
`no such table: users`。
实测证据(SITE_DATA_DIR 指向空目录,起真实 uvicorn):
修复前:POST /auth/login -> 500;site.db 4096 字节 / 0 张表
修复后:POST /auth/login -> 400(用户名或密码错误,即查询真的执行了);
site.db 61440 字节 / 9 张表(含 users、settings)
这个故障还极难定位:500 的响应体是「登录失败,请稍后重试」,
把 no such table 整个吞掉了,日志不翻到底看不出来。
改法:在 lifespan 的 yield 之前 `await asyncio.to_thread(auth_system.init_db)`。
init_db 全程 CREATE TABLE IF NOT EXISTS + 幂等迁移,对已有生产库重复执行无副作用
(本次 175 项测试与真实启动冒烟均未触碰 data/,指纹三项一致);初始化失败则让
异常冲出、启动即失败,优于静默带病运行。
118 lines
4.9 KiB
Python
118 lines
4.9 KiB
Python
# -*- 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
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _lifespan(app: FastAPI):
|
|
"""应用生命周期:启动确保站点库表结构就绪,关闭时释放 aiohttp 连接池。
|
|
|
|
启动必须建表。全新部署时宿主机的 data/ 往往是个空目录(compose 用
|
|
`./data:/app/data` 挂载,会盖掉镜像内的任何内容),此时若只等首次查询
|
|
让 sqlite3 自动建库,得到的是一张 **0 表空库**——随后所有涉库端点报
|
|
`no such table: users`(实测登录端点直接 500,且错误被吞成"请稍后重试",
|
|
极难定位)。init_db 全程 `CREATE TABLE IF NOT EXISTS` + 幂等迁移,
|
|
对已有库重复执行无副作用,故放启动期最稳妥。
|
|
"""
|
|
await asyncio.to_thread(auth_system.init_db)
|
|
yield
|
|
try:
|
|
from core import aiohttp_compat, http_client
|
|
|
|
await http_client.client.close()
|
|
await aiohttp_compat.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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 = '') -> FastAPI:
|
|
"""创建 FastAPI 应用。
|
|
|
|
api_prefix:路由挂载前缀。
|
|
- ''(默认):直接服务前端使用的根路径(生产形态)
|
|
- '/api/v1':挂在子前缀下(便于契约测试与反向代理)
|
|
"""
|
|
_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=_lifespan,
|
|
)
|
|
|
|
# 响应侧统一处理:安全头 + 音频直链签名保护 + 版本指纹抹除
|
|
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')
|
|
|
|
# 业务路由:统一挂到 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
|