diff --git a/tests/test_root_mode.py b/tests/test_root_mode.py new file mode 100644 index 0000000..3da261f --- /dev/null +++ b/tests/test_root_mode.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""纯 FastAPI 模式(api_prefix='')测试:前端全部路径由 FastAPI 服务。""" +import warnings + +import pytest +from fastapi.testclient import TestClient + +warnings.filterwarnings('ignore') + +# 前端实际调用的路径(从 index.html / site_auth.js 抽取的代表集合) +FRONTEND_PATHS = [ + '/', '/health', '/api/info', '/song', '/search', '/playlist', '/album', + '/toplist', '/artist_songs', '/comments', + '/auth/me', '/auth/login', '/auth/logout', '/auth/heartbeat', '/auth/register', + '/auth/send_code', '/auth/redeem', '/auth/forgot_password', '/auth/ann_seen', + '/auth/admin_login/email', '/auth/admin_login/verify', + '/user/status', '/user/logout', '/user/qr/create', '/user/qr/check', + '/user/playlists', '/user/playlist', '/user/collect', '/user/comment', + '/account/vip', '/global/playlists', '/global/playlist', '/api/playlist/play', + '/download', '/download/batch_zip', '/download/result', '/download/batch_zip/result', + '/podcast/parse', '/podcast/programs', '/podcast/search', '/podcast/hot', + '/podcast/download', '/podcast/download/batch_zip', + '/api/topen', '/api/queue', '/api/batch_progress', '/api/task/cancel', + '/api/task/heartbeat', '/api/task/list', '/api/fe_error', '/audio_stream', + '/admin/qr/create', '/admin/qr/check', '/admin/cookie/status', '/admin/cookie/delete', + '/admin/logs', '/admin/site/users', '/admin/site/logs', '/admin/site/cards/list', + '/admin/site/ann/get', '/admin/site/play_key/get', '/admin/site/backup/list', + '/admin/site/smtp/get', '/admin/site/user/cookie', +] + + +@pytest.fixture() +def root_client(tmp_path, monkeypatch): + """root 模式应用(不挂 Flask 兜底、前缀为空)。""" + import auth_system as auth + import core.db as db + + monkeypatch.setattr(auth, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth, 'DB_PATH', tmp_path / 'site.db') + monkeypatch.setattr(auth, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db') + auth.init_db() + monkeypatch.setattr('web.deps.secret_key', lambda: 'root-mode-secret') + + from web.app import create_app + return TestClient(create_app(api_prefix='')) + + +@pytest.mark.parametrize('path', FRONTEND_PATHS) +def test_frontend_path_not_404(root_client, path): + """前端调用的每条路径都必须有 FastAPI 路由(不得 404)。""" + r = root_client.post(path, json={}) + assert r.status_code != 404, f'{path} 在纯 FastAPI 模式下 404' + + +def test_root_page_served(root_client): + r = root_client.get('/') + assert r.status_code == 200 + assert r.headers.get('cache-control', '').startswith('no-cache') + + +def test_no_api_v1_prefix_in_root_mode(root_client): + """root 模式下不再暴露 /api/v1 前缀(避免与 Flask 时代语义混淆)。""" + r = root_client.post('/api/v1/song', json={}) + assert r.status_code == 404 diff --git a/web/app.py b/web/app.py index fc06d4b..c4fe781 100644 --- a/web/app.py +++ b/web/app.py @@ -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 diff --git a/web/main.py b/web/main.py index 348d0b9..6c26ff5 100644 --- a/web/main.py +++ b/web/main.py @@ -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( diff --git a/web/routers/tasks.py b/web/routers/tasks.py index 0d69474..e761fcb 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -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) diff --git a/web/routers/user.py b/web/routers/user.py index 04203ed..5c4e832 100644 --- a/web/routers/user.py +++ b/web/routers/user.py @@ -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):