diff --git a/requirements-fastapi.txt b/requirements-fastapi.txt new file mode 100644 index 0000000..bfdb4ae --- /dev/null +++ b/requirements-fastapi.txt @@ -0,0 +1,5 @@ +# FastAPI 迁移目标依赖(当前生产仍以 Flask + waitress 运行,本文件不影响既有部署) +# 待阶段 C 切换生产服务器后,合并进 requirements.txt 并移除 Flask 相关项。 +# 版本上界口径同 requirements.txt(第 95 条 P2-6:防上游大版本静默破坏)。 +fastapi>=0.115,<1.0 +uvicorn>=0.30,<1.0 diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..ba4b739 --- /dev/null +++ b/web/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +"""FastAPI 应用层(Flask → FastAPI 迁移目标)。 + +迁移策略:strangler(绞杀者)——新路由在 FastAPI 侧实现,未迁移路由由 +遗留 Flask 应用兜底(见 web/app.py 的可选 WSGI 挂载)。两侧共享 core/ 的 +配置、运行时状态与工具,保证迁移期状态一致、行为可比。 + +本包只依赖 core/ 与上游服务模块(music_api 等),**不得**从 main.py 导入路由。 +当前为脚手架:仅迁移 /health、/api/info 两个无鉴权、无状态的系统端点, +作为验证「双框架响应契约一致」的样板。 +""" diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..3c78612 --- /dev/null +++ b/web/app.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""FastAPI 应用工厂。 + +设计(strangler 迁移): +- 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存, + 便于逐端点比对响应契约(.zcode/refactor/parity.py)。 +- `serve_flask_fallback=True` 时,把未迁移的遗留 Flask 应用整体挂到根路径兜底, + 实现「新端点走 FastAPI、其余走 Flask」的单进程双框架运行;迁移完成后删掉该开关。 + +注意(默认关闭的原因):Flask fallback 会在 FastAPI 进程内 import main, +进而初始化 MusicAPIService / 打开 SQLite —— 与生产既有的 waitress 进程是 +两套独立进程语义。生产切换前需按计划把状态与任务模型统一(见迁移计划阶段 C), +当前仅用于开发期联调。 +""" +from fastapi import FastAPI + +from core.config import APP_VERSION +from web.routers import system as system_router + + +def create_app(serve_flask_fallback: bool = False) -> FastAPI: + """创建 FastAPI 应用。""" + app = FastAPI( + title='云村小助手API服务', + version=APP_VERSION, + docs_url='/api/v1/docs', + redoc_url=None, + openapi_url='/api/v1/openapi.json', + ) + + # 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比) + app.include_router(system_router.router, prefix='/api/v1', tags=['system']) + + if serve_flask_fallback: + _mount_flask_fallback(app) + + return app + + +def _mount_flask_fallback(app: FastAPI) -> None: + """把遗留 Flask WSGI 应用挂到根路径,兜底未迁移的路由。""" + from starlette.middleware.wsgi import WSGIMiddleware + import main as legacy_flask + app.mount('/', WSGIMiddleware(legacy_flask.app)) diff --git a/web/main.py b/web/main.py new file mode 100644 index 0000000..f7ad6d0 --- /dev/null +++ b/web/main.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""FastAPI 服务入口(uvicorn)。 + +开发期启动: .venv/Scripts/python.exe web/main.py +生产切换前需先将状态/任务模型与既有 Flask 进程统一(见迁移计划阶段 C)。 +""" +import os +import sys + +import uvicorn + +# 直接以 `python web/main.py` 运行时,sys.path[0] 是 web/ 而非项目根—— +# 补上项目根,保证 `import core` / `import music_api` / `import web.app` 可用。 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from web.app import create_app + +# 生产可在联调期临时打开 Flask 兜底(未迁移路由回落到遗留应用) +app = create_app(serve_flask_fallback=os.environ.get('FASTAPI_FLASK_FALLBACK') == '1') + +if __name__ == '__main__': + uvicorn.run(app, host='0.0.0.0', port=5000, log_level='info') diff --git a/web/response.py b/web/response.py new file mode 100644 index 0000000..7712ce9 --- /dev/null +++ b/web/response.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""响应工具:与 Flask 侧 APIResponse 的 JSON 契约逐字段对齐。 + +Flask 的 APIResponse.success/error 返回 (dict, status) 元组,由 Flask 序列化。 +FastAPI 侧改为直接返回 JSONResponse,但**包体结构完全一致**: + success: {"status": sc, "success": true, "message": msg, ["data": ...]} + error: {"status": sc, "success": false, "message": msg, ["error_code": ...]} +这样前端无需任何改动即可同时对接两个框架。 +""" +from typing import Any + +from fastapi.responses import JSONResponse + + +def success(data: Any = None, message: str = 'success', status_code: int = 200) -> JSONResponse: + """成功响应(对应 Flask APIResponse.success)""" + body = { + 'status': status_code, + 'success': True, + 'message': message, + } + if data is not None: + body['data'] = data + return JSONResponse(content=body, status_code=status_code) + + +def error(message: str, status_code: int = 400, error_code: str = None) -> JSONResponse: + """错误响应(对应 Flask APIResponse.error)""" + body = { + 'status': status_code, + 'success': False, + 'message': message, + } + if error_code: + body['error_code'] = error_code + return JSONResponse(content=body, status_code=status_code) diff --git a/web/routers/__init__.py b/web/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/routers/system.py b/web/routers/system.py new file mode 100644 index 0000000..af907eb --- /dev/null +++ b/web/routers/system.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""系统端点(迁移样板):/health、/api/info。 + +/flask 侧的这两个端点无鉴权、无状态、无任务依赖,是最安全的迁移起点。 +此处实现与 main.py 的 health_check / api_info **响应契约逐字段对齐**, +用于验证双框架一致性(见 .zcode/refactor/parity.py)。 +""" +import sys +import time + +from fastapi import APIRouter + +from core.config import APP_VERSION +from web.response import error, success + +router = APIRouter() + +# 与 main.py api_info 的 endpoints 表保持一致(迁移期以 Flask 侧为准,逐字对齐) +_ENDPOINTS = { + '/health': 'GET - 健康检查', + '/song': 'GET/POST - 获取歌曲信息', + '/search': 'GET/POST - 搜索音乐', + '/playlist': 'GET/POST - 获取歌单详情', + '/album': 'GET/POST - 获取专辑详情', + '/download': 'POST - 下载音乐(付费动作仅接受 POST)', + '/podcast/parse': 'GET/POST - 播客解析(电台/单集)', + '/podcast/programs': 'GET/POST - 播客单集列表(分页)', + '/podcast/search': 'GET/POST - 播客搜索', + '/podcast/hot': 'GET/POST - 热门播客与分类', + '/podcast/download': 'POST - 播客单集下载', + '/podcast/download/batch_zip': 'POST - 整台播客打包ZIP', + '/global/playlists': 'GET/POST - 全局账号歌单列表', + '/global/playlist': 'GET/POST - 全局账号歌单详情', + '/api/playlist/play': 'GET/POST - 公开歌单播放API(id/level/limit)', + '/api/info': 'GET - API信息', +} + + +@router.get('/health') +def health_check(): + """健康检查API(对应 Flask health_check)""" + try: + from music_api import load_cookies + cookies = load_cookies() + cookie_status = 'valid' if cookies else 'invalid' + + health_info = { + 'service': 'running', + 'timestamp': int(time.time()) if 'time' in sys.modules else None, + 'cookie_status': cookie_status, + 'cookie_count': len(cookies), + 'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径 + 'version': APP_VERSION, + } + return success(health_info, "API服务运行正常") + except Exception: + return error("服务暂时不可用,请稍后再试", 500) + + +@router.get('/api/info') +def api_info(): + """API信息接口(对应 Flask api_info)""" + try: + info = { + 'name': '云村小助手API服务', + 'version': APP_VERSION, + 'description': '云村小助手 · 网易云音乐解析/下载服务', + 'endpoints': dict(_ENDPOINTS), + 'supported_qualities': [ + 'standard', 'exhigh', 'lossless', + 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby' + ], + 'config': { + 'downloads_dir': 'configured', + 'max_body_bytes': '2MB(备份上传 200MB)', + }, + } + return success(info, "API信息获取成功") + except Exception: + return error("服务暂时不可用,请稍后再试", 500)