diff --git a/README_私有包警告.txt b/README_私有包警告.txt deleted file mode 100644 index 03806d3..0000000 --- a/README_私有包警告.txt +++ /dev/null @@ -1,5 +0,0 @@ -⚠️ 私有部署包——严禁外传、严禁上传网盘/代码仓库/发给任何人。 -本包包含全部敏感数据:cookie.txt(网易云 SVIP 登录态)、data/site.db(用户库)、 -data/secret.key(会话与音频签名根)、交接文档.md(含管理员凭据)。 -泄露后果与应急流程见交接文档 §4/§5.7 A2/C1/C2/E4。 -(本文件由 _mkpkg.py 私有模式自动生成;对外分发包由 --public 生成并剔除全部敏感物。) diff --git a/_mkpkg.py b/_mkpkg.py index aa31ba4..00d29bf 100644 --- a/_mkpkg.py +++ b/_mkpkg.py @@ -3,10 +3,10 @@ - 默认:生成 owner 私有部署包(含 data/site.db、data/secret.key、cookie.txt, 供 owner 自己的服务器迁移使用,严禁对外分发) -- --public:生成可对外分发的干净包(F01:剔除全部敏感数据——数据库/密钥/网易云 Cookie/ +- --public:生成可对外分发的干净包(剔除全部敏感数据——数据库/密钥/网易云 Cookie/ 日志),包内另附 _pkg_ok.txt 标记 -F67:源/目标路径基于脚本自身位置推导,不再写死原作者桌面绝对路径。 +源/目标路径基于脚本自身位置推导,不再写死原作者桌面绝对路径。 """ import glob import os @@ -22,7 +22,7 @@ ignore = shutil.ignore_patterns( 'venv*', '__pycache__', 'downloads', 'release*', '*.log', 'music_api.log*', '_smoke_test_downloader.py', 'backups', '云村小助手-网站开关.bat', '_audit_work*', - # 第 87 条:.zcode 为 AI 工具工作目录(内部规划/会话文件),不得进任何分发包 + # .zcode 为 AI 工具工作目录(内部规划/会话文件),不得进任何分发包 '.zcode*', # Cookie 备份文件(qr_login._backup_cookie 产生,含真实 MUSIC_U): # 'cookie.*.txt' 匹配 cookie.prewrite/backup/logout.*.txt 但不匹配 cookie.txt 本身 @@ -42,14 +42,8 @@ for _pat in ('cookie.*.txt', 'user_cookie.*.txt', 'music_api.log*'): pass if public: - # F01:公开包必须剔除的敏感/私有内容(P1-1:补齐后产生的审计文档与内部引导文件—— - # 修复台账含全部 117 项漏洞细节,新会话引导含内部路径与运维细节,均不得对外) + # 公开包必须剔除的敏感数据:站点库/密钥、网易云 Cookie、运行日志,以及私有包标记文件 for rel in ('data', 'cookie.txt', 'user_cookie.txt', 'music_api.log', - '交接文档.md', '待修问题清单_三轮汇总.md', - '修复验收台账_F01-F117.md', '新会话引导.md', - '待修问题清单_第四轮深挖.md', - # 第 87 条:第五轮深挖清单同样含全部漏洞细节,公开包必须剔除 - '待修问题清单_第五轮深挖.md', '_pkg_ok.txt',): p = os.path.join(DST, rel) try: @@ -68,9 +62,9 @@ if public: pass marker = 'public-clean package (no secrets)' else: - # 第 100 条(运维审计):私有包严禁携带《README_公开包说明.txt》——它宣称 - # "不含任何敏感数据/由 --public 生成",而私有包含 cookie.txt/site.db/secret.key - # 与交接文档(明文管理员凭据),接手运维按该 README 认知会整包外发造成泄露。 + # 私有包严禁携带《README_公开包说明.txt》——它宣称 + # "不含任何敏感数据/由 --public 生成",而私有包含 cookie.txt/site.db/secret.key, + # 接手运维按该 README 认知会整包外发造成泄露。 # 私有包写入显式警告文件替代。 _p = os.path.join(DST, 'README_公开包说明.txt') if os.path.exists(_p): @@ -82,8 +76,9 @@ else: _f.write( '⚠️ 私有部署包——严禁外传、严禁上传网盘/代码仓库/发给任何人。\n' '本包包含全部敏感数据:cookie.txt(网易云 SVIP 登录态)、data/site.db(用户库)、\n' - 'data/secret.key(会话与音频签名根)、交接文档.md(含管理员凭据)。\n' - '泄露后果与应急流程见交接文档 §4/§5.7 A2/C1/C2/E4。\n' + 'data/secret.key(会话与音频签名根)。\n' + '一旦泄露,请立即轮换 data/secret.key(全部存量会话与音频直链随即失效)、\n' + '吊销 cookie.txt 中的网易云登录态,并核查 data/site.db 是否已被第三方获取。\n' '(本文件由 _mkpkg.py 私有模式自动生成;对外分发包由 --public 生成并剔除全部敏感物。)\n') marker = None diff --git a/auth_system.py b/auth_system.py deleted file mode 100644 index 2f32112..0000000 --- a/auth_system.py +++ /dev/null @@ -1,1546 +0,0 @@ -# -*- coding: utf-8 -*- -"""站点认证与管理系统 - -提供网站级用户体系: -- 用户注册(邮箱验证码)/ 登录 / 登出 / 注册即赠下载次数(第 98 条口径:试用领取已并入注册) -- 按次计费(一首歌 = 1 次)、卡密(次数卡)充值 -- 管理员后台:用户管理 / SMTP 配置 / 卡密生成 / 公告 / 日志 / 数据库备份 -- 接口装饰器:login_required(强制登录 + 按次扣费)/ admin_required(仅管理员) - -数据存储:SQLite(data/site.db),会话:Flask 签名 Cookie(secret_key 持久化) -""" - -import base64 -import hashlib -import json -import math -import os -import re -import secrets -import shutil -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 pathlib import Path -from typing import Any - -# ============ 路径与常量 ============ -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]: - """单语句读:**不持锁**。 - - WAL 模式下每个连接读到一致性快照,读与写互不阻塞;旧实现对读也加 _DB_LOCK, - 等于把全部读串行化并被写挡住。此处仅放开单语句读——写事务(_x 与各 - `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) - 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) -> sqlite3.Row | None: - # 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。 - 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) -> sqlite3.Row | None: - rows = _q('SELECT * FROM users WHERE username = ?', (username,)) - return rows[0] if rows else None - - -def get_user_by_email(email: str) -> sqlite3.Row | None: - """按邮箱查用户(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) -> sqlite3.Row | None: - rows = _q('SELECT * FROM users WHERE id = ?', (uid,)) - return rows[0] if rows else None - - -def verify_login(username: str, password: str) -> sqlite3.Row | None: - """校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号—— - 状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。 - 原实现把禁用账号提前过滤成 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 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 urllib.parse as _ul - - from core import sync_http as _rq - 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 '' - - -# ============ 使用次数(按次计费:一首歌 = 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) -> int | None: - """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) -> dict[str, Any] | None: - 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 - - -# ============ 日志 ============ - - -# 日志裁剪计数器(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 条纠正)。""" - import hashlib as _hl - - from cryptography.fernet import Fernet - 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 # 存量明文:透明兼容,下次保存时升级为密文 - import hashlib as _hl - - from cryptography.fernet import Fernet - 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') - # email 库推荐写法:Header 对象由 Generator._fold 的 Header 分支做 RFC 2047 编码 - # (typeshed 把 Message.__setitem__ 的值参数收窄成 str,属桩过窄;运行时已实测正确) - msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment] - 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) -> tuple[list[dict[str, Any]], int]: - """卡密列表(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) -> Path | None: - 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('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None - # 第 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/auth_system/__init__.py b/auth_system/__init__.py new file mode 100644 index 0000000..33d4df5 --- /dev/null +++ b/auth_system/__init__.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +"""站点认证与管理系统 + +提供网站级用户体系: +- 用户注册(邮箱验证码)/ 登录 / 登出 / 注册即赠下载次数(试用领取已并入注册) +- 按次计费(一首歌 = 1 次)、卡密(次数卡)充值 +- 管理员后台:用户管理 / SMTP 配置 / 卡密生成 / 公告 / 日志 / 数据库备份 +- 接口装饰器:login_required(强制登录 + 按次扣费)/ admin_required(仅管理员) + +数据存储:SQLite(data/site.db),会话:Flask 签名 Cookie(secret_key 持久化) +""" + +# 兼容门面:实现已按职责拆分到同包各子模块,此处原样再导出。 +# 用 `__all__` 声明再导出(ruff F401 与 pyright 均据此认定「有意再导出」), +# 使既有调用点(auth_system.xxx / from auth_system import Y)零改动。 + +from .backup import ( + _BACKUP_ERR_TS, + _BACKUP_NAME_LOCK, + _auto_backup_loop, + _backup_is_valid_db, + _backup_thread_started, + _before_restore_hook, + backup_path, + create_backup, + delete_backup, + list_backups, + prune_backups, + register_before_restore_hook, + restore_backup, + start_auto_backup, +) +from .cards import ( + delete_card, + generate_cards, + list_cards, + redeem_card, + set_card_disabled, +) +from .credits import ( + add_credits, + charge_credits, + format_credits, + refund_credits, + reserve_credits, +) +from .db import ( + _DB_LOCK, + BACKUP_DIR, + DATA_DIR, + DB_PATH, + DEFAULT_ADMIN_USER, + DEFAULT_REGISTER_CREDITS, + SECRET_KEY_PATH, + SESSION_KEY, + _q, + _x, + get_conn, + get_secret_key, + init_db, +) +from .geo import ( + _PROVINCE_CACHE, + PROVINCE_BAN_LIMIT, + PROVINCE_WINDOW, + _is_private_ip, + query_ip_province, + record_login_location, +) +from .ledger import ( + ledger_cleanup_settled, + ledger_get, + ledger_open, + ledger_recover_orphans, + ledger_rename, + ledger_settle, + ledger_supplement, + reserve_and_open, +) +from .logs import ( + _LOG_WRITE_COUNTER, + add_log, + clear_logs, + query_logs, +) +from .mail import ( + _CODE_EMAIL_TEMPLATES, + _CODE_FAILS, + _CODE_FAILS_MAX, + _CODE_PURPOSES, + _EMAIL_SENT_24H, + _SMTP_ENC_PREFIX, + _code_fails_trim, + _smtp_connect, + _smtp_password_decrypt, + _smtp_password_encrypt, + check_verify_code, + get_smtp_config, + restore_verify_code, + save_smtp_config, + send_email, + send_verify_code, + smtp_configured, +) +from .security import ( + _PBKDF2_ITERS, + _hash, + _hash_new, + _normalize_email, + _verify_password, +) +from .settings import ( + get_announcement, + get_setting, + save_announcement, + set_setting, +) +from .users import ( + _last_province, + _user_public, + ban_user, + create_user, + delete_user, + device_used, + get_user_by_email, + get_user_by_id, + get_user_by_name, + list_users, + register_ip_exceeded, + reset_password, + set_user_credits, + set_user_netease_cookie, + set_user_password, + set_user_status, + unban_user, + verify_login, +) + +__all__ = [ + 'BACKUP_DIR', + 'DATA_DIR', + 'DB_PATH', + 'DEFAULT_ADMIN_USER', + 'DEFAULT_REGISTER_CREDITS', + 'PROVINCE_BAN_LIMIT', + 'PROVINCE_WINDOW', + 'SECRET_KEY_PATH', + 'SESSION_KEY', + '_BACKUP_ERR_TS', + '_BACKUP_NAME_LOCK', + '_CODE_EMAIL_TEMPLATES', + '_CODE_FAILS', + '_CODE_FAILS_MAX', + '_CODE_PURPOSES', + '_DB_LOCK', + '_EMAIL_SENT_24H', + '_LOG_WRITE_COUNTER', + '_PBKDF2_ITERS', + '_PROVINCE_CACHE', + '_SMTP_ENC_PREFIX', + '_auto_backup_loop', + '_backup_is_valid_db', + '_backup_thread_started', + '_before_restore_hook', + '_code_fails_trim', + '_hash', + '_hash_new', + '_is_private_ip', + '_last_province', + '_normalize_email', + '_q', + '_smtp_connect', + '_smtp_password_decrypt', + '_smtp_password_encrypt', + '_user_public', + '_verify_password', + '_x', + 'add_credits', + 'add_log', + 'backup_path', + 'ban_user', + 'charge_credits', + 'check_verify_code', + 'clear_logs', + 'create_backup', + 'create_user', + 'delete_backup', + 'delete_card', + 'delete_user', + 'device_used', + 'format_credits', + 'generate_cards', + 'get_announcement', + 'get_conn', + 'get_secret_key', + 'get_setting', + 'get_smtp_config', + 'get_user_by_email', + 'get_user_by_id', + 'get_user_by_name', + 'init_db', + 'ledger_cleanup_settled', + 'ledger_get', + 'ledger_open', + 'ledger_recover_orphans', + 'ledger_rename', + 'ledger_settle', + 'ledger_supplement', + 'list_backups', + 'list_cards', + 'list_users', + 'prune_backups', + 'query_ip_province', + 'query_logs', + 'record_login_location', + 'redeem_card', + 'refund_credits', + 'register_before_restore_hook', + 'register_ip_exceeded', + 'reserve_and_open', + 'reserve_credits', + 'reset_password', + 'restore_backup', + 'restore_verify_code', + 'save_announcement', + 'save_smtp_config', + 'send_email', + 'send_verify_code', + 'set_card_disabled', + 'set_setting', + 'set_user_credits', + 'set_user_netease_cookie', + 'set_user_password', + 'set_user_status', + 'smtp_configured', + 'start_auto_backup', + 'unban_user', + 'verify_login', +] diff --git a/auth_system/backup.py b/auth_system/backup.py new file mode 100644 index 0000000..6f2f725 --- /dev/null +++ b/auth_system/backup.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +"""数据库备份、恢复与自动备份线程。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import math +import os +import shutil +import sqlite3 +import sys +import threading +import time +from pathlib import Path +from typing import Any + +import auth_system # 路径常量以包属性为唯一来源(动态读取,便于测试/运维覆盖) + +from .db import ( + _DB_LOCK, + get_conn, + init_db, +) +from .settings import ( + get_setting, + set_setting, +) + +# ============ 数据库备份 ============ + + +# 备份命名专用锁:create_backup 不再持有全局 _DB_LOCK(不阻塞写请求), +# 但同秒并发的两次备份仍需保证文件名唯一 +_BACKUP_NAME_LOCK = threading.Lock() + + +def create_backup() -> str: + """创建备份(唯一文件名——同秒冲突自动追加序号,不再互相覆盖)。 + + WAL 模式下用独立只读连接走 SQLite 在线备份 API:拿到的是一致性快照, + 且不持有 _DB_LOCK——原实现备份期间阻塞全站写请求,大库时会卡住所有用户。""" + auth_system.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 (auth_system.BACKUP_DIR / name).exists(): + name = f'{base}-{k}.db' + k += 1 + # 占位创建目标文件(锁内),保证并发下同秒命名仍唯一 + dest = sqlite3.connect(str(auth_system.BACKUP_DIR / name)) + conn = get_conn() + try: + try: + conn.backup(dest) + finally: + dest.close() + # 任何备份产生后统一裁剪(手动/自动/上传同配额) + try: + prune_backups(20, 5) + except Exception: + pass + return name + except Exception: + # 备份失败:清掉占位文件,不留半个坏备份 + try: + (auth_system.BACKUP_DIR / name).unlink(missing_ok=True) + except OSError: + pass + raise + finally: + conn.close() + + +def list_backups() -> list[dict[str, Any]]: + """备份列表(按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)""" + if not auth_system.BACKUP_DIR.exists(): + return [] + out = [] + for p in auth_system.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) -> Path | None: + name = Path(name).name # 防目录穿越 + p = auth_system.BACKUP_DIR / name + return p if p.exists() and p.suffix == '.db' else None + + +def _backup_is_valid_db(src: Path) -> bool: + """深度校验备份文件: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 + + +# 恢复前静止钩子——由应用层注册(取消全部在途下载任务),避免 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: + """恢复备份(先验证、留回滚、后替换、失败自动回滚,源备份文件保留不移动) + 恢复前先通过钩子取消全部在途任务(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 = auth_system.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(auth_system.DB_PATH), str(safety)) + # 2) 源备份复制到临时文件(不动源备份本身) + tmp = auth_system.DB_PATH.with_suffix('.db.restore_tmp') + shutil.copy2(str(src), str(tmp)) + # 3) 原子替换 + 立即校验 + os.replace(str(tmp), str(auth_system.DB_PATH)) + try: + init_db() + except Exception: + # 替换后初始化失败:回滚安全副本,恢复原库 + shutil.copy2(str(safety), str(auth_system.DB_PATH)) + init_db() + raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None + # 裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写 + try: + prune_backups(20, 5) + except Exception: + pass + + +def delete_backup(name: str) -> bool: + p = backup_path(name) + # 允许删除 pre_restore_ 安全副本(配额裁剪已保证至少保留 + # 最近 5 份;原实现一刀切拒删导致恢复频繁时无界累积且管理员手动也删不掉) + if p: + p.unlink() + return True + return False + + +# 自动备份线程 +_backup_thread_started = False + +_BACKUP_ERR_TS = {'ts': 0.0, 'n': 0} # 备份失败限频记录(失败不能零痕迹) + + +def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None: + """备份配额裁剪(从自动备份循环抽出为独立函数)—— + 非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: + (auth_system.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) + # 非有限值(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 份(按修改时间,不按文件名) + # pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份 + prune_backups(20, 5) + except Exception: + # 定时备份失败绝不能静默(磁盘满/权限问题时 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() + # 仅在真实启动成功后置位,失败可重试 + _backup_thread_started = True diff --git a/auth_system/cards.py b/auth_system/cards.py new file mode 100644 index 0000000..e35de27 --- /dev/null +++ b/auth_system/cards.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""次数卡密生成 / 兑换 / 管理。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import secrets +import time +from typing import Any + +from .db import ( + _DB_LOCK, + _q, + get_conn, +) + +# ============ 卡密 ============ + + +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) -> tuple[list[dict[str, Any]], int]: + """卡密列表(支持分页 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]: + """兑换卡密,返回 (卡密次数, 新剩余次数)(标记已用与加余额同事务,失败整体回滚不吞卡)""" + 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() diff --git a/auth_system/credits.py b/auth_system/credits.py new file mode 100644 index 0000000..1943a53 --- /dev/null +++ b/auth_system/credits.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""下载次数增减与格式化。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import time + +from .db import ( + _DB_LOCK, + _x, + get_conn, +) +from .users import ( + get_user_by_id, + get_user_by_name, +) + +# ============ 使用次数(按次计费:一首歌 = 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} 次' diff --git a/auth_system/db.py b/auth_system/db.py new file mode 100644 index 0000000..7f035ad --- /dev/null +++ b/auth_system/db.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +"""路径常量、连接与建表。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import os +import secrets +import sqlite3 +import sys +import threading +import time +from pathlib import Path + +import auth_system # 路径常量以包属性为唯一来源(动态读取,便于测试/运维覆盖) + +from .security import ( + _hash_new, + _normalize_email, +) + +# ============ 路径与常量 ============ +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 死常量已删) + + +_DB_LOCK = threading.RLock() + + +# ============ 数据库 ============ + + +def get_conn() -> sqlite3.Connection: + auth_system.DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(auth_system.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]: + """单语句读:**不持锁**。 + + WAL 模式下每个连接读到一致性快照,读与写互不阻塞;旧实现对读也加 _DB_LOCK, + 等于把全部读串行化并被写挡住。此处仅放开单语句读——写事务(_x 与各 + `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: + """初始化表结构与默认数据""" + auth_system.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)') + # 计费账本(任务实扣/交付/退款/终态的持久化唯一记录,结算幂等) + 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 + )''') + # 轻量迁移:新增列。仅"列已存在"可忽略(其余迁移失败必须暴露,不能吞掉) + _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(): + # 磁盘/锁/结构等真实故障:抛出,禁止静默带病初始化 + raise + # 非空邮箱唯一(部分索引,不约束历史空串)。存量库若已有重复非空邮箱, + # 索引创建失败仅告警不阻断启动(查重逻辑在注册/建号路由另有兜底) + try: + conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ' + 'ON users(email) WHERE email != \'\'') + except sqlite3.IntegrityError: + print('[警告] users.email 存在重复的非空邮箱,唯一索引未创建(请人工清理后重启)') + # 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']} 的邮箱归一化后与他人重复,保留原值") + # 初始管理员(新装不再使用公开默认口令,随机生成并写入日志一次) + # 引导条件改为"不存在任何管理员角色"——按用户名查会在 + # 管理员改名(如 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) + 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()))) + # ——非 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: + """获取(或生成)持久化的会话签名密钥(原子写入 + 失败显式告警)""" + key = '' + try: + if auth_system.SECRET_KEY_PATH.exists(): + key = auth_system.SECRET_KEY_PATH.read_text(encoding='utf-8').strip() + if key: + return key + except OSError: + pass + new_key = secrets.token_hex(32) + try: + auth_system.SECRET_KEY_PATH.parent.mkdir(parents=True, exist_ok=True) + # 先写临时文件再原子替换,避免部分写入留下损坏密钥 + tmp = auth_system.SECRET_KEY_PATH.with_suffix('.key.tmp') + tmp.write_text(new_key, encoding='utf-8') + os.replace(str(tmp), str(auth_system.SECRET_KEY_PATH)) + # 并发初始化者以"替换后文件里的值"为准(最后写入者胜出但全员一致) + final = auth_system.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 diff --git a/auth_system/geo.py b/auth_system/geo.py new file mode 100644 index 0000000..2511271 --- /dev/null +++ b/auth_system/geo.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""登录 IP 归属地探测与异地自动封禁。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import json +import sqlite3 +import time + +from .db import ( + _x, +) +from .logs import ( + add_log, +) +from .users import ( + ban_user, +) + +_PROVINCE_CACHE: dict[str, str] = {} + + +def _is_private_ip(ip: str) -> bool: + """内网/回环地址判断(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 query_ip_province(ip: str) -> str: + """查询 IP 归属省份(内网/失败返回空串),带进程内缓存 + 改用官方路径 /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 urllib.parse as _ul + + from core import sync_http as _rq + 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() + # 查询失败(超时/限流/垃圾 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 diff --git a/auth_system/ledger.py b/auth_system/ledger.py new file mode 100644 index 0000000..1c075fe --- /dev/null +++ b/auth_system/ledger.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +"""预扣账本(开账 / 改名 / 补扣 / 结算 / 对账)。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import time +from typing import Any + +from .db import ( + _DB_LOCK, + _q, + _x, + get_conn, +) +from .logs import ( + add_log, +) + +# ============ 计费账本(实扣、交付、退款、终态的持久化唯一记录) ============ + + +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) -> int | None: + """预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。 + 足额:扣减+开账并返回剩余;不限/管理员:开账(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: + """把请求级临时账本行改名为正式 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 后按实际数量补扣"场景: + 原子补扣用户余额并同步累加账本 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) -> dict[str, Any] | None: + 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(不看结算时的账户状态) + - 以账本 reserved 为准计算应退;口径更正:-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 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受); + # 原注释"照样退"与实现相反,已更正为此口径。 + 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: + """启动对账:任务表在内存,进程重启后账本中仍 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: + """清理已结算的历史账本行(每任务一行、只增不删会让表无限膨胀; + 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 diff --git a/auth_system/logs.py b/auth_system/logs.py new file mode 100644 index 0000000..5113f1f --- /dev/null +++ b/auth_system/logs.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""后台审计日志。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import time + +from .db import ( + _DB_LOCK, + _q, + _x, + get_conn, +) + +# ============ 日志 ============ + + +# 日志裁剪计数器(原实现用 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() diff --git a/auth_system/mail.py b/auth_system/mail.py new file mode 100644 index 0000000..5576fd6 --- /dev/null +++ b/auth_system/mail.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +"""SMTP 配置、发信与邮箱验证码。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import base64 +import re +import secrets +import smtplib +import sqlite3 +import ssl +import time +from email.header import Header +from email.mime.text import MIMEText +from email.utils import formataddr +from typing import Any + +from .db import ( + _DB_LOCK, + _q, + _x, + get_conn, + get_secret_key, +) +from .security import ( + _normalize_email, +) + +# ============ 邮箱验证码 / SMTP ============ + +# 验证码猜错计数(内存态,重启清零可接受)——同邮箱累计错 5 次作废验证码 +# 键='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:' # 信封加密标记前缀(存量明文读取时透明升级) + + +def _smtp_password_encrypt(plaintext: str) -> str: + """用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码。 + + 原实现明文落库、并随数据库备份一起导出,等于扩大泄露半径。""" + import hashlib as _hl + + from cryptography.fernet import Fernet + 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 # 存量明文:透明兼容,下次保存时升级为密文 + import hashlib as _hl + + from cryptography.fernet import Fernet + 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': ''} + # 读取时解密密码——调用方(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) + # 显式校验证书与主机名的 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: + """收件人格式校验下沉到本入口——未来任何新调用方漏校验也不再成为 + 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') + # email 库推荐写法:Header 对象由 Generator._fold 的 Header 分支做 RFC 2047 编码 + # (typeshed 把 Message.__setitem__ 的值参数收窄成 str,属桩过窄;运行时已实测正确) + msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment] + 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 = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受) + + +# 验证码用途隔离——注册码不能用于找回密码,反之亦然(库行带 purpose, +# 消费/作废/还原均按用途匹配)。邮件主题与正文按用途区分,用户一眼可辨。 +# 新增 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 分钟有效)(先发信成功再落库;同用途旧码即时作废)。 +① 同邮箱 24h 硬上限 5 封(防邮件轰炸代理/发信配额消耗); + ② email 先归一化(Gmail +tag 别名剥离)——别名刷号被邮箱查重堵死。 + purpose 区分注册/找回(文案不同 + 库行用途隔离);24h 计数移到 + 发信成功之后(原实现 SMTP 失败也烧掉额度)。""" + if purpose not in _CODE_PURPOSES: + raise ValueError('验证码用途不正确') + email = _normalize_email(email or '') + # 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('邮箱服务未配置,请联系管理员') + # 同邮箱 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 + # 键只增不减(旧时间戳被过滤但空键留存)——顺手剔除过期 + # 空键并做容量上限,防慢速灌任意格式邮箱造成无界增长 + 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: + """校验验证码(单条 DELETE 原子消费,rowcount=1 才算通过,杜绝并发双读)。 + 成功返回剩余有效秒数(真值),失败返回 0。 + 同一邮箱累计错 5 次→该用途验证码作废(防 10 分钟窗口内挂机猜码)。 + 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。 + # 仅"不支持 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: + """注册流程后续步骤失败时还原已消费的验证码(第二轮:建号失败不应吞掉验证码)""" + 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() diff --git a/auth_system/security.py b/auth_system/security.py new file mode 100644 index 0000000..1a26580 --- /dev/null +++ b/auth_system/security.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""口令哈希常量与校验。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import hashlib +import secrets + +# 口令哈希升级。旧库 SHA-256(salt+pwd) 哈希在登录成功时透明升级为 PBKDF2 +_PBKDF2_ITERS = 100_000 + + +# ============ 密码与用户 ============ + + +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,格式 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 _normalize_email(email: str) -> str: + """:邮箱归一化——剥离 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 diff --git a/auth_system/settings.py b/auth_system/settings.py new file mode 100644 index 0000000..7fc3800 --- /dev/null +++ b/auth_system/settings.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""公告与站点设置。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import time +from typing import Any + +from .db import ( + _q, + _x, +) + +# ============ 公告 ============ + + +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' + # 版本号精确到毫秒,同秒多次保存也能区分(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))) diff --git a/auth_system/users.py b/auth_system/users.py new file mode 100644 index 0000000..46067f0 --- /dev/null +++ b/auth_system/users.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""用户增删改查、登录校验与状态。 + +(自 auth_system.py 拆出,行为与原实现一致。) +""" + +import json +import sqlite3 +import time +from typing import Any + +from .db import ( + _DB_LOCK, + _q, + _x, + get_conn, +) +from .security import ( + _hash_new, + _normalize_email, + _verify_password, +) + + +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) -> sqlite3.Row | None: + # 余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限" + try: + credits = int(credits) + except (TypeError, ValueError): + credits = 0 + if credits < -1: + credits = 0 + # 注册入口传 register_ip_limit=3 —— 同 IP 24h 注册数检查挪进 + # _DB_LOCK 事务内与 INSERT 原子完成,堵住"锁外检查+并发插入"的 TOCTOU 刷号; + # 管理建号与不限制场景传 0 关闭。超限抛 ValueError 由调用方转 400/429。 + 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: + # 设备赠送闸并入同一事务——锁外 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) -> sqlite3.Row | None: + rows = _q('SELECT * FROM users WHERE username = ?', (username,)) + return rows[0] if rows else None + + +def get_user_by_email(email: str) -> sqlite3.Row | None: + """按邮箱查用户(注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)""" + 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 register_ip_exceeded(ip: str, limit: int = 3, window: int = 86400) -> bool: + """同 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) -> sqlite3.Row | None: + rows = _q('SELECT * FROM users WHERE id = ?', (uid,)) + return rows[0] if rows else None + + +def verify_login(username: str, password: str) -> sqlite3.Row | None: + """校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号—— + 状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。 + 原实现把禁用账号提前过滤成 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: + # 存量 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: + """删除用户(卡密 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: + """重置密码(同时递增 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: + """解封账号,恢复正常(清空触发封禁的省份历史,否则下次登录立即再封)""" + return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,))) + + +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: + # 新哈希 + 递增 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'], + # 列表不再携带明文网易云 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 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/core/__init__.py b/core/__init__.py index 95058f1..b910367 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -"""框架无关的核心层(Flask → FastAPI 迁移期两边共用)。 +"""框架无关的核心层。 分层约定: - core.config —— 常量、配置类、路径(无框架依赖,可被任意层导入) - core.state —— 进程内运行时状态容器与锁(纯 threading/collections) - core.utils —— 纯函数工具(字符串/时间/歌词/SRT、短链展开) -- core.security —— 音频直链签名与校验(密钥由启动时注入,不依赖 Flask) +- core.security —— 音频直链签名与校验(密钥由启动时注入,不依赖具体框架) -本包**不得**导入 flask / fastapi,也不得持有路由或请求上下文相关知识, -以便同一套状态与工具在迁移期被新老两个应用同时复用。 +本包**不得**导入 fastapi,也不得持有路由或请求上下文相关知识, +以便同一套状态与工具被 web/ 应用层与各服务模块同时复用。 """ diff --git a/core/aiohttp_compat.py b/core/aiohttp_compat.py index b02c796..7cc8c75 100644 --- a/core/aiohttp_compat.py +++ b/core/aiohttp_compat.py @@ -6,7 +6,7 @@ `from core import aiohttp_compat as requests`,加 `await`、加 `async def` 即可, 其余 `.text/.json()/.status_code/.raise_for_status()` 语义保持不变。 -与 requests 的语义对齐点(很关键,迁移期不能变): +与 requests 的语义对齐点(很关键,不得改动): - 请求本身**不**因 4xx/5xx 抛异常;只有显式 `raise_for_status()` 才抛 `HTTPError` (上游风控 429/460 的重试/不重试判断依赖调用方读取 `status_code`,见 music_api.HTTPClient)。 - 网络异常/超时抛 `RequestException`(含 `Timeout`)。 diff --git a/core/async_bridge.py b/core/async_bridge.py index 94e6479..fb7c0a3 100644 --- a/core/async_bridge.py +++ b/core/async_bridge.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""同步 ↔ 异步桥(迁移期用)。 +"""同步 ↔ 异步桥。 -场景:部分调用方仍是同步代码(遗留 Flask 路由、线程池下载器),而服务层已异步。 +场景:部分调用方仍是同步代码(线程池下载器、后台任务线程),而服务层已异步。 `run_sync(coro)` 把协程提交到**专用后台事件循环线程**执行并阻塞等待结果, 使同步代码得以调用异步实现,无需把调用链一次性全部改异步。 diff --git a/core/config.py b/core/config.py index cfb81e0..342a9b9 100644 --- a/core/config.py +++ b/core/config.py @@ -1,35 +1,35 @@ # -*- coding: utf-8 -*- """集中配置与常量(框架无关)。 -自 main.py 原样迁出,语义不变:仅常量、配置类与路径定义。 -不导入 flask/fastapi,不持有任何运行时可变状态(运行时状态见 core.state)。 +仅常量、配置类与路径定义;分层重构未改变任何取值。 +不导入 fastapi,不持有任何运行时可变状态(运行时状态见 core.state)。 """ import os from dataclasses import dataclass -# 站点统一版本号(P3-12/P3-22:/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码) +# 站点统一版本号(/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码) APP_VERSION = '2.4.0' -# 交付结果保留期(P1-3/D-1:服务端与前端「领取」入口统一 35 分钟——前端入口保留 35 分钟, +# 交付结果保留期(/D-1:服务端与前端「领取」入口统一 35 分钟——前端入口保留 35 分钟, # 原 30 分钟会在最后 5 分钟让领取必 404)。终态任务记录、任务目录、下载缓存均按此回收。 RESULT_RETENTION_SECONDS = 35 * 60 @dataclass class Settings: - """服务配置类(原 main.APIConfig;迁移期保留 APIConfig 别名以兼容既有引用)""" + """服务配置类(保留 APIConfig 别名以兼容既有引用)""" host: str = '0.0.0.0' port: int = 5000 debug: bool = False downloads_dir: str = 'downloads' - # 第 98 条:max_file_size/request_timeout 两个架空字段已删除——请求体上限真实 + # 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 条:可经环境变量收敛同源(空值回落默认) + ).strip() or '*' # 可经环境变量收敛同源(空值回落默认) # 向后兼容别名:既有代码/文档沿用 APIConfig 名称 @@ -37,8 +37,8 @@ APIConfig = Settings # 游客个人登录使用的独立 Cookie 文件(与管理员全局 Cookie 分离)。 -# 第 100 条:这是有意的隔离——QRLoginClient 的默认值恰是全局 cookie.txt, -# 显式传参防误写全局文件;第 91 条起本站任何路径都不再写它(仅构造参数) +# 这是有意的隔离——QRLoginClient 的默认值恰是全局 cookie.txt, +# 显式传参防误写全局文件;起本站任何路径都不再写它(仅构造参数) USER_COOKIE_FILE = 'user_cookie.txt' @@ -47,10 +47,10 @@ 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 分钟供领取,容量闸只数在途任务——旧口径把 +# 终态任务要留表 35 分钟供领取,容量闸只数在途任务——旧口径把 # 终态记录计入 200 上限会把全站吞吐封顶在约 5.7 任务/分钟;终态另设更大的滞留 # 上限保内存有界(1000 ≈ 28 任务/分钟的领取窗口,远高于在途处理能力) MAX_TERMINAL_TASKS = 1000 @@ -60,7 +60,7 @@ BATCH_ZIP_CONCURRENCY = 3 # 音频直链多重防护(HMAC 签名 + 时效 + 代理流) -AUDIO_TOKEN_TTL = RESULT_RETENTION_SECONDS + 60 # 第 93 条(P2-4):= 36 分钟——原 30 分钟 +AUDIO_TOKEN_TTL = RESULT_RETENTION_SECONDS + 60 # = 36 分钟——原 30 分钟 # 大于交付/解析结果 35 分钟保留期 60 秒:交付窗口末段试听不再 403(旧 30 分钟口径矛盾已修) AUDIO_URL_KEYS = {'url', 'audioUrl'} AUDIO_HOST_MARKS = ('126.net', 'music.126.net', '.netease') diff --git a/core/db.py b/core/db.py index 657d05e..32a05e1 100644 --- a/core/db.py +++ b/core/db.py @@ -26,7 +26,7 @@ DATA_DIR = Path(os.environ.get('SITE_DATA_DIR', 'data')) DB_PATH = DATA_DIR / 'site.db' # 锁按事件循环隔离:asyncio.Lock 绑定创建它的 loop,跨 loop 复用会抛 -# "bound to a different event loop"(迁移期 FastAPI loop + 同步桥 loop + 测试各自 +# "bound to a different event loop"(应用主 loop + 同步桥 loop + 测试各自 # 的 loop 并存,必须分开持有)。 _LOOP_LOCKS: dict[tuple[int, str], asyncio.Lock] = {} diff --git a/core/http_client.py b/core/http_client.py index a0c961f..9ccb450 100644 --- a/core/http_client.py +++ b/core/http_client.py @@ -25,7 +25,7 @@ import aiohttp DEFAULT_TIMEOUT = 30 DEFAULT_MAX_BYTES = 8 * 1024 * 1024 -# 第 95 条(P2-6)/第 98 条口径:UA 与 Referer 由调用方传入(music_api.APIConstants) +# UA 与 Referer 由调用方传入(music_api.APIConstants) _EMPTY: Mapping[str, str] = {} @@ -109,7 +109,7 @@ class AsyncHTTPClient: async def _session_for_loop(self) -> aiohttp.ClientSession: """按事件循环隔离 session。 - 迁移期同一进程内可能有两个 loop(FastAPI 主 loop + 遗留 Flask 的同步桥 loop)。 + 同一进程内可能有两个 loop(应用主 loop + 同步桥的后台 loop)。 aiohttp 的 ClientSession 不能跨 loop 复用,故按 loop 分别持有。 """ key = id(asyncio.get_running_loop()) diff --git a/core/media.py b/core/media.py index 7466f34..bf7ac9e 100644 --- a/core/media.py +++ b/core/media.py @@ -2,7 +2,7 @@ """媒体相关的纯函数(文件大小/音质显示名/Cookie 串解析)——框架无关。 与 main.MusicAPIService 上的同名方法保持逐字一致的实现, -供 FastAPI 栈直接复用,避免两栈各写一份而漂移。 +供 web/ 应用层与服务模块直接复用,避免各写一份而漂移。 """ diff --git a/core/net.py b/core/net.py index 061f931..c0dae7f 100644 --- a/core/net.py +++ b/core/net.py @@ -2,15 +2,15 @@ """客户端 IP 解析工具(框架无关)。 从 auth_system 抽出:可信反代判定与 XFF/X-Real-IP 解析规则。 -Flask 侧原本经 request.headers 取头;FastAPI 侧由 web.ratelimit 传入 header 值调用, +调用方(web.ratelimit / web.deps)从请求头取值后传入, 故此处只保留纯函数(不依赖任何 Web 框架)。 """ def is_loopback_ip(ip: str) -> bool: - """可信反代判定(第 91 条 P2-4:从"所有内网源"收窄为仅回环地址)。 + """可信反代判定(从"所有内网源"收窄为仅回环地址)。 服务绑 0.0.0.0 时内网任意主机都可直连伪造 XFF/X-Real-IP 轮换绕过限流与 - 登录锁定(实测实锤);同机 Caddy 反代(127.0.0.1 → Flask)不受影响。""" + 登录锁定(实测实锤);同机 Caddy 反代(127.0.0.1 → 本服务)不受影响。""" try: import ipaddress addr = ipaddress.ip_address(ip) @@ -22,7 +22,7 @@ def is_loopback_ip(ip: str) -> bool: def trusted_proxy_ip(header_value: str) -> str: - """第 97 条(P2-1):从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。 + """从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。 最右段是可信反代(Caddy)追加的直连方地址;最左段与 X-Real-IP 均可被客户端 伪造(Caddy 默认不改写客户端自带的 X-Real-IP),采信它们等于给限流/锁定/省份 记录开了伪造口。无有效值返回空串,调用方回落 remote_addr。""" diff --git a/core/repo.py b/core/repo.py index a396195..ddbb53c 100644 --- a/core/repo.py +++ b/core/repo.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -"""站点数据异步读写仓储(FastAPI 栈用)。 +"""站点数据异步读写仓储。 直接读写 auth_system 建立的同一份 SQLite(同一 DB_PATH / 同一 schema), 但走 core.db 的 aiosqlite 异步层——读并发、写串行、WAL 读写同时进行。 设计约束:本模块**不改动** auth_system 既有 schema,只做读写;字段口径与 -auth_system 保持一致(键名、默认值)。迁移期两条栈共存、共库。 +auth_system 保持一致(键名、默认值),读写同一份库文件。 """ from typing import Any diff --git a/core/security.py b/core/security.py index 5553a62..289d05d 100644 --- a/core/security.py +++ b/core/security.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """音频直链多重防护(HMAC 签名 + 时效 + 代理流)——框架无关。 -自 main.py 原样迁出,唯一改动是把密钥来源从 Flask 的 `app.secret_key` -改为**启动时注入**(`init_secret_key`),从而解除对本模块对 Flask app 的依赖; -签名/校验算法与口径保持逐字一致,迁移期新旧应用共用同一密钥即可互认 token。 +密钥来源为**启动时注入**(`init_secret_key`),不依赖任何 Web 框架; +签名与校验算法逐字保持与历史实现一致, +故存量客户端已持有的直链 token 仍然有效。 """ import base64 diff --git a/core/session.py b/core/session.py index c09c404..6930005 100644 --- a/core/session.py +++ b/core/session.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -"""Flask 会话 Cookie 与 Starlette/FastAPI 的互操作。 +"""Flask 兼容的会话 Cookie 编解码(itsdangerous 签名格式)。 Flask 默认会话即 itsdangerous 签名 Cookie(`session` cookie,格式 `payload.timestamp.hmac`, -payload 为 compact JSON、值类型化 `{"site_uid":{" b":i"4":".."}}`)。迁移期要让 -「Flask 签发的会话在 FastAPI 侧可读、FastAPI 刷新后可被 Flask 读回」, -本模块给出同一套编解码,避免两栈各签一份、互相看不懂。 +payload 为会话字典的 compact JSON;bytes/datetime/tuple 等类型会被 Flask 打上 +单字符标签再包一层)。本站早期由 Flask 签发会话,为让**存量 Cookie 无需重新登录即可继续使用**, +本模块复刻同一套编解码(同格式、同密钥、同摘要算法)。 -密钥同源:都取自 auth_system.get_secret_key()(data/secret.key)。 +密钥同源:取自 auth_system.get_secret_key()(data/secret.key)。 """ import base64 import hashlib diff --git a/core/state.py b/core/state.py index a9bd80b..d9853f5 100644 --- a/core/state.py +++ b/core/state.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- """进程内运行时状态(框架无关)。 -自 main.py 原样迁出:这些容器全部**原地修改**(永不重新绑定),因此 -`from core.state import DOWNLOAD_TASKS` 与 main 内的旧引用共享同一对象, +自原单体模块迁出:这些容器全部**原地修改**(永不重新绑定),因此 +`from core.state import DOWNLOAD_TASKS` 与各模块的引用共享同一对象, 搬移不改变任何并发行为。 注意:这些状态**仅存于进程内存**,重启即丢(任务记录/限流桶/统计/缓存); 持久数据(账本/用户/设置)在 SQLite(auth_system)。集中在此以便 -Flask/FastAPI 迁移期共用,并为后续封装成带锁的单例(State 对象)留出唯一入口。 +web/ 与各服务模块共用,并为后续封装成带锁的单例(State 对象)留出唯一入口。 依赖仅限标准库 threading/collections——任何下载器/HTTP 相关对象 (如批量打包信号量)不放在这里,随对应服务模块搬迁。 @@ -62,8 +62,8 @@ GLOBAL_MUSIC_U_CACHE = {'v': None, 'ts': 0.0} # 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称 SERVER_NICK_CACHE = {'nick': None, 'ts': 0.0} -SERVER_NICK_LOCK = threading.Lock() # 第 91 条(P3-9):刷新单飞锁,防并发重复打上游 -VIP_PROBE_LOCK = threading.Lock() # 第 100 条:音质探测单飞锁(有界等待) +SERVER_NICK_LOCK = threading.Lock() # 刷新单飞锁,防并发重复打上游 +VIP_PROBE_LOCK = threading.Lock() # 音质探测单飞锁(有界等待) # 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, # 公网下账号栏高频刷新会连累服务器账号触发风控。 diff --git a/core/utils.py b/core/utils.py index 40b9c20..58222f9 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""纯函数工具(框架无关,无 Flask/FastAPI 依赖)。 +"""纯函数工具(框架无关,无 Web 框架依赖)。 -自 main.py 原样迁出:安全整数解析、控制台输出兜底、SRT 时间轴、 +安全整数解析、控制台输出兜底、SRT 时间轴、 LRC→SRT 转换、字段参数解析、163cn.tv 短链展开(含私有缓存)。 """ @@ -42,7 +42,7 @@ def srt_time(t: float) -> str: def format_lyric(lrc: str, lyric_format: str) -> str: """将 LRC 歌词转换为指定格式(lrc / srt) - F37:时间轴先规范化再转 SRT——支持一行多时间标签(同句多时段各自成条)、 + 时间轴先规范化再转 SRT——支持一行多时间标签(同句多时段各自成条)、 分钟超两位([100:00])、乱序行(排序后区间不再倒挂)、纯文本行(无时间戳则原样保留)。""" if lyric_format != 'srt': return lrc @@ -111,7 +111,7 @@ def expand_shortlink(url: str) -> str: expanded = response.headers.get('Location', url) except Exception: expanded = url - # 第 97 条(P3):展开失败(Location 缺失/网络异常,expanded 原样等于 url)不写 + # 展开失败(Location 缺失/网络异常,expanded 原样等于 url)不写 # 缓存——旧口径一次网络抖动会让该分享链接在整个缓存存续期内必然解析失败 if expanded != url: with _SHORTLINK_CACHE_LOCK: diff --git a/music_api/__init__.py b/music_api/__init__.py new file mode 100644 index 0000000..cbce559 --- /dev/null +++ b/music_api/__init__.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""网易云音乐API模块 + +提供网易云音乐相关API接口的封装,包括: +- 音乐URL获取 +- 歌曲详情获取 +- 歌词获取 +- 搜索功能 +- 歌单和专辑详情 +- 二维码登录 +""" + +# 兼容门面:实现已按职责拆分到同包各子模块,此处原样再导出。 +# 用 `__all__` 声明再导出(ruff F401 与 pyright 均据此认定「有意再导出」), +# 使既有调用点(music_api.xxx / from music_api import Y)零改动。 + +from .api import ( + NeteaseAPI, +) +from .client import ( + _MAX_UPSTREAM_JSON_BYTES, + HTTPClient, + _as_dict, + _ensure_dict, +) +from .compat import ( + account_info, + album_detail, + album_subscribe, + artist_songs, + detect_vip_by_probe, + get_pic_url, + lyric_v1, + name_v1, + playlist_detail, + playlist_subscribe, + post_comment, + probe_levels, + qr_login, + search_music, + song_comments, + toplist_list, + url_v1, + user_playlists, +) +from .constants import ( + APIConstants, + QualityLevel, +) +from .cookies import ( + DEFAULT_COOKIE_FILE, + load_cookies, + parse_cookie_string, +) +from .crypto import ( + CryptoUtils, + WeapiCrypto, +) +from .errors import ( + APIException, +) +from .qr import ( + QRLoginManager, +) + +__all__ = [ + 'DEFAULT_COOKIE_FILE', + '_MAX_UPSTREAM_JSON_BYTES', + 'APIConstants', + 'APIException', + 'CryptoUtils', + 'HTTPClient', + 'NeteaseAPI', + 'QRLoginManager', + 'QualityLevel', + 'WeapiCrypto', + '_as_dict', + '_ensure_dict', + 'account_info', + 'album_detail', + 'album_subscribe', + 'artist_songs', + 'detect_vip_by_probe', + 'get_pic_url', + 'load_cookies', + 'lyric_v1', + 'name_v1', + 'parse_cookie_string', + 'playlist_detail', + 'playlist_subscribe', + 'post_comment', + 'probe_levels', + 'qr_login', + 'search_music', + 'song_comments', + 'toplist_list', + 'url_v1', + 'user_playlists', +] diff --git a/music_api/__main__.py b/music_api/__main__.py new file mode 100644 index 0000000..4264951 --- /dev/null +++ b/music_api/__main__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""命令行入口(python -m music_api)。""" + + + + + +if __name__ == "__main__": + # 测试代码 + print("网易云音乐API模块") + print("支持的功能:") + print("- 歌曲URL获取") + print("- 歌曲详情获取") + print("- 歌词获取") + print("- 音乐搜索") + print("- 歌单详情") + print("- 专辑详情") + print("- 二维码登录") diff --git a/music_api.py b/music_api/api.py similarity index 58% rename from music_api.py rename to music_api/api.py index be29bea..873e003 100644 --- a/music_api.py +++ b/music_api/api.py @@ -1,272 +1,31 @@ # -*- coding: utf-8 -*- -"""网易云音乐API模块 +"""网易云 API 主类。 -提供网易云音乐相关API接口的封装,包括: -- 音乐URL获取 -- 歌曲详情获取 -- 歌词获取 -- 搜索功能 -- 歌单和专辑详情 -- 二维码登录 +(自 music_api.py 拆出,行为与原实现一致。) """ -import asyncio import json -import os import time -import urllib.parse -from enum import Enum -from hashlib import md5 from random import randrange from typing import Any -from cryptography.hazmat.primitives import padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API) -from core.async_bridge import run_sync # 模块级同步兼容接口用的同步/异步桥 - -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) - - -def _as_dict(v: Any) -> dict[str, Any]: - """上游嵌套字段类型不稳定(dict / None / 列表都真实出现过):非 dict 一律回落空 dict。 - - 收窄成显式函数,避免满屏 `x if isinstance(x, dict) else {}` 三元式—— - 那种写法读起来累,且把字段来自 `user.get(...)`(类型 Any|None)时的收窄责任 - 散落在各处,容易被后来的改动改漏。 - """ - return v if isinstance(v, dict) else {} - - -class HTTPClient: - """HTTP客户端类""" - - @staticmethod - async 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 = await requests.post(url, headers=headers, cookies=request_cookies, - data={"params": params}, timeout=30) - if response.status_code >= 500 and attempt == 0: - await asyncio.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}") from e - except requests.RequestException as e: - last_err = e - if attempt == 0: - await asyncio.sleep(1.0) - continue - raise APIException(f"HTTP请求失败: {last_err}") - - @staticmethod - async 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 = await requests.post(url, headers=headers, cookies=request_cookies, - data={"params": params}, timeout=30) - if response.status_code >= 500 and attempt == 0: - await asyncio.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}") from e - except requests.RequestException as e: - last_err = e - if attempt == 0: - await asyncio.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 +from .client import ( + HTTPClient, + _as_dict, + _ensure_dict, +) +from .constants import ( + APIConstants, +) +from .crypto import ( + CryptoUtils, + WeapiCrypto, +) +from .errors import ( + APIException, +) class NeteaseAPI: @@ -308,7 +67,7 @@ class NeteaseAPI: response_text = await self.http_client.post_request(APIConstants.SONG_URL_V1, params, cookies) result = json.loads(response_text) - # R5-19:合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真 + # 合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真 if not isinstance(result, dict): raise APIException("获取歌曲URL失败: 响应格式异常") if result.get('code') != 200: @@ -332,7 +91,7 @@ class NeteaseAPI: """ try: data = {'c': json.dumps([{"id": song_id, "v": 0}])} - # R5-18:补齐 UA/Referer——本方法是全站唯一裸 UA 请求,风控/460 单点偏高 + # 补齐 UA/Referer——本方法是全站唯一裸 UA 请求,风控/460 单点偏高 headers = { 'User-Agent': APIConstants.USER_AGENT, 'Referer': APIConstants.REFERER @@ -342,7 +101,7 @@ class NeteaseAPI: response.raise_for_status() result = response.json() - # R5-19:合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真 + # 合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真 if not isinstance(result, dict): raise APIException("获取歌曲详情失败: 响应格式异常") if result.get('code') != 200: @@ -409,7 +168,7 @@ class NeteaseAPI: cookies: 用户cookies limit: 返回数量限制 search_type: 搜索类型 (1-歌曲, 10-专辑, 100-歌手, 1000-歌单) - offset: 分页偏移(F45:透传上游,翻页生效) + offset: 分页偏移(透传上游,翻页生效) Returns: 搜索结果列表 @@ -430,7 +189,7 @@ class NeteaseAPI: response.raise_for_status() result = response.json() - # R5-P3:非 dict 响应(null/数组)给明确错误 + # 非 dict 响应(null/数组)给明确错误 if not isinstance(result, dict): raise APIException("搜索失败: 响应格式异常") if result.get('code') != 200: @@ -440,7 +199,7 @@ class NeteaseAPI: items: list[dict[str, Any]] = [] if search_type == 1: - # 歌曲(P2-12:id 改 .get——上游单条残缺(下架/屏蔽)不再拖死整页搜索) + # 歌曲(id 改 .get——上游单条残缺(下架/屏蔽)不再拖死整页搜索) for item in result_data.get('songs', []): if not item.get('id'): continue @@ -453,7 +212,7 @@ class NeteaseAPI: '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 @@ -467,7 +226,7 @@ class NeteaseAPI: 'picUrl': item.get('picUrl') }) elif search_type == 100: - # 歌手(P2-12 同上) + # 歌手(同上) for item in result_data.get('artists', []): if not item.get('id'): continue @@ -481,7 +240,7 @@ class NeteaseAPI: 'picUrl': item.get('picUrl') }) elif search_type == 1000: - # 歌单(P2-12 同上) + # 歌单(同上) for item in result_data.get('playlists', []): if not item.get('id'): continue @@ -527,13 +286,13 @@ class NeteaseAPI: response.raise_for_status() result = response.json() - # R5-P2:非 dict 响应(null/数组)给明确错误 + # 非 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 —— + # 上游对不存在/私密/无权限歌单返回 code:200 + playlist:null —— # 显式转为 APIException(四条路由统一转"歌单不存在"语义,不再 500) playlist = result.get('playlist') or {} if not playlist: @@ -548,14 +307,14 @@ class NeteaseAPI: 'tracks': [] } - # 获取所有trackIds并分批获取详细信息(F72:明细批次的业务 code 也要校验, + # 获取所有trackIds并分批获取详细信息(明细批次的业务 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 的歌跳过不拖整批 + # trackIds 混入非数字脏 ID 时跳过该条(原实现 int 抛 ValueError + # 不在捕获列表,整歌单 500);明细缺 id 的歌跳过不拖整批 try: song_data = {'c': json.dumps([{'id': int(sid), 'v': 0} for sid in batch_ids if str(sid).isdigit()])} @@ -568,7 +327,7 @@ class NeteaseAPI: song_result = song_resp.json() if not isinstance(song_result, dict): - # 第 91 条(P2-8):上游 null/数组体按失败批次计,不让 AttributeError 失真 + # 上游 null/数组体按失败批次计,不让 AttributeError 失真 failed_batches += 1 continue if song_result.get('code') != 200: @@ -585,7 +344,7 @@ class NeteaseAPI: 'album': (song.get('al') or {}).get('name') or '未知专辑', 'picUrl': (song.get('al') or {}).get('picUrl') or '' }) - # F72:trackIds 数与实际拿到的不一致时显式标记"不完整",不冒充完整结果 + # trackIds 数与实际拿到的不一致时显式标记"不完整",不冒充完整结果 if len(info['tracks']) != len(track_ids): info['incomplete'] = True info['missing'] = len(track_ids) - len(info['tracks']) @@ -637,7 +396,7 @@ class NeteaseAPI: } for song in result.get('songs', []): - if not song.get('id'): # P2-12:残缺条目跳过 + if not song.get('id'): # 残缺条目跳过 continue info['songs'].append({ 'id': song['id'], @@ -868,7 +627,7 @@ class NeteaseAPI: Raises: APIException: API调用失败时抛出 - F63:超过 100 个歌单时按上游 more 标志续页(上限 20 页 = 2000 个), + 超过 100 个歌单时按上游 more 标志续页(上限 20 页 = 2000 个), 不再只取第一页就静默截断。 """ try: @@ -960,7 +719,7 @@ class NeteaseAPI: """ ladder = ['jymaster', 'jyeffect', 'sky', 'hires', 'lossless', 'exhigh', 'standard', 'dolby'] levels_ok = [] - # 第 100 条:探测总预算 60s——上游黑洞时 8 级串行最坏 ~16 分钟线程滞留, + # 探测总预算 60s——上游黑洞时 8 级串行最坏 ~16 分钟线程滞留, # 超预算即返回已测得的结果(调用方 5 分钟缓存内复用) _deadline = time.monotonic() + 60 for level in ladder: @@ -1072,7 +831,7 @@ class NeteaseAPI: } if comment_id: payload['commentId'] = comment_id - # 第 91 条(P2-5):官方 /weapi/comment 要求携带 csrf_token(取自 Cookie + # 官方 /weapi/comment 要求携带 csrf_token(取自 Cookie # 的 __csrf)——原实现缺失,与加密偏差叠加导致评论疑似从未成功 csrf = (cookies or {}).get('__csrf') or '' if csrf: @@ -1142,340 +901,3 @@ class NeteaseAPI: 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() - - async def generate_qr_key(self) -> str | None: - """生成二维码的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 = await 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}") from e - - async def create_qr_login(self) -> str | None: - """创建登录二维码并在控制台显示 - - Returns: - 成功返回unikey,失败返回None - """ - try: - import qrcode - - unikey = await 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 - - async 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 = await 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}") from e - - async def qr_login(self) -> str | None: - """完整的二维码登录流程 - - Returns: - 成功返回cookie字符串,失败返回None - """ - try: - unikey = await self.create_qr_login() - if not unikey: - return None - - while True: - code, cookies = await 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 - - await asyncio.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 run_sync(api.get_song_url(song_id, level, cookies)) - - -def name_v1(song_id: int) -> dict[str, Any]: - """获取歌曲详情(向后兼容)""" - api = NeteaseAPI() - return run_sync(api.get_song_detail(song_id)) - - -def lyric_v1(song_id: int, cookies: dict[str, str]) -> dict[str, Any]: - """获取歌词(向后兼容)""" - api = NeteaseAPI() - return run_sync(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 run_sync(api.search_music(keywords, cookies, limit, search_type, offset=offset)) - - -def toplist_list() -> list[dict[str, Any]]: - """获取官方榜单列表(向后兼容)""" - api = NeteaseAPI() - return run_sync(api.get_toplist()) - - -def artist_songs(artist_id: int) -> dict[str, Any]: - """获取歌手信息和热门歌曲(向后兼容)""" - api = NeteaseAPI() - return run_sync(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 run_sync(api.get_song_comments(song_id, limit, offset)) - - -def account_info(cookies: dict[str, str]) -> dict[str, Any] | None: - """获取当前账号信息(向后兼容)""" - api = NeteaseAPI() - return run_sync(api.get_account_info(cookies)) - - -def user_playlists(user_id: int, cookies: dict[str, str]) -> list[dict[str, Any]]: - """获取用户收藏/创建的歌单列表(向后兼容)""" - api = NeteaseAPI() - return run_sync(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 run_sync(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 run_sync(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 run_sync(api.subscribe_album(album_id, cookies, action)) - - -def post_comment(thread_id: str, content: str, action: int = 1, - comment_id: int | None = None, - cookies: dict[str, str] | None = None) -> dict[str, Any]: - """发送/回复歌曲评论(向后兼容)""" - api = NeteaseAPI() - return run_sync(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 run_sync(api.probe_levels(cookies, probe_song_id)) - - -def playlist_detail(playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]: - """获取歌单详情(向后兼容)""" - api = NeteaseAPI() - return run_sync(api.get_playlist_detail(playlist_id, cookies)) - - -def album_detail(album_id: int, cookies: dict[str, str]) -> dict[str, Any]: - """获取专辑详情(向后兼容)""" - api = NeteaseAPI() - return run_sync(api.get_album_detail(album_id, cookies)) - - -def get_pic_url(pic_id: int | None, size: int = 300) -> str: - """获取图片URL(向后兼容)""" - api = NeteaseAPI() - return api.get_pic_url(pic_id, size) - - -def qr_login() -> str | None: - """二维码登录(向后兼容)""" - manager = QRLoginManager() - return run_sync(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, 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_api/client.py b/music_api/client.py new file mode 100644 index 0000000..40695be --- /dev/null +++ b/music_api/client.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""HTTP 客户端与响应体收窄工具。 + +(自 music_api.py 拆出,行为与原实现一致。) +""" + +import asyncio +from typing import Any + +from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API) + +from .constants import ( + APIConstants, +) +from .errors import ( + APIException, +) + + +def _ensure_dict(result: Any, msg: str) -> dict[str, Any]: + """上游 200 但返回 null/数组等非 dict 体(风控/抖动)时统一前置校验""" + if isinstance(result, dict): + return result + raise APIException(msg) + + +def _as_dict(v: Any) -> dict[str, Any]: + """上游嵌套字段类型不稳定(dict / None / 列表都真实出现过):非 dict 一律回落空 dict。 + + 收窄成显式函数,避免满屏 `x if isinstance(x, dict) else {}` 三元式—— + 那种写法读起来累,且把字段来自 `user.get(...)`(类型 Any|None)时的收窄责任 + 散落在各处,容易被后来的改动改漏。 + """ + return v if isinstance(v, dict) else {} + + +class HTTPClient: + """HTTP客户端类""" + + @staticmethod + async def post_request(url: str, params: str, cookies: dict[str, str]) -> str: + """发送POST请求并返回文本响应。 + 网络异常/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 = await requests.post(url, headers=headers, cookies=request_cookies, + data={"params": params}, timeout=30) + if response.status_code >= 500 and attempt == 0: + await asyncio.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: + # 4xx(429/460 风控)不重试——原实现 HTTPError 落入 + # 下方 RequestException 分支被重试一次,与 意图相反 + raise APIException(f"HTTP请求失败: {e}") from e + except requests.RequestException as e: + last_err = e + if attempt == 0: + await asyncio.sleep(1.0) + continue + raise APIException(f"HTTP请求失败: {last_err}") + + @staticmethod + async def post_request_full(url: str, params: str, cookies: dict[str, str]) -> requests.Response: + """发送POST请求并返回完整响应对象(同 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 = await requests.post(url, headers=headers, cookies=request_cookies, + data={"params": params}, timeout=30) + if response.status_code >= 500 and attempt == 0: + await asyncio.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: + # 4xx 不重试(同上) + raise APIException(f"HTTP请求失败: {e}") from e + except requests.RequestException as e: + last_err = e + if attempt == 0: + await asyncio.sleep(1.0) + continue + raise APIException(f"HTTP请求失败: {last_err}") + + +# 上游 JSON 响应体上限——requests 已全量入内存,此处拦截的是 +# 异常/被劫持上游的超大 body 进入后续 .json() 解析与传播(音频/封面已有字节预算) +_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024 diff --git a/music_api/compat.py b/music_api/compat.py new file mode 100644 index 0000000..e55af55 --- /dev/null +++ b/music_api/compat.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""同步兼容函数接口(内部经 run_sync 桥接异步实现)。 + +(自 music_api.py 拆出,行为与原实现一致。) +""" + +from typing import Any + +from core.async_bridge import run_sync # 模块级同步兼容接口用的同步/异步桥 + +from .api import ( + NeteaseAPI, +) +from .qr import ( + QRLoginManager, +) + + +# 向后兼容的函数接口 +def url_v1(song_id: int, level: str, cookies: dict[str, str]) -> dict[str, Any]: + """获取歌曲URL(向后兼容)""" + api = NeteaseAPI() + return run_sync(api.get_song_url(song_id, level, cookies)) + + +def name_v1(song_id: int) -> dict[str, Any]: + """获取歌曲详情(向后兼容)""" + api = NeteaseAPI() + return run_sync(api.get_song_detail(song_id)) + + +def lyric_v1(song_id: int, cookies: dict[str, str]) -> dict[str, Any]: + """获取歌词(向后兼容)""" + api = NeteaseAPI() + return run_sync(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 run_sync(api.search_music(keywords, cookies, limit, search_type, offset=offset)) + + +def toplist_list() -> list[dict[str, Any]]: + """获取官方榜单列表(向后兼容)""" + api = NeteaseAPI() + return run_sync(api.get_toplist()) + + +def artist_songs(artist_id: int) -> dict[str, Any]: + """获取歌手信息和热门歌曲(向后兼容)""" + api = NeteaseAPI() + return run_sync(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 run_sync(api.get_song_comments(song_id, limit, offset)) + + +def account_info(cookies: dict[str, str]) -> dict[str, Any] | None: + """获取当前账号信息(向后兼容)""" + api = NeteaseAPI() + return run_sync(api.get_account_info(cookies)) + + +def user_playlists(user_id: int, cookies: dict[str, str]) -> list[dict[str, Any]]: + """获取用户收藏/创建的歌单列表(向后兼容)""" + api = NeteaseAPI() + return run_sync(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 run_sync(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 run_sync(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 run_sync(api.subscribe_album(album_id, cookies, action)) + + +def post_comment(thread_id: str, content: str, action: int = 1, + comment_id: int | None = None, + cookies: dict[str, str] | None = None) -> dict[str, Any]: + """发送/回复歌曲评论(向后兼容)""" + api = NeteaseAPI() + return run_sync(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 run_sync(api.probe_levels(cookies, probe_song_id)) + + +def playlist_detail(playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]: + """获取歌单详情(向后兼容)""" + api = NeteaseAPI() + return run_sync(api.get_playlist_detail(playlist_id, cookies)) + + +def album_detail(album_id: int, cookies: dict[str, str]) -> dict[str, Any]: + """获取专辑详情(向后兼容)""" + api = NeteaseAPI() + return run_sync(api.get_album_detail(album_id, cookies)) + + +def get_pic_url(pic_id: int | None, size: int = 300) -> str: + """获取图片URL(向后兼容)""" + api = NeteaseAPI() + return api.get_pic_url(pic_id, size) + + +def qr_login() -> str | None: + """二维码登录(向后兼容)""" + manager = QRLoginManager() + return run_sync(manager.qr_login()) diff --git a/music_api/constants.py b/music_api/constants.py new file mode 100644 index 0000000..0d57583 --- /dev/null +++ b/music_api/constants.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""音质枚举与接口常量。 + +(自 music_api.py 拆出,行为与原实现一致。) +""" + +from enum import Enum + + +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!" + } diff --git a/music_api/cookies.py b/music_api/cookies.py new file mode 100644 index 0000000..789cbf2 --- /dev/null +++ b/music_api/cookies.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""Cookie 读取与解析。 + +(自 music_api.py 拆出,行为与原实现一致。) +""" + +import os + +# ============ 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(不抛异常,调用方决定如何处理)。 + utf-8-sig 剥记事本 BOM(BOM 会让首键变成 \\ufeffMUSIC_U 静默 + 丢 Cookie);解析改逐行再按 ``;`` 切(原实现 ';|\\n' 二选一,混排时丢键值对)。 + """ + if not os.path.isfile(path): + return {} + try: + with open(path, 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 + # 先逐行再按 ; 切——原实现 ";|\\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 diff --git a/music_api/crypto.py b/music_api/crypto.py new file mode 100644 index 0000000..a445e9b --- /dev/null +++ b/music_api/crypto.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""weapi 加解密(AES + RSA)。 + +(自 music_api.py 拆出,行为与原实现一致。) +""" + +import json +import urllib.parse +from hashlib import md5 +from typing import Any + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .constants import ( + APIConstants, +) + + +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" + # 官方随机 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。 + 按官方协议重写——secKey 取官方字符集随机 16 位, + encSecKey = secKey 反转后做 textbook RSA(无填充)的 256 位 hex。 + 原实现用 os.urandom + PKCS1v15 且未反转,服务端按 textbook RSA 解出的 + AES key 与 params 第二层不一致,评论等 weapi 写接口疑似从未成功过。 + 官方协议第二层加密的输入是第一层密文的 + **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} diff --git a/music_api/errors.py b/music_api/errors.py new file mode 100644 index 0000000..93ae77d --- /dev/null +++ b/music_api/errors.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +"""API 异常。 + +(自 music_api.py 拆出,行为与原实现一致。) +""" + + + +class APIException(Exception): + """API异常类""" + pass diff --git a/music_api/qr.py b/music_api/qr.py new file mode 100644 index 0000000..67f8895 --- /dev/null +++ b/music_api/qr.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""扫码登录管理器。 + +(自 music_api.py 拆出,行为与原实现一致。) +""" + +import asyncio +import json +from random import randrange + +from .client import ( + HTTPClient, + _ensure_dict, +) +from .constants import ( + APIConstants, +) +from .crypto import ( + CryptoUtils, +) +from .errors import ( + APIException, +) + + +class QRLoginManager: + """二维码登录管理器""" + + def __init__(self): + self.http_client = HTTPClient() + self.crypto_utils = CryptoUtils() + + async def generate_qr_key(self) -> str | None: + """生成二维码的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 = await self.http_client.post_request_full(APIConstants.QR_UNIKEY_API, params, {}) + + result = json.loads(response.text) + # 非 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}") from e + + async def create_qr_login(self) -> str | None: + """创建登录二维码并在控制台显示 + + Returns: + 成功返回unikey,失败返回None + """ + try: + import qrcode + + unikey = await 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 + + async 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 = await self.http_client.post_request_full(APIConstants.QR_LOGIN_API, params, {}) + + result = json.loads(response.text) + # 非 dict 响应不再 AttributeError 逃出捕获(同 generate_qr_key) + _ensure_dict(result, '解析登录状态响应失败(非标准 JSON 对象)') + cookie_dict = {} + + if result.get('code') == 803: + # 登录成功,提取cookie。 + # 此前只提取 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}") from e + + async def qr_login(self) -> str | None: + """完整的二维码登录流程 + + Returns: + 成功返回cookie字符串,失败返回None + """ + try: + unikey = await self.create_qr_login() + if not unikey: + return None + + while True: + code, cookies = await self.check_qr_login(unikey) + + if code == 803: + print("\n登录成功!") + # CLI 序列化须与 qr_login 口径一致,带上 __csrf + # (此串正是写往全局 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 + + await asyncio.sleep(2) + except KeyboardInterrupt: + print("\n用户取消登录") + return None + except Exception as e: + print(f"\n登录过程中发生错误: {e}") + return None diff --git a/music_downloader/__init__.py b/music_downloader/__init__.py new file mode 100644 index 0000000..76d68f5 --- /dev/null +++ b/music_downloader/__init__.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""网易云音乐下载器(适配 ffmpeg 写入元数据) + +提供功能: +- 同步 / 异步下载(本地落盘 / 内存返回) +- 批量并发下载 +- 进度查询 +- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus) + +设计要点: +1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装 +2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险 +3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart +4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底 +""" + +# 兼容门面:实现已按职责拆分到同包各子模块,此处原样再导出。 +# 用 `__all__` 声明再导出(ruff F401 与 pyright 均据此认定「有意再导出」), +# 使既有调用点(music_downloader.xxx / from music_downloader import Y)零改动。 + +from ._concurrency import ( + _FILE_LOCKS, + _FILE_LOCKS_GUARD, + CountingSemaphore, + RefCountedFileLock, + _FileLockHandle, + get_file_lock, +) +from ._http import ( + _AUDIO_MAGIC, + _SESSION_LOCAL, + DEFAULT_UA, + DL_CHUNK_SIZE, + _sniff_audio_header, + get_http_session, +) +from ._log import ( + logger, +) +from .cover import ( + _COVER_CACHE, + _COVER_CACHE_LOCK, + _COVER_CACHE_MAX, + _COVER_CACHE_ORDER, + fetch_cover_bytes, +) +from .downloader import ( + MusicDownloader, +) +from .models import ( + API_TYPE_TO_EXT, + EXT_TO_FFMPEG_FORMAT, + METADATA_FORMATS, + DownloadCanceled, + DownloadException, + DownloadResult, + MusicInfo, + QualityLevel, + _RetryFromScratch, +) + +__all__ = [ + 'API_TYPE_TO_EXT', + 'DEFAULT_UA', + 'DL_CHUNK_SIZE', + 'EXT_TO_FFMPEG_FORMAT', + 'METADATA_FORMATS', + '_AUDIO_MAGIC', + '_COVER_CACHE', + '_COVER_CACHE_LOCK', + '_COVER_CACHE_MAX', + '_COVER_CACHE_ORDER', + '_FILE_LOCKS', + '_FILE_LOCKS_GUARD', + '_SESSION_LOCAL', + 'CountingSemaphore', + 'DownloadCanceled', + 'DownloadException', + 'DownloadResult', + 'MusicDownloader', + 'MusicInfo', + 'QualityLevel', + 'RefCountedFileLock', + '_FileLockHandle', + '_RetryFromScratch', + '_sniff_audio_header', + 'fetch_cover_bytes', + 'get_file_lock', + 'get_http_session', + 'logger', +] diff --git a/music_downloader/__main__.py b/music_downloader/__main__.py new file mode 100644 index 0000000..57782d5 --- /dev/null +++ b/music_downloader/__main__.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""命令行入口(python -m music_downloader)。""" + +from __future__ import annotations + +from .downloader import ( + MusicDownloader, +) +from .models import ( + DownloadException, +) + +# ============ 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/music_downloader/_concurrency.py b/music_downloader/_concurrency.py new file mode 100644 index 0000000..91251c8 --- /dev/null +++ b/music_downloader/_concurrency.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""文件锁与计数信号量。 + +(自 music_downloader.py 拆出,行为与原实现一致。) +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +# per-file 写锁:保证同一目标文件的 .part 临时文件同时只有一个写入者, +# 防止并发下载同名歌曲(不同用户/任务)互相覆盖导致文件损坏 +# 锁表带引用计数与回收——无人引用时从表中移除,防止长期运行无界增长 +# 计数在注册表守卫锁内完成("取出引用"即计数 +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: + # 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 语句的锁句柄(引用计数在守卫锁内登记,无回收竞态)""" + return _FileLockHandle(path) + + +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: + """可取消地等待名额:等待期间 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() diff --git a/music_downloader/_http.py b/music_downloader/_http.py new file mode 100644 index 0000000..681fb23 --- /dev/null +++ b/music_downloader/_http.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""HTTP 会话复用与音频内容嗅探。 + +(自 music_downloader.py 拆出,行为与原实现一致。) +""" + +from __future__ import annotations + +import threading + +from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests) + +# ============ 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() + + +# 下载内容嗅探的音频魔数(未知期望尺寸时唯一的内容防线) +_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: + """获取当前线程的共享同步会话(连接池由底层 aiohttp 统一维护)。""" + session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None) + if session is None: + session = requests.Session() + session.headers.update({"User-Agent": DEFAULT_UA}) + _SESSION_LOCAL.session = session + return session diff --git a/music_downloader/_log.py b/music_downloader/_log.py new file mode 100644 index 0000000..3d26296 --- /dev/null +++ b/music_downloader/_log.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""模块 logger。 + +(自 music_downloader.py 拆出,行为与原实现一致。) +""" + +from __future__ import annotations + +import logging + +# worker 线程的输出一律走 logger(NSSM GBK 重定向下裸 print +# 非可编码字符会 UnicodeEncodeError 崩掉 worker,尤其 ffmpeg stderr 原文回显) +logger = logging.getLogger('music_downloader') diff --git a/music_downloader/cover.py b/music_downloader/cover.py new file mode 100644 index 0000000..f6b3225 --- /dev/null +++ b/music_downloader/cover.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""封面字节缓存(同专辑只从网络拉一次)。 + +(自 music_downloader.py 拆出,行为与原实现一致。) +""" + +from __future__ import annotations + +import threading +from collections import deque + +from ._http import ( + get_http_session, +) + +# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用) +# 容量抽成常量:淘汰循环按它判定,不再读 deque.maxlen(该属性类型为 int|None, +# 一旦有人把 deque 改成无界,len() >= None 会直接 TypeError) +_COVER_CACHE_MAX = 128 + +_COVER_CACHE: dict[str, bytes] = {} + +_COVER_CACHE_ORDER: deque[str] = deque(maxlen=_COVER_CACHE_MAX) + +_COVER_CACHE_LOCK = threading.Lock() + + +def fetch_cover_bytes(pic_url: str) -> bytes | None: + """下载并缓存封面图片字节;失败返回 None(字节预算 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 # 垃圾 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: + # 所有路径统一关闭响应(原 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_MAX: + evict = _COVER_CACHE_ORDER.popleft() + _COVER_CACHE.pop(evict, None) + _COVER_CACHE[pic_url] = data + _COVER_CACHE_ORDER.append(pic_url) + return data diff --git a/music_downloader.py b/music_downloader/downloader.py similarity index 64% rename from music_downloader.py rename to music_downloader/downloader.py index a0cd14d..22d1c40 100644 --- a/music_downloader.py +++ b/music_downloader/downloader.py @@ -1,21 +1,11 @@ -"""网易云音乐下载器(适配 ffmpeg 写入元数据) +# -*- coding: utf-8 -*- +"""下载主类:落盘、元数据写入与 ZIP 打包。 -提供功能: -- 同步 / 异步下载(本地落盘 / 内存返回) -- 批量并发下载 -- 进度查询 -- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus) - -设计要点: -1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装 -2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险 -3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart -4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底 +(自 music_downloader.py 拆出,行为与原实现一致。) """ from __future__ import annotations -import logging import os import re import shutil @@ -23,342 +13,37 @@ import subprocess import tempfile import threading import time -from collections import deque -from dataclasses import dataclass -from enum import Enum from pathlib import Path from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests) from core.async_bridge import run_sync 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" +from ._concurrency import ( + CountingSemaphore, + get_file_lock, +) +from ._http import ( + DL_CHUNK_SIZE, + _sniff_audio_header, + get_http_session, +) +from ._log import ( + logger, +) +from .cover import ( + fetch_cover_bytes, +) +from .models import ( + API_TYPE_TO_EXT, + EXT_TO_FFMPEG_FORMAT, + METADATA_FORMATS, + DownloadCanceled, + DownloadException, + DownloadResult, + MusicInfo, + _RetryFromScratch, ) - -# 音频流式下载分块大小(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: - """获取当前线程的共享同步会话(连接池由底层 aiohttp 统一维护)。""" - session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None) - if session is None: - session = requests.Session() - 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 打包 共用) -# 容量抽成常量:淘汰循环按它判定,不再读 deque.maxlen(该属性类型为 int|None, -# 一旦有人把 deque 改成无界,len() >= None 会直接 TypeError) -_COVER_CACHE_MAX = 128 -_COVER_CACHE: dict[str, bytes] = {} -_COVER_CACHE_ORDER: deque[str] = deque(maxlen=_COVER_CACHE_MAX) -_COVER_CACHE_LOCK = threading.Lock() - - -def fetch_cover_bytes(pic_url: str) -> bytes | None: - """下载并缓存封面图片字节;失败返回 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_MAX: - 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: int | None = None - - -@dataclass -class DownloadResult: - """下载结果数据类""" - success: bool - file_path: str | None = None - file_size: int = 0 - error_message: str = "" - music_info: MusicInfo | None = 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"} - # ============ 核心实现 ============ @@ -409,8 +94,8 @@ class MusicDownloader: def _sanitize_filename(self, filename: str, max_bytes: int = 180) -> str: """清理文件名非法字符。 - F79:换行/回车/制表等控制字符一律剔除(否则 send_file 头部 500); - F32:按 UTF-8 字节截断(Linux 文件系统 255 字节上限,中文按字符截 200 会超), + 换行/回车/制表等控制字符一律剔除(否则 send_file 头部 500); + 按 UTF-8 字节截断(Linux 文件系统 255 字节上限,中文按字符截 200 会超), 且从 _build_file_path 传入时保证尾部 [ID 音质] 标记不被截掉。""" filename = re.sub(r'[\x00-\x1f\x7f]', '', filename) filename = re.sub(r'[<>:"/\\|?*]', '_', filename) @@ -486,7 +171,7 @@ class MusicDownloader: cookies = cookies if cookies is not None else load_cookies() # 3 个接口并行请求,单曲下载/批量预取都能明显提速 - # F99:URL/详情拿到后立即取消未完成的歌词请求,已知失败不再被拖着等完 + # URL/详情拿到后立即取消未完成的歌词请求,已知失败不再被拖着等完 # 异步化:改用 asyncio.gather 并发(api 方法已异步),失败语义与旧线程池一致 import asyncio @@ -507,7 +192,7 @@ class MusicDownloader: if not url_result.get("data") or not url_result["data"]: raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接") - # 第 91 条(P2-8):上游 data 数组元素可能为 null(F94 同族) + # 上游 data 数组元素可能为 null(同族) song_data = url_result["data"][0] or {} download_url = song_data.get("url", "") or "" if not download_url: @@ -518,7 +203,7 @@ class MusicDownloader: song_detail = detail_result["songs"][0] - # F94:上游可选字段可能为 null(而非缺失),一律 (x or {}) / (x or []) 容错, + # 上游可选字段可能为 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 "" @@ -544,7 +229,7 @@ class MusicDownloader: 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_type=(song_data.get("type") or "mp3").lower(), # null 容错 file_size=song_data.get("size", 0) or 0, quality=quality, lyric=lyric, @@ -565,7 +250,7 @@ class MusicDownloader: 文件名附带歌曲 ID 与请求音质:不同歌曲同名(不同版本/重复收录)落盘互不冲突; 同一首歌不同音质也各自独立,避免低音质文件被当作高音质交付(调包)。 交付文件名由调用方另行决定,不受影响。 - F32:先截断主名(按字节),再拼接 [ID 音质] 标记——标记永不丢失, + 先截断主名(按字节),再拼接 [ID 音质] 标记——标记永不丢失, 杜绝截断后两首不同 ID 的歌落到同一路径。 """ base = f"{music_info.artists} - {music_info.name}" @@ -574,12 +259,12 @@ class MusicDownloader: music_info.download_url, api_type=music_info.file_type, ) - # 第 97 条(P3):按完整路径反算文件名可用长度——旧口径只按 Linux 255 字节 + # 按完整路径反算文件名可用长度——旧口径只按 Linux 255 字节 # 截断文件名,未启用 LongPathsEnabled 的 Windows 上超长歌名会在 open 时 # OSError 白白重试 3 轮。预算 = 240 - 目录前缀 - 扩展名 - ".part"等余量; # 中文名 UTF-8 字节数 ≥ 字符数,按字节截断天然保守。 - # 目录过深时按比例压缩 tag 预算(对抗复审补:下限保底仍可能超 260 的 - # 病理场景由部署侧控制 downloads 目录长度,见交接文档第 97 条备案) + # 目录过深时按比例压缩 tag 预算。注意下限保底仍可能超过 260 字节, + # 该病理场景需由部署侧控制 downloads 目录深度来规避。 budget = 240 - len(str(self.download_dir)) - len(ext) - 8 if budget < 124: tag_budget = max(24, budget // 3) @@ -634,11 +319,11 @@ class MusicDownloader: 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 外抛出会绕过清理) + # 重试轮次顶部的取消检查也要清理 .part(原实现在 try 外抛出会绕过清理) if cancel_event is not None and cancel_event.is_set(): self._unlink_quiet(part_path0) raise DownloadCanceled("下载已取消") @@ -651,14 +336,14 @@ class MusicDownloader: # 缓存与本次期望不符(跨账号音质降级/上游变更都会让同 ID 同音质 # 的实际文件不同):视为缓存 miss 删除重下,而不是把旧文件 # 当本次结果交付(原实现命中即返回、从不校验尺寸)。 - # 第 91 条(P3):期望未知(file_size=0)时至少拒绝 0 字节坏文件 + # 期望未知(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,幂等) + # 缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等) if write_meta: with self._meta_semaphore: self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields) @@ -674,9 +359,9 @@ class MusicDownloader: 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: @@ -689,7 +374,7 @@ class MusicDownloader: ) elif existing > 0: if music_info.file_size > 0 and existing > music_info.file_size: - # R5-15:超尺寸残片没有续传价值(上游多发货/坏分片), + # 超尺寸残片没有续传价值(上游多发货/坏分片), # 丢弃从头下——否则下轮被 >= 误转正交付坏文件 self._unlink_quiet(part_path) else: @@ -697,7 +382,7 @@ class MusicDownloader: else: self._unlink_quiet(part_path) - # 第 91 条(P2-7):排队改用可取消等待(F91 的 acquire_cancellable)—— + # 排队改用可取消等待(的 acquire_cancellable)—— # 原阻塞式 with 使取消后仍要等全局 10 名额腾出,任务卡死、退款延迟 if not self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5): raise DownloadCanceled("下载已取消") @@ -724,7 +409,7 @@ class MusicDownloader: # 服务器忽略 Range 返回 200 → 从头重写而非追加 if resume_from > 0 and resp.status_code != 206: mode = "wb" - # F29:206 必须从我们请求的起点开始;起点错位的分片拼起来是坏文件 + # 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()) @@ -732,13 +417,13 @@ class MusicDownloader: self._unlink_quiet(part_path) raise _RetryFromScratch( f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载") - # R5-16:206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件, + # 206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件, # 与起点错位同口径处理:清残片从头下 if not m: self._unlink_quiet(part_path) raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载") resp.raise_for_status() - # F20:下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘 + # 下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘 max_bytes = 500 * 1024 * 1024 written = resume_from if mode == 'ab' else 0 over_limit = False @@ -750,7 +435,7 @@ class MusicDownloader: f.write(chunk) written += len(chunk) if written > max_bytes: - # C12:超限残片没有任何续传价值。第 91→93 条: + # 超限残片没有任何续传价值。 # Windows 无法删除打开中的文件——置标志 break, # with 关闭后再清理(原实现在打开中 unlink 被静默吞掉) over_limit = True @@ -771,7 +456,7 @@ class MusicDownloader: 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 风控等)是确定性 + # CDN 4xx(403 防盗链/429 风控等)是确定性 # 失败,对同一失效直链白重试只会加剧风控、拖慢失败返回 # (408 请求超时保留可重试) self._unlink_quiet(part_path) @@ -782,10 +467,10 @@ class MusicDownloader: if resp is not None: resp.close() finally: - # 第 91 条(P2-7):与 acquire_cancellable 配对释放(原 with 语义) + # 与 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) @@ -798,10 +483,10 @@ class MusicDownloader: f"实际 {actual_size} 字节" ) if attempt < retries: - next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条) + next_wait = 0.5 * (attempt + 1) # 锁外睡眠 continue - # 第 97 条(P3):转正前内容嗅探——未知期望尺寸("非空即通过"口径) + # 转正前内容嗅探——未知期望尺寸("非空即通过"口径) # 时 CDN 错误页/风控 JSON 曾可被存成音频交付 if music_info.file_size <= 0 and not _sniff_audio_header(part_path): self._unlink_quiet(part_path) @@ -812,7 +497,7 @@ class MusicDownloader: # 元数据写入移出下载信号量(CPU 操作不占网络名额), # 但仍在文件锁内执行,保证其它请求看到的是已完成标记的最终文件 if write_meta: - # 第 97 条(P3):元数据段入口补取消检查点——已取消的任务不再 + # 元数据段入口补取消检查点——已取消的任务不再 # 进入信号量排队/ffmpeg 等待(有界但可达 120s),收尾不再延迟 if cancel_event is not None and cancel_event.is_set(): raise DownloadCanceled("下载已取消") @@ -831,32 +516,32 @@ class MusicDownloader: 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 条) + next_wait = 0.5 * (attempt + 1) # 锁外睡眠 continue except (requests.RequestException, OSError) as e: # 网络错误:保留 .part 支持断点续传; - # F117:但若任务已被取消,.part 没有任何续传价值,一并清理 + # 但若任务已被取消,.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 条) + next_wait = 0.5 * (attempt + 1) # 锁外睡眠 continue except Exception as e: last_error = f"下载过程中发生错误: {e}" if attempt < retries: - next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条) + next_wait = 0.5 * (attempt + 1) # 锁外睡眠 continue - # 最后一次尝试仍失败:若任务已取消按取消收尾(F117) + # 最后一次尝试仍失败:若任务已取消按取消收尾 if cancel_event is not None and cancel_event.is_set(): self._unlink_quiet(part_path0) raise DownloadCanceled("下载已取消") @@ -864,7 +549,7 @@ class MusicDownloader: # ============ ffmpeg 元数据写入(核心) ============ - # P3-42:download_music_to_memory / download_batch_async / get_download_progress + # download_music_to_memory / download_batch_async / get_download_progress # 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受 # cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写 @@ -904,7 +589,7 @@ class MusicDownloader: ) # 临时封面文件 cover_tmp: str | None = None - cover_ok = False # F114:创建成功与否与"路径是否需要清理"分离管理 + cover_ok = False # 创建成功与否与"路径是否需要清理"分离管理 try: # 下载封面到临时文件(走共享缓存:与 ZIP 打包共用一次网络请求) @@ -919,7 +604,7 @@ class MusicDownloader: cf.write(cover_data) cover_ok = True except Exception as e: - # cover_tmp 保持指向已创建的文件,finally 统一清理(F114: + # cover_tmp 保持指向已创建的文件,finally 统一清理 # 原实现置 None 会把已创建的零字节文件遗留在下载目录) logger.warning(f"封面写盘失败,跳过封面写入: {e}") else: @@ -933,7 +618,7 @@ class MusicDownloader: "-i", str(file_path), ] use_cover = cover_ok - # F35:OGG/Opus 的封面流 copy 不被支持(Unsupported codec id), + # OGG/Opus 的封面流 copy 不被支持(Unsupported codec id), # 主动降级为"只写文本元数据",不再让整个写入失败 if use_cover and file_ext in (".ogg", ".opus"): use_cover = False @@ -943,7 +628,7 @@ class MusicDownloader: # 通用元数据(仅写入勾选的字段) meta = {} - # F33:前端字段名为 name,与后端 title 统一映射 + # 前端字段名为 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: @@ -964,7 +649,7 @@ class MusicDownloader: if clean_lyric: meta["lyrics"] = clean_lyric if music_info.track_number > 0 and (fields is None or "track" in fields): - # F33:只写当前曲号;没有真实专辑总数时不虚构 tracktotal + # 只写当前曲号;没有真实专辑总数时不虚构 tracktotal meta["track"] = str(music_info.track_number) for k, v in meta.items(): if v: @@ -989,12 +674,12 @@ class MusicDownloader: ) if result.returncode == 0 and tmp_output.exists(): - # 第 97 条(P3):os.replace 才是同卷原子替换——shutil.move 在目标已 + # os.replace 才是同卷原子替换——shutil.move 在目标已 # 存在时退化为 copy2+unlink,进程中断会留半截文件 os.replace(str(tmp_output), str(file_path)) return True - # P2-5:ffmpeg stderr 原文走 logger(errors='ignore' 解码后的任意 Unicode + # ffmpeg stderr 原文走 logger(errors='ignore' 解码后的任意 Unicode # 在 GBK 重定向下裸 print 会崩 worker) logger.warning( f"ffmpeg 写入元数据失败: {(result.stderr or result.stdout or '').strip()}" @@ -1026,7 +711,7 @@ class MusicDownloader: def _build_format_args(self, file_ext: str, has_cover: bool) -> list[str]: """根据文件格式构造 mapping / 编码参数 - F34:不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面), + 不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面), 原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。""" if file_ext == ".mp3": # MP3: 封面通过 ID3v2 APIC 帧写入 @@ -1060,7 +745,7 @@ class MusicDownloader: "-metadata:s:v", "title=Album cover", "-metadata:s:v", "comment=Cover (front)", ] - # F34:保留既有封面流(原 -map 0:a 会删掉已有封面) + # 保留既有封面流(原 -map 0:a 会删掉已有封面) return ["-map", "0", "-c", "copy"] if file_ext == ".mp4": @@ -1089,79 +774,3 @@ class MusicDownloader: 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/music_downloader/models.py b/music_downloader/models.py new file mode 100644 index 0000000..a1ef6e8 --- /dev/null +++ b/music_downloader/models.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""数据模型、异常与 ffmpeg 格式常量。 + +(自 music_downloader.py 拆出,行为与原实现一致。) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests) + +# ============ 类型定义 ============ + +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: int | None = None + + +@dataclass +class DownloadResult: + """下载结果数据类""" + success: bool + file_path: str | None = None + file_size: int = 0 + error_message: str = "" + music_info: MusicInfo | None = None + + +class DownloadException(Exception): + """下载异常类""" + pass + + +class DownloadCanceled(DownloadException): + """下载被用户取消""" + pass + + +class _RetryFromScratch(requests.RequestException): + """内部信号:断点续传起点失效(HTTP 416 等),清除 .part 后从头重试""" + pass + + +# ============ 工具映射表 ============ + +# 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"} diff --git a/podcast_api.py b/podcast_api.py index d9189ef..384f15f 100644 --- a/podcast_api.py +++ b/podcast_api.py @@ -48,7 +48,7 @@ class PodcastConstants: DEFAULT_HOT_CATE_IDS = [6, 3, 8, 10001] -# 第 97 条(对抗复审补漏):与 music_api 同口径的上游 JSON 响应体上限 +# 与 music_api 同口径的上游 JSON 响应体上限 _MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024 @@ -178,10 +178,10 @@ _SHORTLINK_CACHE: dict[str, str] = {} def _resolve_short_link(url: str) -> str: - """解析 163cn.tv 短链,返回跳转后的真实链接(T4:仅接受整串短链,子串不触发请求)""" + """解析 163cn.tv 短链,返回跳转后的真实链接(仅接受整串短链,子串不触发请求)""" if not re.match(r'^https?://(www\.)?163cn\.tv/\S+$', str(url or '').strip(), re.IGNORECASE): return url - # 第 91 条(P3):带浏览器 UA(短链 CDN 对默认 UA 区别对待)+ 模块级缓存 + # 带浏览器 UA(短链 CDN 对默认 UA 区别对待)+ 模块级缓存 #(此前仅音乐路径有缓存,播客同一分享链接每次解析都发真实 GET) global _SHORTLINK_CACHE cached = _SHORTLINK_CACHE.get(url) @@ -197,7 +197,7 @@ def _resolve_short_link(url: str) -> str: finally: if resp is not None: resp.close() - # 第 99 条:与 main._expand_shortlink 同口径——展开失败(200 无 Location/5xx, + # 短链展开口径——展开失败(200 无 Location/5xx, # real==url)不写缓存,一次 CDN 抖动不再让该链接整个缓存存续期必然失败 if real == url: return real @@ -208,7 +208,7 @@ def _resolve_short_link(url: str) -> str: def extract_podcast_ref(raw: str) -> dict[str, str]: - """从用户输入中提取播客引用(T4:正规 URL 解析,替代子串匹配与非数字剔除) + """从用户输入中提取播客引用(正规 URL 解析,替代子串匹配与非数字剔除) Returns: {'kind': 'radio'|'program'|'unknown', 'id': '...'} @@ -375,7 +375,7 @@ def get_categories() -> list[dict[str, Any]]: except json.JSONDecodeError as e: raise APIException(f"解析播客分类响应失败: {e}") from e if not isinstance(result, dict): - # 第 97 条(P3):非 dict 响应(null/数组)显式报错,不再 AttributeError + # 非 dict 响应(null/数组)显式报错,不再 AttributeError raise APIException('解析播客分类响应失败(非标准 JSON 对象)') cats = [] for c in (result.get('categories') or []): @@ -399,7 +399,7 @@ def get_hot_radios(cookies: dict[str, str] | None = None, """获取分类下热门播客 Args: - cate_id: 播客分类ID;F84:用户明确指定分类时只查该分类 + cate_id: 播客分类ID;用户明确指定分类时只查该分类 (即便为空也不悄悄混入其他分类的内容),未指定时按默认分类依次尝试 Returns: @@ -427,7 +427,7 @@ def get_hot_radios(cookies: dict[str, str] | None = None, if radios: return radios if user_cate: - break # F84:指定分类为空就返回空,不注入默认分类内容 + break # 指定分类为空就返回空,不注入默认分类内容 return [] @@ -443,7 +443,7 @@ def resolve_episode_url(song_id: Any, cookies: dict[str, str] | None = None, Returns: {'url','level','type','size','br','md5'} 或 None(全部失败) """ - # F83:降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh + # 降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh _FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard'] order: list[str] = [] level = str(level or 'auto').strip() @@ -526,7 +526,7 @@ def list_radio_all_program_ids(radio_id: Any, cookies: dict[str, str] | None = N """拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}] 最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。 - F98:按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止, + 按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止, 不再固定 offset+=100 盲目翻页。 """ out: list[dict[str, Any]] = [] diff --git a/pyproject.toml b/pyproject.toml index 7410b98..e8b7bf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ ignore = [ "core/aiohttp_compat.py" = ["ASYNC109"] [tool.ruff.lint.isort] -known-first-party = ["core", "web", "services", "music_api", "auth_system"] +known-first-party = ["core", "web", "services", "music_api", "auth_system", "music_downloader"] [tool.ruff.format] quote-style = "preserve" @@ -81,10 +81,10 @@ include = [ "web", "services", "tests", - "music_api.py", - "auth_system.py", + "music_api", + "auth_system", + "music_downloader", "podcast_api.py", - "music_downloader.py", "qr_login.py", "_mkpkg.py", ] @@ -103,3 +103,7 @@ reportGeneralTypeIssues = "warning" asyncio_mode = "auto" testpaths = ["tests"] addopts = "-q" +# 本工程不装进环境(package = false),且 tests/ 无 __init__.py, +# 故必须显式把项目根加入 import 路径——否则裸跑 `pytest` 时 +# `import core` 必然 ModuleNotFoundError(只有 `python -m pytest` 才碰巧能用)。 +pythonpath = ["."] diff --git a/qr_login.py b/qr_login.py index f0b01be..176e810 100644 --- a/qr_login.py +++ b/qr_login.py @@ -59,7 +59,7 @@ class QRLoginClient: return "" def _write_cookie(self, cookie: str) -> bool: - """F86:临时文件 + 原子替换写入;失败自动恢复备份,杜绝半截文件毁掉在用 Cookie。 + """临时文件 + 原子替换写入;失败自动恢复备份,杜绝半截文件毁掉在用 Cookie。 临时文件名带随机后缀:同进程多线程并发写同一 Cookie 文件不再互相覆盖 .tmp""" if not cookie or not cookie.strip(): self.logger.error("Cookie 内容不能为空") @@ -90,7 +90,7 @@ class QRLoginClient: def _backup_cookie(self, suffix: str = "backup") -> Path | None: if not self.cookie_file.exists(): return None - ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 第 91 条(P3):微秒防同秒覆盖 + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 微秒防同秒覆盖 backup_path = self.cookie_file.with_name( f"{self.cookie_file.stem}.{suffix}.{ts}{self.cookie_file.suffix}" ) @@ -131,7 +131,7 @@ class QRLoginClient: return False def _is_cookie_valid(self) -> bool: - # F61:登录态凭据是 MUSIC_U 且必须非空—— + # 登录态凭据是 MUSIC_U 且必须非空—— # 只有 __csrf/NMTID 这类匿名 cookie 也存在时不能算"有效登录" cookies = load_cookies(str(self.cookie_file)) return bool((cookies.get('MUSIC_U') or '').strip()) @@ -140,7 +140,7 @@ class QRLoginClient: 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 为准 + valid = bool((cookies.get('MUSIC_U') or '').strip()) # 以 MUSIC_U 为准 info = { "file_path": str(self.cookie_file.absolute()), "file_exists": self.cookie_file.exists(), @@ -209,8 +209,8 @@ class QRLoginClient: 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 发评论必败 + # 序列化带上 __csrf——历史上只修了 web 的两条路径, + # 漏了 CLI 换号:会导致全站 __csrf 恒空、weapi 发评论必败 serialized = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;" csrf = cookie_dict.get('__csrf', '') if csrf: diff --git a/services/__init__.py b/services/__init__.py index 705b09c..73504fd 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -1,6 +1,30 @@ # -*- coding: utf-8 -*- -"""服务层:从原 Flask 巨石中剥离的框架无关实现。 +"""服务层:从原 Flask 巨石中剥离的框架无关实现(不含任何 Flask 依赖)。 -包含 MusicAPIService、下载/打包 worker、任务登记与监控、下载缓存清扫等。 -不含任何 Flask 依赖(路由层已全部迁至 web/routers)。 +模块划分(原先集中在 runtime.py 的 1900 行,现按职责拆分;`services.runtime` +保留为兼容门面,原样再导出下列全部符号,既有调用点零改动): + +- ``api_service``:MusicAPIService 与进程级实例(config / api_service) +- ``_shared``:限流桶、请求统计、命名与共享常量 +- ``_taskcore``:任务生命周期原语(启动 / 终态 / 文件收尾 / 结算) +- ``downloads``:单曲下载、账号探测与任务交付 +- ``batch``:歌单/专辑批量打包 worker +- ``podcast``:播客单集下载与整台打包 worker +- ``streaming``:同步流式响应垫片(音频分段透传) +- ``tasks``:任务监控循环、设备心跳与缓存清扫 """ + +# ============ 依赖自检 ============ +# 应用依赖缺失时给出可读提示并非零退出。本层常以 NSSM 服务方式运行、控制台重定向为 +# GBK,裸 traceback 反而更难定位,故提示串保持纯 ASCII。放在包初始化里是因为它必然 +# 先于任何子模块执行——子模块各自 import 失败会先抛 traceback,那段提示就没机会出现。 +import importlib +import sys + +for _dep in ('auth_system', 'podcast_api', 'music_api', 'music_downloader', 'qr_login'): + try: + importlib.import_module(_dep) + except ImportError as _exc: + print("ImportError: " + str(_exc)) + print("Please make sure all dependency modules exist and are importable.") + sys.exit(1) diff --git a/services/_shared.py b/services/_shared.py new file mode 100644 index 0000000..cbaa7f8 --- /dev/null +++ b/services/_shared.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""服务层共享状态与低层工具(限流桶、请求统计、命名与常量)。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + +import collections +import re +import time +from typing import Any + +from core.config import ( + BATCH_ZIP_CONCURRENCY, +) +from core.state import RATE_BUCKETS as _RATE_BUCKETS +from core.state import RATE_LOCK as _RATE_LOCK +from core.state import ( + REQ_LOG, +) +from music_downloader import ( + CountingSemaphore, +) + + +def _rate_ok(key: str, limit: int, window: int) -> bool: + """滑动窗口限流:True=放行,False=超限""" + now = time.time() + with _RATE_LOCK: + dq = _RATE_BUCKETS.get(key) + if dq is None: + # 桶数量防膨胀:超限时清理不活跃桶 + if len(_RATE_BUCKETS) > 8000: + for k in [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > window]: + _RATE_BUCKETS.pop(k, None) + dq = collections.deque() + _RATE_BUCKETS[key] = dq + while dq and now - dq[0] > window: + dq.popleft() + if len(dq) >= limit: + return False + dq.append(now) + return True + + +# 旧版管理员口令已废除(2026-09-15):管理员权限仅认站点管理员会话(admin 登录), +# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*) +# 均通过该会话鉴权。 + +# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。 +# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。 +# (下载本身的并发已由下载器内的全局信号量控制) +# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。 +# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁, +# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。 +BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY) + + +_MONITOR_ERR_TS = {'ts': 0.0, 'n': 0} # 监控线程异常限频记录(持续异常不静默也不刷屏) + +# 监控线程周期兜底的三个节流字典——补丁时定义丢失 +# (只剩使用),NameError 被外层 except 吞成 WARNING,孤儿对账/账本清理/tasks +# 补扫自那以后从未执行(music_api.log 实证 2064 条 WARNING)。红转绿:重启后 +# 日志零 NameError 且对账真实运行。 +_LEDGER_LAST_RECOVER = {'ts': 0.0} # 上次孤儿账本对账时间(每 60s 一次) + +_LEDGER_LAST_CLEANUP = {'ts': 0.0} # 上次 settled 账本 90 天清理时间(每 24h 一次) + +_TASKS_SWEEP_LAST = {'ts': 0.0} # 上次 tasks/ 孤儿目录补扫时间(每小时一次) + + +def _sweep_rate_buckets() -> None: + """限流桶周期清扫(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) + + +# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享) + + +# ============ 接口请求统计 ============ +# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享) + +# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用) +# 兼容别名(/Search、/Song_V1、/Playlist、/Album、/Download)一并纳入统计与限流, +# 防止用大写别名绕过业务限流 +COUNTED_PATHS = { + '/song', '/search', '/playlist', '/album', '/toplist', '/artist_songs', + '/comments', '/download', '/download/batch_zip', + '/podcast/parse', '/podcast/programs', '/podcast/search', '/podcast/hot', + '/podcast/download', '/podcast/download/batch_zip', + '/user/playlists', '/user/playlist', '/user/status', '/user/logout', + '/user/qr/create', '/user/qr/check', + # 账号探测与个人操作端点此前漏在限流名单外—— + # /account/vip 每请求最多 9 次上游探测、/user/comment 与 /user/collect 携 + # 任意 cookie 触发上游 weapi、/auth/me 可匿名无限刷,全部纳入免费 30/分桶 + '/account/vip', '/user/collect', '/user/comment', '/auth/me', + '/admin/qr/create', '/admin/qr/check', '/admin/cookie/status', + '/admin/cookie/delete', '/admin/logs', +'/admin/site/play_key/get', '/admin/site/play_key/rotate', + '/global/playlists', '/global/playlist', '/api/playlist/play', + '/song_v1', +} + +_COUNTED_LOWER = {p.lower() for p in COUNTED_PATHS} + + +ENDPOINT_NAMES = { + '/song': '歌曲信息', '/search': '搜索', '/playlist': '歌单详情', + '/album': '专辑详情', '/toplist': '榜单列表', '/artist_songs': '歌手歌曲', + '/comments': '评论', '/download': '音乐下载', '/download/batch_zip': '批量ZIP', + '/health': '健康检查', '/api/info': '服务信息', '/api/topen': '请求统计', + '/account/vip': '账号VIP信息', + '/podcast/parse': '播客解析', '/podcast/programs': '播客单集列表', + '/podcast/search': '播客搜索', '/podcast/hot': '热门播客', + '/podcast/download': '播客单集下载', '/podcast/download/batch_zip': '播客整台打包', + '/user/playlists': '个人歌单', '/user/playlist': '个人歌单详情', + '/user/status': '个人登录状态', '/user/logout': '个人退出', + '/user/qr/create': '个人二维码', '/user/qr/check': '个人登录轮询', + '/admin/qr/create': '管理员二维码', '/admin/qr/check': '管理员登录轮询', + '/admin/cookie/status': 'Cookie状态', '/admin/cookie/delete': 'Cookie管理', + '/admin/logs': '操作记录', + '/global/playlists': '全局账号歌单', '/global/playlist': '全局歌单详情', + '/api/playlist/play': '公开歌单播放API', +} + + +# 用户解析/下载操作记录(IP)——REQ_LOG 已迁出至 core.state(此处 import 同名共享) + + +def _record_log(action: str, ip: str, song: str) -> None: + """记录用户操作(解析/下载)。歌名截断(来自上游/用户输入,不设上限会撑内存与后台页)""" + REQ_LOG.append({ + 'action': action, + 'ip': ip, + 'song': str(song or '')[:100], + 'time': time.strftime('%Y-%m-%d %H:%M:%S'), + }) + if len(REQ_LOG) > 2000: + del REQ_LOG[:len(REQ_LOG) - 2000] + + +def _build_download_base(filename_format: str, music_info: dict[str, Any]) -> str: + """按文件名格式生成下载基础名(不含扩展名)""" + name = music_info.get('name', '未知歌曲') + artist = music_info.get('artist_string', '') or '未知歌手' + + def _safe(s: str) -> str: + # 交付文件名必须剔除控制字符——歌名含 \r\n 时交付头的 + # Content-Disposition 会抛 ValueError,任务完成已扣费但领取必 500 + # (当年只修了下载器磁盘名这条同型路径,交付名漏网) + s = re.sub(r'[\x00-\x1f\x7f]', '', s) + return ''.join(c for c in s if c not in r'<>:"/\|?*').strip() or 'unknown' + + if filename_format == 'song': + return _safe(name) + if filename_format == 'song_artist': + return _safe(f"{name} - {artist}") + return _safe(f"{artist} - {name}") # artist_song(默认) + + +# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、 +# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。 +# 用户主动动作(任务列表/取消)保留日志。 +_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat', + '/audio_stream', '/api/fe_error'} diff --git a/services/_taskcore.py b/services/_taskcore.py new file mode 100644 index 0000000..44432d7 --- /dev/null +++ b/services/_taskcore.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""任务生命周期原语(启动 / 终态 / 文件收尾 / 结算)。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + +import threading +import time +import traceback +from typing import Any + +import auth_system +from core.state import ( + DOWNLOAD_TASKS, + DOWNLOAD_TASKS_LOCK, +) + +from .api_service import ( + api_service, +) + + +def _start_task_thread(task_id: str, target, args=()) -> str | None: + """启动任务 worker(启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。 + 退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。 + 线程句柄存入任务字典——监控循环对 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 _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None: + """任务进入终态的统一入口(记录 completed_at,保留期从完成时刻起算, + 而不是从提交时刻起算)。 + /F-E:置 error/canceled 终态时若 worker 线程已死(无人再走正常结算), + 代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。 + 终态先到先得——已终态(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 + # 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 _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int: + """批量任务结算(走持久账本,按实际预扣-实际交付幂等退款; + 重复调用/重启后调用都只会退一次)""" + try: + refund = auth_system.ledger_settle(task_id, ok_count, final_status) + except Exception: + # 本函数多在异常处理器内被调用——结算再抛错(库忙超时) + # 会杀死 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 diff --git a/services/api_service.py b/services/api_service.py new file mode 100644 index 0000000..e12058d --- /dev/null +++ b/services/api_service.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""服务类与进程级服务实例。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + +import logging +import re +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from core.config import ( + Settings, +) +from core.utils import expand_shortlink as _expand_shortlink +from music_api import ( + NeteaseAPI, + load_cookies, +) +from music_downloader import ( + MusicDownloader, +) + +# 向后兼容别名:既有代码/文档沿用 APIConfig 名称 +APIConfig = Settings + + +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 = 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 + + +class MusicAPIService: + """音乐API服务类""" + + def __init__(self, config: APIConfig): + self.config = config + self.logger = self._setup_logger() + self.netease_api = NeteaseAPI() + # 下载目录传给下载器——原实现下载器固定 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(正规 URL 解析替代子串/删除非数字启发式) + - 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF) + - music.163.com 链接用 parse_qs 取 id 参数(参数顺序无关) + """ + raw = str(id_or_url or '').strip() + try: + # 短链展开:仅接受"整个输入就是 163cn.tv 链接"(子串包含不算) + if re.match(r'^https?://(www\.)?163cn\.tv/\S+$', raw, re.IGNORECASE): + raw = _expand_shortlink(raw) + + # 网易云链接:正规解析 query 取 id(不再 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})") + + +# 服务实例(框架无关) +config = APIConfig() + +api_service = MusicAPIService(config) diff --git a/services/batch.py b/services/batch.py new file mode 100644 index 0000000..8320cc2 --- /dev/null +++ b/services/batch.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +"""歌单/专辑批量打包 worker。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + +import os +import time +import traceback +import zipfile +from pathlib import Path + +from core.state import ( + BATCH_DURATIONS, + BATCH_DURATIONS_LOCK, + DOWNLOAD_TASKS, + DOWNLOAD_TASKS_LOCK, +) +from core.utils import format_lyric as _format_lyric +from music_downloader import ( + DownloadCanceled, + fetch_cover_bytes, +) + +from ._shared import ( + BATCH_ZIP_SEM, + _build_download_base, +) +from ._taskcore import ( + _mark_task_terminal, + _settle_batch_billing, +) +from .api_service import ( + api_service, +) + + +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: str | None = None + 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() + + # 预取按窗口进行——原实现一次性提交全部(最多 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'] = '继续下载...' + + # ZIP 构建放 downloads 卷(原实现落系统 %TEMP%——500 首无损可达 GB 级, + # 小系统盘一次大单即塞爆;单曲/播客早已指定 dir,唯批量漏了) + # F-A:临时包建在任务专属目录 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: + # 不用 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 个批量任务在下载) + # 等待名额期间可被取消,不再无限阻塞线程与退款 + 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) # 只预取本窗口 + 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 + assert result.file_path is not None + fp = Path(result.file_path) + if not fp.exists(): + # 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务 + failed.append(str(mid)) + _update_progress() + continue + mi = result.music_info + if mi is None: + # 与上面 fp.exists() 同口径:成功但缺元信息(上游详情缺失) + # 按单曲失败计,不让 mi.name 抛 AttributeError 把整批转 error + failed.append(str(mid)) + _update_progress() + continue + 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 + # 缓存文件是跨任务共享资源,任务不得删除; + # 磁盘回收交给监控线程的下载缓存清扫(按文件年龄) + # 歌词与封面已在并行下载线程中拉取,直接写入 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: + # 取消/异常时不等待在途下载线程(其可能阻塞在预取 HTTP 上), + # 排队任务一并取消;在途线程自行收尾(.part 残片由续传/清扫兜底) + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + # 复审 修正: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 + + # 全部失败不发布空 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) + # 发布前锁内复查取消/失效状态——取消请求落在"检查后、拿锁前"的窗口也不得覆盖终态 + 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': [], # 缓存文件不归任务所有,不进入清理清单 + } + api_service.logger.info(f"批量ZIP完成: 成功{ok_count} 失败{len(failed)} 退回{refunded}次") + except DownloadCanceled: + # 部分成功后取消——已入包部分照常交付(成果守恒),未完成部分按账本退费 + # 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: + # 通用异常与取消同一套交付/结算规则:有成果则交付,按账本退未交付部分 + # F-B:ZIP 未正常完成(写坏)时按全额退费,不发布坏包 + # 非预期异常原文 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",退回 {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) diff --git a/services/downloads.py b/services/downloads.py new file mode 100644 index 0000000..001d335 --- /dev/null +++ b/services/downloads.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +"""单曲下载、账号探测与任务交付。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + +import os +import shutil +import time +import traceback +import zipfile +from pathlib import Path + +import auth_system +from core.config import ( + USER_COOKIE_FILE, +) +from core.state import ( + DOWNLOAD_TASKS, + DOWNLOAD_TASKS_LOCK, +) +from core.state import GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE +from core.state import VIP_PROBE_LOCK as _VIP_PROBE_LOCK +from core.utils import format_lyric as _format_lyric +from music_api import ( + account_info, + detect_vip_by_probe, +) +from music_downloader import ( + DownloadCanceled, + DownloadException, + fetch_cover_bytes, +) +from qr_login import QRLoginClient + +from ._shared import ( + _build_download_base, + _record_log, +) +from ._taskcore import ( + _mark_task_terminal, +) +from .api_service import ( + api_service, +) + + +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 已迁出至 core.state(此处 import 同名共享) + + +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 _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 精确),并始终用音质探测接口实测可解析列表。 + 探测单飞锁(有界等待 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() + + +# ============ 管理员设置(二维码登录 / Cookie 管理) ============ + +def _admin_client() -> QRLoginClient: + """创建二维码登录客户端(基于 cookie.txt)""" + return QRLoginClient() + + +# ============ 个人歌单(游客扫码登录自己的账号) ============ + +def _user_client() -> QRLoginClient: + """创建个人用户二维码登录客户端(后不再落盘 user_cookie.txt, + USER_COOKIE_FILE 仅作 QRLoginClient 构造参数遗留)""" + return QRLoginClient(USER_COOKIE_FILE) + + +def _task_delivery_copy(task_id: str, src: Path, filename: str) -> str: + """把共享缓存文件复制/硬链接为任务专属交付文件(任务成果与共享缓存分离, + 任何任务的清理都不会再删掉别的任务的成果)""" + 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) + + +# _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: str | None = None + zip_tmp_path: str | None = None + task_dir_path: str | None = 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 "下载失败") + # 下载完成后的每一阶段都可能已被取消,发布前统一校验 + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + assert download_result.file_path is not None + cache_path = Path(download_result.file_path) + # 交付文件用任务专属副本,共享缓存不进清理清单 + 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) + # 打包阶段同样以取消检查收口 + 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], + } + + # 最终发布前的取消检查(末段取消不得覆盖为 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 + # 交付已落定;结算失败不再把任务改标 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: + # 非预期异常的 repr 可能含上游主机/连接细节——用户可见文案 + # 泛化为固定文案,原文 logger 先行留档(教训:先记录再泛化) + 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) diff --git a/services/podcast.py b/services/podcast.py new file mode 100644 index 0000000..b027bde --- /dev/null +++ b/services/podcast.py @@ -0,0 +1,582 @@ +# -*- coding: utf-8 -*- +"""播客单集与整台打包 worker。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + +import os +import shutil +import threading +import time +import traceback +import zipfile +from pathlib import Path +from typing import Any + +import auth_system +import podcast_api +from core.state import ( + DOWNLOAD_TASKS, + DOWNLOAD_TASKS_LOCK, +) +from music_api import ( + APIException, +) +from music_downloader import ( + DownloadCanceled, + DownloadException, + fetch_cover_bytes, +) + +from ._shared import ( + BATCH_ZIP_SEM, + _record_log, +) +from ._taskcore import ( + _mark_task_terminal, + _settle_batch_billing, +) +from .api_service import ( + api_service, +) +from .streaming import ( + _stream_open, +) + + +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 更新一次任务进度消息。 + """ + done = 0 + last_update = 0.0 + part = target.with_name(target.name + '.part') + # 等待全局下载名额期间可被取消(与音乐批量 BATCH_ZIP_SEM 同语义) + sem = api_service.downloader._download_semaphore + if not sem.acquire_cancellable(cancel_ev): + raise DownloadCanceled("任务已取消") + try: + with _stream_open(url, timeout=60) as r: + if r.status >= 400: + raise DownloadException(f"上游返回 {r.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 会"正常"结束,文件静默截断 + # (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 + + +def _podcast_download_worker(task_id: str) -> None: + """后台播客单集下载:解析单集 → 音质降级取直链 → 流式下载(可选 ZIP 打包) + + 任务 type 为 'single',完成后经 /download/result 交付,与单曲下载共用交付通道。 + 落盘文件进入任务专属目录且带节目 ID,跨任务永不重名;清理只动本任务目录。 + """ + import tempfile as _tempfile + + file_path_str: str | None = None + zip_tmp_path: str | None = None + task_dir: Path | None = 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('.') + # 磁盘名附节目 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 + # 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) + # 打包阶段后同样以取消检查收口 + 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], + } + + # 最终发布前的取消检查(末段取消不得覆盖为 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 + # 交付已落定;结算失败不再把任务改标 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: + # 非预期异常原文 logger 先行,用户可见文案泛化(教训) + 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[str | None]) -> 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: str | None = None + ok_count = 0 + failed: list[str] = [] + # 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) + + # 按次计费补扣:整台电台提交时只预扣 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}") from 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 + + # 对齐音乐批量 F-B 口径——zip_valid 仅在 ZIP with 块正常 + # 退出(中央目录完整写出)后才置 True(初始 False 已上移函数头); + # 未 valid 一律不发布、不按 ok_count 结算 + # 同音乐批量——ZIP 构建放 downloads 卷,不落系统 %TEMP% + # 与音乐批量打包同口径:建在任务专属目录 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() # 包内条目名去重(同名单集/同号不同集不得互相覆盖) + 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']}") + # 中间文件移入任务目录——落 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) + # 重名条目自动追加序号 + 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: + # 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() + + # 走到这里说明 ZIP with 块正常退出、中央目录完整,才允许发布 + zip_valid = True + + if ok_count == 0: + raise DownloadException(f"全部单集下载失败: {'; '.join(failed[:3])}") + + # 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 + + # 最终发布前的取消检查(末段取消不得覆盖为 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) + # 发布前锁内复查取消/失效状态(同音乐批量) + 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: + # 部分成功后取消——已入包部分照常交付,未完成部分按账本退费 + # 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) + # 终态先到先得——已终态(如用户取消/超时兜底) + # 不再被覆盖为 error(堵 canceled→error 非法迁移边) + if _t and _t.get('status') not in ('done', 'error', 'canceled'): + _t['status'] = 'error' + _t['completed_at'] = time.time() # 保留期从完成时刻起算 + _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: + # 非预期异常原文 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) + # 同上——已终态不再覆盖为 error + if _t and _t.get('status') not in ('done', 'error', 'canceled'): + _t['status'] = 'error' + _t['completed_at'] = time.time() # 保留期从完成时刻起算 + _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()}") diff --git a/services/runtime.py b/services/runtime.py index de0d1d2..2caf642 100644 --- a/services/runtime.py +++ b/services/runtime.py @@ -9,1896 +9,112 @@ - 健康检查 """ -import collections -import logging -import os -import re -import shutil -import sys -import threading -import time -import traceback -import zipfile -from pathlib import Path -from typing import Any -from urllib.parse import parse_qs, urlsplit +# 兼容门面:实现已按职责拆分到同包各子模块,此处原样再导出。 +# 用 `__all__` 声明再导出(ruff F401 与 pyright 均据此认定「有意再导出」), +# 使既有调用点(services.xxx / from services import Y)零改动。 -try: - import auth_system - import podcast_api - from music_api import ( - APIException, - NeteaseAPI, - account_info, - detect_vip_by_probe, - load_cookies, - ) - from music_downloader import ( - CountingSemaphore, - DownloadCanceled, - DownloadException, - MusicDownloader, - fetch_cover_bytes, - ) - from qr_login import QRLoginClient -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) - - -# ============ 框架无关核心层(core/)============ -# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现** -# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。 -# QRLoginManager 等已异步化的上游方法,Flask 同步路由经 run_sync 桥接调用。 -from core.config import ( - BATCH_ZIP_CONCURRENCY, - CANCEL_AFTER_SECONDS, - HEARTBEAT_INTERVAL, - PAUSE_AFTER_SECONDS, - RESULT_RETENTION_SECONDS, - USER_COOKIE_FILE, - Settings, +from ._shared import ( + _COUNTED_LOWER, + _LEDGER_LAST_CLEANUP, + _LEDGER_LAST_RECOVER, + _MONITOR_ERR_TS, + _QUIET_LOG_PATHS, + _TASKS_SWEEP_LAST, + BATCH_ZIP_SEM, + COUNTED_PATHS, + ENDPOINT_NAMES, + _build_download_base, + _rate_ok, + _record_log, + _sweep_rate_buckets, ) -from core.state import ( - BATCH_DURATIONS, - BATCH_DURATIONS_LOCK, - DEVICE_LAST_SEEN, - DEVICE_LAST_SEEN_LOCK, - DOWNLOAD_TASKS, - DOWNLOAD_TASKS_LOCK, - REQ_LOG, +from ._taskcore import ( + _mark_task_terminal, + _settle_batch_billing, + _start_task_thread, + _task_files, ) -from core.state import GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE -from core.state import RATE_BUCKETS as _RATE_BUCKETS -from core.state import RATE_LOCK as _RATE_LOCK -from core.state import VIP_PROBE_LOCK as _VIP_PROBE_LOCK -from core.utils import expand_shortlink as _expand_shortlink -from core.utils import format_lyric as _format_lyric - -# 向后兼容别名:既有代码/文档沿用 APIConfig 名称 -APIConfig = Settings - - -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 = 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 - - -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 _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 - -# 数据库恢复前静默在途任务(与 Flask 版同口径) -auth_system.register_before_restore_hook(_quiesce_tasks_for_restore) - -# 服务实例(框架无关) -config = APIConfig() -api_service = MusicAPIService(config) - - - -def _rate_ok(key: str, limit: int, window: int) -> bool: - """滑动窗口限流:True=放行,False=超限""" - now = time.time() - with _RATE_LOCK: - dq = _RATE_BUCKETS.get(key) - if dq is None: - # 桶数量防膨胀:超限时清理不活跃桶 - if len(_RATE_BUCKETS) > 8000: - for k in [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > window]: - _RATE_BUCKETS.pop(k, None) - dq = collections.deque() - _RATE_BUCKETS[key] = dq - while dq and now - dq[0] > window: - dq.popleft() - if len(dq) >= limit: - return False - dq.append(now) - return True - -# 旧版管理员口令已废除(2026-09-15):管理员权限仅认站点管理员会话(admin 登录), -# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*) -# 均通过该会话鉴权。 - -# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。 -# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。 -# (下载本身的并发已由下载器内的全局信号量控制) -# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。 -# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁, -# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。 -BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY) - - -def _start_task_thread(task_id: str, target, args=()) -> str | None: - """启动任务 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 _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 dev else None - 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=lambda k: DEVICE_LAST_SEEN.get(k, 0.0))[:2500]: - DEVICE_LAST_SEEN.pop(d, None) - DEVICE_LAST_SEEN[device_id] = time.time() - - -# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享) - - -# ============ 接口请求统计 ============ -# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享) - -# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用) -# 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 已迁出至 core.state(此处 import 同名共享) - - -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 已迁出至 core.state(此处 import 同名共享) - - -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 _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 时交付头的 - # 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 _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() - - -# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、 -# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。 -# 用户主动动作(任务列表/取消)保留日志。 -_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat', - '/audio_stream', '/api/fe_error'} - - -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 - - - -class _SyncStreamResponse: - """同步流式响应包装(把 aiohttp 的异步流按需拉成同步迭代)。 - - 供流式下载使用:worker 是同步线程,这里经 core.async_bridge 在后台事件循环 - 逐块取数据,避免引入 requests 依赖(Flask 已删除、requests 已移出依赖)。 - """ - - def __init__(self, url: str, timeout: float): - from core import async_bridge - self._bridge = async_bridge - self._url = url - self._timeout = timeout - self._agent = None - self.status = 0 - self.headers: dict[str, str] = {} - self._resp = None - - def __enter__(self): - self._agent = self._bridge.run_sync(self._open()) - self.status = self._agent[0] - self.headers = self._agent[1] - return self - - async def _open(self): - from core.http_client import client - resp = await client.open('GET', self._url, 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/', - }, timeout=self._timeout) - return resp.status, dict(resp.headers), resp - - def iter_content(self, chunk_size: int = 256 * 1024): - assert self._agent is not None - resp = self._agent[2] - it = resp.content.iter_chunked(chunk_size) - while True: - try: - chunk = self._bridge.run_sync(it.__anext__()) - except StopAsyncIteration: - break - yield chunk - - def __exit__(self, *exc): - if self._agent is not None and self._agent[2] is not None: - try: - self._agent[2].close() - except Exception: - pass - return False - - -def _stream_open(url: str, timeout: float = 60) -> _SyncStreamResponse: - """打开同步可迭代的流式响应(见 _SyncStreamResponse)。""" - return _SyncStreamResponse(url, timeout) - -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 更新一次任务进度消息。 - """ - 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 _stream_open(url, timeout=60) as r: - if r.status >= 400: - raise DownloadException(f"上游返回 {r.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 - - -# ============ 管理员设置(二维码登录 / Cookie 管理) ============ - -def _admin_client() -> QRLoginClient: - """创建二维码登录客户端(基于 cookie.txt)""" - return QRLoginClient() - - -# ============ 个人歌单(游客扫码登录自己的账号) ============ - -def _user_client() -> QRLoginClient: - """创建个人用户二维码登录客户端(第 91 条后不再落盘 user_cookie.txt, - USER_COOKIE_FILE 仅作 QRLoginClient 构造参数遗留)""" - return QRLoginClient(USER_COOKIE_FILE) - - -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: str | None = None - 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 - assert result.file_path is not None - fp = Path(result.file_path) - if not fp.exists(): - # 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务 - failed.append(str(mid)) - _update_progress() - continue - mi = result.music_info - if mi is None: - # 与上面 fp.exists() 同口径:成功但缺元信息(上游详情缺失) - # 按单曲失败计,不让 mi.name 抛 AttributeError 把整批转 error - failed.append(str(mid)) - _update_progress() - continue - 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",退回 {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: str | None = None - zip_tmp_path: str | None = None - task_dir: Path | None = 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[str | None]) -> 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: str | None = 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}") from 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()}") - - -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: str | None = None - zip_tmp_path: str | None = None - task_dir_path: str | None = 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("任务已取消") - assert download_result.file_path is not None - 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) - +from .api_service import ( + APIConfig, + APIResponse, + MusicAPIService, + api_service, + config, +) +from .batch import ( + _batch_zip_worker, +) +from .downloads import ( + _admin_client, + _detect_vip, + _global_music_u, + _parse_cookie_str, + _single_download_worker, + _task_delivery_copy, + _user_client, + _vip_name, +) +from .podcast import ( + _podcast_cleanup_files, + _podcast_download_worker, + _podcast_resolve_audio, + _podcast_safe_name, + _podcast_stream_download, + _podcast_zip_worker, +) +from .streaming import ( + _stream_open, + _SyncStreamResponse, +) +from .tasks import ( + _quiesce_tasks_for_restore, + _sweep_download_cache, + _task_monitor_loop, + _touch_device, +) + +__all__ = [ + 'BATCH_ZIP_SEM', + 'COUNTED_PATHS', + 'ENDPOINT_NAMES', + '_COUNTED_LOWER', + '_LEDGER_LAST_CLEANUP', + '_LEDGER_LAST_RECOVER', + '_MONITOR_ERR_TS', + '_QUIET_LOG_PATHS', + '_TASKS_SWEEP_LAST', + 'APIConfig', + 'APIResponse', + 'MusicAPIService', + '_SyncStreamResponse', + '_admin_client', + '_batch_zip_worker', + '_build_download_base', + '_detect_vip', + '_global_music_u', + '_mark_task_terminal', + '_parse_cookie_str', + '_podcast_cleanup_files', + '_podcast_download_worker', + '_podcast_resolve_audio', + '_podcast_safe_name', + '_podcast_stream_download', + '_podcast_zip_worker', + '_quiesce_tasks_for_restore', + '_rate_ok', + '_record_log', + '_settle_batch_billing', + '_single_download_worker', + '_start_task_thread', + '_stream_open', + '_sweep_download_cache', + '_sweep_rate_buckets', + '_task_delivery_copy', + '_task_files', + '_task_monitor_loop', + '_touch_device', + '_user_client', + '_vip_name', + 'api_service', + 'config', +] diff --git a/services/streaming.py b/services/streaming.py new file mode 100644 index 0000000..5f703f2 --- /dev/null +++ b/services/streaming.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +"""同步流式响应垫片(音频分段透传)。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + + + +class _SyncStreamResponse: + """同步流式响应包装(把 aiohttp 的异步流按需拉成同步迭代)。 + + 供流式下载使用:worker 是同步线程,这里经 core.async_bridge 在后台事件循环 + 逐块取数据,避免引入 requests 依赖(Flask 已删除、requests 已移出依赖)。 + """ + + def __init__(self, url: str, timeout: float): + from core import async_bridge + self._bridge = async_bridge + self._url = url + self._timeout = timeout + self._agent = None + self.status = 0 + self.headers: dict[str, str] = {} + self._resp = None + + def __enter__(self): + self._agent = self._bridge.run_sync(self._open()) + self.status = self._agent[0] + self.headers = self._agent[1] + return self + + async def _open(self): + from core.http_client import client + resp = await client.open('GET', self._url, 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/', + }, timeout=self._timeout) + return resp.status, dict(resp.headers), resp + + def iter_content(self, chunk_size: int = 256 * 1024): + assert self._agent is not None + resp = self._agent[2] + it = resp.content.iter_chunked(chunk_size) + while True: + try: + chunk = self._bridge.run_sync(it.__anext__()) + except StopAsyncIteration: + break + yield chunk + + def __exit__(self, *exc): + if self._agent is not None and self._agent[2] is not None: + try: + self._agent[2].close() + except Exception: + pass + return False + + +def _stream_open(url: str, timeout: float = 60) -> _SyncStreamResponse: + """打开同步可迭代的流式响应(见 _SyncStreamResponse)。""" + return _SyncStreamResponse(url, timeout) diff --git a/services/tasks.py b/services/tasks.py new file mode 100644 index 0000000..d036326 --- /dev/null +++ b/services/tasks.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +"""任务监控循环与设备心跳。 + +(自 services/runtime.py 拆出,行为与原实现一致。) +""" + +import os +import shutil +import time +import traceback + +import auth_system +from core.config import ( + CANCEL_AFTER_SECONDS, + HEARTBEAT_INTERVAL, + PAUSE_AFTER_SECONDS, + RESULT_RETENTION_SECONDS, +) +from core.state import ( + DEVICE_LAST_SEEN, + DEVICE_LAST_SEEN_LOCK, + DOWNLOAD_TASKS, + DOWNLOAD_TASKS_LOCK, +) + +from ._shared import ( + _LEDGER_LAST_CLEANUP, + _LEDGER_LAST_RECOVER, + _MONITOR_ERR_TS, + _TASKS_SWEEP_LAST, + _sweep_rate_buckets, +) +from ._taskcore import ( + _task_files, +) +from .api_service import ( + api_service, +) + + +def _quiesce_tasks_for_restore() -> None: + """恢复数据库前取消全部在途任务(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) + + +def _sweep_download_cache(max_age_seconds: int = RESULT_RETENTION_SECONDS) -> None: + """下载缓存清扫(配套:批量任务不再即时删除共享缓存,改由这里按年龄回收)。 + 只清扫 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 + + +def _task_monitor_loop() -> None: + """后台监控: + - 心跳超时(40s)→ 暂停离线设备的批量任务,释放排队名额给下一位 + - 心跳超时 > 30 分钟 → 自动取消该设备的所有未完成任务并清理文件 + - 终态任务按 completed_at 保留 35 分钟(与前端「领取」入口对齐) + - 运行中/排队/暂停任务超过 4 小时仍未结束 → 置取消(cancel Event 一并置位) + - 下载缓存按年龄清扫(配套,同为 35 分钟口径) + - 顺带清理限流桶中的过期项(O-5:不再只依赖"新建桶时"的清理时机) + """ + while True: + time.sleep(HEARTBEAT_INTERVAL) + try: + now = time.time() + # 终态任务清理(按完成时刻;删除失败保留记录下轮重试,最多 8 次)---- + # 锁内只收集过期清单,文件删除移到锁外——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 # 文件暂时不可删(被占用等) + 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 + # 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}") + # 结算移到任务锁外(同 _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() + # 账本周期兜底(原仅启动执行一次)---- + # 孤儿对账:长运行进程内 open 且账龄 > 5h(严格大于任务 4h 硬超时 + 监控 + # tick 抖动——账本行 created_at 整秒截断使账龄恒 ≥ 任务龄,旧 4h 相等口径 + # 存在抢退合法 4h+ 长任务的窗口)的行每分钟周期检查退回; + # settled 行 90 天清理改为每小时低频执行,防长运行进程下表无界增长 + try: + # 时间戳门槛替代 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}") + # 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 # 单条竞态不中止整轮扫描 + 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 dev else None + if not gap: + continue + st = task.get('status') + if gap > CANCEL_AFTER_SECONDS and st in ('queued', 'running', 'paused'): + # 离线超 30 分钟:请求取消(文件删除与退款统一交给 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 _touch_device(device_id: str) -> None: + """记录设备心跳时间(设备表容量上限——device_id 是客户端任意串, + 登录用户可提交海量随机 ID 使字典无界增长,超限清最旧; + 入口已统一钳 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=lambda k: DEVICE_LAST_SEEN.get(k, 0.0))[:2500]: + DEVICE_LAST_SEEN.pop(d, None) + DEVICE_LAST_SEEN[device_id] = time.time() diff --git a/static/site_auth.js b/static/site_auth.js index 27868ac..26e22e8 100644 --- a/static/site_auth.js +++ b/static/site_auth.js @@ -17,7 +17,7 @@ }; } - var SITE_VERSION = '2.4.0'; // P3-12:与 APP_VERSION(main.py)、index.html CHANGELOG 对齐 + var SITE_VERSION = '2.4.0'; // 与后端 APP_VERSION、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 首歌),注册页领取,每设备限一次', '卡密系统升级为次数卡,后台可管理用户次数'] }, diff --git a/web/__init__.py b/web/__init__.py index ba4b739..a2ce089 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -1,11 +1,9 @@ # -*- coding: utf-8 -*- -"""FastAPI 应用层(Flask → FastAPI 迁移目标)。 +"""FastAPI 应用层——本项目的唯一服务端。 -迁移策略:strangler(绞杀者)——新路由在 FastAPI 侧实现,未迁移路由由 -遗留 Flask 应用兜底(见 web/app.py 的可选 WSGI 挂载)。两侧共享 core/ 的 -配置、运行时状态与工具,保证迁移期状态一致、行为可比。 +Flask 巨石已删除,全部 HTTP 端点、鉴权、静态资源与 SSE 均在本包内实现; +应用工厂见 web/app.py,路由按业务域拆分在 web/routers/ 下。 -本包只依赖 core/ 与上游服务模块(music_api 等),**不得**从 main.py 导入路由。 -当前为脚手架:仅迁移 /health、/api/info 两个无鉴权、无状态的系统端点, -作为验证「双框架响应契约一致」的样板。 +本包只依赖 core/ 与上游服务模块(music_api、auth_system、services 等), +**不得**反向从入口脚本导入任何路由定义。 """ diff --git a/web/app.py b/web/app.py index 750ae3e..bbd732b 100644 --- a/web/app.py +++ b/web/app.py @@ -1,11 +1,8 @@ # -*- coding: utf-8 -*- """FastAPI 应用工厂。 -设计(strangler 迁移): -- 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存, - 便于逐端点比对响应契约(.zcode/refactor/parity.py)。 -迁移已完成:Flask 巨石(main.py/auth_routes.py)已删除,本应用即唯一服务端。 -`api_prefix` 为空即直接服务前端使用的根路径;非空则挂在给定前缀下(用于契约比对)。 +本应用即唯一服务端:全部端点在 web/routers/ 下按业务域注册。 +`api_prefix` 用于把整套路由挂到指定前缀下(便于契约测试与反向代理)。 """ from contextlib import asynccontextmanager @@ -50,7 +47,7 @@ async def _lifespan(app: FastAPI): def _init_audio_secret() -> None: - """注入音频签名密钥(与 Flask 同源,令两栈 token 互认)。""" + """注入音频签名密钥(与 auth_system 同源,令直链 token 与服务端一致)。""" try: import auth_system @@ -64,7 +61,7 @@ def create_app(api_prefix: str = '') -> FastAPI: api_prefix:路由挂载前缀。 - ''(默认):直接服务前端使用的根路径(生产形态) - - '/api/v1':挂在子前缀下(契约比对脚本 .zcode/refactor/parity.py 用) + - '/api/v1':挂在子前缀下(便于契约测试与反向代理) """ _init_audio_secret() @@ -85,7 +82,7 @@ def create_app(api_prefix: str = '') -> FastAPI: if _static_dir.is_dir(): app.mount('/static', StaticFiles(directory=str(_static_dir)), name='static') - # 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比) + # 业务路由:统一挂到 api_prefix(默认空前缀,即前端使用的根路径) app.include_router(system_router.router, prefix=api_prefix, tags=['system']) app.include_router(site_router.router, prefix=api_prefix, tags=['site']) app.include_router(music_router.router, prefix=api_prefix, tags=['music']) @@ -101,7 +98,7 @@ def create_app(api_prefix: str = '') -> FastAPI: app.include_router(register_router.router, prefix=api_prefix, tags=['auth']) app.include_router(admin_site_router.router, prefix=api_prefix, tags=['admin']) app.include_router(download_router.router, prefix=api_prefix, tags=['download']) - # 首页与静态资源(迁移完成形态;fallback 模式下同路径也能由 Flask 提供) + # 首页(根路径,不挂前缀) app.include_router(index_router.router, tags=['index']) install_error_handlers(app) diff --git a/web/auth_state.py b/web/auth_state.py index bf65e7d..46d0bc0 100644 --- a/web/auth_state.py +++ b/web/auth_state.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""认证的内存态与校验辅助(原 auth_routes 的模块级状态,框架无关)。 +"""认证的内存态与校验辅助(模块级状态,框架无关)。 包含:登录失败锁定、管理员 2FA 票据、发码 IP 限流、用户名/保留名校验。 这些状态**进程内共享**——登录/注册/2FA 各端点都读写同一份,避免「换个端点绕过锁定」。 @@ -38,7 +38,7 @@ _ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限 _ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧) -_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——第 87 条初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环 +_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环 def _admin2fa_purge() -> None: @@ -58,7 +58,7 @@ def _admin2fa_mask(email: str) -> str: def _admin2fa_issue(user) -> str: _admin2fa_purge() - # 第 87 条:每管理员仅 1 张活票——新票签发即销毁同账号旧票 + # 每管理员仅 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: @@ -111,7 +111,7 @@ def _login_fail_clear(username: str) -> None: def _login_fail_locked_log_once(username: str) -> bool: - """R5-4:锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表; + """锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表; 解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。""" if username in _LOGIN_FAIL_LOGGED: return False @@ -121,7 +121,7 @@ def _login_fail_locked_log_once(username: str) -> bool: def _safe_register_credits() -> int: """注册赠送次数(settings.register_credits,默认 3)。 - R5 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。""" + 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。""" try: credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0) except (TypeError, ValueError): @@ -139,7 +139,7 @@ _SEND_CODE_IP_WINDOW = 3600 def _send_code_ip_check(ip: str) -> bool: - """返回 True 表示允许发送;超限返回 False(S4:表超限时顺手清理过期项,防无界增长)""" + """返回 True 表示允许发送;超限返回 False(表超限时顺手清理过期项,防无界增长)""" 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] @@ -159,8 +159,8 @@ _RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'mod def re_username_ok(username: str) -> bool: import re as _re - # P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险) - # R5-P3:白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符 + # 禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险) + # 白名单收紧为中文/字母/数字/下划线——`\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('_') \ diff --git a/web/billing.py b/web/billing.py index ffeadb9..c69b501 100644 --- a/web/billing.py +++ b/web/billing.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""FastAPI 计费依赖(账本预扣/退款),与 Flask login_required 同口径。 +"""计费依赖(账本预扣/退款),口径与登录闸一致。 -Flask 版 login_required 用 flask.g 暂存请求级账本键;FastAPI 无 g,改用 +历史实现用请求上下文(flask.g)暂存请求级账本键;本实现无 g,改用 「依赖返回值携带 ledger_key + 路由内显式退款」的方式,语义完全一致: -- 预扣与开账同事务:auth_system.reserve_and_open(A2/B8) +- 预扣与开账同事务:auth_system.reserve_and_open - 失败退款:只退「仍 open 的账本行」(与装饰器 _refund_open 同口径,杜绝双退) - 任务接手:auth_system.ledger_rename 把请求级行改挂 task_id(worker 结算唯一入口) diff --git a/web/deps.py b/web/deps.py index de04242..5c95ea5 100644 --- a/web/deps.py +++ b/web/deps.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """FastAPI 依赖:会话解析、当前用户、登录校验。 -与 Flask 侧 auth_system 的语义对齐(迁移期两栈共用同一 Cookie、同一站点库): +与 auth_system 的语义对齐(共用同一 Cookie、同一站点库): - 会话 Cookie 经 core.session 解码(Flask 兼容签名),密钥同源 - 封禁账号(status 0/2):按未登录处理 - 顶号校验:会话内的 `site_uid_seq` 必须与库中 `login_seq` 一致,否则视为失效 -- 剩余次数/账本预扣属付费流程,待付费路由迁移时再补(此处先覆盖免费端点) +- 剩余次数/账本预扣属付费流程,由 web.billing 承担(此处只做身份解析) """ from dataclasses import dataclass from typing import Annotated, Any @@ -20,7 +20,7 @@ _SESSION_COOKIE = 'session' def secret_key() -> str: - """会话签名密钥(与 Flask 同源:data/secret.key)。""" + """会话签名密钥(密钥来源:data/secret.key)。""" import auth_system return auth_system.get_secret_key() @@ -75,7 +75,7 @@ async def current_user( async def require_login( user: Annotated[SiteUser | None, Depends(current_user)], ) -> SiteUser: - """登录闸:未登录抛 401(与 Flask login_required 同形包体),已登录返回用户。""" + """登录闸:未登录抛 401,已登录返回用户。""" if user is None: raise AppError('请先登录后再使用解析功能', 401, error_code='unauthorized') return user @@ -84,7 +84,7 @@ async def require_login( async def require_admin( user: Annotated[SiteUser | None, Depends(current_user)], ) -> SiteUser: - """管理员闸:非管理员抛 403(与 Flask admin_required 同形包体)。""" + """管理员闸:非管理员抛 403。""" if user is None or not user.is_admin: raise AppError('需要管理员权限', 403, error_code='forbidden') return user diff --git a/web/errors.py b/web/errors.py index aed8c8a..7210ce3 100644 --- a/web/errors.py +++ b/web/errors.py @@ -2,7 +2,7 @@ """统一应用异常与错误响应。 FastAPI 依赖里无法直接「返回响应短路」,因此用抛出 AppError + 全局异常处理器 -的方式,产出与 Flask 侧同形的 JSON 包体({status, success, message, error_code})。 +的方式,产出统一的 JSON 包体({status, success, message, error_code})。 """ from typing import Any @@ -30,7 +30,7 @@ def install_error_handlers(app) -> None: @app.exception_handler(StarletteHTTPException) async def _http_error(_request: Request, exc: StarletteHTTPException) -> Any: - # 404/405 等也统一为本站包体(Flask 侧 404 返回 {'success': False, ...}) + # 404/405 等也统一为本站包体(404 也返回 {'success': False, ...}) msg = {404: '请求的资源不存在', 405: '请求方法不允许'}.get(exc.status_code, str(exc.detail or '请求错误')) return error(msg, exc.status_code) diff --git a/web/middleware.py b/web/middleware.py index 7c86e48..a0f0fa3 100644 --- a/web/middleware.py +++ b/web/middleware.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -"""FastAPI 中间件:镜像 Flask after_request 的响应侧行为。 +"""FastAPI 中间件:响应侧统一处理(安全头 / 直链签名 / 指纹抹除)。 -与 main.py `after_request` 对齐的关键点: +处理要点: 1. 音频直链保护——JSON 响应中的网易云音频地址替换为 HMAC 签名代理地址 - (`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 Flask 同源, - 故两栈签发的 token 互相可校验。 + (`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 core.security 同源, + 故校验口径一致。 2. 零成本安全响应头(nosniff / DENY / Referrer-Policy / CSP 基线)。 3. 抹去服务器版本指纹(Server 头统一为 NeteaseUrl)。 4. 429 统一带 Retry-After,供客户端退避。 diff --git a/web/ratelimit.py b/web/ratelimit.py index 20fd66c..a66d657 100644 --- a/web/ratelimit.py +++ b/web/ratelimit.py @@ -1,19 +1,19 @@ # -*- coding: utf-8 -*- -"""公网限流(FastAPI 栈)——按 IP 滑动窗口,与 Flask 侧 _rate_ok 同一实现口径。 +"""公网限流——按 IP 滑动窗口。 -复用 core.state.RATE_BUCKETS/_RATE_LOCK,使两栈在迁移期共享同一批限流桶 -(同一 IP 无论打到哪个栈都计入同一窗口),避免出现「绕到另一个框架即绕过限流」。 +限流桶集中在 core.state.RATE_BUCKETS/_RATE_LOCK,使所有端点共享同一批桶 +(同一 IP 一律计入同一窗口),避免出现绕过限流的口子。 """ import collections import time from fastapi import Request -from core.config import RESULT_RETENTION_SECONDS # noqa: F401 - 保持与 Flask 侧常量同源 +from core.config import RESULT_RETENTION_SECONDS # noqa: F401 - 再导出,与 core.config 口径同源 from core.state import RATE_BUCKETS, RATE_LOCK from web.errors import AppError -# 免于限流的端点(试听流/进度轮询,与 Flask _QUIET/_rate_limit_guard 同口径) +# 免于限流的端点(试听流/进度轮询) _EXEMPT_PREFIXES = ('/audio_stream',) _EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'} @@ -21,7 +21,7 @@ _EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'} def client_ip(request: Request) -> str: """客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP。 - 规则与 Flask 时代完全一致(core.net 与原实现同源):XFF 最右段优先, + 规则见 core.net(与原实现同源):XFF 最右段优先, 最右段为回环时回落 X-Real-IP,全部无效回落直连地址。 """ from core.net import is_loopback_ip, trusted_proxy_ip @@ -41,7 +41,7 @@ def client_ip(request: Request) -> str: def rate_ok(key: str, limit: int, window: int) -> bool: - """滑动窗口限流:True=放行。与 Flask main._rate_ok 同算法、同桶容器。""" + """滑动窗口限流:True=放行。""" now = time.time() with RATE_LOCK: dq = RATE_BUCKETS.get(key) @@ -60,7 +60,7 @@ def rate_ok(key: str, limit: int, window: int) -> bool: async def guard(request: Request, *, bucket: str, limit: int, window: int) -> None: - """限流闸:超限抛 429(与 Flask 包体同形)。豁免端点直接放行。""" + """限流闸:超限抛 429。豁免端点直接放行。""" path = request.url.path if path in _EXEMPT_PATHS or path.startswith(_EXEMPT_PREFIXES): return diff --git a/web/response.py b/web/response.py index ef75b08..2ae250a 100644 --- a/web/response.py +++ b/web/response.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -"""响应工具:与 Flask 侧 APIResponse 的 JSON 契约逐字段对齐。 +"""响应工具:统一 JSON 包体契约。 -Flask 的 APIResponse.success/error 返回 (dict, status) 元组,由 Flask 序列化。 +沿用历史 APIResponse 的包体结构(逐字段一致): FastAPI 侧改为直接返回 JSONResponse,但**包体结构完全一致**: success: {"status": sc, "success": true, "message": msg, ["data": ...]} error: {"status": sc, "success": false, "message": msg, ["error_code": ...]} -这样前端无需任何改动即可同时对接两个框架。 +这样前端无需任何改动即可无缝对接。 """ from typing import Any @@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse def success(data: Any = None, message: str = 'success', status_code: int = 200) -> JSONResponse: - """成功响应(对应 Flask APIResponse.success)""" + """成功响应。""" body = { 'status': status_code, 'success': True, @@ -26,7 +26,7 @@ def success(data: Any = None, message: str = 'success', status_code: int = 200) def error(message: str, status_code: int = 400, error_code: str | None = None) -> JSONResponse: - """错误响应(对应 Flask APIResponse.error)""" + """错误响应。""" body = { 'status': status_code, 'success': False, diff --git a/web/routers/account.py b/web/routers/account.py index 16f1bdf..4e29a07 100644 --- a/web/routers/account.py +++ b/web/routers/account.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""账号与全局歌单端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。 +"""账号与全局歌单端点。 -已迁移: +端点: - POST/GET /api/v1/account/vip ↔ Flask /account/vip(admin/user 双路径 + 缓存) - POST/GET /api/v1/global/playlists ↔ Flask /global/playlists(管理员) - POST/GET /api/v1/global/playlist ↔ Flask /global/playlist(管理员) @@ -31,7 +31,7 @@ _VIP_NAMES = { 0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP', } -# 探测单飞:与 Flask _VIP_PROBE_LOCK 同源(跨栈共享,避免两栈各自串行 8 级探测) +# 探测单飞:与 core.state 中同一把锁共享,避免并发请求各自串行 8 级探测 _VIP_PROCESS_LOCK: Any = VIP_PROBE_LOCK @@ -44,7 +44,7 @@ def _cookies() -> dict[str, str]: async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]: - """检测账号会员类型与可解析音质(与 Flask _detect_vip 同口径)。 + """检测账号会员类型与可解析音质。 单飞锁为 threading.Lock:异步上下文里用 to_thread 有界等待 0.5s, 拿不到锁的请求直接用账号接口给降级结果(不等 8 级探测)。 @@ -100,7 +100,7 @@ async def account_vip( 'vip_name': _vip_name(vip_type), 'detected_by': method, 'levels_ok': levels_ok, 'nickname': (info or {}).get('nickname', ''), } - # 降级结果(levels_ok 空)短缓存 30s,正常结果 300s(与 Flask 同口径) + # 降级结果(levels_ok 空)短缓存 30s,正常结果 300s ttl = 30 if not levels_ok else 300 if len(VIP_USER_CACHE) > 500: VIP_USER_CACHE.clear() diff --git a/web/routers/admin.py b/web/routers/admin.py index 9a9009f..bb3ae6b 100644 --- a/web/routers/admin.py +++ b/web/routers/admin.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -"""管理员扫码登录 / Cookie 管理 / 操作记录(FastAPI 栈)。 +"""管理员扫码登录 / Cookie 管理 / 操作记录。 -已迁移: +端点: - POST /api/v1/admin/qr/create ↔ Flask /admin/qr/create - POST /api/v1/admin/qr/check ↔ Flask /admin/qr/check - POST /api/v1/admin/cookie/status ↔ Flask /admin/cookie/status - POST /api/v1/admin/cookie/delete ↔ Flask /admin/cookie/delete - POST /api/v1/admin/logs ↔ Flask /admin/logs -鉴权:Flask 这些端点用 _verify_admin_password(文案"需要管理员权限,请使用管理员账号登录"), +鉴权:管理员密码闸(文案"需要管理员权限,请使用管理员账号登录"), 与 auth_system.admin_required 的文案不同,故本模块用本地 admin 闸保持逐字对齐。 说明:QRLoginManager 已异步化(直接 await);QRLoginClient 的 save_cookie/logout/ @@ -34,7 +34,7 @@ logger = logging.getLogger('web.admin') async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)]) -> SiteUser: - """管理员闸(文案与 Flask _verify_admin_password 逐字对齐)。""" + """管理员闸(文案与历史实现逐字对齐)。""" if user is None or not user.is_admin: raise AppError('需要管理员权限,请使用管理员账号登录', 403) return user @@ -52,7 +52,7 @@ def _admin_client(): def _reset_caches() -> None: - """Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存(与 Flask 同口径)。""" + """Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存。""" VIP_CACHE['data'] = None VIP_CACHE['ts'] = 0.0 GLOBAL_MUSIC_U_CACHE['v'] = None diff --git a/web/routers/admin_site.py b/web/routers/admin_site.py index 48fd554..e3002a6 100644 --- a/web/routers/admin_site.py +++ b/web/routers/admin_site.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- -"""管理后台(/admin/site/*)端点(FastAPI 栈)——与 Flask 侧逐字段对齐。 +"""管理后台(/admin/site/*)端点。 鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。 说明:这些端点多为对 auth_system 同步函数(SQLite / SMTP / 文件)的薄封装, -统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名 -(与 Flask _actor 同口径)。 +统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名。 """ import asyncio import math diff --git a/web/routers/auth.py b/web/routers/auth.py index a3a497f..a41e542 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -1,18 +1,18 @@ # -*- coding: utf-8 -*- -"""认证操作端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。 +"""认证操作端点。 -已迁移: +端点: - POST /api/v1/auth/logout ↔ Flask /auth/logout(顶号:递增 login_seq + 删 Cookie) - POST /api/v1/auth/heartbeat ↔ Flask /auth/heartbeat - POST /api/v1/auth/ann_seen ↔ Flask /auth/ann_seen - POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据) 设计: -- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,两栈共享同一份 +- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,全进程共享同一份 计数/票据,避免「换个框架打就绕过锁定」。 - 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行, 不阻塞事件循环。 -- 会话 Cookie 由 core.session 签发(与 Flask 同格式同密钥),删 Cookie 亦同口径。 +- 会话 Cookie 由 core.session 签发(沿用同一格式与密钥,存量会话继续有效),删 Cookie 亦同口径。 """ import asyncio import os @@ -52,7 +52,7 @@ def _cookie_kwargs() -> dict: def _login_response(payload: dict, message: str, uid: int, seq: int) -> JSONResponse: - """构造带会话 Cookie 的成功响应(Cookie 格式与 Flask 一致)。""" + """构造带会话 Cookie 的成功响应(沿用同一 Cookie 格式)。""" resp = JSONResponse(content={'status': 200, 'success': True, 'message': message, 'data': payload}) cookie = cs.dumps(deps.secret_key(), { 'site_uid': uid, 'site_uid_seq': seq, 'site_uid_ts': int(time.time()), @@ -78,7 +78,7 @@ async def logout(user: Annotated[SiteUser | None, Depends(current_user)]): @router.post('/auth/heartbeat') async def heartbeat(user: Annotated[SiteUser | None, Depends(current_user)]): - """会话保活:返回最新次数;超限/失效给 401(expired 语义与 Flask 同)。""" + """会话保活:返回最新次数;超限/失效给 401(expired 语义不变)。""" if user is None: return error('登录已失效', 401, error_code='unauthorized') row = await repo.get_user_by_id(user.id) diff --git a/web/routers/auth_status.py b/web/routers/auth_status.py index e5e7852..47f17e0 100644 --- a/web/routers/auth_status.py +++ b/web/routers/auth_status.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""认证状态端点(FastAPI 栈)——与 Flask /auth/me 响应契约逐字段对齐。 +"""认证状态端点。 - GET/POST /api/v1/auth/me ↔ Flask /auth/me · user:站点用户公开字段(未登录为 None) @@ -28,7 +28,7 @@ async def auth_me( user: Annotated[SiteUser | None, Depends(current_user)], request: Request, ): - """当前会话与站点公开配置(未登录也返回,与 Flask 同口径)。""" + """当前会话与站点公开配置(未登录也返回)。""" await ratelimit.guard(request, bucket='free', limit=30, window=60) try: ann = await repo.get_announcement() diff --git a/web/routers/delivery.py b/web/routers/delivery.py index b14a69a..45e3cf5 100644 --- a/web/routers/delivery.py +++ b/web/routers/delivery.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -"""结果交付端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。 +"""结果交付端点。 -已迁移: +端点: - POST/GET /api/v1/download/result ↔ Flask /download/result(单曲) - POST/GET /api/v1/download/batch_zip/result ↔ Flask /download/batch_zip/result(批量 ZIP) 语义对齐要点: - 任务归属:仅任务本人的站点账号可领取(非本人 403) - 状态语义:进行中 202 / 暂停 200+提示 / 取消 200+原因 / 失败 500+原因 / - 文件缺失 404(与 Flask 分支逐一对应) + 文件缺失 404(与历史分支逐一对应) - 交付物:FileResponse 流式发送;Cache-Control: no-store; X-Download-Filename(URL 编码,供前端取回原始文件名) - 文件清理仍由监控线程按 35 分钟保留期统一回收(交付请求不删文件) diff --git a/web/routers/download.py b/web/routers/download.py index ae968e2..fc6f0ed 100644 --- a/web/routers/download.py +++ b/web/routers/download.py @@ -1,15 +1,15 @@ # -*- coding: utf-8 -*- -"""下载提交端点(FastAPI 栈)——与 Flask 侧逐字段对齐。 +"""下载提交端点。 -已迁移: +端点: - POST /api/v1/download ↔ Flask /download(计费 1 次;file/json 两模式) - POST /api/v1/download/batch_zip ↔ Flask /download/batch_zip(按去重歌曲数计费) - POST /api/v1/podcast/download ↔ Flask /podcast/download(计费 1 次) - POST /api/v1/podcast/download/batch_zip ↔ Flask /podcast/download/batch_zip 计费:经 web.billing.require_billing(预扣+开账同事务 / 失败只退 open 行), -任务登记与启动复用 web.tasks_registry(与 main._register_download_task 同口径); -worker 仍复用 main 的既有实现(无 flask 依赖),故账本与交付语义不变。 +任务登记与启动由 web.tasks_registry 承担; +worker 复用 services 层既有实现,账本与交付语义不变。 """ import asyncio import logging @@ -283,7 +283,7 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us # ============ 播客单集下载 ============ def _podcast_billing_count(data: dict[str, Any]) -> int: - """播客打包计费(与 Flask _podcast_billing_count 同口径)。""" + """播客打包计费。""" if data.get('radio_id'): return 1 limit = _safe_int(data.get('limit', 200) or 200, 200) diff --git a/web/routers/index.py b/web/routers/index.py index 914ca6c..7ea8677 100644 --- a/web/routers/index.py +++ b/web/routers/index.py @@ -1,11 +1,8 @@ # -*- coding: utf-8 -*- -"""首页与静态资源(FastAPI 栈)。 +"""首页与静态资源。 -- GET / 首页:渲染同一份 templates/index.html,禁止缓存(与 Flask 一致) +- GET / 首页:渲染 templates/index.html,禁止缓存 - 静态资源由 Starlette StaticFiles 挂载 /static(见 web/app.py) - -说明:首页在 Flask fallback 模式下由 Flask 兜底提供;本端点用于「关闭 Flask 后」 -FastAPI 独立提供首页(迁移完成的形态)。两者渲染的是同一模板,故内容一致。 """ from pathlib import Path @@ -21,8 +18,8 @@ _TEMPLATE = Path(__file__).resolve().parent.parent.parent / 'templates' / 'index async def index(request: Request) -> HTMLResponse: """首页(禁止缓存,确保前端更新即时生效)。 - 用 Jinja2 以 Flask 相同的默认口径渲染(keep_trailing_newline=False), - 使输出与 Flask render_template 逐字节一致。 + 用 Jinja2 以与历史模板渲染一致的默认口径(keep_trailing_newline=False), + 使输出逐字节不变。 """ import jinja2 diff --git a/web/routers/music.py b/web/routers/music.py index 100264a..d7f3638 100644 --- a/web/routers/music.py +++ b/web/routers/music.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""音乐端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。 +"""音乐端点。 -已迁移: +端点: - 免费解析类(需登录,不扣次数): POST/GET /api/v1/toplist ↔ Flask /toplist POST/GET /api/v1/artist_songs ↔ Flask /artist_songs @@ -11,7 +11,7 @@ POST/GET /api/v1/playlist ↔ Flask /playlist POST/GET /api/v1/song ↔ Flask /song(四种 type:url/name/lyric/json) -参数获取沿用 Flask 口径:GET query / POST json / POST form 三路兼容。 +参数获取口径:GET query / POST json / POST form 三路兼容。 上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。 """ import asyncio @@ -62,7 +62,7 @@ def _nick_lock() -> asyncio.Lock: async def mask_server_nickname(nick: str, user: SiteUser) -> str: - """非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。 + """非管理员响应中把服务器账号昵称替换为站点名。 - 管理员本人可见真实昵称; - 缓存 10 分钟内复用;上游拿不到时回落站点库里的最后已知昵称继续遮蔽。 @@ -175,7 +175,7 @@ async def search(user: LoginUser, request: Request): except APIException: return error('服务暂时不可用,请稍后再试', 500) - # 向后兼容字段 + 服务器昵称遮蔽(与 Flask 侧同口径) + # 向后兼容字段 + 服务器昵称遮蔽 if result: for song in result: if song.get('type', 'song') == 'song' and 'artists' in song: @@ -191,7 +191,7 @@ async def search(user: LoginUser, request: Request): # ============ 个人/全局账号 Cookie ============ def _global_music_u() -> str: - """全局账号 MUSIC_U(5 分钟缓存,与 Flask main._global_music_u 同源同缓存)。""" + """全局账号 MUSIC_U(5 分钟缓存)。""" now = time.time() if GLOBAL_MUSIC_U_CACHE['v'] is None or now - GLOBAL_MUSIC_U_CACHE['ts'] > 300: try: @@ -209,7 +209,7 @@ async def _resolve_user_cookies( """解析个人账号 Cookie(浏览器传入的 user_cookie,不落服务器)。 全局账号(cookie.txt)零个人身份权限:浏览器提交的 MUSIC_U 若与全局账号一致 - (全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计(与 Flask 同口径)。 + (全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计。 """ ck = media.parse_cookie_str(data.get('user_cookie')) mu = (ck.get('MUSIC_U') or '').strip() diff --git a/web/routers/podcast.py b/web/routers/podcast.py index 9932f76..e31ca9e 100644 --- a/web/routers/podcast.py +++ b/web/routers/podcast.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -"""播客端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。 +"""播客端点。 -已迁移(免费解析类,需登录,不扣次数): +端点(免费解析类,需登录,不扣次数): - POST/GET /api/v1/podcast/parse ↔ Flask /podcast/parse - POST/GET /api/v1/podcast/programs ↔ Flask /podcast/programs - POST/GET /api/v1/podcast/search ↔ Flask /podcast/search - POST/GET /api/v1/podcast/hot ↔ Flask /podcast/hot 说明:podcast_api 仍是同步模块(requests),此处以 asyncio.to_thread 包装, -让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。 +让阻塞的上游调用不占用事件循环——行为不变,且不阻塞其它请求。 """ import asyncio from typing import Annotated @@ -164,6 +164,6 @@ async def podcast_hot(_user: LoginUser, request: Request): categories = await asyncio.to_thread(podcast_api.get_categories) except APIException: categories = [] - # 满页视为可能还有更多(与 Flask 同口径) + # 满页视为可能还有更多 return success({'radios': radios, 'categories': categories, 'has_more': len(radios) >= limit}, '获取热门播客成功') diff --git a/web/routers/register.py b/web/routers/register.py index 5537ad3..ff84709 100644 --- a/web/routers/register.py +++ b/web/routers/register.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""注册 / 找回密码 / 卡密 / 验证码(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。 +"""注册 / 找回密码 / 卡密 / 验证码。 -已迁移: +端点: - POST /api/v1/auth/send_code ↔ Flask /auth/send_code - POST /api/v1/auth/register ↔ Flask /auth/register - POST /api/v1/auth/trial ↔ Flask /auth/trial @@ -10,7 +10,7 @@ 说明: - 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行, - 不阻塞事件循环;行为与 Flask 完全一致。 + 不阻塞事件循环;行为与历史实现完全一致。 - 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。 - 失败锁定/验证码消费等安全口径逐字保留。 """ diff --git a/web/routers/site.py b/web/routers/site.py index 5e6d825..e7b406f 100644 --- a/web/routers/site.py +++ b/web/routers/site.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -"""站点数据端点(FastAPI 栈):走 core.db 异步 SQLite(读并发/写串行/WAL)。 +"""站点数据端点:走 core.db 异步 SQLite(读并发/写串行/WAL)。 -这些是**新增**的 `/api/v1` 只读端点,用于验证异步读写路径与共享同一份站点库; -不替代、不影响 Flask 侧既有端点。鉴权端点(需登录/管理员)待会话互通层就绪后再迁移。 +这些是 `/api/v1` 下的只读端点,走异步读写路径并共享同一份站点库; +鉴权端点(需登录/管理员)由 web.deps 统一把关。 注意:本模块只做读(与 announcements/settings 这些低风险资源对应), -写操作仍归 auth_system 的事务口径,避免迁移期两套写路径互相踩踏。 +写操作统一归 auth_system 的事务口径,避免两套写路径互相踩踏。 """ from fastapi import APIRouter diff --git a/web/routers/stream.py b/web/routers/stream.py index 1b82c01..a4fe4cc 100644 --- a/web/routers/stream.py +++ b/web/routers/stream.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -"""音频流代理(FastAPI 栈)——与 Flask /audio_stream 行为逐项对齐。 +"""音频流代理。 -要点(对齐 Flask 实现): -- 签名校验:token 由 core.security 校验(密钥同源,两栈 token 互认);失效 403。 +要点: +- 签名校验:token 由 core.security 校验(密钥同源);失效 403。 - HEAD 无正文:直接 200 + Accept-Ranges,不请求上游。 - Range 透传:客户端 Range 原样转发上游,206/Content-Range 原样回传(拖动进度可用)。 - 编码透明:上游若带 Content-Encoding(aiohttp 会解压),不得沿用压缩前的 - Content-Length/Content-Range(否则头体不一致,同 Flask F92)。 + Content-Length/Content-Range(否则头体不一致)。 - 流式转发:16KB 分块,不整段入内存;下游中断时 finally 关闭上游响应。 - Cache-Control: no-store。 """ diff --git a/web/routers/system.py b/web/routers/system.py index a3dde99..9e64959 100644 --- a/web/routers/system.py +++ b/web/routers/system.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""系统端点(迁移样板):/health、/api/info。 +"""系统端点:/health、/api/info。 -/flask 侧的这两个端点无鉴权、无状态、无任务依赖,是最安全的迁移起点。 -此处实现与 main.py 的 health_check / api_info **响应契约逐字段对齐**, -用于验证双框架一致性(见 .zcode/refactor/parity.py)。 +两个端点无鉴权、无状态、无任务依赖。 +响应契约与历史快照逐字段对齐, +由 tests/golden/responses.json 兜住回归。 """ import sys @@ -16,7 +16,7 @@ from web.response import error, success router = APIRouter() -# 与 main.py api_info 的 endpoints 表保持一致(迁移期以 Flask 侧为准,逐字对齐) +# 与 api_info 的 endpoints 表保持一致(逐字对齐历史契约) _ENDPOINTS = { '/health': 'GET - 健康检查', '/song': 'GET/POST - 获取歌曲信息', @@ -51,7 +51,7 @@ def health_check(): 'timestamp': int(time.time()) if 'time' in sys.modules else None, 'cookie_status': cookie_status, 'cookie_count': len(cookies), - 'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径 + 'downloads_dir': 'configured', # 不向匿名访客回显服务器绝对路径 'version': APP_VERSION, } return success(health_info, "API服务运行正常") diff --git a/web/routers/tasks.py b/web/routers/tasks.py index a39b58f..1d2637d 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""任务状态端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。 +"""任务状态端点。 -已迁移: +端点: - POST/GET /api/v1/topen ↔ Flask /api/topen(请求统计,无鉴权) - POST/GET /api/v1/queue ↔ Flask /api/queue(排队状态,无鉴权) - POST/GET /api/v1/batch_progress ↔ Flask /api/batch_progress(需登录,仅本人任务) @@ -9,8 +9,8 @@ - POST/GET /api/v1/task/heartbeat ↔ Flask /api/task/heartbeat(需登录) - POST/GET /api/v1/task/list ↔ Flask /api/task/list(需登录) -这些端点读写 core.state 的共享任务表(与 Flask 同一批对象),因此两栈在迁移期 -看到的是同一份任务状态——这是「单进程双框架」能成立的关键。 +这些端点读写 core.state 的共享任务表(与任务执行侧同一批对象),因此 +看到的是同一份任务状态。 """ import time from typing import Annotated, Any @@ -41,7 +41,7 @@ LoginUser = Annotated[SiteUser, Depends(require_login)] def _touch_device(device_id: str) -> None: - """记录设备心跳(与 Flask _touch_device 同口径,共享 DEVICE_LAST_SEEN)。""" + """记录设备心跳(与 core.state 的 DEVICE_LAST_SEEN 共享)。""" device_id = str(device_id or '')[:64] if not device_id: return @@ -57,7 +57,7 @@ def _owned_by(task: dict[str, Any], user: SiteUser) -> bool: def _semaphores(): - """批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。""" + """批量打包/下载信号量(懒取,与 core.state 中的同一对象共享计数)。""" import services.runtime as services return services.BATCH_ZIP_SEM, services.api_service.downloader._download_semaphore diff --git a/web/routers/user.py b/web/routers/user.py index 5c4e832..afd17bd 100644 --- a/web/routers/user.py +++ b/web/routers/user.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""个人账号与公开播放端点(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。 +"""个人账号与公开播放端点。 -已迁移: +端点: - POST /api/v1/user/qr/create ↔ Flask /user/qr/create(扫码登录二维码 SVG) - POST /api/v1/user/qr/check ↔ Flask /user/qr/check(轮询登录状态) - POST /api/v1/user/status ↔ Flask /user/status diff --git a/web/serializers.py b/web/serializers.py index 0f323e2..410515c 100644 --- a/web/serializers.py +++ b/web/serializers.py @@ -5,13 +5,13 @@ from typing import Any from core.state import REQ_LOG -# 站点用户公开字段(与 Flask auth_system._user_public 的非 full 口径一致) +# 站点用户公开字段 _PUBLIC_FIELDS = ('id', 'username', 'email', 'role', 'duration_seconds', 'created_at', 'last_login', 'ann_seen') def format_credits(count: int) -> str: - """次数文案(与 Flask auth_system.format_credits 逐字一致)。""" + """次数文案。""" if count < 0: return '不限次数' if count <= 0: diff --git a/web/tasks_registry.py b/web/tasks_registry.py index 1fb61dc..b42e9b2 100644 --- a/web/tasks_registry.py +++ b/web/tasks_registry.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""下载/播客任务登记(FastAPI 栈)——镜像 main._register_download_task。 +"""下载/播客任务登记。 -与 Flask 版的唯一差异:不依赖 flask.g,请求级账本键由 Billing 显式携带。 +与原实现的唯一差异:不依赖请求上下文(flask.g),请求级账本键由 Billing 显式携带。 其余(归属、容量闸、task_id 白名单与碰撞换新、账本承接与撞名重试)逐条对齐。 """ import logging @@ -46,7 +46,7 @@ async def register_download_task(billing: Billing, task: dict[str, Any], task['task_id'] = tid DOWNLOAD_TASKS[tid] = task - # 账本承接(撞名重试,与 Flask 同口径) + # 账本承接(撞名重试) try: renamed = await billing.rename_to(tid) retries = 0 diff --git a/功能介绍.md b/功能介绍.md deleted file mode 100644 index 8785858..0000000 --- a/功能介绍.md +++ /dev/null @@ -1,170 +0,0 @@ -# 云村小助手 · 功能介绍 - -> 一个基于 FastAPI + 原生前端的云村小助手。 -> 支持搜索、单曲/歌单/专辑/歌手解析、官方榜单、个人歌单、多账号体系、批量下载、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 deleted file mode 100644 index c98b086..0000000 --- a/部署文档.md +++ /dev/null @@ -1,211 +0,0 @@ -# 云村小助手 · 部署文档(仅 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 归属地检测、音频代理) | - ---- - -## 二、文件说明 - -``` -NeteaseUrl/ -├── web/ # FastAPI 应用(唯一服务端) -│ ├── main.py # uvicorn 入口(python web/main.py) -│ ├── app.py # create_app 工厂(挂路由 / 静态资源 / 中间件) -│ ├── deps.py # 会话解析、当前用户、登录/管理员依赖 -│ ├── billing.py # 计费依赖(预扣+开账同事务、只退 open 行) -│ ├── tasks_registry.py # 任务登记/启动(容量闸、账本承接) -│ ├── middleware.py # 安全头 + 音频直链签名保护 -│ ├── ratelimit.py # 按 IP 滑动窗限流(与 core.state 共享桶) -│ ├── auth_state.py # 登录失败锁定 / 管理员 2FA 票据 / 发码限流 -│ └── routers/ # 各领域路由(music/podcast/download/auth/admin…) -├── core/ # 框架无关核心层 -│ ├── config.py # 常量与配置 -│ ├── state.py # 进程内运行时状态(任务表/限流桶/缓存) -│ ├── db.py / repo.py # 异步 SQLite(WAL:读并发、写串行) -│ ├── http_client.py # aiohttp 连接池客户端 -│ ├── aiohttp_compat.py # requests 同形异步垫片 -│ ├── async_bridge.py # 同步↔异步桥(遗留同步调用点) -│ ├── session.py # itsdangerous 兼容会话编解码 -│ ├── security.py # 音频 HMAC 签名 -│ ├── net.py / media.py / utils.py / serializers… # 纯工具 -├── services/runtime.py # 服务层(MusicAPIService、下载/打包 worker、任务监控) -├── auth_system.py # 站点账号系统(SQLite:用户/卡密/公告/日志/封禁/顶号) -├── 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 # 前端认证 / 个人中心 / 管理后台交互 -├── pyproject.toml # 依赖与工具配置(uv 管理) -├── uv.lock # 精确锁定依赖版本 -├── tests/ # 测试(含 golden 契约回归) -├── Dockerfile # 镜像构建文件(uv sync) -├── 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:数据库损坏 / 恢复** -后台 →「数据库管理」→ 上传历史备份文件即可覆盖恢复。 - ----