This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/web/app.py
T
snowy-yang 9df0028195 feat(routes): 迁移下载/批量打包/播客下载(账本事务)+ 修密钥绑定缺陷
迁移端点:
- POST /api/v1/download(单曲:file 后台任务 / json 同步交付+结算)
- POST /api/v1/download/batch_zip(按去重歌曲数计费)
- POST /api/v1/podcast/download、/podcast/download/batch_zip
- 新增 web/billing.py(预扣+开账同事务 / 只退 open 行 / rename 承接)
  与 web/tasks_registry.py(任务登记/容量闸/账本撞名重试,与 main 同口径)
- worker 仍复用 main 既有实现(无 flask 依赖),账本与交付语义不变

修复我引入的真实缺陷:
- download 参数校验分支(ID/音质/格式非法)漏退款 → 预扣泄漏。改为统一 _fail() 退款
- web/routers/{auth,register}.py 在导入期绑定 deps.secret_key,导致 patch web.deps.secret_key
  无效、且测试顺序相关。改为调用期 deps.secret_key() 解析

测试:tests/test_download_billing.py 7 项沙箱用例如实断言账本守恒
(预扣必退、无 open 行残留、次数不足不建任务);
全套 pytest 69/69(顺序无关);ruff All checks passed;pyright 0 errors;奇偶 36/36。
2026-09-26 10:13:16 +08:00

122 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
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 连接池(含按事件循环隔离的 session)。"""
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:
"""注入音频签名密钥(与 Flask 同源,令两栈 token 互认)。"""
try:
import auth_system
init_secret_key(auth_system.get_secret_key())
except Exception:
pass
def create_app(serve_flask_fallback: bool = False) -> FastAPI:
"""创建 FastAPI 应用。"""
_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/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'])
# 首页与静态资源(迁移完成形态;fallback 模式下同路径也能由 Flask 提供)
app.include_router(index_router.router, tags=['index'])
install_error_handlers(app)
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))