refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档

为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留
大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述,
会误导后来者按不复存在的架构去改代码。

拆分(对外导入路径不变,行为零变更):
- music_downloader.py  -> music_downloader/(models/downloader/cover/_http/_log/_concurrency)
- auth_system.py       -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...)
- music_api.py         -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors)
- services/runtime.py  -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/
  streaming/tasks);services/runtime.py 保留为兼容门面

兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点
(auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性
动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。

修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字
等价」校验逐一定位,静态检查只会报出其中 4 处):
- core/config.py  ).strip() or '*'
- core/state.py   threading.Lock() x2
- qr_login.py     datetime.now() / .strip()
- music_downloader/downloader.py  .lower()
- services/podcast.py  set() / time.time() x2

清理与删除:
- 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt
- 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等
  失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明)
- _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含
- pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败

验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed;
真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
This commit is contained in:
snowy-yang committed 2026-09-26 12:37:48 +08:00
1 parent 838d635bc5
commit 6288a98ecc
89 files changed
+5806 -5112

No files matched your search

+5 -7
View File
@@ -1,11 +1,9 @@
# -*- coding: utf-8 -*-
"""FastAPI 应用层(Flask → FastAPI 迁移目标)。
"""FastAPI 应用层——本项目的唯一服务端。
迁移策略:strangler(绞杀者)——新路由在 FastAPI 侧实现,未迁移路由由
遗留 Flask 应用兜底(见 web/app.py 的可选 WSGI 挂载)。两侧共享 core/ 的
配置、运行时状态与工具,保证迁移期状态一致、行为可比。
Flask 巨石已删除,全部 HTTP 端点、鉴权、静态资源与 SSE 均在本包内实现;
应用工厂见 web/app.py,路由按业务域拆分在 web/routers/ 下。
本包只依赖 core/ 与上游服务模块(music_api 等),**不得**从 main.py 导入路由。
当前为脚手架:仅迁移 /health、/api/info 两个无鉴权、无状态的系统端点,
作为验证「双框架响应契约一致」的样板。
本包只依赖 core/ 与上游服务模块(music_api、auth_system、services 等),
**不得**反向从入口脚本导入任何路由定义。
"""
+6 -9
View File
@@ -1,11 +1,8 @@
# -*- coding: utf-8 -*-
"""FastAPI 应用工厂。
设计(strangler 迁移):
- 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存,
便于逐端点比对响应契约(.zcode/refactor/parity.py)。
迁移已完成:Flask 巨石(main.py/auth_routes.py)已删除,本应用即唯一服务端。
`api_prefix` 为空即直接服务前端使用的根路径;非空则挂在给定前缀下(用于契约比对)。
本应用即唯一服务端:全部端点在 web/routers/ 下按业务域注册。
`api_prefix` 用于把整套路由挂到指定前缀下(便于契约测试与反向代理)。
"""
from contextlib import asynccontextmanager
@@ -50,7 +47,7 @@ async def _lifespan(app: FastAPI):
def _init_audio_secret() -> None:
"""注入音频签名密钥(与 Flask 同源,令两栈 token 互认)。"""
"""注入音频签名密钥(与 auth_system 同源,令直链 token 与服务端一致)。"""
try:
import auth_system
@@ -64,7 +61,7 @@ def create_app(api_prefix: str = '') -> FastAPI:
api_prefix:路由挂载前缀。
- ''(默认):直接服务前端使用的根路径(生产形态)
- '/api/v1':挂在子前缀下(契约比对脚本 .zcode/refactor/parity.py 用)
- '/api/v1':挂在子前缀下(便于契约测试与反向代理)
"""
_init_audio_secret()
@@ -85,7 +82,7 @@ def create_app(api_prefix: str = '') -> FastAPI:
if _static_dir.is_dir():
app.mount('/static', StaticFiles(directory=str(_static_dir)), name='static')
# 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比)
# 业务路由:统一挂到 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'])
@@ -101,7 +98,7 @@ def create_app(api_prefix: str = '') -> FastAPI:
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)
+8 -8
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""认证的内存态与校验辅助(原 auth_routes 的模块级状态,框架无关)。
"""认证的内存态与校验辅助(模块级状态,框架无关)。
包含:登录失败锁定、管理员 2FA 票据、发码 IP 限流、用户名/保留名校验。
这些状态**进程内共享**——登录/注册/2FA 各端点都读写同一份,避免「换个端点绕过锁定」。
@@ -38,7 +38,7 @@ _ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限
_ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧)
_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——第 87 条初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环
_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环
def _admin2fa_purge() -> None:
@@ -58,7 +58,7 @@ def _admin2fa_mask(email: str) -> str:
def _admin2fa_issue(user) -> str:
_admin2fa_purge()
# 第 87 条:每管理员仅 1 张活票——新票签发即销毁同账号旧票
# 每管理员仅 1 张活票——新票签发即销毁同账号旧票
for t in [t for t, v in list(_ADMIN_2FA.items()) if v['username'] == user['username']]:
_ADMIN_2FA.pop(t, None)
if len(_ADMIN_2FA) >= _ADMIN_2FA_MAX_TICKETS:
@@ -111,7 +111,7 @@ def _login_fail_clear(username: str) -> None:
def _login_fail_locked_log_once(username: str) -> bool:
"""R5-4:锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表;
"""锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表;
解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。"""
if username in _LOGIN_FAIL_LOGGED:
return False
@@ -121,7 +121,7 @@ def _login_fail_locked_log_once(username: str) -> bool:
def _safe_register_credits() -> int:
"""注册赠送次数(settings.register_credits,默认 3)。
R5 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。"""
防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。"""
try:
credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0)
except (TypeError, ValueError):
@@ -139,7 +139,7 @@ _SEND_CODE_IP_WINDOW = 3600
def _send_code_ip_check(ip: str) -> bool:
"""返回 True 表示允许发送;超限返回 False(S4:表超限时顺手清理过期项,防无界增长)"""
"""返回 True 表示允许发送;超限返回 False(表超限时顺手清理过期项,防无界增长)"""
now = time.time()
if len(_SEND_CODE_IP) > 10000:
stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW]
@@ -159,8 +159,8 @@ _RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'mod
def re_username_ok(username: str) -> bool:
import re as _re
# P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险)
# R5-P3:白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符
# 禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险)
# 白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符
#("аdmin" 冒充钓鱼);保留字(admin/qfbsh 等)一并拒绝,防冒充管理入口
return bool(_re.fullmatch(r'[A-Za-z0-9_\u4e00-\u9fa5]{3,20}', username)) and \
not _re.match(r'^_+$', username) and not username.startswith('_') and not username.endswith('_') \
+3 -3
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""FastAPI 计费依赖(账本预扣/退款),与 Flask login_required 同口径。
"""计费依赖(账本预扣/退款),口径与登录闸一致。
Flask 版 login_required 用 flask.g 暂存请求级账本键;FastAPI 无 g,改用
历史实现用请求上下文(flask.g)暂存请求级账本键;本实现无 g,改用
「依赖返回值携带 ledger_key + 路由内显式退款」的方式,语义完全一致:
- 预扣与开账同事务:auth_system.reserve_and_open(A2/B8)
- 预扣与开账同事务:auth_system.reserve_and_open
- 失败退款:只退「仍 open 的账本行」(与装饰器 _refund_open 同口径,杜绝双退)
- 任务接手:auth_system.ledger_rename 把请求级行改挂 task_id(worker 结算唯一入口)
+5 -5
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""FastAPI 依赖:会话解析、当前用户、登录校验。
与 Flask 侧 auth_system 的语义对齐(迁移期两栈共用同一 Cookie、同一站点库):
与 auth_system 的语义对齐(共用同一 Cookie、同一站点库):
- 会话 Cookie 经 core.session 解码(Flask 兼容签名),密钥同源
- 封禁账号(status 0/2):按未登录处理
- 顶号校验:会话内的 `site_uid_seq` 必须与库中 `login_seq` 一致,否则视为失效
- 剩余次数/账本预扣属付费流程,待付费路由迁移时再补(此处先覆盖免费端点)
- 剩余次数/账本预扣属付费流程,由 web.billing 承担(此处只做身份解析)
"""
from dataclasses import dataclass
from typing import Annotated, Any
@@ -20,7 +20,7 @@ _SESSION_COOKIE = 'session'
def secret_key() -> str:
"""会话签名密钥(与 Flask 同源:data/secret.key)。"""
"""会话签名密钥(密钥来源:data/secret.key)。"""
import auth_system
return auth_system.get_secret_key()
@@ -75,7 +75,7 @@ async def current_user(
async def require_login(
user: Annotated[SiteUser | None, Depends(current_user)],
) -> SiteUser:
"""登录闸:未登录抛 401(与 Flask login_required 同形包体),已登录返回用户。"""
"""登录闸:未登录抛 401,已登录返回用户。"""
if user is None:
raise AppError('请先登录后再使用解析功能', 401, error_code='unauthorized')
return user
@@ -84,7 +84,7 @@ async def require_login(
async def require_admin(
user: Annotated[SiteUser | None, Depends(current_user)],
) -> SiteUser:
"""管理员闸:非管理员抛 403(与 Flask admin_required 同形包体)。"""
"""管理员闸:非管理员抛 403。"""
if user is None or not user.is_admin:
raise AppError('需要管理员权限', 403, error_code='forbidden')
return user
+2 -2
View File
@@ -2,7 +2,7 @@
"""统一应用异常与错误响应。
FastAPI 依赖里无法直接「返回响应短路」,因此用抛出 AppError + 全局异常处理器
的方式,产出与 Flask 侧同形的 JSON 包体({status, success, message, error_code})。
的方式,产出统一的 JSON 包体({status, success, message, error_code})。
"""
from typing import Any
@@ -30,7 +30,7 @@ def install_error_handlers(app) -> None:
@app.exception_handler(StarletteHTTPException)
async def _http_error(_request: Request, exc: StarletteHTTPException) -> Any:
# 404/405 等也统一为本站包体(Flask 侧 404 返回 {'success': False, ...})
# 404/405 等也统一为本站包体(404 也返回 {'success': False, ...})
msg = {404: '请求的资源不存在', 405: '请求方法不允许'}.get(exc.status_code,
str(exc.detail or '请求错误'))
return error(msg, exc.status_code)
+4 -4
View File
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
"""FastAPI 中间件:镜像 Flask after_request 的响应侧行为。
"""FastAPI 中间件:响应侧统一处理(安全头 / 直链签名 / 指纹抹除)。
与 main.py `after_request` 对齐的关键点:
处理要点:
1. 音频直链保护——JSON 响应中的网易云音频地址替换为 HMAC 签名代理地址
(`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 Flask 同源,
故两栈签发的 token 互相可校验。
(`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 core.security 同源,
故校验口径一致。
2. 零成本安全响应头(nosniff / DENY / Referrer-Policy / CSP 基线)。
3. 抹去服务器版本指纹(Server 头统一为 NeteaseUrl)。
4. 429 统一带 Retry-After,供客户端退避。
+8 -8
View File
@@ -1,19 +1,19 @@
# -*- coding: utf-8 -*-
"""公网限流(FastAPI 栈)——按 IP 滑动窗口,与 Flask 侧 _rate_ok 同一实现口径。
"""公网限流——按 IP 滑动窗口。
复用 core.state.RATE_BUCKETS/_RATE_LOCK,使两栈在迁移期共享同一批限流桶
(同一 IP 无论打到哪个栈都计入同一窗口),避免出现「绕到另一个框架即绕过限流」。
限流桶集中在 core.state.RATE_BUCKETS/_RATE_LOCK,使所有端点共享同一批桶
(同一 IP 一律计入同一窗口),避免出现绕过限流的口子。
"""
import collections
import time
from fastapi import Request
from core.config import RESULT_RETENTION_SECONDS # noqa: F401 - 保持与 Flask 侧常量同源
from core.config import RESULT_RETENTION_SECONDS # noqa: F401 - 再导出,与 core.config 口径同源
from core.state import RATE_BUCKETS, RATE_LOCK
from web.errors import AppError
# 免于限流的端点(试听流/进度轮询,与 Flask _QUIET/_rate_limit_guard 同口径)
# 免于限流的端点(试听流/进度轮询)
_EXEMPT_PREFIXES = ('/audio_stream',)
_EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'}
@@ -21,7 +21,7 @@ _EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'}
def client_ip(request: Request) -> str:
"""客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP。
规则与 Flask 时代完全一致(core.net 与原实现同源):XFF 最右段优先,
规则见 core.net(与原实现同源):XFF 最右段优先,
最右段为回环时回落 X-Real-IP,全部无效回落直连地址。
"""
from core.net import is_loopback_ip, trusted_proxy_ip
@@ -41,7 +41,7 @@ def client_ip(request: Request) -> str:
def rate_ok(key: str, limit: int, window: int) -> bool:
"""滑动窗口限流:True=放行。与 Flask main._rate_ok 同算法、同桶容器。"""
"""滑动窗口限流:True=放行。"""
now = time.time()
with RATE_LOCK:
dq = RATE_BUCKETS.get(key)
@@ -60,7 +60,7 @@ def rate_ok(key: str, limit: int, window: int) -> bool:
async def guard(request: Request, *, bucket: str, limit: int, window: int) -> None:
"""限流闸:超限抛 429(与 Flask 包体同形)。豁免端点直接放行。"""
"""限流闸:超限抛 429。豁免端点直接放行。"""
path = request.url.path
if path in _EXEMPT_PATHS or path.startswith(_EXEMPT_PREFIXES):
return
+5 -5
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""响应工具:与 Flask 侧 APIResponse 的 JSON 契约逐字段对齐。
"""响应工具:统一 JSON 包体契约。
Flask 的 APIResponse.success/error 返回 (dict, status) 元组,由 Flask 序列化。
沿用历史 APIResponse 的包体结构(逐字段一致):
FastAPI 侧改为直接返回 JSONResponse,但**包体结构完全一致**:
success: {"status": sc, "success": true, "message": msg, ["data": ...]}
error: {"status": sc, "success": false, "message": msg, ["error_code": ...]}
这样前端无需任何改动即可同时对接两个框架。
这样前端无需任何改动即可无缝对接。
"""
from typing import Any
@@ -14,7 +14,7 @@ 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,
@@ -26,7 +26,7 @@ def success(data: Any = None, message: str = 'success', status_code: int = 200)
def error(message: str, status_code: int = 400, error_code: str | None = None) -> JSONResponse:
"""错误响应(对应 Flask APIResponse.error)"""
"""错误响应。"""
body = {
'status': status_code,
'success': False,
+5 -5
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""账号与全局歌单端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""账号与全局歌单端点。
已迁移:
端点:
- POST/GET /api/v1/account/vip ↔ Flask /account/vip(admin/user 双路径 + 缓存)
- POST/GET /api/v1/global/playlists ↔ Flask /global/playlists(管理员)
- POST/GET /api/v1/global/playlist ↔ Flask /global/playlist(管理员)
@@ -31,7 +31,7 @@ _VIP_NAMES = {
0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP',
}
# 探测单飞:与 Flask _VIP_PROBE_LOCK 同源(跨栈共享,避免两栈各自串行 8 级探测)
# 探测单飞:与 core.state 中同一把锁共享,避免并发请求各自串行 8 级探测
_VIP_PROCESS_LOCK: Any = VIP_PROBE_LOCK
@@ -44,7 +44,7 @@ def _cookies() -> dict[str, str]:
async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
"""检测账号会员类型与可解析音质(与 Flask _detect_vip 同口径)。
"""检测账号会员类型与可解析音质。
单飞锁为 threading.Lock:异步上下文里用 to_thread 有界等待 0.5s,
拿不到锁的请求直接用账号接口给降级结果(不等 8 级探测)。
@@ -100,7 +100,7 @@ async def account_vip(
'vip_name': _vip_name(vip_type), 'detected_by': method, 'levels_ok': levels_ok,
'nickname': (info or {}).get('nickname', ''),
}
# 降级结果(levels_ok 空)短缓存 30s,正常结果 300s(与 Flask 同口径)
# 降级结果(levels_ok 空)短缓存 30s,正常结果 300s
ttl = 30 if not levels_ok else 300
if len(VIP_USER_CACHE) > 500:
VIP_USER_CACHE.clear()
+5 -5
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""管理员扫码登录 / Cookie 管理 / 操作记录(FastAPI 栈)。
"""管理员扫码登录 / Cookie 管理 / 操作记录。
已迁移:
端点:
- POST /api/v1/admin/qr/create ↔ Flask /admin/qr/create
- POST /api/v1/admin/qr/check ↔ Flask /admin/qr/check
- POST /api/v1/admin/cookie/status ↔ Flask /admin/cookie/status
- POST /api/v1/admin/cookie/delete ↔ Flask /admin/cookie/delete
- POST /api/v1/admin/logs ↔ Flask /admin/logs
鉴权:Flask 这些端点用 _verify_admin_password(文案"需要管理员权限,请使用管理员账号登录"),
鉴权:管理员密码闸(文案"需要管理员权限,请使用管理员账号登录"),
与 auth_system.admin_required 的文案不同,故本模块用本地 admin 闸保持逐字对齐。
说明:QRLoginManager 已异步化(直接 await);QRLoginClient 的 save_cookie/logout/
@@ -34,7 +34,7 @@ logger = logging.getLogger('web.admin')
async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)]) -> SiteUser:
"""管理员闸(文案与 Flask _verify_admin_password 逐字对齐)。"""
"""管理员闸(文案与历史实现逐字对齐)。"""
if user is None or not user.is_admin:
raise AppError('需要管理员权限,请使用管理员账号登录', 403)
return user
@@ -52,7 +52,7 @@ def _admin_client():
def _reset_caches() -> None:
"""Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存(与 Flask 同口径)。"""
"""Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存。"""
VIP_CACHE['data'] = None
VIP_CACHE['ts'] = 0.0
GLOBAL_MUSIC_U_CACHE['v'] = None
+2 -3
View File
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
"""管理后台(/admin/site/*)端点(FastAPI 栈)——与 Flask 侧逐字段对齐。
"""管理后台(/admin/site/*)端点。
鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。
说明:这些端点多为对 auth_system 同步函数(SQLite / SMTP / 文件)的薄封装,
统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名
(与 Flask _actor 同口径)。
统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名。
"""
import asyncio
import math
+6 -6
View File
@@ -1,18 +1,18 @@
# -*- coding: utf-8 -*-
"""认证操作端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""认证操作端点。
已迁移:
端点:
- POST /api/v1/auth/logout ↔ Flask /auth/logout(顶号:递增 login_seq + 删 Cookie)
- POST /api/v1/auth/heartbeat ↔ Flask /auth/heartbeat
- POST /api/v1/auth/ann_seen ↔ Flask /auth/ann_seen
- POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据)
设计:
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,两栈共享同一份
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,全进程共享同一份
计数/票据,避免「换个框架打就绕过锁定」。
- 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行,
不阻塞事件循环。
- 会话 Cookie 由 core.session 签发(与 Flask 同格式同密钥),删 Cookie 亦同口径。
- 会话 Cookie 由 core.session 签发(沿用同一格式与密钥,存量会话继续有效),删 Cookie 亦同口径。
"""
import asyncio
import os
@@ -52,7 +52,7 @@ def _cookie_kwargs() -> dict:
def _login_response(payload: dict, message: str, uid: int, seq: int) -> JSONResponse:
"""构造带会话 Cookie 的成功响应(Cookie 格式与 Flask 一致)。"""
"""构造带会话 Cookie 的成功响应(沿用同一 Cookie 格式)。"""
resp = JSONResponse(content={'status': 200, 'success': True, 'message': message, 'data': payload})
cookie = cs.dumps(deps.secret_key(), {
'site_uid': uid, 'site_uid_seq': seq, 'site_uid_ts': int(time.time()),
@@ -78,7 +78,7 @@ async def logout(user: Annotated[SiteUser | None, Depends(current_user)]):
@router.post('/auth/heartbeat')
async def heartbeat(user: Annotated[SiteUser | None, Depends(current_user)]):
"""会话保活:返回最新次数;超限/失效给 401(expired 语义与 Flask 同)。"""
"""会话保活:返回最新次数;超限/失效给 401(expired 语义不变)。"""
if user is None:
return error('登录已失效', 401, error_code='unauthorized')
row = await repo.get_user_by_id(user.id)
+2 -2
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""认证状态端点(FastAPI 栈)——与 Flask /auth/me 响应契约逐字段对齐。
"""认证状态端点。
- GET/POST /api/v1/auth/me ↔ Flask /auth/me
· user:站点用户公开字段(未登录为 None)
@@ -28,7 +28,7 @@ async def auth_me(
user: Annotated[SiteUser | None, Depends(current_user)],
request: Request,
):
"""当前会话与站点公开配置(未登录也返回,与 Flask 同口径)。"""
"""当前会话与站点公开配置(未登录也返回)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
try:
ann = await repo.get_announcement()
+3 -3
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""结果交付端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""结果交付端点。
已迁移:
端点:
- POST/GET /api/v1/download/result ↔ Flask /download/result(单曲)
- POST/GET /api/v1/download/batch_zip/result ↔ Flask /download/batch_zip/result(批量 ZIP)
语义对齐要点:
- 任务归属:仅任务本人的站点账号可领取(非本人 403)
- 状态语义:进行中 202 / 暂停 200+提示 / 取消 200+原因 / 失败 500+原因 /
文件缺失 404(与 Flask 分支逐一对应)
文件缺失 404(与历史分支逐一对应)
- 交付物:FileResponse 流式发送;Cache-Control: no-store;
X-Download-Filename(URL 编码,供前端取回原始文件名)
- 文件清理仍由监控线程按 35 分钟保留期统一回收(交付请求不删文件)
+5 -5
View File
@@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
"""下载提交端点(FastAPI 栈)——与 Flask 侧逐字段对齐。
"""下载提交端点。
已迁移:
端点:
- POST /api/v1/download ↔ Flask /download(计费 1 次;file/json 两模式)
- POST /api/v1/download/batch_zip ↔ Flask /download/batch_zip(按去重歌曲数计费)
- POST /api/v1/podcast/download ↔ Flask /podcast/download(计费 1 次)
- POST /api/v1/podcast/download/batch_zip ↔ Flask /podcast/download/batch_zip
计费:经 web.billing.require_billing(预扣+开账同事务 / 失败只退 open 行),
任务登记与启动复用 web.tasks_registry(与 main._register_download_task 同口径);
worker 仍复用 main 的既有实现(无 flask 依赖),故账本与交付语义不变。
任务登记与启动由 web.tasks_registry 承担;
worker 复用 services 层既有实现,账本与交付语义不变。
"""
import asyncio
import logging
@@ -283,7 +283,7 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
# ============ 播客单集下载 ============
def _podcast_billing_count(data: dict[str, Any]) -> int:
"""播客打包计费(与 Flask _podcast_billing_count 同口径)。"""
"""播客打包计费。"""
if data.get('radio_id'):
return 1
limit = _safe_int(data.get('limit', 200) or 200, 200)
+4 -7
View File
@@ -1,11 +1,8 @@
# -*- coding: utf-8 -*-
"""首页与静态资源(FastAPI 栈)。
"""首页与静态资源。
- GET / 首页:渲染同一份 templates/index.html,禁止缓存(与 Flask 一致)
- GET / 首页:渲染 templates/index.html,禁止缓存
- 静态资源由 Starlette StaticFiles 挂载 /static(见 web/app.py)
说明:首页在 Flask fallback 模式下由 Flask 兜底提供;本端点用于「关闭 Flask 后」
FastAPI 独立提供首页(迁移完成的形态)。两者渲染的是同一模板,故内容一致。
"""
from pathlib import Path
@@ -21,8 +18,8 @@ _TEMPLATE = Path(__file__).resolve().parent.parent.parent / 'templates' / 'index
async def index(request: Request) -> HTMLResponse:
"""首页(禁止缓存,确保前端更新即时生效)。
用 Jinja2 以 Flask 相同的默认口径渲染(keep_trailing_newline=False),
使输出与 Flask render_template 逐字节一致。
用 Jinja2 以与历史模板渲染一致的默认口径(keep_trailing_newline=False),
使输出逐字节不变。
"""
import jinja2
+7 -7
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""音乐端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""音乐端点。
已迁移:
端点:
- 免费解析类(需登录,不扣次数):
POST/GET /api/v1/toplist ↔ Flask /toplist
POST/GET /api/v1/artist_songs ↔ Flask /artist_songs
@@ -11,7 +11,7 @@
POST/GET /api/v1/playlist ↔ Flask /playlist
POST/GET /api/v1/song ↔ Flask /song(四种 type:url/name/lyric/json)
参数获取沿用 Flask 口径:GET query / POST json / POST form 三路兼容。
参数获取口径:GET query / POST json / POST form 三路兼容。
上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。
"""
import asyncio
@@ -62,7 +62,7 @@ def _nick_lock() -> asyncio.Lock:
async def mask_server_nickname(nick: str, user: SiteUser) -> str:
"""非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。
"""非管理员响应中把服务器账号昵称替换为站点名。
- 管理员本人可见真实昵称;
- 缓存 10 分钟内复用;上游拿不到时回落站点库里的最后已知昵称继续遮蔽。
@@ -175,7 +175,7 @@ async def search(user: LoginUser, request: Request):
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
# 向后兼容字段 + 服务器昵称遮蔽(与 Flask 侧同口径)
# 向后兼容字段 + 服务器昵称遮蔽
if result:
for song in result:
if song.get('type', 'song') == 'song' and 'artists' in song:
@@ -191,7 +191,7 @@ async def search(user: LoginUser, request: Request):
# ============ 个人/全局账号 Cookie ============
def _global_music_u() -> str:
"""全局账号 MUSIC_U(5 分钟缓存,与 Flask main._global_music_u 同源同缓存)。"""
"""全局账号 MUSIC_U(5 分钟缓存)。"""
now = time.time()
if GLOBAL_MUSIC_U_CACHE['v'] is None or now - GLOBAL_MUSIC_U_CACHE['ts'] > 300:
try:
@@ -209,7 +209,7 @@ async def _resolve_user_cookies(
"""解析个人账号 Cookie(浏览器传入的 user_cookie,不落服务器)。
全局账号(cookie.txt)零个人身份权限:浏览器提交的 MUSIC_U 若与全局账号一致
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计(与 Flask 同口径)。
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计。
"""
ck = media.parse_cookie_str(data.get('user_cookie'))
mu = (ck.get('MUSIC_U') or '').strip()
+4 -4
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""播客端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""播客端点。
已迁移(免费解析类,需登录,不扣次数):
端点(免费解析类,需登录,不扣次数):
- POST/GET /api/v1/podcast/parse ↔ Flask /podcast/parse
- POST/GET /api/v1/podcast/programs ↔ Flask /podcast/programs
- POST/GET /api/v1/podcast/search ↔ Flask /podcast/search
- POST/GET /api/v1/podcast/hot ↔ Flask /podcast/hot
说明:podcast_api 仍是同步模块(requests),此处以 asyncio.to_thread 包装,
让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。
让阻塞的上游调用不占用事件循环——行为不变,且不阻塞其它请求。
"""
import asyncio
from typing import Annotated
@@ -164,6 +164,6 @@ async def podcast_hot(_user: LoginUser, request: Request):
categories = await asyncio.to_thread(podcast_api.get_categories)
except APIException:
categories = []
# 满页视为可能还有更多(与 Flask 同口径)
# 满页视为可能还有更多
return success({'radios': radios, 'categories': categories,
'has_more': len(radios) >= limit}, '获取热门播客成功')
+3 -3
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""注册 / 找回密码 / 卡密 / 验证码(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。
"""注册 / 找回密码 / 卡密 / 验证码。
已迁移:
端点:
- POST /api/v1/auth/send_code ↔ Flask /auth/send_code
- POST /api/v1/auth/register ↔ Flask /auth/register
- POST /api/v1/auth/trial ↔ Flask /auth/trial
@@ -10,7 +10,7 @@
说明:
- 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行,
不阻塞事件循环;行为与 Flask 完全一致。
不阻塞事件循环;行为与历史实现完全一致。
- 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。
- 失败锁定/验证码消费等安全口径逐字保留。
"""
+4 -4
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""站点数据端点(FastAPI 栈):走 core.db 异步 SQLite(读并发/写串行/WAL)。
"""站点数据端点:走 core.db 异步 SQLite(读并发/写串行/WAL)。
这些是**新增**的 `/api/v1` 只读端点,用于验证异步读写路径与共享同一份站点库;
不替代、不影响 Flask 侧既有端点。鉴权端点(需登录/管理员)待会话互通层就绪后再迁移。
这些是 `/api/v1` 下的只读端点,走异步读写路径并共享同一份站点库;
鉴权端点(需登录/管理员)由 web.deps 统一把关。
注意:本模块只做读(与 announcements/settings 这些低风险资源对应),
写操作仍归 auth_system 的事务口径,避免迁移期两套写路径互相踩踏。
写操作统一归 auth_system 的事务口径,避免两套写路径互相踩踏。
"""
from fastapi import APIRouter
+4 -4
View File
@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
"""音频流代理(FastAPI 栈)——与 Flask /audio_stream 行为逐项对齐。
"""音频流代理。
要点(对齐 Flask 实现):
- 签名校验:token 由 core.security 校验(密钥同源,两栈 token 互认);失效 403。
要点:
- 签名校验:token 由 core.security 校验(密钥同源);失效 403。
- HEAD 无正文:直接 200 + Accept-Ranges,不请求上游。
- Range 透传:客户端 Range 原样转发上游,206/Content-Range 原样回传(拖动进度可用)。
- 编码透明:上游若带 Content-Encoding(aiohttp 会解压),不得沿用压缩前的
Content-Length/Content-Range(否则头体不一致,同 Flask F92)。
Content-Length/Content-Range(否则头体不一致)。
- 流式转发:16KB 分块,不整段入内存;下游中断时 finally 关闭上游响应。
- Cache-Control: no-store。
"""
+6 -6
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""系统端点(迁移样板):/health、/api/info。
"""系统端点:/health、/api/info。
/flask 侧的这两个端点无鉴权、无状态、无任务依赖,是最安全的迁移起点。
此处实现与 main.py 的 health_check / api_info **响应契约逐字段对齐**,
用于验证双框架一致性(见 .zcode/refactor/parity.py)。
两个端点无鉴权、无状态、无任务依赖。
响应契约与历史快照逐字段对齐,
由 tests/golden/responses.json 兜住回归。
"""
import sys
@@ -16,7 +16,7 @@ from web.response import error, success
router = APIRouter()
# 与 main.py api_info 的 endpoints 表保持一致(迁移期以 Flask 侧为准,逐字对齐)
# 与 api_info 的 endpoints 表保持一致(逐字对齐历史契约)
_ENDPOINTS = {
'/health': 'GET - 健康检查',
'/song': 'GET/POST - 获取歌曲信息',
@@ -51,7 +51,7 @@ def health_check():
'timestamp': int(time.time()) if 'time' in sys.modules else None,
'cookie_status': cookie_status,
'cookie_count': len(cookies),
'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径
'downloads_dir': 'configured', # 不向匿名访客回显服务器绝对路径
'version': APP_VERSION,
}
return success(health_info, "API服务运行正常")
+6 -6
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""任务状态端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""任务状态端点。
已迁移:
端点:
- POST/GET /api/v1/topen ↔ Flask /api/topen(请求统计,无鉴权)
- POST/GET /api/v1/queue ↔ Flask /api/queue(排队状态,无鉴权)
- POST/GET /api/v1/batch_progress ↔ Flask /api/batch_progress(需登录,仅本人任务)
@@ -9,8 +9,8 @@
- POST/GET /api/v1/task/heartbeat ↔ Flask /api/task/heartbeat(需登录)
- POST/GET /api/v1/task/list ↔ Flask /api/task/list(需登录)
这些端点读写 core.state 的共享任务表(与 Flask 同一批对象),因此两栈在迁移期
看到的是同一份任务状态——这是「单进程双框架」能成立的关键。
这些端点读写 core.state 的共享任务表(与任务执行侧同一批对象),因此
看到的是同一份任务状态。
"""
import time
from typing import Annotated, Any
@@ -41,7 +41,7 @@ LoginUser = Annotated[SiteUser, Depends(require_login)]
def _touch_device(device_id: str) -> None:
"""记录设备心跳(与 Flask _touch_device 同口径,共享 DEVICE_LAST_SEEN)。"""
"""记录设备心跳(与 core.state 的 DEVICE_LAST_SEEN 共享)。"""
device_id = str(device_id or '')[:64]
if not device_id:
return
@@ -57,7 +57,7 @@ def _owned_by(task: dict[str, Any], user: SiteUser) -> bool:
def _semaphores():
"""批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。"""
"""批量打包/下载信号量(懒取,与 core.state 中的同一对象共享计数)。"""
import services.runtime as services
return services.BATCH_ZIP_SEM, services.api_service.downloader._download_semaphore
+2 -2
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""个人账号与公开播放端点(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。
"""个人账号与公开播放端点。
已迁移:
端点:
- POST /api/v1/user/qr/create ↔ Flask /user/qr/create(扫码登录二维码 SVG)
- POST /api/v1/user/qr/check ↔ Flask /user/qr/check(轮询登录状态)
- POST /api/v1/user/status ↔ Flask /user/status
+2 -2
View File
@@ -5,13 +5,13 @@ from typing import Any
from core.state import REQ_LOG
# 站点用户公开字段(与 Flask auth_system._user_public 的非 full 口径一致)
# 站点用户公开字段
_PUBLIC_FIELDS = ('id', 'username', 'email', 'role', 'duration_seconds',
'created_at', 'last_login', 'ann_seen')
def format_credits(count: int) -> str:
"""次数文案(与 Flask auth_system.format_credits 逐字一致)。"""
"""次数文案。"""
if count < 0:
return '不限次数'
if count <= 0:
+3 -3
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""下载/播客任务登记(FastAPI 栈)——镜像 main._register_download_task。
"""下载/播客任务登记。
与 Flask 版的唯一差异:不依赖 flask.g,请求级账本键由 Billing 显式携带。
与原实现的唯一差异:不依赖请求上下文(flask.g),请求级账本键由 Billing 显式携带。
其余(归属、容量闸、task_id 白名单与碰撞换新、账本承接与撞名重试)逐条对齐。
"""
import logging
@@ -46,7 +46,7 @@ async def register_download_task(billing: Billing, task: dict[str, Any],
task['task_id'] = tid
DOWNLOAD_TASKS[tid] = task
# 账本承接(撞名重试,与 Flask 同口径)
# 账本承接(撞名重试)
try:
renamed = await billing.rename_to(tid)
retries = 0