commit d209af847a7052e7f8f0053adc44900ee1997b9e Author: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat Sep 26 00:13:05 2026 +0800 chore: 基线快照 —— 纳入 git 版本控制(站点 v2.4.0 / Flask 单体) 在框架迁移与模块拆分前保存当前可运行状态: - 后端:main.py/auth_routes.py/auth_system.py/music_api.py/music_downloader.py/podcast_api.py/qr_login.py - 前端:templates/index.html + static/site_auth.js - 部署:Dockerfile/docker-compose.yml/entrypoint.sh/requirements.txt 敏感数据(data/、cookie.txt)与内部文档(交接文档/新会话引导)按 .gitignore 排除。 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e5e39aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# T1:构建镜像严禁带入的敏感/私有/无关内容 +LICENSE +README.md +.gitignore +.git/ +__pycache__/ +*.pyc +.env +downloads/ +*.part +release/ +release.zip +tests/ +_smoke_test_downloader.py +_test_podcast_api.py +部署文档.md +功能介绍.md +# T1 新增:真实凭据、用户数据库、密钥、日志、交接文档、运维脚本与审查工作目录 +cookie.txt +user_cookie.txt +# Cookie 备份(qr_login._backup_cookie 产生,含真实 MUSIC_U): +# 原模式 *.cookie.backup.* 与实际文件名 cookie.backup.*.txt 不匹配,排除形同虚设 +cookie.*.txt +user_cookie.*.txt +data/ +data/backups/ +*.log +# 第 97 条(P3):轮转件 music_api.log.1/2/3 不匹配 *.log——含 IP/UA 的历史日志 +# 曾随 _mkpkg 同类问题外泄(第 55 条),docker 轨道同步堵上 +music_api.log* +*.log.* +交接文档.md +修复验收台账_F01-F117.md +待修问题清单_三轮汇总.md +# P2-9:补齐后产生的审计清单与内部引导文件(含内部路径/运维细节) +待修问题清单_第四轮深挖.md +待修问题清单_第五轮深挖.md +新会话引导.md +_pkg_ok.txt +_css_ok.txt +.zcode/ +云村小助手-网站开关.bat +_audit_work/ +venv/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16ef0ed --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# 复制为 .env 后按需修改(F59:不提供 .env 时 docker-compose 使用默认 TZ,可直接运行) +TZ=Asia/Shanghai +# HTTPS 反代(Caddy/Nginx 等)上线后取消注释:会话 Cookie 仅经 HTTPS 传输 +# (纯 HTTP 部署不要开启,否则浏览器拒收 Cookie;详见交接文档 F11) +# ENABLE_SECURE_COOKIE=1 +# 第 100 条:跨域来源控制——默认 `*`(历史口径);自用收敛同源可改为实际域名 +# CORS_ORIGINS=https://qf.linxingran.top diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17098c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +.idea +.vscode + +venv +.venv + +__pycache__ +*.pyc +*.pyo +*.pyd + +# P3-41:运行时敏感数据与私有文档——公开包买家若将本项目纳入 git 管理, +# 以下内容绝不能进版本库(用户数据库/网易云 Cookie/日志/审计文档) +data/ +cookie.txt +cookie.*.txt +user_cookie.txt +user_cookie.*.txt +downloads/ +*.log +music_api.log* +_pkg_ok.txt +_css_ok.txt +_audit_work/ +.zcode/ +.env +新会话引导.md +交接文档.md +修复验收台账_F01-F117.md +待修问题清单_三轮汇总.md +待修问题清单_第四轮深挖.md +待修问题清单_第五轮深挖.md +_smoke_test_downloader.py +云村小助手-网站开关.bat diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79b79be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# T7:基础镜像升级到 Python 3.12(3.9 已于 2025-10 EOL);alpine + ffmpeg 组合保持不变 +FROM python:3.12-alpine3.21 +WORKDIR /app +# 使用阿里云 apk 镜像源加速安装 ffmpeg +# 第 97 条(P3):补 tzdata——alpine 无 zoneinfo,ENV TZ=Asia/Shanghai 不装它不生效 +# (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响) +RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories && \ + apk add --no-cache tzdata ffmpeg +COPY requirements.txt requirements.txt +RUN pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip3 install --no-cache-dir -r requirements.txt +COPY . . +RUN chmod +x /app/entrypoint.sh +# 非 root 运行(容器安全基线)。注:挂载卷 ./downloads ./data 的宿主机目录 +# 需对 UID 10001 可写(见 docker-compose.yml 注释),否则下载与数据库写入会失败 +RUN adduser -D -u 10001 appuser && chown -R appuser:appuser /app +USER appuser +ENV TZ=Asia/Shanghai +EXPOSE 5000 +# 容器健康检查(/health 为轻量本地端点,不触发上游请求) +HEALTHCHECK --interval=60s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:5000/health || exit 1 +CMD ["/app/entrypoint.sh"] diff --git a/README_私有包警告.txt b/README_私有包警告.txt new file mode 100644 index 0000000..03806d3 --- /dev/null +++ b/README_私有包警告.txt @@ -0,0 +1,5 @@ +⚠️ 私有部署包——严禁外传、严禁上传网盘/代码仓库/发给任何人。 +本包包含全部敏感数据:cookie.txt(网易云 SVIP 登录态)、data/site.db(用户库)、 +data/secret.key(会话与音频签名根)、交接文档.md(含管理员凭据)。 +泄露后果与应急流程见交接文档 §4/§5.7 A2/C1/C2/E4。 +(本文件由 _mkpkg.py 私有模式自动生成;对外分发包由 --public 生成并剔除全部敏感物。) diff --git a/_csscheck.py b/_csscheck.py new file mode 100644 index 0000000..1a99e8e --- /dev/null +++ b/_csscheck.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +"""CSS 完整性检查:花括号平衡 + 关键美化标记存活""" +import sys +src = open('templates/index.html', encoding='utf-8').read() +style = src.split('')[0] +ok = style.count('{') == style.count('}') +marks = sum(1 for m in ['font-family: inherit', 'kw.on { text-shadow', 'uc-card:hover', 'theme-fading', 'textarea.input', 'prefers-reduced-motion', '美化层 v4', '美化层 v5', 'safe-area-inset'] if m in style) +with open('_css_ok.txt', 'w', encoding='utf-8') as f: + f.write(f'braces={style.count("{")}=={style.count("}")} {ok} | marks {marks}/9') +print('ok') diff --git a/_mkpkg.py b/_mkpkg.py new file mode 100644 index 0000000..fda8de5 --- /dev/null +++ b/_mkpkg.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""重新生成部署包——在主项目目录下运行:python _mkpkg.py [--public] + +- 默认:生成 owner 私有部署包(含 data/site.db、data/secret.key、cookie.txt, + 供 owner 自己的服务器迁移使用,严禁对外分发) +- --public:生成可对外分发的干净包(F01:剔除全部敏感数据——数据库/密钥/网易云 Cookie/ + 日志),包内另附 _pkg_ok.txt 标记 + +F67:源/目标路径基于脚本自身位置推导,不再写死原作者桌面绝对路径。 +""" +import glob +import os +import shutil +import sys + +SRC = os.path.dirname(os.path.abspath(__file__)) +DST = os.path.join(os.path.dirname(SRC), 'NeteaseUrl-部署包') + +public = '--public' in sys.argv[1:] + +ignore = shutil.ignore_patterns( + 'venv*', '__pycache__', 'downloads', 'release*', '*.log', 'music_api.log*', + '_smoke_test_downloader.py', 'backups', '云村小助手-网站开关.bat', + '_audit_work*', + # 第 87 条:.zcode 为 AI 工具工作目录(内部规划/会话文件),不得进任何分发包 + '.zcode*', + # Cookie 备份文件(qr_login._backup_cookie 产生,含真实 MUSIC_U): + # 'cookie.*.txt' 匹配 cookie.prewrite/backup/logout.*.txt 但不匹配 cookie.txt 本身 + 'cookie.*.txt', 'user_cookie.*.txt') + +if os.path.exists(DST): + shutil.rmtree(DST) +shutil.copytree(SRC, DST, ignore=ignore) + +# 双保险:显式删除目标里可能漏网的敏感文件(万一 ignore 模式被绕过)。 +# 含轮转日志 music_api.log.1/2/3——fnmatch 的 *.log 不匹配它们,曾会随包外泄(含 IP/UA) +for _pat in ('cookie.*.txt', 'user_cookie.*.txt', 'music_api.log*'): + for _p in glob.glob(os.path.join(DST, _pat)): + try: + os.remove(_p) + except OSError: + pass + +if public: + # F01:公开包必须剔除的敏感/私有内容(P1-1:补齐后产生的审计文档与内部引导文件—— + # 修复台账含全部 117 项漏洞细节,新会话引导含内部路径与运维细节,均不得对外) + for rel in ('data', 'cookie.txt', 'user_cookie.txt', 'music_api.log', + '交接文档.md', '待修问题清单_三轮汇总.md', + '修复验收台账_F01-F117.md', '新会话引导.md', + '待修问题清单_第四轮深挖.md', + # 第 87 条:第五轮深挖清单同样含全部漏洞细节,公开包必须剔除 + '待修问题清单_第五轮深挖.md', + '_pkg_ok.txt', '_css_ok.txt'): + p = os.path.join(DST, rel) + try: + if os.path.isdir(p): + shutil.rmtree(p) + elif os.path.exists(p): + os.remove(p) + except OSError: + pass + # 公开包同样剔除轮转日志与 Cookie 备份(双保险与上面同集) + for _pat in ('music_api.log*', 'cookie.*.txt', 'user_cookie.*.txt'): + for _p in glob.glob(os.path.join(DST, _pat)): + try: + os.remove(_p) + except OSError: + pass + marker = 'public-clean package (no secrets)' +else: + # 第 100 条(运维审计):私有包严禁携带《README_公开包说明.txt》——它宣称 + # "不含任何敏感数据/由 --public 生成",而私有包含 cookie.txt/site.db/secret.key + # 与交接文档(明文管理员凭据),接手运维按该 README 认知会整包外发造成泄露。 + # 私有包写入显式警告文件替代。 + _p = os.path.join(DST, 'README_公开包说明.txt') + if os.path.exists(_p): + try: + os.remove(_p) + except OSError: + pass + with open(os.path.join(DST, 'README_私有包警告.txt'), 'w', encoding='utf-8') as _f: + _f.write( + '⚠️ 私有部署包——严禁外传、严禁上传网盘/代码仓库/发给任何人。\n' + '本包包含全部敏感数据:cookie.txt(网易云 SVIP 登录态)、data/site.db(用户库)、\n' + 'data/secret.key(会话与音频签名根)、交接文档.md(含管理员凭据)。\n' + '泄露后果与应急流程见交接文档 §4/§5.7 A2/C1/C2/E4。\n' + '(本文件由 _mkpkg.py 私有模式自动生成;对外分发包由 --public 生成并剔除全部敏感物。)\n') + marker = None + +n = 0 +sz = 0 +for r, _, fs in os.walk(DST): + for f in fs: + n += 1 + sz += os.path.getsize(os.path.join(r, f)) +with open(os.path.join(SRC, '_pkg_ok.txt'), 'w', encoding='utf-8') as f: + f.write(marker if marker else f'{n} files, {sz // 1024} KB') +shutil.copy2(os.path.join(SRC, '_pkg_ok.txt'), os.path.join(DST, '_pkg_ok.txt')) +print('ok:', 'public' if public else 'private', '-', n, 'files') diff --git a/auth_routes.py b/auth_routes.py new file mode 100644 index 0000000..7e088b2 --- /dev/null +++ b/auth_routes.py @@ -0,0 +1,1050 @@ +# -*- coding: utf-8 -*- +"""站点认证与管理路由(/auth/* 与 /admin/site/*) + +由 main.py 调用 register_auth_routes(app) 挂载。 +""" + +import hmac +import secrets + +import sqlite3 +import time + +from typing import Optional, Tuple + +from flask import request, send_file + +import auth_system as auth + + +def _data() -> dict: + try: + if request.method == 'GET': + return dict(request.args) + json_data = request.get_json(silent=True) or {} + return {**dict(request.form), **json_data} + except Exception: + return {} + + +# 登录失败锁定(P3-7):按用户名维度记录失败,10 分钟窗口内 >=5 次临时锁定该用户名。 +# 与既有按 IP 10/分限流互补——IP 池轮换爆破同一弱口令账号时仍被挡住 +_LOGIN_FAILS: dict = {} +_LOGIN_FAIL_LIMIT = 5 +_LOGIN_FAIL_WINDOW = 600 +# R5-6:清理节流——超限后的全表扫描每 60 秒最多一次,不再每个请求都 O(n); +# 硬上限 20000 条(按插入序砍掉最早的一半),防止节流窗口内被随机用户名撑爆 +_LOGIN_FAIL_SWEEP_TS = 0.0 +# R5-4:本锁定周期已落过审计日志的用户名(解锁或成功登录即重置,下个周期允许再记一条) +_LOGIN_FAIL_LOGGED: set = set() + +# 第 86 条:管理员两步验证(2FA)——票据表(内存态,单实例部署与全站任务表同口径)。 +# ticket -> {user_id, username, email, ts, attempts};密码验证通过才签发, +# Step2 邮箱比对与 Step3 验证码共用 attempts(上限内错满即作废),Step3 成功即销毁。 +# 第 87 条:每管理员仅 1 张活票(新票签发即销毁同账号旧票);_ADMIN2FA_LAST_SEND +# 为每账号 admin2fa 发码软冷却(60s,防持密码者借多票持续轰炸绑定邮箱) +_ADMIN_2FA: dict = {} +_ADMIN2FA_LAST_SEND: dict = {} +_ADMIN_2FA_TTL = 300 # 票据 5 分钟有效 +_ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限 +_ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧) +_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——第 87 条初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环 + + +def _admin2fa_purge() -> None: + now = time.time() + for t in [t for t, v in _ADMIN_2FA.items() if now - v['ts'] > _ADMIN_2FA_TTL]: + _ADMIN_2FA.pop(t, None) + + +def _admin2fa_mask(email: str) -> str: + """邮箱掩码(审计日志与前端提示用):3*****@qq.com 样式""" + try: + local, dom = str(email).split('@', 1) + return (local[0] if local else '*') + '*****@' + dom + except Exception: + return '*****' + + +def _admin2fa_issue(user) -> str: + _admin2fa_purge() + # 第 87 条:每管理员仅 1 张活票——新票签发即销毁同账号旧票 + for t in [t for t, v in list(_ADMIN_2FA.items()) if v['username'] == user['username']]: + _ADMIN_2FA.pop(t, None) + if len(_ADMIN_2FA) >= _ADMIN_2FA_MAX_TICKETS: + items = list(_ADMIN_2FA.items()) # 快照后再 min,防并发 pop 使迭代失效 + if items: + oldest = min(items, key=lambda kv: kv[1]['ts'])[0] + _ADMIN_2FA.pop(oldest, None) + ticket = secrets.token_urlsafe(16) + _ADMIN_2FA[ticket] = {'user_id': user['id'], 'username': user['username'], + 'email': user['email'], 'ts': int(time.time()), 'attempts': 0} + return ticket + + +def _admin2fa_take(ticket: str): + """取活票(不销毁):无票/过期返回 (None, None);命中返回 (ticket, 数据)。""" + _admin2fa_purge() + v = _ADMIN_2FA.get(ticket or '') + if not v: + return None, None + return ticket, v + + +def _login_fail_check(username: str) -> bool: + """返回 True 表示允许尝试登录;锁定窗口内返回 False""" + global _LOGIN_FAIL_SWEEP_TS + now = time.time() + fails = [t for t in _LOGIN_FAILS.get(username, []) if now - t < _LOGIN_FAIL_WINDOW] + _LOGIN_FAILS[username] = fails + if len(fails) < _LOGIN_FAIL_LIMIT: + _LOGIN_FAIL_LOGGED.discard(username) + if len(_LOGIN_FAILS) > 10000 and now - _LOGIN_FAIL_SWEEP_TS > 60: # 容量上限(用户名为键,防无界增长) + _LOGIN_FAIL_SWEEP_TS = now + stale_users = [u for u, ts in _LOGIN_FAILS.items() + if not ts or now - max(ts) > _LOGIN_FAIL_WINDOW] + for u in stale_users[:5000]: + _LOGIN_FAILS.pop(u, None) + if len(_LOGIN_FAILS) > 20000: + for u in list(_LOGIN_FAILS.keys())[:len(_LOGIN_FAILS) // 2]: + _LOGIN_FAILS.pop(u, None) + return len(fails) < _LOGIN_FAIL_LIMIT + + +def _login_fail_record(username: str) -> None: + _LOGIN_FAILS.setdefault(username, []).append(time.time()) + + +def _login_fail_clear(username: str) -> None: + _LOGIN_FAILS.pop(username, None) + _LOGIN_FAIL_LOGGED.discard(username) + + +def _login_fail_locked_log_once(username: str) -> bool: + """R5-4:锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表; + 解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。""" + if username in _LOGIN_FAIL_LOGGED: + return False + _LOGIN_FAIL_LOGGED.add(username) + return True + + +def _actor() -> str: + """审计日志/卡密 created_by 的操作者名——跟随站点管理员的真实用户名 + (2026-09-22 管理员账号由 admin 更名 qfbsh 后,日志不得再硬编码旧名; + 无会话兜底用 system——admin 账号已不存在,不能再出现在新日志里)""" + cu = auth.current_user() + return cu['username'] if cu else 'system' + + +def _safe_register_credits() -> int: + """注册赠送次数(settings.register_credits,默认 3)。 + R5 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。""" + try: + credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0) + except (TypeError, ValueError): + return auth.DEFAULT_REGISTER_CREDITS + return max(0, credits) + + +# 验证码按 IP 限流(每 IP 每小时 10 次),防止公网环境换邮箱刷爆 SMTP +_SEND_CODE_IP = {} +_SEND_CODE_IP_LIMIT = 10 +_SEND_CODE_IP_WINDOW = 3600 + + +def _send_code_ip_check(ip: str) -> bool: + """返回 True 表示允许发送;超限返回 False(S4:表超限时顺手清理过期项,防无界增长)""" + now = time.time() + if len(_SEND_CODE_IP) > 10000: + stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW] + for k in stale: + _SEND_CODE_IP.pop(k, None) + cnt, win = _SEND_CODE_IP.get(ip, (0, now)) + if now - win > _SEND_CODE_IP_WINDOW: + cnt, win = 0, now + if cnt >= _SEND_CODE_IP_LIMIT: + return False + _SEND_CODE_IP[ip] = (cnt + 1, win) + return True + + +def _ok(data=None, message: str = 'success'): + resp = {'status': 200, 'success': True, 'message': message} + if data is not None: + resp['data'] = data + return resp, 200 + + +def _err(message: str, code: int = 400, error_code: str = ''): + resp = {'status': code, 'success': False, 'message': message} + if error_code: + resp['error_code'] = error_code + return resp, code + + +def register_auth_routes(app) -> None: + # ==================== 用户侧 ==================== + + @app.route('/auth/me', methods=['GET', 'POST']) + def auth_me(): + user = auth.current_user() + ann = auth.get_announcement() + data = { + 'user': auth._user_public(user) if user else None, + 'duration_text': auth.format_credits(user['duration_seconds']) if user else '', + 'announcement': {'title': ann['title'], 'content': ann['content'], + 'popup_mode': ann['popup_mode'], 'updated_at': ann['updated_at']}, + # 未配置 SMTP 时前端隐藏注册入口(owner 手动建号模式,不开放邮箱注册) + 'smtp_configured': auth.smtp_configured(), + # F-6.1(第 79 条):注册赠送次数动态下发——设置改值后前端文案同步,不再硬编码"3 次" + 'register_credits': _safe_register_credits(), + } + return _ok(data) + + @app.route('/auth/send_code', methods=['POST']) + def auth_send_code(): + try: + if not _send_code_ip_check(auth._client_ip()): + return _err('验证码请求过于频繁,请稍后再试') + data = _data() + email = str(data.get('email') or '').strip() + # 第 85 条:验证码按用途区分(注册/找回),邮件文案与库行用途隔离 + purpose = str(data.get('purpose') or 'register').strip() + if purpose not in ('register', 'reset'): + return _err('验证码用途不正确') + auth.send_verify_code(email, purpose) + return _ok(message='验证码已发送,请查收邮箱') + except ValueError as e: + return _err(str(e)) + except RuntimeError as e: + return _err(str(e)) + except Exception: + return _err('验证码发送失败,请稍后重试', 500) + + @app.route('/auth/register', methods=['POST']) + def auth_register(): + try: + data = _data() + username = str(data.get('username') or '').strip() + password = str(data.get('password') or '') + email = auth._normalize_email(str(data.get('email') or '')) + code = str(data.get('code') or '').strip() + device_id = str(data.get('device_id') or '').strip()[:64] + # 第 81 条:注册前必须已同意《用户协议》与《隐私政策》——前端入口闸的 + # 服务端兜底(同意状态本身存于用户浏览器,此处为语义性强制) + if str(data.get('agree') or '').strip() != '1': + return _err('请先阅读并同意《用户协议》与《隐私政策》') + + if not (3 <= len(username) <= 20 and re_username_ok(username)): + return _err('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线') + if len(password) < 8 or len(password) > 128: + return _err('密码长度需在 8-128 位之间') + if len(email) > 254: + return _err('邮箱格式不正确') + # R5-P2:查重统一后移到验证码校验通过之后——验证码是“证明你能收这个 + # 邮箱的信”的凭证,凭证在手才有资格探测注册状态(堵未认证枚举); + # 并发双提交仍由 UNIQUE 索引兜底 + code_ttl = auth.check_verify_code(email, code, 'register') + if not code_ttl: + return _err('邮箱验证码错误或已过期') + if auth.get_user_by_name(username): + auth.restore_verify_code(email, code, code_ttl, 'register') + return _err('用户名已被注册') + # P3-19:邮箱查重(配合库侧唯一索引——索引防并发,这里给出友好提示) + if email and auth.get_user_by_email(email): + auth.restore_verify_code(email, code, code_ttl, 'register') + return _err('该邮箱已被注册,请更换邮箱或联系管理员') + + # P1-1(第 79 条):防刷号双闸——① 同设备只送一次注册赠送;② 同 IP 24h + # 最多 3 次注册。device_id 是客户端随机串可伪造,故两层都要,叠加 + # send_code 10 封/时/IP 与邮箱归一化查重后,刷号产能压到可忽略。 + device_used_flag = auth.device_used(device_id) if device_id else False + reg_ip = auth._client_ip() + if reg_ip and auth.register_ip_exceeded(reg_ip): + auth.restore_verify_code(email, code, code_ttl, 'register') + auth.add_log('fail', username, reg_ip, '注册', '同 IP 24h 注册次数超限') + return _err('当前网络注册过于频繁,请 24 小时后再试或联系管理员') + + # P3-21 + R5 防呆:赠送次数读取与钳制统一走 _safe_register_credits + credits = _safe_register_credits() + if credits < 0: + credits = 0 # R5 防呆:赠送次数不允许配成 -1(会造出不限次新号) + if device_used_flag: + # 同设备重复注册:账号仍可创建(家庭共用电脑场景),但不再重复赠送 + credits = 0 + try: + user = auth.create_user(username, password, email=email, credits=credits, + device_id=device_id, + ip=reg_ip, + ua=str(request.headers.get('User-Agent') or ''), + register_ip_limit=3, + register_device_once=True) + except sqlite3.IntegrityError: + # 并发双击提交时的兜底(前面已有重名预检);还原验证码供重试 + auth.restore_verify_code(email, code, code_ttl, 'register') + return _err('用户名或邮箱已被注册,请换一个再试') + except ValueError as e: + # 锁内 IP 闸复查(register_ip_limit)等业务性拒绝:友好 400 而非 500 + auth.restore_verify_code(email, code, code_ttl, 'register') + auth.add_log('fail', username, reg_ip, '注册', str(e)) + return _err(str(e)) + except Exception: + # 建号失败不吞验证码(F14 第二轮补证) + auth.restore_verify_code(email, code, code_ttl, 'register') + raise + auth.login_session(user) + fresh = auth.get_user_by_name(username) + # 第 100 条(对抗复审):设备闸在事务内置零时路由层 credits 不回读—— + # 按入库实际次数对齐审计日志与 toast 文案(正常路径 min 无操作) + credits = min(credits, max(0, int(fresh['duration_seconds'] or 0))) + # 第 81 条:注册成功落审计日志(后台日志页可见刷号行为) + auth.add_log('success', username, reg_ip, '注册账号', + f'赠送 {credits} 次' if credits else '未赠送次数') + return _ok({'user': auth._user_public(fresh), + 'duration_text': auth.format_credits(fresh['duration_seconds']), + 'redirect': 'admin' if fresh['role'] == 'admin' else 'user'}, + f'注册成功' + (f',已赠送 {credits} 次下载次数' if credits else '')) + except Exception: + return _err('注册失败,请稍后重试', 500) + + @app.route('/auth/trial', methods=['POST']) + def auth_trial(): + """试用已并入注册:注册即送 N 次(随后台设置联动,第 98 条不再硬编码),无需领取""" + return _err(f'注册即送 {_safe_register_credits()} 次免费下载次数,请直接注册账号') + + @app.route('/auth/login', methods=['POST']) + def auth_login(): + try: + data = _data() + username = str(data.get('username') or '').strip() + password = str(data.get('password') or '') + if len(password) > 128: + # 超长密码不做 PBKDF2(CPU 放大器),直接按密码错误拒绝 + return _err('用户名或密码错误') + # 第 85 条:邮箱登录——含 @ 按归一化邮箱解析出真实账号再验密; + # 锁定/日志/状态检查统一用解析出的真实用户名(邮箱大小写与 +tag + # 变体归一后,锁定不被变体绕过,日志也不再出现邮箱串)。 + # 邮箱不存在与密码错误统一文案,不暴露账号存在性。 + if '@' in username: + iuser = auth.get_user_by_email(username) + lock_name = iuser['username'] if iuser else (auth._normalize_email(username) or username) + else: + iuser = None + lock_name = username + # P3-7:用户名维度失败锁定(10 分钟内 5 次失败);R5-4:日志整个锁定周期只记一条 + if not _login_fail_check(lock_name): + if _login_fail_locked_log_once(lock_name): + auth.add_log('fail', lock_name, auth._client_ip(), '登录', '失败次数过多,临时锁定') + return _err('尝试过于频繁,该账号已临时锁定,请 10 分钟后再试') + if iuser is not None: + user = auth.verify_login(iuser['username'], password) + else: + user = auth.verify_login(username, password) + if not user: + _login_fail_record(lock_name) + return _err('用户名或密码错误') + status = user['status'] if 'status' in user.keys() else 1 + if status == 2: + reason = user['ban_reason'] if 'ban_reason' in user.keys() else '' + auth.add_log('fail', lock_name, auth._client_ip(), '登录', f'封禁账号尝试登录: {reason}') + return _err('账号已被封禁' + (f':{reason}' if reason else '') + ',请联系管理员') + if status == 0: + return _err('账号已被禁用,请联系管理员') + _login_fail_clear(lock_name) # 登录成功清零失败计数 + if user['role'] == 'admin': + # 第 86 条:管理员不在此建立会话——一律转两步验证通道 + # (密码正确才签发票据;未绑定邮箱 fail-closed) + admin_email = user['email'] if 'email' in user.keys() else '' + if not admin_email: + auth.add_log('fail', lock_name, auth._client_ip(), '管理员登录', '未绑定邮箱,两步验证不可用') + return _err('管理员账号未绑定邮箱,请联系管理员先完成绑定') + ticket = _admin2fa_issue(user) + auth.add_log('success', lock_name, auth._client_ip(), '管理员登录', '密码验证通过,待邮箱验证') + return _ok({'ticket': ticket, 'email_masked': _admin2fa_mask(admin_email), + 'username': user['username'], 'admin_2fa': True}, + '密码验证通过,请完成邮箱验证') + auth.login_session(user) + # IP 归属地异常检测:24h 内 >=3 个不同省份自动封禁(防倒卖) + banned, info = auth.record_login_location(user, auth._client_ip()) + if banned: + auth.logout_session() + return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员') + fresh = auth.get_user_by_id(user['id']) + return _ok({ + 'user': auth._user_public(fresh), + 'duration_text': auth.format_credits(fresh['duration_seconds']), + 'redirect': 'admin' if fresh['role'] == 'admin' else 'user', + }, '登录成功') + except Exception: + return _err('登录失败,请稍后重试', 500) + + @app.route('/auth/admin_login/email', methods=['POST']) + def auth_admin_login_email(): + """管理员两步验证 Step2:比对绑定邮箱 → 发送 admin2fa 验证码(第 86 条)""" + try: + data = _data() + ticket, v = _admin2fa_take(str(data.get('ticket') or '')) + if not v: + return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired') + email_input = auth._normalize_email(str(data.get('email') or '')) + # 第 97 条(P3):第二因子的比对走常时比较,与本文件密钥/令牌口径一致 + if (not email_input or not v.get('email') + or not hmac.compare_digest(email_input, v['email'])): + v['attempts'] += 1 # 第 87 条:失败路径自增(原实现计数器从未生效) + auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '绑定邮箱不匹配') + if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS: + _ADMIN_2FA.pop(ticket, None) + return _err('错误次数过多,请重新开始登录') + return _err(f'绑定邮箱不正确,还可尝试 {_ADMIN_2FA_MAX_ATTEMPTS - v["attempts"]} 次') + # 第 87 条:同账号 60s 发码软冷却——票据一次性+单活票后仍防持续轰炸绑定邮箱 + last_send = _ADMIN2FA_LAST_SEND.get(v['username'], 0) + if time.time() - last_send < _ADMIN2FA_SEND_GAP: + return _err('验证码发送过于频繁,请 1 分钟后再试') + auth.send_verify_code(v['email'], 'admin2fa') + _ADMIN2FA_LAST_SEND[v['username']] = time.time() + auth.add_log('success', v['username'], auth._client_ip(), '管理员登录', + f'绑定邮箱验证通过,验证码已发送({_admin2fa_mask(v["email"])})') + return _ok(message='验证码已发送至管理员邮箱,请查收') + except ValueError as e: + return _err(str(e)) + except RuntimeError as e: + return _err(str(e)) + except Exception: + return _err('验证码发送失败,请稍后重试', 500) + + @app.route('/auth/admin_login/verify', methods=['POST']) + def auth_admin_login_verify(): + """管理员两步验证 Step3:验证码原子消费 → 建立管理员会话(第 86 条)""" + try: + data = _data() + ticket, v = _admin2fa_take(str(data.get('ticket') or '')) + if not v: + return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired') + code = str(data.get('code') or '').strip() + if not auth.check_verify_code(v['email'], code, 'admin2fa'): + v['attempts'] += 1 # 第 87 条:失败路径自增(原实现计数器从未生效) + if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS: + _ADMIN_2FA.pop(ticket, None) + return _err('错误次数过多,请重新开始登录') + auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '验证码错误') + return _err('验证码错误或已过期') + # Step3 成功即销毁票据(一次性);建会话前以库内最新状态复核 + _ADMIN_2FA.pop(ticket, None) + user = auth.get_user_by_id(v['user_id']) + if not user or user['role'] != 'admin': + return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired') + status = user['status'] if 'status' in user.keys() else 1 + if status == 2: + auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '封禁账号尝试登录') + return _err('账号已被封禁,请联系管理员') + if status == 0: + return _err('账号已被禁用,请联系管理员') + auth.login_session(user) + # IP 归属地异常检测:管理员天然豁免省份封禁(record_login_location 内部判定) + banned, info = auth.record_login_location(user, auth._client_ip()) + if banned: + auth.logout_session() + return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员') + fresh = auth.get_user_by_id(user['id']) + auth.add_log('success', v['username'], auth._client_ip(), '管理员登录', '两步验证通过') + return _ok({ + 'user': auth._user_public(fresh), + 'duration_text': auth.format_credits(fresh['duration_seconds']), + 'redirect': 'admin', + }, '登录成功') + except Exception: + return _err('登录失败,请稍后重试', 500) + + @app.route('/auth/logout', methods=['POST']) + def auth_logout(): + auth.logout_session() + return _ok(message='已退出登录') + + @app.route('/auth/heartbeat', methods=['POST']) + def auth_heartbeat(): + user = auth.current_user() + if not user: + return _err('登录已失效', 401, 'unauthorized') + fresh = auth.get_user_by_id(user['id']) + credits = int(fresh['duration_seconds'] or 0) + if fresh['role'] != 'admin' and credits != -1 and credits <= 0: + return _err('剩余次数已用完', 401, 'expired') + return _ok({'duration_seconds': fresh['duration_seconds'], + 'duration_text': auth.format_credits(fresh['duration_seconds'])}) + + @app.route('/auth/redeem', methods=['POST']) + def auth_redeem(): + user = auth.current_user() + if not user: + return _err('请先登录', 401, 'unauthorized') + try: + code = str(_data().get('code') or '') + credits, remaining = auth.redeem_card(code, user['username']) + auth.add_log('success', user['username'], auth._client_ip(), '卡密充值', + f'{code[:4]}**** · +{credits} 次') + return _ok({'credits': credits, 'duration_seconds': remaining, + 'duration_text': auth.format_credits(remaining)}, + f'充值成功,+{credits} 次') + except ValueError as e: + auth.add_log('fail', user['username'], auth._client_ip(), '卡密充值', str(e)) + return _err(str(e)) + except Exception: + return _err('充值失败,请稍后重试', 500) + + @app.route('/auth/ann_seen', methods=['POST']) + def auth_ann_seen(): + user = auth.current_user() + if user: + ann = auth.get_announcement() + auth._x('UPDATE users SET ann_seen = ? WHERE id = ?', (ann['updated_at'], user['id'])) + return _ok() + + # -------------------- 自助找回密码(第 79 条:复用验证码通道) -------------------- + + @app.route('/auth/forgot_password', methods=['POST']) + def auth_forgot_password(): + """自助找回密码:注册邮箱收验证码 → 验码原子消费 → 重置密码并踢下全部会话。 + 安全口径:响应统一为「验证码错误或已过期」(不区分账号是否存在,防枚举)。""" + try: + data = _data() + # 第 84 条修复:此处原为裸调用 `_normalize_email(...)`——该名称从未定义/ + # 导入,NameError 被下方 except 吞成 500,导致本接口自第 79 条诞生起 + # 任何输入都返回"重置失败"。改用 auth_system 的实现(邮箱归一化)。 + email = auth._normalize_email(str(data.get('email') or '')) + code = str(data.get('code') or '').strip() + newpwd = str(data.get('password') or '') + if len(newpwd) < 8 or len(newpwd) > 128: + return _err('新密码长度需在 8-128 位之间') + if not email: + return _err('缺少邮箱') + if not auth.check_verify_code(email, code, 'reset'): + return _err('邮箱验证码错误或已过期') + user = auth.get_user_by_email(email) + if not user: + # 验证码已对空邮箱消费掉——统一文案,不暴露账号存在性 + return _err('邮箱验证码错误或已过期') + # 第 86 条:管理员不走自助找回——放行则邮箱持有者可自助改密, + # 改完的密码+邮箱验证码=两因子全握,2FA 形同虚设。统一防枚举文案 + if user['role'] == 'admin': + return _err('邮箱验证码错误或已过期') + auth.reset_password(user['id'], newpwd) + auth.add_log('success', user['username'], auth._client_ip(), '自助找回密码', '邮箱验证码重置') + return _ok(message='密码已重置,请使用新密码登录(所有设备已下线)') + except Exception: + return _err('重置失败,请稍后重试', 500) + + # ==================== 管理后台 ==================== + + @app.route('/admin/site/users', methods=['POST']) + @auth.admin_required + def admin_site_users(): + return _ok({'users': auth.list_users()}) + + @app.route('/admin/site/user/delete', methods=['POST']) + @auth.admin_required + def admin_site_user_delete(): + try: + uid = int(_data().get('id') or 0) + target = auth.get_user_by_id(uid) + if not auth.delete_user(uid): + return _err('用户不存在或为管理员') + auth.add_log('success', _actor(), auth._client_ip(), '删除用户', target['username'] if target else str(uid)) + return _ok(message='用户已删除') + except Exception as e: + return _err(f'删除失败: {str(e)}', 500) + + @app.route('/admin/site/user/duration', methods=['POST']) + @auth.admin_required + def admin_site_user_duration(): + try: + data = _data() + uid = int(data.get('id') or 0) + try: + count = int(float(data.get('count') or data.get('minutes') or 0)) + except (TypeError, ValueError, OverflowError): + return _err('次数必须为数字') + if count < -1: + return _err('次数不能小于 -1(-1 表示不限次数)') + target = auth.get_user_by_id(uid) + if not target: + return _err('用户不存在') + if target['role'] == 'admin': + return _err('管理员不限次数') + auth.set_user_credits(uid, count) + auth.add_log('success', _actor(), auth._client_ip(), '修改用户次数', + f'{target["username"]} → {count} 次') + return _ok(message=f'已修改为 {count} 次') + except Exception as e: + return _err(f'修改失败: {str(e)}', 500) + + def _block_admin_target(target) -> Optional[Tuple]: + """禁止对管理员账号执行危险操作(防自锁)""" + if target and target['role'] == 'admin': + return _err('不能对管理员账号执行该操作') + return None + + @app.route('/admin/site/user/create', methods=['POST']) + @auth.admin_required + def admin_site_user_create(): + """管理员手动添加用户""" + try: + data = _data() + username = str(data.get('username') or '').strip() + password = str(data.get('password') or '') + email = auth._normalize_email(str(data.get('email') or '')) + try: + credits = int(float(data.get('count') or 0)) + except (TypeError, ValueError, OverflowError): + # OverflowError:count='inf' → int(inf) 溢出(原实现漏捕 → 500) + return _err('次数必须为数字') + import math as _math + if not _math.isfinite(credits): + return _err('次数必须为有限数字') + if credits < -1: + return _err('次数不能小于 -1(-1 表示不限次数)') + if not re_username_ok(username): + # 第 100 条:管理端为受信方——保留名给明确提示(注册端保持防枚举模糊文案) + if username.lower() in _RESERVED_USERNAMES: + return _err('该用户名为系统保留名,不可使用') + return _err('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线') + if len(password) < 8 or len(password) > 128: + return _err('密码长度需在 8-128 位之间') + if auth.get_user_by_name(username): + return _err('用户名已存在') + # R5-13:与注册路径同口径的邮箱查重——否则重复邮箱触发 UNIQUE 约束 + # 走兜底 except,给管理员看原始 500 报错 + if email and auth.get_user_by_email(email): + return _err('该邮箱已被注册,请更换邮箱') + auth.create_user(username, password, email=email, credits=credits, + ip=auth._client_ip(), ua='admin创建') + auth.add_log('success', _actor(), auth._client_ip(), '添加用户', + f'{username} · {credits} 次') + return _ok(message=f'用户 {username} 创建成功({credits} 次)') + except Exception as e: + return _err(f'创建失败: {str(e)}', 500) + + @app.route('/admin/site/user/reset_password', methods=['POST']) + @auth.admin_required + def admin_site_user_reset_password(): + try: + data = _data() + uid = int(data.get('id') or 0) + newpwd = str(data.get('password') or '') + if len(newpwd) < 8 or len(newpwd) > 128: + # 第 87 条:与注册/找回/建号统一 8 位下限(原 6 位为最后一处口径不一) + return _err('新密码长度需在 8-128 位之间') + target = auth.get_user_by_id(uid) + if not target: + return _err('用户不存在') + current = auth.current_user() + if target['role'] == 'admin': + # F02:允许管理员修改自己的密码(需验证旧密码),否则文档要求的改密入口形同虚设 + if not current or current['id'] != target['id']: + return _err('不能对其他管理员账号执行该操作') + oldpwd = str(data.get('old_password') or '') + ok, _ = auth._verify_password(target['password_hash'], oldpwd, target['salt']) + if not ok: + return _err('原密码错误') + auth.reset_password(uid, newpwd) + auth.add_log('success', current['username'], auth._client_ip(), '修改自己的密码', '管理员') + return _ok(message='密码已修改,下次登录请使用新密码(其他设备已全部下线)') + auth.reset_password(uid, newpwd) + auth.add_log('success', _actor(), auth._client_ip(), '重置密码', target['username']) + return _ok(message=f'已重置 {target["username"]} 的密码') + except Exception as e: + return _err(f'重置失败: {str(e)}', 500) + + @app.route('/admin/site/user/toggle', methods=['POST']) + @auth.admin_required + def admin_site_user_toggle(): + """启用 / 禁用账号""" + try: + uid = int(_data().get('id') or 0) + target = auth.get_user_by_id(uid) + if not target: + return _err('用户不存在') + blocked = _block_admin_target(target) + if blocked: + return blocked + new_status = 0 if (target['status'] if 'status' in target.keys() else 1) else 1 + auth.set_user_status(uid, new_status) + auth.add_log('success', _actor(), auth._client_ip(), '禁用/启用', + f'{target["username"]} → {"启用" if new_status else "禁用"}') + return _ok({'status': new_status}, + message=f'已{"启用" if new_status else "禁用"} {target["username"]}') + except Exception as e: + return _err(f'操作失败: {str(e)}', 500) + + @app.route('/admin/site/user/ban', methods=['POST']) + @auth.admin_required + def admin_user_ban(): + """封禁/解封用户(防倒卖:封禁后立即踢下线)""" + try: + data = _data() + uid = int(data.get('id') or 0) + action = str(data.get('action') or 'ban') + reason = str(data.get('reason') or '') + row = auth.get_user_by_id(uid) + if not row: + return _err('用户不存在') + if row['role'] == 'admin': + return _err('不能对管理员执行该操作') + admin_name = _actor() + if action == 'ban': + if not auth.ban_user(uid, reason or '管理员手动封禁'): + return _err('封禁失败') + auth.add_log('success', admin_name, + auth._client_ip(), '封禁用户', f'{row["username"]}: {reason or "手动封禁"}') + return _ok(message=f'已封禁用户「{row["username"]}」,该账号已强制下线') + else: + if not auth.unban_user(uid): + return _err('解封失败') + auth.add_log('success', admin_name, + auth._client_ip(), '解封用户', row['username']) + return _ok(message=f'已解封用户「{row["username"]}」') + except Exception as e: + return _err(f'操作失败: {str(e)}', 500) + + @app.route('/admin/site/user/cookie', methods=['POST']) + @auth.admin_required + def admin_site_user_cookie(): + username = str(_data().get('username') or '') + user = auth.get_user_by_name(username) + if not user: + return _err('用户不存在', 404) + return _ok({'username': username, 'cookie': user['netease_cookie'] or ''}) + + @app.route('/admin/site/smtp/get', methods=['POST']) + @auth.admin_required + def admin_site_smtp_get(): + cfg = auth.get_smtp_config() + return _ok({'host': cfg['host'], 'port': cfg['port'], + 'sender': cfg['sender'], 'configured': auth.smtp_configured()}) + + @app.route('/admin/site/smtp/save', methods=['POST']) + @auth.admin_required + def admin_site_smtp_save(): + try: + data = _data() + host = str(data.get('host') or '').strip() + try: + port = int(data.get('port') or 587) + except (TypeError, ValueError): + return _err('端口必须为数字') + sender = str(data.get('sender') or '').strip() + password = str(data.get('password') or '') + if not host or not sender: + return _err('SMTP 服务器与发件邮箱不能为空') + if not password: + # 密码留空表示保持原有授权码不变 + password = auth.get_smtp_config().get('password', '') + auth.save_smtp_config(host, port, sender, password) + auth.add_log('success', _actor(), auth._client_ip(), '保存SMTP配置', f'{host}:{port}') + return _ok(message='SMTP 配置已保存') + except Exception as e: + return _err(f'保存失败: {str(e)}', 500) + + @app.route('/admin/site/smtp/test', methods=['POST']) + @auth.admin_required + def admin_site_smtp_test(): + try: + to = str(_data().get('to') or '').strip() + if not to: + return _err('请填写测试收件邮箱') + # 收件人地址校验(防 CRLF 头注入;与验证码发送同规则) + import re as _re + if not _re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to): + return _err('收件邮箱格式不正确') + auth.send_email(to, 'SMTP 测试邮件', + '这是一封来自云村小助手的测试邮件,收到即说明 SMTP 配置成功。') + auth.add_log('success', _actor(), auth._client_ip(), 'SMTP测试', to) + return _ok(message='测试邮件已发送,请查收') + except Exception as e: + auth.add_log('fail', _actor(), auth._client_ip(), 'SMTP测试', str(e)) + return _err(f'发送失败: {str(e)}', 500) + + @app.route('/admin/site/logs', methods=['POST']) + @auth.admin_required + def admin_site_logs(): + """日志分页查询(10 条/页)""" + data = _data() + log_type = 'fail' if str(data.get('type')) == 'fail' else 'success' + try: + page = max(1, int(data.get('page') or 1)) + except (TypeError, ValueError): + page = 1 + rows, total = auth.query_logs(log_type, limit=10, offset=(page - 1) * 10) + return _ok({'logs': rows, 'type': log_type, 'page': page, + 'total': total, 'pages': max(1, (total + 9) // 10)}) + + @app.route('/admin/site/logs/clear', methods=['POST']) + @auth.admin_required + def admin_site_logs_clear(): + """清除指定类型的全部日志""" + log_type = 'fail' if str(_data().get('type')) == 'fail' else 'success' + deleted = auth.clear_logs(log_type) + auth.add_log('success', _actor(), + auth._client_ip(), '清除日志', f'{log_type} · {deleted} 条') + return _ok(message=f'已清除 {"失败" if log_type == "fail" else "成功"}日志 {deleted} 条') + + @app.route('/admin/site/cards/generate', methods=['POST']) + @auth.admin_required + def admin_site_cards_generate(): + try: + data = _data() + try: + count = int(data.get('count') or 1) + valid_days = int(data.get('valid_days') or 0) + except (TypeError, ValueError): + return _err('张数 / 次数 / 有效天数必须为数字') + # 第 81 条:显式传 0(JSON 体)不再被 or 链静默替换成默认 5 + raw_credits = data.get('credits', data.get('minutes')) + try: + credits = int(raw_credits) if raw_credits is not None else 5 + except (TypeError, ValueError): + return _err('张数 / 次数 / 有效天数必须为数字') + if credits <= 0: + return _err('卡密次数必须大于 0') + prefix = str(data.get('prefix') or 'VIP') + note = str(data.get('note') or '') + codes = auth.generate_cards(count, credits, created_by=_actor(), + prefix=prefix, note=note, valid_days=valid_days) + auth.add_log('success', _actor(), auth._client_ip(), '生成卡密', + f'{count} 张 · {credits} 次/张 · 有效{valid_days or "永久"}天') + return _ok({'codes': codes, 'credits': credits, 'count': len(codes)}, + f'已生成 {len(codes)} 张卡密') + except Exception as e: + return _err(f'生成失败: {str(e)}', 500) + + @app.route('/admin/site/cards/list', methods=['POST']) + @auth.admin_required + def admin_site_cards_list(): + # F85:支持 limit/offset 分页与总数返回,较早卡密可翻页查看 + data = _data() + try: + limit = int(data.get('limit') or 100) + except (TypeError, ValueError): + limit = 100 + try: + offset = int(data.get('offset') or 0) + except (TypeError, ValueError): + offset = 0 + cards, total = auth.list_cards(limit=limit, offset=offset) + return _ok({'cards': cards, 'total': total}) + + @app.route('/admin/site/cards/disable', methods=['POST']) + @auth.admin_required + def admin_site_cards_disable(): + # 第 81 条:禁用/恢复未使用卡密(可逆操作;已使用卡密无禁用意义) + try: + data = _data() + code = str(data.get('code') or '').strip() + if not code: + return _err('缺少卡密') + disable = str(data.get('disabled') or '').strip().lower() in ('1', 'true') # 第 87 条:兼容布尔/大小写变体 + auth.set_card_disabled(code, disable) + auth.add_log('success', _actor(), auth._client_ip(), + '禁用卡密' if disable else '恢复卡密', code[:4] + '****') + return _ok(message=('已禁用 ' if disable else '已恢复 ') + code[:4] + '****') + except ValueError as e: + return _err(str(e)) + except Exception: + return _err('操作失败,请稍后重试', 500) + + @app.route('/admin/site/cards/delete', methods=['POST']) + @auth.admin_required + def admin_site_cards_delete(): + # 第 81 条:删除未使用卡密(已使用卡密保留兑换凭证,按 owner 规则不可删) + try: + data = _data() + code = str(data.get('code') or '').strip() + if not code: + return _err('缺少卡密') + auth.delete_card(code) + auth.add_log('success', _actor(), auth._client_ip(), '删除卡密', code[:4] + '****') + return _ok(message='已删除 ' + code[:4] + '****') + except ValueError as e: + return _err(str(e)) + except Exception: + return _err('操作失败,请稍后重试', 500) + + @app.route('/admin/site/ann/get', methods=['POST']) + @auth.admin_required + def admin_site_ann_get(): + return _ok(auth.get_announcement()) + + @app.route('/admin/site/ann/save', methods=['POST']) + @auth.admin_required + def admin_site_ann_save(): + try: + data = _data() + auth.save_announcement(str(data.get('title') or ''), str(data.get('content') or ''), + str(data.get('popup_mode') or 'off')) + auth.add_log('success', _actor(), auth._client_ip(), '保存公告', str(data.get('title') or '')) + return _ok(message='公告已保存') + except Exception as e: + return _err(f'保存失败: {str(e)}', 500) + + # -------------------- 播放 API 密钥 -------------------- + + @app.route('/admin/site/play_key/get', methods=['POST']) + @auth.admin_required + def admin_site_play_key_get(): + """获取当前播放 API 密钥(不存在则自动生成一次,保证弹窗拼 URL 总有可用密钥)""" + key = auth.get_setting('playlist_play_key', '') + if not key: + key = secrets.token_urlsafe(16) + auth.set_setting('playlist_play_key', key) + return _ok({'key': key}) + @app.route('/admin/site/play_key/rotate', methods=['POST']) + @auth.admin_required + def admin_site_play_key_rotate(): + """刷新播放 API 密钥:旧密钥立即失效(已签发的音频地址按 36 分钟自然过期(随交付保留期联动))""" + key = secrets.token_urlsafe(16) + auth.set_setting('playlist_play_key', key) + auth.add_log('success', _actor(), auth._client_ip(), '刷新播放API密钥', '旧密钥已失效') + return _ok({'key': key}, '密钥已刷新,旧密钥已全部失效') + + # -------------------- 数据库管理 -------------------- + + @app.route('/admin/site/backup/create', methods=['POST']) + @auth.admin_required + def admin_site_backup_create(): + try: + name = auth.create_backup() + auth.set_setting('last_backup_ts', str(time.time())) + auth.add_log('success', _actor(), auth._client_ip(), '数据库备份', name) + return _ok({'name': name}, f'备份完成:{name}') + except Exception as e: + return _err(f'备份失败: {str(e)}', 500) + + @app.route('/admin/site/backup/list', methods=['POST']) + @auth.admin_required + def admin_site_backup_list(): + return _ok({'backups': auth.list_backups(), + 'interval_hours': auth.get_setting('backup_interval_hours', '0'), + 'db_size': auth.DB_PATH.stat().st_size if auth.DB_PATH.exists() else 0}) + + @app.route('/admin/site/backup/download', methods=['GET']) + @auth.admin_required + def admin_site_backup_download(): + name = request.args.get('name') or '' + p = auth.backup_path(name) + if not p: + return _err('备份文件不存在', 404) + resp = send_file(str(p), as_attachment=True, download_name=name) + # 第 97 条(P3):备份含完整用户库(邮箱+哈希),禁止共享缓存存储 + resp.headers['Cache-Control'] = 'no-store' + return resp + + @app.route('/admin/site/backup/upload', methods=['POST']) + @auth.admin_required + def admin_site_backup_upload(): + dest = None + try: + f = request.files.get('file') + if not f or not f.filename: + return _err('请选择数据库文件') + if not f.filename.endswith('.db'): + return _err('仅支持 .db 文件') + auth.BACKUP_DIR.mkdir(parents=True, exist_ok=True) + # S6:同秒上传自动追加序号,不再互相覆盖 + base = 'upload_' + time.strftime('%Y%m%d_%H%M%S') + name = base + '.db' + k = 1 + while (auth.BACKUP_DIR / name).exists(): + name = f'{base}-{k}.db' + k += 1 + dest = auth.BACKUP_DIR / name + f.save(str(dest)) + # 只读文件头 16 字节做魔数校验(原实现 read_bytes() 会把整个备份读进内存) + with dest.open('rb') as hf: + header = hf.read(16) + if not header.startswith(b'SQLite format 3'): + dest.unlink() + return _err('不是有效的 SQLite 数据库文件') + auth.add_log('success', _actor(), auth._client_ip(), '上传数据库', dest.name) + # 第 93 条(P2-3):上传备份同样纳入配额裁剪(20+5) + try: + auth.prune_backups(20, 5) + except Exception: + pass + return _ok({'name': dest.name}, '上传成功,可在下方选择该备份进行恢复') + except Exception as e: + # 第 99 条:上传中途失败(ENOSPC/IO)清理半截文件——残缺 .db 会进 + # list_backups 且因 mtime 最新挤掉一份好备份(恢复前校验虽能拒收) + try: + if dest and dest.exists(): + dest.unlink() + except Exception: + pass + return _err(f'上传失败: {str(e)}', 500) + + @app.route('/admin/site/backup/restore', methods=['POST']) + @auth.admin_required + def admin_site_backup_restore(): + try: + name = str(_data().get('name') or '') + auth.restore_backup(name) + auth.add_log('success', _actor(), auth._client_ip(), '恢复数据库', name) + return _ok(message='数据库已恢复,请刷新页面') + except ValueError as e: + # 第 95 条(P3-1):客户端可纠正错误(备份不存在/校验失败)返回 400, + # 与 delete 的 404 口径对齐——原实现一律 500 + return _err(f'恢复失败: {str(e)}', 400) + except Exception as e: + auth.add_log('fail', _actor(), auth._client_ip(), '恢复数据库', str(e)) + return _err(f'恢复失败: {str(e)}', 500) + + @app.route('/admin/site/backup/delete', methods=['POST']) + @auth.admin_required + def admin_site_backup_delete(): + try: + name = str(_data().get('name') or '') + if auth.delete_backup(name): + return _ok(message='备份已删除') + return _err('备份文件不存在', 404) + except Exception as e: + return _err(f'删除失败: {str(e)}', 500) + + @app.route('/admin/site/backup/auto', methods=['POST']) + @auth.admin_required + def admin_site_backup_auto(): + try: + import math as _math + try: + hours = float(_data().get('hours') or 0) + except (TypeError, ValueError): + return _err('间隔必须为数字') + # F46:NaN/Infinity 一律拒绝,不能持久化后让调度循环静默失效 + if not _math.isfinite(hours): + return _err('间隔必须为有限数字') + if hours < 0: + return _err('间隔不能为负') + auth.set_setting('backup_interval_hours', str(hours)) + return _ok(message='定时备份已开启,每 {} 小时自动备份'.format(hours) if hours > 0 + else '定时备份已关闭') + except Exception as e: + return _err(f'设置失败: {str(e)}', 500) + + +# ============ 辅助 ============ + + +_RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'moderator', '管理员', '客服'} + + +def re_username_ok(username: str) -> bool: + import re as _re + # P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险) + # R5-P3:白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符 + #("аdmin" 冒充钓鱼);保留字(admin/qfbsh 等)一并拒绝,防冒充管理入口 + return bool(_re.fullmatch(r'[A-Za-z0-9_\u4e00-\u9fa5]{3,20}', username)) and \ + not _re.match(r'^_+$', username) and not username.startswith('_') and not username.endswith('_') \ + and username.lower() not in _RESERVED_USERNAMES + + +def secrets_rand() -> int: + import secrets as _s + return _s.randbelow(900000) + 100000 diff --git a/auth_system.py b/auth_system.py new file mode 100644 index 0000000..d5f0933 --- /dev/null +++ b/auth_system.py @@ -0,0 +1,1785 @@ +# -*- coding: utf-8 -*- +"""站点认证与管理系统 + +提供网站级用户体系: +- 用户注册(邮箱验证码)/ 登录 / 登出 / 注册即赠下载次数(第 98 条口径:试用领取已并入注册) +- 按次计费(一首歌 = 1 次)、卡密(次数卡)充值 +- 管理员后台:用户管理 / SMTP 配置 / 卡密生成 / 公告 / 日志 / 数据库备份 +- 接口装饰器:login_required(强制登录 + 按次扣费)/ admin_required(仅管理员) + +数据存储:SQLite(data/site.db),会话:Flask 签名 Cookie(secret_key 持久化) +""" + +import hashlib +import shutil +import json +import math +import base64 +import os +import re +import secrets +import smtplib +import sqlite3 +import ssl +import sys +import threading +import time +from email.header import Header +from email.mime.text import MIMEText +from email.utils import formataddr +from functools import wraps +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from flask import request, session, g + +# ============ 路径与常量 ============ +DATA_DIR = Path('data') +DB_PATH = DATA_DIR / 'site.db' +BACKUP_DIR = DATA_DIR / 'backups' +SECRET_KEY_PATH = DATA_DIR / 'secret.key' + +DEFAULT_ADMIN_USER = 'admin' +SESSION_KEY = 'site_uid' +DEFAULT_REGISTER_CREDITS = 3 # 注册直接赠送 3 次下载次数(无需领取;TRIAL_CREDITS 死常量已删,第 98 条) + +# F13:口令哈希升级。旧库 SHA-256(salt+pwd) 哈希在登录成功时透明升级为 PBKDF2 +_PBKDF2_ITERS = 100_000 + +_DB_LOCK = threading.RLock() + +# ============ 数据库 ============ + + +def get_conn() -> sqlite3.Connection: + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=15) + conn.row_factory = sqlite3.Row + conn.execute('PRAGMA journal_mode=WAL') + return conn + + +def _q(sql: str, params: Tuple = ()) -> List[sqlite3.Row]: + with _DB_LOCK: + conn = get_conn() + try: + return conn.execute(sql, params).fetchall() + finally: + conn.close() + + +def _x(sql: str, params: Tuple = ()) -> int: + """执行写操作,返回 lastrowid / rowcount""" + with _DB_LOCK: + conn = get_conn() + try: + cur = conn.execute(sql, params) + conn.commit() + return cur.lastrowid if cur.lastrowid else cur.rowcount + finally: + conn.close() + + +def init_db() -> None: + """初始化表结构与默认数据""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + with _DB_LOCK: + conn = get_conn() + try: + conn.executescript(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + email TEXT DEFAULT '', + role TEXT DEFAULT 'user', + duration_seconds INTEGER DEFAULT 0, + trial_device TEXT DEFAULT '', + register_ip TEXT DEFAULT '', + register_ua TEXT DEFAULT '', + netease_cookie TEXT DEFAULT '', + ann_seen TEXT DEFAULT '', + last_active INTEGER DEFAULT 0, + created_at INTEGER DEFAULT 0, + last_login INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS email_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + code TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER DEFAULT 0, + purpose TEXT DEFAULT 'register' + ); + CREATE TABLE IF NOT EXISTS cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + minutes INTEGER NOT NULL, + created_by TEXT DEFAULT '', + created_at INTEGER DEFAULT 0, + used_by TEXT DEFAULT '', + used_at INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT DEFAULT 'success', + username TEXT DEFAULT '', + ip TEXT DEFAULT '', + action TEXT DEFAULT '', + detail TEXT DEFAULT '', + time TEXT DEFAULT '' + ); + ''') + # 单行配置表 + conn.execute('''CREATE TABLE IF NOT EXISTS smtp_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + host TEXT DEFAULT '', port INTEGER DEFAULT 587, + sender TEXT DEFAULT '', password TEXT DEFAULT '', + updated_at INTEGER DEFAULT 0 + )''') + conn.execute('INSERT OR IGNORE INTO smtp_config (id, port) VALUES (1, 587)') + # 键值设置表 + conn.execute('''CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, value TEXT DEFAULT '' + )''') + conn.execute('''CREATE TABLE IF NOT EXISTS announcements ( + id INTEGER PRIMARY KEY CHECK (id = 1), + title TEXT DEFAULT '', content TEXT DEFAULT '', + popup_mode TEXT DEFAULT 'off', + updated_at INTEGER DEFAULT 0 + )''') + conn.execute('INSERT OR IGNORE INTO announcements (id) VALUES (1)') + # 计费账本(F25/F26:任务实扣/交付/退款/终态的持久化唯一记录,结算幂等) + conn.execute('''CREATE TABLE IF NOT EXISTS billing_ledger ( + task_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + reserved INTEGER DEFAULT 0, + delivered INTEGER DEFAULT 0, + refunded INTEGER DEFAULT 0, + status TEXT DEFAULT 'open', + created_at INTEGER DEFAULT 0, + settled_at INTEGER DEFAULT 0 + )''') + # 轻量迁移:新增列。仅"列已存在"可忽略(F93:其余迁移失败必须暴露,不能吞掉) + _already = set() + for trow in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall(): + for crow in conn.execute(f"PRAGMA table_info({trow['name']})").fetchall(): + _already.add(f"{trow['name']}.{crow['name']}") + for alter_sql, tbl_col in ( + ('ALTER TABLE users ADD COLUMN status INTEGER DEFAULT 1', 'users.status'), + ('ALTER TABLE users ADD COLUMN login_seq INTEGER DEFAULT 0', 'users.login_seq'), + ('ALTER TABLE users ADD COLUMN recent_provinces TEXT DEFAULT \'[]\'', 'users.recent_provinces'), + ('ALTER TABLE users ADD COLUMN ban_reason TEXT DEFAULT \'\'', 'users.ban_reason'), + ('ALTER TABLE cards ADD COLUMN note TEXT DEFAULT \'\'', 'cards.note'), + ('ALTER TABLE cards ADD COLUMN expire_at INTEGER DEFAULT 0', 'cards.expire_at'), + ('ALTER TABLE cards ADD COLUMN disabled INTEGER DEFAULT 0', 'cards.disabled'), + ('ALTER TABLE email_codes ADD COLUMN purpose TEXT DEFAULT \'register\'', 'email_codes.purpose'), + ): + if tbl_col in _already: + continue # 幂等:列已存在 + try: + conn.execute(alter_sql) + except sqlite3.OperationalError as e: + if 'duplicate column' not in str(e).lower(): + # 磁盘/锁/结构等真实故障:抛出,禁止静默带病初始化(F93) + raise + # P3-19:非空邮箱唯一(部分索引,不约束历史空串)。存量库若已有重复非空邮箱, + # 索引创建失败仅告警不阻断启动(查重逻辑在注册/建号路由另有兜底) + try: + conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ' + 'ON users(email) WHERE email != \'\'') + except sqlite3.IntegrityError: + print('[警告] users.email 存在重复的非空邮箱,唯一索引未创建(请人工清理后重启)') + # 第 85 条:users.email 口径统一为归一化(lower + 剥 Gmail +tag)——与 + # get_user_by_email/找回/邮箱登录的查询口径对齐。逐行迁移,归一化后与他 + # 人撞唯一索引的行保留原值跳过(与"存量重复仅告警"同口径)。幂等:已 + # 归一化的行零写入 + for urow in conn.execute("SELECT id, email FROM users WHERE email != ''").fetchall(): + fixed = _normalize_email(urow['email']) + if not fixed or fixed == urow['email']: + continue + try: + conn.execute('UPDATE users SET email = ? WHERE id = ?', (fixed, urow['id'])) + except sqlite3.IntegrityError: + print(f"[警告] 用户 {urow['id']} 的邮箱归一化后与他人重复,保留原值") + # 初始管理员(F02:新装不再使用公开默认口令,随机生成并写入日志一次) + # R5(第 79/80 条):引导条件改为"不存在任何管理员角色"——按用户名查会在 + # 管理员改名(如 admin→qfbsh)后误判缺失,重启即重建出一个新的 admin 账号 + row = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + if not row: + initial_pass = secrets.token_urlsafe(12) + salt = secrets.token_hex(8) + conn.execute( + 'INSERT INTO users (username, password_hash, salt, role, duration_seconds, created_at) ' + 'VALUES (?, ?, ?, ?, -1, ?)', + (DEFAULT_ADMIN_USER, _hash_new(initial_pass), '', + 'admin', int(time.time()))) + # P2-5:坑 11 口径——非 ASCII 输出须防 GBK 重定向下 UnicodeEncodeError + try: + print(f'[初始化] 已创建管理员账号 admin,初始密码:{initial_pass}(请立即登录并修改,本提示不再显示)') + except UnicodeEncodeError: + print('[init] admin account created, initial password printed above failed to encode') + conn.commit() + finally: + conn.close() + + +def get_secret_key() -> str: + """获取(或生成)持久化的会话签名密钥(F102:原子写入 + 失败显式告警)""" + key = '' + try: + if SECRET_KEY_PATH.exists(): + key = SECRET_KEY_PATH.read_text(encoding='utf-8').strip() + if key: + return key + except OSError: + pass + new_key = secrets.token_hex(32) + try: + SECRET_KEY_PATH.parent.mkdir(parents=True, exist_ok=True) + # 先写临时文件再原子替换,避免部分写入留下损坏密钥 + tmp = SECRET_KEY_PATH.with_suffix('.key.tmp') + tmp.write_text(new_key, encoding='utf-8') + os.replace(str(tmp), str(SECRET_KEY_PATH)) + # 并发初始化者以"替换后文件里的值"为准(最后写入者胜出但全员一致) + final = SECRET_KEY_PATH.read_text(encoding='utf-8').strip() + return final or new_key + except OSError as e: + # 写入失败不能装作成功:显著告警,返回临时值(本进程会话可用,重启后失效) + try: + print(f'[警告] 会话密钥持久化失败({e}),本进程将使用临时密钥,重启后所有会话失效!', file=sys.stderr) + except UnicodeEncodeError: + print('[warn] secret key persistence failed; using temporary key for this process', file=sys.stderr) + return new_key + + +# ============ 密码与用户 ============ + + +def _hash(password: str, salt: str) -> str: + """旧版口令哈希:SHA-256(salt + password)。仅用于校验存量库,新哈希一律走 _hash_new""" + return hashlib.sha256((salt + password).encode('utf-8')).hexdigest() + + +def _hash_new(password: str, salt: str = '', iterations: int = _PBKDF2_ITERS) -> str: + """新版口令哈希:PBKDF2-HMAC-SHA256(F13),格式 pbkdf2$iter$salt$digest""" + salt = salt or secrets.token_hex(8) + digest = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), + salt.encode('utf-8'), iterations).hex() + return f'pbkdf2${iterations}${salt}${digest}' + + +def _verify_password(stored: str, password: str, salt: str) -> Tuple[bool, bool]: + """校验口令。返回 (是否正确, 是否需要升级到 PBKDF2)""" + if stored.startswith('pbkdf2$'): + try: + _, iters, psalt, digest = stored.split('$', 3) + calc = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), + psalt.encode('utf-8'), int(iters)).hex() + return secrets.compare_digest(calc, digest), False + except Exception: + return False, False + ok = secrets.compare_digest(stored, _hash(password, salt)) + return ok, ok # 旧哈希校验通过 → 登录时升级 + + +def create_user(username: str, password: str, email: str = '', credits: int = 0, + role: str = 'user', device_id: str = '', ip: str = '', ua: str = '', + register_ip_limit: int = 0, register_ip_window: int = 86400, + register_device_once: bool = False) -> Dict[str, Any]: + # F77:余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限" + try: + credits = int(credits) + except (TypeError, ValueError): + credits = 0 + if credits < -1: + credits = 0 + # R5(第 79 条):注册入口传 register_ip_limit=3 —— 同 IP 24h 注册数检查挪进 + # _DB_LOCK 事务内与 INSERT 原子完成,堵住"锁外检查+并发插入"的 TOCTOU 刷号; + # 管理建号与不限制场景传 0 关闭。超限抛 ValueError 由调用方转 400/429。 + salt = secrets.token_hex(8) + now = int(time.time()) + with _DB_LOCK: + conn = get_conn() + try: + if register_ip_limit > 0 and ip: + n = conn.execute('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?', + (ip, now - register_ip_window)).fetchone()[0] + if n >= register_ip_limit: + raise ValueError('当前网络注册过于频繁,请 24 小时后再试或联系管理员') + if register_device_once and device_id: + # 第 100 条:设备赠送闸并入同一事务——锁外 device_used 预检有 TOCTOU, + # 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建) + n = conn.execute("SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'", + (device_id,)).fetchone()[0] + if n > 0: + credits = 0 + conn.execute( + 'INSERT INTO users (username, password_hash, salt, email, role, duration_seconds, ' + 'trial_device, register_ip, register_ua, last_active, created_at) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + (username, _hash_new(password), '', email, role, + -1 if role == 'admin' else int(credits), + device_id, ip, ua, now, now)) + conn.commit() + finally: + conn.close() + return get_user_by_name(username) + + +def get_user_by_name(username: str) -> Optional[sqlite3.Row]: + rows = _q('SELECT * FROM users WHERE username = ?', (username,)) + return rows[0] if rows else None + + +def get_user_by_email(email: str) -> Optional[sqlite3.Row]: + """按邮箱查用户(P3-19:注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)""" + email = _normalize_email(email) + if not email: + return None + rows = _q('SELECT * FROM users WHERE email = ? LIMIT 1', (email,)) + return rows[0] if rows else None + + +def _normalize_email(email: str) -> str: + """R5(第 79 条):邮箱归一化——剥离 Gmail 等服务商的 +tag 别名 + (user+1@ 与 user@ 视为同一邮箱),堵住别名的无限刷号绕过;无 + 原样返回。 + local 为空(如 '+a@gmail.com')时返回空串,由调用方按格式错误拒绝。""" + email = (email or '').strip().lower() + if '+' in email: + local, _, domain = email.partition('@') + local = local.split('+', 1)[0] + if local and domain: + email = f'{local}@{domain}' + elif not local: + return '' + return email + + +def register_ip_exceeded(ip: str, limit: int = 3, window: int = 86400) -> bool: + """R5-P1:同 IP 24h 内注册数超限(默认 3 次)——防 IP 轮换刷免费赠送次数; + 设备指纹可被客户端伪造,IP 维度是第二道闸。家庭 NAT 场景超限可由管理员建号兜底。""" + if not ip: + return False + rows = _q('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?', + (ip, int(time.time() - window))) + return bool(rows and rows[0]['n'] >= limit) + + +def get_user_by_id(uid: int) -> Optional[sqlite3.Row]: + rows = _q('SELECT * FROM users WHERE id = ?', (uid,)) + return rows[0] if rows else None + + +def verify_login(username: str, password: str) -> Optional[sqlite3.Row]: + """校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号—— + 状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。 + 原实现把禁用账号提前过滤成 None,路由的"账号已被禁用"分支永不可达)""" + user = get_user_by_name(username) + if user: + ok, needs_upgrade = _verify_password(user['password_hash'], password, user['salt']) + if ok: + if needs_upgrade: + # F13:存量 SHA-256 哈希在登录成功时透明升级为 PBKDF2 + _x('UPDATE users SET password_hash = ?, salt = \'\' WHERE id = ?', + (_hash_new(password), user['id'])) + return user + return None + + +def list_users() -> List[Dict[str, Any]]: + rows = _q('SELECT * FROM users ORDER BY id DESC') + return [_user_public(r, full=True) for r in rows] + + +def delete_user(uid: int) -> bool: + """删除用户(第 93 条 P3-1:卡密 used_by 为用户名字符串无外键——删除时同步 + 标注"(已注销#id)",管理端卡密列表不再显示悬空的已删用户名)""" + u = get_user_by_id(uid) + if not u or u['role'] == 'admin': + return False + with _DB_LOCK: + conn = get_conn() + try: + name = u['username'] + conn.execute('DELETE FROM users WHERE id = ? AND role != ?', (uid, 'admin')) + conn.execute("UPDATE cards SET used_by = used_by || ? WHERE used_by = ?", + (f'(已注销#{uid})', name)) + conn.commit() + finally: + conn.close() + return True + + +def reset_password(uid: int, new_password: str) -> bool: + """重置密码(F03:同时递增 login_seq,让所有已签发会话立即失效)""" + with _DB_LOCK: + conn = get_conn() + try: + conn.execute('UPDATE users SET password_hash = ?, salt = \'\', login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', + (_hash_new(new_password), uid)) + conn.commit() + return True + finally: + conn.close() + + +def set_user_status(uid: int, status: int) -> bool: + """启用 / 禁用账号(1 启用,0 禁用);管理员账号不可禁用""" + if status not in (0, 1): + return False + row = _q('SELECT role FROM users WHERE id = ?', (uid,)) + if row and row[0]['role'] == 'admin': + return False + return bool(_x('UPDATE users SET status = ? WHERE id = ?', (status, uid))) + + +def ban_user(uid: int, reason: str = '') -> bool: + """封禁账号(管理员不可封)""" + row = _q('SELECT role FROM users WHERE id = ?', (uid,)) + if not row or row[0]['role'] == 'admin': + return False + return bool(_x('UPDATE users SET status = 2, ban_reason = ? WHERE id = ?', + (str(reason or '')[:200], uid))) + + +def unban_user(uid: int) -> bool: + """解封账号,恢复正常(F80:清空触发封禁的省份历史,否则下次登录立即再封)""" + return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,))) + + +_PROVINCE_CACHE: Dict[str, str] = {} + + +def _is_private_ip(ip: str) -> bool: + """内网/回环地址判断(F47:172.16-31.* 才是私网,不能把整个 172.* 误判)""" + try: + import ipaddress + addr = ipaddress.ip_address(ip) + return addr.is_private or addr.is_loopback or addr.is_link_local + except ValueError: + return False + + +def _is_loopback_ip(ip: str) -> bool: + """可信反代判定(第 91 条 P2-4:从"所有内网源"收窄为仅回环地址)。 + 服务绑 0.0.0.0 时内网任意主机都可直连伪造 XFF/X-Real-IP 轮换绕过限流与 + 登录锁定(实测实锤);同机 Caddy 反代(127.0.0.1 → Flask)不受影响。""" + try: + import ipaddress + addr = ipaddress.ip_address(ip) + return addr.is_loopback + except ValueError: + return False + + +def query_ip_province(ip: str) -> str: + """查询 IP 归属省份(内网/失败返回空串),带进程内缓存 + F47:改用官方路径 /json/{query} 与 zh-CN 语言参数""" + ip = (ip or '').split(',')[0].strip() + if not ip or ip in ('-', '127.0.0.1', '0.0.0.0') or _is_private_ip(ip): + return '' + if ip in _PROVINCE_CACHE: + return _PROVINCE_CACHE[ip] + province = '' + try: + import requests as _rq + import urllib.parse as _ul + r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip), + params={'lang': 'zh-CN', 'fields': 'status,regionName'}, + timeout=5) + d = r.json() + if d.get('status') == 'success': + province = str(d.get('regionName') or '') + except Exception: + province = '' + # 简单容量上限,防止公网长期运行下无界增长 + if len(_PROVINCE_CACHE) > 5000: + _PROVINCE_CACHE.clear() + # 第 99 条:查询失败(超时/限流/垃圾 JSON)的空省份不写缓存——旧口径一次 + # ip-api 抖动窗口内登录的 IP 被永久标记"无省份",省份防倒卖静默失效 + if province: + _PROVINCE_CACHE[ip] = province + return province + + +PROVINCE_WINDOW = 24 * 3600 # 省份变化检测窗口:24 小时 +PROVINCE_BAN_LIMIT = 3 # 窗口内出现 >=3 个不同省份即封禁 + + +def record_login_location(user: sqlite3.Row, ip: str) -> tuple: + """记录本次登录省份并检测异常:24h 内出现 >=3 个不同省份自动封禁。 + 返回 (是否被封禁, 说明)""" + if user['role'] == 'admin': + return False, '' + province = query_ip_province(ip) + if not province: + return False, '' + now = int(time.time()) + try: + history = json.loads(user['recent_provinces'] if 'recent_provinces' in user.keys() and user['recent_provinces'] else '[]') + except Exception: + history = [] + history = [h for h in history if isinstance(h, dict) and now - int(h.get('t', 0)) <= PROVINCE_WINDOW] + history.append({'p': province, 't': now}) + provinces = [] + for h in history: + if h.get('p') and h['p'] not in provinces: + provinces.append(h['p']) + _x('UPDATE users SET recent_provinces = ? WHERE id = ?', + (json.dumps(history, ensure_ascii=False), user['id'])) + if len(provinces) >= PROVINCE_BAN_LIMIT: + reason = f'24小时内检测到 {len(provinces)} 个不同省份登录({"/".join(provinces[-3:])}),疑似账号倒卖,已自动封禁' + ban_user(user['id'], reason) + add_log('fail', user['username'], ip, '自动封禁', reason) + return True, reason + return False, province + + +def set_user_credits(uid: int, count: int) -> None: + """设置用户剩余次数(-1 表示不限)""" + count = -1 if count < 0 else int(count) + _x('UPDATE users SET duration_seconds = ? WHERE id = ?', (count, uid)) + + +def set_user_password(uid: int, password: str) -> None: + # F03/F13:新哈希 + 递增 login_seq(所有旧会话立即失效) + with _DB_LOCK: + conn = get_conn() + try: + conn.execute('UPDATE users SET password_hash = ?, salt = \'\', ' + 'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', + (_hash_new(password), uid)) + conn.commit() + finally: + conn.close() + + +def set_user_netease_cookie(username: str, cookie: str) -> None: + _x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username)) + + +def _user_public(r: sqlite3.Row, full: bool = False) -> Dict[str, Any]: + d = { + 'id': r['id'], + 'username': r['username'], + 'email': r['email'], + 'role': r['role'], + 'duration_seconds': r['duration_seconds'], + 'created_at': r['created_at'], + 'last_login': r['last_login'], + 'ann_seen': r['ann_seen'], + } + if full: + d.update({ + 'status': r['status'] if 'status' in r.keys() else 1, + 'trial_device': r['trial_device'], + 'register_ip': r['register_ip'], + 'register_ua': r['register_ua'], + # P2-4:列表不再携带明文网易云 Cookie(前端只用布尔值;原文按需走 + # /admin/site/user/cookie 单查接口,避免一次列表请求扩散全部用户凭据) + 'has_netease_cookie': bool(r['netease_cookie']), + 'ban_reason': r['ban_reason'] if 'ban_reason' in r.keys() else '', + 'last_province': _last_province(r), + }) + return d + + +def _last_province(r: sqlite3.Row) -> str: + try: + history = json.loads(r['recent_provinces'] if 'recent_provinces' in r.keys() and r['recent_provinces'] else '[]') + return history[-1].get('p', '') if history else '' + except Exception: + return '' + + +# ============ 会话 ============ + + +def login_session(user: sqlite3.Row) -> None: + """登录会话:数据库侧原子递增 login_seq 实现顶号(旧会话自动失效)(F04)""" + session.permanent = True + with _DB_LOCK: + conn = get_conn() + try: + conn.execute('UPDATE users SET last_active = ?, last_login = ?, ' + 'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', + (int(time.time()), int(time.time()), user['id'])) + row = conn.execute('SELECT login_seq FROM users WHERE id = ?', (user['id'],)).fetchone() + conn.commit() + seq = int(row['login_seq']) if row else 1 + finally: + conn.close() + session[SESSION_KEY] = user['id'] + session[SESSION_KEY + '_seq'] = seq + # P3-23:记录本次签发时间,供滚动续期判断(活跃用户不再登录满 7 天被硬登出) + session[SESSION_KEY + '_ts'] = int(time.time()) + + +def _clear_session_only() -> None: + """仅清除本浏览器会话字段(不动数据库 login_seq)—— + 用于顶号/失效检测路径:清掉旧会话即可,绝不能递增 seq, + 否则会把"刚刚在别处合法登录"的新会话一并踢下线""" + session.pop(SESSION_KEY, None) + session.pop(SESSION_KEY + '_seq', None) + + +def logout_session() -> None: + """退出登录(F03:递增 login_seq,使本账号全部已签发会话 Cookie 立即失效)""" + uid = session.get(SESSION_KEY) + _clear_session_only() + if uid: + try: + _x('UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (int(uid),)) + except Exception: + pass + + +def current_user() -> Optional[sqlite3.Row]: + """当前登录用户(每请求只查一次库:结果缓存到 flask.g。 + 一次下载流程会调用本函数 5-6 次——原实现每次都新建 SQLite 连接,公网下是纯浪费。 + 缓存含 None:未登录同样只查一次。请求内登录态不会变化(登录/注册路由不走本函数)。)""" + cached = getattr(g, '_site_current_user', None) + if cached is not None: + return cached[0] + user = _load_current_user() + g._site_current_user = (user,) + return user + + +def _load_current_user() -> Optional[sqlite3.Row]: + uid = session.get(SESSION_KEY) + if not uid: + return None + user = get_user_by_id(int(uid)) + if not user: + return None + status = user['status'] if 'status' in user.keys() else 1 + if status == 0 or status == 2: + # 被封禁账号的残留会话:清除并记录一次(只清本会话,不动 seq) + if status == 2: + _clear_session_only() + add_log('fail', user['username'], _client_ip(), '封禁账号访问', + user['ban_reason'] if 'ban_reason' in user.keys() else '') + return None + # 顶号校验(F04):数据库已有版本号时,会话版本必须严格一致; + # 缺失/为 0 的会话版本不再放行(防绕过)。检测路径只清本会话,不动 seq。 + db_seq = int(user['login_seq'] if 'login_seq' in user.keys() and user['login_seq'] else 0) + sess_seq = int(session.get(SESSION_KEY + '_seq') or 0) + if db_seq and sess_seq != db_seq: + _clear_session_only() + add_log('fail', user['username'], _client_ip(), '顶号下线', + '账号已在其他设备登录或会话已失效,本机会话已下线') + return None + # P3-23:滚动续期——会话签发超过一半寿命(3.5 天)时刷新签发时间并 touch 会话, + # 活跃用户的 Cookie 有效期自动顺延;只有这半天窗口内的请求会带一次 Set-Cookie + try: + issued = int(session.get(SESSION_KEY + '_ts') or 0) + if issued and time.time() - issued > 3.5 * 86400: + session[SESSION_KEY + '_ts'] = int(time.time()) + session.permanent = True # 触发 cookie 重发(刷新 7 天窗口) + except Exception: + pass + return user + + +def _trusted_proxy_ip(header_value: str) -> str: + """第 97 条(P2-1):从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。 + 最右段是可信反代(Caddy)追加的直连方地址;最左段与 X-Real-IP 均可被客户端 + 伪造(Caddy 默认不改写客户端自带的 X-Real-IP),采信它们等于给限流/锁定/省份 + 记录开了伪造口。无有效值返回空串,调用方回落 remote_addr。""" + if not header_value: + return '' + import ipaddress as _ipa + for part in reversed(header_value.split(',')): + part = part.strip() + if not part: + continue + try: + return str(_ipa.ip_address(part)) + except ValueError: + continue + return '' + + +def _client_ip_from_headers(remote: str) -> str: + """第 97 条:可信反代场景的客户端 IP 解析(_client_ip 与 main._rate_client_ip 共用)。 + XFF 最右段优先(唯一由可信反代追加的值,客户端伪造的最左段无效);最右段为 + 回环(PassNAT 纯 TCP 隧道下 Caddy 只能看到隧道本机端)时回落采信 X-Real-IP—— + 该场景要求隧道开启真实 IP 透传(覆写 X-Real-IP,部署闸 §5.7 B2 必须实测); + 客户端伪造面已由"Caddy 恒追加 XFF + 仅回环信任"收口。全部无效回落 remote。""" + fwd = _trusted_proxy_ip(request.headers.get('X-Forwarded-For') or '') + if fwd and not _is_loopback_ip(fwd): + return fwd + xrip = _trusted_proxy_ip(request.headers.get('X-Real-IP') or '') + if xrip: + return xrip + return remote + + +def _client_ip() -> str: + """客户端 IP(F08:仅当直连方是可信反代时才信任代理头,防伪造绕过限流。 + 第 91 条 P2-4:可信范围从"本机/内网"收窄为"仅回环"——绑 0.0.0.0 时内网任意 + 主机直连可伪造 XFF 轮换绕过限流/登录锁定(动态实测实锤)。 + 第 97 条 P2-1:解析规则见 _client_ip_from_headers——XFF 最右段优先, + 最右段为回环时回落 X-Real-IP(PassNAT 隧道场景),不再无条件读 X-Real-IP)""" + remote = request.remote_addr or '-' + if remote != '-' and _is_loopback_ip(remote): + return _client_ip_from_headers(remote) + return remote + + +# ============ 使用次数(按次计费:一首歌 = 1 次) ============ + + +def refund_credits(uid: int, count: int) -> int: + """退回下载次数(批量打包失败部分自动退费)。返回剩余次数(-1 表示不限)""" + user = get_user_by_id(uid) + if user and (user['duration_seconds'] or 0) == -1: + return -1 # 不限次数用户无需退费 + count = max(0, int(count)) + if count <= 0: + return (user['duration_seconds'] or 0) if user else 0 + _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?', + (count, uid)) + user = get_user_by_id(uid) + return (user['duration_seconds'] or 0) if user else 0 + + +def charge_credits(uid: int, count: int = 1) -> int: + """按次扣减使用次数(管理员与不限次数(-1)用户不扣减,返回 -1)。返回剩余次数 + + 扣减在 _DB_LOCK 内"读取-判断-写入"一次完成,防止并发双击导致超扣/双花""" + user = get_user_by_id(uid) + if not user: + return 0 + if user['role'] == 'admin' or (user['duration_seconds'] or 0) == -1: + _x('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + return -1 + count = max(1, int(count)) + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT duration_seconds FROM users WHERE id = ?', (uid,)).fetchone() + cur = int(row['duration_seconds'] or 0) if row else 0 + if cur == -1: + conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + conn.commit() + return -1 + newval = max(0, cur - count) + conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', + (newval, int(time.time()), uid)) + conn.commit() + return newval + finally: + conn.close() + + +def reserve_credits(uid: int, count: int = 1): + """原子预扣下载次数(下载类接口在路由执行前调用,防止并发双击双花)。 + + 足额:扣减并返回剩余次数;不限次数(-1)/管理员:不扣减返回 -1; + 不足:返回 None(调用方应拒绝请求)""" + count = max(1, int(count)) + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,)).fetchone() + if not row: + return None + if row['role'] == 'admin': + conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + conn.commit() + return -1 + cur = int(row['duration_seconds'] or 0) + if cur == -1: + conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + conn.commit() + return -1 + if cur < count: + return None + conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', + (cur - count, int(time.time()), uid)) + conn.commit() + return cur - count + finally: + conn.close() + + +def add_credits(username: str, count: int) -> int: + """增加次数(卡密充值/试用领取),返回新剩余次数;不限次数(-1)用户保持不限 + + 累加用 SQL 原生自增(原子),避免并发充值互相覆盖""" + user = get_user_by_name(username) + if not user: + return 0 + if user['role'] == 'admin' or (user['duration_seconds'] or 0) == -1: + return -1 + _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?', + (max(0, int(count)), user['id'])) + user = get_user_by_name(username) + return (user['duration_seconds'] or 0) if user else 0 + + +def format_credits(count: int) -> str: + if count < 0: + return '不限次数' + if count <= 0: + return '已用完' + return f'{count} 次' + + +# ============ 计费账本(F25/F26:实扣、交付、退款、终态的持久化唯一记录) ============ + + +def ledger_open(task_id: str, user_id: int, reserved: int) -> None: + """任务计费开账:记录本任务实际预扣次数(admin/-1 用户为 0)。 + task_id 唯一;重复 task_id 由任务系统在提交侧拒绝,这里幂等保护。""" + with _DB_LOCK: + conn = get_conn() + try: + conn.execute( + 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' + 'VALUES (?, ?, ?, \'open\', ?)', + (str(task_id), int(user_id), max(0, int(reserved)), int(time.time()))) + conn.commit() + finally: + conn.close() + + +def reserve_and_open(uid: int, count: int, ledger_key: str) -> Optional[int]: + """A2/B8:预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。 + 足额:扣减+开账并返回剩余;不限/管理员:开账(reserved=0)返回 -1;不足:返回 None(不开账)。""" + count = max(1, int(count)) + key = str(ledger_key) + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,)).fetchone() + if not row: + return None + now = int(time.time()) + if row['role'] == 'admin': + conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid)) + conn.execute( + 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' + 'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now)) + conn.commit() + return -1 + cur = int(row['duration_seconds'] or 0) + if cur == -1: + conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid)) + conn.execute( + 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' + 'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now)) + conn.commit() + return -1 + if cur < count: + return None + conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', + (cur - count, now, uid)) + conn.execute( + 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' + 'VALUES (?, ?, ?, \'open\', ?)', (key, int(uid), count, now)) + conn.commit() + return cur - count + finally: + conn.close() + + +def ledger_rename(old_key: str, new_key: str) -> bool: + """A2/B8:把请求级临时账本行改名为正式 task_id(任务接手账目)。 + old 不存在/已关闭/new 已存在 → False。""" + with _DB_LOCK: + conn = get_conn() + try: + dup = conn.execute('SELECT 1 FROM billing_ledger WHERE task_id = ?', (str(new_key),)).fetchone() + if dup: + return False + cur = conn.execute( + 'UPDATE billing_ledger SET task_id = ? WHERE task_id = ? AND status = \'open\'', + (str(new_key), str(old_key))) + conn.commit() + return cur.rowcount == 1 + finally: + conn.close() + + +def ledger_supplement(task_id: str, extra: int) -> bool: + """整台播客等"先扣 1 后按实际数量补扣"场景(F21): + 原子补扣用户余额并同步累加账本 reserved。任一步失败返回 False(调用方必须让任务失败, + 不允许无账本继续交付)。余额不足同样返回 False。""" + task_id = str(task_id) + extra = int(extra) + if extra <= 0: + return True + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT user_id, status FROM billing_ledger WHERE task_id = ?', + (task_id,)).fetchone() + if not row or row['status'] != 'open': + return False + urow = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', + (row['user_id'],)).fetchone() + if not urow or urow['role'] == 'admin' or int(urow['duration_seconds'] or 0) == -1: + return True # 不限用户:无需补扣,账本也不加 + cur_credits = int(urow['duration_seconds'] or 0) + if cur_credits < extra: + return False # 余额不足:任务必须失败(不允许扣到 0 硬做) + conn.execute('UPDATE users SET duration_seconds = duration_seconds - ? WHERE id = ?', + (extra, row['user_id'])) + conn.execute('UPDATE billing_ledger SET reserved = reserved + ? WHERE task_id = ?', + (extra, task_id)) + conn.commit() + return True + except Exception: + conn.rollback() + return False + finally: + conn.close() + + +def ledger_get(task_id: str) -> Optional[Dict[str, Any]]: + rows = _q('SELECT * FROM billing_ledger WHERE task_id = ?', (str(task_id),)) + return dict(rows[0]) if rows else None + + +def ledger_settle(task_id: str, delivered: int, final_status: str) -> int: + """任务计费结算(幂等):按账本实扣与实际交付计算应退次数,原子退款并落终态。 + - delivered:本次任务实际交付给用户的数量(单曲/单集 0 或 1;批量为成功入包数) + - 重复调用/重启后再次调用:账本已非 open 直接返回 0,不重复退款 + - admin/-1 提交(reserved=0):退款自然为 0(F26:不看结算时的账户状态) + - P3-1:以账本 reserved 为准计算应退;R5-10 口径更正:-1(不限次)账户不落余额 + (加次数会破坏 -1 哨兵值),账本 refunded 记录应付口径,作废可接受 + 返回实际退款次数(落余额的部分;-1/admin 场景为 0)。""" + task_id = str(task_id) + delivered = max(0, int(delivered)) + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT * FROM billing_ledger WHERE task_id = ?', (task_id,)).fetchone() + if not row or row['status'] != 'open': + return 0 + refund = max(0, int(row['reserved']) - delivered) + # 退款与账本终态同事务落库:要么都成,要么都不成。 + # 注意 WHERE 必须排除 admin/-1:向 -1(不限次)账户加次数会把 -1 哨兵值破坏成 + # -1+N,向 admin 加无意义——因此任务运行期间账户升级为 -1 时退款不落余额, + # 账本 refunded 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受); + # R5-10:原注释"照样退"与实现相反,已更正为此口径。 + if refund > 0: + conn.execute('UPDATE users SET duration_seconds = duration_seconds + ? ' + 'WHERE id = ? AND role != \'admin\' AND duration_seconds != -1', + (refund, row['user_id'])) + conn.execute('UPDATE billing_ledger SET delivered = ?, refunded = ?, status = ?, ' + 'settled_at = ? WHERE task_id = ?', + (delivered, refund, str(final_status)[:20], int(time.time()), task_id)) + conn.commit() + return refund + finally: + conn.close() + + +def ledger_recover_orphans(max_age_seconds: int = 2 * 3600) -> int: + """启动对账(F26):任务表在内存,进程重启后账本中仍 open 的行代表 + "已扣款但任务已丢失"——退回全部实扣。仅处理超过宽限期的行, + 避免误伤同库其他存活进程的进行中任务。返回退款笔数。""" + now = int(time.time()) + recovered = 0 + rows = _q('SELECT task_id FROM billing_ledger WHERE status = \'open\' AND created_at <= ?', + (now - int(max_age_seconds),)) + for r in rows: + try: + refunded = ledger_settle(r['task_id'], 0, 'lost_restart') + if refunded: + add_log('success', 'system', 'system', '重启对账退款', + f'任务 {r["task_id"]} 因进程重启丢失,退回 {refunded} 次') + else: + ledger_settle(r['task_id'], 0, 'lost_restart') + recovered += 1 + except Exception: + pass + return recovered + + +def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int: + """P3-20:清理已结算的历史账本行(每任务一行、只增不删会让表无限膨胀; + settled 状态行无对账价值,保留 90 天足够追溯)""" + try: + return _x('DELETE FROM billing_ledger WHERE status != \'open\' AND settled_at <= ?', + (int(time.time()) - int(max_age_seconds),)) + except Exception: + return 0 + + +# ============ 装饰器 ============ + + +def _deny(message: str, code: int = 401, error_code: str = 'unauthorized') -> Tuple[Dict[str, Any], int]: + return ({'status': code, 'success': False, 'message': message, 'error_code': error_code}, code) + + +def login_required(action: str = '', cost=None, free: bool = False): + """强制登录装饰器: + - 未登录 401;剩余次数不足本次消耗时 401/expired + - free=True:解析/试听类接口,仅要求登录,不检查也不扣减次数 + - cost: 可选 callable,返回本次请求消耗次数(下载类:单曲 1 次,批量按歌曲数) + - A2/B8 统一账本通道:预扣与开账同事务(reserve_and_open,临时键挂在 flask.g); + 路由创建任务后用 ledger_rename 把账本改挂 task_id,worker 结算唯一入口走账本; + 请求失败(HTTP>=400/异常)时装饰器只对"仍 open 的账本行"结算退款—— + 任何路径(含线程启动失败、json 模式)都恰好退一次,杜绝双退/漏退。 + """ + def deco(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + user = current_user() + if not user: + return _deny('请先登录后再使用解析功能') + fresh = get_user_by_id(user['id']) + if fresh is None: + # 极小竞态:会话校验通过后、预扣前用户被删除——按未登录收敛,不再 None 解引用 500 + return _deny('登录已失效,请重新登录') + credits = int(fresh['duration_seconds'] or 0) + unlimited = fresh['role'] == 'admin' or credits == -1 + need = 0 + reserved = False + from flask import g as _flask_g + if not free: + try: + need = max(1, int(cost() if cost is not None else 1)) + except Exception: + need = 1 + if not unlimited: + key = 'req-' + secrets.token_hex(8) + remaining = reserve_and_open(fresh['id'], need, key) + if remaining is None: + add_log('fail', fresh['username'], _client_ip(), action or request.path, + f'剩余次数不足(本次需 {need} 次)') + return _deny(f'下载次数已用尽(本次需 {need} 次,当前 {credits} 次),' + '请充值卡密后继续下载', error_code='expired') + _flask_g.billing_ledger_key = key + reserved = True + def _refund_open(reason: str) -> None: + """A2:仅对仍 open 的账本行全额结算退款(被任务 rename/结算过则跳过)""" + key = getattr(_flask_g, 'billing_ledger_key', None) + if not key: + return + try: + row = ledger_get(key) + if row and row['status'] == 'open': + ledger_settle(key, 0, reason) + except Exception: + pass + try: + resp = fn(*args, **kwargs) + except Exception: + # 路由内部异常:预扣的次数经账本退回 + if reserved: + _refund_open('rejected') + raise + status = resp[1] if isinstance(resp, tuple) else 200 + if status < 400: + # P2-15:free 端点(搜索/解析等高频动作)成功不再写逐次 success 日志—— + # 它们与卡密充值/退费/封禁等审计关键日志同表竞争 5000 行配额, + # 忙站点几小时就把追账依据冲掉;下载/计费/失败路径照常记录 + if action and not free: + detail = (f'-{need} 次,剩余 {credits - need} 次' if reserved and need > 0 else '') + add_log('success', fresh['username'], _client_ip(), action, detail) + elif action and free: + # 解析类动作改记到 REQ_LOG(管理后台"操作记录"页),不入 logs 表 + pass + elif action: + add_log('fail', fresh['username'], _client_ip(), action, f'HTTP {status}') + if status >= 400 and reserved: + # 路由失败(404/版权限制等):预扣的次数经账本退回 + _refund_open('rejected') + return resp + return wrapper + return deco + + +def admin_required(fn): + """仅站点管理员可用""" + @wraps(fn) + def wrapper(*args, **kwargs): + user = current_user() + if not user or user['role'] != 'admin': + return _deny('需要管理员权限', 403, 'forbidden') + return fn(*args, **kwargs) + return wrapper + + +# ============ 日志 ============ + + +# 日志裁剪计数器(P3-2:原实现用 secrets.randbelow 触发——密码学随机数浪费熵池) +_LOG_WRITE_COUNTER = {'n': 0} + + +def add_log(log_type: str, username: str, ip: str, action: str, detail: str = '') -> None: + try: + _x('INSERT INTO logs (type, username, ip, action, detail, time) VALUES (?, ?, ?, ?, ?, ?)', + (log_type, username or '', ip or '-', action or '', detail or '', + time.strftime('%Y-%m-%d %H:%M:%S'))) + # 控制日志表体积(每 50 次写入触发一次裁剪) + _LOG_WRITE_COUNTER['n'] += 1 + if _LOG_WRITE_COUNTER['n'] % 50 == 0: + _x('DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT 5000)') + except Exception: + pass + + +def query_logs(log_type: str, limit: int = 100, offset: int = 0): + """分页查询日志,返回 (日志列表, 总条数)""" + limit = max(1, min(int(limit), 500)) + offset = max(0, int(offset)) + rows = _q('SELECT * FROM logs WHERE type = ? ORDER BY id DESC LIMIT ? OFFSET ?', + (log_type, limit, offset)) + total_row = _q('SELECT COUNT(*) AS c FROM logs WHERE type = ?', (log_type,)) + total = int(total_row[0]['c']) if total_row else 0 + return [dict(r) for r in rows], total + + +def clear_logs(log_type: str) -> int: + """清除指定类型的全部日志,返回删除条数""" + with _DB_LOCK: + conn = get_conn() + try: + cur = conn.execute('DELETE FROM logs WHERE type = ?', (log_type,)) + conn.commit() + return cur.rowcount + finally: + conn.close() + + +# ============ 邮箱验证码 / SMTP ============ + +# R5-P3:验证码猜错计数(内存态,重启清零可接受)——同邮箱累计错 5 次作废验证码 +# 第 97 条(P3):键='purpose:任意邮箱' 无上限可被慢速灌大——超限清最旧 +_CODE_FAILS: dict = {} +_CODE_FAILS_MAX = 5000 + + +def _code_fails_trim() -> None: + """超上限时按写入序丢弃最旧的一半(仅防内存无界,不承担安全语义)""" + if len(_CODE_FAILS) > _CODE_FAILS_MAX: + for k in list(_CODE_FAILS.keys())[:len(_CODE_FAILS) // 2]: + _CODE_FAILS.pop(k, None) + + +_SMTP_ENC_PREFIX = 'enc1:' # 第 95 条:信封加密标记前缀(存量明文读取时透明升级) + + +def _smtp_password_encrypt(plaintext: str) -> str: + """用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码(第 95 条 P2-7: + 原明文落库+随备份导出=泄露半径扩大;注释编号笔误已按第 97 条纠正)。""" + from cryptography.fernet import Fernet + import hashlib as _hl + key = _hl.sha256(get_secret_key().encode('utf-8')).digest() + f = Fernet(base64.urlsafe_b64encode(key)) + return _SMTP_ENC_PREFIX + f.encrypt(plaintext.encode('utf-8')).decode('utf-8') + + +def _smtp_password_decrypt(ciphertext: str) -> str: + if not ciphertext.startswith(_SMTP_ENC_PREFIX): + return ciphertext # 存量明文:透明兼容,下次保存时升级为密文 + from cryptography.fernet import Fernet + import hashlib as _hl + key = _hl.sha256(get_secret_key().encode('utf-8')).digest() + f = Fernet(base64.urlsafe_b64encode(key)) + return f.decrypt(ciphertext[len(_SMTP_ENC_PREFIX):].encode('utf-8')).decode('utf-8') + + +def get_smtp_config() -> Dict[str, Any]: + rows = _q('SELECT * FROM smtp_config WHERE id = 1') + cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''} + # 第 95 条:读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文 + if cfg.get('password'): + try: + cfg['password'] = _smtp_password_decrypt(cfg['password']) + except Exception: + cfg['password'] = '' + return cfg + + +def save_smtp_config(host: str, port: int, sender: str, password: str) -> None: + enc_pwd = _smtp_password_encrypt(password) if password else '' + _x('UPDATE smtp_config SET host = ?, port = ?, sender = ?, password = ?, updated_at = ? WHERE id = 1', + (host, int(port or 587), sender, enc_pwd, int(time.time()))) + + +def smtp_configured() -> bool: + cfg = get_smtp_config() + return bool(cfg['host'] and cfg['sender'] and cfg['password']) + + +def _smtp_connect() -> smtplib.SMTP: + cfg = get_smtp_config() + port = int(cfg['port'] or 587) + # F15:显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值) + ctx = ssl.create_default_context() + if port == 465: + server = smtplib.SMTP_SSL(cfg['host'], port, timeout=15, context=ctx) + else: + server = smtplib.SMTP(cfg['host'], port, timeout=15) + server.starttls(context=ctx) + server.login(cfg['sender'], cfg['password']) + return server + + +def send_email(to: str, subject: str, body: str) -> None: + """R5-P3:收件人格式校验下沉到本入口——未来任何新调用方漏校验也不再成为 + CRLF/信封注入点(现有调用点的校验仅作 UX 提示)。""" + to = _normalize_email(to) + if not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to, re.ASCII): + raise ValueError('收件邮箱格式不正确') + cfg = get_smtp_config() + msg = MIMEText(body, 'plain', 'utf-8') + msg['Subject'] = Header(subject, 'utf-8') + msg['From'] = formataddr(('云村小助手', cfg['sender'])) + msg['To'] = to + with _smtp_connect() as server: + server.sendmail(cfg['sender'], [to], msg.as_string()) + + +_EMAIL_SENT_24H: dict = {} # R5-F2:同邮箱 24h 发送计数(内存态,重启清零可接受) + +# 第 85 条:验证码用途隔离——注册码不能用于找回密码,反之亦然(库行带 purpose, +# 消费/作废/还原均按用途匹配)。邮件主题与正文按用途区分,用户一眼可辨。 +# 第 86 条:新增 admin2fa(管理员两步验证)——发码前置条件是密码已验证正确, +# 故豁免 24h 5 封与 60s 冷却(否则管理员每天最多登录 5 次会被自己锁死); +# 滥用面由"票据仅密码正确时签发 + 票据尝试上限 + 登录限流"约束 +_CODE_PURPOSES = ('register', 'reset', 'admin2fa') +_CODE_EMAIL_TEMPLATES = { + 'register': ('云村小助手 注册验证码', + '您正在注册云村小助手账号。\n\n' + '验证码:{code}\n\n' + '验证码 10 分钟内有效,请勿泄露给他人。\n' + '如非本人操作,请忽略本邮件。'), + 'reset': ('云村小助手 找回密码验证码', + '您正在找回云村小助手账号的登录密码。\n\n' + '验证码:{code}\n\n' + '验证码 10 分钟内有效,仅用于重置密码,请勿泄露给他人。\n' + '如非本人操作,请忽略本邮件(您的密码不会被修改)。'), + 'admin2fa': ('云村小助手 管理员登录验证码', + '您正在登录云村小助手管理员账号(两步验证第二步)。\n\n' + '验证码:{code}\n\n' + '验证码 10 分钟内有效,请勿泄露给任何人。\n' + '如非本人操作,请立即修改管理员密码并检查邮箱安全。'), +} + + +def send_verify_code(email: str, purpose: str = 'register') -> None: + """生成并发送邮箱验证码(10 分钟有效)(F14:先发信成功再落库;同用途旧码即时作废)。 + R5(第 79 条):① 同邮箱 24h 硬上限 5 封(防邮件轰炸代理/发信配额消耗); + ② email 先归一化(Gmail +tag 别名剥离)——别名刷号被邮箱查重堵死。 + 第 85 条:purpose 区分注册/找回(文案不同 + 库行用途隔离);24h 计数移到 + 发信成功之后(原实现 SMTP 失败也烧掉额度)。""" + if purpose not in _CODE_PURPOSES: + raise ValueError('验证码用途不正确') + email = _normalize_email(email or '') + # 第 93 条(P3):re.ASCII——\w 的 Unicode 语义会放行"中文邮箱@例子.com"这类 + # 非 ASCII 地址(SMTP 阶段才失败,路由兜底成 500);加 254 长度上限(RFC 口径, + # 原实现 5000 字符邮箱可入库) + if len(email) > 254 or not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', email, re.ASCII): + raise ValueError('邮箱格式不正确') + if not smtp_configured(): + raise RuntimeError('邮箱服务未配置,请联系管理员') + # R5-F2:同邮箱 24h ≤5 封(内存计数,不再依赖会被消费 DELETE 清零的 DB 行数); + # admin2fa 豁免 24h 上限与 60s 冷却(见 _CODE_PURPOSES 处注释) + now_ts = time.time() + sent = [t for t in _EMAIL_SENT_24H.get(email, []) if now_ts - t < 86400] + if purpose != 'admin2fa' and len(sent) >= 5: + raise RuntimeError('该邮箱验证码发送已达上限(24 小时 5 封),请明日再试') + # 60 秒内限制重发(按同邮箱同用途的最近一封计) + if purpose != 'admin2fa': + rows = _q('SELECT created_at FROM email_codes WHERE email = ? AND purpose = ? ORDER BY id DESC LIMIT 1', + (email, purpose)) + if rows and time.time() - rows[0]['created_at'] < 60: + raise RuntimeError('发送过于频繁,请 1 分钟后再试') + code = f'{secrets.randbelow(1000000):06d}' + subject, body_tpl = _CODE_EMAIL_TEMPLATES[purpose] + # 先发送:SMTP 失败不留任何有效验证码与冷却记录;成功后才计入 24h 额度(admin2fa 不计数) + send_email(email, subject, body_tpl.format(code=code)) + if purpose != 'admin2fa': + sent.append(now_ts) + _EMAIL_SENT_24H[email] = sent + # 第 97 条(P3):键只增不减(旧时间戳被过滤但空键留存)——顺手剔除过期 + # 空键并做容量上限,防慢速灌任意格式邮箱造成无界增长 + if len(_EMAIL_SENT_24H) > 5000: + for k in [k for k, v in _EMAIL_SENT_24H.items() if not v][:2500]: + _EMAIL_SENT_24H.pop(k, None) + if len(_EMAIL_SENT_24H) > 5000: + for k in list(_EMAIL_SENT_24H.keys())[:len(_EMAIL_SENT_24H) // 2]: + _EMAIL_SENT_24H.pop(k, None) + with _DB_LOCK: + conn = get_conn() + try: + # 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码 + conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?', + (email, purpose, int(time.time()))) + conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)', + (email, code, int(time.time() + 600), int(time.time()), purpose)) + conn.commit() + finally: + conn.close() + + +def check_verify_code(email: str, code: str, purpose: str = 'register') -> int: + """校验验证码(F14:单条 DELETE 原子消费,rowcount=1 才算通过,杜绝并发双读)。 + 成功返回剩余有效秒数(真值),失败返回 0。 + R5-P3:同一邮箱累计错 5 次→该用途验证码作废(防 10 分钟窗口内挂机猜码)。 + 第 85 条:WHERE 带 purpose——注册码与找回码互不可用。""" + if purpose not in _CODE_PURPOSES: + return 0 + email = _normalize_email(email or '') + fail_key = purpose + ':' + email # 失败计数按"用途:邮箱"隔离,互不连带 + with _DB_LOCK: + conn = get_conn() + try: + cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? ' + 'RETURNING expires_at', + (email, (code or '').strip(), purpose, int(time.time()))) + row = cur.fetchone() + conn.commit() + if row: + _CODE_FAILS.pop(fail_key, None) + return max(0, int(row[0]) - int(time.time())) + except sqlite3.OperationalError as _oe: + # 老版本 SQLite 无 RETURNING:回退为删除后判 rowcount。 + # 第 99 条:仅"不支持 RETURNING"走回退——库忙(database is locked)原样 + # 上抛,否则第一次等满 busy 15s、回退再等 15s,全程持锁冻结全站写路径 + if 'RETURNING' not in str(_oe) and 'syntax' not in str(_oe).lower(): + raise + conn.rollback() + cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?', + (email, (code or '').strip(), purpose, int(time.time()))) + conn.commit() + if cur.rowcount == 1: + _CODE_FAILS.pop(fail_key, None) + return 60 + row = None + finally: + conn.close() + # 猜错:失败计数 +1,累计 5 次直接作废该用途的验证码 + n = _CODE_FAILS.get(fail_key, 0) + 1 + _CODE_FAILS[fail_key] = n + _code_fails_trim() + if n >= 5: + _CODE_FAILS.pop(fail_key, None) + try: + _x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose)) + except Exception: + pass + return 0 + + +def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None: + """注册流程后续步骤失败时还原已消费的验证码(F14 第二轮:建号失败不应吞掉验证码)""" + if ttl_seconds <= 0: + return + if purpose not in _CODE_PURPOSES: + return + email = _normalize_email(email or '') + code = (code or '').strip() + if not email or not code: + return + with _DB_LOCK: + conn = get_conn() + try: + conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)', + (email, code, int(time.time() + ttl_seconds), int(time.time()), purpose)) + conn.commit() + finally: + conn.close() + + +# ============ 卡密 ============ + + +def generate_cards(count: int, credits: int, created_by: str = '', + prefix: str = '', note: str = '', valid_days: int = 0) -> List[str]: + """生成次数卡密(credits = 每张卡可下载次数) + prefix: 自定义卡密前缀(默认 VIP-) + note: 备注信息 + valid_days: 有效天数(0 = 永久有效,过期后不可兑换) + """ + count = max(1, min(int(count), 500)) + prefix = ''.join(ch for ch in (prefix or 'VIP') if ch.isalnum()).upper()[:10] or 'VIP' + expire_at = int(time.time()) + int(valid_days) * 86400 if valid_days and int(valid_days) > 0 else 0 + codes = [] + with _DB_LOCK: + conn = get_conn() + try: + for _ in range(count): + code = prefix + '-' + secrets.token_hex(5).upper() + conn.execute( + 'INSERT INTO cards (code, minutes, created_by, created_at, note, expire_at) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + (code, int(credits), created_by, int(time.time()), note or '', expire_at)) + codes.append(code) + conn.commit() + finally: + conn.close() + return codes + + +def list_cards(limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]: + """卡密列表(F85:支持分页 limit/offset,管理路由透传)""" + limit = max(1, min(int(limit), 500)) + offset = max(0, int(offset)) + rows = _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset)) + total_row = _q('SELECT COUNT(*) AS c FROM cards') + total = int(total_row[0]['c']) if total_row else 0 + now = time.time() + cards = [] + for r in rows: + d = dict(r) + # 过期判定以服务端时钟为准(前端时钟偏差会误标已过期/未使用) + d['expired'] = bool(d.get('expire_at') and d['expire_at'] < now) + cards.append(d) + return cards, total + + +def set_card_disabled(code: str, disabled: bool) -> None: + """禁用/恢复未使用卡密(已使用卡密无禁用意义,拒绝操作)""" + code = (code or '').strip().upper() + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT used_by, disabled FROM cards WHERE code = ?', (code,)).fetchone() + if not row: + raise ValueError('卡密不存在') + if row['used_by']: + raise ValueError('卡密已被使用,无需禁用') + if bool(row['disabled']) == bool(disabled): + raise ValueError('卡密已处于该状态') + conn.execute('UPDATE cards SET disabled = ? WHERE code = ?', + (1 if disabled else 0, code)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def delete_card(code: str) -> None: + """删除未使用卡密(已使用卡密含兑换凭证:使用者/时间,按 owner 规则不可删)""" + code = (code or '').strip().upper() + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT used_by FROM cards WHERE code = ?', (code,)).fetchone() + if not row: + raise ValueError('卡密不存在') + if row['used_by']: + raise ValueError('已使用的卡密不可删除(保留兑换凭证)') + conn.execute('DELETE FROM cards WHERE code = ?', (code,)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def redeem_card(code: str, username: str) -> Tuple[int, int]: + """兑换卡密,返回 (卡密次数, 新剩余次数)(F25:标记已用与加余额同事务,失败整体回滚不吞卡)""" + code = (code or '').strip().upper() + with _DB_LOCK: + conn = get_conn() + try: + row = conn.execute('SELECT * FROM cards WHERE code = ?', (code,)).fetchone() + if not row: + raise ValueError('卡密不存在') + if row['used_by']: + raise ValueError('卡密已被使用') + if row['disabled']: + raise ValueError('卡密已被禁用,请联系管理员') + if row['expire_at'] and row['expire_at'] < time.time(): + raise ValueError('卡密已过期') + # 卡密标记 + 余额增加同事务:任一失败整体回滚,卡密可重试兑换。 + # WHERE 带 used_by=""/disabled=0 防重标记 + rowcount 校验:命中 0 行 + # (跨进程并发抢卡)立即回滚报错,绝不出现"未标记已用却加了余额" + cur = conn.execute( + 'UPDATE cards SET used_by = ?, used_at = ? ' + 'WHERE code = ? AND used_by = "" AND disabled = 0', + (username, int(time.time()), code)) + if cur.rowcount != 1: + raise ValueError('卡密已被使用') + urow = conn.execute('SELECT role, duration_seconds FROM users WHERE username = ?', + (username,)).fetchone() + if not urow: + raise ValueError('用户不存在') + minutes = int(row['minutes']) + remaining = int(urow['duration_seconds'] or 0) + if urow['role'] == 'admin' or remaining == -1: + remaining = -1 + else: + remaining = remaining + minutes + conn.execute('UPDATE users SET duration_seconds = ? WHERE username = ?', + (remaining, username)) + conn.commit() + return minutes, remaining + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +# ============ 公告 ============ + + +def get_announcement() -> Dict[str, Any]: + rows = _q('SELECT * FROM announcements WHERE id = 1') + return dict(rows[0]) if rows else {'title': '', 'content': '', 'popup_mode': 'off'} + + +def save_announcement(title: str, content: str, popup_mode: str) -> None: + if popup_mode not in ('off', 'every', 'once'): + popup_mode = 'off' + # F81:版本号精确到毫秒,同秒多次保存也能区分(once 已读判断依赖该字段) + updated = time.strftime('%Y-%m-%d %H:%M:%S') + f'.{int(time.time() * 1000) % 1000:03d}' + _x('UPDATE announcements SET title = ?, content = ?, popup_mode = ?, updated_at = ? WHERE id = 1', + (title, content, popup_mode, updated)) + + +# ============ 设置(键值) ============ + + +def get_setting(key: str, default: str = '') -> str: + rows = _q('SELECT value FROM settings WHERE key = ?', (key,)) + return rows[0]['value'] if rows else default + + +def set_setting(key: str, value: str) -> None: + _x('INSERT INTO settings (key, value) VALUES (?, ?) ' + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value', (key, str(value))) + + +# ============ 数据库备份 ============ + + +# 备份命名专用锁:create_backup 不再持有全局 _DB_LOCK(不阻塞写请求), +# 但同秒并发的两次备份仍需保证文件名唯一(F44) +_BACKUP_NAME_LOCK = threading.Lock() + + +def create_backup() -> str: + """创建备份(F44:唯一文件名——同秒冲突自动追加序号,不再互相覆盖)。 + + WAL 模式下用独立只读连接走 SQLite 在线备份 API:拿到的是一致性快照, + 且不持有 _DB_LOCK——原实现备份期间阻塞全站写请求,大库时会卡住所有用户。""" + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + with _BACKUP_NAME_LOCK: + base = 'backup_' + time.strftime('%Y%m%d_%H%M%S') + name = base + '.db' + k = 1 + while (BACKUP_DIR / name).exists(): + name = f'{base}-{k}.db' + k += 1 + # 占位创建目标文件(锁内),保证并发下同秒命名仍唯一 + dest = sqlite3.connect(str(BACKUP_DIR / name)) + conn = get_conn() + try: + try: + conn.backup(dest) + finally: + dest.close() + # 第 93 条(P2-3):任何备份产生后统一裁剪(手动/自动/上传同配额) + try: + prune_backups(20, 5) + except Exception: + pass + return name + except Exception: + # 备份失败:清掉占位文件,不留半个坏备份 + try: + (BACKUP_DIR / name).unlink(missing_ok=True) + except OSError: + pass + raise + finally: + conn.close() + + +def list_backups() -> List[Dict[str, Any]]: + """备份列表(F44:按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)""" + if not BACKUP_DIR.exists(): + return [] + out = [] + for p in BACKUP_DIR.glob('*.db'): + st = p.stat() + out.append({'name': p.name, 'size': st.st_size, + 'time': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(st.st_mtime)), + '_mtime': st.st_mtime}) + out.sort(key=lambda x: x['_mtime'], reverse=True) + for o in out: + o.pop('_mtime', None) + return out + + +def backup_path(name: str) -> Optional[Path]: + name = Path(name).name # 防目录穿越 + p = BACKUP_DIR / name + return p if p.exists() and p.suffix == '.db' else None + + +def _backup_is_valid_db(src: Path) -> bool: + """深度校验备份文件(F43):SQLite 头 + 能真正打开 + 含 users 表,三者缺一不可""" + try: + with open(src, 'rb') as f: + if f.read(16) != b'SQLite format 3\x00'[:16]: + return False + probe = sqlite3.connect(f'file:{src}?mode=ro', uri=True) + try: + tables = {r[0] for r in probe.execute("SELECT name FROM sqlite_master WHERE type='table'")} + return 'users' in tables + finally: + probe.close() + except Exception: + return False + + +# S5:恢复前静止钩子——由应用层注册(取消全部在途下载任务),避免 worker 按旧库账目结算 +_before_restore_hook = None + + +def register_before_restore_hook(fn) -> None: + global _before_restore_hook + _before_restore_hook = fn + + +def restore_backup(name: str) -> None: + """恢复备份(F43:先验证、留回滚、后替换、失败自动回滚,源备份文件保留不移动) + S5:恢复前先通过钩子取消全部在途任务(worker 不再引用旧库用户结算)""" + src = backup_path(name) + if not src: + raise ValueError('备份文件不存在') + if not _backup_is_valid_db(src): + raise ValueError('不是有效的站点数据库备份(校验失败,未做任何修改)') + if _before_restore_hook is not None: + try: + _before_restore_hook() + except Exception: + pass + safety = BACKUP_DIR / ('pre_restore_' + time.strftime('%Y%m%d_%H%M%S') + '.db') + with _DB_LOCK: + # 0) 先尽力清 WAL 并合并进主库(被占用导致 checkpoint 忙时明确失败,不假装成功); + # 必须在留安全副本之前做——否则副本缺 WAL 里未合并的最近提交,回滚点不完整 + conn = get_conn() + try: + row = conn.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone() + busy = bool(row and row[0] == 1) # 1 = busy + finally: + conn.close() + if busy: + raise RuntimeError('数据库当前有其他读写在途(checkpoint 忙),请稍后重试恢复') + # 1) checkpoint 后留安全副本(包含全部已提交数据,可回滚) + shutil.copy2(str(DB_PATH), str(safety)) + # 2) 源备份复制到临时文件(不动源备份本身) + tmp = DB_PATH.with_suffix('.db.restore_tmp') + shutil.copy2(str(src), str(tmp)) + # 3) 原子替换 + 立即校验 + os.replace(str(tmp), str(DB_PATH)) + try: + init_db() + except Exception: + # 替换后初始化失败:回滚安全副本,恢复原库 + shutil.copy2(str(safety), str(DB_PATH)) + init_db() + raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') + # 第 94 条(P3-4):裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写 + try: + prune_backups(20, 5) + except Exception: + pass + + +def delete_backup(name: str) -> bool: + p = backup_path(name) + # 第 93 条(P2-3):允许删除 pre_restore_ 安全副本(配额裁剪已保证至少保留 + # 最近 5 份;原实现一刀切拒删导致恢复频繁时无界累积且管理员手动也删不掉) + if p: + p.unlink() + return True + return False + + +# 自动备份线程 +_backup_thread_started = False +_BACKUP_ERR_TS = {'ts': 0.0, 'n': 0} # 备份失败限频记录(P2-6:失败不能零痕迹) + + +def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None: + """备份配额裁剪(第 93 条 P2-3:从自动备份循环抽出为独立函数)—— + 非pre_restore 保留最近 N 份、pre_restore_ 安全副本保留最近 M 份。 + 在 create_backup/上传备份/恢复备份后统一调用(原实现只在自动备份循环里 + 裁剪,手动备份/上传/恢复会让 data/backups 无界累积)。""" + baks = [b for b in list_backups() if not b['name'].startswith('pre_restore_')] + for b in baks[keep_normal:]: + try: + delete_backup(b['name']) + except Exception: + pass + pre = [b for b in list_backups() if b['name'].startswith('pre_restore_')] + for b in pre[keep_pre_restore:]: + try: + (BACKUP_DIR / b['name']).unlink() + except OSError: + pass + + +def _auto_backup_loop() -> None: + while True: + try: + interval = float(get_setting('backup_interval_hours', '0') or 0) + # F46:非有限值(NaN/inf)或负数一律视为关闭 + if not math.isfinite(interval): + interval = 0 + set_setting('backup_interval_hours', '0') + if interval > 0: + last = float(get_setting('last_backup_ts', '0') or 0) + if not math.isfinite(last): + last = 0 + if time.time() - last >= interval * 3600: + create_backup() + set_setting('last_backup_ts', str(time.time())) + # 仅保留最近 20 份(F44:按修改时间,不按文件名) + # S7:pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份 + prune_backups(20, 5) + except Exception: + # P2-6:定时备份失败绝不能静默(磁盘满/权限问题时 owner 以为有备份、 + # 出事时没有)——限频告警,每 10 分钟最多一条(对齐监控线程口径) + import traceback as _tb + _now = time.time() + _BACKUP_ERR_TS['n'] += 1 + if _now - _BACKUP_ERR_TS['ts'] > 600: + print(f'[警告] 自动备份失败(近 10 分钟累计 {_BACKUP_ERR_TS["n"]} 次): ' + f'{_tb.format_exc()[-400:]}', file=sys.stderr) + _BACKUP_ERR_TS['ts'] = _now + _BACKUP_ERR_TS['n'] = 0 + time.sleep(600) + + +def start_auto_backup() -> None: + global _backup_thread_started + if _backup_thread_started: + return + t = threading.Thread(target=_auto_backup_loop, daemon=True) + t.start() + # F44:仅在真实启动成功后置位,失败可重试 + _backup_thread_started = True + + +# ============ 免费体验(设备识别) ============ + + +def device_used(device_id: str) -> bool: + if not device_id: + return False + rows = _q("SELECT id FROM users WHERE trial_device = ? AND role != 'admin' LIMIT 1", (device_id,)) + return bool(rows) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f8017fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + netease-url: + build: + context: . + dockerfile: Dockerfile + # F59:.env 缺失时不再启动失败(required 属性 compose v2.24+ 支持;老版本可删掉该行) + env_file: + - path: .env + required: false + environment: + - TZ=Asia/Shanghai # 未提供 .env 时的默认时区(F59:文档"默认即可运行"现在成立) + - ENABLE_SECURE_COOKIE=${ENABLE_SECURE_COOKIE:-} # HTTPS 反代上线后设 1(见 .env.example) + ports: + - "5001:5000" # 对外 5001 端口(容器内 Flask 监听 5000) + restart: unless-stopped + volumes: + - ./cookie.txt:/app/cookie.txt # 全局管理员 Cookie(持久化,重建不丢失;需先创建文件) + - ./user_cookie.txt:/app/user_cookie.txt # 游客个人 Cookie(持久化;需先创建文件) + - ./downloads:/app/downloads # 下载文件目录 + - ./data:/app/data # 站点数据库 / 备份(认证系统) + # 注:Dockerfile 以非 root(appuser, UID 10001)运行;宿主机需保证 + # ./downloads 与 ./data 对 UID 10001 可写(如 sudo chown -R 10001:10001 downloads data) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..3642bea --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# F59:exec 让 python 成为 PID 1 直接接收 SIGTERM,容器停止不再经 shell 转发而超时 +# 第 93 条(P2-4):compose 挂载 ./user_cookie.txt 时若宿主不存在该文件,Docker 会 +# 在启动前创建同名**目录**并挂载——容器内 touch 只对无卷 docker run 有效。 +# 第 97 条(P3):检测到同名目录直接报错退出(fail-fast),不再静默带病运行 +for f in user_cookie.txt cookie.txt; do + if [ -d "/app/$f" ]; then + echo "FATAL: /app/$f 是目录(宿主机缺文件时 Docker 挂载会建同名目录)。请在宿主机先 touch 该文件再启动。" >&2 + exit 1 + fi + [ -f "/app/$f" ] || touch "/app/$f" +done +exec python3 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..85d2a0f --- /dev/null +++ b/main.py @@ -0,0 +1,4670 @@ +# -*- coding: utf-8 -*- +"""网易云音乐API服务主程序 + +提供网易云音乐相关API服务,包括: +- 歌曲信息获取 +- 音乐搜索 +- 歌单和专辑详情 +- 音乐下载 +- 健康检查 +""" + +import base64 +import json +import collections +import hashlib +import hmac +import logging +import os +import re +import requests +import secrets +import shutil +import sys +import threading +import time +import traceback +import uuid +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Any, Optional, Tuple, List +from urllib.parse import quote, urlsplit, parse_qs +from flask import Flask, request, send_file, render_template, Response, jsonify, stream_with_context, make_response, g + +try: + from music_api import ( + NeteaseAPI, APIException, QualityLevel, + url_v1, name_v1, lyric_v1, search_music, + playlist_detail, album_detail, + toplist_list, artist_songs, song_comments, + account_info, user_playlists, detect_vip_by_probe, + playlist_subscribe, album_subscribe, post_comment, + load_cookies, + ) + from music_downloader import MusicDownloader, DownloadException, DownloadCanceled, CountingSemaphore, fetch_cover_bytes + from qr_login import QRLoginClient + import podcast_api + import auth_system + from auth_routes import register_auth_routes +except ImportError as e: + # 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印 + print("ImportError: " + str(e)) + print("Please make sure all dependency modules exist and are importable.") + sys.exit(1) + + +# 站点统一版本号(P3-12/P3-22:/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码) +APP_VERSION = '2.4.0' + +# 交付结果保留期(P1-3/D-1:服务端与前端「领取」入口统一 35 分钟——前端入口保留 35 分钟, +# 原 30 分钟会在最后 5 分钟让领取必 404)。终态任务记录、任务目录、下载缓存均按此回收。 +RESULT_RETENTION_SECONDS = 35 * 60 + + +@dataclass +class APIConfig: + """API配置类""" + host: str = '0.0.0.0' + port: int = 5000 + debug: bool = False + downloads_dir: str = 'downloads' + # 第 98 条:max_file_size/request_timeout 两个架空字段已删除——请求体上限真实 + # 生效点是 _body_size_limit(业务 2MB/备份 200MB),上游超时在 music_api 各调用点 + log_level: str = 'INFO' + cors_origins: str = (os.environ.get('CORS_ORIGINS') or '*').strip() or '*' # 第 100 条:可经环境变量收敛同源(空值回落默认) + + +class APIResponse: + """API响应工具类""" + + @staticmethod + def success(data: Any = None, message: str = 'success', status_code: int = 200) -> Tuple[Dict[str, Any], int]: + """成功响应""" + response = { + 'status': status_code, + 'success': True, + 'message': message + } + if data is not None: + response['data'] = data + return response, status_code + + @staticmethod + def error(message: str, status_code: int = 400, error_code: str = None) -> Tuple[Dict[str, Any], int]: + """错误响应""" + response = { + 'status': status_code, + 'success': False, + 'message': message + } + if error_code: + response['error_code'] = error_code + return response, status_code + + +# 短链展开结果缓存:用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) +_SHORTLINK_CACHE: Dict[str, str] = {} +_SHORTLINK_CACHE_LOCK = threading.Lock() + + +def _expand_shortlink(url: str) -> str: + """展开 163cn.tv 短链(带进程内缓存;失败原样返回)""" + with _SHORTLINK_CACHE_LOCK: + hit = _SHORTLINK_CACHE.get(url) + if hit is not None: + return hit + expanded = url + try: + import requests + response = requests.get(url, allow_redirects=False, timeout=10) + expanded = response.headers.get('Location', url) + except Exception: + expanded = url + # 第 97 条(P3):展开失败(Location 缺失/网络异常,expanded 原样等于 url)不写 + # 缓存——旧口径一次网络抖动会让该分享链接在整个缓存存续期内必然解析失败 + if expanded != url: + with _SHORTLINK_CACHE_LOCK: + if len(_SHORTLINK_CACHE) > 500: + _SHORTLINK_CACHE.clear() + _SHORTLINK_CACHE[url] = expanded + return expanded + + +class MusicAPIService: + """音乐API服务类""" + + def __init__(self, config: APIConfig): + self.config = config + self.logger = self._setup_logger() + self.netease_api = NeteaseAPI() + # F97:下载目录传给下载器——原实现下载器固定 downloads, + # 服务配置指向自定义目录时音乐落盘位置与配置分离 + self.downloader = MusicDownloader(download_dir=config.downloads_dir) + + # 创建下载目录 + self.downloads_path = Path(config.downloads_dir) + self.downloads_path.mkdir(exist_ok=True) + + self.logger.info(f"音乐API服务初始化完成,下载目录: {self.downloads_path.absolute()}") + + def _setup_logger(self) -> logging.Logger: + """设置日志记录器""" + logger = logging.getLogger('music_api') + logger.setLevel(getattr(logging, self.config.log_level.upper())) + + if not logger.handlers: + # 控制台处理器 + console_handler = logging.StreamHandler() + console_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + # 文件处理器(轮转:单文件 5MB,保留 3 份,防止长期运行无限膨胀) + try: + from logging.handlers import RotatingFileHandler + file_handler = RotatingFileHandler('music_api.log', maxBytes=5 * 1024 * 1024, + backupCount=3, encoding='utf-8') + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' + ) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + except Exception as e: + logger.warning(f"无法创建日志文件: {e}") + + return logger + + def _get_cookies(self) -> Dict[str, str]: + """直接从 cookie.txt 读取并解析为 dict""" + try: + cookies = load_cookies() + if not cookies: + self.logger.warning("cookie.txt 为空或不存在,部分接口(VIP 音质)将不可用") + return cookies + except Exception as e: + self.logger.error(f"读取 cookie.txt 异常: {e}") + return {} + + def _extract_music_id(self, id_or_url: str) -> str: + """提取音乐ID(F7/F74:正规 URL 解析替代子串/删除非数字启发式) + - 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF) + - music.163.com 链接用 parse_qs 取 id 参数(参数顺序无关) + """ + raw = str(id_or_url or '').strip() + try: + # 短链展开:仅接受"整个输入就是 163cn.tv 链接"(F7:子串包含不算) + if re.match(r'^https?://(www\.)?163cn\.tv/\S+$', raw, re.IGNORECASE): + raw = _expand_shortlink(raw) + + # 网易云链接:正规解析 query 取 id(F74:不再 find('id=') 误吃 userid= 尾部) + if 'music.163.com' in raw: + try: + q = parse_qs(urlsplit(raw).query) + for key in ('id', 'songid'): + if q.get(key): + return str(q[key][0]).strip() + except Exception: + pass + # 兜底:老格式 #/song?id=xxx 的片段部分 + m = re.search(r'[?&#]id=(\d+)', raw) + if m: + return m.group(1) + + # 纯数字/普通文本直接返回 + return raw + except Exception as e: + self.logger.error(f"提取音乐ID失败: {e}") + return str(id_or_url or '').strip() + + def _format_file_size(self, size_bytes) -> str: + """格式化文件大小(上游 size 可能为 null/垃圾值:容错返回"大小未知"而非 500)""" + try: + size = float(size_bytes or 0) + except (TypeError, ValueError): + return "大小未知" + if size <= 0: + return "0B" + + units = ["B", "KB", "MB", "GB", "TB"] + unit_index = 0 + + while size >= 1024.0 and unit_index < len(units) - 1: + size /= 1024.0 + unit_index += 1 + + return f"{size:.2f}{units[unit_index]}" + + def _get_quality_display_name(self, quality: str) -> str: + """获取音质显示名称""" + quality_names = { + 'standard': "标准音质", + 'exhigh': "极高音质", + 'lossless': "无损音质", + 'hires': "Hi-Res音质", + 'sky': "沉浸环绕声", + 'jyeffect': "高清环绕声", + 'jymaster': "超清母带", + 'dolby': "杜比全景声" + } + return quality_names.get(quality, f"未知音质({quality})") + + def _validate_request_params(self, required_params: Dict[str, Any]) -> Optional[Tuple[Dict[str, Any], int]]: + """验证请求参数""" + for param_name, param_value in required_params.items(): + if not param_value: + return APIResponse.error(f"参数 '{param_name}' 不能为空", 400) + return None + + def _safe_get_request_data(self) -> Dict[str, Any]: + """安全获取请求数据""" + try: + if request.method == 'GET': + return dict(request.args) + else: + # 优先使用JSON数据,然后是表单数据 + json_data = request.get_json(silent=True) or {} + form_data = dict(request.form) + # 合并数据,JSON优先 + return {**form_data, **json_data} + except Exception as e: + self.logger.error(f"获取请求数据失败: {e}") + return {} + + +# 创建Flask应用和服务实例 +config = APIConfig() +app = Flask(__name__) +api_service = MusicAPIService(config) + +# ============ 站点认证系统(SQLite + 签名会话) ============ +app.secret_key = auth_system.get_secret_key() + + +def _quiesce_tasks_for_restore() -> None: + """S5:恢复数据库前取消全部在途任务(worker 不再引用旧库用户结算)""" + with DOWNLOAD_TASKS_LOCK: + for task in DOWNLOAD_TASKS.values(): + if task.get('status') in ('queued', 'running', 'paused'): + task['status'] = 'canceled' + task['msg'] = '数据库恢复中,任务已取消(费用按实际交付结算)' + task['completed_at'] = time.time() + try: + task['cancel'].set() + except Exception: + pass + + +auth_system.register_before_restore_hook(_quiesce_tasks_for_restore) +app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 7 # 会话 7 天 +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF 加固:跨站请求不携带会话 Cookie +# F11:部署在 HTTPS 反代(如 Caddy)后设置环境变量 ENABLE_SECURE_COOKIE=1, +# 会话 Cookie 仅经 HTTPS 传输(HTTP 部署下不要开启,否则浏览器拒收 Cookie) +if os.environ.get('ENABLE_SECURE_COOKIE', '').strip() in ('1', 'true', 'yes'): + app.config['SESSION_COOKIE_SECURE'] = True +auth_system.init_db() +register_auth_routes(app) + + +# ============ 公网限流(按 IP 滑动窗口,防刷防爆破) ============ +_RATE_BUCKETS = {} +_RATE_LOCK = threading.Lock() + + +def _rate_client_ip() -> str: + # F08:仅当直连方是可信反代时才信任代理头(与 auth_system._client_ip 同策略)。 + # 第 91 条 P2-4:可信范围从"本机/内网"收窄为"仅回环"——绑 0.0.0.0 时内网任意 + # 主机直连可伪造 XFF 轮换绕过全部限流(动态实测实锤) + remote = request.remote_addr or '-' + ip = remote + if remote != '-' and auth_system._is_loopback_ip(remote): + # 第 97 条(P2-1):统一走 auth_system._client_ip_from_headers——XFF 最右段 + # 优先(客户端伪造的最左段与直连伪造均无效),最右段为回环(PassNAT 隧道 + # 场景)回落 X-Real-IP;取值做可解析校验,垃圾值回落直连地址 + ip = auth_system._client_ip_from_headers(remote) + return ip + + +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 + + +@app.before_request +def _body_size_limit(): + """P2-16:POST 请求体上限。默认 2MB(正常业务请求都在 KB 级);数据库备份上传 + 单独放宽到 200MB(P3-11)。按 Content-Length 直接短路返回 413 JSON—— + 原实现的 MAX_CONTENT_LENGTH 方式,413 会被 silent=True 的 JSON 解析吞成 400。 + R5-1:无 Content-Length 的 chunked 流式请求同样拒绝——本站前端从不发 chunked, + 放行等于给公网开无限内存放大面(Werkzeug 逐线程全量读取)。""" + limit = 200 * 1024 * 1024 if request.path == '/admin/site/backup/upload' else 2 * 1024 * 1024 + cl = request.content_length or 0 + if cl > limit: + return APIResponse.error("请求体过大,请减少提交内容后重试", 413) + if request.method == 'POST' and cl == 0 and \ + 'chunked' in (request.headers.get('Transfer-Encoding') or '').lower(): + return APIResponse.error("请求体过大,请减少提交内容后重试", 413) + + +@app.before_request +def _rate_limit_guard(): + """公网接口限流(按 IP):免费业务 30 次/分,下载提交 20 次/分,登录 10 次/分。 + 试听流 /audio_stream 与进度轮询不受限(保证播放与任务状态刷新)。""" + if request.method == 'OPTIONS': + return None + path = request.path + if path == '/auth/login': + if not _rate_ok('login:' + _rate_client_ip(), 10, 60): + return APIResponse.error('尝试过于频繁,请稍后再试', 429) + return None + if path in ('/auth/admin_login/email', '/auth/admin_login/verify'): + # 第 86 条:管理员两步验证 Step2/3 共桶 10 次/分/IP(Step1 走 /auth/login 桶; + # 票据侧另有 5 次尝试上限) + if not _rate_ok('adminlogin:' + _rate_client_ip(), 10, 60): + return APIResponse.error('尝试过于频繁,请稍后再试', 429) + return None + if path == '/auth/redeem': + # R5-P3:卡密爆破在线虽不可行,但登录号无限速刷失败会灌掉审计日志——独立 5 次/分桶 + if not _rate_ok('redeem:' + _rate_client_ip(), 5, 60): + return APIResponse.error('操作过于频繁,请稍后再试', 429) + return None + if path == '/auth/forgot_password': + # R5-P3:找回密码涉及验码+改密,独立 5 次/分桶(配合验证码 5 次错作废) + if not _rate_ok('forgot:' + _rate_client_ip(), 5, 60): + return APIResponse.error('操作过于频繁,请稍后再试', 429) + return None + if path == '/auth/register': + # 注册接口本身不在统计名单里,但验证码是 6 位数字且消费是原子删除—— + # 不限流就存在无速率上限的暴力猜测面,这里按 IP 10 次/分钟封住 + if not _rate_ok('reg:' + _rate_client_ip(), 10, 60): + return APIResponse.error('注册尝试过于频繁,请稍后再试', 429) + return None + if path == '/api/task/heartbeat': + # 第 98 条:心跳不在统计名单且原零限速——正常节奏 15s/次(4 次/分), + # 60 次/分/IP 的宽松桶只封滥用,不影响合法客户端 + if not _rate_ok('hb:' + _rate_client_ip(), 60, 60): + return APIResponse.error('请求过于频繁,请稍后再试', 429) + return None + if path.lower() not in _COUNTED_LOWER: + return None + # R5-P3:/download/result 等领取端点不计入 20 次/分付费提交桶——连续领取多个 + # 文件(批量逐首模式)会被自家限流误伤;领取免费且归属校验在端点内 + if path.startswith('/download') and '/result' not in path: + ok = _rate_ok('dl:' + _rate_client_ip(), 20, 60) + elif path.startswith('/podcast/download'): + ok = _rate_ok('dl:' + _rate_client_ip(), 20, 60) + else: + # 30/分:实测网易云搜索接口的快速突发容忍度就在 30 次附近, + # 超过的请求会被网易云拒绝并变成 500——限流阈值对齐后用户只会看到干净的 429 + ok = _rate_ok('free:' + _rate_client_ip(), 30, 60) + if not ok: + return APIResponse.error('请求过于频繁,请稍后再试', 429) + + +@app.before_request +def _count_request(): + """统计各接口请求数(仅统计真实业务接口)。C8:键统一小写,别名不再分开计数""" + path = request.path.lower() + if path in _COUNTED_LOWER: + with _STATS_LOCK: + info = REQUEST_STATS.setdefault(path, {'count': 0, 'last': None}) + info['count'] += 1 + info['last'] = time.strftime('%Y-%m-%d %H:%M:%S') + + +@app.route('/api/topen', methods=['GET', 'POST']) +def api_topen(): + """接口请求统计(实时)(F112:锁内快照,杜绝 total 与明细不一致或迭代中 500)""" + try: + with _STATS_LOCK: + snapshot = {p: dict(v) for p, v in REQUEST_STATS.items()} + endpoints = [] + total = sum(v['count'] for v in snapshot.values()) + for path, info in snapshot.items(): + endpoints.append({ + 'path': path, + 'name': ENDPOINT_NAMES.get(path, path), + 'count': info['count'], + 'percent': round(info['count'] / total * 100, 1) if total else 0, + 'last': info['last'], + }) + endpoints.sort(key=lambda x: x['count'], reverse=True) + return APIResponse.success({ + 'total': total, + 'endpoint_count': len(endpoints), + 'max_count': endpoints[0]['count'] if endpoints else 0, + 'endpoints': endpoints, + }, "获取请求统计成功") + except Exception as e: + api_service.logger.error(f"获取请求统计异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/api/queue', methods=['GET', 'POST']) +def api_queue(): + """实时排队状态(前端悬浮框轮询):排队人数 + 预计等待时间""" + try: + dl_sem = api_service.downloader._download_semaphore + avg_batch = None + with BATCH_DURATIONS_LOCK: + if BATCH_DURATIONS: + avg_batch = sum(BATCH_DURATIONS) / len(BATCH_DURATIONS) + data = { + 'batch_capacity': BATCH_ZIP_SEM.capacity, + 'batch_active': BATCH_ZIP_SEM.active, + 'batch_waiting': BATCH_ZIP_SEM.waiting, + 'download_capacity': dl_sem.capacity, + 'download_active': dl_sem.active, + 'download_waiting': dl_sem.waiting, + 'avg_batch_seconds': round(avg_batch, 1) if avg_batch else None, + } + return APIResponse.success(data, "获取排队状态成功") + except Exception as e: + api_service.logger.error(f"获取排队状态异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/api/batch_progress', methods=['GET', 'POST']) +@auth_system.login_required(free=True) +def api_batch_progress(): + """下载任务进度(前端悬浮窗轮询):已处理 X/Y + 百分比 + 预计剩余时间。 + F05:仅任务本人可查询;F38:终态保留期从完成时刻起算;F95:未知总数不是 100%""" + try: + data = api_service._safe_get_request_data() + task_id = str(data.get('task_id') or '').strip() + if not task_id: + return APIResponse.error("缺少 task_id 参数") + + now = time.time() + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS.get(task_id) + + if not task: + return APIResponse.error("任务不存在或已过期", 404) + if not _task_owned_by_current_user(task): + return APIResponse.error("无权查询该任务", 403) + + total = task.get('total', 0) + done = task.get('done', 0) + # F95:总数未知(total=0)的运行中任务进度应为 0%,不是 100%; + # 单曲任务运行中优先用下载器回报的字节级 live_percent(进度条不再卡 0%) + live = task.get('live_percent') + if task.get('status') == 'running' and isinstance(live, (int, float)): + percent = round(min(99.0, max(0.0, float(live))), 1) + elif total: + percent = round(done / total * 100, 1) + else: + percent = 100.0 if task.get('status') in ('done',) else 0.0 + elapsed = now - task.get('start', now) + # 预计剩余:按当前已完成进度速率外推(仅运行中且有进度时计算) + eta = None + if task.get('status') == 'running' and done > 0 and elapsed > 0 and total: + rate = done / elapsed + if rate > 0: + eta = round((total - done) / rate, 1) + result = task.get('result') or {} + has_result = bool(result.get('zip_path') or result.get('file_path')) + return APIResponse.success({ + 'task_id': task_id, + 'type': task.get('type', 'batch'), + 'total': total, + 'done': done, + 'failed': task.get('failed', 0), + 'percent': percent, + 'status': task.get('status', 'running'), + 'elapsed': round(elapsed, 1), + 'eta': eta, + 'msg': task.get('msg', ''), + 'refunded': task.get('refunded', 0), + # F24:取消/失败但已有部分成果 → 前端凭此直接领取 + 'has_result': has_result, + }, "获取任务进度成功") + except Exception as e: + api_service.logger.error(f"获取任务进度异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +# R5-P3:取消是状态变更动作,收敛 POST-only(SameSite=Lax 下顶层导航 GET 可携 Cookie +# 触发取消自己的任务,属滋扰面);前端既有调用就是 POST,零兼容影响 +@app.route('/api/task/cancel', methods=['POST']) +@auth_system.login_required(free=True) +def api_task_cancel(): + """取消下载任务(批量打包 / 单曲下载)—— 仅任务本人的站点账号可取消(F05)""" + try: + data = api_service._safe_get_request_data() + task_id = str(data.get('task_id') or '').strip() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + if not task_id: + return APIResponse.error("缺少 task_id 参数") + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS.get(task_id) + if not task: + return APIResponse.error("任务不存在或已过期", 404) + # 归属校验:站点用户必须匹配(device_id 仅作辅助兼容) + if not _task_owned_by_current_user(task): + return APIResponse.error("无权取消该任务", 403) + if task.get('status') in ('done', 'error', 'canceled'): + return APIResponse.error(f"任务已结束({task.get('status')})", 400) + task['status'] = 'canceled' + task['msg'] = '任务已取消' + task['completed_at'] = time.time() # F38:手工取消也记录完成时刻(worker 收尾会再刷新) + task['cancel'].set() + return APIResponse.success({'task_id': task_id, 'status': 'canceled'}, "已请求取消") + except Exception as e: + api_service.logger.error(f"取消任务异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/api/task/heartbeat', methods=['GET', 'POST']) +@auth_system.login_required(free=True) +def api_task_heartbeat(): + """设备心跳:保持任务活跃;暂停中的任务自动恢复(重新进入排队)。 + F05:恢复操作仅对本人任务生效""" + try: + data = api_service._safe_get_request_data() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + if not device_id: + return APIResponse.error("缺少 device_id 参数") + _touch_device(device_id) + me = auth_system.current_user() + my_uid = int(me['id']) if me else 0 + resumed = 0 + with DOWNLOAD_TASKS_LOCK: + for task in DOWNLOAD_TASKS.values(): + if (task.get('device_id') == device_id + and my_uid and int(task.get('user_id') or 0) == my_uid + and task.get('status') == 'paused'): + task['status'] = 'queued' + task['msg'] = '恢复中...' + task['pause'].clear() # 唤醒暂停中的 worker 继续下载 + resumed += 1 + # 清理过期设备记录 + now = time.time() + with DEVICE_LAST_SEEN_LOCK: + stale = [d for d, ts in DEVICE_LAST_SEEN.items() if now - ts > 86400] + for d in stale: + DEVICE_LAST_SEEN.pop(d, None) + return APIResponse.success({'resumed': resumed, 'online': True}, "心跳成功") + except Exception as e: + api_service.logger.error(f"心跳异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/api/task/list', methods=['GET', 'POST']) +@auth_system.login_required(free=True) +def api_task_list(): + """获取本设备未结束的任务列表(重新打开网站时恢复任务悬浮窗)。 + F05:仅返回当前登录用户本人的任务""" + try: + data = api_service._safe_get_request_data() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + if not device_id: + return APIResponse.error("缺少 device_id 参数") + _touch_device(device_id) + me = auth_system.current_user() + my_uid = int(me['id']) if me else 0 + my_tasks = [] + with DOWNLOAD_TASKS_LOCK: + for tid, task in DOWNLOAD_TASKS.items(): + if (task.get('device_id') == device_id + and my_uid and int(task.get('user_id') or 0) == my_uid + and task.get('status') in ('queued', 'running', 'paused')): + my_tasks.append({ + 'task_id': tid, + 'type': task.get('type', 'batch'), + # P3-33:播客打包 type 同为 batch,用播客特有键区分 kind, + # 供前端恢复时显示正确单位(首/集) + 'kind': ('podcast' if ('program_id' in (task.get('params') or {}) + or 'radio_name' in (task.get('params') or {}) + or (task.get('params') or {}).get('radio_id')) + else 'music'), + 'total': task.get('total', 0), + 'done': task.get('done', 0), + 'status': task.get('status', 'queued'), + 'msg': task.get('msg', ''), + }) + return APIResponse.success({'tasks': my_tasks}, "获取任务列表成功") + except Exception as e: + api_service.logger.error(f"任务列表异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/api/fe_error', methods=['POST', 'GET']) +def api_fe_error(): + """前端错误日志上报(2026-09-17 v10 新增) + + 用途:收集各浏览器(含老内核/微信 QQ 内置/国产浏览器)的 JS 运行错误、 + Promise 拒绝与资源加载失败,便于排查"某些浏览器 API 行为不一致/样式错位"类问题。 + 安全:每 IP 每分钟 10 次;字段全部截断;仅写服务日志(music_api.log,已轮转),不入库。 + """ + try: + # 独立限流:10 次/分/IP(异常场景下用户连续报错也不会刷爆日志) + if not _rate_ok('fe:' + _rate_client_ip(), 10, 60): + return APIResponse.error('上报过于频繁', 429) + data = api_service._safe_get_request_data() + # P2-3:全部字段与 stack 同口径清洗换行/回车(否则可向 music_api.log 注入伪造多行内容) + msg = str(data.get('msg') or '')[:200].replace('\n', ' | ').replace('\r', '') + etype = str(data.get('type') or 'error')[:20].replace('\n', ' ').replace('\r', '') + stack = str(data.get('stack') or '')[:800].replace('\n', ' | ').replace('\r', '') + page = str(data.get('page') or '')[:120].replace('\n', ' | ').replace('\r', '') + env = str(data.get('env') or '')[:200].replace('\n', ' | ').replace('\r', '') + if not msg: + return APIResponse.error("缺少 msg 参数") + ip = _rate_client_ip() + api_service.logger.error( + f"[前端错误] type={etype} msg={msg} page={page} ip={ip} env={env} stack={stack}") + return APIResponse.success(None, "已记录") + except Exception as e: + api_service.logger.error(f"前端错误上报处理异常: {e}") + return APIResponse.error("记录失败", 500) + +# 旧版管理员口令已废除(2026-09-15):管理员权限仅认站点管理员会话(admin 登录), +# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*) +# 均通过该会话鉴权。 + +# 游客个人登录使用的独立 Cookie 文件(与管理员全局 Cookie 分离)。 +# 第 100 条:这是有意的隔离——QRLoginClient 的默认值恰是全局 cookie.txt, +# 显式传参防误写全局文件;第 91 条起本站任何路径都不再写它(仅构造参数) +USER_COOKIE_FILE = 'user_cookie.txt' + +# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。 +# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。 +# (下载本身的并发已由下载器内的全局信号量控制) +# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。 +BATCH_ZIP_SEM = CountingSemaphore(3) + +# 最近完成批次(成功打包过)的耗时记录,用于估算排队用户的预计等待时间 +BATCH_DURATIONS = collections.deque(maxlen=10) +BATCH_DURATIONS_LOCK = threading.Lock() + +# 下载任务表(批量打包 + 单曲下载统一):task_id -> {type, total, done, failed, status, start, msg, cancel, pause, device_id, result, params} +# 前端通过 /api/batch_progress 轮询进度,支持取消;完成后文件按 35 分钟保留期清理 +DOWNLOAD_TASKS: Dict[str, Dict[str, Any]] = {} +DOWNLOAD_TASKS_LOCK = threading.Lock() + +# 设备在线心跳:device_id -> 最近心跳时间 +# 心跳超时(PAUSE_AFTER_SECONDS)未上线的设备,其批量任务自动暂停,让出排队名额给下一位; +# 重新打开网站会恢复心跳并自动回到队列继续下载 +# 心跳超时超过 CANCEL_AFTER_SECONDS(30 分钟)则自动取消任务并删除音乐文件 +DEVICE_LAST_SEEN: Dict[str, float] = {} +DEVICE_LAST_SEEN_LOCK = threading.Lock() +PAUSE_AFTER_SECONDS = 40 +CANCEL_AFTER_SECONDS = 30 * 60 +HEARTBEAT_INTERVAL = 15 + +# 每用户同时在途(排队/运行/暂停)任务上限与全站任务表上限(F20:资源上限) +MAX_ACTIVE_TASKS_PER_USER = 5 +MAX_TOTAL_TASKS = 200 +# 第 97 条(P2-2):终态任务要留表 35 分钟供领取,容量闸只数在途任务——旧口径把 +# 终态记录计入 200 上限会把全站吞吐封顶在约 5.7 任务/分钟;终态另设更大的滞留 +# 上限保内存有界(1000 ≈ 28 任务/分钟的领取窗口,远高于在途处理能力) +MAX_TERMINAL_TASKS = 1000 + + +def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: str = '') -> Optional[str]: + """任务登记统一入口:归属校验、数量上限、账本承接。失败返回错误描述(str),成功返回 None。 + + - F05:任务必须绑定站点用户(提交接口都有 login_required,这里再取一次会话) + - F20:每用户在途任务与全站任务上限 + - A4:task_id 的碰撞检查、换新与写入在同一把锁内完成(消除 TOCTOU 覆盖窗口) + - A2/B8:把请求级临时账本行改名挂到正式 task_id(worker 结算唯一入口走账本) + """ + user = auth_system.current_user() + if not user: + return '登录已失效,请重新登录' + task['user_id'] = int(user['id']) + task['billing_reserved'] = int(reserved) + with DOWNLOAD_TASKS_LOCK: + tid = (task_id_hint or '').strip() or uuid.uuid4().hex[:12] + # R5-P1(第 77 条):task_id 是 downloads/tasks/ 的路径成分(mkdir/rmtree + # 都用它),客户端值必须强制白名单——含路径分隔符/../绝对路径/超长一律换服务端 uuid + if not re.match(r'^[A-Za-z0-9_-]{1,32}$', tid): + tid = uuid.uuid4().hex[:12] + while tid in DOWNLOAD_TASKS: + tid = uuid.uuid4().hex[:12] + active = sum(1 for t in DOWNLOAD_TASKS.values() + if t.get('user_id') == user['id'] and t.get('status') in ('queued', 'running', 'paused')) + if active >= MAX_ACTIVE_TASKS_PER_USER: + return f'您有 {active} 个任务正在进行,请等待完成或取消后再提交' + # 第 97 条(P2-2):全站容量只数在途任务——终态记录留表 35 分钟属领取窗口, + # 不再挤占在途配额;终态单独用更大的滞留上限保内存有界 + active_total = sum(1 for t in DOWNLOAD_TASKS.values() + if t.get('status') in ('queued', 'running', 'paused')) + if active_total >= MAX_TOTAL_TASKS: + return '当前任务队列已满,请稍后再试' + if len(DOWNLOAD_TASKS) - active_total >= MAX_TERMINAL_TASKS: + return '当前任务队列已满,请稍后再试' + task['task_id'] = tid + DOWNLOAD_TASKS[tid] = task + # 账本承接:请求级临时行(login_required 预扣时创建)改名为正式 task_id; + # 无临时行(unlimited 用户路径)则直接开一行 reserved=0 的账。 + # 第 91 条(P2-2):承接抛异常(SQLite 锁超时等)时回滚任务注册——否则僵尸 + # queued 任务占用户在途名额最长 4 小时(无 worker、无账本行,装饰器只退请求级行) + _gkey = getattr(g, 'billing_ledger_key', None) + try: + if _gkey: + # R5-P3:rename 失败 = task_id 与 90 天保留期内的历史账本行撞名—— + # 复用会把本次预扣挂到已结算的旧行上(结算打到旧行返 0,预扣冻结成孤儿)。 + # 换服务端 uuid 重新注册任务键并重试 rename,彻底消除撞名面。 + renamed = auth_system.ledger_rename(_gkey, tid) + retries = 0 + while not renamed and retries < 5: + retries += 1 + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS.pop(tid, None) + tid = uuid.uuid4().hex[:12] + while tid in DOWNLOAD_TASKS: + tid = uuid.uuid4().hex[:12] + task['task_id'] = tid + DOWNLOAD_TASKS[tid] = task + renamed = auth_system.ledger_rename(_gkey, tid) + if renamed: + g.billing_ledger_key = tid # 后续失败退款由装饰器按"仍 open 才退"处理 + else: + # 第 91 条(P3-1):5 次撞名后不再兜底开新行——那会给同一笔预扣留下 + # 两个 open 账本行(结算/对账各认一行,破坏守恒)。直接失败:任务注册 + # 由下方 except 回滚,装饰器退回请求级预扣行(概率≈0 的极端路径) + raise RuntimeError('任务账本键撞名,请重新提交') + else: + auth_system.ledger_open(tid, user['id'], reserved) + except Exception: + with DOWNLOAD_TASKS_LOCK: + if DOWNLOAD_TASKS.get(tid) is task: + DOWNLOAD_TASKS.pop(tid, None) + raise + return None + + +def _start_task_thread(task_id: str, target, args=()) -> Optional[str]: + """启动任务 worker(F90:启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。 + A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。 + R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死 + (无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。""" + try: + th = threading.Thread(target=target, args=args, daemon=True) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['worker_thread'] = th + th.start() + return None + except Exception as e: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.pop(task_id, None) + if t: + try: + auth_system.ledger_settle(task_id, 0, 'submit_failed') + except Exception: + pass + api_service.logger.error(f"任务线程启动失败: {task_id} {e}") + return f'任务启动失败:{e}' + + +def _task_owned_by_current_user(task: Dict[str, Any]) -> bool: + """任务归属校验(F05:站点用户是任务的第一归属,device_id 仅作辅助)""" + user = auth_system.current_user() + if not user: + return False + return int(task.get('user_id') or 0) == int(user['id']) + + +def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None: + """任务进入终态的统一入口(F38:记录 completed_at,保留期从完成时刻起算, + 而不是从提交时刻起算)。 + R5-14/F-E:置 error/canceled 终态时若 worker 线程已死(无人再走正常结算), + 代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。 + 第 100 条(T1 不变量):终态先到先得——已终态(done/error/canceled)的任务 + 不再被覆盖(堵住 canceled→error 的非法迁移边:取消后解析阶段的自身失败 + 不得把"已取消"改写成"失败",completed_at 也不再刷新)。""" + need_settle = False + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t and t.get('status') in ('done', 'error', 'canceled'): + return + if t: + t['status'] = status + t['msg'] = msg or t.get('msg', '') + t['completed_at'] = time.time() + if status in ('canceled', 'error'): + th = t.get('worker_thread') + if th is not None and not th.is_alive(): + need_settle = True + # 第 97 条(P2-3):settle 移到任务锁外——ledger_settle 内部要抢 _DB_LOCK 写库 + # (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆; + # settle 幂等,锁外调用无双退 + if need_settle: + try: + auth_system.ledger_settle(task_id, 0, status) + except Exception: + pass + + +def _task_files(task: Dict[str, Any]) -> List[str]: + """任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)""" + result = task.get('result') or {} + files = [] + for f in (result.get('zip_path'), result.get('file_path')): + if f: + files.append(f) + return files + + +def _sweep_download_cache(max_age_seconds: int = RESULT_RETENTION_SECONDS) -> None: + """下载缓存清扫(F27 配套:批量任务不再即时删除共享缓存,改由这里按年龄回收)。 + 只清扫 downloads 根目录的普通文件,不碰 tasks/ 子目录(那是任务专属交付物)。""" + try: + root = api_service.downloads_path + now = time.time() + for p in root.iterdir(): + try: + if p.is_file() and now - p.stat().st_mtime > max_age_seconds: + p.unlink() + except OSError: + pass + except Exception: + pass + + +_MONITOR_ERR_TS = {'ts': 0.0, 'n': 0} # 监控线程异常限频记录(持续异常不静默也不刷屏) +# 第 95 条(P1-1):监控线程周期兜底的三个节流字典——第 94 条补丁时定义丢失 +# (只剩使用),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 _task_monitor_loop() -> None: + """后台监控: + - 心跳超时(40s)→ 暂停离线设备的批量任务,释放排队名额给下一位 + - 心跳超时 > 30 分钟 → 自动取消该设备的所有未完成任务并清理文件 + - 终态任务按 completed_at 保留 35 分钟(F38/P1-3:与前端「领取」入口对齐) + - 运行中/排队/暂停任务超过 4 小时仍未结束 → 置取消(F38:cancel Event 一并置位) + - 下载缓存按年龄清扫(F27 配套,同为 35 分钟口径) + - 顺带清理限流桶中的过期项(O-5:不再只依赖"新建桶时"的清理时机) + """ + while True: + time.sleep(HEARTBEAT_INTERVAL) + try: + now = time.time() + # ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)---- + # 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录 + # 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆 + finished_done: List[str] = [] + finished_retry: List[str] = [] + expired: List[tuple] = [] + with DOWNLOAD_TASKS_LOCK: + for tid, task in DOWNLOAD_TASKS.items(): + if task.get('status') not in ('done', 'error', 'canceled'): + continue + done_at = task.get('completed_at') or 0 + # 兼容无 completed_at 的旧记录:回退到 start + if not done_at: + done_at = task.get('start', 0) + if now - done_at <= RESULT_RETENTION_SECONDS: + continue + expired.append((tid, _task_files(task), + api_service.downloads_path / 'tasks' / tid)) + cleanup_results: List[tuple] = [] + for tid, files, task_dir in expired: + all_removed = True + for f in files: + try: + os.remove(f) + except FileNotFoundError: + pass + except OSError: + all_removed = False # F106:文件暂时不可删(被占用等) + if task_dir.exists(): + shutil.rmtree(str(task_dir), ignore_errors=True) + if task_dir.exists(): + all_removed = False + cleanup_results.append((tid, all_removed)) + with DOWNLOAD_TASKS_LOCK: + for tid, all_removed in cleanup_results: + task = DOWNLOAD_TASKS.get(tid) + if task is None: + finished_done.append(tid) # 锁外期间已被其他路径移除 + continue + if all_removed: + finished_done.append(tid) + else: + task['_cleanup_tries'] = int(task.get('_cleanup_tries') or 0) + 1 + if task['_cleanup_tries'] >= 8: + # 重试多次仍失败:放弃记录但大声记录日志(文件留给缓存清扫兜底) + api_service.logger.warning( + f"任务 {tid} 结果文件多次清理失败,已放弃(文件可能被占用)") + finished_done.append(tid) + else: + finished_retry.append(tid) + for tid in finished_done: + DOWNLOAD_TASKS.pop(tid, None) + if finished_done: + api_service.logger.info(f"监控线程清理过期终态任务: {finished_done}") + if finished_retry: + api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}") + # ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)---- + timeout_settle: List[str] = [] + with DOWNLOAD_TASKS_LOCK: + for tid, task in DOWNLOAD_TASKS.items(): + if (task.get('status') in ('queued', 'running', 'paused') + and now - task.get('start', 0) > 4 * 3600): + task['status'] = 'canceled' + task['msg'] = '任务超时未完成,已自动取消(费用按实际交付结算)' + task['completed_at'] = time.time() + try: + task['cancel'].set() + except Exception: + pass + # R5-14:worker 线程已死则无人再走终态结算——监控代为按账本 + # 结算退款(settle 幂等:worker 若已结算过,此处返回 0) + th = task.get('worker_thread') + if th is not None and not th.is_alive(): + timeout_settle.append(tid) + api_service.logger.warning(f"任务超时自动取消: {tid}") + # 第 97 条(P2-3):结算移到任务锁外(同 _mark_task_terminal 口径)—— + # 库忙 15s 不再随任务锁波及全站任务端点 + for tid in timeout_settle: + try: + auth_system.ledger_settle(tid, 0, 'timeout') + except Exception: + pass + # ---- 下载缓存清扫(保留期同交付口径 35 分钟)+ 限流桶过期项顺手清理(O-5)---- + _sweep_download_cache(RESULT_RETENTION_SECONDS) + _sweep_rate_buckets() + # ---- 第 91→93 条(P2-1/P2-2):账本周期兜底(原仅启动执行一次)---- + # 孤儿对账:长运行进程内 open 且账龄 > 5h(严格大于任务 4h 硬超时 + 监控 + # tick 抖动——账本行 created_at 整秒截断使账龄恒 ≥ 任务龄,旧 4h 相等口径 + # 存在抢退合法 4h+ 长任务的窗口,第 97 条 P2-4)的行每分钟周期检查退回; + # settled 行 90 天清理改为每小时低频执行,防长运行进程下表无界增长 + try: + # 第 94 条(P3-1):时间戳门槛替代 tick 取模——单轮工作段超过周期时 + # 取模落点会被整段跳过,时间戳门槛只延迟不漏跑 + if now - _LEDGER_LAST_RECOVER['ts'] > 60: + _LEDGER_LAST_RECOVER['ts'] = now + auth_system.ledger_recover_orphans(max_age_seconds=5 * 3600) + if now - _LEDGER_LAST_CLEANUP['ts'] > 24 * 3600: + _LEDGER_LAST_CLEANUP['ts'] = now + auth_system.ledger_cleanup_settled(90 * 86400) + except Exception as e: + api_service.logger.warning(f"账本周期兜底异常(下轮重试): {e}") + # ---- 第 91→94 条(P3-3):tasks/ 孤儿目录每小时低频补扫(>24h 且无记录)---- + try: + if now - _TASKS_SWEEP_LAST['ts'] > 3600: + _TASKS_SWEEP_LAST['ts'] = now + tasks_root = api_service.downloads_path / 'tasks' + if tasks_root.exists(): + for d in tasks_root.iterdir(): + try: + if (d.is_dir() and d.name not in DOWNLOAD_TASKS + and now - d.stat().st_mtime > 24 * 3600): + shutil.rmtree(str(d), ignore_errors=True) + api_service.logger.info(f"tasks 孤儿目录补扫移除: {d.name}") + except FileNotFoundError: + pass # 第 94 条(P3-3):单条竞态不中止整轮扫描 + except OSError: + pass + except Exception as e: + api_service.logger.warning(f"tasks 孤儿目录补扫异常(下轮重试): {e}") + # ---- 设备心跳离线处理 ---- + with DEVICE_LAST_SEEN_LOCK: + offline = {d: now - ts for d, ts in DEVICE_LAST_SEEN.items() if now - ts > PAUSE_AFTER_SECONDS} + if not offline: + continue + with DOWNLOAD_TASKS_LOCK: + for tid, task in DOWNLOAD_TASKS.items(): + dev = task.get('device_id') + gap = offline.get(dev) + if not gap: + continue + st = task.get('status') + if gap > CANCEL_AFTER_SECONDS and st in ('queued', 'running', 'paused'): + # 离线超 30 分钟:请求取消(C3:文件删除与退款统一交给 worker 的 + # 取消收尾/终态清理;worker 已不在时由启动对账按账本兜底) + task['status'] = 'canceled' + task['msg'] = '设备离线超过 30 分钟,任务已自动取消' + task['completed_at'] = time.time() + try: + task['cancel'].set() + except Exception: + pass + api_service.logger.info(f"设备离线超 30 分钟自动请求取消: {tid}") + elif task.get('type') == 'batch' and st in ('queued', 'running'): + # 心跳超时:先暂停,让出排队名额给下一位 + task['status'] = 'paused' + task['msg'] = '设备离线,任务暂停(重新打开页面自动恢复)' + task['pause'].set() + except Exception: + # 持续异常不能完全静默(否则监控失能无人知晓):限频记录,每 10 分钟最多一条 + _now = time.time() + _MONITOR_ERR_TS['n'] += 1 + if _now - _MONITOR_ERR_TS['ts'] > 600: + api_service.logger.warning( + f"任务监控循环异常(近 10 分钟累计 {_MONITOR_ERR_TS['n']} 次," + f"最后异常见堆栈): {traceback.format_exc()[-600:]}") + _MONITOR_ERR_TS['ts'] = _now + _MONITOR_ERR_TS['n'] = 0 + + +def _sweep_rate_buckets() -> None: + """限流桶周期清扫(O-5):把超过 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) + + +def _touch_device(device_id: str) -> None: + """记录设备心跳时间(P2-10:设备表容量上限——device_id 是客户端任意串, + 登录用户可提交海量随机 ID 使字典无界增长,超限清最旧; + 第 98 条:入口已统一钳 64,此处兜底再钳一次防新调用点绕过)""" + device_id = str(device_id or '')[:64] + if not device_id: + return + with DEVICE_LAST_SEEN_LOCK: + if len(DEVICE_LAST_SEEN) >= 5000: + # 保留最近心跳的设备:按最后心跳时间排序,砍掉最旧的一半 + for d in sorted(DEVICE_LAST_SEEN, key=DEVICE_LAST_SEEN.get)[:2500]: + DEVICE_LAST_SEEN.pop(d, None) + DEVICE_LAST_SEEN[device_id] = time.time() + + +def _safe_int(value, default: int) -> int: + """用户输入的安全整数解析:垃圾值回退默认值(而非 500)""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_console_print(text: str) -> None: + """控制台输出兜底:stdout 被重定向到文件且系统编码非 UTF-8 时 + (典型:NSSM 服务日志 + 中文 Windows GBK 控制台),emoji 等字符会触发 + UnicodeEncodeError 中断启动流程——实测崩溃循环。此处降级为可编码字符。""" + try: + print(text) + except UnicodeEncodeError: + enc = getattr(sys.stdout, 'encoding', None) or 'ascii' + print(text.encode(enc, errors='replace').decode(enc, errors='replace')) + + +# ============ 接口请求统计 ============ +REQUEST_STATS: Dict[str, Dict[str, Any]] = {} +_STATS_LOCK = threading.Lock() + +# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用) +# F09:兼容别名(/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', + # P1-2/P2-17/P3-6:账号探测与个人操作端点此前漏在限流名单外—— + # /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: List[Dict[str, Any]] = [] + + +def _record_log(action: str, ip: str, song: str) -> None: + """记录用户操作(解析/下载)。P3-4:歌名截断(来自上游/用户输入,不设上限会撑内存与后台页)""" + 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 _parse_cookie_str(raw) -> Dict[str, str]: + """将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典""" + cookies: Dict[str, str] = {} + if not raw: + return cookies + for part in str(raw).split(';'): + if '=' in part: + k, v = part.strip().split('=', 1) + if k: + cookies[k] = v + return cookies + + +_GLOBAL_MUSIC_U_CACHE = {'v': None, 'ts': 0.0} + + +def _global_music_u() -> str: + """全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值)""" + now = time.time() + if _GLOBAL_MUSIC_U_CACHE['v'] is None or now - _GLOBAL_MUSIC_U_CACHE['ts'] > 300: + try: + ck = api_service._get_cookies() + _GLOBAL_MUSIC_U_CACHE['v'] = (ck.get('MUSIC_U') or '').strip() + except Exception: + _GLOBAL_MUSIC_U_CACHE['v'] = '' + _GLOBAL_MUSIC_U_CACHE['ts'] = now + return _GLOBAL_MUSIC_U_CACHE['v'] + + +def _global_music_u_cache_reset() -> None: + """第 94 条(P3-5):重新扫码/删除 Cookie 后同步清空全局 MUSIC_U 缓存—— + 否则 5 分钟窗口内冒用闸拿着旧值比对,新 Cookie 被放行、旧 Cookie 被误拦""" + _GLOBAL_MUSIC_U_CACHE['v'] = None + _GLOBAL_MUSIC_U_CACHE['ts'] = 0.0 + + +def _resolve_user_cookies(data: Dict[str, Any]) -> Dict[str, str]: + """解析个人账号 Cookie:仅使用浏览器传入的 user_cookie(不落服务器,杜绝跨设备访问他人账号)。 + 第 92 条(owner 拍板):全局账号(cookie.txt)在发评/收藏/下载等个人身份操作上 + 零权限、零功能、零入口——浏览器提交的 MUSIC_U 若与全局账号一致(全局 Cookie + 泄露被当个人 Cookie 冒用),一律拒绝并大声记录,防止以全局账号身份执行任何 + 操作(评论区发言、收藏变更、多 IP 下载触发上游风控均属"危害全局账号")。""" + ck = _parse_cookie_str(data.get('user_cookie')) + mu = (ck.get('MUSIC_U') or '').strip() + gmu = _global_music_u() + if mu and gmu and hmac.compare_digest(mu, gmu): + try: + _cu = auth_system.current_user() + auth_system.add_log('fail', _cu['username'] if _cu else '匿名', + auth_system._client_ip(), '全局账号冒用拦截', + '浏览器提交的 MUSIC_U 与全局账号一致,已按未登录拒绝') + except Exception: + pass + api_service.logger.warning( + f"[全局账号冒用拦截] {request.path} 提交的 MUSIC_U 与全局账号一致,已按未登录处理") + return {} + return ck + + +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: + # P2-14:交付文件名必须剔除控制字符——歌名含 \r\n 时 send_file 的 + # Content-Disposition 会抛 ValueError,任务完成已扣费但领取必 500 + # (F79 当年只修了下载器磁盘名这条同型路径,交付名漏网) + 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(默认) + + +def _srt_time(t: float) -> str: + """秒 → SRT 时间格式 HH:MM:SS,mmm""" + h = int(t // 3600) + m = int((t % 3600) // 60) + s = int(t % 60) + ms = int(round((t - int(t)) * 1000)) + if ms >= 1000: + ms = 999 + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def _format_lyric(lrc: str, lyric_format: str) -> str: + """将 LRC 歌词转换为指定格式(lrc / srt) + F37:时间轴先规范化再转 SRT——支持一行多时间标签(同句多时段各自成条)、 + 分钟超两位([100:00])、乱序行(排序后区间不再倒挂)、纯文本行(无时间戳则原样保留)。""" + if lyric_format != 'srt': + return lrc + tag_re = re.compile(r'\[(\d{1,3}):(\d{1,2}(?:[.:]\d+)?)\]') + parsed = [] + for line in lrc.split('\n'): + tags = tag_re.findall(line) + text = tag_re.sub('', line).strip() + if not tags: + # 无时间戳的纯文本:保留为 0 时长行不合适,直接附在输出尾部 + if text: + parsed.append((None, text)) + continue + if not text: + continue + for m, s in tags: + try: + secs = int(m) * 60 + float(s.replace(':', '.')) + except ValueError: + continue + parsed.append((secs, text)) + timed = [(t, x) for t, x in parsed if t is not None] + untimed = [x for t, x in parsed if t is None] + if not timed: + return lrc + timed.sort(key=lambda p: p[0]) + out = [] + for i, (t, text) in enumerate(timed): + end = timed[i + 1][0] if i + 1 < len(timed) else t + 5 + if end <= t: + end = t + 1 # 同时间多行:给 1 秒最小区间,不再生成倒挂区间 + out.append(f"{i + 1}\n{_srt_time(t)} --> {_srt_time(end)}\n{text}\n") + if untimed: + out.append('\n'.join(untimed)) + return '\n'.join(out) + + +def _parse_meta_fields(raw) -> Optional[List[str]]: + """解析写入字段参数,空返回 None(表示全部写入)""" + if raw is None or raw == '': + return None + if isinstance(raw, str): + return [f.strip() for f in raw.split(',') if f.strip()] or None + try: + return list(raw) or None + except Exception: + return None + + +def _verify_admin_password(data) -> Optional[Tuple[Dict[str, Any], int]]: + """校验管理员权限:仅站点管理员会话(旧版口令已废除,2026-09-15)""" + _user = auth_system.current_user() + if _user is not None and _user['role'] == 'admin': + return None + return APIResponse.error('需要管理员权限,请使用管理员账号登录', 403) + + +def _vip_name(vip_type: int) -> str: + """根据 vipType 返回会员类型名称(兼容新旧取值)""" + return { + 0: '普通用户', + 1: '音乐包', + 11: '黑胶VIP', + 100: '黑胶VIP', + 10: '黑胶SVIP', + 110: '黑胶SVIP' + }.get(vip_type, '普通用户') + + +def _detect_vip(cookies: Dict[str, str]) -> Tuple[int, str, List[str]]: + """检测账号会员类型与可解析音质: + 优先账号接口(vipType 精确),并始终用音质探测接口实测可解析列表。 + 第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行 + 8 级探测(最坏 16 分钟线程滞留),等锁超时者直接用账号接口给降级结果""" + if not _VIP_PROBE_LOCK.acquire(timeout=0.5): + info = account_info(cookies) + if info: + return info.get('vipType', 0), 'account', [] + return 0, 'probe', [] + try: + probe = detect_vip_by_probe(cookies) + levels_ok = probe.get('levels_ok', []) + info = account_info(cookies) + if info: + return info.get('vipType', 0), 'account', levels_ok + return probe.get('vip_type', 0), 'probe', levels_ok + finally: + _VIP_PROBE_LOCK.release() + + +# 服务器网易云账号昵称缓存(10 分钟):用于在非管理员可见的响应中遮蔽真实昵称 +_SERVER_NICK_CACHE = {'nick': None, 'ts': 0.0} +_SERVER_NICK_LOCK = threading.Lock() # 第 91 条(P3-9):刷新单飞锁,防并发重复打上游 +_VIP_PROBE_LOCK = threading.Lock() # 第 100 条:音质探测单飞锁(有界等待) + + +def _mask_server_nickname(nick: str) -> str: + """非站点管理员可见的响应中,文本内出现的服务器账号昵称替换为站点名 + (覆盖 creator 精确字段与歌单名等含昵称的文本),管理员本人仍可见真实昵称。 + P1-4:查询失败时保留上一次已知昵称继续遮蔽(原实现清空缓存导致失败开放, + 网易接口抖动的 10 分钟窗口内真昵称原样放行)。 + R5-11:昵称成功获取后同时持久化进站点库(settings 表,私有数据、不进公开包)—— + 内存缓存丢失(进程重启)或服务器 Cookie 失效导致上游拿不到昵称时, + 回落读库里的最后已知昵称继续遮蔽,不再"从未成功获取就无限期放行"。""" + if not nick: + return nick + _cu = auth_system.current_user() + if _cu is not None and _cu['role'] == 'admin': + return nick + now = time.time() + if _SERVER_NICK_CACHE['nick'] is None or now - _SERVER_NICK_CACHE['ts'] > 600: + # 第 91 条(P3-9):单飞锁——缓存过期时并发请求只放一个去打上游,其余等结果 + if not _SERVER_NICK_LOCK.acquire(timeout=10): + server_nick = _SERVER_NICK_CACHE['nick'] + return nick.replace(server_nick, '云村小助手') if (server_nick and server_nick in nick) else nick + try: + # 双检:等锁期间别的请求可能已完成刷新 + if _SERVER_NICK_CACHE['nick'] is None or time.time() - _SERVER_NICK_CACHE['ts'] > 600: + fetched = '' + try: + fetched = (account_info(api_service._get_cookies()) or {}).get('nickname', '') + except Exception: + fetched = '' + if fetched: + # 仅在成功拿到非空昵称时覆盖(失败保留旧值,遮蔽不失效),并持久化兜底 + _SERVER_NICK_CACHE['nick'] = fetched + _SERVER_NICK_CACHE['ts'] = time.time() + try: + auth_system.set_setting('server_nickname', fetched) + except Exception: + pass + else: + # 上游拿不到(Cookie 失效/网络故障):回落站点库里的最后已知昵称继续遮蔽 + try: + persisted = auth_system.get_setting('server_nickname', '') + except Exception: + persisted = '' + if persisted: + _SERVER_NICK_CACHE['nick'] = persisted + _SERVER_NICK_CACHE['ts'] = time.time() # 库值视为可信,走正常 10 分钟重取节奏 + else: + # 库里也从未有过(全新部署且从未成功获取):缩短重试间隔,本次无遮蔽依据只能放行 + _SERVER_NICK_CACHE['ts'] = time.time() - 600 + 30 + finally: + _SERVER_NICK_LOCK.release() + server_nick = _SERVER_NICK_CACHE['nick'] + if server_nick and server_nick in nick: + return nick.replace(server_nick, '云村小助手') + return nick + + +# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、 +# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。 +# 用户主动动作(任务列表/取消)保留日志。 +_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat', + '/audio_stream', '/api/fe_error'} + + +@app.before_request +def before_request(): + """请求前处理(高频轮询/流式端点降噪:3 秒排队轮询 + 1.5 秒进度轮询会刷爆轮转日志)""" + if request.path in _QUIET_LOG_PATHS: + return + # 记录请求信息(P3-3:UA 截断并清洗换行——4KB 垃圾 UA 或伪日志行不再整段进日志; + # 第 97 条:path 同口径清洗——%0A 解码后的请求路径可伪造日志行) + ua = (request.headers.get('User-Agent', 'Unknown') or 'Unknown')[:200].replace('\n', ' ').replace('\r', '') + req_path = request.path.replace('\n', ' ').replace('\r', '') + api_service.logger.info( + f"{request.method} {req_path} - IP: {request.remote_addr} - User-Agent: {ua}" + ) + + +# ============ 音频直链多重防护(HMAC 签名 + 时效 + 代理流)============ +_AUDIO_TOKEN_TTL = RESULT_RETENTION_SECONDS + 60 # 第 93 条(P2-4):= 36 分钟——原 30 分钟 +# 大于交付/解析结果 35 分钟保留期 60 秒:交付窗口末段试听不再 403(旧 30 分钟口径矛盾已修) +_AUDIO_URL_KEYS = {'url', 'audioUrl'} +_AUDIO_HOST_MARKS = ('126.net', 'music.126.net', '.netease') + + +def _is_audio_url(v) -> bool: + return isinstance(v, str) and v.startswith('http') and any(m in v for m in _AUDIO_HOST_MARKS) + + +def _sign_payload(payload: str) -> str: + key = app.secret_key.encode('utf-8') if isinstance(app.secret_key, str) else app.secret_key + return hmac.new(key, payload.encode('utf-8'), hashlib.sha256).hexdigest()[:32] + + +def _make_audio_token(url: str) -> str: + exp = int(time.time()) + _AUDIO_TOKEN_TTL + nonce = secrets.token_hex(4) + payload = base64.urlsafe_b64encode(f'{exp}|{nonce}|{url}'.encode('utf-8')).decode().rstrip('=') + return payload + '.' + _sign_payload(payload) + + +def _verify_audio_token(token: str) -> Optional[str]: + try: + payload, sig = token.rsplit('.', 1) + if not hmac.compare_digest(sig, _sign_payload(payload)): + return None + pad = '=' * (-len(payload) % 4) + exp, _nonce, url = base64.urlsafe_b64decode(payload + pad).decode('utf-8').split('|', 2) + if int(exp) < time.time(): + return None + return url + except Exception: + return None + + +def _protect_audio_urls(obj) -> bool: + """递归就地替换 dict/list 中的音频直链为签名代理地址,返回是否发生替换""" + hit = {'v': False} + + def walk(o): + if isinstance(o, dict): + for k, v in list(o.items()): + if k in _AUDIO_URL_KEYS and _is_audio_url(v): + o[k] = '/audio_stream?token=' + _make_audio_token(v) + hit['v'] = True + else: + walk(v) + elif isinstance(o, list): + for it in o: + walk(it) + + walk(obj) + return hit['v'] + + +@app.route('/audio_stream') +@auth_system.login_required(free=True) +def audio_stream(): + """签名音频代理流:校验 HMAC 签名与时效后流式转发(支持 Range 拖动进度) + F40:下游中断/HEAD 也保证上游 Response 显式关闭;F92:解压后正文不再沿用压缩前的长度头""" + if request.method == 'HEAD': + # HEAD 无正文:不去请求上游(原实现会真的发起 GET 却不消费) + return Response(status=200, headers={'Accept-Ranges': 'bytes'}) + token = request.args.get('token', '') + url = _verify_audio_token(token) + if not url: + return APIResponse.error('音频链接已过期或无效,请重新解析', 403) + upstream_headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://music.163.com/', + } + range_header = request.headers.get('Range') + if range_header: + upstream_headers['Range'] = range_header + try: + session = getattr(audio_stream, '_session', None) + if session is None: + session = requests.Session() + session.trust_env = False # 忽略环境代理,直连音频 CDN + audio_stream._session = session + upstream = session.get(url, headers=upstream_headers, stream=True, timeout=20) + except Exception as e: + api_service.logger.warning(f'音频代理获取失败: {type(e).__name__}: {e}') + return APIResponse.error('音频获取失败', 502) + + # requests 对 gzip 等编码做了透明解压:iter_content 吐出的是解码后字节, + # 上游 Content-Length/Content-Range 是压缩前口径,直接转发会造成头体不一致(F92) + upstream_encoded = bool(upstream.headers.get('Content-Encoding')) + + def _stream_and_close(): + try: + yield from upstream.iter_content(chunk_size=16 * 1024) + finally: + # F40:下游断开/提前关闭时也释放上游连接 + try: + upstream.close() + except Exception: + pass + + resp = Response( + stream_with_context(_stream_and_close()), + status=upstream.status_code, + content_type=upstream.headers.get('Content-Type', 'audio/mpeg'), + ) + if not upstream_encoded: + for h in ('Content-Length', 'Content-Range', 'Accept-Ranges'): + if h in upstream.headers: + resp.headers[h] = upstream.headers[h] + else: + # 解压转发:长度未知,交给分块传输;不携带 Range 语义 + resp.headers.pop('Content-Length', None) + if 'Accept-Ranges' in upstream.headers: + resp.headers['Accept-Ranges'] = upstream.headers['Accept-Ranges'] + resp.headers['Cache-Control'] = 'no-store' + return resp + + +@app.after_request +def after_request(response: Response) -> Response: + """请求后处理 - 设置CORS头""" + response.headers.add('Access-Control-Allow-Origin', config.cors_origins) + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') + response.headers.add('Access-Control-Max-Age', '3600') + + # P3-16:零成本安全响应头(nosniff 防 MIME 嗅探、DENY 防点击劫持、 + # Referrer-Policy 防跨站 Referer 泄漏);O-3:429 统一带 Retry-After 供客户端退避 + response.headers.setdefault('X-Content-Type-Options', 'nosniff') + response.headers.setdefault('X-Frame-Options', 'DENY') + response.headers.setdefault('Referrer-Policy', 'same-origin') + # 第 91 条(P3):CSP 基线(站点全部脚本/样式内联,需 unsafe-inline;音频同源 + + # blob 预览;图片允许 data:/http:/https:——网易封面直链为 http 协议)—— + # 防注入脚本外联的纵深防御 + response.headers.setdefault( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: http: https:; media-src 'self' blob:; connect-src 'self'; " + "font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'") + # 第 91 条(P3):抹掉 Werkzeug/Python 精确版本号(fuzz 实测泄露) + response.headers['Server'] = 'NeteaseUrl' + if response.status_code == 429: + response.headers.setdefault('Retry-After', '30') + + # 音频直链保护:JSON 响应中的网易云音频地址替换为签名代理地址 + if response.is_json and response.status_code == 200: + try: + data = response.get_json(silent=True) + if isinstance(data, (dict, list)) and _protect_audio_urls(data): + response.data = json.dumps(data, ensure_ascii=False) + except Exception: + pass + + # 记录响应信息(高频轮询/流式端点降噪,与 before_request 同口径) + if request.path not in _QUIET_LOG_PATHS: + api_service.logger.info(f"响应状态: {response.status_code}") + return response + + +@app.errorhandler(400) +def handle_bad_request(e): + """处理400错误""" + return APIResponse.error("请求参数错误", 400) + + +@app.errorhandler(404) +def handle_not_found(e): + """处理404错误""" + return APIResponse.error("请求的资源不存在", 404) + + +@app.errorhandler(405) +def handle_method_not_allowed(e): + """处理405错误(P3-28:fetch 消费方拿到统一 JSON,而非 werkzeug 默认 HTML)""" + return APIResponse.error("不支持的请求方法", 405) + + +@app.errorhandler(413) +def handle_payload_too_large(e): + """处理413错误(P2-16:请求体超过上限时给出可读提示而非 werkzeug HTML)""" + return APIResponse.error("请求体过大,请减少提交内容后重试", 413) + + +@app.errorhandler(500) +def handle_internal_error(e): + """处理500错误""" + api_service.logger.error(f"服务器内部错误: {e}") + return APIResponse.error("服务器内部错误", 500) + + +@app.route('/') +def index() -> str: + """首页路由""" + resp = make_response(render_template('index.html')) + # 禁止缓存首页,确保前端更新即时生效(避免旧 HTML 缓存引发的功能异常) + resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + return resp + + +@app.route('/health', methods=['GET']) +def health_check(): + """健康检查API""" + try: + # 检查 Cookie 状态:直接读 cookie.txt + cookies = load_cookies() + cookie_status = 'valid' if cookies else 'invalid' + + health_info = { + 'service': 'running', + 'timestamp': int(time.time()) if 'time' in sys.modules else None, + 'cookie_status': cookie_status, + 'cookie_count': len(cookies), + 'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径 + 'version': APP_VERSION + } + + return APIResponse.success(health_info, "API服务运行正常") + + except Exception as e: + api_service.logger.error(f"健康检查失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/song', methods=['GET', 'POST']) +@app.route('/Song_V1', methods=['GET', 'POST']) # 向后兼容 +@auth_system.login_required('歌曲解析', free=True) +def get_song_info(): + """获取歌曲信息API""" + try: + # 获取请求参数 + data = api_service._safe_get_request_data() + song_ids = data.get('ids') or data.get('id') + url = data.get('url') + level = data.get('level', 'lossless') + info_type = data.get('type', 'url') + + # 参数验证 + if not song_ids and not url: + return APIResponse.error("必须提供 'ids'、'id' 或 'url' 参数") + + # 提取音乐ID + music_id = api_service._extract_music_id(song_ids or url) + # P2-2:ID 白名单(与 /album、/artist_songs、/comments 同口径—— + # 垃圾输入不再透传上游并把上游报错原文回显给公网) + if not str(music_id).strip().isdigit(): + return APIResponse.error("无效的歌曲ID,请输入数字ID或分享链接") + + # 验证音质参数 + valid_levels = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] + if level not in valid_levels: + return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_levels)}") + + # 验证类型参数 + valid_types = ['url', 'name', 'lyric', 'json'] + if info_type not in valid_types: + return APIResponse.error(f"无效的类型参数,支持: {', '.join(valid_types)}") + + # 解析账号选择:user=游客个人账号(个人歌单登录,Cookie 由浏览器传入),默认 admin=全局管理员账号 + account = data.get('account', 'admin') + if account == 'user': + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) + else: + cookies = api_service._get_cookies() + + # 根据类型获取不同信息 + if info_type == 'url': + result = url_v1(music_id, level, cookies) + if result and result.get('data') and len(result['data']) > 0: + # 第 99 条:无版权/VIP 歌上游返回 data:[null]——元素为 null 时走下方 + # 404 分支而非 AttributeError 500(下载器同族已修,解析路由补齐) + song_data = result['data'][0] or {} + if not song_data: + return APIResponse.error("获取音乐URL失败,可能是版权限制或音质不支持", 404) + response_data = { + 'id': song_data.get('id'), + 'url': song_data.get('url'), + 'level': song_data.get('level'), + 'quality_name': api_service._get_quality_display_name(song_data.get('level', level)), + 'size': song_data.get('size'), + 'size_formatted': api_service._format_file_size(song_data.get('size', 0)), + 'type': song_data.get('type'), + 'bitrate': song_data.get('br') + } + return APIResponse.success(response_data, "获取歌曲URL成功") + else: + return APIResponse.error("获取音乐URL失败,可能是版权限制或音质不支持", 404) + + elif info_type == 'name': + result = name_v1(music_id) + return APIResponse.success(result, "获取歌曲信息成功") + + elif info_type == 'lyric': + result = lyric_v1(music_id, cookies) + return APIResponse.success(result, "获取歌词成功") + + elif info_type == 'json': + # 获取完整的歌曲信息(用于前端解析) + song_info = name_v1(music_id) + url_info = url_v1(music_id, level, cookies) + try: + lyric_info = lyric_v1(music_id, cookies) + except Exception: + lyric_info = None # 歌词接口抖动不阻断解析 + + if not song_info or 'songs' not in song_info or not song_info['songs']: + return APIResponse.error("未找到歌曲信息", 404) + + song_data = song_info['songs'][0] + + # S1/F94:可选字段为 null(而非缺失)时同样容错,不让有效歌曲解析失败 + _al = song_data.get('al') or {} + _ar_names = ', '.join(filter(None, ((a or {}).get('name') for a in (song_data.get('ar') or [])))) + + # 构建前端期望的响应格式 + response_data = { + 'id': music_id, + 'name': song_data.get('name') or '', + 'ar_name': _ar_names, + 'al_name': _al.get('name') or '', + 'pic': _al.get('picUrl') or '', + 'level': level, + 'lyric': ((lyric_info or {}).get('lrc') or {}).get('lyric') or '', + 'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '', + 'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or '' + } + + # 添加URL和大小信息 + if url_info and url_info.get('data') and len(url_info['data']) > 0: + url_data = url_info['data'][0] + response_data.update({ + 'url': url_data.get('url', ''), + 'size': api_service._format_file_size(url_data.get('size', 0)), + 'level': url_data.get('level', level) + }) + else: + response_data.update({ + 'url': '', + 'size': '获取失败' + }) + + # 记录用户解析操作(IP + 歌曲名) + try: + _record_log('解析', auth_system._client_ip(), response_data.get('name') or str(music_id)) + except Exception: + pass + + return APIResponse.success(response_data, "获取歌曲信息成功") + + except APIException as e: + api_service.logger.error(f"API调用失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取歌曲信息异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/search', methods=['GET', 'POST']) +@app.route('/Search', methods=['GET', 'POST']) # 向后兼容 +@auth_system.login_required('搜索', free=True) +def search_music_api(): + """搜索音乐API""" + try: + # 获取请求参数 + data = api_service._safe_get_request_data() + keyword = data.get('keyword') or data.get('keywords') or data.get('q') + limit = max(1, _safe_int(data.get('limit'), 30)) + offset = max(0, _safe_int(data.get('offset'), 0)) + search_type = data.get('type', '1') # 1-歌曲, 10-专辑, 100-歌手, 1000-歌单 + + # 参数验证 + validation_error = api_service._validate_request_params({'keyword': keyword}) + if validation_error: + return validation_error + + # 限制搜索数量 + if limit > 100: + limit = 100 + + cookies = api_service._get_cookies() + # F45:offset 透传给上游,翻页不再永远从第一页开始 + result = search_music(keyword, cookies, limit, int(search_type), offset=offset) + + # 向后兼容:歌曲搜索结果补充 artist_string 字段;歌单结果遮蔽服务器账号昵称 + if result: + for song in result: + if song.get('type', 'song') == 'song' and 'artists' in song: + song['artist_string'] = song['artists'] + if song.get('type') == 'playlist': + if song.get('creator'): + song['creator'] = _mask_server_nickname(song['creator']) + if song.get('name'): + song['name'] = _mask_server_nickname(song['name']) + + return APIResponse.success(result, "搜索完成") + + except ValueError as e: + return APIResponse.error(f"参数格式错误: {str(e)}") + except Exception as e: + api_service.logger.error(f"搜索音乐异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/playlist', methods=['GET', 'POST']) +@app.route('/Playlist', methods=['GET', 'POST']) # 向后兼容 +@auth_system.login_required('歌单解析', free=True) +def get_playlist(): + """获取歌单详情API""" + try: + # 获取请求参数 + data = api_service._safe_get_request_data() + playlist_id = data.get('id') + + # 参数验证 + validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) + if validation_error: + return validation_error + + # P2-2:ID 白名单(同 /album 口径——原实现垃圾 ID 透传上游并把上游报错回显) + playlist_id = api_service._extract_music_id(playlist_id) + if not str(playlist_id).strip().isdigit(): + return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") + + cookies = api_service._get_cookies() + result = playlist_detail(playlist_id, cookies) + # 非管理员遮蔽服务器账号昵称(creator / 歌单名等文本字段) + if isinstance(result, dict): + if result.get('creator'): + result['creator'] = _mask_server_nickname(result['creator']) + if result.get('name'): + result['name'] = _mask_server_nickname(result['name']) + + # 适配前端期望的响应格式 + response_data = { + 'status': 'success', + 'playlist': result + } + + return APIResponse.success(response_data, "获取歌单详情成功") + + except Exception as e: + api_service.logger.error(f"获取歌单异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/album', methods=['GET', 'POST']) +@app.route('/Album', methods=['GET', 'POST']) # 向后兼容 +@auth_system.login_required('专辑解析', free=True) +def get_album(): + """获取专辑详情API""" + try: + # 获取请求参数 + data = api_service._safe_get_request_data() + album_id = data.get('id') + + # 参数验证 + validation_error = api_service._validate_request_params({'album_id': album_id}) + if validation_error: + return validation_error + + # ID 白名单:必须能归一化为纯数字(原实现直接拼进上游 URL 路径, + # 恶意输入可构造路径穿越让服务器携 Cookie 请求任意 GET 接口) + album_id = api_service._extract_music_id(album_id) + if not str(album_id).strip().isdigit(): + return APIResponse.error("无效的专辑ID,请输入数字ID或分享链接") + + cookies = api_service._get_cookies() + result = album_detail(album_id, cookies) + + # 适配前端期望的响应格式 + response_data = { + 'status': 200, + 'album': result + } + + return APIResponse.success(response_data, "获取专辑详情成功") + + except Exception as e: + api_service.logger.error(f"获取专辑异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/toplist', methods=['GET', 'POST']) +@auth_system.login_required('榜单获取', free=True) +def get_toplist_api(): + """获取网易云官方榜单列表API""" + try: + result = toplist_list() + return APIResponse.success(result, "获取榜单列表成功") + except APIException as e: + api_service.logger.error(f"获取榜单失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取榜单异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/artist_songs', methods=['GET', 'POST']) +@auth_system.login_required('歌手解析', free=True) +def get_artist_songs_api(): + """获取歌手信息和热门歌曲API""" + try: + data = api_service._safe_get_request_data() + artist_id = data.get('id') or data.get('artist_id') + + validation_error = api_service._validate_request_params({'artist_id': artist_id}) + if validation_error: + return validation_error + + artist_id = api_service._extract_music_id(artist_id) + # ID 白名单:归一化后必须为纯数字(防路径拼接注入面,同 /album) + if not str(artist_id).strip().isdigit(): + return APIResponse.error("无效的歌手ID,请输入数字ID或分享链接") + result = artist_songs(artist_id) + return APIResponse.success(result, "获取歌手歌曲成功") + except APIException as e: + api_service.logger.error(f"获取歌手歌曲失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取歌手歌曲异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +# ============ 播客(电台 / 单集声音)============ + +@app.route('/podcast/parse', methods=['GET', 'POST']) +@auth_system.login_required('播客解析', free=True) +def podcast_parse_api(): + """播客解析:电台链接/ID → 电台详情+单集列表;单集链接/ID → 单集详情+音频直链""" + try: + data = api_service._safe_get_request_data() + raw = data.get('id') or data.get('url') + validation_error = api_service._validate_request_params({'id': raw}) + if validation_error: + return validation_error + page_size = max(1, min(_safe_int(data.get('page_size'), 30), 100)) + quality = str(data.get('quality') or 'auto') + + cookies = api_service._get_cookies() + result = podcast_api.parse_podcast_ref(raw, cookies, page_size=page_size) + + # 单集解析:附音频直链(音质自动降级) + if result['kind'] == 'program': + program = result['program'] + song_id = program.get('main_song_id') + url_info = podcast_api.resolve_episode_url(song_id, cookies, quality) if song_id else None + if url_info: + program['audio'] = { + 'url': url_info['url'], + 'level': url_info['level'], + 'level_name': api_service._get_quality_display_name(url_info['level']), + 'type': url_info['type'], + 'size': url_info['size'], + 'size_formatted': api_service._format_file_size(url_info['size']), + 'br': url_info['br'], + } + else: + program['audio'] = None + try: + _record_log('解析', auth_system._client_ip(), program.get('name') or str(raw)) + except Exception: + pass + else: + try: + _record_log('解析', auth_system._client_ip(), result['radio'].get('name') or str(raw)) + except Exception: + pass + + return APIResponse.success(result, "播客解析成功") + except APIException as e: + api_service.logger.warning(f"播客解析失败: {e}") + return APIResponse.error(str(e), 404) + except Exception as e: + api_service.logger.error(f"播客解析异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/podcast/programs', methods=['GET', 'POST']) +@auth_system.login_required('播客单集列表', free=True) +def podcast_programs_api(): + """播客单集列表(分页加载)""" + try: + data = api_service._safe_get_request_data() + radio_id = data.get('radio_id') or data.get('id') + validation_error = api_service._validate_request_params({'radio_id': radio_id}) + if validation_error: + return validation_error + limit = max(1, min(_safe_int(data.get('limit'), 30), 100)) + offset = max(0, _safe_int(data.get('offset'), 0)) + asc = str(data.get('asc', 'true')).lower() in ('1', 'true', 'yes') + + cookies = api_service._get_cookies() + radio_id = api_service._extract_music_id(str(radio_id)) + # R5-P3:数字白名单——垃圾 radio_id 会在 podcast_api 的 int() 处抛 ValueError 变 500 + if not str(radio_id).isdigit(): + return APIResponse.error("无效的电台 ID") + result = podcast_api.get_radio_programs(radio_id, cookies, limit=limit, offset=offset, asc=asc) + return APIResponse.success(result, "获取单集列表成功") + except APIException as e: + return APIResponse.error(str(e), 404) + except Exception as e: + api_service.logger.error(f"获取播客单集列表异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/podcast/search', methods=['GET', 'POST']) +@auth_system.login_required('播客搜索', free=True) +def podcast_search_api(): + """播客搜索(电台 / 单集声音)""" + try: + data = api_service._safe_get_request_data() + keyword = data.get('keyword') or data.get('q') + validation_error = api_service._validate_request_params({'keyword': keyword}) + if validation_error: + return validation_error + limit = max(1, min(_safe_int(data.get('limit'), 20), 50)) + offset = max(0, _safe_int(data.get('offset'), 0)) + search_type = data.get('type', 'all') + if search_type not in ('all', 'radio', 'voice'): + search_type = 'all' + + cookies = api_service._get_cookies() + result = podcast_api.search_podcast(keyword, cookies, limit=limit, + offset=offset, search_type=search_type) + return APIResponse.success(result, "播客搜索完成") + except APIException as e: + return APIResponse.error(str(e), 404) + except Exception as e: + api_service.logger.error(f"播客搜索异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/podcast/hot', methods=['GET', 'POST']) +@auth_system.login_required(free=True) +def podcast_hot_api(): + """热门播客 + 播客分类列表""" + try: + data = api_service._safe_get_request_data() + limit = max(1, min(_safe_int(data.get('limit'), 30), 100)) + offset = max(0, _safe_int(data.get('offset'), 0)) + cate_id = data.get('cate_id') or None + + cookies = api_service._get_cookies() + radios = podcast_api.get_hot_radios(cookies, limit=limit, offset=offset, cate_id=cate_id) + try: + categories = podcast_api.get_categories() + except APIException: + categories = [] + # S2:满页视为可能还有更多,供前端"加载更多"按钮显隐(空页/短页则没有更多) + return APIResponse.success({ + 'radios': radios, + 'categories': categories, + 'has_more': len(radios) >= limit, + }, "获取热门播客成功") + except APIException as e: + return APIResponse.error(str(e), 404) + except Exception as e: + api_service.logger.error(f"获取热门播客异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/podcast/download', methods=['POST']) +@auth_system.login_required('播客单集下载') +def podcast_download_api(): + """播客单集下载:后台任务下载音频到服务器,完成后经 /download/result 交付 + + 参数: id(单集ID/链接), quality(auto/exhigh/standard), zip(打包音频+封面+信息), task_id, device_id + """ + try: + data = api_service._safe_get_request_data() + raw = data.get('id') + validation_error = api_service._validate_request_params({'id': raw}) + if validation_error: + return validation_error + quality = str(data.get('quality') or 'auto') + if quality not in ('auto', 'standard', 'exhigh', 'lossless', 'hires'): + quality = 'auto' + need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') + + # 单集ID提取(支持 program?id= / sound?id= / 短链 / 纯数字) + ref = podcast_api.extract_podcast_ref(raw) + if ref['kind'] == 'radio': + return APIResponse.error("这是电台链接,请先解析电台后选择单集下载,或使用「打包整个播客」") + + # F64:个人账号选择贯通播客入口。第 91 条(P3-2):删除 user_cookie.txt + # 服务器回退——旧版部署残留文件可被任意登录用户复用(跨设备访问他人账号), + # 与 /song、/download 的"仅浏览器传入"口径统一 + account = data.get('account', 'admin') + if account == 'user': + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) + else: + cookies = api_service._get_cookies() + task_id_hint = str(data.get('task_id') or '').strip() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + _touch_device(device_id) + _cur_user = auth_system.current_user() + _reserved = 0 if (not _cur_user or _cur_user['role'] == 'admin' + or int(_cur_user['duration_seconds'] or 0) == -1) else 1 + task = { + 'type': 'single', + 'billing_user_id': int(_cur_user['id']) if _cur_user else 0, + 'billing_reserved': _reserved, + 'total': 1, + 'done': 0, + 'failed': 0, + 'status': 'queued', + 'start': time.time(), + 'msg': '排队中...', + 'cancel': threading.Event(), + 'pause': threading.Event(), + 'device_id': device_id, + 'result': None, + 'params': { + 'program_id': ref['id'], + 'quality': quality, + 'need_zip': need_zip, + 'cookies': cookies, + 'remote_ip': auth_system._client_ip(), + }, + } + reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint) + if reg_err: + return APIResponse.error(reg_err, 429) + task_id = task['task_id'] + start_err = _start_task_thread(task_id, _podcast_download_worker, (task_id,)) + if start_err: + return APIResponse.error(start_err, 500) + return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "播客单集下载任务已提交") + except APIException as e: + return APIResponse.error(str(e), 404) + except Exception as e: + api_service.logger.error(f"播客单集下载提交异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +def _podcast_billing_count() -> int: + """播客打包计费:指定单集列表按数量扣;整台电台提交时先扣 1 次, + worker 解析完实际单集数后补扣差额(一首单集 = 1 次) + F22:与路由完全同口径——ids 模式先做与路由一致的 limit 截断,避免多扣""" + try: + data = api_service._safe_get_request_data() + if data.get('radio_id'): + return 1 + try: + limit = int(data.get('limit', 200) or 200) + except (TypeError, ValueError): + limit = 200 + limit = max(1, min(limit, 500)) + raw = data.get('ids') + if isinstance(raw, str): + raw_list = raw.split(',') + else: + raw_list = list(raw or []) + # 与路由完全同口径(提取→去重):URL 形式与纯数字形式的同一单集不再重复计费 + ids, seen = [], set() + for x in raw_list: + try: + mid = api_service._extract_music_id(x) + except Exception: + mid = str(x).strip() + if mid and mid not in seen: + seen.add(mid) + ids.append(mid) + return max(1, min(len(ids), limit)) + except Exception: + return 1 + + +@app.route('/podcast/download/batch_zip', methods=['POST']) +@auth_system.login_required('播客打包', cost=_podcast_billing_count) +def podcast_batch_zip_api(): + """整台播客打包:后台任务逐集下载并入 ZIP,完成后经 /download/batch_zip/result 交付 + + 参数: radio_id(电台ID/链接) 或 ids(单集ID列表,逗号分隔), limit(最多集数), quality, task_id, device_id + """ + try: + data = api_service._safe_get_request_data() + raw_radio = data.get('radio_id') + raw_ids = data.get('ids') + if not raw_radio and not raw_ids: + return APIResponse.error("缺少 radio_id 或 ids 参数") + try: + limit = int(data.get('limit', 200) or 200) + except (TypeError, ValueError): + limit = 200 + limit = max(1, min(limit, 500)) + quality = str(data.get('quality') or 'auto') + if quality not in ('auto', 'standard', 'exhigh', 'lossless', 'hires'): + quality = 'auto' + + cookies = api_service._get_cookies() + + if raw_radio: + ref = podcast_api.extract_podcast_ref(raw_radio) + if ref['kind'] == 'program': + return APIResponse.error("这是单集链接,请使用「下载单集」或直接输入电台链接/ID") + radio_id = ref['id'] + radio_name = '' + try: + radio_name = podcast_api.get_radio_detail(radio_id, cookies).get('name', '') + except APIException: + pass + else: + # 直接传单集ID列表(从解析结果勾选);F46:数组元素统一 str() 清洗 + if isinstance(raw_ids, str): + id_list = [x.strip() for x in raw_ids.split(',') if x.strip()] + else: + id_list = [str(x).strip() for x in (raw_ids or []) if str(x).strip()] + # 与音乐批量同口径(S10):提取 ID 后去重,重复单集不多扣也不重复下载 + _seen = set() + deduped = [] + for x in id_list: + mid = api_service._extract_music_id(x) + if mid and mid not in _seen: + _seen.add(mid) + deduped.append(mid) + id_list = deduped[:limit] + if not id_list: + return APIResponse.error("缺少 ids 参数") + radio_id = None + radio_name = str(data.get('name') or '播客单集') + + task_id_hint = str(data.get('task_id') or '').strip() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + _touch_device(device_id) + _cu = auth_system.current_user() + _billed = _podcast_billing_count() + _unlimited = (_cu is None or _cu['role'] == 'admin' + or int(_cu['duration_seconds'] or 0) == -1) + _reserved = 0 if _unlimited else _billed + task = { + 'type': 'batch', + 'billing_user_id': int(_cu['id']) if _cu else 0, + 'billing_reserved': _reserved, + 'total': 0, # worker 解析完单集列表后回填 + 'done': 0, + 'failed': 0, + 'status': 'queued', + 'start': time.time(), + 'msg': '排队中...', + 'cancel': threading.Event(), + 'pause': threading.Event(), + 'device_id': device_id, + 'result': None, + 'params': { + 'radio_id': radio_id, + 'ids': None if radio_id else id_list, + 'limit': limit, + 'quality': quality, + 'radio_name': radio_name, + 'cookies': cookies, + 'remote_ip': auth_system._client_ip(), + # 整台电台模式:worker 解析完实际单集数后按差额补扣(一首 = 1 次) + 'billing_uid': (_cu['id'] if _cu is not None else None), + 'billed': _billed, + }, + } + reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint) + if reg_err: + return APIResponse.error(reg_err, 429) + task_id = task['task_id'] + start_err = _start_task_thread(task_id, _podcast_zip_worker, (task_id,)) + if start_err: + return APIResponse.error(start_err, 500) + return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "播客打包任务已提交") + except APIException as e: + return APIResponse.error(str(e), 404) + except Exception as e: + api_service.logger.error(f"播客打包提交异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +def _podcast_safe_name(s: str, fallback: str = 'unknown') -> str: + """文件名安全化(去除非法字符)""" + s = ''.join(c for c in str(s or '') if c not in r'<>:"/\|?*' and ord(c) >= 32).strip() + return s or fallback + + +def _podcast_stream_download(url: str, target: Path, total_size: int, + cancel_ev: threading.Event, + task_id: str = '', progress_label: str = '') -> int: + """流式下载播客音频到 target(先写 .part 再改名),返回实际字节数 + + 自动加入全局下载信号量排队;每 0.5s 更新一次任务进度消息。 + """ + import requests as _requests + done = 0 + last_update = 0.0 + part = target.with_name(target.name + '.part') + # B2:等待全局下载名额期间可被取消(与音乐批量 BATCH_ZIP_SEM 同语义) + sem = api_service.downloader._download_semaphore + if not sem.acquire_cancellable(cancel_ev): + raise DownloadCanceled("任务已取消") + try: + with _requests.get(url, stream=True, timeout=(15, 60), headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36', + 'Referer': 'https://music.163.com/', + }) as r: + r.raise_for_status() + total = total_size or int(r.headers.get('Content-Length') or 0) + with open(part, 'wb') as f: + for chunk in r.iter_content(chunk_size=256 * 1024): + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + if chunk: + f.write(chunk) + done += len(chunk) + now = time.time() + if now - last_update > 0.5: + last_update = now + if total: + pct = min(99, int(done / total * 100)) + msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}" + else: + msg = f"{progress_label} 已下载 {api_service._format_file_size(done)}" + if task_id: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t and t['status'] == 'running': + t['msg'] = msg + # 单集任务的字节级进度(批量任务保持按集数口径) + if t.get('type') == 'single' and total: + t['live_percent'] = min(99.0, float(pct)) + finally: + sem.release() + if part.exists(): + part.replace(target) + # 完整性校验:CDN 提前断流时 iter_content 会"正常"结束,文件静默截断 + # (F29 同口径:API 报告的期望大小时精确相等——95% 容差会交付截断的坏文件还扣次数; + # 期望未知时退化为非空校验,Content-Length 兜底值可能是压缩口径不做精确比对) + if total_size and done != total_size: + try: + os.remove(str(target)) + except OSError: + pass + raise DownloadException(f"下载不完整({done}/{total_size} 字节),请重试") + if not total_size and done <= 0: + try: + os.remove(str(target)) + except OSError: + pass + raise DownloadException("下载内容为空,请重试") + return done + + +def _podcast_resolve_audio(program: Dict[str, Any], quality: str, + cookies: Dict[str, str]) -> Dict[str, Any]: + """解析单集音频直链,失败抛 DownloadException""" + song_id = program.get('main_song_id') + if not song_id: + raise DownloadException("该单集缺少音频信息(mainSong)") + url_info = podcast_api.resolve_episode_url(song_id, cookies, quality) + if not url_info or not url_info.get('url'): + raise DownloadException("未获取到音频直链(单集可能已下架或受版权限制)") + return url_info + + +@app.route('/comments', methods=['GET', 'POST']) +@auth_system.login_required('评论获取', free=True) +def get_song_comments_api(): + """获取歌曲评论API""" + try: + data = api_service._safe_get_request_data() + song_id = data.get('id') or data.get('song_id') + limit = max(1, min(_safe_int(data.get('limit'), 20), 100)) + offset = max(0, _safe_int(data.get('offset'), 0)) + + validation_error = api_service._validate_request_params({'song_id': song_id}) + if validation_error: + return validation_error + + if limit > 100: + limit = 100 + + song_id = api_service._extract_music_id(song_id) + # ID 白名单:归一化后必须为纯数字(防 URL 路径拼接注入面,同 /album) + if not str(song_id).strip().isdigit(): + return APIResponse.error("无效的歌曲ID,请输入数字ID或分享链接") + result = song_comments(song_id, limit, offset) + return APIResponse.success(result, "获取评论成功") + except ValueError as e: + return APIResponse.error(f"参数格式错误: {str(e)}") + except APIException as e: + api_service.logger.error(f"获取评论失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取评论异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +# ============ 管理员设置(二维码登录 / Cookie 管理) ============ + +def _admin_client() -> QRLoginClient: + """创建二维码登录客户端(基于 cookie.txt)""" + return QRLoginClient() + + +@app.route('/admin/qr/create', methods=['POST']) +def admin_qr_create(): + """生成登录二维码(返回 SVG)""" + try: + data = api_service._safe_get_request_data() + auth_error = _verify_admin_password(data) + if auth_error: + return auth_error + + client = _admin_client() + unikey = client.qr_manager.generate_qr_key() + if not unikey: + return APIResponse.error("生成二维码失败", 500) + + import qrcode + from qrcode.image.svg import SvgPathImage + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M) + qr.add_data(f'https://music.163.com/login?codekey={unikey}') + qr.make(fit=True) + svg_bytes = qr.make_image(image_factory=SvgPathImage).to_string() + if isinstance(svg_bytes, bytes): + svg_bytes = svg_bytes.decode('utf-8') + + return APIResponse.success({ + 'unikey': unikey, + 'qr_svg': svg_bytes, + 'expires_in': 180 + }, "二维码生成成功") + except Exception as e: + api_service.logger.error(f"生成二维码异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/admin/qr/check', methods=['POST']) +def admin_qr_check(): + """轮询二维码登录状态,成功时自动保存 Cookie""" + try: + data = api_service._safe_get_request_data() + auth_error = _verify_admin_password(data) + if auth_error: + return auth_error + + unikey = data.get('unikey') + if not unikey: + return APIResponse.error("缺少 unikey 参数") + + client = _admin_client() + code, cookie_dict = client.qr_manager.check_qr_login(unikey) + + if code == 803: + # 登录成功,自动保存 Cookie + musu_u = cookie_dict.get('MUSIC_U', '') + if not musu_u: + return APIResponse.error("登录成功但未获取到 Cookie", 500) + # 第 94 条(P1-2):__csrf 一并入库——weapi 写接口(发评论等)的 + # csrf_token 取自 Cookie 的 __csrf,缺失则写接口恒被上游拒绝 + cookie = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;" + if cookie_dict.get('__csrf'): + cookie += f"__csrf={cookie_dict['__csrf']};" + if not client.save_cookie(cookie): + return APIResponse.error("登录成功但 Cookie 保存失败", 500) + _vip_cache_reset() # 账号已变更,VIP 探测缓存立即失效 + _global_music_u_cache_reset() # 第 92 条闸的全局 MUSIC_U 缓存同步刷新 + api_service.logger.info("二维码登录成功,Cookie 已自动保存") + return APIResponse.success( + {'status': 'success', 'cookie_info': client._get_cookie_info()}, + "登录成功,Cookie 已保存") + elif code == 801: + return APIResponse.success({'status': 'waiting'}, "等待扫码") + elif code == 802: + return APIResponse.success({'status': 'scanned'}, "已扫码,请在手机上确认登录") + elif code == 800: + return APIResponse.success({'status': 'expired'}, "二维码已过期") + else: + return APIResponse.success({'status': 'error', 'code': code}, f"登录状态异常({code})") + except Exception as e: + api_service.logger.error(f"检查登录状态异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/admin/cookie/status', methods=['POST']) +def admin_cookie_status(): + """获取当前 Cookie 状态""" + try: + data = api_service._safe_get_request_data() + auth_error = _verify_admin_password(data) + if auth_error: + return auth_error + + client = _admin_client() + return APIResponse.success(client._get_cookie_info(), "获取 Cookie 状态成功") + except Exception as e: + api_service.logger.error(f"获取 Cookie 状态异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/admin/cookie/delete', methods=['POST']) +def admin_cookie_delete(): + """删除(清除)Cookie,备份后删除,可重新扫码登录""" + try: + data = api_service._safe_get_request_data() + auth_error = _verify_admin_password(data) + if auth_error: + return auth_error + + client = _admin_client() + # F60:检查登出返回值,失败如实报告(原实现无论成败都回"已清除") + ok = client.logout() + if not ok: + info = client._get_cookie_info() + return APIResponse.error( + f"Cookie 清除失败(文件仍存在:{info.get('file_exists')}," + f"可能被占用或为挂载点,请手动删除)", 500) + _vip_cache_reset() # Cookie 已删除,VIP 探测缓存立即失效 + _global_music_u_cache_reset() # 第 92 条闸的全局 MUSIC_U 缓存同步刷新 + api_service.logger.info("管理员已清除 Cookie") + return APIResponse.success(client._get_cookie_info(), "Cookie 已清除") + except Exception as e: + api_service.logger.error(f"清除 Cookie 异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/admin/logs', methods=['POST']) +def admin_logs(): + """用户 IP 解析/下载记录(分页)""" + try: + data = api_service._safe_get_request_data() + auth_error = _verify_admin_password(data) + if auth_error: + return auth_error + + page = max(1, _safe_int(data.get('page'), 1)) + page_size = max(1, min(50, _safe_int(data.get('page_size'), 10))) + + logs = list(reversed(REQ_LOG)) # 最新在前 + total = len(logs) + start = (page - 1) * page_size + items = logs[start:start + page_size] + + return APIResponse.success({ + 'total': total, + 'page': page, + 'page_size': page_size, + 'pages': max(1, (total + page_size - 1) // page_size), + 'items': items, + }, "获取操作记录成功") + except ValueError: + return APIResponse.error("参数格式错误") + except Exception as e: + api_service.logger.error(f"获取操作记录异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +# ============ 个人歌单(游客扫码登录自己的账号) ============ + +def _user_client() -> QRLoginClient: + """创建个人用户二维码登录客户端(第 91 条后不再落盘 user_cookie.txt, + USER_COOKIE_FILE 仅作 QRLoginClient 构造参数遗留)""" + return QRLoginClient(USER_COOKIE_FILE) + + +@app.route('/user/qr/create', methods=['POST']) +def user_qr_create(): + """生成个人登录二维码(返回 SVG),游客无需密码""" + try: + client = _user_client() + unikey = client.qr_manager.generate_qr_key() + if not unikey: + return APIResponse.error("生成二维码失败", 500) + + import qrcode + from qrcode.image.svg import SvgPathImage + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M) + qr.add_data(f'https://music.163.com/login?codekey={unikey}') + qr.make(fit=True) + svg_bytes = qr.make_image(image_factory=SvgPathImage).to_string() + if isinstance(svg_bytes, bytes): + svg_bytes = svg_bytes.decode('utf-8') + + return APIResponse.success({ + 'unikey': unikey, + 'qr_svg': svg_bytes, + 'expires_in': 180 + }, "二维码生成成功") + except Exception as e: + api_service.logger.error(f"生成个人二维码异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/user/qr/check', methods=['POST']) +def user_qr_check(): + """轮询个人登录状态(第 91 条后不写服务器文件;站点账号登录时同步一份 + 到站点库供管理后台查看,第 98 条注释口径与 UI 对齐)""" + try: + data = api_service._safe_get_request_data() + unikey = data.get('unikey') + if not unikey: + return APIResponse.error("缺少 unikey 参数") + + client = _user_client() + code, cookie_dict = client.qr_manager.check_qr_login(unikey) + + if code == 803: + musu_u = cookie_dict.get('MUSIC_U', '') + if not musu_u: + return APIResponse.error("登录成功但未获取到 Cookie", 500) + # 个人登录信息仅返回给浏览器(存 localStorage),不写入服务器文件。 + # 第 94 条(P1-2):__csrf 一并返回(weapi 写接口的 csrf_token 来源) + cookie = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;" + if cookie_dict.get('__csrf'): + cookie += f"__csrf={cookie_dict['__csrf']};" + # 站点登录用户:同步记录网易云 Cookie 供后台管理查看 + try: + _site_user = auth_system.current_user() + if _site_user is not None: + auth_system.set_user_netease_cookie(_site_user['username'], cookie) + except Exception: + pass + api_service.logger.info("个人账号扫码登录成功(Cookie 已返回浏览器本地保存)") + return APIResponse.success( + {'status': 'success', 'cookie': cookie}, + "登录成功,Cookie 已保存在本机浏览器") + elif code == 801: + return APIResponse.success({'status': 'waiting'}, "等待扫码") + elif code == 802: + return APIResponse.success({'status': 'scanned'}, "已扫码,请在手机上确认登录") + elif code == 800: + return APIResponse.success({'status': 'expired'}, "二维码已过期") + else: + return APIResponse.success({'status': 'error', 'code': code}, f"登录状态异常({code})") + except Exception as e: + api_service.logger.error(f"检查个人登录状态异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/user/status', methods=['POST']) +def user_status(): + """获取个人用户登录状态与账号信息(Cookie 由浏览器本地保存并随请求传入)""" + try: + data = api_service._safe_get_request_data() + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.success({'cookie': {'is_valid': False}, 'user': None}, "个人账号未登录") + user = account_info(cookies) + return APIResponse.success({'cookie': {'is_valid': user is not None}, 'user': user}, "获取个人登录状态成功") + except Exception as e: + api_service.logger.error(f"获取个人登录状态异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/user/logout', methods=['POST']) +def user_logout(): + """退出个人账号登录(浏览器清除本地 Cookie 即可,服务器无可清除内容)""" + try: + return APIResponse.success({'is_valid': False}, "已退出登录") + except Exception as e: + api_service.logger.error(f"个人退出登录异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/user/playlists', methods=['POST']) +def user_playlists_api(): + """获取个人用户收藏/创建的歌单(含「我喜欢的音乐」)""" + try: + data = api_service._safe_get_request_data() + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录你的网易云账号", 401) + + account = account_info(cookies) + if not account or not account.get('userId'): + return APIResponse.error("获取账号信息失败,Cookie 可能已失效,请重新登录", 401) + + playlists = user_playlists(account['userId'], cookies) + # R5-P3:列表内歌单名/创建者昵称过遮蔽(与 /playlist、/search 口径一致) + if isinstance(playlists, list): + for pl in playlists: + if isinstance(pl, dict): + if pl.get('name'): + pl['name'] = _mask_server_nickname(pl['name']) + cr = pl.get('creator') + if isinstance(cr, dict) and cr.get('nickname'): + cr['nickname'] = _mask_server_nickname(cr['nickname']) + return APIResponse.success({'user': account, 'playlists': playlists}, "获取个人歌单成功") + except APIException as e: + api_service.logger.error(f"获取个人歌单失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取个人歌单异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/user/playlist', methods=['POST']) +def user_playlist_api(): + """获取个人歌单详情(使用个人 Cookie,支持「我喜欢的音乐」等私有歌单)""" + try: + data = api_service._safe_get_request_data() + playlist_id = data.get('id') + + validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) + if validation_error: + return validation_error + + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录你的网易云账号", 401) + + playlist_id = api_service._extract_music_id(playlist_id) + # 第 91 条(P3-3):与 /playlist 同口径的数字白名单 + if not str(playlist_id).strip().isdigit(): + return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") + result = playlist_detail(playlist_id, cookies) + # R5-P3:详情内歌单名/创建者昵称同样过遮蔽(铁律:服务器昵称对非 admin 不可见) + if isinstance(result, dict): + pl = result.get('playlist') + if isinstance(pl, dict): + if pl.get('name'): + pl['name'] = _mask_server_nickname(pl['name']) + cr = pl.get('creator') + if isinstance(cr, dict) and cr.get('nickname'): + cr['nickname'] = _mask_server_nickname(cr['nickname']) + return APIResponse.success({'playlist': result}, "获取个人歌单详情成功") + except APIException as e: + api_service.logger.error(f"获取个人歌单详情失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取个人歌单详情异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/user/collect', methods=['POST']) +@auth_system.login_required(free=True) +def user_collect(): + """收藏/取消收藏歌单或专辑(官方接口,真实收藏到个人账号)""" + try: + data = api_service._safe_get_request_data() + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录你的网易云账号", 401) + + collect_id = data.get('id') + collect_type = data.get('type', 'playlist') # playlist | album + action = _safe_int(data.get('action'), 1) # 1=收藏, 2=取消收藏(垃圾值不 500) + + if not collect_id: + return APIResponse.error("缺少 id 参数") + if collect_type not in ('playlist', 'album'): + return APIResponse.error("type 仅支持 playlist/album") + if action not in (1, 2): + return APIResponse.error("action 仅支持 1(收藏)/2(取消)") + + collect_id = api_service._extract_music_id(collect_id) + # 第 91 条(P3-3):数字白名单 + if not str(collect_id).strip().isdigit(): + return APIResponse.error("无效的ID,请输入数字ID或分享链接") + if collect_type == 'album': + result = album_subscribe(collect_id, cookies, action) + else: + result = playlist_subscribe(collect_id, cookies, action) + + return APIResponse.success({'result': result}, "收藏成功" if action == 1 else "取消收藏成功") + except APIException as e: + api_service.logger.error(f"收藏操作失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"收藏操作异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/user/comment', methods=['POST']) +@auth_system.login_required(free=True) +def user_comment(): + """发送/回复歌曲评论(官方 weapi 接口,使用个人账号)""" + try: + data = api_service._safe_get_request_data() + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录你的网易云账号", 401) + + song_id = data.get('song_id') or data.get('id') + content = (data.get('content') or '').strip() + action = _safe_int(data.get('action'), 1) # 1=发送评论, 2=回复评论(垃圾值不 500) + comment_id = data.get('comment_id') or data.get('reply_to') + + if not song_id: + return APIResponse.error("缺少歌曲 ID") + if not content: + return APIResponse.error("评论内容不能为空") + if len(content) > 140: + return APIResponse.error("评论内容不能超过 140 字") + if action not in (1, 2): + return APIResponse.error("action 仅支持 1(评论)/2(回复)") + if action == 2 and not comment_id: + return APIResponse.error("回复评论缺少目标评论 ID") + + song_id = api_service._extract_music_id(song_id) + # R5-P3:thread_id 由 song_id 拼接,垃圾值先拦下(与 /download 同口径) + if not str(song_id).isdigit(): + return APIResponse.error("无效的歌曲 ID") + thread_id = f"R_SO_4_{song_id}" + # 回复目标 ID 归一化:垃圾值不再触发 int() 500 + reply_target = _safe_int(comment_id, 0) if comment_id else 0 + if action == 2 and reply_target <= 0: + return APIResponse.error("回复评论的目标评论 ID 无效") + result = post_comment(thread_id, content, action, + reply_target if reply_target > 0 else None, cookies) + return APIResponse.success({'result': result}, "评论成功" if action == 1 else "回复成功") + except APIException as e: + api_service.logger.error(f"评论操作失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"评论操作异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +# ============ 全局账号歌单(管理员 cookie.txt,无需游客扫码) ============ + +VALID_QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] + + +@app.route('/global/playlists', methods=['GET', 'POST']) +@auth_system.admin_required +def global_playlists_api(): + """获取全局账号收藏/创建的歌单(含「我喜欢的音乐」)""" + try: + cookies = api_service._get_cookies() + account = account_info(cookies) + if not account or not account.get('userId'): + return APIResponse.error("全局账号未登录或 Cookie 已失效,请先在「管理员设置」扫码登录", 401) + + playlists = user_playlists(account['userId'], cookies) + return APIResponse.success({'user': account, 'playlists': playlists}, "获取全局账号歌单成功") + except APIException as e: + api_service.logger.error(f"获取全局账号歌单失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取全局账号歌单异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/global/playlist', methods=['GET', 'POST']) +@auth_system.admin_required +def global_playlist_api(): + """获取全局账号歌单详情(使用全局 Cookie,支持「我喜欢的音乐」等私有歌单)""" + try: + data = api_service._safe_get_request_data() + playlist_id = data.get('id') + + validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) + if validation_error: + return validation_error + + cookies = api_service._get_cookies() + playlist_id = api_service._extract_music_id(playlist_id) + # 第 91 条(P3-3):数字白名单 + if not str(playlist_id).strip().isdigit(): + return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") + result = playlist_detail(playlist_id, cookies) + return APIResponse.success({'playlist': result}, "获取全局账号歌单详情成功") + except APIException as e: + api_service.logger.error(f"获取全局账号歌单详情失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取全局账号歌单详情异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/api/playlist/play', methods=['GET', 'POST']) +def playlist_play_api(): + """公开的歌单播放 API(方案 C:需 API 密钥,管理后台「播放API密钥」可刷新) + + 返回歌单信息与每首歌的可播放直链(使用全局账号解析音质)。 + GET/POST 均支持,参数: + id - 必填,歌单 ID(或网易云分享链接) + key - 必填,播放 API 密钥(管理后台生成/刷新,旧密钥刷新即失效) + level - 选填,音质等级,默认 lossless(支持 standard/exhigh/lossless/hires/sky/jyeffect/jymaster/dolby) + limit - 选填,最多返回的歌曲数量,默认 100,最大 500(0 或超过按 500 封顶,第 99 条文案与实现对齐) + """ + try: + data = api_service._safe_get_request_data() + playlist_id = data.get('id') + level = data.get('level', 'lossless') + raw_limit = data.get('limit', 100) + + # 方案 C(第 78 条):密钥前置校验——放在一切上游调用之前, + # 无密钥的匿名请求 0 次上游调用(消除 1:501 的放大面) + provided = str(data.get('key') or request.args.get('key') or '') + expected = auth_system.get_setting('playlist_play_key', '') + if not expected or not provided or not hmac.compare_digest(provided, expected): + return APIResponse.error("需要有效的 API 密钥(请联系站点管理员获取)", 401) + + validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) + if validation_error: + return validation_error + + if level not in VALID_QUALITY_LEVELS: + return APIResponse.error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}") + + try: + limit = int(raw_limit) + except (TypeError, ValueError): + return APIResponse.error("limit 参数必须为整数") + if limit < 0: + return APIResponse.error("limit 参数不能为负数") + if limit == 0: + # F18:limit=0 表示"全部",但必须封顶 500(原实现可对 501 首歌单逐首放大上游请求) + limit = 500 + if limit > 500: + limit = 500 + + playlist_id = api_service._extract_music_id(playlist_id) + # 第 91 条(P3-3):数字白名单 + if not str(playlist_id).strip().isdigit(): + return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") + cookies = api_service._get_cookies() + + detail = playlist_detail(playlist_id, cookies) + all_tracks = detail.get('tracks', []) + tracks = all_tracks if limit == 0 else all_tracks[:limit] + + # 逐首解析播放直链,跳过无版权/无法解析的歌曲 + # O-2:小并发(4)解析——原实现 500 首串行逐首请求可达数分钟; + # 4 并发对齐批量下载窗口的节奏,不放大风控面 + from concurrent.futures import ThreadPoolExecutor + playable = [] + skipped = 0 + + def _resolve_track(t): + try: + url_info = url_v1(t['id'], level, cookies) + if url_info and url_info.get('data') and url_info['data'][0].get('url'): + d = url_info['data'][0] + return { + 'id': t['id'], + 'name': t.get('name', ''), + 'artists': t.get('artists', ''), + 'album': t.get('album', ''), + 'picUrl': t.get('picUrl', ''), + 'url': d.get('url', ''), + 'level': d.get('level', level), + 'level_name': api_service._get_quality_display_name(d.get('level', level)), + 'size': d.get('size', 0), + 'size_formatted': api_service._format_file_size(d.get('size', 0)), + 'type': d.get('type'), + 'bitrate': d.get('br'), + } + return None # 该曲不可播放 + except Exception: + return None + + with ThreadPoolExecutor(max_workers=4) as _pool: + for item in _pool.map(_resolve_track, tracks): + if item is not None: + playable.append(item) + else: + skipped += 1 + + response_data = { + 'playlist': { + 'id': detail.get('id'), + 'name': _mask_server_nickname(detail.get('name', '')), + 'coverImgUrl': detail.get('coverImgUrl', ''), + 'creator': _mask_server_nickname(detail.get('creator', '')), + 'trackCount': detail.get('trackCount', len(all_tracks)), + }, + 'account': 'admin', + 'level': level, + 'level_name': api_service._get_quality_display_name(level), + 'limit': limit or len(tracks), + 'total': len(all_tracks), + 'playable': len(playable), + 'skipped': skipped, + 'tracks': playable, + } + return APIResponse.success(response_data, "获取歌单播放列表成功") + except APIException as e: + api_service.logger.error(f"获取歌单播放列表失败: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + except Exception as e: + api_service.logger.error(f"获取歌单播放列表异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +# 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, +# 公网下账号栏高频刷新会连累服务器账号触发风控。 +# 第 91 条:修复潜伏缩进缺陷——原定义误缩进在 /account/vip 函数体内,每次调用 +# 都被重置(5 分钟缓存从未生效),且模块级 _vip_cache_reset 引用它必然 NameError +_VIP_CACHE = {'data': None, 'ts': 0.0} +_VIP_USER_CACHE = {} # user 路径按 cookie 哈希的 5 分钟缓存 + + +def _vip_cache_reset() -> None: + """重新扫码登录/删除 Cookie 后调用,强制下次探测走真实接口""" + _VIP_CACHE['data'] = None + _VIP_CACHE['ts'] = 0.0 + + +@app.route('/account/vip', methods=['GET', 'POST']) +def account_vip_info(): + """获取当前解析账号的 VIP 类型(admin=全局管理员账号 / user=个人账号)""" + try: + data = api_service._safe_get_request_data() + account = data.get('account', 'admin') + + if account == 'user': + user_cookies = _resolve_user_cookies(data) + if not user_cookies: + return APIResponse.success( + {'account': 'user', 'logged_in': False, 'vip_type': None, 'vip_name': '未登录', + 'levels_ok': []}, + "个人账号未登录") + # 第 91 条(P3-7):user 路径加 5 分钟缓存——_detect_vip 每次调用 = + # 8 次音质探测 + 1 次账号接口,匿名 30/分/IP 打满即 270 次上游调用/分 + ck = '|'.join(f'{k}={v}' for k, v in sorted(user_cookies.items())) + ck_hash = hashlib.md5(ck.encode('utf-8')).hexdigest() + cached_user = _VIP_USER_CACHE.get(ck_hash) + if cached_user is not None and time.time() - cached_user['ts'] <= cached_user.get('ttl', 300): + return APIResponse.success(dict(cached_user['data'], account='user'), + "获取个人账号信息成功(缓存)") + vip_type, method, levels_ok = _detect_vip(user_cookies) + info = account_info(user_cookies) + user_payload = { + 'account': 'user', 'logged_in': True, 'vip_type': vip_type, + 'vip_name': _vip_name(vip_type), 'detected_by': method, + 'levels_ok': levels_ok, + 'nickname': (info or {}).get('nickname', ''), + } + # 第 100 条(对抗复审):降级结果(探测列表为空 = 单飞超时/预算耗尽)只缓存 + # 30s——上游恢复后 VIP 账号不再被"普通用户"钉满 5 分钟;正常结果仍 300s + _vip_ttl = 30 if not levels_ok else 300 + if len(_VIP_USER_CACHE) > 500: + _VIP_USER_CACHE.clear() + _VIP_USER_CACHE[ck_hash] = {'ts': time.time(), 'ttl': _vip_ttl, 'data': user_payload} + return APIResponse.success(user_payload, "获取个人账号信息成功") + + # admin(默认)—— 探测结果缓存 5 分钟 + cached = _VIP_CACHE['data'] + if cached is None or time.time() - _VIP_CACHE['ts'] > _VIP_CACHE.get('ttl', 300): + admin_cookies = api_service._get_cookies() + vip_type, method, levels_ok = _detect_vip(admin_cookies) + info = account_info(admin_cookies) + cached = { + 'vip_type': vip_type, 'vip_name': _vip_name(vip_type), + 'detected_by': method, 'levels_ok': levels_ok, + 'nickname': (info or {}).get('nickname', ''), + 'logged_in': info is not None, + 'note': '' if info else '账号接口未返回会员信息,已通过歌曲音质探测接口检测', + } + _VIP_CACHE['data'] = cached + _VIP_CACHE['ts'] = time.time() + _VIP_CACHE['ttl'] = 30 if not levels_ok else 300 # 第 100 条:降级结果短缓存 + # 服务器账号昵称仅站点管理员可见;VIP 等级与可解析音质对所有人开放 + _cu = auth_system.current_user() + _is_admin = _cu is not None and _cu['role'] == 'admin' + payload = dict(cached) + payload['account'] = 'admin' + payload['nickname'] = cached['nickname'] if _is_admin else '' + return APIResponse.success(payload, "获取全局账号信息成功") + except Exception as e: + api_service.logger.error(f"获取账号VIP信息异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/download', methods=['POST']) +@app.route('/Download', methods=['POST']) # 向后兼容(F10:付费动作仅接受 POST,杜绝跨站 GET 顶层导航扣费) +@auth_system.login_required('音乐下载') +def download_music_api(): + """下载音乐API""" + try: + # 获取请求参数 + data = api_service._safe_get_request_data() + music_id = data.get('id') + quality = data.get('quality', 'lossless') + return_format = data.get('format', 'file') # file 或 json + + # 参数验证 + validation_error = api_service._validate_request_params({'music_id': music_id}) + if validation_error: + return validation_error + # R5-P3:ID 数字白名单(与 /song 同口径;付费入口优先收紧) + music_id = str(music_id).strip() + if not music_id.isdigit(): + return APIResponse.error("无效的歌曲 ID") + + # 验证音质参数 + valid_qualities = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] + if quality not in valid_qualities: + return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_qualities)}") + + # 验证返回格式 + if return_format not in ['file', 'json']: + return APIResponse.error("返回格式只支持 'file' 或 'json'") + + music_id = api_service._extract_music_id(music_id) + + # 解析账号选择:user=游客个人账号,默认 admin=全局管理员账号 + account = data.get('account', 'admin') + if account == 'user': + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) + else: + cookies = api_service._get_cookies() + + # 下载配置参数 + filename_format = data.get('filename_format', 'artist_song') # song | artist_song | song_artist + write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') + meta_fields = _parse_meta_fields(data.get('meta_fields')) + lyric_format = data.get('lyric_format', 'lrc') # lrc | srt + # F-C:白名单——垃圾 lyric_format 会拼出覆盖音频条目的 ZIP arcname + if lyric_format not in ('lrc', 'srt'): + lyric_format = 'lrc' + need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') + + # return_format=json:同步下载并返回歌曲信息(不产生后台任务) + if return_format == 'json': + # 一次预取 URL/详情/歌词(3 接口并行),同时供下载与响应使用, + # 避免下载器内部再走一遍 API + try: + info = api_service.downloader.get_music_info(music_id, quality, cookies) + except DownloadException as e: + # R5-P3:对外泛化(原始错误含上游主机/URL 细节),细节留日志 + api_service.logger.error(f"获取音乐信息失败: {e}") + return APIResponse.error("获取失败:歌曲不存在或暂无版权", 404) + # 按前端选择的命名格式生成本次响应的文件名 + base = _build_download_base(filename_format, {'name': info.name, 'artist_string': info.artists}) + filename = f"{base}.{info.file_type}" + try: + download_result = api_service.downloader.download_music_file( + music_id, quality, + write_meta=write_meta, + meta_fields=meta_fields, + cookies=cookies, + music_info=info, + ) + if not download_result.success: + # R5-P3:对外泛化(error_message 可能含上游细节),原文留日志 + api_service.logger.error(f"下载失败: {download_result.error_message}") + return APIResponse.error("下载失败,请稍后重试或更换音质", 500) + file_path = Path(download_result.file_path) + api_service.logger.info(f"下载完成: {filename}") + except DownloadException as e: + api_service.logger.error(f"下载异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + try: + _record_log('下载', auth_system._client_ip(), info.name) + except Exception: + pass + response_data = { + 'music_id': music_id, + 'name': info.name, + 'artist': info.artists.replace('/', ', '), + 'album': info.album, + 'quality': quality, + 'quality_name': api_service._get_quality_display_name(quality), + 'file_type': info.file_type, + 'file_size': info.file_size, + 'file_size_formatted': api_service._format_file_size(info.file_size), + # P3-29:不再回传服务器绝对路径(路径信息披露);交付语义只需文件名, + # 文件在 downloads 根目录按 35 分钟口径由缓存清扫回收 + 'filename': filename, + 'duration': info.duration * 1000 + } + # A2/B8:json 模式无 task_id,成功交付即按请求级账本键结算 delivered=1 + _gkey = getattr(g, 'billing_ledger_key', None) + if _gkey: + try: + auth_system.ledger_settle(_gkey, 1, 'delivered') + except Exception as _e: + # 结算失败不改交付结果;账本行留 open,重启对账兜底退款(宁商家小损不坑用户) + api_service.logger.error(f"json 交付结算失败(重启对账兜底): {_gkey} {_e}") + return APIResponse.success(response_data, "下载完成") + + # return_format=file:注册后台任务,立即返回 task_id(支持进度/取消/完成后自动清理) + task_id = str(data.get('task_id') or '').strip() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + _touch_device(device_id) + _cur_user = auth_system.current_user() + _reserved = 0 if (not _cur_user or _cur_user['role'] == 'admin' + or int(_cur_user['duration_seconds'] or 0) == -1) else 1 + task = { + 'type': 'single', + 'billing_user_id': int(_cur_user['id']) if _cur_user else 0, + 'billing_reserved': _reserved, + 'total': 1, + 'done': 0, + 'failed': 0, + 'status': 'queued', + 'start': time.time(), + 'msg': '排队中...', + 'cancel': threading.Event(), + 'pause': threading.Event(), + 'device_id': device_id, + 'result': None, + 'params': { + 'music_id': music_id, + 'quality': quality, + 'filename_format': filename_format, + 'write_meta': write_meta, + 'meta_fields': meta_fields, + 'lyric_format': lyric_format, + 'need_zip': need_zip, + 'cookies': cookies, + 'remote_ip': auth_system._client_ip(), + }, + } + reg_err = _register_download_task(task, _reserved, task_id_hint=task_id) + if reg_err: + return APIResponse.error(reg_err, 429) + task_id = task['task_id'] + start_err = _start_task_thread(task_id, _single_download_worker, (task_id,)) + if start_err: + return APIResponse.error(start_err, 500) + return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "下载任务已提交") + except Exception as e: + api_service.logger.error(f"下载音乐异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +def _billing_ids_count() -> int: + """按次计费:解析请求中的 ids 数量(一首歌 = 1 次;100 首歌单打包 = 100 次) + F23:与路由的清洗规则完全一致(提取→去重),预扣数=实际执行数,重复 ID 不多扣""" + try: + data = api_service._safe_get_request_data() + raw = data.get('ids') + if isinstance(raw, str): + raw_list = raw.split(',') + else: + raw_list = list(raw or []) + ids, seen = [], set() + for x in raw_list: + try: + mid = api_service._extract_music_id(x) + except Exception: + mid = str(x).strip() + if mid and mid not in seen: + seen.add(mid) + ids.append(mid) + # 与路由同口径:单任务上限 500(超出部分路由会 400 拒绝并退款, + # 预扣数先封顶可让"请分批提交"提示优先于"次数不足") + return max(1, min(len(ids), 500)) + except Exception: + return 1 + + +@app.route('/download/batch_zip', methods=['POST']) +@auth_system.login_required('批量ZIP下载', cost=_billing_ids_count) +def download_batch_zip(): + """提交批量打包任务(后台线程执行,立即返回 task_id,前端轮询进度/取消) + + 改为后台任务的原因: + - 原方案在请求线程里一次性构建并回传大 ZIP,长连接易被代理/浏览器超时中断(~60% 时失败) + - 后台执行 + 轮询,连接断开不影响任务继续;音频入包即删,缓解磁盘占用 + """ + try: + data = api_service._safe_get_request_data() + task_id_hint = str(data.get('task_id') or '').strip() + raw_ids = data.get('ids') + if isinstance(raw_ids, str): + raw_id_list = [x.strip() for x in raw_ids.split(',') if x.strip()] + else: + # F46:数组分支元素可能是数字,统一 str() 后再清洗(原实现直接 x.strip() 会 500) + raw_id_list = [str(x).strip() for x in (raw_ids or []) if str(x).strip()] + if not raw_id_list: + return APIResponse.error("缺少 ids 参数") + # R5-P3:ID 数字白名单(与 /download 同口径;垃圾 ID 不再透传上游) + non_digit = [x for x in raw_id_list if not x.isdigit()] + if non_digit: + return APIResponse.error("ids 中含无效歌曲 ID") + + quality = data.get('quality', 'lossless') + # 音质白名单校验(与 /download 同口径:垃圾音质直接 400,而不是让整批全失败再退费) + valid_qualities = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] + if quality not in valid_qualities: + return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_qualities)}") + filename_format = data.get('filename_format', 'artist_song') + write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') + meta_fields = _parse_meta_fields(data.get('meta_fields')) + lyric_format = data.get('lyric_format', 'lrc') + # F-C:白名单——垃圾 lyric_format 会在 ZIP 内覆盖音频条目 + if lyric_format not in ('lrc', 'srt'): + lyric_format = 'lrc' + try: + concurrency = int(data.get('concurrency', 8) or 8) + concurrency = max(1, min(10, concurrency)) + except (TypeError, ValueError): + concurrency = 8 + + account = data.get('account', 'admin') + if account == 'user': + # 个人账号 Cookie 由浏览器传入(与 /song 等接口一致)。 + # 第 91 条(P3-2):删除 user_cookie.txt 服务器回退(跨账号复用残留 Cookie 的洞) + cookies = _resolve_user_cookies(data) + if not cookies: + return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) + else: + cookies = api_service._get_cookies() + + # 提前解析并去重 ID,避免同一首歌被重复下载 + ids = [] + seen = set() + for raw_id in raw_id_list: + try: + mid = api_service._extract_music_id(raw_id) + except Exception: + mid = str(raw_id).strip() + if mid and mid not in seen: + seen.add(mid) + ids.append(mid) + if not ids: + return APIResponse.error("缺少 ids 参数") + # 数量上限:防止超大歌单长期占满批量队列(播客打包同为 500 上限) + if len(ids) > 500: + return APIResponse.error("单个批量任务最多 500 首,请分批提交") + + # 注册任务,后台线程执行(避免长连接被中断) + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + _touch_device(device_id) + _cur_user = auth_system.current_user() + _unlimited = (not _cur_user or _cur_user['role'] == 'admin' + or int(_cur_user['duration_seconds'] or 0) == -1) + _reserved = 0 if _unlimited else len(ids) + task = { + 'type': 'batch', + 'billing_user_id': int(_cur_user['id']) if _cur_user else 0, + 'billing_reserved': _reserved, + 'billing_cost': len(ids), # 名义歌曲数(对账用);实际退款以账本 reserved 为准 + 'total': len(ids), + 'done': 0, + 'failed': 0, + 'status': 'queued', + 'start': time.time(), + 'msg': '排队中...', + 'cancel': threading.Event(), + 'pause': threading.Event(), + 'device_id': device_id, + 'result': None, + 'params': { + 'ids': ids, + 'quality': quality, + 'filename_format': filename_format, + 'write_meta': write_meta, + 'meta_fields': meta_fields, + 'lyric_format': lyric_format, + 'concurrency': concurrency, + 'cookies': cookies, + }, + } + reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint) + if reg_err: + return APIResponse.error(reg_err, 429) + task_id = task['task_id'] + start_err = _start_task_thread(task_id, _batch_zip_worker, (task_id,)) + if start_err: + return APIResponse.error(start_err, 500) + return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "批量打包任务已提交") + except Exception as e: + api_service.logger.error(f"批量打包提交异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int: + """批量任务结算(F25/F26:走持久账本,按实际预扣-实际交付幂等退款; + 重复调用/重启后调用都只会退一次)""" + try: + refund = auth_system.ledger_settle(task_id, ok_count, final_status) + except Exception: + # 第 97 条(P3):本函数多在异常处理器内被调用——结算再抛错(库忙超时) + # 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等) + api_service.logger.error( + f"批量结算异常(留对账兜底): {task_id}\n{traceback.format_exc()}") + return 0 + if refund: + try: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) or {} + uid = int(t.get('billing_user_id') or 0) + u = auth_system.get_user_by_id(uid) if uid else None + uname = u['username'] if u else 'system' + remaining = u['duration_seconds'] if u else '?' + auth_system.add_log('success', uname, 'system', '批量退费', + f'失败部分 {refund} 次已退回,剩余 {remaining} 次') + except Exception: + pass + return refund + + +def _batch_zip_worker(task_id: str) -> None: + """后台批量打包:窗口化下载 → 写 ZIP(音频免压缩),支持取消与断线暂停 + + - 断线暂停:设备心跳超时后,当前窗口下载完即释放 BATCH_ZIP_SEM 名额给下一位, + 保存进度进入暂停等待;重新打开网站(心跳恢复)后自动回到队列继续下载 + - 音频入包即删,缓解磁盘占用 + """ + import tempfile as _tempfile + from concurrent.futures import ThreadPoolExecutor, as_completed + + zip_tmp_path: Optional[str] = None + zipped_files: List[str] = [] + ok_count = 0 + failed: List[str] = [] + used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖) + info_pool = None + info_futures = {} + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '下载打包中...' + params = task['params'] + cancel_ev = task['cancel'] + pause_ev = task['pause'] + ids = list(params['ids']) + quality = params['quality'] + filename_format = params['filename_format'] + write_meta = params['write_meta'] + meta_fields = params['meta_fields'] + lyric_format = params['lyric_format'] + concurrency = params['concurrency'] + cookies = params['cookies'] + batch_start = time.time() + + # P2-7:预取按窗口进行——原实现一次性提交全部(最多 500 首 ×3 接口)预取任务, + # 大歌单瞬间以 10 并发连发上游约 1500 次请求打风控;改为每个下载窗口只预取本窗口 + # 的歌(与下载节奏对齐),_download_one 语义不变(等预取结果再下载) + from concurrent.futures import ThreadPoolExecutor as _InfoPool + info_pool = _InfoPool(max_workers=min(4, max(1, concurrency))) + + def _prefetch(window_ids): + for mid in window_ids: + if mid not in info_futures: + info_futures[mid] = info_pool.submit( + api_service.downloader.get_music_info, mid, quality, cookies + ) + + def _download_one(mid): + """并行下载单元:等待预取信息 → 下载 → 复用预取歌词与缓存封面(零重复请求)""" + info = info_futures[mid].result() + result = api_service.downloader.download_music_file( + mid, quality, + write_meta=write_meta, + meta_fields=meta_fields, + cookies=cookies, + cancel_event=cancel_ev, + music_info=info, + ) + extra = {'lyric': '', 'cover': b''} + if result.success: + # 歌词已随 get_music_info 预取返回,无需再调一次歌词接口 + extra['lyric'] = getattr(info, 'lyric', '') or '' + # 封面走共享缓存(同一专辑只拉一次网络);取消后不再发起新请求 + if not cancel_ev.is_set() and info.pic_url: + cover = fetch_cover_bytes(info.pic_url) + if cover: + extra['cover'] = cover + return result, extra + + def _update_progress() -> None: + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['done'] = ok_count + len(failed) + DOWNLOAD_TASKS[task_id]['failed'] = len(failed) + + def _wait_if_paused() -> None: + """暂停时等待恢复或取消(不持有 BATCH_ZIP_SEM)""" + if not (pause_ev.is_set() or task['status'] == 'paused'): + return + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'paused' + task['done'] = ok_count + len(failed) + task['msg'] = '已暂停(重新打开页面自动恢复)' + while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'): + time.sleep(1) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + pause_ev.clear() + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'running' + task['msg'] = '继续下载...' + + # P3-26:ZIP 构建放 downloads 卷(原实现落系统 %TEMP%——500 首无损可达 GB 级, + # 小系统盘一次大单即塞爆;单曲/播客早已指定 dir,唯批量漏了) + # F-A(第 76 条):临时包建在任务专属目录 tasks//——原放根目录会被 + # 缓存清扫线程按 mtime 回收(Linux 上已打开文件也能被 unlink,Docker 部署 + # 排队超 35 分钟即出现"扣费后领取 404"的悬空交付),根目录从此碰不到交付物 + _task_dir_for_zip = api_service.downloads_path / 'tasks' / task_id + _task_dir_for_zip.mkdir(parents=True, exist_ok=True) + zip_valid = False # F-B:ZIP 中央目录完整写出才置 True,否则一律按失败全额退费 + with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip', + dir=str(_task_dir_for_zip), + delete=False) as zip_tmp: + zip_tmp_path = zip_tmp.name + # 音频本身已压缩,用 ZIP_STORED(免压缩)大幅提速;歌词等文本仍压缩 + with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf: + # P3-27:不用 with 管理下载池——取消时 with 退出会 wait=True 等在途 + # future(其内部阻塞在预取 HTTP 上最长 30s),改为不等待 + 取消排队任务 + pool = ThreadPoolExecutor(max_workers=concurrency) + try: + while ids: + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + # 断线暂停:释放名额等待恢复/取消,恢复后重新进入排队 + _wait_if_paused() + # 每个窗口持有 BATCH_ZIP_SEM(同一时刻全局最多 3 个批量任务在下载) + # F91:等待名额期间可被取消,不再无限阻塞线程与退款 + if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev): + raise DownloadCanceled("任务已取消") + try: + if cancel_ev.is_set() or pause_ev.is_set() or task['status'] == 'paused': + continue # 立即释放名额,回到循环顶部处理暂停 + window = ids[:concurrency] + ids = ids[concurrency:] + _prefetch(window) # P2-7:只预取本窗口 + future_map = { + pool.submit(_download_one, mid): mid + for mid in window + } + for future in as_completed(future_map): + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + mid = future_map[future] + try: + result, extra = future.result() + except DownloadCanceled: + raise + except Exception as e: + failed.append(f"{mid}({e})") + _update_progress() + continue + if not result.success: + failed.append(str(mid)) + _update_progress() + continue + fp = Path(result.file_path) + if not fp.exists(): + # 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务 + failed.append(str(mid)) + _update_progress() + continue + mi = result.music_info + base = _build_download_base(filename_format, {'name': mi.name, 'artist_string': mi.artists}) + # 同名去重:歌单里同一首歌出现两次/同名不同版本时, + # 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首) + # F-C:音频/歌词/封面三个 arcname 都要查重——同名同歌手但 + # 音源扩展不同(flac vs mp3)时歌词/封面条目也会互相覆盖 + final_name = f"{base}.{mi.file_type}" + lrc_name = f"{base}.{lyric_format}" + jpg_name = f"{base}.jpg" + k = 2 + while (final_name in used_arcnames + or lrc_name in used_arcnames + or jpg_name in used_arcnames): + final_name = f"{base} ({k}).{mi.file_type}" + lrc_name = f"{base} ({k}).{lyric_format}" + jpg_name = f"{base} ({k}).jpg" + k += 1 + used_arcnames.update((final_name, lrc_name, jpg_name)) + # F-D:exists 与 zf.write 之间的缓存竞态(另一任务缓存 miss + # 删文件)按单曲失败计,不再让整批转 error + try: + zf.write(str(fp), arcname=final_name, compress_type=zipfile.ZIP_STORED) + except OSError as e: + failed.append(f"{mid}(缓存文件读取失败: {e})") + _update_progress() + continue + # F27:缓存文件是跨任务共享资源,任务不得删除; + # 磁盘回收交给监控线程的下载缓存清扫(按文件年龄) + # 歌词与封面已在并行下载线程中拉取,直接写入 ZIP(无网络等待) + lrc = extra.get('lyric', '') + if lrc: + try: + zf.writestr(lrc_name, _format_lyric(lrc, lyric_format), compress_type=zipfile.ZIP_DEFLATED) + except Exception: + pass + if extra.get('cover'): + try: + zf.writestr(jpg_name, extra['cover'], compress_type=zipfile.ZIP_STORED) + except Exception: + pass + ok_count += 1 + _update_progress() + finally: + BATCH_ZIP_SEM.release() + finally: + # P3-27:取消/异常时不等待在途下载线程(其可能阻塞在预取 HTTP 上), + # 排队任务一并取消;在途线程自行收尾(.part 残片由续传/清扫兜底) + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + # 第 93 条复审 P2-1 修正:zip_valid = True 必须在内层 with zipfile 退出之后 + # (中央目录在 close 时才写出)——原缩进 16 在 with 内部,守卫不可达 + zip_valid = True + + if ok_count > 0: + with BATCH_DURATIONS_LOCK: + BATCH_DURATIONS.append(time.time() - batch_start) + + # F-B:ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛 + if not zip_valid: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + zip_tmp_path = None + refunded = _settle_batch_billing(task_id, 0, 'error') + _mark_task_terminal(task_id, 'error', + f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded + else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['failed'] = t.get('failed', 0) + ok_count + t['refunded'] = refunded + api_service.logger.error(f"批量ZIP构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次") + return + + # F41:全部失败不发布空 ZIP、不标 done —— 按失败收敛并全额退费 + if ok_count == 0 and failed: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + zip_tmp_path = None + refunded = _settle_batch_billing(task_id, 0, 'error') + _mark_task_terminal(task_id, 'error', + f"全部失败({len(failed)} 首)" + (f",已退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['failed'] = len(failed) + DOWNLOAD_TASKS[task_id]['refunded'] = refunded + api_service.logger.info(f"批量ZIP全部失败: {task_id} 失败{len(failed)} 退回{refunded}次") + return + + refunded = _settle_batch_billing(task_id, ok_count) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + # S3:发布前锁内复查取消/失效状态——取消请求落在"检查后、拿锁前"的窗口也不得覆盖终态 + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = ok_count + len(failed) + t['failed'] = len(failed) + t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '') + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"批量下载_{ok_count}首.zip", + 'audio_files': [], # F27:缓存文件不归任务所有,不进入清理清单 + } + api_service.logger.info(f"批量ZIP完成: 成功{ok_count} 失败{len(failed)} 退回{refunded}次") + except DownloadCanceled: + # F24:部分成功后取消——已入包部分照常交付(成果守恒),未完成部分按账本退费 + # F-B:ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛 + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered, 'canceled') + if delivered > 0 and zip_tmp_path: + _mark_task_terminal(task_id, 'canceled', + f"已取消(已打包 {ok_count} 首可领取)" + + (f",退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"批量下载_{ok_count}首.zip", + 'audio_files': [], + } + else: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + _mark_task_terminal(task_id, 'canceled', + '任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + api_service.logger.info(f"批量ZIP已取消: {task_id}(成功{ok_count} 退回{refunded}次)") + except Exception as e: + # F24:通用异常与取消同一套交付/结算规则:有成果则交付,按账本退未交付部分 + # F-B:ZIP 未正常完成(写坏)时按全额退费,不发布坏包 + # 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化 + api_service.logger.error(f"批量打包异常: {task_id} {e}\n{traceback.format_exc()}") + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered, 'error') + if delivered > 0 and zip_tmp_path: + _mark_task_terminal(task_id, 'error', + f"部分歌曲已打包完成,可领取" + + (f",退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"批量下载_{ok_count}首.zip", + 'audio_files': [], + } + else: + _mark_task_terminal(task_id, 'error', + '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.error(f"批量ZIP任务异常: {task_id} {e}(成功{ok_count} 退回{refunded}次)\n{traceback.format_exc()}") + finally: + # 回收信息预取线程池,避免线程泄漏(未启动的预取任务一并取消) + if info_pool is not None: + try: + info_pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + info_pool.shutdown(wait=False) + + +def _podcast_download_worker(task_id: str) -> None: + """后台播客单集下载:解析单集 → 音质降级取直链 → 流式下载(可选 ZIP 打包) + + 任务 type 为 'single',完成后经 /download/result 交付,与单曲下载共用交付通道。 + F28:落盘文件进入任务专属目录且带节目 ID,跨任务永不重名;清理只动本任务目录。 + """ + import tempfile as _tempfile + + file_path_str: Optional[str] = None + zip_tmp_path: Optional[str] = None + task_dir: Optional[Path] = None + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '解析单集信息...' + params = task['params'] + cancel_ev = task['cancel'] + program_id = params['program_id'] + quality = params['quality'] + need_zip = params['need_zip'] + cookies = params['cookies'] + remote_ip = params.get('remote_ip') or '-' + + program = podcast_api.get_program_detail(program_id, cookies) + url_info = _podcast_resolve_audio(program, quality, cookies) + + serial = int(program.get('serial_num') or 0) + name = program.get('name') or program.get('main_song_name') or '播客单集' + prefix = f"{serial:03d} - " if serial else '' + base = _podcast_safe_name(f"{prefix}{name}") + ext = (url_info.get('type') or 'mp3').lower().lstrip('.') + # F28:磁盘名附节目 ID + 任务目录隔离(两个任务同下载一集互不覆盖、互不删除) + task_dir = api_service.downloads_path / 'tasks' / task_id + task_dir.mkdir(parents=True, exist_ok=True) + disk_name = f"{base} [{program_id}].{ext}" + target = task_dir / disk_name + filename = f"{base}.{ext}" # 交付名保持干净(不带 ID) + + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS[task_id] + t['msg'] = '下载中...' + _podcast_stream_download(url_info['url'], target, url_info.get('size', 0), + cancel_ev, task_id, '下载中') + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + file_path_str = str(target) + + result: Dict[str, Any] + if need_zip: + fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip', + dir=str(task_dir)) + os.close(fd) + with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(file_path_str, arcname=filename) + cover_url = program.get('cover_url') or '' + if cover_url: + try: + cover = fetch_cover_bytes(cover_url) + if cover: + zf.writestr(f"{base}.jpg", cover) + except Exception: + pass + # F17:info.txt 不再写直链(真实 URL 不应随交付文件外泄) + info_txt = ( + f"单集: {name}\n" + f"播客: {program.get('radio_name') or ''}\n" + f"主播: {program.get('dj_name') or ''}\n" + f"时长: {program.get('duration_text') or ''}\n" + f"音质: {api_service._get_quality_display_name(url_info.get('level', quality))}\n" + ) + zf.writestr("info.txt", info_txt) + # F42:打包阶段后同样以取消检查收口 + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + result = { + 'file_path': file_path_str, + 'zip_path': zip_tmp_path, + 'filename': f"{base}.zip", + 'mimetype': 'application/zip', + 'audio_files': [file_path_str], + } + else: + result = { + 'file_path': file_path_str, + 'filename': filename, + 'mimetype': f"audio/{ext}", + 'audio_files': [file_path_str], + } + + # F42:最终发布前的取消检查(末段取消不得覆盖为 done) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = 1 + t['msg'] = '下载完成' + t['result'] = result + # C1:交付已落定;结算失败不再把任务改标 error(同单曲) + try: + auth_system.ledger_settle(task_id, 1, 'delivered') + except Exception as _e: + api_service.logger.error(f"播客单集交付结算失败(重启对账兜底): {task_id} {_e}") + try: + _record_log('下载', remote_ip, f"[播客] {name}") + except Exception: + pass + api_service.logger.info(f"播客单集下载完成: {filename} ({task_id})") + except DownloadCanceled: + _mark_task_terminal(task_id, 'canceled', '任务已取消') + try: + auth_system.ledger_settle(task_id, 0, 'canceled') + except Exception: + pass + if task_dir is not None: + shutil.rmtree(str(task_dir), ignore_errors=True) + api_service.logger.info(f"播客单集下载已取消: {task_id}") + except DownloadException as e: + _mark_task_terminal(task_id, 'error', str(e)) + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir is not None: + shutil.rmtree(str(task_dir), ignore_errors=True) + api_service.logger.error(f"播客单集下载失败: {task_id} {e}") + except Exception as e: + # 第 97 条(P3):非预期异常原文 logger 先行,用户可见文案泛化(第 84 条教训) + api_service.logger.error(f"播客单集下载异常: {task_id} {e}\n{traceback.format_exc()}") + _mark_task_terminal(task_id, 'error', '下载失败,请稍后重试') + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir is not None: + shutil.rmtree(str(task_dir), ignore_errors=True) + + +def _podcast_cleanup_files(paths: List[Optional[str]]) -> None: + """清理播客任务产生的临时/残留文件""" + for p in paths: + if not p: + continue + try: + os.remove(p) + except OSError: + pass + # 连带清理可能残留的 .part 文件 + try: + part = Path(str(p) + '.part') + if part.exists(): + part.unlink() + except OSError: + pass + + +def _podcast_zip_worker(task_id: str) -> None: + """整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)""" + import tempfile as _tempfile + + zip_tmp_path: Optional[str] = None + ok_count = 0 + failed: List[str] = [] + # 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等 + # 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程 + zip_valid = False + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '解析单集列表...' + params = task['params'] + cancel_ev = task['cancel'] + pause_ev = task['pause'] + radio_id = params['radio_id'] + preset_ids = params.get('ids') + limit = params['limit'] + quality = params['quality'] + cookies = params['cookies'] + remote_ip = params.get('remote_ip') or '-' + + # 组装单集清单:整台电台(正序全量)或指定单集ID列表 + if radio_id: + episodes = podcast_api.list_radio_all_program_ids(radio_id, cookies, max_count=limit) + if not episodes: + raise DownloadException("该电台暂无可下载的单集") + else: + episodes = [] + for pid in (preset_ids or []): + try: + prog = podcast_api.get_program_detail(pid, cookies) + episodes.append({ + 'id': prog['id'], + 'name': prog['name'], + 'main_song_id': prog['main_song_id'], + 'serial_num': prog['serial_num'], + 'cover_url': prog['cover_url'], + 'detail': prog, + }) + except APIException as ae: + failed.append(f"{pid}({ae})") + episodes = episodes[:limit] + if not episodes: + raise DownloadException("没有可下载的单集") + + radio_name = _podcast_safe_name(params.get('radio_name') or '', '播客') + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['total'] = len(episodes) + + # 按次计费补扣(F21):整台电台提交时只预扣 1 次,此处按实际单集数补足差额。 + # 补扣失败(余额不足/数据库异常)任务必须失败并按账本退费——不允许无账本继续交付 + try: + _buid = params.get('billing_uid') + _extra = len(episodes) - int(params.get('billed') or 1) + if _buid and _extra > 0: + if not auth_system.ledger_supplement(task_id, _extra): + raise DownloadException( + f"补扣下载次数失败(需补扣 {_extra} 次),任务已取消,预扣部分将退回") + if _buid: + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['billing_user_id'] = int(_buid) + DOWNLOAD_TASKS[task_id]['billing_cost'] = len(episodes) + except DownloadCanceled: + raise + except DownloadException: + raise + except Exception as e: + raise DownloadException(f"补扣下载次数异常: {e}") + + def _wait_if_paused() -> None: + """暂停时等待恢复或取消""" + if not (pause_ev.is_set() or task['status'] == 'paused'): + return + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'paused' + task['done'] = ok_count + len(failed) + task['msg'] = '已暂停(重新打开页面自动恢复)' + while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'): + time.sleep(1) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + pause_ev.clear() + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'running' + task['msg'] = '继续下载...' + + def _update_progress(msg: str = '') -> None: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS[task_id] + t['done'] = ok_count + len(failed) + t['failed'] = len(failed) + if msg: + t['msg'] = msg + + # 第 91 条(P1-1):对齐音乐批量 F-B 口径——zip_valid 仅在 ZIP with 块正常 + # 退出(中央目录完整写出)后才置 True(初始 False 已上移函数头,第 97 条); + # 未 valid 一律不发布、不按 ok_count 结算 + # P3-26:同音乐批量——ZIP 构建放 downloads 卷,不落系统 %TEMP% + # R5-P3/W-A 同口径:建在任务专属目录 tasks//,根目录缓存清扫不再触及交付物 + _podcast_task_dir = api_service.downloads_path / 'tasks' / task_id + _podcast_task_dir.mkdir(parents=True, exist_ok=True) + with _tempfile.NamedTemporaryFile(prefix='podcast_batch_', suffix='.zip', + dir=str(_podcast_task_dir), + delete=False) as zip_tmp: + zip_tmp_path = zip_tmp.name + used_arcnames = set() # F73:包内条目名去重(同名单集/同号不同集不得互相覆盖) + with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf: + for idx, ep in enumerate(episodes, 1): + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + _wait_if_paused() + # 并发对齐音乐批量:播客打包同样占用全局批量名额(BATCH_ZIP_SEM,全局 3), + # 逐集 acquire/release——暂停等待/取消时不长期占名额;音频本身仍受 + # 全局下载信号量约束。原实现不受批量并发控制,多用户同时打包会无限并发 + if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev): + raise DownloadCanceled("任务已取消") + try: + label = f"[{idx}/{len(episodes)}] {ep['name'][:24]}" + _update_progress(f"{label} 解析中...") + try: + prog = ep.get('detail') or podcast_api.get_program_detail(ep['id'], cookies) + url_info = _podcast_resolve_audio(prog, quality, cookies) + _update_progress(f"{label} 下载中...") + ext = (url_info.get('type') or 'mp3').lower().lstrip('.') + serial = int(ep.get('serial_num') or 0) + prefix = f"{serial:03d} - " if serial else '' + base = _podcast_safe_name(f"{prefix}{ep['name']}") + # 第 91 条(P3-5):中间文件移入任务目录——落 downloads 根目录会被 + # 缓存清扫按 mtime 打断慢速单集(Linux 上 unlink 打开中的文件) + tmp_audio = _podcast_task_dir / f"podcast_zip_tmp_{task_id}_{idx}.{ext}" + try: + _podcast_stream_download(url_info['url'], tmp_audio, + url_info.get('size', 0), cancel_ev) + # F73:重名条目自动追加序号 + arcname = f"{base}.{ext}" + k = 2 + while arcname in used_arcnames: + arcname = f"{base} ({k}).{ext}" + k += 1 + used_arcnames.add(arcname) + zf.write(str(tmp_audio), arcname=arcname, compress_type=zipfile.ZIP_STORED) + finally: + # P3-5:finally 连带清理 .part 残片(原实现只删正文, + # 取消/失败时残片要等 1 小时缓存清扫兜底) + _podcast_cleanup_files([str(tmp_audio)]) + # 首集附带封面,便于识别 + if idx == 1 and ep.get('cover_url'): + try: + cover = fetch_cover_bytes(ep['cover_url']) + if cover: + zf.writestr("cover.jpg", cover, compress_type=zipfile.ZIP_STORED) + except Exception: + pass + ok_count += 1 + _update_progress(f"{label} 完成") + except DownloadCanceled: + raise + except Exception as e: + failed.append(f"{ep['name']}({e})") + _update_progress() + finally: + BATCH_ZIP_SEM.release() + + # 第 91 条(P1-1):走到这里说明 ZIP with 块正常退出、中央目录完整,才允许发布 + zip_valid = True + + if ok_count == 0: + raise DownloadException(f"全部单集下载失败: {'; '.join(failed[:3])}") + + # 第 91 条(P1-1):ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛 + if not zip_valid: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + zip_tmp_path = None + refunded = _settle_batch_billing(task_id, 0, 'error') + _mark_task_terminal(task_id, 'error', + f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded + else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['failed'] = t.get('failed', 0) + ok_count + t['refunded'] = refunded + api_service.logger.error(f"播客打包构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次") + return + + # F42:最终发布前的取消检查(末段取消不得覆盖为 done) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + refunded = _settle_batch_billing(task_id, ok_count) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + # S3:发布前锁内复查取消/失效状态(同音乐批量) + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = ok_count + len(failed) + t['failed'] = len(failed) + t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '') + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"播客_{radio_name}_{ok_count}集.zip", + 'audio_files': [], + } + try: + first_name = episodes[0].get('name') if episodes else '' + _record_log('下载', remote_ip, f"[播客打包] {radio_name} · {first_name}") + except Exception: + pass + api_service.logger.info(f"播客打包完成: {radio_name} 成功{ok_count} 失败{len(failed)} ({task_id})") + except DownloadCanceled: + # F24:部分成功后取消——已入包部分照常交付,未完成部分按账本退费 + # 第 91 条(P1-1):ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛 + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered, 'canceled') + if delivered > 0 and zip_tmp_path: + _mark_task_terminal(task_id, 'canceled', + f"已取消(已打包 {ok_count} 集可领取)" + + (f",退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"播客_{radio_name or '打包'}_{ok_count}集.zip", + 'audio_files': [], + } + else: + _mark_task_terminal(task_id, 'canceled', + '任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.info(f"播客打包已取消: {task_id}(成功{ok_count} 退回{refunded}次)") + except DownloadException as e: + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered) + with DOWNLOAD_TASKS_LOCK: + _t = DOWNLOAD_TASKS.get(task_id) + # 第 100 条(T1 不变量):终态先到先得——已终态(如用户取消/超时兜底) + # 不再被覆盖为 error(堵 canceled→error 非法迁移边) + if _t and _t.get('status') not in ('done', 'error', 'canceled'): + _t['status'] = 'error' + _t['completed_at'] = time.time() # F38:保留期从完成时刻起算 + _t['msg'] = str(e) + (f"(已退回 {refunded} 次下载次数)" if refunded else '') + _t['refunded'] = refunded + elif _t: + _t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.error(f"播客打包失败: {task_id} {e}(退回{refunded}次)") + except Exception as e: + # 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化 + # (DownloadException 的文案是我们自己拼的用户可读文本,保留 str(e)) + api_service.logger.error(f"播客打包异常: {task_id} {e}\n{traceback.format_exc()}") + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered) + with DOWNLOAD_TASKS_LOCK: + _t = DOWNLOAD_TASKS.get(task_id) + # 第 100 条(T1 不变量):同上——已终态不再覆盖为 error + if _t and _t.get('status') not in ('done', 'error', 'canceled'): + _t['status'] = 'error' + _t['completed_at'] = time.time() # F38:保留期从完成时刻起算 + _t['msg'] = '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '') + _t['refunded'] = refunded + elif _t: + _t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.error(f"播客打包异常: {task_id} {e}(退回{refunded}次)\n{traceback.format_exc()}") + + +# P2-8:交付结果的延迟清理不再用"每请求 spawn 一个 sleep 35 分钟线程"的方案 +# (高并发下线程堆积;且线程是内存态,重启即丢、无人兜底)。统一交给监控线程: +# 终态任务记录+结果文件+任务目录按 completed_at+35 分钟清理,下载根目录的 +# ZIP/文件另有按 mtime 的缓存清扫双保险(同一 35 分钟口径)。 + + +@app.route('/download/batch_zip/result', methods=['GET', 'POST']) +@auth_system.login_required(free=True) +def download_batch_zip_result(): + """获取批量打包结果:任务完成后流式返回 ZIP;ZIP 发完即删,任务记录按 35 分钟保留期清理""" + try: + data = api_service._safe_get_request_data() + task_id = str(data.get('task_id') or '').strip() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + if not task_id: + return APIResponse.error("缺少 task_id 参数") + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS.get(task_id) + if not task or task.get('type') != 'batch': + return APIResponse.error("任务不存在或已过期", 404) + # F05:站点用户是任务的第一归属(device_id 仅辅助),非本人任务 403 + if not _task_owned_by_current_user(task): + return APIResponse.error("无权下载该任务", 403) + status = task.get('status') + result = task.get('result') or {} + has_deliverable = bool(result.get('zip_path')) and os.path.exists(result.get('zip_path')) + if status in ('queued', 'running'): + return APIResponse.error("任务还在进行中,请稍后再试", 202) + if status == 'paused': + return APIResponse.error("任务已暂停,请重新打开页面后恢复", 200) + # F24:取消/失败但已有部分成果的任务,成果必须可领取(钱不白花) + if status in ('canceled', 'error') and not has_deliverable: + return APIResponse.error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'), + 200 if status == 'canceled' else 500) + zip_path = result.get('zip_path') + if not zip_path or not os.path.exists(zip_path): + return APIResponse.error("打包文件不存在或已被清理", 404) + zip_name = result.get('filename') or '批量下载.zip' + response = send_file( + zip_path, as_attachment=True, + download_name=zip_name, + mimetype='application/zip' + ) + # 第 97 条(P3):私有交付物禁止共享缓存存储(no-cache 允许存储后重验) + response.headers['Cache-Control'] = 'no-store' + response.headers['X-Download-Filename'] = quote(zip_name, safe='') + # P2-8:清理统一交监控线程(completed_at + 35 分钟),交付请求不再挂 sleep 线程 + return response + except Exception as e: + api_service.logger.error(f"批量结果下载异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +def _task_delivery_copy(task_id: str, src: Path, filename: str) -> str: + """把共享缓存文件复制/硬链接为任务专属交付文件(F27:任务成果与共享缓存分离, + 任何任务的清理都不会再删掉别的任务的成果)""" + task_dir = api_service.downloads_path / 'tasks' / task_id + task_dir.mkdir(parents=True, exist_ok=True) + dst = task_dir / filename + if dst.exists(): + return str(dst) + try: + os.link(str(src), str(dst)) # 同卷硬链接:零拷贝 + except OSError: + shutil.copy2(str(src), str(dst)) + return str(dst) + + +# 第 91 条(P3):_cleanup_task_dir 死代码删除——worker 直接 rmtree、监控线程直接 +# rmtree,本函数全文件零调用(审计确认)。 + + +def _single_download_worker(task_id: str) -> None: + """后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理""" + import tempfile as _tempfile + + file_path_str: Optional[str] = None + zip_tmp_path: Optional[str] = None + task_dir_path: Optional[str] = None + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '下载中...' + params = task['params'] + cancel_ev = task['cancel'] + music_id = params['music_id'] + quality = params['quality'] + filename_format = params['filename_format'] + write_meta = params['write_meta'] + meta_fields = params['meta_fields'] + lyric_format = params['lyric_format'] + need_zip = params['need_zip'] + cookies = params['cookies'] + remote_ip = params.get('remote_ip') or '-' + + # 一次预取歌曲完整信息(URL/详情/歌词三接口并行), + # 替代原先 name_v1 + url_v1 + 下载器内部 get_music_info 的重复串行调用 + mi = api_service.downloader.get_music_info(music_id, quality, cookies) + music_info = { + 'id': music_id, + 'name': mi.name, + 'artist_string': mi.artists, + 'album': mi.album, + 'pic_url': mi.pic_url, + 'file_type': mi.file_type, + 'file_size': mi.file_size, + 'duration': mi.duration * 1000, + 'download_url': mi.download_url, + } + base = _build_download_base(filename_format, music_info) + filename = f"{base}.{music_info['file_type']}" + + # 字节级进度回调(限频 0.4s):写进任务 live_percent,/api/batch_progress + # 对运行中的单曲任务优先返回它——原实现 done 按首计数,单曲进度条全程 0% + _cb_ts = {'t': 0.0} + + def _progress_cb(written, total_bytes): + try: + now = time.time() + if now - _cb_ts['t'] < 0.4 and written < (total_bytes or 0): + return + _cb_ts['t'] = now + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if not t or t.get('status') != 'running': + return + if total_bytes: + pct = min(99.0, written / total_bytes * 100) + t['live_percent'] = pct + t['msg'] = (f"下载中 {int(pct)}% · " + f"{api_service._format_file_size(written)}/{api_service._format_file_size(total_bytes)}") + else: + t['msg'] = f"下载中 · 已接收 {api_service._format_file_size(written)}" + except Exception: + pass # 进度上报故障绝不影响下载 + + download_result = api_service.downloader.download_music_file( + music_id, quality, + write_meta=write_meta, + meta_fields=meta_fields, + cookies=cookies, + cancel_event=cancel_ev, + music_info=mi, + progress_cb=_progress_cb, + ) + if not download_result.success: + raise DownloadException(download_result.error_message or "下载失败") + # F42:下载完成后的每一阶段都可能已被取消,发布前统一校验 + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + cache_path = Path(download_result.file_path) + # F27:交付文件用任务专属副本,共享缓存不进清理清单 + file_path_str = _task_delivery_copy(task_id, cache_path, filename) + task_dir_path = str(Path(file_path_str).parent) + + if need_zip: + fd, zip_tmp_path = _tempfile.mkstemp(prefix='single_zip_', suffix='.zip', + dir=str(Path(file_path_str).parent)) + os.close(fd) + with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(file_path_str, arcname=filename) + # 歌词已在预取时一并拿到,无需再请求一次 + if mi.lyric: + try: + zf.writestr(f"{base}.{lyric_format}", _format_lyric(mi.lyric, lyric_format)) + except Exception: + pass + # 封面走共享缓存(与元数据写入共用同一次网络请求) + cover = fetch_cover_bytes(mi.pic_url) + if cover: + zf.writestr(f"{base}.jpg", cover) + info_txt = ( + f"歌曲: {music_info['name']}\n" + f"歌手: {music_info['artist_string']}\n" + f"专辑: {music_info['album']}\n" + f"音质: {api_service._get_quality_display_name(quality)}\n" + ) + zf.writestr("info.txt", info_txt) + # F42:打包阶段同样以取消检查收口 + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + result = { + 'file_path': file_path_str, + 'zip_path': zip_tmp_path, + 'filename': f"{base}.zip", + 'mimetype': 'application/zip', + 'audio_files': [file_path_str], + } + else: + result = { + 'file_path': file_path_str, + 'filename': filename, + 'mimetype': f"audio/{music_info['file_type']}", + 'audio_files': [file_path_str], + } + + # F42:最终发布前的取消检查(末段取消不得覆盖为 done) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = 1 + t['msg'] = '下载完成' + t['result'] = result + # C1:交付已落定;结算失败不再把任务改标 error(钱由重启对账兜底,宁可商家小损不坑用户) + try: + auth_system.ledger_settle(task_id, 1, 'delivered') + except Exception as _e: + api_service.logger.error(f"单曲交付结算失败(重启对账兜底): {task_id} {_e}") + try: + _record_log('下载', remote_ip, music_info['name']) + except Exception: + pass + api_service.logger.info(f"单曲下载完成: {filename} ({task_id})") + except DownloadCanceled: + _mark_task_terminal(task_id, 'canceled', '任务已取消') + try: + auth_system.ledger_settle(task_id, 0, 'canceled') + except Exception: + pass + if task_dir_path: + shutil.rmtree(task_dir_path, ignore_errors=True) + api_service.logger.info(f"单曲下载已取消: {task_id}") + except DownloadException as e: + _mark_task_terminal(task_id, 'error', str(e)) + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir_path: + shutil.rmtree(task_dir_path, ignore_errors=True) + api_service.logger.error(f"单曲下载失败: {task_id} {e}") + except Exception as e: + # 第 97 条(P3):非预期异常的 repr 可能含上游主机/连接细节——用户可见文案 + # 泛化为固定文案,原文 logger 先行留档(第 84 条教训:先记录再泛化) + api_service.logger.error(f"单曲下载异常: {task_id} {e}\n{traceback.format_exc()}") + _mark_task_terminal(task_id, 'error', '下载失败,请稍后重试') + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir_path: + shutil.rmtree(task_dir_path, ignore_errors=True) + + +@app.route('/download/result', methods=['GET', 'POST']) +@auth_system.login_required(free=True) +def download_single_result(): + """获取单曲下载结果:任务完成后流式返回文件;交付后按 35 分钟保留期清理""" + try: + data = api_service._safe_get_request_data() + task_id = str(data.get('task_id') or '').strip() + device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) + if not task_id: + return APIResponse.error("缺少 task_id 参数") + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS.get(task_id) + if not task or task.get('type') != 'single': + return APIResponse.error("任务不存在或已过期", 404) + # F05:站点用户是任务的第一归属(device_id 仅辅助),非本人任务 403 + if not _task_owned_by_current_user(task): + return APIResponse.error("无权下载该任务", 403) + status = task.get('status') + if status in ('queued', 'running'): + return APIResponse.error("任务还在进行中,请稍后再试", 202) + if status == 'paused': + # 与批量结果端点同口径:暂停任务给出明确提示(而非 404"文件不存在") + return APIResponse.error("任务已暂停,请重新打开页面后恢复", 200) + result = task.get('result') or {} + target = result.get('zip_path') or result.get('file_path') + if status in ('canceled', 'error') and not (target and os.path.exists(target)): + return APIResponse.error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'), + 200 if status == 'canceled' else 500) + if not target or not os.path.exists(target): + return APIResponse.error("文件不存在或已被清理", 404) + filename = result.get('filename') or 'download' + response = send_file( + target, as_attachment=True, + download_name=filename, + mimetype=result.get('mimetype') or 'application/octet-stream' + ) + # 第 97 条(P3):私有交付物禁止共享缓存存储(no-cache 允许存储后重验) + response.headers['Cache-Control'] = 'no-store' + response.headers['X-Download-Filename'] = quote(filename, safe='') + # P2-8:任务目录清理统一交监控线程(completed_at + 35 分钟) + return response + except Exception as e: + api_service.logger.error(f"单曲结果下载异常: {e}\n{traceback.format_exc()}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +@app.route('/api/info', methods=['GET']) +def api_info(): + """API信息接口""" + try: + info = { + 'name': '云村小助手API服务', + 'version': APP_VERSION, + 'description': '云村小助手 · 网易云音乐解析/下载服务', + 'endpoints': { + '/health': 'GET - 健康检查', + '/song': 'GET/POST - 获取歌曲信息', + '/search': 'GET/POST - 搜索音乐', + '/playlist': 'GET/POST - 获取歌单详情', + '/album': 'GET/POST - 获取专辑详情', + '/download': 'POST - 下载音乐(付费动作仅接受 POST)', + '/podcast/parse': 'GET/POST - 播客解析(电台/单集)', + '/podcast/programs': 'GET/POST - 播客单集列表(分页)', + '/podcast/search': 'GET/POST - 播客搜索', + '/podcast/hot': 'GET/POST - 热门播客与分类', + '/podcast/download': 'POST - 播客单集下载', + '/podcast/download/batch_zip': 'POST - 整台播客打包ZIP', + '/global/playlists': 'GET/POST - 全局账号歌单列表', + '/global/playlist': 'GET/POST - 全局账号歌单详情', + '/api/playlist/play': 'GET/POST - 公开歌单播放API(id/level/limit)', + '/api/info': 'GET - API信息' + }, + 'supported_qualities': [ + 'standard', 'exhigh', 'lossless', + 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby' + ], + 'config': { + 'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径 + # 第 98 条:原 max_file_size/request_timeout 为架空字段且与实际口径 + # 不符——改为真实生效的请求体上限(业务 2MB/备份上传 200MB) + 'max_body_bytes': '2MB(备份上传 200MB)' + } + } + + return APIResponse.success(info, "API信息获取成功") + + except Exception as e: + api_service.logger.error(f"获取API信息异常: {e}") + return APIResponse.error("服务暂时不可用,请稍后再试", 500) + + +def start_api_server(): + """启动API服务器""" + try: + # F26:启动对账——账本中仍 open 的孤儿任务(上次进程中断遗留)按实扣全额退回。 + # 宽限期 2 小时,避免误伤同库其他存活进程的进行中任务 + try: + recovered = auth_system.ledger_recover_orphans(max_age_seconds=2 * 3600) + if recovered: + api_service.logger.info(f"启动对账:已结算孤儿任务 {recovered} 笔(详见管理日志)") + except Exception as e: + api_service.logger.error(f"启动对账异常: {e}") + # P3-20:清理 90 天前已结算的账本行(只增不删会让表无限膨胀) + try: + auth_system.ledger_cleanup_settled(90 * 86400) + except Exception as e: + api_service.logger.error(f"账本历史清理异常: {e}") + # F27 配套:启动时清扫上次运行遗留的下载缓存(35 分钟口径,P1-3 统一保留期) + _sweep_download_cache(RESULT_RETENTION_SECONDS) + # P2-18:清扫 tasks/ 孤儿目录——交付清理线程与任务记录都是内存态,进程在 + # 保留期内重启会让任务目录无主残留(实测发生过);超过 24 小时的必是孤儿 + try: + tasks_root = api_service.downloads_path / 'tasks' + if tasks_root.exists(): + now = time.time() + for d in tasks_root.iterdir(): + try: + if d.is_dir() and now - d.stat().st_mtime > 24 * 3600: + shutil.rmtree(str(d), ignore_errors=True) + except OSError: + pass + except Exception as e: + api_service.logger.error(f"tasks 孤儿目录清扫异常: {e}") + # 启动设备心跳监控:心跳超时自动暂停离线设备的批量任务,让出排队名额 + threading.Thread(target=_task_monitor_loop, daemon=True).start() + + # 启动数据库定时备份线程(间隔可在管理后台设置) + auth_system.start_auto_backup() + + _safe_console_print("\n" + "="*60) + _safe_console_print("🚀 云村小助手API服务启动中...") + _safe_console_print("="*60) + _safe_console_print(f"📡 服务地址: http://{config.host}:{config.port}") + _safe_console_print(f"📁 下载目录: {api_service.downloads_path.absolute()}") + _safe_console_print(f"📋 日志级别: {config.log_level}") + _safe_console_print("\n📚 API端点:") + _safe_console_print(f" ├─ GET /health - 健康检查") + _safe_console_print(f" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)") + _safe_console_print(f" ├─ POST /song - 获取歌曲信息") + _safe_console_print(f" ├─ POST /playlist - 获取歌单详情") + _safe_console_print(f" ├─ POST /album - 获取专辑详情") + _safe_console_print(f" ├─ POST /toplist - 获取官方榜单列表") + _safe_console_print(f" ├─ POST /artist_songs - 获取歌手信息与热门歌曲") + _safe_console_print(f" ├─ POST /comments - 获取歌曲评论") + _safe_console_print(f" ├─ POST /download - 下载音乐") + _safe_console_print(f" ├─ GET/POST /global/playlists - 全局账号歌单列表") + _safe_console_print(f" ├─ GET/POST /global/playlist - 全局账号歌单详情") + _safe_console_print(f" ├─ GET/POST /api/playlist/play - 公开歌单播放API") + _safe_console_print(f" └─ GET /api/info - API信息") + _safe_console_print("\n🎵 支持的音质:") + _safe_console_print(f" standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby") + _safe_console_print("="*60) + _safe_console_print(f"⏰ 启动时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") + _safe_console_print("🌟 服务已就绪,等待请求...\n") + + # 第 102 条(owner 拍板):生产服务器 waitress——安装了即自动启用; + # 未安装(开发机零依赖口径)回退 Werkzeug 开发服务器。 + # 注意 trusted_proxy='127.0.0.1':waitress 默认清除"非可信代理"的 + # X-Forwarded-For——Caddy 在本机回环转发属可信,必须放行其 XFF, + # 否则 _client_ip 的 XFF 最右段口径(第 97/100 条)失效。 + try: + from waitress import serve + except ImportError: + api_service.logger.warning( + "未安装 waitress,回退 Werkzeug 开发服务器(生产部署建议:pip install waitress)") + _safe_console_print("⚠️ 未安装 waitress,当前为 Werkzeug 开发服务器(生产环境建议安装)") + app.run( + host=config.host, + port=config.port, + debug=config.debug, + threaded=True + ) + else: + # 第 102 条(对抗复审实锤修复):trusted_proxy 必须配 trusted_proxy_headers—— + # waitress 3.x 只配前者时仅隐式信任 x-forwarded-proto,会把 Caddy 追加的 + # X-Forwarded-For 当"不可信头"从 environ 清掉 → 全站 IP 塌缩 127.0.0.1。 + # 显式信任 XFF 后,waitress 将 XFF 最右段重写进 REMOTE_ADDR,与应用口径一致。 + _safe_console_print(f"🍳 waitress 生产服务器已就绪: {config.host}:{config.port}(16 threads)") + serve( + app, + host=config.host, + port=config.port, + threads=16, + channel_timeout=120, # 弱网下载/大 ZIP 流式响应容限(不活动超时) + trusted_proxy='127.0.0.1', # Caddy 本机反代可信 + trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'}, + clear_untrusted_proxy_headers=True, + ident='NeteaseUrl', # 不暴露 waitress 版本指纹 + ) + + except KeyboardInterrupt: + _safe_console_print("\n\n👋 服务已停止") + except Exception as e: + api_service.logger.error(f"启动服务失败: {e}") + _safe_console_print(f"❌ 启动失败: {e}") + sys.exit(1) + + +if __name__ == '__main__': + start_api_server() + diff --git a/music_api.py b/music_api.py new file mode 100644 index 0000000..3f4781b --- /dev/null +++ b/music_api.py @@ -0,0 +1,1468 @@ +# -*- coding: utf-8 -*- +"""网易云音乐API模块 + +提供网易云音乐相关API接口的封装,包括: +- 音乐URL获取 +- 歌曲详情获取 +- 歌词获取 +- 搜索功能 +- 歌单和专辑详情 +- 二维码登录 +""" + +import json +import os +import urllib.parse +import time +from random import randrange +from typing import Dict, List, Optional, Tuple, Any +from hashlib import md5 +from enum import Enum + +import requests +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +class QualityLevel(Enum): + """音质等级枚举""" + STANDARD = "standard" # 标准音质 + EXHIGH = "exhigh" # 极高音质 + LOSSLESS = "lossless" # 无损音质 + HIRES = "hires" # Hi-Res音质 + SKY = "sky" # 沉浸环绕声 + JYEFFECT = "jyeffect" # 高清环绕声 + JYMASTER = "jymaster" # 超清母带 + DOLBY = "dolby" # 杜比全景声 + + +# 常量定义 +class APIConstants: + """API相关常量""" + AES_KEY = b"e82ckenh8dichen8" + USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/2.10.2.200154' + REFERER = 'https://music.163.com/' + + # API URLs + SONG_URL_V1 = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1" + SONG_DETAIL_V3 = "https://interface3.music.163.com/api/v3/song/detail" + LYRIC_API = "https://interface3.music.163.com/api/song/lyric" + SEARCH_API = 'https://music.163.com/api/cloudsearch/pc' + PLAYLIST_DETAIL_API = 'https://music.163.com/api/v6/playlist/detail' + ALBUM_DETAIL_API = 'https://music.163.com/api/v1/album/' + TOPLIST_API = 'https://music.163.com/api/toplist' + ARTIST_API = 'https://music.163.com/api/v1/artist/' + COMMENT_API = 'https://music.163.com/api/v1/resource/comments/R_SO_4_' + USER_ACCOUNT_API = 'https://music.163.com/api/nuser/account/get' + USER_PLAYLIST_API = 'https://music.163.com/api/user/playlist' + PLAYLIST_SUBSCRIBE_API = 'https://music.163.com/api/playlist/subscribe' + ALBUM_SUBSCRIBE_API = 'https://music.163.com/api/album/sub' + COMMENT_POST_API = 'https://music.163.com/weapi/comment' + QR_UNIKEY_API = 'https://interface3.music.163.com/eapi/login/qrcode/unikey' + QR_LOGIN_API = 'https://interface3.music.163.com/eapi/login/qrcode/client/login' + + # 默认配置 + DEFAULT_CONFIG = { + "os": "pc", + "appver": "", + "osver": "", + "deviceId": "pyncm!" + } + + DEFAULT_COOKIES = { + "os": "pc", + "appver": "", + "osver": "", + "deviceId": "pyncm!" + } + + +class CryptoUtils: + """加密工具类""" + @staticmethod + def hex_digest(data: bytes) -> str: + """将字节数据转换为十六进制字符串""" + return "".join([hex(d)[2:].zfill(2) for d in data]) + + @staticmethod + def hash_digest(text: str) -> bytes: + """计算MD5哈希值""" + return md5(text.encode("utf-8")).digest() + + @staticmethod + def hash_hex_digest(text: str) -> str: + """计算MD5哈希值并转换为十六进制字符串""" + return CryptoUtils.hex_digest(CryptoUtils.hash_digest(text)) + + @staticmethod + def encrypt_params(url: str, payload: Dict[str, Any]) -> str: + """加密请求参数""" + url_path = urllib.parse.urlparse(url).path.replace("/eapi/", "/api/") + digest = CryptoUtils.hash_hex_digest(f"nobody{url_path}use{json.dumps(payload)}md5forencrypt") + params = f"{url_path}-36cd479b6b5-{json.dumps(payload)}-36cd479b6b5-{digest}" + + # AES加密 + padder = padding.PKCS7(algorithms.AES(APIConstants.AES_KEY).block_size).padder() + padded_data = padder.update(params.encode()) + padder.finalize() + cipher = Cipher(algorithms.AES(APIConstants.AES_KEY), modes.ECB()) + encryptor = cipher.encryptor() + enc = encryptor.update(padded_data) + encryptor.finalize() + + return CryptoUtils.hex_digest(enc) + + +class WeapiCrypto: + """网易云 weapi 接口加密(AES-CBC 双重加密 + RSA 公钥加密) + + 用于发送/回复评论等前端写操作接口(/weapi/comment 等)。 + """ + + AES_KEY = b"0CoJUm6Qyw8W8jud" + AES_IV = b"0102030405060708" + RSA_MODULUS = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7" + RSA_EXPONENT = "010001" + # 第 91 条(P2-5):官方随机 secKey 字符集(官方 JS 与主流逆向实现一致) + SEC_KEY_CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + + @staticmethod + def _aes_encrypt(text: bytes, key: bytes) -> bytes: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.padding import PKCS7 + padder = PKCS7(128).padder() + padded = padder.update(text) + padder.finalize() + cipher = Cipher(algorithms.AES(key), modes.CBC(WeapiCrypto.AES_IV)) + encryptor = cipher.encryptor() + return encryptor.update(padded) + encryptor.finalize() + + @staticmethod + def encrypt(payload: Dict[str, Any]) -> Dict[str, str]: + """将请求体加密为 weapi 的 params/encSecKey。 + 第 91 条(P2-5):按官方协议重写——secKey 取官方字符集随机 16 位, + encSecKey = secKey 反转后做 textbook RSA(无填充)的 256 位 hex。 + 原实现用 os.urandom + PKCS1v15 且未反转,服务端按 textbook RSA 解出的 + AES key 与 params 第二层不一致,评论等 weapi 写接口疑似从未成功过。 + 第 94 条(P1-1,复审实锤):官方协议第二层加密的输入是第一层密文的 + **base64 字符串**(两次 AES 各自输出 base64)——93 条版本漏了中间那次 + base64,服务端解 params 第一层得到二进制、b64 解码必失败。""" + import base64 + import secrets as _secrets + + text = json.dumps(payload).encode("utf-8") + secret_key = ''.join(_secrets.choice(WeapiCrypto.SEC_KEY_CHARSET) for _ in range(16)) + + first = base64.b64encode( + WeapiCrypto._aes_encrypt(text, WeapiCrypto.AES_KEY) + ).decode("utf-8") + params = base64.b64encode( + WeapiCrypto._aes_encrypt(first.encode("utf-8"), secret_key.encode('utf-8')) + ).decode("utf-8") + + n = int(WeapiCrypto.RSA_MODULUS, 16) + e = int(WeapiCrypto.RSA_EXPONENT, 16) + reversed_hex = secret_key[::-1].encode('utf-8').hex() + enc_sec_key = format(pow(int(reversed_hex, 16), e, n), '0>256x') + + return {"params": params, "encSecKey": enc_sec_key} + + + +def _ensure_dict(result: Any, msg: str) -> Dict[str, Any]: + """第 91 条(P2-8):上游 200 但返回 null/数组等非 dict 体(风控/抖动)时统一前置校验""" + if isinstance(result, dict): + return result + raise APIException(msg) + + +class HTTPClient: + """HTTP客户端类""" + + @staticmethod + def post_request(url: str, params: str, cookies: Dict[str, str]) -> str: + """发送POST请求并返回文本响应。 + R5-20:网络异常/5xx 做一次 1 秒退避重试(全站 API 调用此前零重试, + 上游抖动即整首失败);4xx(429/460 风控)不重试——立即重试会加剧风控。""" + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER, + } + + request_cookies = APIConstants.DEFAULT_COOKIES.copy() + request_cookies.update(cookies) + + last_err = None + for attempt in range(2): + try: + response = requests.post(url, headers=headers, cookies=request_cookies, + data={"params": params}, timeout=30) + if response.status_code >= 500 and attempt == 0: + time.sleep(1.0) + continue + response.raise_for_status() + if len(response.content) > _MAX_UPSTREAM_JSON_BYTES: + raise APIException("上游响应体异常(超过大小上限)") + return response.text + except requests.HTTPError as e: + # 第 91 条(P2-6):4xx(429/460 风控)不重试——原实现 HTTPError 落入 + # 下方 RequestException 分支被重试一次,与 R5-20 意图相反 + raise APIException(f"HTTP请求失败: {e}") + except requests.RequestException as e: + last_err = e + if attempt == 0: + time.sleep(1.0) + continue + raise APIException(f"HTTP请求失败: {last_err}") + + @staticmethod + def post_request_full(url: str, params: str, cookies: Dict[str, str]) -> requests.Response: + """发送POST请求并返回完整响应对象(R5-20:同 post_request 的有界重试)""" + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER, + } + + request_cookies = APIConstants.DEFAULT_COOKIES.copy() + request_cookies.update(cookies) + + last_err = None + for attempt in range(2): + try: + response = requests.post(url, headers=headers, cookies=request_cookies, + data={"params": params}, timeout=30) + if response.status_code >= 500 and attempt == 0: + time.sleep(1.0) + continue + response.raise_for_status() + if len(response.content) > _MAX_UPSTREAM_JSON_BYTES: + raise APIException("上游响应体异常(超过大小上限)") + return response + except requests.HTTPError as e: + # 第 91 条(P2-6):4xx 不重试(同上) + raise APIException(f"HTTP请求失败: {e}") + except requests.RequestException as e: + last_err = e + if attempt == 0: + time.sleep(1.0) + continue + raise APIException(f"HTTP请求失败: {last_err}") + + +# 第 97 条(P3):上游 JSON 响应体上限——requests 已全量入内存,此处拦截的是 +# 异常/被劫持上游的超大 body 进入后续 .json() 解析与传播(音频/封面已有字节预算) +_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024 + + +class APIException(Exception): + """API异常类""" + pass + + +class NeteaseAPI: + """网易云音乐API主类""" + + def __init__(self): + self.http_client = HTTPClient() + self.crypto_utils = CryptoUtils() + + def get_song_url(self, song_id: int, quality: str, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取歌曲播放URL + + Args: + song_id: 歌曲ID + quality: 音质等级 (standard, exhigh, lossless, hires, sky, jyeffect, jymaster) + cookies: 用户cookies + + Returns: + 包含歌曲URL信息的字典 + + Raises: + APIException: API调用失败时抛出 + """ + try: + config = APIConstants.DEFAULT_CONFIG.copy() + config["requestId"] = str(randrange(20000000, 30000000)) + + payload = { + 'ids': [song_id], + 'level': quality, + 'encodeType': 'mp4' if quality == 'dolby' else 'flac', + 'header': json.dumps(config), + } + + if quality == 'sky': + payload['immerseType'] = 'c51' + + params = self.crypto_utils.encrypt_params(APIConstants.SONG_URL_V1, payload) + response_text = self.http_client.post_request(APIConstants.SONG_URL_V1, params, cookies) + + result = json.loads(response_text) + # R5-19:合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真 + if not isinstance(result, dict): + raise APIException("获取歌曲URL失败: 响应格式异常") + if result.get('code') != 200: + raise APIException(f"获取歌曲URL失败: {result.get('message', '未知错误')}") + + return result + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析响应数据失败: {e}") + + def get_song_detail(self, song_id: int) -> Dict[str, Any]: + """获取歌曲详细信息 + + Args: + song_id: 歌曲ID + + Returns: + 包含歌曲详细信息的字典 + + Raises: + APIException: API调用失败时抛出 + """ + try: + data = {'c': json.dumps([{"id": song_id, "v": 0}])} + # R5-18:补齐 UA/Referer——本方法是全站唯一裸 UA 请求,风控/460 单点偏高 + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + response = requests.post(APIConstants.SONG_DETAIL_V3, data=data, + headers=headers, timeout=30) + response.raise_for_status() + + result = response.json() + # R5-19:合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真 + if not isinstance(result, dict): + raise APIException("获取歌曲详情失败: 响应格式异常") + if result.get('code') != 200: + raise APIException(f"获取歌曲详情失败: {result.get('message', '未知错误')}") + + return result + except requests.RequestException as e: + raise APIException(f"获取歌曲详情请求失败: {e}") + except json.JSONDecodeError as e: + raise APIException(f"解析歌曲详情响应失败: {e}") + + def get_lyric(self, song_id: int, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取歌词信息 + + Args: + song_id: 歌曲ID + cookies: 用户cookies + + Returns: + 包含歌词信息的字典 + + Raises: + APIException: API调用失败时抛出 + """ + try: + data = { + 'id': song_id, + 'cp': 'false', + 'tv': '0', + 'lv': '0', + 'rv': '0', + 'kv': '0', + 'yv': '1', + 'ytv': '0', + 'yrv': '0' + } + + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.post(APIConstants.LYRIC_API, data=data, + headers=headers, cookies=cookies, timeout=30) + response.raise_for_status() + + result = response.json() + _ensure_dict(result, 'get_lyric 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"获取歌词失败: {result.get('message', '未知错误')}") + + return result + except requests.RequestException as e: + raise APIException(f"获取歌词请求失败: {e}") + except json.JSONDecodeError as e: + raise APIException(f"解析歌词响应失败: {e}") + + def search_music(self, keywords: str, cookies: Dict[str, str], limit: int = 10, + search_type: int = 1, offset: int = 0) -> List[Dict[str, Any]]: + """搜索音乐 + + Args: + keywords: 搜索关键词 + cookies: 用户cookies + limit: 返回数量限制 + search_type: 搜索类型 (1-歌曲, 10-专辑, 100-歌手, 1000-歌单) + offset: 分页偏移(F45:透传上游,翻页生效) + + Returns: + 搜索结果列表 + + Raises: + APIException: API调用失败时抛出 + """ + try: + data = {'s': keywords, 'type': search_type, 'limit': limit, + 'offset': max(0, int(offset or 0))} + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.post(APIConstants.SEARCH_API, data=data, + headers=headers, cookies=cookies, timeout=30) + response.raise_for_status() + + result = response.json() + # R5-P3:非 dict 响应(null/数组)给明确错误 + if not isinstance(result, dict): + raise APIException("搜索失败: 响应格式异常") + if result.get('code') != 200: + raise APIException(f"搜索失败: {result.get('message', '未知错误')}") + + result_data = result.get('result') or {} + items: List[Dict[str, Any]] = [] + + if search_type == 1: + # 歌曲(P2-12:id 改 .get——上游单条残缺(下架/屏蔽)不再拖死整页搜索) + for item in result_data.get('songs', []): + if not item.get('id'): + continue + items.append({ + 'type': 'song', + 'id': item['id'], + 'name': item.get('name') or '未知歌曲', + 'artists': '/'.join(filter(None, (a.get('name') for a in item.get('ar') or []))) or '未知歌手', + 'album': (item.get('al') or {}).get('name') or '未知专辑', + 'picUrl': (item.get('al') or {}).get('picUrl') or '' + }) + elif search_type == 10: + # 专辑(P2-12 同上) + for item in result_data.get('albums', []): + if not item.get('id'): + continue + items.append({ + 'type': 'album', + 'id': item['id'], + 'name': item.get('name') or '未知名称', + 'artists': '/'.join(filter(None, (a.get('name') for a in item.get('artists') or []))), + 'alias': '', + 'album': '', + 'picUrl': item.get('picUrl') + }) + elif search_type == 100: + # 歌手(P2-12 同上) + for item in result_data.get('artists', []): + if not item.get('id'): + continue + items.append({ + 'type': 'artist', + 'id': item['id'], + 'name': item.get('name') or '未知名称', + 'artists': '', + 'alias': '/'.join(item.get('alias', [])) if item.get('alias') else '', + 'album': '', + 'picUrl': item.get('picUrl') + }) + elif search_type == 1000: + # 歌单(P2-12 同上) + for item in result_data.get('playlists', []): + if not item.get('id'): + continue + items.append({ + 'type': 'playlist', + 'id': item['id'], + 'name': item.get('name') or '未知名称', + 'artists': '', + 'creator': (item.get('creator') or {}).get('nickname', ''), + 'trackCount': item.get('trackCount', 0), + 'album': '', + 'picUrl': item.get('coverImgUrl') + }) + + return items + except requests.RequestException as e: + raise APIException(f"搜索请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析搜索响应失败: {e}") + + def get_playlist_detail(self, playlist_id: int, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取歌单详情 + + Args: + playlist_id: 歌单ID + cookies: 用户cookies + + Returns: + 歌单详情信息 + + Raises: + APIException: API调用失败时抛出 + """ + try: + data = {'id': playlist_id} + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.post(APIConstants.PLAYLIST_DETAIL_API, data=data, + headers=headers, cookies=cookies, timeout=30) + response.raise_for_status() + + result = response.json() + # R5-P2:非 dict 响应(null/数组)给明确错误 + if not isinstance(result, dict): + raise APIException("获取歌单详情失败: 响应格式异常") + if result.get('code') != 200: + raise APIException(f"获取歌单详情失败: {result.get('message', '未知错误')}") + + # R5-P2:上游对不存在/私密/无权限歌单返回 code:200 + playlist:null —— + # 显式转为 APIException(四条路由统一转"歌单不存在"语义,不再 500) + playlist = result.get('playlist') or {} + if not playlist: + raise APIException("歌单不存在或无权访问") + info = { + 'id': playlist.get('id'), + 'name': playlist.get('name'), + 'coverImgUrl': playlist.get('coverImgUrl'), + 'creator': (playlist.get('creator') or {}).get('nickname', ''), + 'trackCount': playlist.get('trackCount'), + 'description': playlist.get('description', ''), + 'tracks': [] + } + + # 获取所有trackIds并分批获取详细信息(F72:明细批次的业务 code 也要校验, + # 失败批次记入 failed_batches,不再静默缺歌) + track_ids = [str(t['id']) for t in (playlist.get('trackIds') or []) if t.get('id')] + failed_batches = 0 + for i in range(0, len(track_ids), 100): + batch_ids = track_ids[i:i+100] + # P3-18:trackIds 混入非数字脏 ID 时跳过该条(原实现 int() 抛 ValueError + # 不在捕获列表,整歌单 500);P2-12:明细缺 id 的歌跳过不拖整批 + try: + song_data = {'c': json.dumps([{'id': int(sid), 'v': 0} for sid in batch_ids + if str(sid).isdigit()])} + except (TypeError, ValueError): + continue + + song_resp = requests.post(APIConstants.SONG_DETAIL_V3, data=song_data, + headers=headers, cookies=cookies, timeout=30) + song_resp.raise_for_status() + + song_result = song_resp.json() + if not isinstance(song_result, dict): + # 第 91 条(P2-8):上游 null/数组体按失败批次计,不让 AttributeError 失真 + failed_batches += 1 + continue + if song_result.get('code') != 200: + # 本批次业务失败:计为失败批次,交付方可见残缺状态 + failed_batches += 1 + continue + for song in (song_result.get('songs') or []): + if not song.get('id'): + continue + info['tracks'].append({ + 'id': song['id'], + 'name': song.get('name') or '未知歌曲', + 'artists': '/'.join(filter(None, (a.get('name') for a in song.get('ar') or []))) or '未知歌手', + 'album': (song.get('al') or {}).get('name') or '未知专辑', + 'picUrl': (song.get('al') or {}).get('picUrl') or '' + }) + # F72:trackIds 数与实际拿到的不一致时显式标记"不完整",不冒充完整结果 + if len(info['tracks']) != len(track_ids): + info['incomplete'] = True + info['missing'] = len(track_ids) - len(info['tracks']) + info['failed_batches'] = failed_batches + + return info + except requests.RequestException as e: + raise APIException(f"获取歌单详情请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析歌单详情响应失败: {e}") + + def get_album_detail(self, album_id: int, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取专辑详情 + + Args: + album_id: 专辑ID + cookies: 用户cookies + + Returns: + 专辑详情信息 + + Raises: + APIException: API调用失败时抛出 + """ + try: + url = f'{APIConstants.ALBUM_DETAIL_API}{album_id}' + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.get(url, headers=headers, cookies=cookies, timeout=30) + response.raise_for_status() + + result = response.json() + _ensure_dict(result, 'get_album_detail 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"获取专辑详情失败: {result.get('message', '未知错误')}") + + album = result.get('album') or {} + info = { + 'id': album.get('id'), + 'name': album.get('name'), + 'coverImgUrl': self.get_pic_url(album.get('pic')), + 'artist': (album.get('artist') or {}).get('name', ''), + 'publishTime': album.get('publishTime'), + 'description': album.get('description', ''), + 'songs': [] + } + + for song in result.get('songs', []): + if not song.get('id'): # P2-12:残缺条目跳过 + continue + info['songs'].append({ + 'id': song['id'], + 'name': song.get('name') or '未知歌曲', + 'artists': '/'.join(filter(None, (a.get('name') for a in song.get('ar') or []))) or '未知歌手', + 'album': (song.get('al') or {}).get('name') or '未知专辑', + 'picUrl': self.get_pic_url((song.get('al') or {}).get('pic')) + }) + + return info + except requests.RequestException as e: + raise APIException(f"获取专辑详情请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析专辑详情响应失败: {e}") + + def get_toplist(self) -> List[Dict[str, Any]]: + """获取网易云官方榜单列表 + + Returns: + 榜单列表信息 + + Raises: + APIException: API调用失败时抛出 + """ + try: + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.get(APIConstants.TOPLIST_API, headers=headers, timeout=30) + response.raise_for_status() + + result = response.json() + _ensure_dict(result, 'get_toplist 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"获取榜单失败: {result.get('message', '未知错误')}") + + toplists = [] + for item in (result.get('list') or []): + toplists.append({ + 'id': item.get('id'), + 'name': item.get('name'), + 'coverImgUrl': item.get('coverImgUrl'), + 'description': item.get('description', ''), + 'updateFrequency': item.get('updateFrequency', ''), + 'playCount': item.get('playCount', 0), + 'trackCount': item.get('trackCount', 0), + 'topTitle': item.get('ToplistType', '') + }) + return toplists + except requests.RequestException as e: + raise APIException(f"获取榜单请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析榜单响应失败: {e}") + + def get_artist_hot_songs(self, artist_id: int) -> Dict[str, Any]: + """获取歌手信息和热门歌曲 + + Args: + artist_id: 歌手ID + + Returns: + 歌手信息和热门歌曲列表 + + Raises: + APIException: API调用失败时抛出 + """ + try: + url = f'{APIConstants.ARTIST_API}{artist_id}' + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + result = response.json() + _ensure_dict(result, 'get_artist_hot_songs 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"获取歌手信息失败: {result.get('message', '未知错误')}") + + artist = result.get('artist') or {} + songs = [] + for song in (result.get('hotSongs') or []): + songs.append({ + 'id': song.get('id'), + 'name': song.get('name'), + 'artists': '/'.join(a.get('name', '') for a in (song.get('ar') or [])), + 'album': ((song.get('al') or {}).get('name', '')), + 'picUrl': (song.get('al') or {}).get('picUrl', '') + }) + + return { + 'artist': { + 'id': artist.get('id'), + 'name': artist.get('name'), + 'picUrl': artist.get('picUrl'), + 'alias': '/'.join(artist.get('alias', [])) if artist.get('alias') else '', + 'briefDesc': artist.get('briefDesc', ''), + 'musicSize': artist.get('musicSize', len(songs)) + }, + 'songs': songs + } + except requests.RequestException as e: + raise APIException(f"获取歌手信息请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析歌手信息响应失败: {e}") + + def get_song_comments(self, song_id: int, limit: int = 20, offset: int = 0) -> Dict[str, Any]: + """获取歌曲评论 + + Args: + song_id: 歌曲ID + limit: 每页数量 + offset: 偏移量 + + Returns: + 评论数据(热门评论 + 全部评论) + + Raises: + APIException: API调用失败时抛出 + """ + try: + url = f'{APIConstants.COMMENT_API}{song_id}?limit={limit}&offset={offset}' + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + result = response.json() + _ensure_dict(result, 'get_song_comments 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"获取评论失败: {result.get('message', '未知错误')}") + + def _comment_dict(c): + c = c or {} + user = c.get('user') or {} + be_replied = c.get('beReplied') or [] + replied = (be_replied[0] or {}) if be_replied else {} + replied_user = replied.get('user') or {} + vip_rights = user.get('vipRights') if isinstance(user.get('vipRights'), dict) else {} + associator = vip_rights.get('associator') if isinstance(vip_rights.get('associator'), dict) else {} + location_info = user.get('locationInfo') if isinstance(user.get('locationInfo'), dict) else {} + return { + 'id': c.get('commentId'), + 'content': c.get('content', ''), + 'time': c.get('time', 0), + 'likedCount': c.get('likedCount', 0), + 'user': { + 'nickname': user.get('nickname', ''), + 'avatarUrl': user.get('avatarUrl', ''), + 'vipType': user.get('vipType', 0), + 'vipLevel': associator.get('level', 0), + 'location': location_info.get('location', '') + }, + 'beReplied': [{ + 'nickname': replied_user.get('nickname', '') or '', + 'content': replied.get('content') or '' + }] + } + + return { + 'total': result.get('total', 0), + 'hasMore': result.get('hasMore', False), + 'hotComments': [_comment_dict(c) for c in (result.get('hotComments') or [])], + 'comments': [_comment_dict(c) for c in (result.get('comments') or [])], + } + except requests.RequestException as e: + raise APIException(f"获取评论请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析评论响应失败: {e}") + + def get_account_info(self, cookies: Dict[str, str]) -> Optional[Dict[str, Any]]: + """获取当前账号信息(用于个人登录状态展示) + + Args: + cookies: 用户cookies + + Returns: + 账号信息字典,获取失败返回 None + """ + try: + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + response = requests.get(APIConstants.USER_ACCOUNT_API, headers=headers, + cookies=cookies, timeout=30) + response.raise_for_status() + + result = response.json() + _ensure_dict(result, 'get_account_info 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + return None + + profile = result.get('profile') or {} + # 无 userId 说明 Cookie 未关联有效登录态(可能已过期) + if not profile.get('userId'): + return None + + return { + 'userId': profile.get('userId'), + 'nickname': profile.get('nickname', ''), + 'avatarUrl': profile.get('avatarUrl', ''), + 'signature': profile.get('signature', ''), + 'vipType': profile.get('vipType', 0), + 'vipLevel': profile.get('vipLevel', 0) + } + except Exception: + return None + + def get_user_playlists(self, user_id: int, cookies: Dict[str, str]) -> List[Dict[str, Any]]: + """获取用户收藏/创建的歌单列表 + + Args: + user_id: 用户ID + cookies: 用户cookies + + Returns: + 歌单列表(含「我喜欢的音乐」等个人歌单) + + Raises: + APIException: API调用失败时抛出 + + F63:超过 100 个歌单时按上游 more 标志续页(上限 20 页 = 2000 个), + 不再只取第一页就静默截断。 + """ + try: + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + + playlists = [] + offset = 0 + page_size = 100 + for _ in range(20): # 有界翻页,防上游 more 异常导致无限循环 + params = {'uid': user_id, 'limit': page_size, 'offset': offset} + response = requests.get(APIConstants.USER_PLAYLIST_API, params=params, + headers=headers, cookies=cookies, timeout=30) + response.raise_for_status() + + result = response.json() + _ensure_dict(result, 'get_user_playlists 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"获取用户歌单失败: {result.get('message', '未知错误')}") + + page_items = result.get('playlist') or [] + seen_ids = {p.get('id') for p in playlists} + for item in page_items: + if item.get('id') in seen_ids: + continue # 上游列表变动导致重复时跳过 + creator = item.get('creator') or {} + playlists.append({ + 'id': item.get('id'), + 'name': item.get('name', ''), + 'coverImgUrl': item.get('coverImgUrl'), + 'trackCount': item.get('trackCount', 0), + 'playCount': item.get('playCount', 0), + 'creator': creator.get('nickname', ''), + 'is_own': creator.get('userId') == user_id, + 'specialType': item.get('specialType', 0), + 'subscribed': item.get('subscribed', False), + 'description': item.get('description', '') + }) + more = bool(result.get('more')) + if not more or len(page_items) < page_size: + break + offset += page_size + return playlists + except requests.RequestException as e: + raise APIException(f"获取用户歌单请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析用户歌单响应失败: {e}") + + def detect_vip_by_probe(self, cookies: Dict[str, str], + probe_song_id: int = 347230) -> Dict[str, Any]: + """通过歌曲URL接口探测账号可用的最高音质,推断会员类型 + + 直接询问网易云接口「这个账号能把歌解析到什么音质」, + 比 account/get 更可靠(旧 Cookie 可能查不到 profile)。 + + Args: + cookies: 用户cookies + probe_song_id: 用于探测的歌曲ID(默认《海阔天空》,支持全音质) + + Returns: + {'vip_type': int, 'highest_level': str, 'levels_ok': List[str]} + """ + levels_ok = self.probe_levels(cookies, probe_song_id) + highest = levels_ok[0] if levels_ok else 'standard' + + if highest in ('jymaster', 'jyeffect', 'sky'): + vip_type = 110 # 黑胶SVIP(可解析沉浸/母带级) + elif highest in ('hires', 'lossless'): + vip_type = 11 # 黑胶VIP + else: + vip_type = 0 # 普通用户 + return {'vip_type': vip_type, 'highest_level': highest, 'levels_ok': levels_ok} + + def probe_levels(self, cookies: Dict[str, str], + probe_song_id: int = 347230) -> List[str]: + """实测账号对每个音质级别能否真正解析 + + 只有接口返回的 level 与请求一致且带 URL,才算该音质可解析。 + 返回按音质从高到低排序的可解析列表。 + + Args: + cookies: 用户cookies + probe_song_id: 探测歌曲ID + + Returns: + 可解析音质列表(从高到低) + """ + ladder = ['jymaster', 'jyeffect', 'sky', 'hires', 'lossless', 'exhigh', 'standard', 'dolby'] + levels_ok = [] + # 第 100 条:探测总预算 60s——上游黑洞时 8 级串行最坏 ~16 分钟线程滞留, + # 超预算即返回已测得的结果(调用方 5 分钟缓存内复用) + _deadline = time.monotonic() + 60 + for level in ladder: + if time.monotonic() > _deadline: + break + try: + result = self.get_song_url(probe_song_id, level, cookies) + if result and result.get('data'): + d = result['data'][0] or {} + # 请求与返回一致且带直链,才说明该音质真正可用 + if d.get('url') and d.get('level') == level: + levels_ok.append(level) + except Exception: + continue + return levels_ok + + def subscribe_playlist(self, playlist_id, cookies: Dict[str, str], + action: int = 1) -> Dict[str, Any]: + """收藏/取消收藏歌单(官方接口) + + Args: + playlist_id: 歌单ID + cookies: 个人账号cookies + action: 1=收藏, 2=取消收藏 + + Returns: + 接口响应 + + Raises: + APIException: 操作失败时抛出 + """ + try: + data = {'id': playlist_id, 't': action} + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + response = requests.post(APIConstants.PLAYLIST_SUBSCRIBE_API, data=data, + headers=headers, cookies=cookies, timeout=30) + response.raise_for_status() + result = response.json() + _ensure_dict(result, 'subscribe_playlist 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"收藏歌单失败: {result.get('message', '未知错误')}") + return result + except requests.RequestException as e: + raise APIException(f"收藏歌单请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析收藏歌单响应失败: {e}") + + def subscribe_album(self, album_id, cookies: Dict[str, str], + action: int = 1) -> Dict[str, Any]: + """收藏/取消收藏专辑(官方接口) + + Args: + album_id: 专辑ID + cookies: 个人账号cookies + action: 1=收藏, 2=取消收藏 + + Returns: + 接口响应 + + Raises: + APIException: 操作失败时抛出 + """ + try: + data = {'id': album_id, 't': action} + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER + } + response = requests.post(APIConstants.ALBUM_SUBSCRIBE_API, data=data, + headers=headers, cookies=cookies, timeout=30) + response.raise_for_status() + result = response.json() + _ensure_dict(result, 'subscribe_album 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"收藏专辑失败: {result.get('message', '未知错误')}") + return result + except requests.RequestException as e: + raise APIException(f"收藏专辑请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析收藏专辑响应失败: {e}") + + def post_comment(self, thread_id: str, content: str, action: int = 1, + comment_id: Optional[int] = None, + cookies: Dict[str, str] = None) -> Dict[str, Any]: + """发送/回复歌曲评论(官方 weapi 接口) + + Args: + thread_id: 资源线程ID,如 R_SO_4_{songId} + content: 评论内容 + action: 1=发送评论, 2=回复评论 + comment_id: 回复目标评论ID(action=2 时必填) + cookies: 个人账号cookies + + Returns: + 接口响应 + + Raises: + APIException: 操作失败时抛出 + """ + try: + payload: Dict[str, Any] = { + 'threadId': thread_id, + 'content': content, + 't': action, + 'type': 6, + } + if comment_id: + payload['commentId'] = comment_id + # 第 91 条(P2-5):官方 /weapi/comment 要求携带 csrf_token(取自 Cookie + # 的 __csrf)——原实现缺失,与加密偏差叠加导致评论疑似从未成功 + csrf = (cookies or {}).get('__csrf') or '' + if csrf: + payload['csrf_token'] = csrf + + body = WeapiCrypto.encrypt(payload) + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER, + } + response = requests.post(APIConstants.COMMENT_POST_API, data=body, + headers=headers, cookies=cookies or {}, timeout=30) + response.raise_for_status() + + text = response.text.strip() + if not text: + # weapi 被网络层吞掉(数据中心 IP 风控),无法确认是否成功 + raise APIException("网易云未返回操作确认(可能被网络风控拦截),请稍后查看评论区确认") + + result = response.json() + _ensure_dict(result, 'post_comment 接口响应异常(非标准 JSON 对象)') + if result.get('code') != 200: + raise APIException(f"评论失败: {result.get('message', '未知错误')}") + return result + except requests.RequestException as e: + raise APIException(f"评论请求失败: {e}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析评论响应失败: {e}") + + def netease_encrypt_id(self, id_str: str) -> str: + """网易云加密图片ID算法 + + Args: + id_str: 图片ID字符串 + + Returns: + 加密后的字符串 + """ + import base64 + import hashlib + + magic = list('3go8&$8*3*3h0k(2)2') + song_id = list(id_str) + + for i in range(len(song_id)): + song_id[i] = chr(ord(song_id[i]) ^ ord(magic[i % len(magic)])) + + m = ''.join(song_id) + md5_bytes = hashlib.md5(m.encode('utf-8')).digest() + result = base64.b64encode(md5_bytes).decode('utf-8') + result = result.replace('/', '_').replace('+', '-') + + return result + + def get_pic_url(self, pic_id: Optional[int], size: int = 300) -> str: + """获取网易云加密歌曲/专辑封面直链 + + Args: + pic_id: 封面ID + size: 图片尺寸 + + Returns: + 图片URL + """ + if pic_id is None: + return '' + + enc_id = self.netease_encrypt_id(str(pic_id)) + return f'https://p3.music.126.net/{enc_id}/{pic_id}.jpg?param={size}y{size}' + + +class QRLoginManager: + """二维码登录管理器""" + + def __init__(self): + self.http_client = HTTPClient() + self.crypto_utils = CryptoUtils() + + def generate_qr_key(self) -> Optional[str]: + """生成二维码的key + + Returns: + 成功返回unikey,失败返回None + + Raises: + APIException: API调用失败时抛出 + """ + try: + config = APIConstants.DEFAULT_CONFIG.copy() + config["requestId"] = str(randrange(20000000, 30000000)) + + payload = { + 'type': 1, + 'header': json.dumps(config) + } + + params = self.crypto_utils.encrypt_params(APIConstants.QR_UNIKEY_API, payload) + response = self.http_client.post_request_full(APIConstants.QR_UNIKEY_API, params, {}) + + result = json.loads(response.text) + # 第 97 条(P3):非 dict 响应(null/数组)不再 AttributeError 逃出捕获 + _ensure_dict(result, '生成二维码key响应异常(非标准 JSON 对象)') + if result.get('code') == 200: + return result.get('unikey') + else: + raise APIException(f"生成二维码key失败: {result.get('message', '未知错误')}") + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析二维码key响应失败: {e}") + + def create_qr_login(self) -> Optional[str]: + """创建登录二维码并在控制台显示 + + Returns: + 成功返回unikey,失败返回None + """ + try: + import qrcode + + unikey = self.generate_qr_key() + if not unikey: + print("生成二维码key失败") + return None + + # 创建二维码 + qr = qrcode.QRCode() + qr.add_data(f'https://music.163.com/login?codekey={unikey}') + qr.make(fit=True) + + # 在控制台显示二维码 + qr.print_ascii(tty=True) + print("\n请使用网易云音乐APP扫描上方二维码登录") + return unikey + except ImportError: + print("请安装qrcode库: pip install qrcode") + return None + except Exception as e: + print(f"创建二维码失败: {e}") + return None + + def check_qr_login(self, unikey: str) -> Tuple[int, Dict[str, str]]: + """检查二维码登录状态 + + Args: + unikey: 二维码key + + Returns: + (登录状态码, cookie字典) + + Raises: + APIException: API调用失败时抛出 + """ + try: + config = APIConstants.DEFAULT_CONFIG.copy() + config["requestId"] = str(randrange(20000000, 30000000)) + + payload = { + 'key': unikey, + 'type': 1, + 'header': json.dumps(config) + } + + params = self.crypto_utils.encrypt_params(APIConstants.QR_LOGIN_API, payload) + response = self.http_client.post_request_full(APIConstants.QR_LOGIN_API, params, {}) + + result = json.loads(response.text) + # 第 97 条(P3):非 dict 响应不再 AttributeError 逃出捕获(同 generate_qr_key) + _ensure_dict(result, '解析登录状态响应失败(非标准 JSON 对象)') + cookie_dict = {} + + if result.get('code') == 803: + # 登录成功,提取cookie。 + # 第 94 条(P1-2):此前只提取 MUSIC_U——__csrf 从未入 Cookie 串, + # weapi 写接口(发评论等)的 csrf_token 恒为空。改用 requests 的 + # cookie jar(requests 已把多条 Set-Cookie 合并解析,不受逗号合并 + # 的脆弱 split 影响)补齐 MUSIC_U 与 __csrf。 + for name in ('MUSIC_U', '__csrf'): + val = response.cookies.get(name) + if val: + cookie_dict[name] = val + if not cookie_dict.get('MUSIC_U'): + # 兜底:jar 异常为空时回退 Set-Cookie 头解析 MUSIC_U + for cookie_str in response.headers.get('Set-Cookie', '').split(', '): + if 'MUSIC_U=' in cookie_str: + cookie_dict['MUSIC_U'] = cookie_str.split('MUSIC_U=')[1].split(';')[0] + break + + return result.get('code', -1), cookie_dict + except (json.JSONDecodeError, KeyError) as e: + raise APIException(f"解析登录状态响应失败: {e}") + + def qr_login(self) -> Optional[str]: + """完整的二维码登录流程 + + Returns: + 成功返回cookie字符串,失败返回None + """ + try: + unikey = self.create_qr_login() + if not unikey: + return None + + while True: + code, cookies = self.check_qr_login(unikey) + + if code == 803: + print("\n登录成功!") + # 第 97 条(P2-8):CLI 序列化同 qr_login 口径带上 __csrf(94 条只修 + # 了 web 路径;此串正是写往全局 cookie.txt 的内容) + serialized = f"MUSIC_U={cookies['MUSIC_U']};os=pc;appver=8.9.70;" + if cookies.get('__csrf'): + serialized += f"__csrf={cookies['__csrf']};" + return serialized + elif code == 801: + print("\r等待扫码...", end='') + elif code == 802: + print("\r扫码成功,请在手机上确认登录...", end='') + else: + print(f"\n登录失败,错误码:{code}") + return None + + time.sleep(2) + except KeyboardInterrupt: + print("\n用户取消登录") + return None + except Exception as e: + print(f"\n登录过程中发生错误: {e}") + return None + + +# 向后兼容的函数接口 +def url_v1(song_id: int, level: str, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取歌曲URL(向后兼容)""" + api = NeteaseAPI() + return api.get_song_url(song_id, level, cookies) + + +def name_v1(song_id: int) -> Dict[str, Any]: + """获取歌曲详情(向后兼容)""" + api = NeteaseAPI() + return api.get_song_detail(song_id) + + +def lyric_v1(song_id: int, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取歌词(向后兼容)""" + api = NeteaseAPI() + return api.get_lyric(song_id, cookies) + + +def search_music(keywords: str, cookies: Dict[str, str], limit: int = 10, + search_type: int = 1, offset: int = 0) -> List[Dict[str, Any]]: + """搜索音乐(向后兼容)""" + api = NeteaseAPI() + return api.search_music(keywords, cookies, limit, search_type, offset=offset) + + +def toplist_list() -> List[Dict[str, Any]]: + """获取官方榜单列表(向后兼容)""" + api = NeteaseAPI() + return api.get_toplist() + + +def artist_songs(artist_id: int) -> Dict[str, Any]: + """获取歌手信息和热门歌曲(向后兼容)""" + api = NeteaseAPI() + return api.get_artist_hot_songs(artist_id) + + +def song_comments(song_id: int, limit: int = 20, offset: int = 0) -> Dict[str, Any]: + """获取歌曲评论(向后兼容)""" + api = NeteaseAPI() + return api.get_song_comments(song_id, limit, offset) + + +def account_info(cookies: Dict[str, str]) -> Optional[Dict[str, Any]]: + """获取当前账号信息(向后兼容)""" + api = NeteaseAPI() + return api.get_account_info(cookies) + + +def user_playlists(user_id: int, cookies: Dict[str, str]) -> List[Dict[str, Any]]: + """获取用户收藏/创建的歌单列表(向后兼容)""" + api = NeteaseAPI() + return api.get_user_playlists(user_id, cookies) + + +def detect_vip_by_probe(cookies: Dict[str, str], probe_song_id: int = 347230) -> Dict[str, Any]: + """通过歌曲URL接口探测账号会员类型(向后兼容)""" + api = NeteaseAPI() + return api.detect_vip_by_probe(cookies, probe_song_id) + + +def playlist_subscribe(playlist_id: int, cookies: Dict[str, str], action: int = 1) -> Dict[str, Any]: + """收藏/取消收藏歌单(向后兼容)""" + api = NeteaseAPI() + return api.subscribe_playlist(playlist_id, cookies, action) + + +def album_subscribe(album_id: int, cookies: Dict[str, str], action: int = 1) -> Dict[str, Any]: + """收藏/取消收藏专辑(向后兼容)""" + api = NeteaseAPI() + return api.subscribe_album(album_id, cookies, action) + + +def post_comment(thread_id: str, content: str, action: int = 1, + comment_id: Optional[int] = None, + cookies: Dict[str, str] = None) -> Dict[str, Any]: + """发送/回复歌曲评论(向后兼容)""" + api = NeteaseAPI() + return api.post_comment(thread_id, content, action, comment_id, cookies) + + +def probe_levels(cookies: Dict[str, str], probe_song_id: int = 347230) -> List[str]: + """实测账号可解析的音质列表(向后兼容)""" + api = NeteaseAPI() + return api.probe_levels(cookies, probe_song_id) + + +def playlist_detail(playlist_id: int, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取歌单详情(向后兼容)""" + api = NeteaseAPI() + return api.get_playlist_detail(playlist_id, cookies) + + +def album_detail(album_id: int, cookies: Dict[str, str]) -> Dict[str, Any]: + """获取专辑详情(向后兼容)""" + api = NeteaseAPI() + return api.get_album_detail(album_id, cookies) + + +def get_pic_url(pic_id: Optional[int], size: int = 300) -> str: + """获取图片URL(向后兼容)""" + api = NeteaseAPI() + return api.get_pic_url(pic_id, size) + + +def qr_login() -> Optional[str]: + """二维码登录(向后兼容)""" + manager = QRLoginManager() + return manager.qr_login() + + +# ============ Cookie 工具 ============ + +DEFAULT_COOKIE_FILE = "cookie.txt" + + +def load_cookies(path: str = DEFAULT_COOKIE_FILE) -> Dict[str, str]: + """从 cookie.txt 直接读取并解析为 dict + + 支持标准 ``NMTID=xxx; _ntes_nnid=xxx; ...`` 格式。 + 文件不存在或解析失败返回空 dict(不抛异常,调用方决定如何处理)。 + 第 94 条(P3):utf-8-sig 剥记事本 BOM(BOM 会让首键变成 \\ufeffMUSIC_U 静默 + 丢 Cookie);解析改逐行再按 ``;`` 切(原实现 ';|\\n' 二选一,混排时丢键值对)。 + """ + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8-sig") as f: + content = f.read() + except OSError: + return {} + return parse_cookie_string(content) + + +def parse_cookie_string(cookie_string: str) -> Dict[str, str]: + """把 ``k1=v1; k2=v2`` 格式字符串解析为 dict + + 支持 ``;`` 或 ``\\n`` 作为分隔符。 + """ + cookies: Dict[str, str] = {} + if not cookie_string: + return cookies + cookie_string = cookie_string.strip() + if not cookie_string: + return cookies + # 第 94 条(P3):先逐行再按 ; 切——原实现 ";|\\n" 二选一,混排时行内 \\n + # 会混进 value、后半段键值对丢失 + pairs = [] + for line in cookie_string.splitlines(): + for part in line.split(";"): + part = part.strip() + if part: + pairs.append(part) + for pair in pairs: + pair = pair.strip() + if not pair or "=" not in pair: + continue + key, value = pair.split("=", 1) + key = key.strip() + value = value.strip() + if key and value: + cookies[key] = value + return cookies + + +if __name__ == "__main__": + # 测试代码 + print("网易云音乐API模块") + print("支持的功能:") + print("- 歌曲URL获取") + print("- 歌曲详情获取") + print("- 歌词获取") + print("- 音乐搜索") + print("- 歌单详情") + print("- 专辑详情") + print("- 二维码登录") diff --git a/music_downloader.py b/music_downloader.py new file mode 100644 index 0000000..d973be5 --- /dev/null +++ b/music_downloader.py @@ -0,0 +1,1173 @@ +"""网易云音乐下载器(适配 ffmpeg 写入元数据) + +提供功能: +- 同步 / 异步下载(本地落盘 / 内存返回) +- 批量并发下载 +- 进度查询 +- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus) + +设计要点: +1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装 +2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险 +3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart +4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底 +""" + +from __future__ import annotations + +import contextlib +import logging +import os +import re +import shutil +import subprocess +import tempfile +import threading +import time +from collections import deque +from dataclasses import dataclass +from enum import Enum +from io import BytesIO +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import requests +from requests.adapters import HTTPAdapter + +from music_api import APIException, NeteaseAPI, load_cookies + +# P2-5:worker 线程的输出一律走 logger(坑 11——NSSM GBK 重定向下裸 print +# 非可编码字符会 UnicodeEncodeError 崩掉 worker,尤其 ffmpeg stderr 原文回显) +logger = logging.getLogger('music_downloader') + + +# ============ HTTP 连接复用(提速核心) ============ + +# 常见浏览器 UA,避免直链 CDN 因空 UA 限速/拒绝 +DEFAULT_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +# 音频流式下载分块大小(256KB):减少系统调用与磁盘 IO 次数,提升吞吐 +DL_CHUNK_SIZE = 256 * 1024 + +# 线程本地 Session:同线程内复用 TCP/TLS 连接(keep-alive),批量并发显著提速 +_SESSION_LOCAL = threading.local() + + +# 第 97 条(P3):下载内容嗅探的音频魔数(未知期望尺寸时唯一的内容防线) +_AUDIO_MAGIC = (b'ID3', b'fLaC', b'OggS', b'RIFF') + + +def _sniff_audio_header(path) -> bool: + """读取文件头做基础音频魔数校验:MP3(ID3/裸帧)、FLAC、OGG/Opus、WAV(RIFF)、 + M4A/MP4(ftyp)。未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容 + 防线——CDN 错误页/风控 JSON 不再能被存成音频交付。""" + try: + with open(path, 'rb') as f: + head = f.read(16) + except OSError: + return False + if not head: + return False + if any(head.startswith(m) for m in _AUDIO_MAGIC): + return True + if len(head) >= 8 and head[:4] == b'\x00\x00\x00' and head[4:8] == b'ftyp': + return True + return len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0 + + +def get_http_session() -> requests.Session: + """获取当前线程的共享 requests.Session(带连接池 + keep-alive)""" + session: Optional[requests.Session] = getattr(_SESSION_LOCAL, "session", None) + if session is None: + session = requests.Session() + adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0) + session.mount("https://", adapter) + session.mount("http://", adapter) + session.headers.update({"User-Agent": DEFAULT_UA}) + _SESSION_LOCAL.session = session + return session + + +# per-file 写锁:保证同一目标文件的 .part 临时文件同时只有一个写入者, +# 防止并发下载同名歌曲(不同用户/任务)互相覆盖导致文件损坏 +# F78:锁表带引用计数与回收——无人引用时从表中移除,防止长期运行无界增长 +# A3:计数在注册表守卫锁内完成("取出引用"即计数 +1),消除"取引用后、acquire 前 +# 被另一个线程回收、第三个线程拿到新锁对象"的互斥破坏窗口 +_FILE_LOCKS: Dict[str, "RefCountedFileLock"] = {} +_FILE_LOCKS_GUARD = threading.Lock() + + +class RefCountedFileLock: + """带引用计数的文件锁:refs 表示"已持有或已登记即将持有"的数量,归零可回收""" + + def __init__(self): + self._lock = threading.Lock() + self.refs = 0 + + def acquire(self) -> None: + self._lock.acquire() + + def release(self) -> None: + self._lock.release() + + def locked(self) -> bool: + return self._lock.locked() + + +class _FileLockHandle: + """兼容 `with get_file_lock(path):` 旧用法的句柄:语义与 file_lock_scope 一致""" + + def __init__(self, path: Path): + self._path = path + self._lock = None + self._key = '' + + def __enter__(self) -> "RefCountedFileLock": + key = str(self._path) + with _FILE_LOCKS_GUARD: + lock = _FILE_LOCKS.get(key) + if lock is None: + lock = RefCountedFileLock() + _FILE_LOCKS[key] = lock + lock.refs += 1 + self._lock = lock + self._key = key + try: + lock.acquire() + except BaseException: + # R5-17:acquire 被打断时回滚引用计数并摘除空表项,防锁表条目永久泄漏 + with _FILE_LOCKS_GUARD: + lock.refs -= 1 + if lock.refs <= 0 and not lock.locked(): + _FILE_LOCKS.pop(key, None) + self._lock = None + self._key = '' + raise + return lock + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if self._lock is None: + return + self._lock.release() + with _FILE_LOCKS_GUARD: + self._lock.refs -= 1 + if self._lock.refs <= 0 and not self._lock.locked(): + _FILE_LOCKS.pop(self._key, None) + self._lock = None + + +def get_file_lock(path: Path) -> "_FileLockHandle": + """兼容入口:返回可用 with 语句的锁句柄(A3:引用计数在守卫锁内登记,无回收竞态)""" + return _FileLockHandle(path) + + +# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用) +_COVER_CACHE: Dict[str, bytes] = {} +_COVER_CACHE_ORDER: deque = deque(maxlen=128) +_COVER_CACHE_LOCK = threading.Lock() + + +def fetch_cover_bytes(pic_url: str) -> Optional[bytes]: + """下载并缓存封面图片字节;失败返回 None(B3:字节预算 10MB,拒绝超大盘点阵/伪装文件)""" + if not pic_url: + return None + with _COVER_CACHE_LOCK: + cached = _COVER_CACHE.get(pic_url) + if cached: + return cached + try: + resp = get_http_session().get(pic_url, timeout=15, stream=True) + try: + resp.raise_for_status() + try: + declared = int(resp.headers.get('Content-Length') or 0) + except ValueError: + declared = 0 # 第 91 条(P3):垃圾 Content-Length 不炸,按未知长度走流式 + if declared > 10 * 1024 * 1024: + return None + # bytearray 就地追加(原 bytes += 是 O(n²) 拷贝,10MB 封面最坏多拷上百 MB) + data = bytearray() + for chunk in resp.iter_content(chunk_size=64 * 1024): + data += chunk + if len(data) > 10 * 1024 * 1024: + return None + if not data: + return None + data = bytes(data) + finally: + # 第 91 条(P3):所有路径统一关闭响应(原 Content-Length 异常路径漏关) + resp.close() + except Exception: + return None + with _COVER_CACHE_LOCK: + if pic_url not in _COVER_CACHE: + # 简单 FIFO 淘汰,防止缓存无界增长 + while len(_COVER_CACHE_ORDER) >= _COVER_CACHE_ORDER.maxlen: + evict = _COVER_CACHE_ORDER.popleft() + _COVER_CACHE.pop(evict, None) + _COVER_CACHE[pic_url] = data + _COVER_CACHE_ORDER.append(pic_url) + return data + + +# ============ 类型定义 ============ + +class QualityLevel(Enum): + """音质等级枚举""" + STANDARD = "standard" + EXHIGH = "exhigh" + LOSSLESS = "lossless" + HIRES = "hires" + SKY = "sky" + JYEFFECT = "jyeffect" + JYMASTER = "jymaster" + DOLBY = "dolby" + + +@dataclass +class MusicInfo: + """音乐信息数据类""" + id: int + name: str + artists: str + album: str + pic_url: str + duration: int + track_number: int + download_url: str + file_type: str + file_size: int + quality: str + lyric: str = "" + tlyric: str = "" + publish_year: Optional[int] = None + + +@dataclass +class DownloadResult: + """下载结果数据类""" + success: bool + file_path: Optional[str] = None + file_size: int = 0 + error_message: str = "" + music_info: Optional[MusicInfo] = None + + +class DownloadException(Exception): + """下载异常类""" + pass + + +class DownloadCanceled(DownloadException): + """下载被用户取消""" + pass + + +class _RetryFromScratch(requests.RequestException): + """内部信号:断点续传起点失效(HTTP 416 等),清除 .part 后从头重试""" + pass + + +class CountingSemaphore: + """带活跃/等待计数的信号量(线程安全),用于实时展示排队情况 + + 用法与 threading.BoundedSemaphore 一致(支持 with 语句), + 额外维护 active(正在执行)/ waiting(排队等待)两个计数, + 供前端实时展示排队人数与预计等待时间。 + """ + + def __init__(self, value: int = 1): + self.capacity = max(1, int(value)) + self._semaphore = threading.BoundedSemaphore(self.capacity) + self._lock = threading.Lock() + self.active = 0 + self.waiting = 0 + + def acquire(self, blocking: bool = True) -> bool: + if not blocking: + if not self._semaphore.acquire(blocking=False): + return False + with self._lock: + self.active += 1 + return True + with self._lock: + self.waiting += 1 + try: + self._semaphore.acquire() + except BaseException: + with self._lock: + self.waiting = max(0, self.waiting - 1) + raise + with self._lock: + self.waiting = max(0, self.waiting - 1) + self.active += 1 + return True + + def acquire_cancellable(self, cancel_ev, poll: float = 1.0) -> bool: + """可取消地等待名额(F91):等待期间 cancel_ev 置位立即返回 False, + 不再无期限阻塞线程与退款。返回 True 表示已获得名额。""" + import time as _time + with self._lock: + self.waiting += 1 + try: + while True: + if cancel_ev is not None and cancel_ev.is_set(): + return False + if self._semaphore.acquire(blocking=False): + with self._lock: + self.active += 1 + return True + _time.sleep(poll) + finally: + with self._lock: + self.waiting = max(0, self.waiting - 1) + + def release(self) -> None: + self._semaphore.release() + with self._lock: + self.active = max(0, self.active - 1) + + def __enter__(self) -> "CountingSemaphore": + self.acquire() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.release() + + +# ============ 工具映射表 ============ + +# API 返回的 type -> 文件扩展名 +API_TYPE_TO_EXT = { + "mp3": ".mp3", + "flac": ".flac", + "m4a": ".m4a", + "mp4": ".mp4", + "ogg": ".ogg", + "opus": ".opus", +} + +# 文件扩展名 -> ffmpeg 输出 muxer 名称 +EXT_TO_FFMPEG_FORMAT = { + ".mp3": "mp3", + ".flac": "flac", + ".m4a": "mp4", + ".mp4": "mp4", + ".ogg": "ogg", + ".opus": "opus", +} + +# 支持元数据写入的扩展名 +METADATA_FORMATS = {".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".opus"} + + +# ============ 核心实现 ============ + +class MusicDownloader: + """网易云音乐下载器 + + 用法: + downloader = MusicDownloader(download_dir="downloads") + result = downloader.download_music_file(music_id, quality="lossless") + """ + + def __init__( + self, + download_dir: str = "downloads", + max_concurrent: int = 3, + ffmpeg_path: Optional[str] = None, + global_max_concurrent: int = 10, + ): + """ + Args: + download_dir: 下载目录 + max_concurrent: 单个批量任务内的最大并发下载数 + ffmpeg_path: ffmpeg 可执行文件路径,为 None 时启动时检测一次 + global_max_concurrent: 整个服务同时下载的文件数(含 ffmpeg 元数据写入)。 + 所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。 + """ + self.download_dir = Path(download_dir) + self.download_dir.mkdir(exist_ok=True) + self.max_concurrent = max_concurrent + + self.api = NeteaseAPI() + + # 全局并发信号量:所有请求共用,限制同时进行的下载数 + # (带计数,供前端实时展示排队情况) + self._download_semaphore = CountingSemaphore(global_max_concurrent) + + # ffmpeg 元数据写入专用信号量(CPU 密集操作): + # 与下载分离限流 —— 元数据写满时只阻塞元数据,不占下载名额、不拖慢其它文件的传输 + cpu_count = os.cpu_count() or 2 + self._meta_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count))) + + # ffmpeg 路径缓存:启动时检测一次,写入时还会再懒加载一次 + self.ffmpeg_path = ffmpeg_path or shutil.which("ffmpeg") + if not self.ffmpeg_path: + logger.warning("未检测到 ffmpeg,写入元数据功能将不可用") + + # ----------- 工具方法 ----------- + + def _sanitize_filename(self, filename: str, max_bytes: int = 180) -> str: + """清理文件名非法字符。 + F79:换行/回车/制表等控制字符一律剔除(否则 send_file 头部 500); + F32:按 UTF-8 字节截断(Linux 文件系统 255 字节上限,中文按字符截 200 会超), + 且从 _build_file_path 传入时保证尾部 [ID 音质] 标记不被截掉。""" + filename = re.sub(r'[\x00-\x1f\x7f]', '', filename) + filename = re.sub(r'[<>:"/\\|?*]', '_', filename) + filename = filename.strip(' .') + # 按字节截断(在字符边界上回退,不产生半个字符) + raw = filename.encode('utf-8') + if len(raw) > max_bytes: + cut = filename.encode('utf-8')[:max_bytes] + # 回退到合法 UTF-8 边界 + while cut and (cut[-1] & 0xC0) == 0x80: + cut = cut[:-1] + filename = cut.decode('utf-8', errors='ignore').rstrip(' .') + return filename or "unknown" + + def _determine_file_extension( + self, + url: str, + content_type: str = "", + api_type: str = "", + ) -> str: + """确定文件扩展名 + + 优先级: API 返回的 type > URL 后缀 > HTTP Content-Type > 默认 mp3 + """ + api_type = (api_type or "").lower() + if api_type in API_TYPE_TO_EXT: + return API_TYPE_TO_EXT[api_type] + + url_lower = url.lower() + if ".flac" in url_lower: + return ".flac" + if ".mp3" in url_lower: + return ".mp3" + if ".m4a" in url_lower or ".mp4" in url_lower: + return ".m4a" + + content_type = (content_type or "").lower() + if "flac" in content_type: + return ".flac" + if "mpeg" in content_type or "mp3" in content_type: + return ".mp3" + if "mp4" in content_type or "m4a" in content_type: + return ".m4a" + + return ".mp3" + + def _resolve_ffmpeg(self) -> Optional[str]: + """懒加载解析 ffmpeg 路径 + + 每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。 + """ + if self.ffmpeg_path: + return self.ffmpeg_path + path = shutil.which("ffmpeg") + if path: + self.ffmpeg_path = path # 回写缓存 + return path + + @staticmethod + def _unlink_quiet(path: Path) -> None: + """安静地删除文件,忽略不存在 / 权限错误""" + try: + path.unlink() + except OSError: + pass + + # ----------- 信息获取 ----------- + + def get_music_info(self, music_id: int, quality: str = "standard", + cookies: Optional[Dict[str, str]] = None) -> MusicInfo: + """获取音乐详细信息(同步,URL/详情/歌词 3 个接口并行请求,加快单曲/批量启动)""" + try: + cookies = cookies if cookies is not None else load_cookies() + + # 3 个接口并行请求,单曲下载/批量预取都能明显提速 + # F99:URL/详情拿到后立即 shutdown(cancel_futures), + # 已知失败不再被无关的歌词请求拖着等完才报错 + from concurrent.futures import ThreadPoolExecutor + _pool = ThreadPoolExecutor(max_workers=3) + url_f = _pool.submit(self.api.get_song_url, music_id, quality, cookies) + detail_f = _pool.submit(self.api.get_song_detail, music_id) + lyric_f = _pool.submit(self.api.get_lyric, music_id, cookies) + try: + url_result = url_f.result() + detail_result = detail_f.result() + try: + lyric_result = lyric_f.result() + except Exception: + lyric_result = None # 歌词接口抖动不阻断解析/下载 + finally: + try: + _pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + _pool.shutdown(wait=False) + + if not url_result.get("data") or not url_result["data"]: + raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接") + + # 第 91 条(P2-8):上游 data 数组元素可能为 null(F94 同族) + song_data = url_result["data"][0] or {} + download_url = song_data.get("url", "") or "" + if not download_url: + raise DownloadException(f"音乐ID {music_id} 无可用的下载链接") + + if not detail_result.get("songs") or not detail_result["songs"]: + raise DownloadException(f"无法获取音乐ID {music_id} 的详细信息") + + song_detail = detail_result["songs"][0] + + # F94:上游可选字段可能为 null(而非缺失),一律 (x or {}) / (x or []) 容错, + # 可选展示信息异常不再让整首有效歌曲解析失败 + lyric = ((lyric_result or {}).get("lrc") or {}).get("lyric", "") or "" + tlyric = ((lyric_result or {}).get("tlyric") or {}).get("lyric", "") or "" + + artists = "/".join(filter(None, ((a or {}).get("name") for a in (song_detail.get("ar") or [])))) + + al = song_detail.get("al") or {} + publish_time = al.get("publishTime", 0) or 0 + publish_year = None + if publish_time: + import datetime + try: + publish_year = datetime.datetime.fromtimestamp(publish_time / 1000).year + except (OSError, ValueError, OverflowError): + publish_year = None + + return MusicInfo( + id=music_id, + name=song_detail.get("name") or "未知歌曲", + artists=artists or "未知艺术家", + album=al.get("name") or "未知专辑", + pic_url=al.get("picUrl") or "", + duration=(song_detail.get("dt") or 0) // 1000, + track_number=song_detail.get("no", 0) or 0, + download_url=download_url, + file_type=(song_data.get("type") or "mp3").lower(), # 第 91 条(P2-8):null 容错 + file_size=song_data.get("size", 0) or 0, + quality=quality, + lyric=lyric, + tlyric=tlyric, + publish_year=publish_year, + ) + + except APIException as e: + raise DownloadException(f"API调用失败: {e}") + except Exception as e: + raise DownloadException(f"获取音乐信息时发生错误: {e}") + + # ----------- 文件路径 ----------- + + def _build_file_path(self, music_info: MusicInfo) -> Path: + """根据 MusicInfo 生成下载文件路径 + + 文件名附带歌曲 ID 与请求音质:不同歌曲同名(不同版本/重复收录)落盘互不冲突; + 同一首歌不同音质也各自独立,避免低音质文件被当作高音质交付(调包)。 + 交付文件名由调用方另行决定,不受影响。 + F32:先截断主名(按字节),再拼接 [ID 音质] 标记——标记永不丢失, + 杜绝截断后两首不同 ID 的歌落到同一路径。 + """ + base = f"{music_info.artists} - {music_info.name}" + tag = f" [{music_info.id} {music_info.quality}]" + ext = self._determine_file_extension( + music_info.download_url, + api_type=music_info.file_type, + ) + # 第 97 条(P3):按完整路径反算文件名可用长度——旧口径只按 Linux 255 字节 + # 截断文件名,未启用 LongPathsEnabled 的 Windows 上超长歌名会在 open 时 + # OSError 白白重试 3 轮。预算 = 240 - 目录前缀 - 扩展名 - ".part"等余量; + # 中文名 UTF-8 字节数 ≥ 字符数,按字节截断天然保守。 + # 目录过深时按比例压缩 tag 预算(对抗复审补:下限保底仍可能超 260 的 + # 病理场景由部署侧控制 downloads 目录长度,见交接文档第 97 条备案) + budget = 240 - len(str(self.download_dir)) - len(ext) - 8 + if budget < 124: + tag_budget = max(24, budget // 3) + else: + tag_budget = 60 + safe_base = self._sanitize_filename(base, max_bytes=max(32, budget - tag_budget)) + safe_tag = self._sanitize_filename(tag, max_bytes=tag_budget) + return self.download_dir / f"{safe_base}{safe_tag}{ext}" + + # ----------- 下载(同步) ----------- + + def download_music_file( + self, + music_id: int, + quality: str = "standard", + write_meta: bool = True, + meta_fields: Optional[List[str]] = None, + cookies: Optional[Dict[str, str]] = None, + retries: int = 2, + cancel_event: Optional[threading.Event] = None, + music_info: Optional[MusicInfo] = None, + progress_cb=None, + ) -> DownloadResult: + """同步下载音乐到本地 + + Args: + music_id: 音乐ID + quality: 音质 + write_meta: 是否写入元数据 + meta_fields: 写入字段列表,None 表示全部 + cookies: 用于获取直链/歌词的 Cookie,None 表示全局账号 + retries: 网络/写入失败时的重试次数(默认 2 次) + cancel_event: 取消事件,置位后立即中止下载(抛 DownloadCanceled) + music_info: 已预取的歌曲信息(跳过重复 API 调用,用于批量加速) + progress_cb: 可选进度回调 progress_cb(written_bytes, total_bytes)。 + 每写一个分块调用一次(含续传起点);回调内部的异常会被吞掉, + 绝不影响下载主流程。调用方自行限频。 + + 说明: + - 流式下载到 .part 临时文件,完整下载后再原子改名,半截文件不会被当作已下载 + - 断点续传:失败保留 .part,重试/下次下载从已写字节续传(服务器支持 Range 时) + - per-file 锁:同名歌曲并发下载互斥,防止 .part 被交叉写入损坏 + - 连接复用:线程级 Session + keep-alive,避免每首歌重建 TCP/TLS 连接 + - 网络传输与 ffmpeg 元数据写入分离限流,互不抢占对方资源 + """ + if music_info is None: + music_info = self.get_music_info(music_id, quality, cookies) + + file_path = self._build_file_path(music_info) + part_path0 = file_path.with_name(file_path.name + ".part") + last_error = "" + + next_wait = 0.0 + for attempt in range(retries + 1): + # 第 91 条(P3):重试退避睡眠在文件锁外执行——同名并发任务不再陪睡 + if next_wait > 0: + time.sleep(next_wait) + next_wait = 0.0 + # F117:重试轮次顶部的取消检查也要清理 .part(原实现在 try 外抛出会绕过清理) + if cancel_event is not None and cancel_event.is_set(): + self._unlink_quiet(part_path0) + raise DownloadCanceled("下载已取消") + + # 同一目标文件的下载者串行化(不同文件之间仍然并行) + with get_file_lock(file_path): + try: + # 其它线程可能刚好完成此文件,锁内复查 + if file_path.exists(): + # 缓存与本次期望不符(跨账号音质降级/上游变更都会让同 ID 同音质 + # 的实际文件不同):视为缓存 miss 删除重下,而不是把旧文件 + # 当本次结果交付(原实现命中即返回、从不校验尺寸)。 + # 第 91 条(P3):期望未知(file_size=0)时至少拒绝 0 字节坏文件 + st_size = file_path.stat().st_size + cache_bad = ((music_info.file_size and st_size != music_info.file_size) + or (not music_info.file_size and st_size == 0)) + if cache_bad: + self._unlink_quiet(file_path) + else: + # F31:缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等) + if write_meta: + with self._meta_semaphore: + self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields) + return DownloadResult( + success=True, + file_path=str(file_path), + file_size=file_path.stat().st_size, + music_info=music_info, + ) + + part_path = file_path.with_name(file_path.name + ".part") + resume_from = 0 + if part_path.exists(): + existing = part_path.stat().st_size + if music_info.file_size > 0 and existing == music_info.file_size: + # 上次已下完但未改名:直接转正(F30:转正后立即返回, + # 不再发起网络请求——原实现离线时会把现成完整文件报为失败) + # R5-15:改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件 + os.replace(part_path, file_path) + if write_meta: + with self._meta_semaphore: + self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields) + return DownloadResult( + success=True, + file_path=str(file_path), + file_size=file_path.stat().st_size, + music_info=music_info, + ) + elif existing > 0: + if music_info.file_size > 0 and existing > music_info.file_size: + # R5-15:超尺寸残片没有续传价值(上游多发货/坏分片), + # 丢弃从头下——否则下轮被 >= 误转正交付坏文件 + self._unlink_quiet(part_path) + else: + resume_from = existing # 断点续传起点 + else: + self._unlink_quiet(part_path) + + # 第 91 条(P2-7):排队改用可取消等待(F91 的 acquire_cancellable)—— + # 原阻塞式 with 使取消后仍要等全局 10 名额腾出,任务卡死、退款延迟 + if not self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5): + raise DownloadCanceled("下载已取消") + try: + headers = {} + mode = "wb" + if resume_from > 0: + headers["Range"] = f"bytes={resume_from}-" + mode = "ab" + + if progress_cb is not None: + try: + progress_cb(resume_from, music_info.file_size or 0) + except Exception: + pass # 进度回调绝不影响下载主流程 + resp: Optional[requests.Response] = None + try: + resp = get_http_session().get( + music_info.download_url, + stream=True, + timeout=(10, 60), + headers=headers, + ) + # 服务器忽略 Range 返回 200 → 从头重写而非追加 + if resume_from > 0 and resp.status_code != 206: + mode = "wb" + # F29:206 必须从我们请求的起点开始;起点错位的分片拼起来是坏文件 + if resume_from > 0 and resp.status_code == 206: + cr = resp.headers.get('Content-Range') or '' + m = re.match(r'bytes\s+(\d+)-', cr.strip()) + if m and int(m.group(1)) != resume_from: + self._unlink_quiet(part_path) + raise _RetryFromScratch( + f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载") + # R5-16:206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件, + # 与起点错位同口径处理:清残片从头下 + if not m: + self._unlink_quiet(part_path) + raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载") + resp.raise_for_status() + # F20:下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘 + max_bytes = 500 * 1024 * 1024 + written = resume_from if mode == 'ab' else 0 + over_limit = False + with open(part_path, mode) as f: + for chunk in resp.iter_content(chunk_size=DL_CHUNK_SIZE): + if cancel_event is not None and cancel_event.is_set(): + raise DownloadCanceled("下载已取消") + if chunk: + f.write(chunk) + written += len(chunk) + if written > max_bytes: + # C12:超限残片没有任何续传价值。第 91→93 条: + # Windows 无法删除打开中的文件——置标志 break, + # with 关闭后再清理(原实现在打开中 unlink 被静默吞掉) + over_limit = True + break + if progress_cb is not None: + try: + progress_cb(written, music_info.file_size or 0) + except Exception: + pass # 进度回调绝不影响下载主流程 + if over_limit: + self._unlink_quiet(part_path) # 文件已关闭,清理可达 + raise DownloadException( + f"文件超过大小上限 {max_bytes // (1024*1024)}MB,已中止") + except requests.HTTPError as e: + # Range 起点无效(如 CDN 环节变化):清除旧 part,从头重新下载 + status = getattr(getattr(e, "response", None), "status_code", None) + if status == 416: + self._unlink_quiet(part_path) + raise _RetryFromScratch("Range 起点失效,已从头重新下载") from e + if status is not None and 400 <= status < 500 and status != 408: + # 第 97 条(P3):CDN 4xx(403 防盗链/429 风控等)是确定性 + # 失败,对同一失效直链白重试只会加剧风控、拖慢失败返回 + # (408 请求超时保留可重试) + self._unlink_quiet(part_path) + raise DownloadException( + f"下载失败(HTTP {status}),下载链接可能已失效") from e + raise + finally: + if resp is not None: + resp.close() + finally: + # 第 91 条(P2-7):与 acquire_cancellable 配对释放(原 with 语义) + self._download_semaphore.release() + + # 完整性校验(F29:已知期望大小时必须精确相等;未知期望时至少非空) + actual_size = part_path.stat().st_size + if music_info.file_size > 0: + complete = (actual_size == music_info.file_size) + else: + complete = (actual_size > 0) + if not complete: + # 保留 .part 以便断点续传;下次续传从现有字节继续 + last_error = ( + f"下载不完整: 期望 {music_info.file_size or '未知'} 字节, " + f"实际 {actual_size} 字节" + ) + if attempt < retries: + next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条) + continue + + # 第 97 条(P3):转正前内容嗅探——未知期望尺寸("非空即通过"口径) + # 时 CDN 错误页/风控 JSON 曾可被存成音频交付 + if music_info.file_size <= 0 and not _sniff_audio_header(part_path): + self._unlink_quiet(part_path) + raise DownloadException("下载内容校验失败(非音频数据)") + + os.replace(part_path, file_path) + + # 元数据写入移出下载信号量(CPU 操作不占网络名额), + # 但仍在文件锁内执行,保证其它请求看到的是已完成标记的最终文件 + if write_meta: + # 第 97 条(P3):元数据段入口补取消检查点——已取消的任务不再 + # 进入信号量排队/ffmpeg 等待(有界但可达 120s),收尾不再延迟 + if cancel_event is not None and cancel_event.is_set(): + raise DownloadCanceled("下载已取消") + with self._meta_semaphore: + self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields) + + return DownloadResult( + success=True, + file_path=str(file_path), + file_size=file_path.stat().st_size, + music_info=music_info, + ) + + except DownloadCanceled: + # 用户明确放弃:清理半截临时文件 + self._unlink_quiet(part_path) + raise + except DownloadException: + # 第 94 条(P2-1):确定性失败(如体积超上限)不重试——原落泛型 + # handler 会删残片后从 0 重拉,3 轮白拉 ~1.5GB + raise + except _RetryFromScratch: + # .part 已清理,直接进入下一轮从头下载 + last_error = "续传起点失效,重新下载" + if attempt < retries: + next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条) + continue + except (requests.RequestException, OSError) as e: + # 网络错误:保留 .part 支持断点续传; + # F117:但若任务已被取消,.part 没有任何续传价值,一并清理 + if cancel_event is not None and cancel_event.is_set(): + self._unlink_quiet(part_path) + raise DownloadCanceled("下载已取消") from e + last_error = f"下载请求失败: {e}" + if attempt < retries: + next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条) + continue + except Exception as e: + last_error = f"下载过程中发生错误: {e}" + if attempt < retries: + next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条) + continue + + # 最后一次尝试仍失败:若任务已取消按取消收尾(F117) + if cancel_event is not None and cancel_event.is_set(): + self._unlink_quiet(part_path0) + raise DownloadCanceled("下载已取消") + return DownloadResult(success=False, error_message=last_error or "下载失败") + + + # ============ ffmpeg 元数据写入(核心) ============ + # P3-42:download_music_to_memory / download_batch_async / get_download_progress + # 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受 + # cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写 + + def _write_metadata_with_ffmpeg( + self, + file_path: Path, + music_info: MusicInfo, + fields: Optional[List[str]] = None, + ) -> bool: + """使用 ffmpeg 写入音频元数据 + + 处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4) + fields: 可选字段列表 ['title','artist','album','cover','lyrics','date']; + None 表示全部写入。 + + Returns: + 是否写入成功(失败不影响主流程) + """ + ffmpeg_path = self._resolve_ffmpeg() + if not ffmpeg_path: + logger.warning("跳过元数据写入: ffmpeg 不可用") + return False + + file_ext = file_path.suffix.lower() + if file_ext not in METADATA_FORMATS: + logger.warning(f"跳过元数据写入: 不支持的格式 {file_ext}") + return False + + if not file_path.exists(): + logger.warning(f"跳过元数据写入: 文件不存在 {file_path}") + return False + + # 临时输出文件:保留原扩展名在最后,便于 ffmpeg 识别 muxer + # 例: 艾辰 - 错位时空.tagging.flac + tmp_output = file_path.with_name( + file_path.stem + ".tagging" + file_path.suffix + ) + # 临时封面文件 + cover_tmp: Optional[str] = None + cover_ok = False # F114:创建成功与否与"路径是否需要清理"分离管理 + + try: + # 下载封面到临时文件(走共享缓存:与 ZIP 打包共用一次网络请求) + include_cover = fields is None or "cover" in fields + if include_cover and music_info.pic_url: + cover_data = fetch_cover_bytes(music_info.pic_url) + if cover_data: + try: + fd, cover_tmp = tempfile.mkstemp(suffix=".jpg", dir=str(self.download_dir)) + os.close(fd) + with open(cover_tmp, "wb") as cf: + cf.write(cover_data) + cover_ok = True + except Exception as e: + # cover_tmp 保持指向已创建的文件,finally 统一清理(F114: + # 原实现置 None 会把已创建的零字节文件遗留在下载目录) + logger.warning(f"封面写盘失败,跳过封面写入: {e}") + else: + logger.warning("封面下载失败,跳过封面写入") + + # 构造 ffmpeg 命令 + # 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output + output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "") + cmd: List[str] = [ + ffmpeg_path, "-y", "-loglevel", "error", + "-i", str(file_path), + ] + use_cover = cover_ok + # F35:OGG/Opus 的封面流 copy 不被支持(Unsupported codec id), + # 主动降级为"只写文本元数据",不再让整个写入失败 + if use_cover and file_ext in (".ogg", ".opus"): + use_cover = False + logger.info("OGG/Opus 不支持封面写入,仅写入文本元数据") + if use_cover and cover_tmp: + cmd += ["-i", cover_tmp] + + # 通用元数据(仅写入勾选的字段) + meta = {} + # F33:前端字段名为 name,与后端 title 统一映射 + if fields is None or "title" in fields or "name" in fields: + meta["title"] = music_info.name + if fields is None or "artist" in fields: + meta["artist"] = music_info.artists + meta["album_artist"] = music_info.artists + if fields is None or "album" in fields: + meta["album"] = music_info.album + if fields is None or "date" in fields: + if music_info.publish_year: + meta["date"] = str(music_info.publish_year) + meta["year"] = str(music_info.publish_year) + if fields is None or "lyrics" in fields: + if music_info.lyric and music_info.lyric.strip(): + # 去掉 LRC 时间标签,写入纯歌词 + clean_lyric = re.sub( + r"\[\d{1,2}:\d{1,2}([.:]\d+)?\]", "", music_info.lyric + ).strip() + if clean_lyric: + meta["lyrics"] = clean_lyric + if music_info.track_number > 0 and (fields is None or "track" in fields): + # F33:只写当前曲号;没有真实专辑总数时不虚构 tracktotal + meta["track"] = str(music_info.track_number) + for k, v in meta.items(): + if v: + cmd += ["-metadata", f"{k}={v}"] + + # 各格式的 mapping / 编码选项 + cmd += self._build_format_args(file_ext, has_cover=use_cover) + + # -f 必须放在所有 -i 之后(作为 output option)才生效 + if output_format: + cmd += ["-f", output_format] + cmd.append(str(tmp_output)) + + # 执行 + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + encoding="utf-8", + errors="ignore", + ) + + if result.returncode == 0 and tmp_output.exists(): + # 第 97 条(P3):os.replace 才是同卷原子替换——shutil.move 在目标已 + # 存在时退化为 copy2+unlink,进程中断会留半截文件 + os.replace(str(tmp_output), str(file_path)) + return True + + # P2-5:ffmpeg stderr 原文走 logger(errors='ignore' 解码后的任意 Unicode + # 在 GBK 重定向下裸 print 会崩 worker) + logger.warning( + f"ffmpeg 写入元数据失败: {(result.stderr or result.stdout or '').strip()}" + ) + return False + + except subprocess.TimeoutExpired: + logger.warning("ffmpeg 执行超时") + return False + except FileNotFoundError: + logger.warning(f"ffmpeg 可执行文件未找到: {ffmpeg_path}") + return False + except Exception as e: + logger.warning(f"ffmpeg 写入元数据异常: {e}") + return False + finally: + # 清理封面临时文件 + if cover_tmp and os.path.exists(cover_tmp): + try: + os.unlink(cover_tmp) + except OSError: + pass + # 清理可能残留的临时输出 + if tmp_output.exists(): + try: + tmp_output.unlink() + except OSError: + pass + + def _build_format_args(self, file_ext: str, has_cover: bool) -> List[str]: + """根据文件格式构造 mapping / 编码参数 + F34:不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面), + 原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。""" + if file_ext == ".mp3": + # MP3: 封面通过 ID3v2 APIC 帧写入 + if has_cover: + return [ + "-map", "0:a", "-map", "1:v", + "-c:a", "copy", "-c:v", "copy", + "-id3v2_version", "3", + "-metadata:s:v", "title=Album cover", + "-metadata:s:v", "comment=Cover (front)", + ] + return ["-map", "0", "-c", "copy", "-id3v2_version", "3"] + + if file_ext == ".flac": + # FLAC: 原生支持封面 + if has_cover: + return [ + "-map", "0", "-map", "1", + "-c", "copy", + "-disposition:v:0", "attached_pic", + ] + return ["-map", "0", "-c", "copy"] + + if file_ext == ".m4a": + # M4A: AAC 音频 + mp4 容器 + if has_cover: + return [ + "-map", "0:a", "-map", "1:v", + "-c", "copy", + "-disposition:v:0", "attached_pic", + "-metadata:s:v", "title=Album cover", + "-metadata:s:v", "comment=Cover (front)", + ] + # F34:保留既有封面流(原 -map 0:a 会删掉已有封面) + return ["-map", "0", "-c", "copy"] + + if file_ext == ".mp4": + # MP4 (杜比全景声 EAC3 / 其它 mp4 容器音频) + # 杜比文件通常无视频流,封面作为 video stream 注入;+faststart 让 moov 在文件头 + if has_cover: + return [ + "-map", "0", "-map", "1:v", + "-c", "copy", + "-disposition:v:0", "attached_pic", + "-metadata:s:v", "title=Album cover", + "-metadata:s:v", "comment=Cover (front)", + "-movflags", "+faststart", + ] + return ["-map", "0", "-c", "copy", "-movflags", "+faststart"] + + if file_ext in (".ogg", ".opus"): + if has_cover: + return [ + "-map", "0:a", "-map", "1:v", + "-c:a", "copy", "-c:v", "copy", + ] + return ["-map", "0:a", "-c:a", "copy"] + + # 默认 + if has_cover: + return ["-map", "0:a", "-map", "1:v", "-c", "copy"] + return ["-map", "0:a", "-c:a", "copy"] + + +# ============ CLI 入口 ============ + +if __name__ == "__main__": + import sys + from dataclasses import asdict + + downloader = MusicDownloader() + + # 无参数:打印模块信息 + if len(sys.argv) < 2: + print("网易云音乐下载器") + print(f"ffmpeg 路径: {downloader.ffmpeg_path or '未找到'}") + print("支持的音质: standard / exhigh / lossless / hires / sky / jyeffect / jymaster / dolby") + print("支持的功能: 同步/批量下载、ffmpeg 元数据写入") + print() + print("用法:") + print(" python music_downloader.py [quality] # 仅打印信息") + print(" python music_downloader.py [quality] --download # 打印并下载") + print() + print("示例:") + print(" python music_downloader.py 123456") + print(" python music_downloader.py 123456 lossless") + print(" python music_downloader.py 123456 hires --download") + sys.exit(0) + + # 解析参数 + try: + music_id = int(sys.argv[1]) + except ValueError: + print(f"错误: music_id 必须是整数,收到: {sys.argv[1]!r}") + sys.exit(1) + + quality = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith("--") else "lossless" + do_download = "--download" in sys.argv + + # 打印音乐信息 + try: + info = downloader.get_music_info(music_id, quality) + except DownloadException as e: + print(f"获取音乐信息失败: {e}") + sys.exit(1) + + print() + print("=" * 70) + print(f"音乐信息 (ID={info.id}, quality={info.quality})") + print("=" * 70) + + info_dict = asdict(info) + for key, value in info_dict.items(): + # 歌词太长,只显示行数 + if key in ("lyric", "tlyric"): + lines = [ln for ln in (value or "").splitlines() if ln.strip()] + preview = " | ".join(lines[:2])[:80] if lines else "(空)" + print(f" {key:<14}: {len(lines)} 行 预览: {preview}{'...' if len(lines) > 2 else ''}") + continue + # URL 类字段:完整展示(如果终端窄可换行) + if key in ("download_url", "pic_url"): + print(f" {key:<14}: {value}") + continue + print(f" {key:<14}: {value}") + + print("=" * 70) + + # 可选:触发下载 + if do_download: + print() + print("开始下载...") + result = downloader.download_music_file(music_id, quality) + if result.success: + print(f"下载完成: {result.file_path} ({result.file_size} 字节)") + else: + print(f"下载失败: {result.error_message}") + sys.exit(1) + diff --git a/podcast_api.py b/podcast_api.py new file mode 100644 index 0000000..37b6b96 --- /dev/null +++ b/podcast_api.py @@ -0,0 +1,554 @@ +# -*- coding: utf-8 -*- +"""网易云音乐播客(电台 / 单集声音)API 模块 + +提供网易云"播客"板块相关接口封装,包括: +- 播客搜索(电台 / 单集声音) +- 播客电台详情与单集列表(分页) +- 单集(program)详情 +- 热门播客推荐 +- 单集音频直链解析(音质自动降级) + +说明: +- 播客单集的音频本质上是网易云曲库中的 song(program.mainSong), + 因此直链复用 music_api.url_v1(eapi song/enhance/player/url/v1)获取。 +- 支持格式:mp3 / m4a / aac 等(以接口返回 type 为准)。 +- 支持链接协议:music.163.com/djradio?id=(电台)、/program?id= 与 /sound?id=(单集)、 + 以及 163cn.tv 短链。 +""" + +import json +import re +import time +from random import randrange +from typing import Dict, List, Optional, Any + +import requests + +from music_api import APIConstants, APIException, url_v1 + + +class PodcastConstants: + """播客接口相关常量""" + + # 搜索(cloudsearch:type=1009 播客电台 / 2000 单集声音) + SEARCH_API = 'https://music.163.com/api/cloudsearch/pc' + # 电台(播客)详情 + DJRADIO_GET_API = 'https://music.163.com/api/djradio/get' + # 电台下的单集列表(分页) + DJ_PROGRAM_BYRADIO_API = 'https://music.163.com/api/dj/program/byradio' + # 单集详情 + DJ_PROGRAM_DETAIL_API = 'https://music.163.com/api/dj/program/detail' + # 热门播客(必须携带有效 cateId) + DJRADIO_HOT_API = 'https://music.163.com/api/djradio/hot' + # 播客分类列表 + DJRADIO_CATEGORY_API = 'https://music.163.com/api/djradio/category/get' + + # 播客音频常用音质降级阶梯(无损对播客基本无意义) + QUALITY_LADDER = ['exhigh', 'standard'] + + # 热门接口的兜底分类ID(生活 / 情感 / 脱口秀 / 有声书) + DEFAULT_HOT_CATE_IDS = [6, 3, 8, 10001] + + +# 第 97 条(对抗复审补漏):与 music_api 同口径的上游 JSON 响应体上限 +_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024 + + +def _post_json(url: str, data: Dict[str, Any], cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + """发送表单 POST 请求并返回 JSON""" + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER, + } + try: + response = requests.post(url, data=data, headers=headers, + cookies=cookies or {}, timeout=30) + response.raise_for_status() + if len(response.content) > _MAX_UPSTREAM_JSON_BYTES: + raise APIException('播客接口响应体异常(超过大小上限)') + result = response.json() + if not isinstance(result, dict): + raise APIException('播客接口响应异常(非标准 JSON 对象)') + except requests.RequestException as e: + raise APIException(f"播客接口请求失败: {e}") + except json.JSONDecodeError as e: + raise APIException(f"解析播客接口响应失败: {e}") + if result.get('code') not in (200, None): + raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}") + return result + + +def _ts_to_date(ts: Optional[int]) -> str: + """毫秒时间戳 → 日期字符串""" + try: + if not ts: + return '' + return time.strftime('%Y-%m-%d', time.localtime(int(ts) / 1000)) + except (TypeError, ValueError, OSError, OverflowError): + return '' + + +def _fmt_duration(ms: Optional[int]) -> str: + """毫秒 → mm:ss / hh:mm:ss""" + try: + sec = int((ms or 0) / 1000) + except (TypeError, ValueError): + return '' + if sec <= 0: + return '' + h, rem = divmod(sec, 3600) + m, s = divmod(rem, 60) + return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}" + + +def _fmt_count(n: Optional[int]) -> str: + """数量 → 万单位简写""" + try: + n = int(n or 0) + except (TypeError, ValueError): + return '0' + if n >= 100000000: + v = n / 100000000 + return f"{v:.1f}亿".replace('.0', '') + if n >= 10000: + v = n / 10000 + return f"{v:.1f}万".replace('.0', '') + return str(n) + + +# ============ 数据规范化 ============ + +def _norm_radio(raw: Dict[str, Any]) -> Dict[str, Any]: + """规范化电台(播客)信息""" + dj = raw.get('dj') or {} + return { + 'id': raw.get('id'), + 'name': raw.get('name', ''), + 'pic_url': raw.get('picUrl', ''), + 'desc': str(raw.get('desc') or raw.get('description') or '').strip(), + 'category': raw.get('category', ''), + 'sub_count': raw.get('subCount', 0), + 'sub_count_text': _fmt_count(raw.get('subCount')), + 'program_count': raw.get('programCount', 0), + 'play_count': raw.get('playCount', 0), + 'play_count_text': _fmt_count(raw.get('playCount')), + 'dj_name': dj.get('nickname', ''), + 'dj_avatar': dj.get('avatarUrl', ''), + 'create_date': _ts_to_date(raw.get('createTime')), + 'second_category': raw.get('secondCategory', ''), + } + + +def _norm_program(raw: Dict[str, Any]) -> Dict[str, Any]: + """规范化单集(program)信息""" + radio = raw.get('radio') or {} + dj = raw.get('dj') or radio.get('dj') or {} + main_song = raw.get('mainSong') or raw.get('mainMusic') or {} + return { + 'id': raw.get('id'), + 'name': raw.get('name', ''), + 'cover_url': raw.get('coverUrl') or raw.get('cover') or radio.get('picUrl', ''), + 'description': str(raw.get('description') or raw.get('desc') or '').strip(), + 'duration': raw.get('duration', 0), + 'duration_text': _fmt_duration(raw.get('duration') or main_song.get('duration')), + 'create_date': _ts_to_date(raw.get('createTime')), + 'serial_num': raw.get('serialNum', 0), + 'listener_count': raw.get('listenerCount', 0), + 'listener_count_text': _fmt_count(raw.get('listenerCount')), + 'liked_count': raw.get('likedCount', 0), + 'comment_count': raw.get('commentCount', 0), + 'main_song_id': main_song.get('id'), + 'main_song_name': main_song.get('name', ''), + 'radio_id': radio.get('id'), + 'radio_name': radio.get('name', ''), + 'dj_name': dj.get('nickname', ''), + 'category': raw.get('category') or radio.get('category', ''), + } + + +# ============ 链接 / ID 解析 ============ + +_SHORTLINK_CACHE: Dict[str, str] = {} + + +def _resolve_short_link(url: str) -> str: + """解析 163cn.tv 短链,返回跳转后的真实链接(T4:仅接受整串短链,子串不触发请求)""" + if not re.match(r'^https?://(www\.)?163cn\.tv/\S+$', str(url or '').strip(), re.IGNORECASE): + return url + # 第 91 条(P3):带浏览器 UA(短链 CDN 对默认 UA 区别对待)+ 模块级缓存 + #(此前仅音乐路径有缓存,播客同一分享链接每次解析都发真实 GET) + global _SHORTLINK_CACHE + cached = _SHORTLINK_CACHE.get(url) + if cached is not None: + return cached + resp = None + try: + resp = requests.get(url, allow_redirects=False, timeout=10, + headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}) + real = resp.headers.get('Location', url) + except requests.RequestException: + return url + finally: + if resp is not None: + resp.close() + # 第 99 条:与 main._expand_shortlink 同口径——展开失败(200 无 Location/5xx, + # real==url)不写缓存,一次 CDN 抖动不再让该链接整个缓存存续期必然失败 + if real == url: + return real + if len(_SHORTLINK_CACHE) > 500: + _SHORTLINK_CACHE.clear() + _SHORTLINK_CACHE[url] = real + return real + + +def extract_podcast_ref(raw: str) -> Dict[str, str]: + """从用户输入中提取播客引用(T4:正规 URL 解析,替代子串匹配与非数字剔除) + + Returns: + {'kind': 'radio'|'program'|'unknown', 'id': '...'} + - radio: djradio?id= / radio?id= 链接 + - program: program?id= / sound?id= 链接 + - unknown: 纯数字 ID(需依次尝试单集/电台) + """ + from urllib.parse import urlsplit, parse_qs + text = str(raw or '').strip() + # 短链先还原(仅当整串就是一个短链 URL) + if '163cn.tv' in text.lower(): + text = _resolve_short_link(text) + + # 有 URL 结构:按路径判断类型,query 正规解析 id(参数顺序无关) + if '://' in text or text.startswith('//'): + try: + parts = urlsplit(text if '://' in text else 'https:' + text) + q = parse_qs(parts.query) + # 兼容老式 #/program?id= 片段路由 + if not q.get('id'): + m = re.search(r'[?&#]id=(\d+)', text) + if m: + q = {'id': [m.group(1)]} + pid = (q.get('id') or [''])[0] + if pid.isdigit(): + path_l = parts.path.lower() + if 'djradio' in path_l or 'radio' in path_l: + return {'kind': 'radio', 'id': pid} + if 'program' in path_l or 'sound' in path_l or 'song' in path_l: + return {'kind': 'program', 'id': pid} + # 路径无类型线索(如 y2027.d.popchan.cn 之类播客域名):按 unknown 走探测 + return {'kind': 'unknown', 'id': pid} + except ValueError: + pass + raise APIException('无法识别的播客链接,请粘贴电台/单集链接或直接输入纯数字 ID') + + # 纯数字 ID + if text.isdigit(): + return {'kind': 'unknown', 'id': text} + raise APIException('无法识别的播客链接或ID,支持 电台链接(djradio?id=)、单集链接(program?id=) 或纯数字ID') + + +# ============ 核心 API ============ + +def search_podcast(keywords: str, cookies: Optional[Dict[str, str]] = None, + limit: int = 20, offset: int = 0, + search_type: str = 'all') -> Dict[str, Any]: + """搜索播客 + + Args: + keywords: 关键词 + cookies: 请求 Cookie(可选) + limit: 每类返回数量(1-50) + offset: 偏移量 + search_type: all=电台+单集 / radio=仅电台 / voice=仅单集 + + Returns: + {'radios': [...], 'voices': [...]} + """ + keywords = str(keywords or '').strip() + if not keywords: + raise APIException('搜索关键词不能为空') + limit = max(1, min(int(limit or 20), 50)) + + result: Dict[str, Any] = {'radios': [], 'voices': []} + + def _do_search(stype: int) -> Dict[str, Any]: + return _post_json(PodcastConstants.SEARCH_API, + {'s': keywords, 'type': stype, 'limit': limit, 'offset': offset}, + cookies) + + if search_type in ('all', 'radio'): + res = _do_search(1009) + radios = (res.get('result') or {}).get('djRadios') or [] + result['radios'] = [_norm_radio(r) for r in radios] + + if search_type in ('all', 'voice'): + res = _do_search(2000) + rdata = res.get('result') or {} + voices = rdata.get('songs') or rdata.get('voices') or rdata.get('programs') or [] + result['voices'] = [_norm_program(v) for v in voices] + + return result + + +def get_radio_detail(radio_id: Any, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + """获取播客电台(djradio)详情""" + try: + rid = int(str(radio_id).strip()) + except (TypeError, ValueError): + raise APIException('无效的电台ID') + result = _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies) + # 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空 + data = result.get('djRadio') or result.get('data') or {} + if not data: + raise APIException('未找到该播客电台(可能已下架或ID错误)') + return _norm_radio(data) + + +def get_radio_programs(radio_id: Any, cookies: Optional[Dict[str, str]] = None, + limit: int = 30, offset: int = 0, asc: bool = True) -> Dict[str, Any]: + """获取电台下的单集列表(分页) + + Args: + radio_id: 电台ID + limit: 每页数量(1-100) + offset: 偏移量 + asc: True=从第1集开始(正序),False=最新在前 + + Returns: + {'programs': [...], 'count': 总数, 'has_more': bool} + """ + try: + rid = int(str(radio_id).strip()) + except (TypeError, ValueError): + raise APIException('无效的电台ID') + limit = max(1, min(int(limit or 30), 100)) + result = _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API, + {'radioId': rid, 'limit': limit, 'offset': max(0, int(offset or 0)), + 'asc': 'true' if asc else 'false'}, + cookies) + programs = [_norm_program(p) for p in (result.get('programs') or [])] + count = _to_int(result.get('count')) + offset_val = max(0, int(offset or 0)) + return { + 'programs': programs, + 'count': count, + 'has_more': (offset_val + len(programs)) < count if count else len(programs) >= limit, + } + + +def get_program_detail(program_id: Any, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + """获取单集(program)详情""" + try: + pid = int(str(program_id).strip()) + except (TypeError, ValueError): + raise APIException('无效的单集ID') + result = _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies) + program = result.get('program') or {} + if not program: + raise APIException('未找到该播客单集(可能已下架或ID错误)') + return _norm_program(program) + + +def get_categories() -> List[Dict[str, Any]]: + """获取播客分类列表(GET 接口) + + Returns: + [{'id': 6, 'name': '生活'}, ...] + """ + headers = { + 'User-Agent': APIConstants.USER_AGENT, + 'Referer': APIConstants.REFERER, + } + try: + response = requests.get(PodcastConstants.DJRADIO_CATEGORY_API, + headers=headers, timeout=30) + response.raise_for_status() + if len(response.content) > _MAX_UPSTREAM_JSON_BYTES: + raise APIException('播客接口响应体异常(超过大小上限)') + result = response.json() + except requests.RequestException as e: + raise APIException(f"获取播客分类失败: {e}") + except json.JSONDecodeError as e: + raise APIException(f"解析播客分类响应失败: {e}") + if not isinstance(result, dict): + # 第 97 条(P3):非 dict 响应(null/数组)显式报错,不再 AttributeError + raise APIException('解析播客分类响应失败(非标准 JSON 对象)') + cats = [] + for c in (result.get('categories') or []): + if not isinstance(c, dict): + continue + try: + cid = int(c.get('id') or 0) + except (TypeError, ValueError): + continue # 垃圾 id 跳过该条,不再拖死整页分类 + name = c.get('name') + if cid and name: + cats.append({'id': cid, 'name': str(name)}) + if not cats: + raise APIException('播客分类列表为空') + return cats + + +def get_hot_radios(cookies: Optional[Dict[str, str]] = None, + limit: int = 30, offset: int = 0, + cate_id: Optional[int] = None) -> List[Dict[str, Any]]: + """获取分类下热门播客 + + Args: + cate_id: 播客分类ID;F84:用户明确指定分类时只查该分类 + (即便为空也不悄悄混入其他分类的内容),未指定时按默认分类依次尝试 + + Returns: + 电台列表(已规范化) + """ + limit = max(1, min(int(limit or 30), 100)) + cate_ids: List[int] = [] + user_cate = False + if cate_id: + try: + cate_ids.append(int(cate_id)) + user_cate = True + except (TypeError, ValueError): + pass + if not user_cate: + for cid in PodcastConstants.DEFAULT_HOT_CATE_IDS: + if cid not in cate_ids: + cate_ids.append(cid) + + for cid in cate_ids: + result = _post_json(PodcastConstants.DJRADIO_HOT_API, + {'limit': limit, 'offset': max(0, int(offset or 0)), 'cateId': cid}, + cookies) + radios = [_norm_radio(r) for r in (result.get('djRadios') or [])] + if radios: + return radios + if user_cate: + break # F84:指定分类为空就返回空,不注入默认分类内容 + return [] + + +def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None, + level: str = 'auto') -> Optional[Dict[str, Any]]: + """解析单集音频直链(带音质降级) + + Args: + song_id: 单集 mainSong 的歌曲ID + cookies: 全局账号 Cookie + level: 期望音质(auto=按阶梯自动尝试) + + Returns: + {'url','level','type','size','br','md5'} 或 None(全部失败) + """ + # F83:降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh + _FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard'] + order: List[str] = [] + level = str(level or 'auto').strip() + if level and level != 'auto': + order.append(level) + # 追加"低于"请求音质的等级(hires > lossless > exhigh > standard) + if level in _FALLDOWN_LADDER: + idx = _FALLDOWN_LADDER.index(level) + for lv in _FALLDOWN_LADDER[idx + 1:]: + order.append(lv) + else: + # 未知等级:退到通用阶梯 + order.extend(PodcastConstants.QUALITY_LADDER) + else: + order.extend(PodcastConstants.QUALITY_LADDER) + # 去重保序 + dedup: List[str] = [] + for lv in order: + if lv not in dedup: + dedup.append(lv) + order = dedup + + for lv in order: + try: + # song_id 可能是 None/垃圾值(防御:int 失败按无音频处理,不再 TypeError 500) + sid = int(str(song_id).strip()) + except (TypeError, ValueError): + return None + try: + res = url_v1(sid, lv, cookies or {}) + except (APIException, ValueError): + continue + data = (res or {}).get('data') or [] + if data and data[0] and data[0].get('url'): + d = data[0] + return { + 'url': d.get('url'), + 'level': d.get('level') or lv, + 'type': d.get('type') or 'mp3', + 'size': _to_int(d.get('size')), + 'br': d.get('br'), + 'md5': d.get('md5', ''), + 'requested_level': lv, + } + return None + + +# ============ 组合能力 ============ + +def parse_podcast_ref(raw: str, cookies: Optional[Dict[str, str]] = None, + page_size: int = 30) -> Dict[str, Any]: + """智能解析用户输入:电台链接 → 电台详情+单集列表;单集链接 → 单集详情 + + Returns: + {'kind': 'radio', 'radio': {...}, 'programs': [...], 'count': n, 'has_more': bool} + 或 {'kind': 'program', 'program': {...}} + """ + ref = extract_podcast_ref(raw) + + if ref['kind'] == 'radio': + radio = get_radio_detail(ref['id'], cookies) + page = get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True) + return {'kind': 'radio', 'radio': radio, **page} + + if ref['kind'] == 'program': + return {'kind': 'program', 'program': get_program_detail(ref['id'], cookies)} + + # 纯数字:先试单集,再试电台 + try: + return {'kind': 'program', 'program': get_program_detail(ref['id'], cookies)} + except APIException: + pass + radio = get_radio_detail(ref['id'], cookies) + page = get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True) + return {'kind': 'radio', 'radio': radio, **page} + + +def list_radio_all_program_ids(radio_id: Any, cookies: Optional[Dict[str, str]] = None, + max_count: int = 200) -> List[Dict[str, Any]]: + """拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}] + + 最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。 + F98:按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止, + 不再固定 offset+=100 盲目翻页。 + """ + out: List[Dict[str, Any]] = [] + seen_ids = set() + offset = 0 + page_size = 100 + max_pages = 30 # 有界保护:即使上游 has_more 恒真也最多翻 30 页 + for _ in range(max_pages): + page = get_radio_programs(radio_id, cookies, limit=page_size, offset=offset, asc=True) + new_count = 0 + for p in page['programs']: + if p['id'] in seen_ids: + continue + seen_ids.add(p['id']) + out.append({ + 'id': p['id'], + 'name': p['name'], + 'main_song_id': p['main_song_id'], + 'serial_num': p['serial_num'], + 'cover_url': p['cover_url'], + }) + new_count += 1 + if len(out) >= max_count: + break + if len(out) >= max_count: + break + # 本页无新增(重复页/短页)或上游明确说没有更多 → 停止 + if new_count == 0 or not page['has_more'] or not page['programs']: + break + offset += page_size + return out diff --git a/qr_login.py b/qr_login.py new file mode 100644 index 0000000..3c514f6 --- /dev/null +++ b/qr_login.py @@ -0,0 +1,396 @@ +"""网易云音乐二维码登录模块 + +提供网易云音乐二维码登录功能,包括: +- 二维码生成和显示 +- 登录状态检查 +- Cookie 获取和保存(直接读写 cookie.txt) +- 用户友好的交互界面 +""" + +import os +import sys +import time +import shutil +import logging +import datetime +from pathlib import Path +from typing import Optional, Tuple + +try: + from music_api import QRLoginManager, APIException, load_cookies +except ImportError as e: + print(f"导入模块失败: {e}") + print("请确保 music_api.py 文件存在且可用") + sys.exit(1) + + +# 判断 cookie 是否"有效":包含任一关键字段即可 +IMPORTANT_COOKIE_KEYS = ("MUSIC_U", "__csrf", "NMTID") + + +class QRLoginClient: + """二维码登录客户端""" + + def __init__(self, cookie_file: str = "cookie.txt"): + """ + Args: + cookie_file: Cookie 保存文件路径 + """ + self.cookie_file = Path(cookie_file) + self.qr_manager = QRLoginManager() + self.logger = logging.getLogger(__name__) + + if not self.logger.handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + + # ----------- Cookie 文件直接操作 ----------- + + def _read_cookie(self) -> str: + if not self.cookie_file.exists(): + return "" + try: + return self.cookie_file.read_text(encoding="utf-8").strip() + except OSError as e: + self.logger.error(f"读取 Cookie 失败: {e}") + return "" + + def _write_cookie(self, cookie: str) -> bool: + """F86:临时文件 + 原子替换写入;失败自动恢复备份,杜绝半截文件毁掉在用 Cookie。 + 临时文件名带随机后缀:同进程多线程并发写同一 Cookie 文件不再互相覆盖 .tmp""" + if not cookie or not cookie.strip(): + self.logger.error("Cookie 内容不能为空") + return False + backup = self._backup_cookie(suffix="prewrite") + tmp = None + try: + import tempfile + fd, tmpname = tempfile.mkstemp( + prefix=self.cookie_file.name + '.tmp-', dir=str(self.cookie_file.parent)) + os.close(fd) + tmp = Path(tmpname) + tmp.write_text(cookie.strip() + "\n", encoding="utf-8") + tmp.replace(self.cookie_file) + return True + except OSError as e: + self.logger.error(f"写入 Cookie 失败: {e}") + # 写失败:恢复备份,保证在用文件仍是旧的有效内容 + try: + if tmp is not None: + tmp.unlink(missing_ok=True) + except OSError: + pass + if backup is not None and backup.exists() and not self.cookie_file.exists(): + shutil.copy2(backup, self.cookie_file) + return False + + def _backup_cookie(self, suffix: str = "backup") -> Optional[Path]: + if not self.cookie_file.exists(): + return None + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 第 91 条(P3):微秒防同秒覆盖 + backup_path = self.cookie_file.with_name( + f"{self.cookie_file.stem}.{suffix}.{ts}{self.cookie_file.suffix}" + ) + try: + shutil.copy2(self.cookie_file, backup_path) + except OSError as e: + self.logger.warning(f"备份 Cookie 失败: {e}") + return None + # 备份保留上限:同一 suffix 只留最近 3 份——备份含真实 MUSIC_U, + # 无限累积既占磁盘也会跟着部署包外泄(_mkpkg ignore 已同步排除) + try: + siblings = sorted( + self.cookie_file.parent.glob( + f"{self.cookie_file.stem}.{suffix}.*{self.cookie_file.suffix}"), + key=lambda p: p.stat().st_mtime) + for old in siblings[:-3]: + old.unlink(missing_ok=True) + # 顺手清理崩溃遗留的原子写临时文件(cookie.txt.tmp-xxxx,超过 1 小时的必是残骸) + now = time.time() + for stale in self.cookie_file.parent.glob(f"{self.cookie_file.name}.tmp-*"): + try: + if now - stale.stat().st_mtime > 3600: + stale.unlink(missing_ok=True) + except OSError: + pass + except OSError: + pass + return backup_path + + def _clear_cookie(self) -> bool: + if not self.cookie_file.exists(): + return True + try: + self.cookie_file.unlink() + return True + except OSError as e: + self.logger.error(f"清除 Cookie 失败: {e}") + return False + + def _is_cookie_valid(self) -> bool: + # F61:登录态凭据是 MUSIC_U 且必须非空—— + # 只有 __csrf/NMTID 这类匿名 cookie 也存在时不能算"有效登录" + cookies = load_cookies(str(self.cookie_file)) + return bool((cookies.get('MUSIC_U') or '').strip()) + + def _get_cookie_info(self) -> dict: + cookies = load_cookies(str(self.cookie_file)) + present = [k for k in IMPORTANT_COOKIE_KEYS + if k == 'MUSIC_U' or (cookies.get(k) or '').strip()] + valid = bool((cookies.get('MUSIC_U') or '').strip()) # F61:以 MUSIC_U 为准 + info = { + "file_path": str(self.cookie_file.absolute()), + "file_exists": self.cookie_file.exists(), + "cookie_count": len(cookies), + "is_valid": valid, + "important_cookies_present": present, + "missing_important_cookies": [k for k in IMPORTANT_COOKIE_KEYS + if not (cookies.get(k) or '').strip()], + } + if self.cookie_file.exists(): + try: + mtime = self.cookie_file.stat().st_mtime + info["last_modified"] = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + except OSError: + pass + return info + + # ----------- 业务方法 ----------- + + def check_existing_login(self) -> bool: + """检查是否已有有效登录""" + try: + if self._is_cookie_valid(): + self.logger.info("检测到有效的登录 Cookie") + return True + self.logger.info("未检测到有效的登录 Cookie") + return False + except Exception as e: + self.logger.error(f"检查登录状态失败: {e}") + return False + + def interactive_login(self) -> Tuple[bool, Optional[str]]: + """交互式二维码登录""" + try: + print("\n=== 网易云音乐二维码登录 ===") + + if self.check_existing_login(): + choice = input("检测到已有有效登录,是否重新登录?(y/N): ").strip().lower() + if choice not in ('y', 'yes', '是'): + print("使用现有登录状态") + return True, None + + print("\n开始二维码登录流程...") + print("正在生成二维码...") + # create_qr_login 返回 unikey 字符串(失败为 None),并已在控制台打印二维码 + qr_key = self.qr_manager.create_qr_login() + + if not qr_key: + error_msg = "生成二维码失败" + self.logger.error(error_msg) + return False, error_msg + + print("\n二维码已生成!") + print("请使用网易云音乐手机 APP 扫描二维码进行登录") + print("二维码有效期: 3 分钟") + print("\n等待扫码中...") + + max_attempts = 60 # 最多 5 分钟 + attempt = 0 + while attempt < max_attempts: + try: + # check_qr_login 返回 (状态码, cookie字典):801等待/802已扫/803成功/800过期 + code, cookie_dict = self.qr_manager.check_qr_login(qr_key) + + if code == 803: + musu_u = cookie_dict.get('MUSIC_U', '') + if not musu_u: + return False, "登录成功但未获取到 Cookie" + # 第 97 条(P2-8):序列化带上 __csrf——第 94 条只修了 main.py + # 的两条 web 路径,CLI 换号后全站 __csrf 恒空、weapi 发评论必败 + serialized = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;" + csrf = cookie_dict.get('__csrf', '') + if csrf: + serialized += f"__csrf={csrf};" + if self.save_cookie(serialized): + # 注:不用 emoji——中文 Windows 控制台默认 GBK 编码不了它, + # print 抛 UnicodeEncodeError 会把"保存成功"误报成"未知错误" + print("\n[成功] 登录成功!Cookie 已保存") + return True, None + return False, "登录成功但 Cookie 保存失败" + + if code == 802: + print("二维码已扫描,请在手机上确认登录") + elif code == 800: + print("\n[过期] 二维码已过期,请重新尝试") + return False, "二维码已过期" + elif code != 801: + print(f"\n登录状态异常({code})") + + if code == 801 and attempt % 10 == 0: + print(f"等待扫码中... ({attempt + 1}/{max_attempts})") + + time.sleep(5) + attempt += 1 + + except KeyboardInterrupt: + print("\n用户取消登录") + return False, "用户取消登录" + except Exception as e: + self.logger.error(f"检查登录状态时发生错误: {e}") + time.sleep(5) + attempt += 1 + + print("\n[超时] 登录超时,请重新尝试") + return False, "登录超时" + + except APIException as e: + self.logger.error(f"API 调用失败: {e}") + return False, f"API 调用失败: {e}" + except Exception as e: + self.logger.error(f"登录过程中发生未知错误: {e}") + return False, f"登录过程中发生未知错误: {e}" + + def save_cookie(self, cookie: str) -> bool: + """保存 Cookie 到文件""" + try: + if self.cookie_file.exists(): + backup = self._backup_cookie() + if backup: + self.logger.info(f"已备份现有 Cookie 到: {backup}") + + if not self._write_cookie(cookie): + return False + + if self._is_cookie_valid(): + self.logger.info("Cookie 保存并验证成功") + return True + + self.logger.warning("Cookie 保存成功但验证失败(缺少关键字段)") + return False + except Exception as e: + self.logger.error(f"保存 Cookie 时发生错误: {e}") + return False + + def show_login_info(self) -> None: + """显示登录信息""" + try: + info = self._get_cookie_info() + print("\n=== 登录状态信息 ===") + print(f"Cookie 文件: {info['file_path']}") + print(f"文件存在: {'是' if info['file_exists'] else '否'}") + print(f"Cookie 数量: {info['cookie_count']}") + print(f"登录状态: {'有效' if info['is_valid'] else '无效'}") + if info.get("last_modified"): + print(f"最后更新: {info['last_modified']}") + if info["is_valid"]: + present = info.get("important_cookies_present", []) + print(f"重要 Cookie: {', '.join(present)}") + else: + missing = info.get("missing_important_cookies", []) + if missing: + print(f"缺少 Cookie: {', '.join(missing)}") + except Exception as e: + print(f"获取登录信息失败: {e}") + + def logout(self) -> bool: + """登出(清除 Cookie)""" + try: + if self.cookie_file.exists(): + backup = self._backup_cookie("logout") + if backup: + print(f"Cookie 已备份到: {backup}") + if self._clear_cookie(): + print("已成功登出") + return True + print("登出失败") + return False + except Exception as e: + print(f"登出时发生错误: {e}") + return False + + +def main(): + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + client = QRLoginClient() + + if len(sys.argv) > 1: + command = sys.argv[1].lower() + + if command == 'login': + success, error = client.interactive_login() + if success: + print("\n登录完成!") + client.show_login_info() + sys.exit(0) + print(f"\n登录失败: {error}") + sys.exit(1) + + elif command in ('status', 'info'): + client.show_login_info() + sys.exit(0) + + elif command == 'logout': + sys.exit(0 if client.logout() else 1) + + elif command in ('help', '-h', '--help'): + print("网易云音乐二维码登录工具") + print("\n用法: python qr_login.py [命令]") + print("\n命令:") + print(" login - 执行二维码登录") + print(" status - 显示登录状态") + print(" logout - 登出(清除 Cookie)") + print(" help - 显示此帮助信息") + print("\n如果不提供命令,将进入交互模式") + sys.exit(0) + + else: + print(f"未知命令: {command}") + print("使用 'python qr_login.py help' 查看帮助") + sys.exit(1) + + # 交互模式 + try: + while True: + print("\n=== 网易云音乐登录工具 ===") + print("1. 二维码登录") + print("2. 查看登录状态") + print("3. 登出") + print("4. 退出") + + choice = input("\n请选择操作 (1-4): ").strip() + + if choice == '1': + success, error = client.interactive_login() + if success: + print("\n登录成功!") + client.show_login_info() + else: + print(f"\n登录失败: {error}") + elif choice == '2': + client.show_login_info() + elif choice == '3': + client.logout() + elif choice == '4': + print("再见!") + break + else: + print("无效选择,请重试") + + except KeyboardInterrupt: + print("\n\n程序已退出") + sys.exit(0) + except Exception as e: + print(f"\n程序运行出错: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d0f41bc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# F68/F62:与实测运行环境一致的依赖清单(Python 3.12 / 2026-09 实测版本)。 +# 代码实际直接依赖为以下 5 个包(其余为传递依赖,随包管理自动解析)。 +# 第 95 条(P2-6):加上界——防上游大版本静默破坏生产(Flask 3→4、Werkzeug 接口变更等)。 +# 升级前先在开发机全量验证后再放宽上界。 +# 第 102 条(owner 拍板):新增 waitress 生产服务器(未安装时 main.py 自动回退 +# Werkzeug 开发服务器,开发机可不装)。 +# 第 100 条(P2-5):cryptography 区间以开发机 venv 实装 50.0.1 为准重定——旧 <44 +# 与实测环境矛盾,新机按旧清单会装到未验证的 43.x。 +Flask>=3.0,<4.0 +requests>=2.31,<3.0 +cryptography>=50.0,<51.0 +qrcode>=7.0,<8.3 +waitress>=3.0,<4.0 diff --git a/static/site_auth.js b/static/site_auth.js new file mode 100644 index 0000000..27868ac --- /dev/null +++ b/static/site_auth.js @@ -0,0 +1,1434 @@ +/* ============================================================ + * 云村小助手 - 站点账号系统前端逻辑(按次计费版) + * 未经授权不得复制、修改或用于商业用途 + * ============================================================ */ +(function () { + 'use strict'; + + /* P2-11:Promise.finally 老内核 polyfill(与 index.html 同一份标准实现—— + index.html 的脚本先加载,这里再兜底一次确保独立引用时也安全) */ + if (!Promise.prototype.finally) { + Promise.prototype.finally = function (cb) { + var P = this.constructor || Promise; + return this.then( + function (v) { return P.resolve(cb()).then(function () { return v; }); }, + function (e) { return P.resolve(cb()).then(function () { throw e; }); } + ); + }; + } + + var SITE_VERSION = '2.4.0'; // P3-12:与 APP_VERSION(main.py)、index.html CHANGELOG 对齐 + var CHANGELOG = [ + { v: '2.4.0', date: '2026-09-21', items: ['修复:老内核浏览器(Chrome 57-62)操作按钮卡加载的问题', '安全:登录失败次数过多临时锁定;公网错误信息不再暴露内部细节', '优化:用户列表不再携带明文网易云 Cookie(按需单独查看)'] }, + { v: '2.1.0', date: '2026-08-28', items: ['计费改为按次计费:一首歌 1 次,歌单打包按歌曲数量扣减', '免费试用改为 3 次(3 首歌),注册页领取,每设备限一次', '卡密系统升级为次数卡,后台可管理用户次数'] }, + { v: '2.0.0', date: '2026-08-28', items: ['新增站点账号系统:强制登录、邮箱验证码注册', '新增管理后台:用户管理、全局Cookie、SMTP、日志、卡密、公告、数据库备份', '新增个人中心:剩余次数、卡密充值、公告查看'] }, + { v: '1.2.0', date: '2026-03-29', items: ['修复部分歌曲解析失败的问题', '优化界面交互体验'] } + ]; + + function $(s, root) { return (root || document).querySelector(s); } + function $$(s, root) { return Array.prototype.slice.call((root || document).querySelectorAll(s)); } + + /* ---------------- toast ---------------- */ + var toastWrap = null; + function toast(msg, type) { + if (!toastWrap) { + toastWrap = document.createElement('div'); + toastWrap.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;'; + document.body.appendChild(toastWrap); + } + var t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'padding:10px 18px;border-radius:10px;font-size:13px;color:#fff;background:' + + (type === 'error' ? 'linear-gradient(135deg,#ff5f57,#e0483f)' : + type === 'success' ? 'linear-gradient(135deg,#34c759,#2aa64c)' : 'rgba(30,30,30,.92)') + + ';box-shadow:0 6px 24px rgba(0,0,0,.25);opacity:0;transform:translateY(-8px);transition:all .25s;max-width:80vw;'; + toastWrap.appendChild(t); + requestAnimationFrame(function () { t.style.opacity = '1'; t.style.transform = 'translateY(0)'; }); + setTimeout(function () { + t.style.opacity = '0'; t.style.transform = 'translateY(-8px)'; + setTimeout(function () { t.remove(); }, 300); + }, 2600); + } + + /* ---------------- 请求 ---------------- */ + function api(url, params) { + var body = Object.keys(params || {}).map(function (k) { + return encodeURIComponent(k) + '=' + encodeURIComponent(params[k] == null ? '' : params[k]); + }).join('&'); + // 30 秒超时:服务端挂起时不再无限等待(登录含省份查询约 5s 上限,留足余量; + // 无 AbortController 的老内核优雅降级为无超时) + var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; + var timer = null; + if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 30000); + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: body, + credentials: 'same-origin', + signal: ctl ? ctl.signal : undefined + }).then(function (r) { + // 双回调解包(不用 Promise.finally:兼容 Chromium 57+ 老内核) + return r.json().then(function (d) { if (timer) clearTimeout(timer); return d; }, + function (e) { if (timer) clearTimeout(timer); throw e; }); + }).catch(function () { + // F9:默认失败兜底——网络断/超时/非 JSON 响应不再产生 unhandledrejection + // 噪音(喂 fe_error 上报通道),统一转为标准失败对象走调用方既有的 toast + if (timer) clearTimeout(timer); + return { status: 0, success: false, message: '网络异常,请检查连接后重试' }; + }); + } + function apiGet(url) { + var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; + var timer = null; + if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 15000); + // 注意:apiGet 不做默认失败兜底——/auth/me 的调用方依赖"失败保持原登录态" + //(拒绝会跳过 then 体,不清空已有 user),转成 resolved 失败对象会让 UI 掉成游客 + return fetch(url, { credentials: 'same-origin', signal: ctl ? ctl.signal : undefined }) + .then(function (r) { + return r.json().then(function (d) { if (timer) clearTimeout(timer); return d; }, + function (e) { if (timer) clearTimeout(timer); throw e; }); + }); + } + +/* ---------------- 设备标识 ---------------- */ +function getDeviceId() { + /* F-7(第 79 条):与 index.html 的 getDeviceId 统一为同一键/同一值—— + 注册指纹与下载任务/心跳的设备号不再各走一套; + 内存缓存兜底 localStorage 不可写时同一页面内保持稳定(F108 同款)。 */ + if (window.__siteDeviceIdCache) return window.__siteDeviceIdCache; + var KEY = 'netease_device_id'; + var d = ''; + try { d = localStorage.getItem(KEY) || ''; } catch (e) { } + if (!d) { + d = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); + try { localStorage.setItem(KEY, d); } catch (e) { } + } + window.__siteDeviceIdCache = d; + return d; +} + + /* ---------------- 状态 ---------------- */ + var user = null; + var durationText = ''; + var ann = null; + var smtpOk = false; // F-8.2:fail-closed——/auth/me 返回前不显示注册入口(防止未配置 SMTP 的窗口期误开) + var registerCredits = 3; // F-6.1:注册赠送次数由 /auth/me 下发,文案不再硬编码 + + /* ---------------- 弹窗 ---------------- */ + function openModal(id) { var m = $('#' + id); if (m) m.classList.add('show'); } + function closeModal(id) { var m = $('#' + id); if (m) m.classList.remove('show'); } + + /* ---------------- 导航栏 ---------------- */ + function renderAuthArea() { + // 同步全局登录状态,供主脚本 requireLogin() 使用 + window.SITE_LOGGED_IN = !!user; + // 未开放邮箱注册(SMTP 未配置)时,隐藏所有注册入口,避免用户注册卡死 + var showRegister = !user && smtpOk; + var regBtn = $('#btn-open-register'); + if (regBtn) regBtn.hidden = !showRegister; + ['goto-register', 'goto-register2'].forEach(function (id) { + var a = $('#' + id); if (a) a.hidden = !showRegister; + }); + // F-8.1:两个弹窗(登录/注册)里的试用提示都要受管——原 querySelector 只命中第一个 + $$('.auth-trial-box').forEach(function (box) { box.hidden = !showRegister; }); + $$('.reg-gift-num').forEach(function (el) { el.textContent = registerCredits; }); // F-6.1 + var guest = $('#auth-guest-area'), uarea = $('#auth-user-area'); + if (guest && uarea) { + if (user) { + guest.hidden = true; uarea.hidden = false; + var pill = $('#auth-duration-pill'); + if (pill) pill.textContent = '⏳ ' + (durationText || '0 次'); + var av = $('#user-avatar'); + if (av) { + av.textContent = (user.username || 'U').charAt(0).toUpperCase(); + av.title = user.username + (user.role === 'admin' ? '(管理员)' : ''); + } + } else { + guest.hidden = false; uarea.hidden = true; + } + } + var ucTab = $('#tab-usercenter'), uaTab = $('#tab-useradmin'); + if (ucTab) ucTab.hidden = !user; + if (uaTab) uaTab.hidden = !(user && user.role === 'admin'); + // 全局账号歌单仅管理员可见 + var gTab = $('#tab-global'); + if (gTab) gTab.hidden = !(user && user.role === 'admin'); + } + + /* ---------------- 会话 ---------------- */ + function refreshSession() { + return apiGet('/auth/me').then(function (data) { + var d = data.data || {}; + user = d.user || null; + durationText = d.duration_text || ''; + ann = d.announcement || null; + smtpOk = !!d.smtp_configured; + // F-6.1:注册赠送次数动态下发(/auth/me),文案不再硬编码 + if (typeof d.register_credits === 'number') registerCredits = d.register_credits; + renderAuthArea(); + renderUserCenter(); + maybeShowAnnouncement(); + // A1:确认登录后再恢复下载任务(游客调任务接口会 401 误弹登录框) + if (user && typeof window.SITE_TASKS_BOOTSTRAP === 'function') { + try { window.SITE_TASKS_BOOTSTRAP(); } catch (e) {} + } + return user; + }).catch(function () { return null; }); + } + + // 全局暴露给主脚本 apiPost 的 401 统一处理(同一提示 5 秒内不重复弹出) + var _lastAuthToast = 0; + function authToastOnce(msg, type) { + var now = Date.now(); + if (now - _lastAuthToast < 5000) return; + _lastAuthToast = now; + toast(msg, type || 'error'); + } + window.handleAuthExpired = function (msg, expired) { + if (expired) { + authToastOnce(msg || '下载次数已用尽,请充值卡密'); + showExpiredModal(); + return; + } + // F53:会话失效时清掉内存里的旧用户状态再引导登录 + //(原实现保留旧 user,页面继续显示"已登录"却处处 401) + user = null; + durationText = ''; + renderAuthArea(); + renderUserCenter(); + authToastOnce(msg || '登录已失效,请重新登录'); + // 第 87 条:登录弹窗已打开且正处于找回/两步验证视图时,不复位视图—— + // 未登录期间后台任务轮询/心跳会持续吃 401 反复进入本函数,若每次都把 + // 弹窗拽回登录视图,管理员正在输入的验证码会被打断、票据被清空 + var _lm = document.getElementById('login-modal'); + var _fv = document.getElementById('forgot-view'); + var _a2v = document.getElementById('admin2fa-view'); + var _authViewOpen = _lm && _lm.classList.contains('show') && + ((_fv && !_fv.hidden) || (_a2v && !_a2v.hidden)); + if (!_authViewOpen) { + hideForgotView(); + hideAdmin2faView(); + } + // 第 98 条:会话失效弹登录窗前,先关掉可能叠开的注册弹窗(双遮罩错乱), + // 已 show 则短路——401 风暴(1.5s 轮询)期间不反复重开 + var _reg = document.getElementById('register-modal'); + if (_reg && _reg.classList.contains('show')) closeModal('register-modal'); + if (_lm && _lm.classList.contains('show')) return; + openModal('login-modal'); + }; + // 暴露给主脚本:扣费/任务结束后刷新顶栏剩余次数与登录态 + window.refreshSITECredits = refreshSession; + window.renderSITEUserCenter = renderUserCenter; + // 暴露当前剩余下载次数给主脚本预检(未登录 -1,不限 999999) + window.getSITECredits = function () { + if (!user) return -1; + var c = (typeof user.duration_seconds === 'number') ? user.duration_seconds : 0; + return c < 0 ? 999999 : c; + }; + + /* 次数用尽弹窗(下载触发,带「去充值」入口) */ + function showExpiredModal() { + // 先移除可能残留的旧实例(此前被 Escape 隐藏但未删 DOM 时,旧实现会直接 + // return,导致该弹窗从此再也弹不出来) + var old = $('#site-expired-modal'); + if (old) old.remove(); + var mask = document.createElement('div'); + mask.className = 'modal-mask show'; + mask.id = 'site-expired-modal'; + mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除 + mask.innerHTML = + ''; + document.body.appendChild(mask); + mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); }); + var go = $('#site-expired-go'), close = $('#site-expired-close'); + if (go) go.addEventListener('click', function () { + mask.remove(); + var t = $('#tab-usercenter'); + if (t) t.click(); + }); + if (close) close.addEventListener('click', function () { mask.remove(); }); + } + + function afterAuth(data) { + var d = data.data || {}; + user = d.user || null; + durationText = d.duration_text || ''; + renderAuthArea(); + renderUserCenter(); + // A1:登录成功后立即恢复本设备的进行中任务 + if (user && typeof window.SITE_TASKS_BOOTSTRAP === 'function') { + try { window.SITE_TASKS_BOOTSTRAP(); } catch (e) {} + } + } + + /* ---------------- 登录 / 注册弹窗 ---------------- */ + // 第 81 条:注册唯一入口——导航按钮与登录弹窗两处链接(全站仅有的 3 条打开 + // 路径)统一经此:未同意协议 → cookieGate('register') 弹「隐私与协议设置」 + // 注册模式(16 秒强制阅读计时)并中止;已同意 → 直接进注册弹窗。 + // 提交侧 doRegister 另有 fail-closed 兜底闸,程序化打开也绕不过。 + function openRegisterFlow() { + closeModal('login-modal'); // 先关登录弹窗,避免协议窗被压在下方 + var gate = window.SITE_COOKIE_GATE; + if (typeof gate !== 'function') { + // fail-closed:协议闸未就绪(主脚本异常)时不放行注册 + toast('页面协议组件未就绪,请刷新页面后重试', 'error'); + return; + } + if (!gate('register')) return; // 未同意:闸内已弹协议窗并提示 + openModal('register-modal'); + } + function bindAuthModal() { + var lo = $('#btn-open-login'), ro = $('#btn-open-register'); + if (lo) lo.addEventListener('click', function () { closeModal('register-modal'); hideForgotView(); hideAdmin2faView(); openModal('login-modal'); }); + if (ro) ro.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); }); + ['login-close', 'register-close', 'announce-close'].forEach(function (id) { + var b = $('#' + id); + if (b) b.addEventListener('click', function () { + if (id.indexOf('login') === 0 && window.resetAuthViews) window.resetAuthViews(); // 第 87 条:关闭侧复位 + closeModal(id.indexOf('login') === 0 ? 'login-modal' : id.indexOf('register') === 0 ? 'register-modal' : 'announce-modal'); + }); + }); + [['login-modal', 'login-close'], ['register-modal', 'register-close']].forEach(function (p) { + var m = $('#' + p[0]); + if (m) m.addEventListener('click', function (e) { + if (e.target === m) { + if (p[0] === 'login-modal' && window.resetAuthViews) window.resetAuthViews(); // 第 87 条:关闭侧复位 + closeModal(p[0]); + } + }); + }); + var g1 = $('#goto-register'), g2 = $('#goto-register2'); + if (g1) g1.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); }); + if (g2) g2.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); }); + var gl1 = $('#goto-login'); + if (gl1) gl1.addEventListener('click', function () { closeModal('register-modal'); hideForgotView(); hideAdmin2faView(); openModal('login-modal'); }); + // 第 86 条:找回密码入口/返回(F5.1 只交付 DOM 漏了绑定) + var gf = $('#goto-forgot'); + if (gf) gf.addEventListener('click', showForgotView); + var fbl = $('#forgot-back-login'); + if (fbl) fbl.addEventListener('click', hideForgotView); + // 第 86 条:管理员两步验证视图的返回链接 + var a2b = $('#a2fa-back'); + if (a2b) a2b.addEventListener('click', hideAdmin2faView); + + // 注册即送 3 次,无需 Tab 切换 + } + + /* ---------------- 登录 ---------------- */ + function doLogin() { + var btn = $('#login-submit'); + var username = ($('#login-username') || {}).value || ''; + var password = ($('#login-password') || {}).value || ''; + if (!username || !password) { toast('请输入用户名/邮箱和密码', 'error'); return; } + btn.disabled = true; btn.textContent = '登录中...'; + api('/auth/login', { username: username, password: password }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '登录失败'); + // 第 86 条:管理员密码验证通过——转两步验证视图(服务端未建会话) + if (data.data && data.data.admin_2fa) { + showAdmin2faView(data.data.ticket, data.data.email_masked); + toast('密码验证通过,请完成邮箱验证', 'success'); + return; + } + afterAuth(data); + closeModal('login-modal'); + toast('登录成功,欢迎回来,' + (user ? user.username : ''), 'success'); + if (data.data && data.data.redirect === 'admin') { + var uaTab = $('#tab-useradmin'); + if (uaTab) uaTab.click(); + } else { + var ucTab = $('#tab-usercenter'); + if (ucTab) ucTab.click(); + } + }) + .catch(function (e) { toast(e.message || '登录失败', 'error'); }) + .finally(function () { btn.disabled = false; btn.textContent = '登 录'; }); + } + + /* ---------------- 注册 ---------------- */ + var _REG_EMAIL_RE = /^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$/; + var _REG_NAME_RE = /^[A-Za-z0-9_\u4e00-\u9fa5]{3,20}$/; + // F5.1:注册弹窗内常驻错误行(长文案 2.6s toast 看不完) + function regShowError(msg) { + toast(msg, 'error'); + var line = $('#reg-error-line'); + if (line) { line.textContent = msg; line.hidden = false; } + } + function regClearError() { + var line = $('#reg-error-line'); + if (line) { line.textContent = ''; line.hidden = true; } + } + function sendCode() { + var btn = $('#reg-send-code'); + var email = ($('#reg-email') || {}).value || ''; + if (!_REG_EMAIL_RE.test(email.trim())) { toast('邮箱格式不正确', 'error'); return; } + regClearError(); + btn.disabled = true; btn.textContent = '发送中...'; + api('/auth/send_code', { email: email, purpose: 'register' }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '发送失败'); + toast('验证码已发送,请查收邮箱', 'success'); + var left = 60; + window.__regCodeTimer = setInterval(function () { + left--; btn.textContent = left + 's'; + if (left <= 0) { clearInterval(window.__regCodeTimer); btn.textContent = '发送验证码'; btn.disabled = false; } + }, 1000); + }) + .catch(function (e) { + toast(e.message || '发送失败', 'error'); + btn.disabled = false; btn.textContent = '发送验证码'; + }); + } + + /* ---------------- 找回密码 ---------------- */ + // 第 84 条:F5.1 只交付了找回视图 DOM 未实现 JS——此处补齐全链路。 + // 后端 /auth/forgot_password(第 79 条)已就绪:验证码原子消费、防枚举 + // 统一文案、login_seq 踢全会话、5 次/分/IP 限流;发码复用 /auth/send_code。 + function forgotShowError(msg) { + toast(msg, 'error'); + var line = $('#forgot-error-line'); + if (line) { line.textContent = msg; line.hidden = false; } + } + function forgotClearError() { + var line = $('#forgot-error-line'); + if (line) { line.textContent = ''; line.hidden = true; } + } + function forgotStopCodeTimer() { + clearInterval(window.__forgotCodeTimer || 0); + var btn = $('#forgot-send-code'); + if (btn && btn.disabled) { btn.disabled = false; btn.textContent = '发送验证码'; } + } + function showForgotView() { + var form = document.querySelector('#login-modal .auth-form'); + var fv = $('#forgot-view'); + if (!form || !fv) return; + form.classList.add('forgot-mode'); + fv.hidden = false; + forgotClearError(); + var em = $('#forgot-email'); + if (em) { try { em.focus(); } catch (e) {} } + } + function hideForgotView() { + var form = document.querySelector('#login-modal .auth-form'); + var fv = $('#forgot-view'); + if (form) form.classList.remove('forgot-mode'); + if (fv && !fv.hidden) fv.hidden = true; + forgotClearError(); + forgotStopCodeTimer(); + } + function doForgotSendCode() { + var btn = $('#forgot-send-code'); + var email = ($('#forgot-email') || {}).value || ''; + if (!_REG_EMAIL_RE.test(email.trim())) { forgotShowError('邮箱格式不正确'); return; } + forgotClearError(); + btn.disabled = true; btn.textContent = '发送中...'; + api('/auth/send_code', { email: email, purpose: 'reset' }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '发送失败'); + toast('验证码已发送,请查收邮箱', 'success'); + var left = 60; + window.__forgotCodeTimer = setInterval(function () { + left--; btn.textContent = left + 's'; + if (left <= 0) { clearInterval(window.__forgotCodeTimer); btn.textContent = '发送验证码'; btn.disabled = false; } + }, 1000); + }) + .catch(function (e) { + forgotShowError(e.message || '发送失败'); + btn.disabled = false; btn.textContent = '发送验证码'; + }); + } + function doForgotReset() { + var btn = $('#forgot-submit'); + var email = ($('#forgot-email') || {}).value || ''; + var code = ($('#forgot-code') || {}).value || ''; + var password = ($('#forgot-password') || {}).value || ''; + forgotClearError(); + if (!email || !code || !password) { forgotShowError('请填写完整找回信息'); return; } + if (!_REG_EMAIL_RE.test(email.trim())) { forgotShowError('邮箱格式不正确'); return; } + if (password.length < 8 || password.length > 128) { forgotShowError('新密码长度需在 8-128 位之间'); return; } + btn.disabled = true; btn.textContent = '重置中...'; + api('/auth/forgot_password', { email: email, code: code, password: password }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '重置失败'); + var fe = $('#forgot-email'), fc = $('#forgot-code'), fp = $('#forgot-password'); + if (fe) fe.value = ''; if (fc) fc.value = ''; if (fp) fp.value = ''; + forgotStopCodeTimer(); + hideForgotView(); + toast('密码已重置,请用新密码登录', 'success'); + }) + .catch(function (e) { forgotShowError(e.message || '重置失败'); }) + .finally(function () { btn.disabled = false; btn.textContent = '重置密码'; }); + } + + /* ---------------- 管理员两步验证(第 86 条) ---------------- + * Step1 用户名+密码 → 服务端签发票据(不建会话);本视图完成 + * Step2 绑定邮箱比对(触发发码)与 Step3 验证码登录。普通用户不经过此流程。 */ + var _a2faTicket = ''; + function a2faShowError(msg) { + toast(msg, 'error'); + var line = $('#a2fa-error-line'); + if (line) { line.textContent = msg; line.hidden = false; } + } + function a2faClearError() { + var line = $('#a2fa-error-line'); + if (line) { line.textContent = ''; line.hidden = true; } + } + function a2faStopTimer() { + clearInterval(window.__a2faTimer || 0); + var btn = $('#a2fa-send'); + if (btn && btn.disabled) { btn.disabled = false; btn.textContent = '发送验证码'; } + } + function showAdmin2faView(ticket, emailMask) { + var form = document.querySelector('#login-modal .auth-form'); + var v = $('#admin2fa-view'); + if (!form || !v) return; + _a2faTicket = ticket || ''; + var mask = $('#a2fa-email-mask'); + if (mask) mask.textContent = emailMask || '-'; + // 第 87 条:清掉上次会话残留的输入(错码/旧邮箱不再被原样重复提交) + var ae = $('#a2fa-email'), ac = $('#a2fa-code'); + if (ae) ae.value = ''; + if (ac) ac.value = ''; + form.classList.add('admin2fa-mode'); + v.hidden = false; + a2faClearError(); + a2faStopTimer(); + var em = $('#a2fa-email'); + if (em) { try { em.focus(); } catch (e) {} } + } + function hideAdmin2faView() { + var form = document.querySelector('#login-modal .auth-form'); + var v = $('#admin2fa-view'); + if (form) form.classList.remove('admin2fa-mode'); + if (v && !v.hidden) v.hidden = true; + _a2faTicket = ''; + a2faClearError(); + a2faStopTimer(); + } + // 第 87 条:关闭侧统一复位——登录弹窗的 X/遮罩/Escape/doLogout 关闭路径都调用, + // 不再只依赖"打开侧纪律";同时供 index.html 的 Escape 通用分支使用 + window.resetAuthViews = function () { hideForgotView(); hideAdmin2faView(); }; + function doA2faSendCode() { + var btn = $('#a2fa-send'); + var email = ($('#a2fa-email') || {}).value || ''; + if (!email.trim()) { a2faShowError('请输入管理员绑定邮箱'); return; } + a2faClearError(); + btn.disabled = true; btn.textContent = '发送中...'; + api('/auth/admin_login/email', { ticket: _a2faTicket, email: email }) + .then(function (data) { + if (data.status === 401) { + // 第 87 条:先复位视图再提示——错误行随视图隐藏,此处 toast 是唯一可达提示 + hideAdmin2faView(); + a2faShowError(data.message || '请重新登录'); + return; + } + if (data.status !== 200) throw new Error(data.message || '发送失败'); + toast('验证码已发送至管理员邮箱,请查收', 'success'); + var ci = $('#a2fa-code'); + if (ci) { try { ci.focus(); } catch (e) {} } + var left = 60; + window.__a2faTimer = setInterval(function () { + left--; btn.textContent = left + 's'; + if (left <= 0) { clearInterval(window.__a2faTimer); btn.textContent = '发送验证码'; btn.disabled = false; } + }, 1000); + }) + .catch(function (e) { + a2faShowError(e.message || '发送失败'); + btn.disabled = false; btn.textContent = '发送验证码'; + }); + } + function doA2faVerify() { + var btn = $('#a2fa-submit'); + var email = ($('#a2fa-email') || {}).value || ''; + var code = ($('#a2fa-code') || {}).value || ''; + a2faClearError(); + if (!email.trim()) { a2faShowError('请输入管理员绑定邮箱'); return; } + if (!code.trim()) { a2faShowError('请先获取并输入邮箱验证码'); return; } + btn.disabled = true; btn.textContent = '验证中...'; + api('/auth/admin_login/verify', { ticket: _a2faTicket, code: code }) + .then(function (data) { + if (data.status === 401) { + // 第 87 条:先复位视图再提示(同上,错误行随视图隐藏) + hideAdmin2faView(); + a2faShowError(data.message || '请重新登录'); + return; + } + if (data.status !== 200) throw new Error(data.message || '验证失败'); + a2faClearError(); + afterAuth(data); + closeModal('login-modal'); + toast('登录成功,欢迎回来,' + (user ? user.username : ''), 'success'); + var uaTab = $('#tab-useradmin'); + if (uaTab) uaTab.click(); + }) + .catch(function (e) { a2faShowError(e.message || '验证失败'); }) + .finally(function () { btn.disabled = false; btn.textContent = '验证并登录'; }); + } + + function doRegister() { + var btn = $('#reg-submit'); + var username = ($('#reg-username') || {}).value || ''; + var password = ($('#reg-password') || {}).value || ''; + var email = ($('#reg-email') || {}).value || ''; + var code = ($('#reg-code') || {}).value || ''; + regClearError(); + // 第 81 条:提交侧兜底闸(入口已统一走 openRegisterFlow,此处防任何程序化 + // 打开路径绕过)。fail-closed:协议闸缺失视为未同意。注册语境只查协议同意 + // (功能性 Cookie 不拦注册,拦解析/下载时另有提示);未同意时闸内已自动 + // 弹「隐私与协议设置」注册模式并 toast,此处不再重复提示 + if (typeof window.SITE_COOKIE_GATE !== 'function') { + toast('页面协议组件未就绪,请刷新页面后重试', 'error'); + return; + } + if (!window.SITE_COOKIE_GATE('register')) return; + // F1.1:前端即时校验(口径与后端一致,省一次网络往返) + if (!username || !password || !email || !code) { regShowError('请填写完整注册信息'); return; } + if (!_REG_NAME_RE.test(username)) { regShowError('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线'); return; } + if (!_REG_EMAIL_RE.test(email.trim())) { regShowError('邮箱格式不正确'); return; } + if (password.length < 8 || password.length > 128) { regShowError('密码长度需在 8-128 位之间'); return; } + btn.disabled = true; btn.textContent = '注册中...'; + api('/auth/register', { + username: username, password: password, email: email, code: code, + device_id: getDeviceId(), agree: 1 + }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '注册失败'); + regClearError(); + afterAuth(data); + closeModal('register-modal'); + toast('注册成功,已自动登录,赠送的下载次数可在个人中心查看', 'success'); + var ucTab = $('#tab-usercenter'); + if (ucTab) ucTab.click(); + }) + .catch(function (e) { regShowError(e.message || '注册失败'); }) + .finally(function () { btn.disabled = false; btn.textContent = '注 册'; }); + } + + /* ---------------- 退出登录 ---------------- */ + function doLogout() { + api('/auth/logout', {}).then(function () { + user = null; durationText = ''; + renderAuthArea(); + renderUserCenter(); // 第 83 条:同步个人中心 gate/content 显隐,与其他登出路径一致 + if (window.resetAuthViews) window.resetAuthViews(); // 第 87 条:登出复位登录弹窗视图 + closeModal('register-modal'); closeModal('login-modal'); + // 第 102 条(U15 拍板):退出连带清除个人网易云登录(index.html 注册的钩子: + // 清 localStorage 的个人 Cookie/解析账号选择并复位账号栏) + if (typeof window.SITE_ON_LOGOUT === 'function') { + try { window.SITE_ON_LOGOUT(); } catch (e) {} + } + var ex = $('#site-expired-modal'); if (ex) ex.remove(); + // 切回搜索页,避免停留在个人中心 / 管理后台 / 管理员歌单 + var search = document.querySelector('.tab[data-tab="search"]') || $('#tab-search'); + if (search) search.click(); + toast('已退出登录', 'success'); + }); + } + + /* ---------------- 个人中心 ---------------- */ + function fmtTime(ts) { + if (!ts) return '-'; + var d = new Date(ts * 1000); + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); + } + + function renderUserCenter() { + var el; + // 登录态切换:隐藏 🔒 占位、显示个人中心内容 + var gate = $('#uc-gate'), content = $('#uc-content'); + if (gate && content) { + gate.hidden = !!user; + content.hidden = !user; + } + if (!user) return; + if ((el = $('#uc-avatar'))) el.textContent = (user.username || 'U').charAt(0).toUpperCase(); + if ((el = $('#uc-username'))) el.textContent = user ? user.username : '-'; + if ((el = $('#uc-role'))) el.textContent = user ? (user.role === 'admin' ? '管理员 · 不限次数' + : (typeof user.duration_seconds === 'number' && user.duration_seconds < 0 ? 'VIP · 不限次数' : '普通用户')) : '-'; + if ((el = $('#uc-duration'))) el.textContent = user ? (durationText || '0 次') : '-'; + if ((el = $('#uc-email'))) el.textContent = (user && user.email) || '未绑定'; + if ((el = $('#uc-created'))) el.textContent = user ? fmtTime(user.created_at) : '-'; + var cl = $('#uc-changelog'); + if (cl) { + cl.innerHTML = CHANGELOG.map(function (v) { + return '
v' + v.v + ' ' + + v.date + '
' + + v.items.map(function (i) { return '· ' + i; }).join('
') + '
'; + }).join('') + '
当前版本 v' + SITE_VERSION + + '
'; + } + } + + function doRedeem() { + var input = $('#uc-card-input'); + var code = (input && input.value || '').trim(); + if (!code) { toast('请输入卡密', 'error'); return; } + var btn = $('#uc-card-redeem'); + btn.disabled = true; + api('/auth/redeem', { code: code }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '兑换失败'); + durationText = (data.data && data.data.duration_text) || durationText; + // F69:同步更新内存余额(批量下载预检读的是 user.duration_seconds, + // 原实现只改显示文字,预检仍用充值前的旧值) + if (user && data.data && typeof data.data.duration_seconds === 'number') { + user.duration_seconds = data.data.duration_seconds; + return null; + } + return refreshSession(); + }) + .then(function () { + renderAuthArea(); renderUserCenter(); + if (input) input.value = ''; + toast('充值成功', 'success'); + }) + .catch(function (e) { toast(e.message || '兑换失败', 'error'); }) + .finally(function () { btn.disabled = false; }); + } + + + /* ---------------- 公告 ---------------- */ + function maybeShowAnnouncement(force) { + // F81:手动"查看公告"(force=true)无条件展示; + // 自动弹出仍按 popup_mode 与 once 已读版本判断 + if (!force && (!ann || !ann.content || ann.popup_mode === 'off')) return; + if (!force && ann && ann.popup_mode === 'once') { + // once 模式的已读判断:登录用户比对服务端 ann_seen; + // 游客比对本地 localStorage(原实现只查 user,游客每次刷新都重复弹窗) + if (user && user.ann_seen && String(user.ann_seen) === String(ann.updated_at)) return; + if (!user) { + var guestSeen = ''; + try { guestSeen = localStorage.getItem('netease_ann_once_seen') || ''; } catch (e) {} + if (guestSeen === String(ann.updated_at)) return; + } + } + if (!ann || !ann.content) { toast('当前没有公告内容', 'info'); return; } + var t = $('#announce-title'), c = $('#announce-content'); + if (t) t.textContent = ann.title || '系统公告'; + if (c) c.innerHTML = String(ann.content).split('\n').map(function (l) { return escapeHtml(l); }).join('
'); + // 第 98 条:首访 Cookie 协议窗显示中不叠加公告弹窗(双遮罩错乱); + // 跳过时不记已读,下次进入页面再弹 + var _cm = document.getElementById('cookie-modal'); + if (_cm && _cm.classList.contains('show')) return; + openModal('announce-modal'); + if (user) api('/auth/ann_seen', {}); + else { try { localStorage.setItem('netease_ann_once_seen', String(ann.updated_at)); } catch (e) {} } + } + + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + /* ---------------- 管理后台 ---------------- */ + var uaLoaded = false; + var uaLogsType = 'success'; + + function uaShowGate() { + var gate = $('#ua-gate'), content = $('#ua-content'); + if (!gate || !content) return; + var isAdmin = !!(user && user.role === 'admin'); + gate.hidden = isAdmin; + content.hidden = !isAdmin; + if (isAdmin && !uaLoaded) { uaLoaded = true; uaLoadUsers(); uaLoadCookieStatus(); uaLoadSmtp(); uaLoadLogs(); uaLoadCards(); uaLoadAnn(); uaLoadDb(); } + } + + function uaBindSide() { + $$('#admin-side .admin-side-item[data-view]').forEach(function (item) { + // 第 97 条(P3):侧栏项补键盘语义(F109 同型——tabindex/role + Enter/空格激活) + item.setAttribute('tabindex', '0'); + item.setAttribute('role', 'button'); + item.addEventListener('click', function () { + $$('#admin-side .admin-side-item').forEach(function (i) { i.classList.remove('active'); }); + item.classList.add('active'); + $$('.admin-view').forEach(function (v) { v.classList.toggle('active', v.getAttribute('data-aview') === item.getAttribute('data-view')); }); + }); + item.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); } + }); + }); + var back = $('#ua-back'); + if (back) back.addEventListener('click', function () { + var t = $('#tab-useradmin'); if (t) t.classList.remove('active'); + var search = document.querySelector('.tab[data-tab="search"]'); + if (search) search.click(); + }); + var lo = $('#ua-logout'); + if (lo) lo.addEventListener('click', doLogout); + // 第 97 条(P3):返回/退出两个无 data-view 的侧栏项同样补键盘语义 + [back, lo].forEach(function (el) { + if (!el) return; + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } + }); + }); + /* ---- 播放 API 密钥(第 78 条) ---- */ + var pkShow = $('#ua-playkey-show'); + var pkInput = $('#ua-playkey-value'); + function pkCopyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { toast('密钥已复制', 'success'); }, + function () { toast('复制失败,请手动选择文本复制', 'error'); }); + } else { + toast('复制失败,请点击「显示密钥」后手动复制', 'error'); + } + } + if (pkShow) pkShow.addEventListener('click', function () { + if (pkShow.dataset.state === 'shown') { + pkInput.value = ''; + pkInput.placeholder = '点击「显示密钥」查看'; + pkShow.textContent = '显示密钥'; + pkShow.dataset.state = ''; + return; + } + api('/admin/site/play_key/get', {}).then(function (d) { + if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; } + pkInput.value = d.data.key; + pkShow.textContent = '隐藏'; + pkShow.dataset.state = 'shown'; + }); + }); + var pkCopy = $('#ua-playkey-copy'); + if (pkCopy) pkCopy.addEventListener('click', function () { + api('/admin/site/play_key/get', {}).then(function (d) { + if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; } + pkCopyToClipboard(d.data.key); + }); + }); + var pkRotate = $('#ua-playkey-rotate'); + if (pkRotate) pkRotate.addEventListener('click', function () { + if (!confirm('刷新后旧密钥立即全部失效(外部旧地址将打不开),确定继续?')) return; + api('/admin/site/play_key/rotate', {}).then(function (d) { + if (d.status !== 200) { toast(d.message || '刷新失败', 'error'); return; } + pkInput.value = d.data.key; + pkShow.textContent = '隐藏'; + pkShow.dataset.state = 'shown'; + toast('密钥已刷新,旧密钥已全部失效', 'success'); + }); + }); + } + + /* ---- 用户管理 ---- */ + function uaInjectUserToolbar() { + var refresh = $('#ua-users-refresh'); + if (!refresh || $('#ua-user-create')) return; + var btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-primary'; + btn.id = 'ua-user-create'; + btn.type = 'button'; + btn.textContent = '➕ 添加用户'; + btn.addEventListener('click', uaCreateUserModal); + refresh.parentNode.insertBefore(btn, refresh); + } + + function uaCreateUserModal() { + var old = $('#site-create-user-modal'); + if (old) old.remove(); + var mask = document.createElement('div'); + mask.className = 'modal-mask show'; + mask.id = 'site-create-user-modal'; + mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除 + mask.innerHTML = + ''; + document.body.appendChild(mask); + mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); }); + var cancel = $('#cnu-cancel'); + if (cancel) cancel.addEventListener('click', function () { mask.remove(); }); + var submit = $('#cnu-submit'); + if (submit) submit.addEventListener('click', function () { + var body = { + username: ($('#cnu-username') || {}).value || '', + password: ($('#cnu-password') || {}).value || '', + email: ($('#cnu-email') || {}).value || '', + count: ($('#cnu-count') || {}).value || 0 + }; + if (!body.username || body.password.length < 8) { toast('请填写用户名和至少 8 位密码', 'error'); return; } + submit.disabled = true; + api('/admin/site/user/create', body).then(function (d) { + toast(d.message || (d.status === 200 ? '创建成功' : '创建失败'), d.status === 200 ? 'success' : 'error'); + if (d.status === 200) { mask.remove(); uaLoadUsers(); } + }).finally(function () { submit.disabled = false; }); + }); + } + + function uaLoadUsers() { + uaInjectUserToolbar(); + api('/admin/site/users', {}).then(function (data) { + var body = $('#ua-users-body'); + if (!body) return; + if (data.status !== 200) { body.innerHTML = '' + escapeHtml(data.message || '加载失败') + ''; return; } + var users = (data.data && data.data.users) || []; + var cnt = $('#ua-users-count'); + if (cnt) cnt.textContent = users.length; + if (!users.length) { body.innerHTML = '暂无用户'; return; } + body.innerHTML = users.map(function (u) { + var isAdmin = u.role === 'admin'; + var disabled = (u.status === 0); + var banned = (u.status === 2); + // 第 97 条(P3):数值字段同过转义(纵深防御——后端口径若变为字符串 + // 不再形成管理后台存储型 XSS 面,与同表 username/email 口径对齐) + var uid = escapeHtml(String(u.id)); + var dur = escapeHtml(String(u.duration_seconds)); + var cred = u.duration_seconds < 0 ? '不限' : (dur + ' 次'); + var dev = [u.register_ip, u.trial_device].filter(Boolean).join(' · ') || '-'; // R5-79:IP 优先显示 + if (u.last_province) dev += ' · ' + u.last_province; + return '' + + '' + uid + '' + + '' + escapeHtml(u.username) + + (banned ? ' 已封禁' : '') + + (disabled ? ' 已禁用' : '') + '' + + '' + escapeHtml(u.email || '-') + '' + + '' + (isAdmin ? '管理员' : '用户') + '' + + '' + cred + '' + + '' + escapeHtml(dev) + '' + + '' + + '' + + ' ' + + (isAdmin ? '' : + ' ' + + ' ' + + ' ' + + '') + + ''; + }).join(''); + $$('[data-uedit]', body).forEach(function (b) { + b.addEventListener('click', function () { + var v = prompt('设置该用户的剩余次数(-1 表示不限):', b.getAttribute('data-cur')); + if (v === null) return; + api('/admin/site/user/duration', { id: b.getAttribute('data-uedit'), count: v }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已修改' : '修改失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + }); + }); + // T3:管理员自助改密(需验证旧密码;改完其他设备全部下线) + $$('[data-uself]', body).forEach(function (b) { + b.addEventListener('click', function () { + var oldpwd = prompt('请输入当前密码:', ''); + if (oldpwd === null) return; + var newpwd = prompt('请输入新密码(至少 8 位):', ''); + if (newpwd === null) return; + api('/admin/site/user/reset_password', { + id: b.getAttribute('data-uself'), password: newpwd, old_password: oldpwd + }).then(function (d) { + toast(d.message || (d.status === 200 ? '密码已修改' : '修改失败'), d.status === 200 ? 'success' : 'error'); + }); + }); + }); + $$('[data-upwd]', body).forEach(function (b) { + b.addEventListener('click', function () { + var v = prompt('为用户「' + b.getAttribute('data-name') + '」设置新密码(至少 8 位):', ''); + if (v === null) return; + api('/admin/site/user/reset_password', { id: b.getAttribute('data-upwd'), password: v }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已重置' : '重置失败'), d.status === 200 ? 'success' : 'error'); }); + }); + }); + $$('[data-utoggle]', body).forEach(function (b) { + b.addEventListener('click', function () { + api('/admin/site/user/toggle', { id: b.getAttribute('data-utoggle') }) + .then(function (d) { toast(d.message || '操作成功', d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + }); + }); + $$('[data-uban]', body).forEach(function (b) { + b.addEventListener('click', function () { + var name = b.getAttribute('data-name'); + if (b.textContent === '封禁') { + var reason = prompt('封禁用户「' + name + '」\n请输入封禁原因(将展示给用户):', '违规使用,封禁处理'); + if (reason === null) return; + api('/admin/site/user/ban', { id: b.getAttribute('data-uban'), action: 'ban', reason: reason }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已封禁' : '操作失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + } else { + if (!window.confirm('解封用户「' + name + '」?解封后可正常登录使用。')) return; + api('/admin/site/user/ban', { id: b.getAttribute('data-uban'), action: 'unban' }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已解封' : '操作失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + } + }); + }); + $$('[data-udel]', body).forEach(function (b) { + b.addEventListener('click', function () { + if (!window.confirm('确定删除用户「' + b.getAttribute('data-name') + '」?')) return; + api('/admin/site/user/delete', { id: b.getAttribute('data-udel') }) + .then(function (d) { toast(d.message || '已删除', d.status === 200 ? 'success' : 'error'); uaLoadUsers(); }); + }); + }); + $$('[data-ucookie]', body).forEach(function (b) { + b.addEventListener('click', function () { + api('/admin/site/user/cookie', { username: b.getAttribute('data-ucookie') }) + .then(function (d) { + if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; } + var ck = (d.data && d.data.cookie) || ''; + if (!ck) { toast('该用户未登录过网易云账号', 'info'); return; } + prompt('用户网易云 Cookie(Ctrl+C 复制):', ck); + }); + }); + }); + }); + } + + /* ---- 全局 Cookie ---- */ + var uaQrTimer = null, uaUnikey = '', uaQrGen = 0; // T2:轮询代次令牌(删除 Cookie 后作废旧轮询) + function uaLoadCookieStatus() { + api('/admin/cookie/status', {}).then(function (data) { + var el = $('#ua-cookie-status'); + if (!el) return; + if (data.status !== 200) { el.textContent = data.message || '获取失败'; return; } + var info = data.data || {}; + el.textContent = (info.is_valid ? '✅ 有效' : '❌ 未登录') + + (info.cookie_count != null ? ' · Cookie ' + info.cookie_count + ' 个' : ''); + }); + } + function uaQrCreate() { + api('/admin/qr/create', {}).then(function (data) { + if (data.status !== 200) { toast(data.message || '生成二维码失败', 'error'); return; } + var d = data.data || {}; + uaUnikey = d.unikey || ''; + var box = $('#ua-qr-box'), tip = $('#ua-qr-tip'); + if (d.qr_svg) { + var uri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(d.qr_svg))); + box.innerHTML = '二维码'; + } + if (tip) tip.textContent = '请使用网易云音乐手机 APP 扫码'; + if (uaQrTimer) clearInterval(uaQrTimer); + uaQrGen++; // T2:新轮询代次 + uaQrTimer = setInterval(uaQrPoll, 3000); + }); + } + function uaQrPoll() { + if (!uaUnikey) { if (uaQrTimer) clearInterval(uaQrTimer); return; } + var myGen = uaQrGen; // T2:本次轮询所属代次 + api('/admin/qr/check', { unikey: uaUnikey }).then(function (data) { + if (myGen !== uaQrGen) return; // 期间已删除 Cookie/重开扫码,丢弃迟到响应 + var tip = $('#ua-qr-tip'); + if (data.status !== 200) { + if (uaQrTimer) clearInterval(uaQrTimer); + if (tip) tip.textContent = data.message || '检查失败'; + return; + } + var st = data.data && data.data.status; + if (st === 'waiting') { if (tip) tip.textContent = '等待扫码...'; } + else if (st === 'scanned') { if (tip) tip.textContent = '✅ 已扫码,请在手机上确认'; } + else if (st === 'success') { + if (uaQrTimer) clearInterval(uaQrTimer); + if (tip) tip.textContent = '🎉 登录成功,Cookie 已保存'; + toast('全局账号登录成功', 'success'); + uaLoadCookieStatus(); + } else { + if (uaQrTimer) clearInterval(uaQrTimer); + if (tip) tip.textContent = '二维码已过期,请重新生成'; + } + }); + } + function uaCookieDelete() { + if (!window.confirm('确定删除全局 Cookie?删除后需重新扫码登录。')) return; + api('/admin/cookie/delete', {}).then(function (data) { + // T2:作废在途扫码轮询——迟到的 803 成功响应不得把刚删除的 Cookie 写回 + if (uaQrTimer) clearInterval(uaQrTimer); + uaUnikey = ''; + uaQrGen++; + toast(data.message || '已删除', data.status === 200 ? 'success' : 'error'); + var box = $('#ua-qr-box'), tip = $('#ua-qr-tip'); + if (box) box.innerHTML = ''; + if (tip) tip.textContent = '点击下方按钮生成二维码'; + uaLoadCookieStatus(); + }); + } + + /* ---- SMTP ---- */ + function uaLoadSmtp() { + api('/admin/site/smtp/get', {}).then(function (data) { + if (data.status !== 200) return; + var d = data.data || {}; + if ($('#ua-smtp-host')) $('#ua-smtp-host').value = d.host || ''; + if ($('#ua-smtp-port')) $('#ua-smtp-port').value = d.port || 587; + if ($('#ua-smtp-sender')) $('#ua-smtp-sender').value = d.sender || ''; + }); + } + function uaSaveSmtp() { + api('/admin/site/smtp/save', { + host: ($('#ua-smtp-host') || {}).value || '', + port: ($('#ua-smtp-port') || {}).value || 587, + sender: ($('#ua-smtp-sender') || {}).value || '', + password: ($('#ua-smtp-pass') || {}).value || '' + }).then(function (d) { + toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); + var p = $('#ua-smtp-pass'); if (p) p.value = ''; + }); + } + function uaTestSmtp() { + var to = ($('#ua-smtp-test-to') || {}).value || ''; + if (!to) { toast('请填写测试收件邮箱', 'error'); return; } + var btn = $('#ua-smtp-test'); + btn.disabled = true; + api('/admin/site/smtp/test', { to: to }).then(function (d) { + toast(d.message || '已发送', d.status === 200 ? 'success' : 'error'); + }).finally(function () { btn.disabled = false; }); + } + + /* ---- 日志 ---- */ + var uaLogPage = 1; + function uaLoadLogs() { + api('/admin/site/logs', { type: uaLogsType, page: uaLogPage }).then(function (data) { + var box = $('#ua-log-list'); + if (!box) return; + if (data.status !== 200) { box.textContent = data.message || '加载失败'; return; } + var logs = (data.data && data.data.logs) || []; + var total = (data.data && data.data.total) || 0; + var pages = (data.data && data.data.pages) || 1; + uaLogPage = (data.data && data.data.page) || 1; + window._uaLogPages = pages; // P3-43:next 按钮按真实页数封顶 + var el; + if ((el = $('#ua-log-page'))) el.textContent = uaLogPage; + if ((el = $('#ua-log-pages'))) el.textContent = pages; + if ((el = $('#ua-log-total'))) el.textContent = total; + if (!logs.length) { box.textContent = '暂无日志'; return; } + box.innerHTML = logs.map(function (l) { + return '
[' + escapeHtml(l.time) + '] ' + escapeHtml(l.username || '-') + + ' · ' + escapeHtml(l.action) + ' · IP ' + escapeHtml(l.ip) + + (l.detail ? ' · ' + escapeHtml(l.detail) : '') + '
'; + }).join(''); + }); + } + + function uaClearLogs() { + var name = uaLogsType === 'fail' ? '失败' : '成功'; + if (!window.confirm('确定清除全部「' + name + '」日志?该操作不可恢复。')) return; + api('/admin/site/logs/clear', { type: uaLogsType }).then(function (d) { + toast(d.message || (d.status === 200 ? '已清除' : '清除失败'), d.status === 200 ? 'success' : 'error'); + uaLogPage = 1; + uaLoadLogs(); + }); + } + + /* ---- 卡密 ---- */ + function uaGenCards() { + var creditsVal = ($('#ua-card-minutes') || {}).value || '10'; + if (creditsVal === 'custom') { + creditsVal = ($('#ua-card-custom') || {}).value || '0'; + if (!(parseInt(creditsVal, 10) > 0)) { toast('请填写自定义次数(正整数)', 'error'); return; } + } + api('/admin/site/cards/generate', { + count: ($('#ua-card-count') || {}).value || 1, + credits: creditsVal, + prefix: ($('#ua-card-prefix') || {}).value || 'VIP', + note: ($('#ua-card-note') || {}).value || '', + valid_days: ($('#ua-card-days') || {}).value || 0 + }).then(function (data) { + if (data.status !== 200) { toast(data.message || '生成失败', 'error'); return; } + var box = $('#ua-card-codes'); + if (box) { + box.hidden = false; + box.textContent = (data.data.codes || []).join('\n'); + } + toast('已生成 ' + (data.data.count || 0) + ' 张卡密(' + (data.data.credits || 0) + ' 次/张),已复制到文本框,请及时保存', 'success'); + uaLoadCards(0); + }); + } + // 第 81 条:卡密管理操作列(禁用/启用/删除)+ 分页(F85 后端已支持, + // 不分页则超出最新 100 张的旧卡密在界面上永远管理不到) + var _uaCardPage = 0; + var _uaCardTotal = 0; + var UA_CARD_PAGE_SIZE = 100; + function uaCardAction(act, code) { + if (!code) return; + if (act === 'delete' && !window.confirm('确定删除卡密 ' + code.slice(0, 4) + '**** 吗?删除后不可恢复。')) return; + var url = act === 'delete' ? '/admin/site/cards/delete' : '/admin/site/cards/disable'; + var body = { code: code, disabled: act === 'enable' ? '0' : '1' }; + api(url, body).then(function (data) { + toast(data.message || (data.status === 200 ? '操作成功' : '操作失败'), + data.status === 200 ? 'success' : 'error'); + if (data.status === 200) uaLoadCards(); + }); + } + function _uaRenderCardPager() { + var pager = $('#ua-cards-pager'); + if (!pager) return; + if (_uaCardTotal <= 0) { pager.hidden = true; return; } + var pages = Math.max(1, Math.ceil(_uaCardTotal / UA_CARD_PAGE_SIZE)); + pager.hidden = false; + pager.textContent = ''; + var prev = document.createElement('button'); + prev.className = 'btn btn-sm'; prev.type = 'button'; prev.textContent = '上一页'; + prev.disabled = _uaCardPage <= 0; + prev.addEventListener('click', function () { uaLoadCards(_uaCardPage - 1); }); + var next = document.createElement('button'); + next.className = 'btn btn-sm'; next.type = 'button'; next.textContent = '下一页'; + next.disabled = _uaCardPage >= pages - 1; + next.addEventListener('click', function () { uaLoadCards(_uaCardPage + 1); }); + var info = document.createElement('span'); + info.className = 'hint'; + info.textContent = '第 ' + (_uaCardPage + 1) + ' / ' + pages + ' 页 · 共 ' + _uaCardTotal + ' 张'; + pager.appendChild(prev); + pager.appendChild(info); + pager.appendChild(next); + } + function uaLoadCards(page) { + if (typeof page === 'number') _uaCardPage = Math.max(0, page); + api('/admin/site/cards/list', { limit: UA_CARD_PAGE_SIZE, offset: _uaCardPage * UA_CARD_PAGE_SIZE }) + .then(function (data) { + var body = $('#ua-cards-body'); + if (!body) return; + if (data.status !== 200) { body.innerHTML = '加载失败'; return; } + var cards = (data.data && data.data.cards) || []; + _uaCardTotal = (data.data && data.data.total) || 0; + if (!cards.length) { + body.innerHTML = '暂无卡密'; + _uaRenderCardPager(); + return; + } + body.innerHTML = cards.map(function (c) { + var st, act; + if (c.used_by) { + st = '已使用'; + act = '—'; // 兑换凭证保留,不给删除/禁用 + } else if (c.disabled) { + st = '已禁用'; + act = ' ' + + ''; + } else { + // 过期判定用服务端下发的 expired(客户端时钟偏差会误标) + st = c.expired ? '已过期' : '未使用'; + act = ' ' + + ''; + } + return '' + escapeHtml(c.code) + '' + escapeHtml(String(c.minutes)) + ' 次' + st + '' + escapeHtml(c.used_by || '-') + '' + + escapeHtml(c.note || '-') + '' + + (c.expire_at ? new Date(c.expire_at * 1000).toLocaleDateString() : '永久') + '' + + escapeHtml(c.created_at ? new Date(c.created_at * 1000).toLocaleDateString() : '-') + '' + act + ''; + }).join(''); + _uaRenderCardPager(); + Array.prototype.forEach.call(body.querySelectorAll('button[data-card-act]'), function (b) { + b.addEventListener('click', function () { + uaCardAction(b.getAttribute('data-card-act'), b.getAttribute('data-card-code')); + }); + }); + }); + } + + /* ---- 公告 ---- */ + function uaLoadAnn() { + api('/admin/site/ann/get', {}).then(function (data) { + if (data.status !== 200) return; + var d = data.data || {}; + if ($('#ua-ann-title')) $('#ua-ann-title').value = d.title || ''; + if ($('#ua-ann-content')) $('#ua-ann-content').value = d.content || ''; + if ($('#ua-ann-mode')) $('#ua-ann-mode').value = d.popup_mode || 'off'; + }); + } + function uaSaveAnn() { + api('/admin/site/ann/save', { + title: ($('#ua-ann-title') || {}).value || '', + content: ($('#ua-ann-content') || {}).value || '', + popup_mode: ($('#ua-ann-mode') || {}).value || 'off' + }).then(function (d) { toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); }); + } + + /* ---- 数据库 ---- */ + function fmtSize(n) { + if (!n && n !== 0) return '-'; + if (n > 1048576) return (n / 1048576).toFixed(2) + ' MB'; + if (n > 1024) return (n / 1024).toFixed(1) + ' KB'; + return n + ' B'; + } + function uaLoadDb() { + api('/admin/site/backup/list', {}).then(function (data) { + var body = $('#ua-db-body'); + if (!body) return; + if (data.status !== 200) { body.innerHTML = '加载失败'; return; } + var d = data.data || {}; + var size = $('#ua-db-size'); if (size) size.textContent = fmtSize(d.db_size || 0); + var hours = $('#ua-db-hours'); if (hours && d.interval_hours != null) hours.value = d.interval_hours; + var baks = d.backups || []; + if (!baks.length) { body.innerHTML = '暂无备份'; return; } + body.innerHTML = baks.map(function (b) { + return '' + escapeHtml(b.name) + '' + fmtSize(b.size) + '' + + escapeHtml(b.time) + '' + + '下载 ' + + ' ' + + ''; + }).join(''); + $$('[data-restore]', body).forEach(function (b) { + b.addEventListener('click', function () { + if (!window.confirm('确定用该备份覆盖当前数据库?')) return; + api('/admin/site/backup/restore', { name: b.getAttribute('data-restore') }) + .then(function (d2) { + toast(d2.message || '已恢复', d2.status === 200 ? 'success' : 'error'); + if (d2.status === 200) setTimeout(function () { location.reload(); }, 1200); + }); + }); + }); + $$('[data-bdel]', body).forEach(function (b) { + b.addEventListener('click', function () { + if (!window.confirm('确定删除该备份?')) return; + api('/admin/site/backup/delete', { name: b.getAttribute('data-bdel') }) + .then(function (d2) { toast(d2.message || '已删除', d2.status === 200 ? 'success' : 'error'); uaLoadDb(); }); + }); + }); + }); + } + function uaBackup() { + api('/admin/site/backup/create', {}).then(function (d) { + toast(d.message || '备份完成', d.status === 200 ? 'success' : 'error'); + uaLoadDb(); + }); + } + function uaBackupAuto() { + api('/admin/site/backup/auto', { hours: ($('#ua-db-hours') || {}).value || 0 }) + .then(function (d) { toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); }); + } + function uaBackupUpload(file) { + var fd = new FormData(); + fd.append('file', file); + fetch('/admin/site/backup/upload', { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (r) { + if (!r.ok) throw new Error('上传失败(HTTP ' + r.status + ')'); + return r.json(); + }) + .then(function (d) { + toast(d.message || '上传完成', d.status === 200 ? 'success' : 'error'); + uaLoadDb(); + }) + .catch(function (err) { + // 第 97 条(P2):全站唯一绕过统一请求封装的调用此前无失败兜底—— + // 网络失败/非 JSON 响应时管理员毫无感知还制造 unhandledrejection 噪音 + toast((err && err.message) || '备份上传失败,请检查网络后重试', 'error'); + }); + } + + /* ---------------- 绑定 ---------------- */ + function bind() { + bindAuthModal(); + var s1 = $('#login-submit'); if (s1) s1.addEventListener('click', doLogin); + var s2 = $('#login-password'); if (s2) s2.addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); }); + // F-3.1:回车提交覆盖全部输入框(登录用户名框 / 注册四框) + var s2u = $('#login-username'); if (s2u) s2u.addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); }); + var s3 = $('#reg-submit'); if (s3) s3.addEventListener('click', doRegister); + var s4 = $('#reg-send-code'); if (s4) s4.addEventListener('click', sendCode); + ['#reg-username', '#reg-password', '#reg-email', '#reg-code'].forEach(function (sel) { + var el = $(sel); + if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doRegister(); }); + }); + // F-4.1:密码明文切换(登录/注册/找回) + [['#login-pwd-eye', '#login-password'], ['#reg-pwd-eye', '#reg-password'], ['#forgot-pwd-eye', '#forgot-password']].forEach(function (pair) { + var eye = $(pair[0]), input = $(pair[1]); + if (!eye || !input) return; + eye.addEventListener('click', function () { + var show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + eye.textContent = show ? '隐藏' : '显示'; + }); + }); + // F-2.1:发码后修改邮箱 → 提前终止倒计时并恢复按钮,提示需重发 + var regEmail = $('#reg-email'); + if (regEmail) regEmail.addEventListener('input', function () { + var btn = $('#reg-send-code'); + if (btn && btn.disabled && /^\d+s$/.test(btn.textContent)) { + clearInterval(window.__regCodeTimer || 0); + btn.disabled = false; btn.textContent = '发送验证码'; + toast('邮箱已修改,请重新发送验证码', 'info'); + } + }); + // 第 84 条:找回视图——发码/提交/回车/改邮箱终止倒计时(对齐注册交互) + var fsc = $('#forgot-send-code'); if (fsc) fsc.addEventListener('click', doForgotSendCode); + var fsub = $('#forgot-submit'); if (fsub) fsub.addEventListener('click', doForgotReset); + ['#forgot-email', '#forgot-code', '#forgot-password'].forEach(function (sel) { + var el = $(sel); + if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doForgotReset(); }); + }); + var forgotEmail = $('#forgot-email'); + if (forgotEmail) forgotEmail.addEventListener('input', function () { + var btn = $('#forgot-send-code'); + if (btn && btn.disabled && /^\d+s$/.test(btn.textContent)) { + clearInterval(window.__forgotCodeTimer || 0); + btn.disabled = false; btn.textContent = '发送验证码'; + toast('邮箱已修改,请重新发送验证码', 'info'); + } + }); + // 第 86 条:管理员两步验证——发码/验证提交/回车提交 + var asc = $('#a2fa-send'); if (asc) asc.addEventListener('click', doA2faSendCode); + var asb = $('#a2fa-submit'); if (asb) asb.addEventListener('click', doA2faVerify); + ['#a2fa-email', '#a2fa-code'].forEach(function (sel) { + var el = $(sel); + if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doA2faVerify(); }); + }); + var s6 = $('#announce-ok'); if (s6) s6.addEventListener('click', function () { closeModal('announce-modal'); }); + + var ucTab = $('#tab-usercenter'); + if (ucTab) ucTab.addEventListener('click', renderUserCenter); + var uaTab = $('#tab-useradmin'); + if (uaTab) uaTab.addEventListener('click', uaShowGate); + var av = $('#user-avatar'); + if (av) av.addEventListener('click', function () { + if (user && user.role === 'admin') { if (uaTab) uaTab.click(); } + else { var t = $('#tab-usercenter'); if (t) t.click(); } + }); + var ucRedeem = $('#uc-card-redeem'); if (ucRedeem) ucRedeem.addEventListener('click', doRedeem); + var ucAnn = $('#uc-ann-view'); if (ucAnn) ucAnn.addEventListener('click', function () { maybeShowAnnouncement(true); }); // F81:手动查看强制展示 + var ucLogout = $('#uc-logout'); if (ucLogout) ucLogout.addEventListener('click', doLogout); + + uaBindSide(); + var b1 = $('#ua-users-refresh'); if (b1) b1.addEventListener('click', uaLoadUsers); + var b2 = $('#ua-cookie-refresh'); if (b2) b2.addEventListener('click', uaLoadCookieStatus); + var b3 = $('#ua-qr-create'); if (b3) b3.addEventListener('click', uaQrCreate); + var b4 = $('#ua-cookie-delete'); if (b4) b4.addEventListener('click', uaCookieDelete); + var b5 = $('#ua-smtp-save'); if (b5) b5.addEventListener('click', uaSaveSmtp); + var b6 = $('#ua-smtp-test'); if (b6) b6.addEventListener('click', uaTestSmtp); + var b7 = $('#ua-log-refresh'); if (b7) b7.addEventListener('click', function () { uaLoadLogs(); }); + var b8 = $('#ua-log-tab-ok'); if (b8) b8.addEventListener('click', function () { uaLogsType = 'success'; uaLogPage = 1; uaLoadLogs(); }); + var b9 = $('#ua-log-tab-fail'); if (b9) b9.addEventListener('click', function () { uaLogsType = 'fail'; uaLogPage = 1; uaLoadLogs(); }); + var blogPrev = $('#ua-log-prev'); if (blogPrev) blogPrev.addEventListener('click', function () { if (uaLogPage > 1) { uaLogPage--; uaLoadLogs(); } }); + // P3-43:next 补边界(原实现可翻到空页——前端无 pages 时放宽为 999 防呆) + var blogNext = $('#ua-log-next'); if (blogNext) blogNext.addEventListener('click', function () { var maxP = window._uaLogPages || 999; if (uaLogPage < maxP) { uaLogPage++; uaLoadLogs(); } }); + var blogClear = $('#ua-log-clear'); if (blogClear) blogClear.addEventListener('click', uaClearLogs); + var b10 = $('#ua-card-gen'); if (b10) b10.addEventListener('click', uaGenCards); + var cardType = $('#ua-card-minutes'); + if (cardType) cardType.addEventListener('change', function () { + var custom = $('#ua-card-custom'); + if (custom) custom.hidden = cardType.value !== 'custom'; + if (cardType.value === 'custom' && custom) custom.focus(); + }); + var b11 = $('#ua-ann-save'); if (b11) b11.addEventListener('click', uaSaveAnn); + var b12 = $('#ua-db-backup'); if (b12) b12.addEventListener('click', uaBackup); + var b13 = $('#ua-db-auto'); if (b13) b13.addEventListener('click', uaBackupAuto); + var b14 = $('#ua-db-upload'); + if (b14) b14.addEventListener('change', function () { + if (b14.files && b14.files[0]) uaBackupUpload(b14.files[0]); + b14.value = ''; + }); + + } + + function init() { + bind(); + refreshSession(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..4669ee1 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,7656 @@ + + + + + + + 云村小助手 + + + + + + +
+
+ 正在加载云村小助手... +
+ + +
+
+
+ +
+
云村小助手
+
实时解析网易云音乐单曲、获取真实下载地址及封面信息
+
+
+
+
+ + +
+ + + + +
+
+
+ +
+ + + + +
+ + +
+ 💡 小提示:可在「个人歌单」登录你自己的 SVIP 网易云音乐账号,并切换为个人账号解析,即可下载/试听更高音质。 +
+ + +
+ 🎧 全局音质 + + + 全局生效:单曲解析、音乐下载、批量下载均使用该音质;「最高音质(自动)」会按账号会员类型自动尝试最高可解析音质。 +
+ +
+
搜索
+
单曲
+
歌单
+
专辑
+
播客
+ + +
歌手
+
榜单
+
个人歌单
+ + +
关于网站
+
+ + +
+
+
🔍
+
+ + + +
+ +
+
支持搜索歌曲、歌单、专辑、歌手;结果点击即可跳转对应功能解析。
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
支持 电台链接(djradio?id=)、单集链接(program?id= / sound?id=)、163cn.tv 短链或纯数字ID;解析后可在线试听、获取直链、下载单集、整台打包 ZIP。
+ + + + + + + + + + + + + +
+
+
+
热门播客
+
+
+
+ +
+
+ + +
+ +
+
+ + +
+
管理员设置:全局二维码登录与 Cookie 管理。登录后 Cookie 自动保存,无需重复扫码。
+
+ + + +
+ + +
+ +
+
+
+ 全局账号歌单展示系统中全局账号(管理员 cookie.txt)收藏的歌单与「我喜欢的音乐」。 + 当前全局账号未登录或 Cookie 已失效,请先在「管理员设置」扫码登录全局账号。 +
+
+ + +
+
+
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ 网易云官方实时排行榜,点击卡片即可查看榜单歌曲 +
+
📊
加载中...
+ +
+ + +
+ +
+
+
+ 扫码登录你自己的网易云账号,即可查看收藏的歌单(包括「我喜欢的音乐」)。 + 个人登录仅用于获取你的歌单,解析与下载音乐仍使用管理员全局账号。 +
+
+ + +
+
+ +
+ + + +
+ + +
+
+ +
+
云村小助手
+
基于 Flask + 原生前端的云村小助手 · 当前版本 v2.1.0
+
+
+ +
+
项目介绍
+

+ 本项目是一个开箱即用的云村小助手,支持歌曲搜索、单曲 / 歌单 / 专辑 / 歌手解析、官方榜单、个人歌单、播客解析等完整功能, + 提供多音质试听与下载(标准 / 极高 / 无损 / Hi-Res / 沉浸环绕声等)、批量下载与 ZIP 打包、元数据写入、歌词下载(LRC / SRT)、 + 自定义播放器与逐字歌词、评论区、多账号体系(全局账号 + 个人账号)与下载任务进度管理。 + 服务端基于 Flask 与网易云官方接口实现,前端零框架原生实现,支持深色模式与响应式布局,可通过 Docker 一键部署。 +

+ +
+
功能说明
+
+
+
🔍 多类型搜索
+
支持按歌曲、歌单、专辑、歌手搜索,结果一键跳转对应功能解析。
+
+
+
🎵 单曲 / 歌单 / 专辑 / 歌手解析
+
输入链接或ID即可解析,自动按账号实测最高可解析音质输出,含封面、歌词与直链。
+
+
+
🎙️ 播客解析
+
支持播客电台与单集解析:在线试听、音频直链、单集下载、整台打包 ZIP,热门播客分类浏览。
+
+
+
📊 官方榜单
+
63 个网易云官方实时排行榜卡片墙,点击即看榜单歌曲。
+
+
+
⬇ 下载能力
+
单曲 / 批量下载、ZIP 打包、文件命名格式、ffmpeg 元数据写入、LRC / SRT 歌词。
+
+
+
🎧 播放器与歌词
+
自定义深色播放器、SMTC 系统媒体控制、逐字 KTV 歌词高亮、翻译双行、偏移校准。
+
+
+
👥 多账号体系
+
全局账号负责解析下载;个人账号扫码登录查看自己的歌单,Cookie 保存在本机浏览器(站点账号登录时管理后台可按需查看)。
+
+
+
🔒 隐私与任务管理
+
隐私协议 Cookie 偏好、下载任务进度悬浮窗、断线续传与离线暂停恢复、接口统计。
+
+
+ +
+
更新日志
+
+ +
本工具仅供个人学习交流使用,音频内容版权归网易云音乐及内容创作者所有,请勿用于商业用途。
+
+ + +
+
🔒
请先登录后查看个人中心
+ +
+ + +
+
🔒
仅管理员可访问管理后台
+ +
+
+ + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
实时排队
+
0
+
预计等待:--
+
下载 - · 打包 -
+
+ + +
+
+ 📥 下载任务 0 + +
+
+
+ + + + + + + diff --git a/功能介绍.md b/功能介绍.md new file mode 100644 index 0000000..27d4ac0 --- /dev/null +++ b/功能介绍.md @@ -0,0 +1,170 @@ +# 云村小助手 · 功能介绍 + +> 一个基于 Flask + 原生前端的云村小助手。 +> 支持搜索、单曲/歌单/专辑/歌手解析、官方榜单、个人歌单、多账号体系、批量下载、ZIP 打包、 +> 歌词逐字高亮、评论区、接口统计、隐私协议 Cookie 管理等完整功能。 + +--- + +## 一、核心功能(菜单栏 Tab) + +| Tab | 功能 | 说明 | +| ---- | ---- | ---- | +| 搜索 | 多类型搜索 | 支持按 歌曲 / 歌单 / 专辑 / 歌手 搜索;结果点击直接跳转对应功能解析 | +| 单曲 | 单曲解析 | 输入歌曲 ID / 链接,自动按账号实测最高音质解析;含封面、歌词、直链、试听播放、下载 | +| 歌单 | 歌单解析 | 输入歌单 ID / 链接,展示歌单信息与歌曲列表(10 首/页),可逐首或批量下载 | +| 专辑 | 专辑解析 | 输入专辑 ID / 链接,展示专辑信息与歌曲列表,可批量下载 | +| 播客 | 播客解析 | 支持播客电台(djradio)与单集(program/sound)链接、短链或 ID 解析;在线试听、音频直链复制、单集服务器下载(可选 ZIP)、整台播客打包 ZIP;支持播客搜索与 19 个分类的热门播客卡片墙;单集列表分页加载、正序/倒序切换 | +| 管理员设置 | 管理员功能 | 站点管理员登录(账号+密码+绑定邮箱验证码两步验证):全局账号二维码登录 / 删除 Cookie / 用户 IP 解析下载记录(分页) | +| 全局账号歌单 | 全局歌单展示 | 展示全局账号(`cookie.txt`)收藏的歌单列表与「我喜欢的音乐」,可查看歌曲、公开播放 API 一键播放 | +| 歌手 | 歌手解析 | 输入歌手 ID / 链接,展示歌手信息与热门歌曲,可批量下载 | +| 榜单 | 官方榜单 | 63 个网易云官方榜单卡片墙,点击即可加载榜单歌曲 | +| 个人歌单 | 个人账号歌单 | 游客扫码登录自己的网易云账号,查看收藏/创建的歌单(含「我喜欢的音乐」);Cookie 仅保存在本机浏览器 | +| 关于网站 | 项目信息 | 项目介绍、功能说明、完整更新日志 | + +## 二、账号体系 + +- **全局账号(管理员)**:`cookie.txt`,负责默认的解析与下载;可在「管理员设置」扫码登录黑胶会员后解析更高音质。 +- **个人账号(游客)**:在「个人歌单」扫码登录自己的账号,**登录信息仅存储在用户自己的电脑浏览器(localStorage)**,其他人无法访问别人的登录账号;仅用于查看个人歌单。 +- **解析账号切换**:登录个人账号后,可在个人歌单内「切换为个人账号解析」;切换后当前浏览器所有菜单的解析与下载都使用个人账号,顶部「解析账号」栏实时显示当前账号 VIP 类型(普通/VIP/SVIP)与**实测可解析音质列表**。 + +## 三、音质体系 + +- 音质等级:标准 / 极高 / 无损 / Hi-Res / 沉浸环绕声 / 高清环绕声 / 超清母带 / 杜比全景声。 +- **最高音质(自动)**:默认选项,按当前账号实测可解析列表从高到低自动尝试(要求返回音质与请求一致),保证拿到该账号能解析的最高音质。 +- **实测音质探测**:账号栏展示的可解析音质为接口实测结果(如:请求 Hi-Res 实际只能拿到无损时会如实显示),避免虚标。 + +## 四、下载能力 + +- 单曲 / 批量下载(歌单、专辑、歌手、个人歌单全部歌曲)。 +- **文件命名格式**:仅歌曲名 / 歌手 - 歌曲名 / 歌曲名 - 歌手。 +- **元数据写入**(ffmpeg):可按字段勾选写入 歌曲名/歌手/专辑/封面/歌词/发布日期,未勾选字段保持原文件值。 +- **ZIP 打包**:单曲下载可打包 音频 + 歌词 + 封面 + 信息文件;批量下载可一次打包整个歌单/专辑为 ZIP。 +- **歌词格式**:LRC / SRT(下载歌词与打包歌词通用)。 +- 下载文件保存于 `/app/downloads`(宿主机 `downloads/` 持久卷)。 + +## 五、播放器与歌词 + +- 自定义深色播放器:播放/暂停、重新播放、进度拖拽、音量/静音、播放进度记忆(续播)。 +- **SMTC**:开启后在系统媒体控制面板(Windows 锁屏/音量)显示歌曲信息并支持控制。 +- **歌词**:LRC 毫秒级解析、翻译合并、当前句**居中标蓝加粗、其他灰色**、点击跳转、KTV 逐字渐变高亮(有 YRC 逐字轴的歌曲;无逐字数据的歌曲整句点亮,不做仿逐字动画)、翻译分两行显示、手动偏移校准(±0.25s)。 + +## 六、评论区 + +- 单曲下方评论区:热门评论 + 全部评论,**10 条/页分页**,展示昵称、VIP/SVIP/音乐包标识、IP 归属地、点赞数、回复引用;可在设置中关闭。 +- **发表/回复评论**:扫码登录**个人网易云账号**后可用(140 字以内、支持回复指定评论);**仅限个人账号**——服务器全局账号在发评论/收藏等个人身份操作上零权限零入口(提交全局账号 Cookie 会被拒绝并记录审计日志)。 + +## 七、设置中心(⚙️) + +- **外观**:浅色 / 深色 / 跟随系统 + 8 种主题色(默认蓝/红/橙/火山/青/绿/粉/紫罗兰)。 +- **下载配置**:文件命名格式、写入元数据开关、写入字段多选、ZIP 打包(各自带「保存」按钮)。 +- **批量下载配置**:写入元数据、ZIP 打包(保存按钮)。 +- **歌词**:LRC/SRT 格式、翻译分两行、KTV 逐字模式(保存按钮)。 +- **播放**:播放进度记忆。 +- **其他配置**:SMTC、显示评论区、接口请求统计(`/api/topen` 实时统计弹窗,10 秒自动刷新)、隐私与协议(Cookie 偏好管理)。 + +## 八、隐私与协议 + +- 新用户首次打开网站自动弹出「隐私与协议设置」:需勾选同意《用户协议》《隐私政策》后才能使用。 +- Cookie 类型: + - **必要 Cookie**(始终启用):CDN 性能监控等。 + - **功能性 Cookie**(可关闭):解析与下载服务、播放进度记忆、历史记录;关闭后无法解析与下载音乐。 +- 可随时在「设置 → 隐私与协议」修改偏好或**撤回同意**。 +- 所有偏好、历史记录、个人登录 Cookie 均只存储在**用户本地浏览器**(localStorage),不上传服务器。 + +## 九、管理员功能 + +- 全局账号状态与 VIP 类型、生成登录二维码(自动保存 Cookie)、删除 Cookie(重新扫码)。 +- **用户 IP 解析/下载记录**:记录每位访问者的 解析/下载 操作(样式:`解析|下载 - IP - 歌曲名 - 时间`),支持分页查看。 + +## 十、接口请求统计 + +- 后端实时统计各真实业务接口的请求数(仅统计搜索/解析/歌单/专辑/榜单/歌手/评论/下载等业务接口,页面刷新与内部探测不计入)。 +- 面板展示:总请求数、不同端点数、最高端点请求数,以及按请求数倒序的端点明细(名称、路径、次数、百分比、最近活动),每 10 秒自动刷新。 + +## 十一、接口 API(速览) + +| 接口 | 方法 | 说明 | +| ---- | ---- | ---- | +| `/search` | POST | 搜索(type: 1歌曲/10专辑/100歌手/1000歌单) | +| `/song` | POST | 单曲解析(url/level/type/account/user_cookie) | +| `/playlist` `/album` | POST | 歌单 / 专辑解析 | +| `/toplist` | POST | 官方榜单列表 | +| `/artist_songs` | POST | 歌手信息与热门歌曲 | +| `/comments` | POST | 歌曲评论(id/limit/offset) | +| `/podcast/parse` | POST | 播客解析(电台/单集链接、短链或 ID;单集附带音频直链) | +| `/podcast/programs` | POST | 播客单集列表(radio_id/limit/offset/asc 分页) | +| `/podcast/search` | POST | 播客搜索(keyword/type=all,radio,voice) | +| `/podcast/hot` | POST | 热门播客与分类列表(cate_id/limit/offset) | +| `/podcast/download` | POST | 播客单集服务器下载(id/quality/zip,返回 task_id) | +| `/podcast/download/batch_zip` | POST | 整台播客打包 ZIP(radio_id 或 ids/limit/quality) | +| `/download` | POST | 下载(含 filename_format/write_meta/meta_fields/lyric_format/zip) | +| `/download/batch_zip` | POST | 批量下载并打包 ZIP | +| `/account/vip` | POST | 当前解析账号 VIP 类型与实测可解析音质 | +| `/api/topen` | GET/POST | 接口请求统计 | +| `/auth/*` | POST | 站点账号系统:注册 / 登录 / 退出 / 邮箱验证码 / 免费体验 / 卡密兑换 / 会话查询;`/auth/admin/*` 为后台管理接口 | +| `/admin/*` | POST | 管理员接口(两步验证登录后的会话鉴权):二维码登录、Cookie 管理、操作记录 | +| `/global/playlists` | GET/POST | 全局账号歌单列表(收藏歌单 + 「我喜欢的音乐」) | +| `/global/playlist` | GET/POST | 全局账号歌单详情(使用全局 Cookie,支持私有歌单) | +| `/api/playlist/play` | GET/POST | 歌单播放 API:返回歌单信息 + 每首歌可播放直链(id/level/limit);**需 `&key=` 密钥**(第 78 条起强制,管理后台「播放API密钥」获取/刷新) | +| `/user/*` | POST | 个人账号接口(Cookie 由浏览器传入 user_cookie) | +| `/health` | GET | 健康检查 | + +## 十二、收藏功能 + +- 登录个人账号后,歌单/专辑解析结果头部显示「♡ 收藏」按钮,个人歌单列表显示「取消收藏」。 +- 使用**网易云官方收藏接口**真实收藏到个人账号: + - 歌单:`POST /api/playlist/subscribe`(id + t=1 收藏 / t=2 取消) + - 专辑:`POST /api/album/sub`(id + t=1 收藏 / t=2 取消) +- 收藏成功后个人歌单界面自动刷新,新收藏的歌单立即出现在列表。 +- ⚠️ 已知限制:网易云对「歌单订阅」接口有风控(返回 `操作过于频繁,请稍后再试`), + 数据中心/云服务器 IP 及操作频繁的账号更容易被拦(本工具在阿里云 IP 上验证时即被拦截, + 而专辑收藏不受影响)。若遇到该提示,请稍后重试或更换网络环境(家庭宽带等非数据中心 IP)后使用。 + +## 十三、账号与权限系统(站点登录 · 按次计费) + +- **强制登录**:所有解析 / 搜索 / 下载接口需登录后使用,未登录返回 401。 +- **按次计费(仅下载)**:解析、搜索、在线试听、榜单、播客浏览**全部免费**;**下载消耗次数**——单曲下载 1 次,批量打包按歌曲数量扣减(如 100 首歌单打包 = 100 次);请求失败不扣费;管理员不限次数。 +- **未登录**:点击解析/下载时弹窗提示「解析功能需要登录后才能使用」并打开登录框(提示 5 秒内不重复弹出)。 +- **次数不足预检**:歌单 / 专辑 / 歌手 / 个人歌单 / 播客打包下载前自动检查剩余次数,不足时弹出选择框——「仅下载前 N 首(集)」或「取消下载」。 +- **次数用尽**:弹窗提示「下载次数已用尽」,支持一键跳转个人中心充值。 +- **注册**:邮箱验证码验证(需管理员先在后台配置 SMTP);**注册成功直接赠送 3 次下载次数**,无需领取;注册前须阅读并同意《用户协议》与《隐私政策》(16 秒强制阅读计时)。 +- **登录**:支持**用户名+密码**或**注册邮箱+密码**两种方式;自动识别角色——管理员登录跳转后台管理界面,普通用户跳转个人中心;会话有效期 7 天(活跃自动滚动续期,连续 7 天不访问才过期)。 +- **找回密码**:登录弹窗「忘记密码?」——通过注册邮箱验证码自助重置(10 分钟有效验证码,重置后全设备下线);管理员账号不走自助找回。 +- **顶号机制**:同一账号在新设备登录后,旧设备会话立即失效(强制下线),防止一号多卖。 +- **防倒卖封禁**:每次登录记录 IP 归属省份,**24 小时内出现 ≥3 个不同省份自动封禁**;管理员可手动封禁(填写原因展示给用户)与解封;被封账号登录即提示封禁原因。 +- **初始管理员**:新装实例在服务首次启动时于控制台**随机打印初始密码**(账号 `admin`,请立即登录后修改);新版已升级为**两步验证登录**:正确的用户名+密码 → 输入绑定邮箱 → 邮箱收取验证码 → 验证通过才可进入后台。 +- **导航栏**:右上角登录 / 注册按钮,登录后显示用户名首字母头像与剩余次数,点击进入个人中心或后台。 +- **次数卡密**:管理员后台生成次数卡(3 / 10 / 50 / 100 / 500 / 2000 次或**自定义次数**,支持前缀 / 备注 / 有效期),用户在个人中心兑换;管理员可对**未使用的卡密禁用 / 启用 / 删除**(带分页管理),已使用的卡密永久保留兑换凭证。 + +### 音频接口多重防护 + +- 所有接口返回的音频地址替换为服务器签名代理地址,**真实 CDN 直链不出现在任何响应中**。 +- 代理地址采用 **HMAC-SHA256 签名 + 36 分钟时效 + 随机 nonce**,伪造 / 过期即失效。 +- 音频流经服务器中转(支持进度拖动),浏览器不与 CDN 直连;已移除「复制 JSON」「复制直链」入口。 + +### 个人中心(普通用户) + +- 剩余次数查看、退出登录、卡密充值、公告查看、系统版本与更新日志。 + +### 后台管理(管理员) + +| 模块 | 功能 | +| ---- | ---- | +| 用户管理 | 添加用户 / 删除 / 修改剩余次数 / 重置密码 / 禁用启用 / **封禁·解封**(显示注册 IP、设备与最近登录省份)/ 查看用户网易云 Cookie,管理员账号受保护不可自锁 | +| 全局 Cookie | 全局网易云账号扫码登录、删除 Cookie(自前端迁移至后台) | +| 邮箱注册管理 | SMTP 服务器 / 端口(默认 587)/ 发件邮箱 / 授权码配置与发信测试 | +| 日志管理 | 成功 / 失败日志 **10 条每页分页**查看,支持清除;记录封禁、解封、自动封禁、顶号下线、扣费明细等关键事件(不记录普通登录;注册成功因防刷号审计会记录一条) | +| 卡密生成 | 次数卡密生成(预设 + **自定义次数**、前缀、备注、有效期)与使用状态查看 | +| 公告设置 | 公告编辑与弹出策略(每次登录弹出 / 仅一次 / 不弹出) | +| 数据库管理 | 定时自动备份、备份文件下载、上传与恢复 | +| 退出登录 / 返回解析 | 快速切换后台与解析页 | + +- **全局账号歌单**菜单仅管理员登录后可见,普通用户不可查看管理员歌单内容。 + +## 十四、Docker 部署要点 + +- 端口映射 `5001:5000`(对外 5001;勿用 6000,浏览器会拦截 `ERR_UNSAFE_PORT`)。 +- 持久卷:`cookie.txt`(全局账号)、`user_cookie.txt`(兼容)、`downloads/`(下载文件)、`data/`(站点数据库与备份)。 +- 镜像内已配置阿里云 apk/pip 源加速,并安装 tzdata(TZ=Asia/Shanghai 生效)。 +- 详细步骤见 `部署文档.md`。 diff --git a/部署文档.md b/部署文档.md new file mode 100644 index 0000000..b20017c --- /dev/null +++ b/部署文档.md @@ -0,0 +1,190 @@ +# 云村小助手 · 部署文档(仅 Docker/Linux 轨道) + +> **轨道定位(2026-09-25)**:本文档**只覆盖 Docker/Linux 一键部署**这一条轨道。 +> 正式部署轨道(Windows 工作站 + NSSM 服务 + PassNAT/Caddy HTTPS)以 `交接文档.md` 第 5/6 节为准——两份文档不要混用。 +> 涵盖:Docker 一键部署、站点配置、安全机制说明与常见问题。 + +--- + +## 一、环境要求 + +| 项目 | 要求 | +| ---- | ---- | +| 操作系统 | Linux(推荐 CentOS 7+ / Ubuntu 18+ / Debian 10+) | +| Docker | 20.10+ | +| Docker Compose | v2.24+(`docker compose` 命令;compose 文件用了 env_file.path/required 语法,v2.24 以下请删掉 docker-compose.yml 中该两行) | +| 内存 | ≥ 1GB(含 ffmpeg 元数据写入建议 2GB) | +| 磁盘 | ≥ 5GB(下载文件与数据库备份) | +| 网络 | 服务器需能访问网易 API 与外网(IP 归属地检测、音频代理) | + +--- + +## 二、文件说明 + +``` +Netease_url/ +├── main.py # Flask 主程序(路由、计费、音频代理防护) +├── auth_system.py # 站点账号系统(SQLite:用户/卡密/公告/日志/封禁/顶号) +├── auth_routes.py # 认证与管理后台路由 +├── music_api.py # 网易云音乐 API 封装 +├── music_downloader.py # 下载器(ffmpeg 元数据 / ZIP 打包) +├── podcast_api.py # 播客解析 API +├── qr_login.py # 网易云扫码登录 +├── templates/index.html # 前端页面(原生 HTML/CSS/JS) +├── static/site_auth.js # 前端认证 / 个人中心 / 管理后台交互 +├── requirements.txt # Python 依赖(Python 3.12) +├── Dockerfile # 镜像构建文件 +├── docker-compose.yml # 编排配置(端口 / 持久卷) +├── entrypoint.sh # 容器启动脚本 +├── cookie.txt # 全局网易云账号 Cookie(部署后扫码生成,勿提交) +├── user_cookie.txt # 游客个人 Cookie(浏览器端,勿提交) +├── data/ # 站点数据库 site.db 与备份(运行时生成) +├── downloads/ # 下载文件目录(运行时生成) +├── 功能介绍.md # 功能清单 +└── 部署文档.md # 本文档 +``` + +--- + +## 三、快速部署(Docker Compose) + +### 1. 上传源码 + +将整个项目目录上传至服务器,例如 `/www/wwwroot/Netease_url`。 + +### 2. 配置环境变量 + +项目根目录创建 `.env` 文件(也可直接使用,默认即可运行): + +```ini +TZ=Asia/Shanghai +``` + +### 3. 构建并启动 + +```bash +cd /www/wwwroot/Netease_url +docker compose up -d --build +``` + +首次构建约 3-5 分钟(已配置阿里云加速源)。启动后检查: + +```bash +curl http://127.0.0.1:5001/health +# 返回 {"status":200,...} 即部署成功 +``` + +### 4. 访问 + +浏览器打开 `http://服务器IP:5001`(对外端口 5001,容器内 5000;勿改用 6000——浏览器会拦截 `ERR_UNSAFE_PORT`)。 + +--- + +## 四、初始配置(必读) + +### 1. 管理员账号 + +新装实例的服务**首次启动时会在控制台随机打印初始管理员密码**(账号 `admin`,请翻阅启动日志获取)。 +登录后自动进入后台 → 请立即在「用户管理」中**重置密码**。 +**注意:管理员账号已升级两步验证登录(2026-09-24)**——输入正确的用户名+密码后,还需输入管理员绑定的邮箱并通过邮箱验证码验证,才能进入后台;请先在数据库或后台为管理员账号绑定可用邮箱。 + +### 2. 网易云全局账号 + +后台 →「全局 Cookie」→ 生成二维码 → 用网易云 App 扫码登录。 +全局账号负责默认解析与下载,黑胶/SVIP 账号可解析更高音质。 + +### 3. SMTP 邮箱(开启用户注册必须配置) + +后台 →「邮箱注册管理」→ 填写: + +| 项 | 说明(以 QQ 邮箱为例) | +| ---- | ---- | +| SMTP 服务器 | `smtp.qq.com` | +| 端口 | `587` | +| 发件邮箱 | 你的 QQ 邮箱 | +| 邮箱密码/授权码 | QQ 邮箱「设置 → 账户 → 开启 SMTP」获取的**授权码**(非 QQ 密码) | + +保存后用「发送测试邮件」验证。 + +### 4. 公告与卡密 + +- 后台 →「公告设置」:编辑公告内容与弹出策略(每次登录弹出 / 仅一次 / 不弹出)。 +- 后台 →「次数卡密」:生成次数卡(张数、类型或自定义次数、前缀、备注、有效天数),用户在个人中心兑换充值。 + +--- + +## 五、站点机制说明 + +| 机制 | 说明 | +| ---- | ---- | +| 强制登录 | 所有解析 / 下载接口需登录;未登录弹窗引导注册/登录 | +| 按次计费 | 解析 / 试听 / 浏览免费;**下载扣次数**:单曲 1 次,打包按歌曲数(100 首歌单 = 100 次);失败不扣费;管理员不限 | +| 注册赠送 | 注册成功自动赠送 3 次下载次数 | +| 批量预检 | 打包下载前检查次数,不足可选择「仅下载前 N 首(集)」或取消 | +| 顶号机制 | 同一账号新设备登录后旧会话立即失效 | +| 防倒卖封禁 | 登录记录 IP 归属省份,24h 内 ≥3 个不同省份自动封禁;管理员可手动封禁 / 解封 | +| 音频防护 | 音频直链经服务器签名代理(HMAC + 36 分钟时效),真实 CDN 链接不暴露 | +| 日志审计 | 成功 / 失败日志 10 条每页分页,可清除;记录封禁、顶号、扣费等关键事件(不含普通登录注册) | + +--- + +## 六、数据持久化 + +`docker-compose.yml` 已配置挂载,**重建容器数据不丢失**: + +| 宿主机路径 | 容器路径 | 内容 | +| ---- | ---- | ---- | +| `./cookie.txt` | `/app/cookie.txt` | 全局网易云 Cookie | +| `./user_cookie.txt` | `/app/user_cookie.txt` | 游客个人 Cookie(兼容) | +| `./downloads/` | `/app/downloads` | 下载文件 | +| `./data/` | `/app/data` | 站点数据库 `site.db`(用户/卡密/日志/公告)与自动备份 | + +> 建议定期在 后台 →「数据库管理」下载备份;更换服务器时将整个目录拷贝即可完整迁移。 + +--- + +## 七、常用运维命令 + +```bash +# 启动 / 重建(代码更新后) +docker compose up -d --build + +# 查看运行日志 +docker logs -f netease_url-netease-url-1 + +# 重启 +docker compose restart + +# 停止 +docker compose down # 数据保留(挂载卷) +``` + +--- + +## 八、常见问题 + +**Q1:端口被占用(5001 already allocated)** +`docker ps` 查看占用容器并 `docker rm -f 容器名`,或修改 `docker-compose.yml` 端口映射。 + +**Q2:页面样式/功能异常** +浏览器 **Ctrl+F5** 强制刷新缓存(前端资源带版本号,一般自动生效)。 + +**Q3:收不到邮箱验证码** +检查 SMTP 配置与授权码;确认服务器出站 587 端口可用;查看失败日志定位报错。 + +**Q4:用户反馈「已被封禁」** +后台 →「用户管理」查看该用户最近登录省份与封禁原因,确认无误后可解封。 + +**Q5:忘记管理员密码** +```bash +docker exec -it netease_url-netease-url-1 python3 -c " +import auth_system as a +# 若实例管理员已由 admin 更名(如 qfbsh),请把 'admin' 替换为实际用户名 +a.set_user_password(a.get_user_by_name('qfbsh')['id'], '新密码至少8位') +print('已重置')" +``` + +**Q6:数据库损坏 / 恢复** +后台 →「数据库管理」→ 上传历史备份文件即可覆盖恢复。 + +---