chore(async-infra): 异步基础层 + 工具链 + 清理

异步基础层(core/):
- db.py   aiosqlite + WAL:读路径无锁并发、写路径 asyncio.Lock 串行,
          busy_timeout 自旋——实现「读写同时进行」(旧实现用 RLock 全读串行)
- http_client.py  aiohttp 连接池 + 有界重试(5xx 重试 / 4xx 不重试)+
          响应体上限 + 流式接口;按事件循环隔离 session(支持迁移期双 loop)

工具链:
- pyproject.toml   ruff(E/F/W/I/B/UP/C4/ASYNC/S/RUF) + pyright(basic) + pytest(asyncio auto)
- requirements-fastapi.txt  FastAPI/aiohttp/aiosqlite/aiofiles 等目标栈
- requirements-dev.txt      开发依赖(pytest/ruff/pyright)

清理:删除无用脚本 _csscheck.py 与产物标记 _css_ok.txt/_pkg_ok.txt,同步 .gitignore

验证:pytest 10/10(含「写 200 条同时 4 读协程零异常」并发用例);
ruff All checks passed;pyright 0 errors。
This commit is contained in:
snowy-yang committed 2026-09-26 00:24:00 +08:00
1 parent 41356e89c4
commit 9f56f68b66
16 files changed
+700 -36

No files matched your search

-10
View File
@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
"""CSS 完整性检查:花括号平衡 + 关键美化标记存活"""
import sys
src = open('templates/index.html', encoding='utf-8').read()
style = src.split('<style>')[1].split('</style>')[0]
ok = style.count('{') == style.count('}')
marks = sum(1 for m in ['font-family: inherit', 'kw.on { text-shadow', 'uc-card:hover', 'theme-fading', 'textarea.input', 'prefers-reduced-motion', '美化层 v4', '美化层 v5', 'safe-area-inset'] if m in style)
with open('_css_ok.txt', 'w', encoding='utf-8') as f:
f.write(f'braces={style.count("{")}=={style.count("}")} {ok} | marks {marks}/9')
print('ok')
+5 -2
View File
@@ -4,10 +4,10 @@
自 main.py 原样迁出,语义不变:仅常量、配置类与路径定义。 自 main.py 原样迁出,语义不变:仅常量、配置类与路径定义。
不导入 flask/fastapi,不持有任何运行时可变状态(运行时状态见 core.state)。 不导入 flask/fastapi,不持有任何运行时可变状态(运行时状态见 core.state)。
""" """
import os import os
from dataclasses import dataclass from dataclasses import dataclass
# 站点统一版本号(P3-12/P3-22:/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码) # 站点统一版本号(P3-12/P3-22:/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码)
APP_VERSION = '2.4.0' APP_VERSION = '2.4.0'
@@ -19,6 +19,7 @@ RESULT_RETENTION_SECONDS = 35 * 60
@dataclass @dataclass
class Settings: class Settings:
"""服务配置类(原 main.APIConfig;迁移期保留 APIConfig 别名以兼容既有引用)""" """服务配置类(原 main.APIConfig;迁移期保留 APIConfig 别名以兼容既有引用)"""
host: str = '0.0.0.0' host: str = '0.0.0.0'
port: int = 5000 port: int = 5000
debug: bool = False debug: bool = False
@@ -26,7 +27,9 @@ class Settings:
# 第 98 条:max_file_size/request_timeout 两个架空字段已删除——请求体上限真实 # 第 98 条:max_file_size/request_timeout 两个架空字段已删除——请求体上限真实
# 生效点是 _body_size_limit(业务 2MB/备份 200MB),上游超时在 music_api 各调用点 # 生效点是 _body_size_limit(业务 2MB/备份 200MB),上游超时在 music_api 各调用点
log_level: str = 'INFO' log_level: str = 'INFO'
cors_origins: str = (os.environ.get('CORS_ORIGINS') or '*').strip() or '*' # 第 100 条:可经环境变量收敛同源(空值回落默认) cors_origins: str = (
os.environ.get('CORS_ORIGINS') or '*'
).strip() or '*' # 第 100 条:可经环境变量收敛同源(空值回落默认)
# 向后兼容别名:既有代码/文档沿用 APIConfig 名称 # 向后兼容别名:既有代码/文档沿用 APIConfig 名称
+137
View File
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
"""异步 SQLite 访问层(aiosqlite + WAL)。
核心目标:**读写同时进行**。
- WAL 模式:读事务与写事务互不阻塞(读快照隔离),这是 SQLite 层并发的根本;
- 读路径:每次操作独立连接、无锁、可并发(`fetch_all`/`fetch_one`/`fetch_val`);
- 写路径:SQLite 全库同一时刻只允许一个写者,用 `asyncio.Lock` 串行化写事务,
但写者不阻塞读者(WAL);
- `busy_timeout`:多进程/多连接下遇到短暂写锁自旋等待而非立刻 SQLITE_BUSY。
与旧实现的差异:旧 `auth_system` 用 `threading.RLock` 把**全部**读写串行化,
读也被写挡住;本层只串行化写,读放行并发,即「读写同时进行」。
"""
import asyncio
import os
from collections.abc import AsyncIterator, Iterable, Sequence
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import aiosqlite
# 站点数据目录(可经环境变量覆盖,便于测试隔离)
DATA_DIR = Path(os.environ.get('SITE_DATA_DIR', 'data'))
DB_PATH = DATA_DIR / 'site.db'
# 写事务串行锁(惰性创建,避免跨事件循环绑定)
_WRITE_LOCK: asyncio.Lock | None = None
def _write_lock() -> asyncio.Lock:
global _WRITE_LOCK
if _WRITE_LOCK is None:
_WRITE_LOCK = asyncio.Lock()
return _WRITE_LOCK
async def _configure(conn: aiosqlite.Connection) -> None:
conn.row_factory = aiosqlite.Row
await conn.execute('PRAGMA journal_mode=WAL') # 读写并发的前提
await conn.execute('PRAGMA busy_timeout=15000') # 遇写锁自旋等待,而非立即报错
await conn.execute('PRAGMA synchronous=NORMAL') # WAL 下兼顾安全与吞吐
await conn.execute('PRAGMA foreign_keys=ON')
async def connect() -> aiosqlite.Connection:
"""新建一个已配置好的连接(调用方负责 close)。"""
DATA_DIR.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240 - 幂等的微秒级一次目录创建,无需线程池
conn = await aiosqlite.connect(str(DB_PATH), timeout=15)
await _configure(conn)
return conn
# ============ 读路径(并发,无锁) ============
async def fetch_all(sql: str, params: Sequence = ()) -> list[aiosqlite.Row]:
conn = await connect()
try:
cur = await conn.execute(sql, params)
return list(await cur.fetchall())
finally:
await conn.close()
async def fetch_one(sql: str, params: Sequence = ()) -> aiosqlite.Row | None:
conn = await connect()
try:
cur = await conn.execute(sql, params)
return await cur.fetchone()
finally:
await conn.close()
async def fetch_val(sql: str, params: Sequence = (), default: Any = None) -> Any:
row = await fetch_one(sql, params)
return row[0] if row is not None else default
# ============ 写路径(串行,不阻塞读) ============
async def execute(sql: str, params: Sequence = ()) -> int:
"""执行单条写语句,返回 lastrowid(INSERT)或 rowcount(UPDATE/DELETE)。"""
async with _write_lock():
conn = await connect()
try:
cur = await conn.execute(sql, params)
await conn.commit()
return cur.lastrowid if cur.lastrowid else cur.rowcount
finally:
await conn.close()
async def execute_many(sql: str, seq: Iterable[Sequence]) -> int:
"""批量写,返回受影响行数。"""
async with _write_lock():
conn = await connect()
try:
cur = await conn.executemany(sql, seq)
await conn.commit()
return cur.rowcount
finally:
await conn.close()
@asynccontextmanager
async def write_transaction() -> AsyncIterator[aiosqlite.Connection]:
"""写事务上下文:多条写语句原子提交(串行锁内)。
用法:
async with write_transaction() as conn:
await conn.execute(...)
await conn.execute(...)
# 退出即 commit;异常则 rollback
"""
async with _write_lock():
conn = await connect()
try:
yield conn
await conn.commit()
except Exception:
await conn.rollback()
raise
finally:
await conn.close()
@asynccontextmanager
async def read_connection() -> AsyncIterator[aiosqlite.Connection]:
"""只读连接上下文(无锁、可并发);需要在一个连接上做多次读时使用。"""
conn = await connect()
try:
yield conn
finally:
await conn.close()
+259
View File
@@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
"""异步 HTTP 客户端(aiohttp):连接池 + 有界重试 + 响应体上限。
替代旧的长连接 `requests.Session`/裸 `requests.get/post`:
- 单一 `ClientSession` 复用 TCP 连接(keep-alive),连接池限制防上游打爆;
- 重试口径与旧实现一致:网络异常/5xx 退避重试一次,4xx(429/460 风控)**不**重试;
- 响应体超过 `max_bytes` 直接拒绝(防止被劫持/异常上游的超大 body 进内存)。
异常层次(调用方按需映射为业务异常,例如 music_api.APIException):
UpstreamError
├── UpstreamNetworkError 网络/超时(可重试)
├── UpstreamHTTPError 非 2xx(5xx 可重试,4xx 不可)
└── UpstreamTooLarge 响应体超限
"""
import asyncio
import json
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
import aiohttp
DEFAULT_TIMEOUT = 30
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
# 第 95 条(P2-6)/第 98 条口径:UA 与 Referer 由调用方传入(music_api.APIConstants)
_EMPTY: Mapping[str, str] = {}
class UpstreamError(Exception):
"""上游请求错误基类。"""
class UpstreamNetworkError(UpstreamError):
"""网络异常/超时(可重试)。"""
class UpstreamHTTPError(UpstreamError):
"""上游返回非 2xx 状态码。"""
def __init__(self, status: int, message: str = ''):
super().__init__(f"HTTP {status} {message}".strip())
self.status = status
class UpstreamTooLarge(UpstreamError):
"""响应体超过大小上限。"""
@dataclass
class HTTPResult:
"""一次请求的完整结果(供需要状态码/头/cookie 的调用点使用)。"""
status: int
url: str
text: str
headers: Mapping[str, str] = field(default_factory=dict)
cookies: Mapping[str, str] = field(default_factory=dict)
def json(self) -> Any:
return json.loads(self.text)
class AsyncHTTPClient:
"""带连接池的异步 HTTP 客户端。"""
def __init__(
self,
*,
total_timeout: float = DEFAULT_TIMEOUT,
max_bytes: int = DEFAULT_MAX_BYTES,
pool_limit: int = 100,
pool_per_host: int = 32,
user_agent: str | None = None,
referer: str | None = None,
):
self._total_timeout = total_timeout
self._max_bytes = max_bytes
self._pool_limit = pool_limit
self._pool_per_host = pool_per_host
self._default_headers: dict[str, str] = {}
if user_agent:
self._default_headers['User-Agent'] = user_agent
if referer:
self._default_headers['Referer'] = referer
self._session: aiohttp.ClientSession | None = None
self._loop_sessions: dict[int, aiohttp.ClientSession] = {}
self._lock = asyncio.Lock()
async def _session_get(self) -> aiohttp.ClientSession:
if self._session is not None and not self._session.closed:
return self._session
async with self._lock:
if self._session is None or self._session.closed:
connector = aiohttp.TCPConnector(
limit=self._pool_limit,
limit_per_host=self._pool_per_host,
ttl_dns_cache=300,
)
self._session = aiohttp.ClientSession(
connector=connector,
timeout=aiohttp.ClientTimeout(total=self._total_timeout),
headers=dict(self._default_headers),
)
return self._session
async def _session_for_loop(self) -> aiohttp.ClientSession:
"""按事件循环隔离 session。
迁移期同一进程内可能有两个 loop(FastAPI 主 loop + 遗留 Flask 的同步桥 loop)。
aiohttp 的 ClientSession 不能跨 loop 复用,故按 loop 分别持有。
"""
key = id(asyncio.get_running_loop())
sess = self._loop_sessions.get(key)
if sess is not None and not sess.closed:
return sess
async with self._lock:
sess = self._loop_sessions.get(key)
if sess is None or sess.closed:
connector = aiohttp.TCPConnector(
limit=self._pool_limit,
limit_per_host=self._pool_per_host,
ttl_dns_cache=300,
)
sess = aiohttp.ClientSession(
connector=connector,
timeout=aiohttp.ClientTimeout(total=self._total_timeout),
headers=dict(self._default_headers),
)
self._loop_sessions[key] = sess
return sess
async def close(self) -> None:
if self._session is not None and not self._session.closed:
await self._session.close()
self._session = None
for sess in self._loop_sessions.values():
if not sess.closed:
await sess.close()
self._loop_sessions.clear()
# ---------- 底层:单次请求(不重试) ----------
async def _once(
self,
method: str,
url: str,
*,
headers: Mapping[str, str] | None = None,
cookies: Mapping[str, str] | None = None,
params: Mapping[str, Any] | None = None,
data: Any = None,
json_body: Any = None,
timeout: float | None = None,
allow_redirects: bool = True,
) -> HTTPResult:
session = await self._session_for_loop()
req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
try:
async with session.request(
method,
url,
headers=dict(headers) if headers else None,
cookies=dict(cookies) if cookies else None,
params=params,
data=data,
json=json_body,
timeout=req_timeout,
allow_redirects=allow_redirects,
) as resp:
body = await resp.read()
if len(body) > self._max_bytes:
raise UpstreamTooLarge(f"上游响应体超过上限 {self._max_bytes} 字节")
if resp.status >= 400:
raise UpstreamHTTPError(resp.status, resp.reason or '')
return HTTPResult(
status=resp.status,
url=str(resp.url),
text=body.decode(resp.charset or 'utf-8', errors='replace'),
headers=dict(resp.headers),
cookies={k: v.value for k, v in resp.cookies.items()},
)
except aiohttp.ClientError as e:
raise UpstreamNetworkError(str(e)) from e
except TimeoutError as e:
raise UpstreamNetworkError('请求超时') from e
async def _with_retry(self, fn, *, attempts: int = 2, backoff: float = 1.0) -> HTTPResult:
"""网络异常/5xx 退避重试;4xx 与超限立即抛出不重试。"""
last: UpstreamError | None = None
for i in range(attempts):
try:
return await fn()
except UpstreamHTTPError as e:
if e.status >= 500 and i + 1 < attempts:
last = e
await asyncio.sleep(backoff)
continue
raise
except UpstreamNetworkError as e:
last = e
if i + 1 < attempts:
await asyncio.sleep(backoff)
continue
raise
# 理论上不可达
raise last or UpstreamError('上游请求失败')
# ---------- 高层 API ----------
async def request(self, method: str, url: str, **kw) -> HTTPResult:
return await self._with_retry(lambda: self._once(method, url, **kw))
async def get_text(self, url: str, **kw) -> str:
return (await self.request('GET', url, **kw)).text
async def post_text(self, url: str, **kw) -> str:
return (await self.request('POST', url, **kw)).text
async def get_json(self, url: str, **kw) -> Any:
return (await self.request('GET', url, **kw)).json()
async def post_json(self, url: str, **kw) -> Any:
return (await self.request('POST', url, **kw)).json()
@asynccontextmanager
async def stream(
self,
method: str,
url: str,
*,
headers: Mapping[str, str] | None = None,
cookies: Mapping[str, str] | None = None,
timeout: float | None = None,
allow_redirects: bool = True,
) -> AsyncIterator[aiohttp.ClientResponse]:
"""流式请求(下载/音视频代理用):yield 原始响应,边读边写不整段入内存。"""
session = await self._session_for_loop()
req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
try:
async with session.request(
method,
url,
headers=dict(headers) if headers else None,
cookies=dict(cookies) if cookies else None,
timeout=req_timeout,
allow_redirects=allow_redirects,
) as resp:
if resp.status >= 400:
raise UpstreamHTTPError(resp.status, resp.reason or '')
yield resp
except aiohttp.ClientError as e:
raise UpstreamNetworkError(str(e)) from e
# 进程级共享客户端(UA/Referer 由 music_api 在初始化后注入)
client = AsyncHTTPClient()
+4 -4
View File
@@ -5,16 +5,16 @@
改为**启动时注入**(`init_secret_key`),从而解除对本模块对 Flask app 的依赖; 改为**启动时注入**(`init_secret_key`),从而解除对本模块对 Flask app 的依赖;
签名/校验算法与口径保持逐字一致,迁移期新旧应用共用同一密钥即可互认 token。 签名/校验算法与口径保持逐字一致,迁移期新旧应用共用同一密钥即可互认 token。
""" """
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import secrets import secrets
import time import time
from typing import Any, Optional from typing import Any
from core.config import AUDIO_HOST_MARKS, AUDIO_TOKEN_TTL, AUDIO_URL_KEYS from core.config import AUDIO_HOST_MARKS, AUDIO_TOKEN_TTL, AUDIO_URL_KEYS
# 由应用启动时注入(来源:auth_system.get_secret_key() → app.secret_key) # 由应用启动时注入(来源:auth_system.get_secret_key() → app.secret_key)
_SECRET_KEY: bytes = b'' _SECRET_KEY: bytes = b''
@@ -36,11 +36,11 @@ def sign_payload(payload: str) -> str:
def make_audio_token(url: str) -> str: def make_audio_token(url: str) -> str:
exp = int(time.time()) + AUDIO_TOKEN_TTL exp = int(time.time()) + AUDIO_TOKEN_TTL
nonce = secrets.token_hex(4) nonce = secrets.token_hex(4)
payload = base64.urlsafe_b64encode(f'{exp}|{nonce}|{url}'.encode('utf-8')).decode().rstrip('=') payload = base64.urlsafe_b64encode(f'{exp}|{nonce}|{url}'.encode()).decode().rstrip('=')
return payload + '.' + sign_payload(payload) return payload + '.' + sign_payload(payload)
def verify_audio_token(token: str) -> Optional[str]: def verify_audio_token(token: str) -> str | None:
try: try:
payload, sig = token.rsplit('.', 1) payload, sig = token.rsplit('.', 1)
if not hmac.compare_digest(sig, sign_payload(payload)): if not hmac.compare_digest(sig, sign_payload(payload)):
+7 -7
View File
@@ -12,14 +12,14 @@ Flask/FastAPI 迁移期共用,并为后续封装成带锁的单例(State 对
依赖仅限标准库 threading/collections——任何下载器/HTTP 相关对象 依赖仅限标准库 threading/collections——任何下载器/HTTP 相关对象
(如批量打包信号量)不放在这里,随对应服务模块搬迁。 (如批量打包信号量)不放在这里,随对应服务模块搬迁。
""" """
import collections import collections
import threading import threading
from typing import Any, Dict, List from typing import Any
# ============ 短链展开结果缓存 ============ # ============ 短链展开结果缓存 ============
# 用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) # 用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀)
SHORTLINK_CACHE: Dict[str, str] = {} SHORTLINK_CACHE: dict[str, str] = {}
SHORTLINK_CACHE_LOCK = threading.Lock() SHORTLINK_CACHE_LOCK = threading.Lock()
@@ -37,23 +37,23 @@ BATCH_DURATIONS_LOCK = threading.Lock()
# ============ 下载任务表 ============ # ============ 下载任务表 ============
# (批量打包 + 单曲下载统一):task_id -> {type, total, done, failed, status, start, msg, cancel, pause, device_id, result, params} # (批量打包 + 单曲下载统一):task_id -> {type, total, done, failed, status, start, msg, cancel, pause, device_id, result, params}
# 前端通过 /api/batch_progress 轮询进度,支持取消;完成后文件按 35 分钟保留期清理 # 前端通过 /api/batch_progress 轮询进度,支持取消;完成后文件按 35 分钟保留期清理
DOWNLOAD_TASKS: Dict[str, Dict[str, Any]] = {} DOWNLOAD_TASKS: dict[str, dict[str, Any]] = {}
DOWNLOAD_TASKS_LOCK = threading.Lock() DOWNLOAD_TASKS_LOCK = threading.Lock()
# ============ 设备在线心跳 ============ # ============ 设备在线心跳 ============
# device_id -> 最近心跳时间。心跳超时(PAUSE_AFTER_SECONDS)未上线的设备,其批量任务 # device_id -> 最近心跳时间。心跳超时(PAUSE_AFTER_SECONDS)未上线的设备,其批量任务
# 自动暂停让出排队名额;超时超过 CANCEL_AFTER_SECONDS(30 分钟)则自动取消任务并删文件 # 自动暂停让出排队名额;超时超过 CANCEL_AFTER_SECONDS(30 分钟)则自动取消任务并删文件
DEVICE_LAST_SEEN: Dict[str, float] = {} DEVICE_LAST_SEEN: dict[str, float] = {}
DEVICE_LAST_SEEN_LOCK = threading.Lock() DEVICE_LAST_SEEN_LOCK = threading.Lock()
# ============ 接口请求统计 ============ # ============ 接口请求统计 ============
REQUEST_STATS: Dict[str, Dict[str, Any]] = {} REQUEST_STATS: dict[str, dict[str, Any]] = {}
STATS_LOCK = threading.Lock() STATS_LOCK = threading.Lock()
# 用户解析/下载操作记录(IP);仅保留最近 2000 条 # 用户解析/下载操作记录(IP);仅保留最近 2000 条
REQ_LOG: List[Dict[str, Any]] = [] REQ_LOG: list[dict[str, Any]] = []
# ============ 上游探测缓存 ============ # ============ 上游探测缓存 ============
+5 -4
View File
@@ -4,10 +4,10 @@
自 main.py 原样迁出:安全整数解析、控制台输出兜底、SRT 时间轴、 自 main.py 原样迁出:安全整数解析、控制台输出兜底、SRT 时间轴、
LRC→SRT 转换、字段参数解析、163cn.tv 短链展开(含私有缓存)。 LRC→SRT 转换、字段参数解析、163cn.tv 短链展开(含私有缓存)。
""" """
import re import re
import sys import sys
import threading import threading
from typing import Any, Dict, List, Optional
def safe_int(value, default: int) -> int: def safe_int(value, default: int) -> int:
@@ -34,7 +34,7 @@ def srt_time(t: float) -> str:
h = int(t // 3600) h = int(t // 3600)
m = int((t % 3600) // 60) m = int((t % 3600) // 60)
s = int(t % 60) s = int(t % 60)
ms = int(round((t - int(t)) * 1000)) ms = round((t - int(t)) * 1000)
if ms >= 1000: if ms >= 1000:
ms = 999 ms = 999
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
@@ -80,7 +80,7 @@ def format_lyric(lrc: str, lyric_format: str) -> str:
return '\n'.join(out) return '\n'.join(out)
def parse_meta_fields(raw) -> Optional[List[str]]: def parse_meta_fields(raw) -> list[str] | None:
"""解析写入字段参数,空返回 None(表示全部写入)""" """解析写入字段参数,空返回 None(表示全部写入)"""
if raw is None or raw == '': if raw is None or raw == '':
return None return None
@@ -93,7 +93,7 @@ def parse_meta_fields(raw) -> Optional[List[str]]:
# 短链展开结果缓存:用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) # 短链展开结果缓存:用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀)
_SHORTLINK_CACHE: Dict[str, str] = {} _SHORTLINK_CACHE: dict[str, str] = {}
_SHORTLINK_CACHE_LOCK = threading.Lock() _SHORTLINK_CACHE_LOCK = threading.Lock()
@@ -106,6 +106,7 @@ def expand_shortlink(url: str) -> str:
expanded = url expanded = url
try: try:
import requests import requests
response = requests.get(url, allow_redirects=False, timeout=10) response = requests.get(url, allow_redirects=False, timeout=10)
expanded = response.headers.get('Location', url) expanded = response.headers.get('Location', url)
except Exception: except Exception:
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
[project]
name = "yunmusic-assistant"
version = "3.0.0"
description = "云村小助手 · 网易云音乐解析/下载服务(FastAPI + asyncio + aiohttp)"
requires-python = ">=3.12"
# ============ Ruff:静态检查 + 导入排序 + 格式化 ============
[tool.ruff]
target-version = "py312"
line-length = 120
extend-exclude = [".venv", ".zcode", "templates", "static", "data"]
[tool.ruff.lint]
# E/F 基础错误、I 导入排序、B bugbear、UP 升级、ASYNC 异步陷阱、S 安全、RUF
select = ["E", "F", "W", "I", "B", "UP", "C4", "ASYNC", "S", "RUF"]
ignore = [
"E501", # 行宽交给 formatter,不强制
"E402", # main.py 存在「先定义后 import」的历史结构,迁移期暂放行
"S101", # 生产代码外的 assert(测试/断言脚本)
"S104", # 绑定 0.0.0.0(服务端有意为之)
"S110", # try/except/pass(本项目大量「失败静默降级」策略,见交接文档)
"S112", # try/except/continue
"S324", # hashlib.md5 用于非安全场景(上游签名口径)
"S603", "S607", # subprocess(打包/运维脚本)
"UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定)
"RUF001", "RUF002", "RUF003", # 全角字符(中文注释/文案,误报)
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S", "ASYNC"]
".zcode/**" = ["S", "ASYNC", "F"]
"_mkpkg.py" = ["S"]
# ASYNC109:timeout 参数是有意 API——直接转发给 aiohttp 的 ClientTimeout,
# 不是「用 sleep 当超时」的反模式。
"core/http_client.py" = ["ASYNC109"]
[tool.ruff.lint.isort]
known-first-party = ["core", "web", "music_api", "auth_system"]
[tool.ruff.format]
quote-style = "preserve"
# ============ Pyright:类型检查 ============
[tool.pyright]
include = ["core", "web"]
exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"]
venvPath = "."
venv = ".venv"
pythonVersion = "3.12"
typeCheckingMode = "basic"
# 迁移期口径:存量 Flask 巨石(main.py/auth_*.py)暂不纳入严格检查,
# 新写的 core/ 与 web/ 要求类型干净。随迁移推进逐步把模块移入 include。
reportMissingImports = true
reportOptionalMemberAccess = "warning"
reportAttributeAccessIssue = "warning"
reportGeneralTypeIssues = "warning"
# ============ Pytest ============
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-q"
+6
View File
@@ -0,0 +1,6 @@
# 开发依赖(开发/CI 专用,不进生产镜像)
pytest>=8.0
pytest-asyncio>=0.24
ruff>=0.6
pyright>=1.1.390
httpx>=0.27
+13 -4
View File
@@ -1,5 +1,14 @@
# FastAPI 迁移目标依赖(当前生产仍以 Flask + waitress 运行,本文件不影响既有部署) # 生产运行时依赖(FastAPI 版)
# 待阶段 C 切换生产服务器后,合并进 requirements.txt 并移除 Flask 相关项。 # 口径同旧 requirements.txt:加版本上界,防上游大版本静默破坏(第 95 条 P2-6)。
# 版本上界口径同 requirements.txt(第 95 条 P2-6:防上游大版本静默破坏)。 #
# 迁移说明:本文件是 FASTAPI 目标栈。当前仓库同时保留 requirements.txt(Flask 旧栈)
# 直到路由全部迁移完成、生产服务器切换为止,两者可并存。
fastapi>=0.115,<1.0 fastapi>=0.115,<1.0
uvicorn>=0.30,<1.0 uvicorn[standard]>=0.30,<1.0
aiohttp>=3.10,<4.0
aiosqlite>=0.20,<1.0
aiofiles>=24.1
itsdangerous>=2.2,<3.0
cryptography>=50.0,<51.0
qrcode>=7.0,<8.3
python-multipart>=0.0.9
+79
View File
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""异步 SQLite 层测试:读写并发、WAL、事务原子性。"""
import asyncio
import pytest
import core.db as db
@pytest.fixture()
def temp_db(tmp_path, monkeypatch):
"""把 DB 指向临时目录,独立于生产 data/site.db。"""
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db')
return tmp_path / 'site.db'
async def test_write_then_read(temp_db):
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)')
rid = await db.execute('INSERT INTO t (v) VALUES (?)', ('hello',))
assert rid == 1
row = await db.fetch_one('SELECT v FROM t WHERE id=?', (1,))
assert row['v'] == 'hello'
assert await db.fetch_val('SELECT COUNT(*) FROM t') == 1
async def test_wal_mode_enabled(temp_db):
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)')
mode = await db.fetch_val('PRAGMA journal_mode')
assert str(mode).lower() == 'wal'
async def test_concurrent_read_write(temp_db):
"""核心诉求:写的同时读不被阻塞、且读到一致快照(WAL 快照隔离)。"""
await db.execute('CREATE TABLE nums (id INTEGER PRIMARY KEY, v INTEGER)')
stop = asyncio.Event()
read_errors = []
write_count = 0
async def writer():
nonlocal write_count
for i in range(200):
await db.execute('INSERT INTO nums (v) VALUES (?)', (i,))
write_count += 1
stop.set()
async def reader():
# 写入过程中持续读,不应抛 SQLITE_BUSY / 不应被阻塞致死
while not stop.is_set():
try:
n = await db.fetch_val('SELECT COUNT(*) FROM nums')
assert isinstance(n, int)
except Exception as e:
read_errors.append(e)
await asyncio.sleep(0)
await asyncio.gather(writer(), *[reader() for _ in range(4)])
assert not read_errors, f'读期间出现异常: {read_errors[:3]}'
assert write_count == 200
assert await db.fetch_val('SELECT COUNT(*) FROM nums') == 200
async def test_write_transaction_commit_and_rollback(temp_db):
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)')
async with db.write_transaction() as conn:
await conn.execute('INSERT INTO t (v) VALUES (?)', ('a',))
await conn.execute('INSERT INTO t (v) VALUES (?)', ('b',))
assert await db.fetch_val('SELECT COUNT(*) FROM t') == 2
with pytest.raises(RuntimeError):
async with db.write_transaction() as conn:
await conn.execute('INSERT INTO t (v) VALUES (?)', ('c',))
raise RuntimeError('boom')
# 回滚:c 不应落库
assert await db.fetch_val('SELECT COUNT(*) FROM t') == 2
+113
View File
@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
"""异步 HTTP 客户端测试:连接复用、有界重试、4xx 不重试、响应体上限。"""
import pytest
from aiohttp import web
from core.http_client import AsyncHTTPClient, UpstreamHTTPError, UpstreamTooLarge
@pytest.fixture()
async def server():
"""本地 aiohttp 测试服务器。"""
hits = {'flaky': 0}
async def ok(request):
return web.json_response({'ok': True})
async def flaky(request):
# 首次 500,之后 200 —— 验证 5xx 退避重试
hits['flaky'] += 1
if hits['flaky'] == 1:
return web.Response(status=500, text='boom')
return web.json_response({'retried': True})
async def notfound(request):
return web.Response(status=404, text='nope')
async def big(request):
return web.Response(body=b'x' * (2 * 1024 * 1024))
app = web.Application()
app.router.add_get('/ok', ok)
app.router.add_get('/flaky', flaky)
app.router.add_get('/notfound', notfound)
app.router.add_get('/big', big)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
base = f'http://127.0.0.1:{port}'
try:
yield base, hits
finally:
await runner.cleanup()
async def test_get_json(server):
base, _ = server
c = AsyncHTTPClient()
try:
assert await c.get_json(base + '/ok') == {'ok': True}
finally:
await c.close()
async def test_retry_on_5xx(server):
base, hits = server
c = AsyncHTTPClient()
try:
data = await c.get_json(base + '/flaky')
assert data == {'retried': True}
assert hits['flaky'] == 2 # 第一次 500 + 重试成功
finally:
await c.close()
async def test_no_retry_on_4xx(server):
base, _ = server
c = AsyncHTTPClient()
try:
with pytest.raises(UpstreamHTTPError) as ei:
await c.get_text(base + '/notfound')
assert ei.value.status == 404
finally:
await c.close()
async def test_response_size_limit(server):
base, _ = server
c = AsyncHTTPClient(max_bytes=1024 * 1024) # 1MB 上限,服务端返回 2MB
try:
with pytest.raises(UpstreamTooLarge):
await c.get_text(base + '/big')
finally:
await c.close()
async def test_session_reuse(server):
"""同一客户端多次请求复用同一(按 loop 隔离的)session —— 连接池生效。"""
base, _ = server
c = AsyncHTTPClient()
try:
await c.get_json(base + '/ok')
s1 = await c._session_for_loop()
await c.get_json(base + '/ok')
s2 = await c._session_for_loop()
assert s1 is s2 and not s1.closed
finally:
await c.close()
async def test_stream(server):
base, _ = server
c = AsyncHTTPClient()
try:
async with c.stream('GET', base + '/ok') as resp:
assert resp.status == 200
body = await resp.read()
assert b'ok' in body
finally:
await c.close()
+3
View File
@@ -12,6 +12,7 @@
两套独立进程语义。生产切换前需按计划把状态与任务模型统一(见迁移计划阶段 C), 两套独立进程语义。生产切换前需按计划把状态与任务模型统一(见迁移计划阶段 C),
当前仅用于开发期联调。 当前仅用于开发期联调。
""" """
from fastapi import FastAPI from fastapi import FastAPI
from core.config import APP_VERSION from core.config import APP_VERSION
@@ -40,5 +41,7 @@ def create_app(serve_flask_fallback: bool = False) -> FastAPI:
def _mount_flask_fallback(app: FastAPI) -> None: def _mount_flask_fallback(app: FastAPI) -> None:
"""把遗留 Flask WSGI 应用挂到根路径,兜底未迁移的路由。""" """把遗留 Flask WSGI 应用挂到根路径,兜底未迁移的路由。"""
from starlette.middleware.wsgi import WSGIMiddleware from starlette.middleware.wsgi import WSGIMiddleware
import main as legacy_flask import main as legacy_flask
app.mount('/', WSGIMiddleware(legacy_flask.app)) app.mount('/', WSGIMiddleware(legacy_flask.app))
+1
View File
@@ -4,6 +4,7 @@
开发期启动: .venv/Scripts/python.exe web/main.py 开发期启动: .venv/Scripts/python.exe web/main.py
生产切换前需先将状态/任务模型与既有 Flask 进程统一(见迁移计划阶段 C)。 生产切换前需先将状态/任务模型与既有 Flask 进程统一(见迁移计划阶段 C)。
""" """
import os import os
import sys import sys
+2 -1
View File
@@ -7,6 +7,7 @@ FastAPI 侧改为直接返回 JSONResponse,但**包体结构完全一致**:
error: {"status": sc, "success": false, "message": msg, ["error_code": ...]} error: {"status": sc, "success": false, "message": msg, ["error_code": ...]}
这样前端无需任何改动即可同时对接两个框架。 这样前端无需任何改动即可同时对接两个框架。
""" """
from typing import Any from typing import Any
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -24,7 +25,7 @@ def success(data: Any = None, message: str = 'success', status_code: int = 200)
return JSONResponse(content=body, status_code=status_code) return JSONResponse(content=body, status_code=status_code)
def error(message: str, status_code: int = 400, error_code: str = None) -> JSONResponse: def error(message: str, status_code: int = 400, error_code: str | None = None) -> JSONResponse:
"""错误响应(对应 Flask APIResponse.error)""" """错误响应(对应 Flask APIResponse.error)"""
body = { body = {
'status': status_code, 'status': status_code,
+3 -4
View File
@@ -5,6 +5,7 @@
此处实现与 main.py 的 health_check / api_info **响应契约逐字段对齐**, 此处实现与 main.py 的 health_check / api_info **响应契约逐字段对齐**,
用于验证双框架一致性(见 .zcode/refactor/parity.py)。 用于验证双框架一致性(见 .zcode/refactor/parity.py)。
""" """
import sys import sys
import time import time
@@ -41,6 +42,7 @@ def health_check():
"""健康检查API(对应 Flask health_check)""" """健康检查API(对应 Flask health_check)"""
try: try:
from music_api import load_cookies from music_api import load_cookies
cookies = load_cookies() cookies = load_cookies()
cookie_status = 'valid' if cookies else 'invalid' cookie_status = 'valid' if cookies else 'invalid'
@@ -66,10 +68,7 @@ def api_info():
'version': APP_VERSION, 'version': APP_VERSION,
'description': '云村小助手 · 网易云音乐解析/下载服务', 'description': '云村小助手 · 网易云音乐解析/下载服务',
'endpoints': dict(_ENDPOINTS), 'endpoints': dict(_ENDPOINTS),
'supported_qualities': [ 'supported_qualities': ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'],
'standard', 'exhigh', 'lossless',
'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'
],
'config': { 'config': {
'downloads_dir': 'configured', 'downloads_dir': 'configured',
'max_body_bytes': '2MB(备份上传 200MB)', 'max_body_bytes': '2MB(备份上传 200MB)',