Archived
为什么:上一次分包重构把 1900 行巨石按职责拆成 9 个子模块后,留了一个 runtime.py 作为「原样再导出」的兼容门面,好让调用点零改动。但迁移期结束后 没有一个调用点迁走——门面成了永久中间层:每次读代码都要先跳一层才知道 符号真正住在哪,pyright 也无法从门面追溯定义位置。 改法:保持「函数内延迟导入」的形式不变,只把导入目标换成真实子模块。 **刻意不提到模块顶部**——services/__init__.py 里有依赖自检 (缺 auth_system/podcast_api/music_api/music_downloader/qr_login 就 sys.exit(1)), 提到顶层会让「import web.*」这一步就触发该自检,改变启动语义,超出本次范围。 7 处调用点(AST 精确扫描确认全集): - web/routers/download.py ×4:download / download_batch_zip / podcast_download / podcast_batch_zip,分别直连 services.downloads / batch / podcast / tasks 外加 _shared._build_download_base 与 api_service.api_service - web/routers/tasks.py ×2:_semaphores 取 _shared.BATCH_ZIP_SEM + api_service, topen 取 _shared.ENDPOINT_NAMES - web/tasks_registry.py ×1:start_task 直连 _taskcore._start_task_thread 子模块短名统一加 `services_` 前缀别名(services_tasks 等),避免与函数内 既有局部名相撞。 顺带清理:9 个子模块 docstring 里「(自 services/runtime.py 拆出……)」的 历史说明在门面删除后已成悬空引用,一并去掉;包的模块划分说明改为 「不再有兼容门面层」。 验证: - 符号可达:11 个被引符号在真实子模块上全部 hasattr 通过; services.runtime 已 ModuleNotFoundError;api_service 仍是同一实例对象 - 三个调用方模块均可正常 import - 门禁:ruff 0 · pyright 0 errors 0 warnings · pytest 175 passed - 真实 uvicorn 冒烟 8/8(含 POST /auth/login 400),临时数据目录独立落库 9 张表,生产 data/ 指纹三项一致
168 lines
7.6 KiB
Python
168 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""服务层共享状态与低层工具(限流桶、请求统计、命名与常量)。
|
||
"""
|
||
|
||
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:
|
||
"""限流桶周期清扫:把超过 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'}
|