Archived
refactor(core): 抽出框架无关核心层 core/,为 FastAPI 迁移铺路
第一步模块拆分(纯搬运,零行为变化): - 新增 core/ 包:config(常量/配置) + state(运行时状态) + utils(纯函数) + security(音频签名) - main.py 改为从 core 导入并以原下划线名再导出,250+ 既有调用点零改动 关键设计: - 状态全部原地修改,import 即共享同一对象(21 项身份断言 + 10 项冒烟全绿) - 音频签名密钥由启动时 _init_audio_secret(app.secret_key) 注入, 解除 core.security 对 Flask app 的依赖,新老应用可互认 token 验证:py_compile 通过;冒烟 10/10 PASS;对象身份 22/22 PASS; waitress 真实启动 /health 200 version=2.4.0。main.py 减 229 行。
This commit is contained in:
1 parent
d209af847a
commit
c2a8da3e43
6 files changed
+405
-227
No files matched your search
@@ -54,25 +54,42 @@ except ImportError as e:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# 站点统一版本号(P3-12/P3-22:/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码)
|
||||
APP_VERSION = '2.4.0'
|
||||
# ============ 框架无关核心层(core/)============
|
||||
# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现**
|
||||
# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。
|
||||
from core.config import (
|
||||
APP_VERSION, RESULT_RETENTION_SECONDS, Settings,
|
||||
USER_COOKIE_FILE,
|
||||
PAUSE_AFTER_SECONDS, CANCEL_AFTER_SECONDS, HEARTBEAT_INTERVAL,
|
||||
MAX_ACTIVE_TASKS_PER_USER, MAX_TOTAL_TASKS, MAX_TERMINAL_TASKS,
|
||||
BATCH_ZIP_CONCURRENCY, VALID_QUALITY_LEVELS,
|
||||
)
|
||||
from core.state import (
|
||||
RATE_BUCKETS as _RATE_BUCKETS, RATE_LOCK as _RATE_LOCK,
|
||||
BATCH_DURATIONS, BATCH_DURATIONS_LOCK,
|
||||
DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK,
|
||||
DEVICE_LAST_SEEN, DEVICE_LAST_SEEN_LOCK,
|
||||
REQUEST_STATS, STATS_LOCK as _STATS_LOCK, REQ_LOG,
|
||||
GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE,
|
||||
VIP_CACHE as _VIP_CACHE, VIP_USER_CACHE as _VIP_USER_CACHE,
|
||||
SERVER_NICK_CACHE as _SERVER_NICK_CACHE, SERVER_NICK_LOCK as _SERVER_NICK_LOCK,
|
||||
VIP_PROBE_LOCK as _VIP_PROBE_LOCK,
|
||||
)
|
||||
from core.utils import (
|
||||
safe_int as _safe_int, safe_console_print as _safe_console_print,
|
||||
srt_time as _srt_time, format_lyric as _format_lyric,
|
||||
parse_meta_fields as _parse_meta_fields, expand_shortlink as _expand_shortlink,
|
||||
)
|
||||
from core.security import (
|
||||
init_secret_key as _init_audio_secret,
|
||||
is_audio_url as _is_audio_url,
|
||||
make_audio_token as _make_audio_token,
|
||||
verify_audio_token as _verify_audio_token,
|
||||
protect_audio_urls as _protect_audio_urls,
|
||||
)
|
||||
|
||||
# 交付结果保留期(P1-3/D-1:服务端与前端「领取」入口统一 35 分钟——前端入口保留 35 分钟,
|
||||
# 原 30 分钟会在最后 5 分钟让领取必 404)。终态任务记录、任务目录、下载缓存均按此回收。
|
||||
RESULT_RETENTION_SECONDS = 35 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIConfig:
|
||||
"""API配置类"""
|
||||
host: str = '0.0.0.0'
|
||||
port: int = 5000
|
||||
debug: bool = False
|
||||
downloads_dir: str = 'downloads'
|
||||
# 第 98 条:max_file_size/request_timeout 两个架空字段已删除——请求体上限真实
|
||||
# 生效点是 _body_size_limit(业务 2MB/备份 200MB),上游超时在 music_api 各调用点
|
||||
log_level: str = 'INFO'
|
||||
cors_origins: str = (os.environ.get('CORS_ORIGINS') or '*').strip() or '*' # 第 100 条:可经环境变量收敛同源(空值回落默认)
|
||||
# 向后兼容别名:既有代码/文档沿用 APIConfig 名称
|
||||
APIConfig = Settings
|
||||
|
||||
|
||||
class APIResponse:
|
||||
@@ -103,34 +120,6 @@ class APIResponse:
|
||||
return response, status_code
|
||||
|
||||
|
||||
# 短链展开结果缓存:用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀)
|
||||
_SHORTLINK_CACHE: Dict[str, str] = {}
|
||||
_SHORTLINK_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _expand_shortlink(url: str) -> str:
|
||||
"""展开 163cn.tv 短链(带进程内缓存;失败原样返回)"""
|
||||
with _SHORTLINK_CACHE_LOCK:
|
||||
hit = _SHORTLINK_CACHE.get(url)
|
||||
if hit is not None:
|
||||
return hit
|
||||
expanded = url
|
||||
try:
|
||||
import requests
|
||||
response = requests.get(url, allow_redirects=False, timeout=10)
|
||||
expanded = response.headers.get('Location', url)
|
||||
except Exception:
|
||||
expanded = url
|
||||
# 第 97 条(P3):展开失败(Location 缺失/网络异常,expanded 原样等于 url)不写
|
||||
# 缓存——旧口径一次网络抖动会让该分享链接在整个缓存存续期内必然解析失败
|
||||
if expanded != url:
|
||||
with _SHORTLINK_CACHE_LOCK:
|
||||
if len(_SHORTLINK_CACHE) > 500:
|
||||
_SHORTLINK_CACHE.clear()
|
||||
_SHORTLINK_CACHE[url] = expanded
|
||||
return expanded
|
||||
|
||||
|
||||
class MusicAPIService:
|
||||
"""音乐API服务类"""
|
||||
|
||||
@@ -281,6 +270,9 @@ api_service = MusicAPIService(config)
|
||||
|
||||
# ============ 站点认证系统(SQLite + 签名会话) ============
|
||||
app.secret_key = auth_system.get_secret_key()
|
||||
# 音频签名密钥注入 core.security(框架无关;与 app.secret_key 同源,
|
||||
# 保证 Flask 与迁移目标 FastAPI 生成的音频 token 互相可校验)
|
||||
_init_audio_secret(app.secret_key)
|
||||
|
||||
|
||||
def _quiesce_tasks_for_restore() -> None:
|
||||
@@ -309,8 +301,7 @@ register_auth_routes(app)
|
||||
|
||||
|
||||
# ============ 公网限流(按 IP 滑动窗口,防刷防爆破) ============
|
||||
_RATE_BUCKETS = {}
|
||||
_RATE_LOCK = threading.Lock()
|
||||
# _RATE_BUCKETS / _RATE_LOCK 已迁出至 core.state(此处 import 同名共享)
|
||||
|
||||
|
||||
def _rate_client_ip() -> str:
|
||||
@@ -679,43 +670,19 @@ def api_fe_error():
|
||||
# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*)
|
||||
# 均通过该会话鉴权。
|
||||
|
||||
# 游客个人登录使用的独立 Cookie 文件(与管理员全局 Cookie 分离)。
|
||||
# 第 100 条:这是有意的隔离——QRLoginClient 的默认值恰是全局 cookie.txt,
|
||||
# 显式传参防误写全局文件;第 91 条起本站任何路径都不再写它(仅构造参数)
|
||||
USER_COOKIE_FILE = 'user_cookie.txt'
|
||||
|
||||
# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。
|
||||
# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。
|
||||
# (下载本身的并发已由下载器内的全局信号量控制)
|
||||
# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。
|
||||
BATCH_ZIP_SEM = CountingSemaphore(3)
|
||||
# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁,
|
||||
# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。
|
||||
BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY)
|
||||
|
||||
# 最近完成批次(成功打包过)的耗时记录,用于估算排队用户的预计等待时间
|
||||
BATCH_DURATIONS = collections.deque(maxlen=10)
|
||||
BATCH_DURATIONS_LOCK = threading.Lock()
|
||||
|
||||
# 下载任务表(批量打包 + 单曲下载统一):task_id -> {type, total, done, failed, status, start, msg, cancel, pause, device_id, result, params}
|
||||
# 前端通过 /api/batch_progress 轮询进度,支持取消;完成后文件按 35 分钟保留期清理
|
||||
DOWNLOAD_TASKS: Dict[str, Dict[str, Any]] = {}
|
||||
DOWNLOAD_TASKS_LOCK = threading.Lock()
|
||||
|
||||
# 设备在线心跳:device_id -> 最近心跳时间
|
||||
# 心跳超时(PAUSE_AFTER_SECONDS)未上线的设备,其批量任务自动暂停,让出排队名额给下一位;
|
||||
# 重新打开网站会恢复心跳并自动回到队列继续下载
|
||||
# 心跳超时超过 CANCEL_AFTER_SECONDS(30 分钟)则自动取消任务并删除音乐文件
|
||||
DEVICE_LAST_SEEN: Dict[str, float] = {}
|
||||
DEVICE_LAST_SEEN_LOCK = threading.Lock()
|
||||
PAUSE_AFTER_SECONDS = 40
|
||||
CANCEL_AFTER_SECONDS = 30 * 60
|
||||
HEARTBEAT_INTERVAL = 15
|
||||
|
||||
# 每用户同时在途(排队/运行/暂停)任务上限与全站任务表上限(F20:资源上限)
|
||||
MAX_ACTIVE_TASKS_PER_USER = 5
|
||||
MAX_TOTAL_TASKS = 200
|
||||
# 第 97 条(P2-2):终态任务要留表 35 分钟供领取,容量闸只数在途任务——旧口径把
|
||||
# 终态记录计入 200 上限会把全站吞吐封顶在约 5.7 任务/分钟;终态另设更大的滞留
|
||||
# 上限保内存有界(1000 ≈ 28 任务/分钟的领取窗口,远高于在途处理能力)
|
||||
MAX_TERMINAL_TASKS = 1000
|
||||
# 以下运行时状态/常量已迁出(原样实现,此处 import 共享同一对象):
|
||||
# DOWNLOAD_TASKS / DEVICE_LAST_SEEN / BATCH_DURATIONS → core.state
|
||||
# PAUSE_AFTER_SECONDS / CANCEL_AFTER_SECONDS / HEARTBEAT_INTERVAL
|
||||
# MAX_ACTIVE_TASKS_PER_USER / MAX_TOTAL_TASKS / MAX_TERMINAL_TASKS
|
||||
# USER_COOKIE_FILE → core.config
|
||||
|
||||
|
||||
def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: str = '') -> Optional[str]:
|
||||
@@ -1089,28 +1056,11 @@ def _touch_device(device_id: str) -> None:
|
||||
DEVICE_LAST_SEEN[device_id] = time.time()
|
||||
|
||||
|
||||
def _safe_int(value, default: int) -> int:
|
||||
"""用户输入的安全整数解析:垃圾值回退默认值(而非 500)"""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_console_print(text: str) -> None:
|
||||
"""控制台输出兜底:stdout 被重定向到文件且系统编码非 UTF-8 时
|
||||
(典型:NSSM 服务日志 + 中文 Windows GBK 控制台),emoji 等字符会触发
|
||||
UnicodeEncodeError 中断启动流程——实测崩溃循环。此处降级为可编码字符。"""
|
||||
try:
|
||||
print(text)
|
||||
except UnicodeEncodeError:
|
||||
enc = getattr(sys.stdout, 'encoding', None) or 'ascii'
|
||||
print(text.encode(enc, errors='replace').decode(enc, errors='replace'))
|
||||
# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享)
|
||||
|
||||
|
||||
# ============ 接口请求统计 ============
|
||||
REQUEST_STATS: Dict[str, Dict[str, Any]] = {}
|
||||
_STATS_LOCK = threading.Lock()
|
||||
# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享)
|
||||
|
||||
# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用)
|
||||
# F09:兼容别名(/Search、/Song_V1、/Playlist、/Album、/Download)一并纳入统计与限流,
|
||||
@@ -1153,8 +1103,7 @@ ENDPOINT_NAMES = {
|
||||
'/api/playlist/play': '公开歌单播放API',
|
||||
}
|
||||
|
||||
# 用户解析/下载操作记录(IP)
|
||||
REQ_LOG: List[Dict[str, Any]] = []
|
||||
# 用户解析/下载操作记录(IP)——REQ_LOG 已迁出至 core.state(此处 import 同名共享)
|
||||
|
||||
|
||||
def _record_log(action: str, ip: str, song: str) -> None:
|
||||
@@ -1182,7 +1131,7 @@ def _parse_cookie_str(raw) -> Dict[str, str]:
|
||||
return cookies
|
||||
|
||||
|
||||
_GLOBAL_MUSIC_U_CACHE = {'v': None, 'ts': 0.0}
|
||||
# _GLOBAL_MUSIC_U_CACHE 已迁出至 core.state(此处 import 同名共享)
|
||||
|
||||
|
||||
def _global_music_u() -> str:
|
||||
@@ -1247,67 +1196,7 @@ def _build_download_base(filename_format: str, music_info: Dict[str, Any]) -> st
|
||||
return _safe(f"{artist} - {name}") # artist_song(默认)
|
||||
|
||||
|
||||
def _srt_time(t: float) -> str:
|
||||
"""秒 → SRT 时间格式 HH:MM:SS,mmm"""
|
||||
h = int(t // 3600)
|
||||
m = int((t % 3600) // 60)
|
||||
s = int(t % 60)
|
||||
ms = int(round((t - int(t)) * 1000))
|
||||
if ms >= 1000:
|
||||
ms = 999
|
||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||
|
||||
|
||||
def _format_lyric(lrc: str, lyric_format: str) -> str:
|
||||
"""将 LRC 歌词转换为指定格式(lrc / srt)
|
||||
F37:时间轴先规范化再转 SRT——支持一行多时间标签(同句多时段各自成条)、
|
||||
分钟超两位([100:00])、乱序行(排序后区间不再倒挂)、纯文本行(无时间戳则原样保留)。"""
|
||||
if lyric_format != 'srt':
|
||||
return lrc
|
||||
tag_re = re.compile(r'\[(\d{1,3}):(\d{1,2}(?:[.:]\d+)?)\]')
|
||||
parsed = []
|
||||
for line in lrc.split('\n'):
|
||||
tags = tag_re.findall(line)
|
||||
text = tag_re.sub('', line).strip()
|
||||
if not tags:
|
||||
# 无时间戳的纯文本:保留为 0 时长行不合适,直接附在输出尾部
|
||||
if text:
|
||||
parsed.append((None, text))
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
for m, s in tags:
|
||||
try:
|
||||
secs = int(m) * 60 + float(s.replace(':', '.'))
|
||||
except ValueError:
|
||||
continue
|
||||
parsed.append((secs, text))
|
||||
timed = [(t, x) for t, x in parsed if t is not None]
|
||||
untimed = [x for t, x in parsed if t is None]
|
||||
if not timed:
|
||||
return lrc
|
||||
timed.sort(key=lambda p: p[0])
|
||||
out = []
|
||||
for i, (t, text) in enumerate(timed):
|
||||
end = timed[i + 1][0] if i + 1 < len(timed) else t + 5
|
||||
if end <= t:
|
||||
end = t + 1 # 同时间多行:给 1 秒最小区间,不再生成倒挂区间
|
||||
out.append(f"{i + 1}\n{_srt_time(t)} --> {_srt_time(end)}\n{text}\n")
|
||||
if untimed:
|
||||
out.append('\n'.join(untimed))
|
||||
return '\n'.join(out)
|
||||
|
||||
|
||||
def _parse_meta_fields(raw) -> Optional[List[str]]:
|
||||
"""解析写入字段参数,空返回 None(表示全部写入)"""
|
||||
if raw is None or raw == '':
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
return [f.strip() for f in raw.split(',') if f.strip()] or None
|
||||
try:
|
||||
return list(raw) or None
|
||||
except Exception:
|
||||
return None
|
||||
# _srt_time / _format_lyric / _parse_meta_fields 已迁出至 core.utils(此处 import 同名共享)
|
||||
|
||||
|
||||
def _verify_admin_password(data) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
@@ -1351,10 +1240,8 @@ def _detect_vip(cookies: Dict[str, str]) -> Tuple[int, str, List[str]]:
|
||||
_VIP_PROBE_LOCK.release()
|
||||
|
||||
|
||||
# 服务器网易云账号昵称缓存(10 分钟):用于在非管理员可见的响应中遮蔽真实昵称
|
||||
_SERVER_NICK_CACHE = {'nick': None, 'ts': 0.0}
|
||||
_SERVER_NICK_LOCK = threading.Lock() # 第 91 条(P3-9):刷新单飞锁,防并发重复打上游
|
||||
_VIP_PROBE_LOCK = threading.Lock() # 第 100 条:音质探测单飞锁(有界等待)
|
||||
# 服务器网易云账号昵称缓存与单飞锁已迁出至 core.state(此处 import 同名共享):
|
||||
# _SERVER_NICK_CACHE(10 分钟) / _SERVER_NICK_LOCK / _VIP_PROBE_LOCK
|
||||
|
||||
|
||||
def _mask_server_nickname(nick: str) -> str:
|
||||
@@ -1434,60 +1321,10 @@ def before_request():
|
||||
|
||||
|
||||
# ============ 音频直链多重防护(HMAC 签名 + 时效 + 代理流)============
|
||||
_AUDIO_TOKEN_TTL = RESULT_RETENTION_SECONDS + 60 # 第 93 条(P2-4):= 36 分钟——原 30 分钟
|
||||
# 大于交付/解析结果 35 分钟保留期 60 秒:交付窗口末段试听不再 403(旧 30 分钟口径矛盾已修)
|
||||
_AUDIO_URL_KEYS = {'url', 'audioUrl'}
|
||||
_AUDIO_HOST_MARKS = ('126.net', 'music.126.net', '.netease')
|
||||
|
||||
|
||||
def _is_audio_url(v) -> bool:
|
||||
return isinstance(v, str) and v.startswith('http') and any(m in v for m in _AUDIO_HOST_MARKS)
|
||||
|
||||
|
||||
def _sign_payload(payload: str) -> str:
|
||||
key = app.secret_key.encode('utf-8') if isinstance(app.secret_key, str) else app.secret_key
|
||||
return hmac.new(key, payload.encode('utf-8'), hashlib.sha256).hexdigest()[:32]
|
||||
|
||||
|
||||
def _make_audio_token(url: str) -> str:
|
||||
exp = int(time.time()) + _AUDIO_TOKEN_TTL
|
||||
nonce = secrets.token_hex(4)
|
||||
payload = base64.urlsafe_b64encode(f'{exp}|{nonce}|{url}'.encode('utf-8')).decode().rstrip('=')
|
||||
return payload + '.' + _sign_payload(payload)
|
||||
|
||||
|
||||
def _verify_audio_token(token: str) -> Optional[str]:
|
||||
try:
|
||||
payload, sig = token.rsplit('.', 1)
|
||||
if not hmac.compare_digest(sig, _sign_payload(payload)):
|
||||
return None
|
||||
pad = '=' * (-len(payload) % 4)
|
||||
exp, _nonce, url = base64.urlsafe_b64decode(payload + pad).decode('utf-8').split('|', 2)
|
||||
if int(exp) < time.time():
|
||||
return None
|
||||
return url
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _protect_audio_urls(obj) -> bool:
|
||||
"""递归就地替换 dict/list 中的音频直链为签名代理地址,返回是否发生替换"""
|
||||
hit = {'v': False}
|
||||
|
||||
def walk(o):
|
||||
if isinstance(o, dict):
|
||||
for k, v in list(o.items()):
|
||||
if k in _AUDIO_URL_KEYS and _is_audio_url(v):
|
||||
o[k] = '/audio_stream?token=' + _make_audio_token(v)
|
||||
hit['v'] = True
|
||||
else:
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for it in o:
|
||||
walk(it)
|
||||
|
||||
walk(obj)
|
||||
return hit['v']
|
||||
# 已迁出至 core.security(框架无关,密钥经 _init_audio_secret 注入):
|
||||
# _AUDIO_TOKEN_TTL 常量 → core.config.AUDIO_TOKEN_TTL(仍按 RESULT_RETENTION_SECONDS+60 计)
|
||||
# _is_audio_url / _make_audio_token / _verify_audio_token / _protect_audio_urls → 此处 import 同名共享
|
||||
# 注:核心层用 sign_payload 替代原 _sign_payload(仅内部调用,未在此保留旧名)
|
||||
|
||||
|
||||
@app.route('/audio_stream')
|
||||
@@ -3017,12 +2854,9 @@ def playlist_play_api():
|
||||
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
|
||||
|
||||
|
||||
# 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口,
|
||||
# 公网下账号栏高频刷新会连累服务器账号触发风控。
|
||||
# 第 91 条:修复潜伏缩进缺陷——原定义误缩进在 /account/vip 函数体内,每次调用
|
||||
# 都被重置(5 分钟缓存从未生效),且模块级 _vip_cache_reset 引用它必然 NameError
|
||||
_VIP_CACHE = {'data': None, 'ts': 0.0}
|
||||
_VIP_USER_CACHE = {} # user 路径按 cookie 哈希的 5 分钟缓存
|
||||
# 全局账号 VIP 探测缓存已迁出至 core.state(此处 import 同名共享):
|
||||
# _VIP_CACHE(5 分钟;第 91 条修复的缩进缺陷原样保留正确语义)
|
||||
# _VIP_USER_CACHE(user 路径按 cookie 哈希的 5 分钟缓存)
|
||||
|
||||
|
||||
def _vip_cache_reset() -> None:
|
||||
|
||||
Reference in new issue
Block a user