This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/services/_shared.py
T
snowy-yang 7a70eef636 refactor: 删除 services.runtime 兼容门面,7 处调用点直连真实子模块
为什么:上一次分包重构把 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/ 指纹三项一致
2026-09-26 14:48:05 +08:00

168 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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'}