feat: 支持纯 FastAPI 模式(根路径服务前端)+ 修任务端点路径

- web/app.py:create_app(api_prefix=) 参数化前缀——'' 即根路径模式,
  87 条路由直接服务前端使用的路径,无需 Flask 兜底
- web/main.py:WEB_MODE=dual(默认,迁移期)/ fastapi(纯 FastAPI)切换
- 修真实路径缺陷:任务类端点漏了 /api 段(Flask 为 /api/topen 等,
  原写成 /topen),根路径模式下前端会 404。已改为 /api/topen、/api/queue、
  /api/batch_progress、/api/task/*;fe_error 同步修正
- 奇偶校验路径映射同步更新

验证:
- tests/test_root_mode.py 新增 66 项:前端调用的每条路径在纯 FastAPI 模式下
  均非 404;root 模式不再暴露 /api/v1
- 纯 FastAPI 模式真实启动(uvicorn, PORT=5002)实测:
  /health、/、/static 200,/song、/api/task/list 401(鉴权生效)
- 全套 pytest 135/135;ruff All checks passed;pyright 0 errors;奇偶 36/36
This commit is contained in:
snowy-yang committed 2026-09-26 10:25:05 +08:00
1 parent cd0f1f78a3
commit f5d1e3c3a9
5 files changed
+107 -28

No files matched your search

+24 -18
View File
@@ -64,8 +64,14 @@ def _init_audio_secret() -> None:
pass
def create_app(serve_flask_fallback: bool = False) -> FastAPI:
"""创建 FastAPI 应用。"""
def create_app(serve_flask_fallback: bool = False, api_prefix: str = '/api/v1') -> FastAPI:
"""创建 FastAPI 应用。
api_prefix:已迁移路由的挂载前缀。
- '/api/v1'(默认):与 Flask 根路径并存,便于逐端点契约比对(迁移期)
- ''(切换完成):FastAPI 直接服务前端使用的根路径,不再需要 Flask 兜底
serve_flask_fallback:把 Flask 应用挂到根路径兜底(仅 api_prefix 非空时可用)。
"""
_init_audio_secret()
app = FastAPI(
@@ -86,27 +92,27 @@ def create_app(serve_flask_fallback: bool = False) -> FastAPI:
app.mount('/static', StaticFiles(directory=str(_static_dir)), name='static')
# 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比)
app.include_router(system_router.router, prefix='/api/v1', tags=['system'])
app.include_router(site_router.router, prefix='/api/v1', tags=['site'])
app.include_router(music_router.router, prefix='/api/v1', tags=['music'])
app.include_router(account_router.router, prefix='/api/v1', tags=['account'])
app.include_router(podcast_router.router, prefix='/api/v1', tags=['podcast'])
app.include_router(tasks_router.router, prefix='/api/v1', tags=['tasks'])
app.include_router(delivery_router.router, prefix='/api/v1', tags=['delivery'])
app.include_router(stream_router.router, prefix='/api/v1', tags=['stream'])
app.include_router(auth_status_router.router, prefix='/api/v1', tags=['auth'])
app.include_router(auth_router.router, prefix='/api/v1', tags=['auth'])
app.include_router(user_router.router, prefix='/api/v1', tags=['user'])
app.include_router(admin_router.router, prefix='/api/v1', tags=['admin'])
app.include_router(register_router.router, prefix='/api/v1', tags=['auth'])
app.include_router(admin_site_router.router, prefix='/api/v1', tags=['admin'])
app.include_router(download_router.router, prefix='/api/v1', tags=['download'])
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'])
# 首页与静态资源(迁移完成形态;fallback 模式下同路径也能由 Flask 提供)
app.include_router(index_router.router, tags=['index'])
install_error_handlers(app)
if serve_flask_fallback:
if serve_flask_fallback and api_prefix:
_mount_flask_fallback(app)
return app
+9 -3
View File
@@ -21,10 +21,16 @@ from web.app import create_app
HOST = os.environ.get('HOST', '0.0.0.0')
PORT = int(os.environ.get('PORT', '5000'))
# 迁移期默认开启 Flask 兜底;设 FASTAPI_FLASK_FALLBACK=0 可关闭
FLASK_FALLBACK = os.environ.get('FASTAPI_FLASK_FALLBACK', '1') == '1'
app = create_app(serve_flask_fallback=FLASK_FALLBACK)
# 运行模式(默认保持迁移期形态,切换为显式操作):
# WEB_MODE=dual(默认)已迁移路由挂 /api/v1 + Flask 兜底其余路由(前端仍走根路径→Flask)
# WEB_MODE=fastapi 已迁移路由直接挂根路径,完全由 FastAPI 服务(不再需要 Flask)
WEB_MODE = os.environ.get('WEB_MODE', 'dual').strip().lower()
if WEB_MODE == 'fastapi':
app = create_app(serve_flask_fallback=False, api_prefix='')
else:
app = create_app(serve_flask_fallback=True, api_prefix='/api/v1')
if __name__ == '__main__':
uvicorn.run(
+6 -6
View File
@@ -62,7 +62,7 @@ def _semaphores():
return legacy.BATCH_ZIP_SEM, legacy.api_service.downloader._download_semaphore
@router.api_route('/topen', methods=['GET', 'POST'])
@router.api_route('/api/topen', methods=['GET', 'POST'])
async def topen(request: Request):
"""接口请求统计(实时)——锁内快照,避免 total 与明细不一致。"""
import main as legacy
@@ -87,7 +87,7 @@ async def topen(request: Request):
}, '获取请求统计成功')
@router.api_route('/queue', methods=['GET', 'POST'])
@router.api_route('/api/queue', methods=['GET', 'POST'])
async def queue(request: Request):
"""实时排队状态(排队人数 + 预计等待时间)。"""
batch_sem, dl_sem = _semaphores()
@@ -106,7 +106,7 @@ async def queue(request: Request):
}, '获取排队状态成功')
@router.api_route('/batch_progress', methods=['GET', 'POST'])
@router.api_route('/api/batch_progress', methods=['GET', 'POST'])
async def batch_progress(user: LoginUser, request: Request):
"""下载任务进度(已处理 X/Y + 百分比 + 预计剩余时间)。"""
data = await _request_data(request)
@@ -152,7 +152,7 @@ async def batch_progress(user: LoginUser, request: Request):
}, '获取任务进度成功')
@router.post('/task/cancel')
@router.post('/api/task/cancel')
async def task_cancel(user: LoginUser, request: Request):
"""取消下载任务(仅任务本人的站点账号可取消)。"""
data = await _request_data(request)
@@ -174,7 +174,7 @@ async def task_cancel(user: LoginUser, request: Request):
return success({'task_id': task_id, 'status': 'canceled'}, '已请求取消')
@router.api_route('/task/heartbeat', methods=['GET', 'POST'])
@router.api_route('/api/task/heartbeat', methods=['GET', 'POST'])
async def task_heartbeat(user: LoginUser, request: Request):
"""设备心跳:保持任务活跃;暂停中的任务自动恢复(重新进入排队)。"""
await ratelimit.guard(request, bucket='hb', limit=60, window=60)
@@ -201,7 +201,7 @@ async def task_heartbeat(user: LoginUser, request: Request):
return success({'resumed': resumed, 'online': True}, '心跳成功')
@router.api_route('/task/list', methods=['GET', 'POST'])
@router.api_route('/api/task/list', methods=['GET', 'POST'])
async def task_list(user: LoginUser, request: Request):
"""本设备未结束的任务列表(重新打开网站时恢复任务悬浮窗)。"""
data = await _request_data(request)
+1 -1
View File
@@ -395,7 +395,7 @@ async def _get_setting(key: str, default: str = '') -> str:
# ============ 前端错误上报 ============
@router.api_route('/fe_error', methods=['POST', 'GET'])
@router.api_route('/api/fe_error', methods=['POST', 'GET'])
async def fe_error(request: Request):
"""前端错误日志上报(10 次/分/IP;字段截断清洗;仅写服务日志,不入库)。"""
if not ratelimit.rate_ok('fe:' + ratelimit.client_ip(request), 10, 60):