# -*- 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'}