Archived
为什么:单文件 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/ 指纹未变;忽略注释的逐字等价性校验通过。
113 lines
4.4 KiB
Python
113 lines
4.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""HTTP 客户端与响应体收窄工具。
|
||
|
||
(自 music_api.py 拆出,行为与原实现一致。)
|
||
"""
|
||
|
||
import asyncio
|
||
from typing import Any
|
||
|
||
from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API)
|
||
|
||
from .constants import (
|
||
APIConstants,
|
||
)
|
||
from .errors import (
|
||
APIException,
|
||
)
|
||
|
||
|
||
def _ensure_dict(result: Any, msg: str) -> dict[str, Any]:
|
||
"""上游 200 但返回 null/数组等非 dict 体(风控/抖动)时统一前置校验"""
|
||
if isinstance(result, dict):
|
||
return result
|
||
raise APIException(msg)
|
||
|
||
|
||
def _as_dict(v: Any) -> dict[str, Any]:
|
||
"""上游嵌套字段类型不稳定(dict / None / 列表都真实出现过):非 dict 一律回落空 dict。
|
||
|
||
收窄成显式函数,避免满屏 `x if isinstance(x, dict) else {}` 三元式——
|
||
那种写法读起来累,且把字段来自 `user.get(...)`(类型 Any|None)时的收窄责任
|
||
散落在各处,容易被后来的改动改漏。
|
||
"""
|
||
return v if isinstance(v, dict) else {}
|
||
|
||
|
||
class HTTPClient:
|
||
"""HTTP客户端类"""
|
||
|
||
@staticmethod
|
||
async def post_request(url: str, params: str, cookies: dict[str, str]) -> str:
|
||
"""发送POST请求并返回文本响应。
|
||
网络异常/5xx 做一次 1 秒退避重试(全站 API 调用此前零重试,
|
||
上游抖动即整首失败);4xx(429/460 风控)不重试——立即重试会加剧风控。"""
|
||
headers = {
|
||
'User-Agent': APIConstants.USER_AGENT,
|
||
'Referer': APIConstants.REFERER,
|
||
}
|
||
|
||
request_cookies = APIConstants.DEFAULT_COOKIES.copy()
|
||
request_cookies.update(cookies)
|
||
|
||
last_err = None
|
||
for attempt in range(2):
|
||
try:
|
||
response = await requests.post(url, headers=headers, cookies=request_cookies,
|
||
data={"params": params}, timeout=30)
|
||
if response.status_code >= 500 and attempt == 0:
|
||
await asyncio.sleep(1.0)
|
||
continue
|
||
response.raise_for_status()
|
||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||
raise APIException("上游响应体异常(超过大小上限)")
|
||
return response.text
|
||
except requests.HTTPError as e:
|
||
# 4xx(429/460 风控)不重试——原实现 HTTPError 落入
|
||
# 下方 RequestException 分支被重试一次,与 意图相反
|
||
raise APIException(f"HTTP请求失败: {e}") from e
|
||
except requests.RequestException as e:
|
||
last_err = e
|
||
if attempt == 0:
|
||
await asyncio.sleep(1.0)
|
||
continue
|
||
raise APIException(f"HTTP请求失败: {last_err}")
|
||
|
||
@staticmethod
|
||
async def post_request_full(url: str, params: str, cookies: dict[str, str]) -> requests.Response:
|
||
"""发送POST请求并返回完整响应对象(同 post_request 的有界重试)"""
|
||
headers = {
|
||
'User-Agent': APIConstants.USER_AGENT,
|
||
'Referer': APIConstants.REFERER,
|
||
}
|
||
|
||
request_cookies = APIConstants.DEFAULT_COOKIES.copy()
|
||
request_cookies.update(cookies)
|
||
|
||
last_err = None
|
||
for attempt in range(2):
|
||
try:
|
||
response = await requests.post(url, headers=headers, cookies=request_cookies,
|
||
data={"params": params}, timeout=30)
|
||
if response.status_code >= 500 and attempt == 0:
|
||
await asyncio.sleep(1.0)
|
||
continue
|
||
response.raise_for_status()
|
||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||
raise APIException("上游响应体异常(超过大小上限)")
|
||
return response
|
||
except requests.HTTPError as e:
|
||
# 4xx 不重试(同上)
|
||
raise APIException(f"HTTP请求失败: {e}") from e
|
||
except requests.RequestException as e:
|
||
last_err = e
|
||
if attempt == 0:
|
||
await asyncio.sleep(1.0)
|
||
continue
|
||
raise APIException(f"HTTP请求失败: {last_err}")
|
||
|
||
|
||
# 上游 JSON 响应体上限——requests 已全量入内存,此处拦截的是
|
||
# 异常/被劫持上游的超大 body 进入后续 .json() 解析与传播(音频/封面已有字节预算)
|
||
_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024
|