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 da3d013e9c chore: 清理前端与容器脚本中指向已删台账的过时编号
模板/样式/脚本的注释里散落着「第 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。
2026-09-26 13:18:44 +08:00

170 lines
7.7 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 -*-
"""服务层共享状态与低层工具(限流桶、请求统计、命名与常量)。
(自 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'}