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/ 指纹未变;忽略注释的逐字等价性校验通过。
163 lines
5.3 KiB
Python
163 lines
5.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""requests 兼容的异步 HTTP 垫片(基于 aiohttp)。
|
||
|
||
目的:让既有大量 `requests.post/get + response.json()/raise_for_status()` 风格的
|
||
代码**以最小改动**迁移到异步——把 `import requests` 换成
|
||
`from core import aiohttp_compat as requests`,加 `await`、加 `async def` 即可,
|
||
其余 `.text/.json()/.status_code/.raise_for_status()` 语义保持不变。
|
||
|
||
与 requests 的语义对齐点(很关键,不得改动):
|
||
- 请求本身**不**因 4xx/5xx 抛异常;只有显式 `raise_for_status()` 才抛 `HTTPError`
|
||
(上游风控 429/460 的重试/不重试判断依赖调用方读取 `status_code`,见 music_api.HTTPClient)。
|
||
- 网络异常/超时抛 `RequestException`(含 `Timeout`)。
|
||
- `Response` 一次性读全 body(与 requests 一致),暴露 text/json/content/headers/cookies/url。
|
||
|
||
连接按事件循环隔离(见 core.http_client 的同样设计)。
|
||
"""
|
||
import asyncio
|
||
import json as _json
|
||
from collections.abc import Mapping
|
||
from typing import Any
|
||
|
||
import aiohttp
|
||
|
||
DEFAULT_TIMEOUT = 30
|
||
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
||
|
||
# ---------- 异常层次(对齐 requests 命名,便于原样 except) ----------
|
||
|
||
|
||
class RequestException(Exception):
|
||
"""请求异常基类(网络/协议层)。"""
|
||
|
||
|
||
class HTTPError(RequestException):
|
||
"""raise_for_status() 抛出的 HTTP 状态异常。"""
|
||
|
||
def __init__(self, message: str, response: 'Response | None' = None):
|
||
super().__init__(message)
|
||
self.response = response
|
||
|
||
|
||
class Timeout(RequestException):
|
||
"""请求超时。"""
|
||
|
||
|
||
class ConnectionError(RequestException):
|
||
"""连接错误。"""
|
||
|
||
|
||
class Response:
|
||
"""requests.Response 的异步子集:body 已读全,属性同步访问。"""
|
||
|
||
def __init__(self, status_code: int, url: str, content: bytes,
|
||
headers: Mapping[str, str], cookies: Mapping[str, str], reason: str = ''):
|
||
self.status_code = status_code
|
||
self.url = url
|
||
self.content = content
|
||
self.headers = dict(headers)
|
||
self.cookies = dict(cookies)
|
||
self.reason = reason
|
||
|
||
@property
|
||
def text(self) -> str:
|
||
return self.content.decode('utf-8', errors='replace')
|
||
|
||
def json(self, **kw) -> Any:
|
||
return _json.loads(self.text, **kw)
|
||
|
||
def raise_for_status(self) -> None:
|
||
if 400 <= self.status_code:
|
||
raise HTTPError(f"{self.status_code} {self.reason}".strip(), self)
|
||
|
||
|
||
# ---------- 会话(按 loop 隔离的 aiohttp session) ----------
|
||
|
||
|
||
class _SessionState:
|
||
def __init__(self):
|
||
self._sessions: dict[int, aiohttp.ClientSession] = {}
|
||
self._lock: asyncio.Lock | None = None
|
||
|
||
def _get_lock(self) -> asyncio.Lock:
|
||
if self._lock is None:
|
||
self._lock = asyncio.Lock()
|
||
return self._lock
|
||
|
||
async def session(self) -> aiohttp.ClientSession:
|
||
key = id(asyncio.get_running_loop())
|
||
sess = self._sessions.get(key)
|
||
if sess is not None and not sess.closed:
|
||
return sess
|
||
async with self._get_lock():
|
||
sess = self._sessions.get(key)
|
||
if sess is None or sess.closed:
|
||
sess = aiohttp.ClientSession(
|
||
connector=aiohttp.TCPConnector(limit=100, limit_per_host=32, ttl_dns_cache=300),
|
||
)
|
||
self._sessions[key] = sess
|
||
return sess
|
||
|
||
async def close_all(self) -> None:
|
||
for sess in self._sessions.values():
|
||
if not sess.closed:
|
||
await sess.close()
|
||
self._sessions.clear()
|
||
|
||
|
||
_state = _SessionState()
|
||
|
||
|
||
async def close() -> None:
|
||
"""关闭所有连接(应用关闭时调用)。"""
|
||
await _state.close_all()
|
||
|
||
|
||
async def request(
|
||
method: str,
|
||
url: str,
|
||
*,
|
||
params: Any = None,
|
||
data: Any = None,
|
||
json: Any = None,
|
||
headers: Mapping[str, str] | None = None,
|
||
cookies: Mapping[str, str] | None = None,
|
||
timeout: float | None = DEFAULT_TIMEOUT,
|
||
allow_redirects: bool = True,
|
||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||
) -> Response:
|
||
"""发起单次异步请求,返回 Response(不因 4xx/5xx 抛异常,同 requests)。"""
|
||
sess = await _state.session()
|
||
req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
||
try:
|
||
async with sess.request(
|
||
method, url,
|
||
params=params, data=data, json=json,
|
||
headers=dict(headers) if headers else None,
|
||
cookies=dict(cookies) if cookies else None,
|
||
timeout=req_timeout, allow_redirects=allow_redirects,
|
||
) as resp:
|
||
body = await resp.read()
|
||
if len(body) > max_bytes:
|
||
raise RequestException(f"响应体超过上限 {max_bytes} 字节")
|
||
return Response(
|
||
status_code=resp.status,
|
||
url=str(resp.url),
|
||
content=body,
|
||
headers=dict(resp.headers),
|
||
cookies={k: v.value for k, v in resp.cookies.items()},
|
||
reason=resp.reason or '',
|
||
)
|
||
except aiohttp.ClientError as e:
|
||
raise ConnectionError(str(e)) from e
|
||
except TimeoutError as e:
|
||
raise Timeout('请求超时') from e
|
||
|
||
|
||
async def get(url: str, **kw) -> Response:
|
||
return await request('GET', url, **kw)
|
||
|
||
|
||
async def post(url: str, **kw) -> Response:
|
||
return await request('POST', url, **kw)
|