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

+169
View File
@@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""服务层共享状态与低层工具(限流桶、请求统计、命名与常量)。
(自 services/runtime.py 拆出,行为与原实现一致。)
"""
import collections
import re
import time
from typing import Any
from core.config import (
BATCH_ZIP_CONCURRENCY,
)
from core.state import RATE_BUCKETS as _RATE_BUCKETS
from core.state import RATE_LOCK as _RATE_LOCK
from core.state import (
REQ_LOG,
)
from music_downloader import (
CountingSemaphore,
)
def _rate_ok(key: str, limit: int, window: int) -> bool:
"""滑动窗口限流:True=放行,False=超限"""
now = time.time()
with _RATE_LOCK:
dq = _RATE_BUCKETS.get(key)
if dq is None:
# 桶数量防膨胀:超限时清理不活跃桶
if len(_RATE_BUCKETS) > 8000:
for k in [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > window]:
_RATE_BUCKETS.pop(k, None)
dq = collections.deque()
_RATE_BUCKETS[key] = dq
while dq and now - dq[0] > window:
dq.popleft()
if len(dq) >= limit:
return False
dq.append(now)
return True
# 旧版管理员口令已废除(2026-09-15):管理员权限仅认站点管理员会话(admin 登录),
# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*)
# 均通过该会话鉴权。
# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。
# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。
# (下载本身的并发已由下载器内的全局信号量控制)
# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。
# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁,
# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。
BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY)
_MONITOR_ERR_TS = {'ts': 0.0, 'n': 0} # 监控线程异常限频记录(持续异常不静默也不刷屏)
# 监控线程周期兜底的三个节流字典——补丁时定义丢失
# (只剩使用),NameError 被外层 except 吞成 WARNING,孤儿对账/账本清理/tasks
# 补扫自那以后从未执行(music_api.log 实证 2064 条 WARNING)。红转绿:重启后
# 日志零 NameError 且对账真实运行。
_LEDGER_LAST_RECOVER = {'ts': 0.0} # 上次孤儿账本对账时间(每 60s 一次)
_LEDGER_LAST_CLEANUP = {'ts': 0.0} # 上次 settled 账本 90 天清理时间(每 24h 一次)
_TASKS_SWEEP_LAST = {'ts': 0.0} # 上次 tasks/ 孤儿目录补扫时间(每小时一次)
def _sweep_rate_buckets() -> None:
"""限流桶周期清扫(O-5):把超过 10 分钟无新请求的桶移出,防止低流量长期运行下
缓慢累积(原实现只在"新建第 8001 个桶"时才触发清理,时机太晚)"""
now = time.time()
with _RATE_LOCK:
stale = [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > 600]
for k in stale:
_RATE_BUCKETS.pop(k, None)
# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享)
# ============ 接口请求统计 ============
# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享)
# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用)
# 兼容别名(/Search、/Song_V1、/Playlist、/Album、/Download)一并纳入统计与限流,
# 防止用大写别名绕过业务限流
COUNTED_PATHS = {
'/song', '/search', '/playlist', '/album', '/toplist', '/artist_songs',
'/comments', '/download', '/download/batch_zip',
'/podcast/parse', '/podcast/programs', '/podcast/search', '/podcast/hot',
'/podcast/download', '/podcast/download/batch_zip',
'/user/playlists', '/user/playlist', '/user/status', '/user/logout',
'/user/qr/create', '/user/qr/check',
# 账号探测与个人操作端点此前漏在限流名单外——
# /account/vip 每请求最多 9 次上游探测、/user/comment 与 /user/collect 携
# 任意 cookie 触发上游 weapi、/auth/me 可匿名无限刷,全部纳入免费 30/分桶
'/account/vip', '/user/collect', '/user/comment', '/auth/me',
'/admin/qr/create', '/admin/qr/check', '/admin/cookie/status',
'/admin/cookie/delete', '/admin/logs',
'/admin/site/play_key/get', '/admin/site/play_key/rotate',
'/global/playlists', '/global/playlist', '/api/playlist/play',
'/song_v1',
}
_COUNTED_LOWER = {p.lower() for p in COUNTED_PATHS}
ENDPOINT_NAMES = {
'/song': '歌曲信息', '/search': '搜索', '/playlist': '歌单详情',
'/album': '专辑详情', '/toplist': '榜单列表', '/artist_songs': '歌手歌曲',
'/comments': '评论', '/download': '音乐下载', '/download/batch_zip': '批量ZIP',
'/health': '健康检查', '/api/info': '服务信息', '/api/topen': '请求统计',
'/account/vip': '账号VIP信息',
'/podcast/parse': '播客解析', '/podcast/programs': '播客单集列表',
'/podcast/search': '播客搜索', '/podcast/hot': '热门播客',
'/podcast/download': '播客单集下载', '/podcast/download/batch_zip': '播客整台打包',
'/user/playlists': '个人歌单', '/user/playlist': '个人歌单详情',
'/user/status': '个人登录状态', '/user/logout': '个人退出',
'/user/qr/create': '个人二维码', '/user/qr/check': '个人登录轮询',
'/admin/qr/create': '管理员二维码', '/admin/qr/check': '管理员登录轮询',
'/admin/cookie/status': 'Cookie状态', '/admin/cookie/delete': 'Cookie管理',
'/admin/logs': '操作记录',
'/global/playlists': '全局账号歌单', '/global/playlist': '全局歌单详情',
'/api/playlist/play': '公开歌单播放API',
}
# 用户解析/下载操作记录(IP)——REQ_LOG 已迁出至 core.state(此处 import 同名共享)
def _record_log(action: str, ip: str, song: str) -> None:
"""记录用户操作(解析/下载)。歌名截断(来自上游/用户输入,不设上限会撑内存与后台页)"""
REQ_LOG.append({
'action': action,
'ip': ip,
'song': str(song or '')[:100],
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
})
if len(REQ_LOG) > 2000:
del REQ_LOG[:len(REQ_LOG) - 2000]
def _build_download_base(filename_format: str, music_info: dict[str, Any]) -> str:
"""按文件名格式生成下载基础名(不含扩展名)"""
name = music_info.get('name', '未知歌曲')
artist = music_info.get('artist_string', '') or '未知歌手'
def _safe(s: str) -> str:
# 交付文件名必须剔除控制字符——歌名含 \r\n 时交付头的
# Content-Disposition 会抛 ValueError,任务完成已扣费但领取必 500
# (当年只修了下载器磁盘名这条同型路径,交付名漏网)
s = re.sub(r'[\x00-\x1f\x7f]', '', s)
return ''.join(c for c in s if c not in r'<>:"/\|?*').strip() or 'unknown'
if filename_format == 'song':
return _safe(name)
if filename_format == 'song_artist':
return _safe(f"{name} - {artist}")
return _safe(f"{artist} - {name}") # artist_song(默认)
# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、
# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。
# 用户主动动作(任务列表/取消)保留日志。
_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat',
'/audio_stream', '/api/fe_error'}