Archived
模板/样式/脚本的注释里散落着「第 N 条」「F59」「P3-43」这类编号,它们指向的 修复验收台账与交接文档已在本轮删除——留着既检索不到、也对不上号,还让后来读 注释的人以为存在一份可查的编号清单。本次只动注释,正文零改动。 覆盖范围(全仓一致): - templates/index.html 163 处、static/site_auth.js 55 处,共 218 处编号整段摘除 (含「第 N 条(Px):」前缀与「(Fxx 短标签)」括号)。 - 容器/配置 16 处:docker-compose.yml、entrypoint.sh(F59 / 第 93 条 / 第 97 条)、 Dockerfile(T7 / 第 97 条)、.dockerignore(T1 / 第 97 条 / 第 55 条 / P2-9)、 .env.example(F59 / 第 100 条 / 「详见交接文档 F11」这条失效指引)、.gitignore(P3-41)。 - Python 4 处:core/config.py(D-1)、services/tasks.py(O-5×2)、services/_shared.py(O-5) ——上一轮 Python 侧清理的漏网。 为什么规则要收窄(两类误伤,实测踩到): 1. 标签首字母不能用泛化的 [A-Z]:X5 是 QQ 浏览器内核、ES5 是 JS 版本,都不是编号。 泛化后会把「微信 XWeb/X5、QQ 内置」改成「微信 XWeb/、QQ 内置」,把 「Chromium <87 的老 X5 不识别 inset」改成「老 不识别」。现按本仓真实用过的 任务族白名单收窄(A B C D F O P R S T U),X5/ES5 原样保留。 2. 括号内「编号 + 长说明」不能整段删:「(F51 只防了切歌,这里补同歌翻页)」 整段删掉会连说明一起丢;现只摘编号、保留说明文字。 为什么同步改黄金契约快照:题头注释属于模板内容,改了注释首页字节数必然变 (466261 → 464333),tests/golden/responses.json 的 index 条目按既定流程刷新 html_len/html_sha256;并在 tests/test_golden.py 补一段说明,写清该条目记的是 「当前模板」而非 Flask 期数值、以及有意改模板后如何重算 (html_len = 文件字节数 - 1,sha256 = 文件去掉末尾换行后的哈希)。 验证(全部通过): - 与 HEAD 比对:剥离全部注释后的正文逐行一致(唯一差异是删掉了一处行尾空注释), 行数不变、每行半角括号/引号数量不变 → 代码与字符串零改动; - node --check 通过(静态 JS + index.html 内联脚本),<style> 块花括号平衡; - 真实 uvicorn 冒烟:/health /user/status /api/topen → 200、/admin/site/backup/list → 403, /static/site_auth.js 响应体与本地文件 sha1 相同,/ 无「第 N 条」残留; - ruff All checks passed;pyright 0 errors / 0 warnings;pytest 144 passed。
170 lines
7.7 KiB
Python
170 lines
7.7 KiB
Python
# -*- 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:
|
||
"""限流桶周期清扫:把超过 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'}
|