Archived
refactor!: uv 管理环境 + 彻底删除技术债(Flask 与 requests 全部移除)
## uv 环境管理 - pyproject.toml 声明 dependencies + [dependency-groups].dev,uv.lock 精确锁定 - 删除 requirements.txt / requirements-fastapi.txt / requirements-dev.txt - .python-version = 3.12;Dockerfile 改用 uv sync --frozen(可复现构建) - 新增 jinja2(首页渲染) ## 删除 Flask 技术债 - 从 main.py 用 ast 精确剥离 56 个 Flask 节点(路由/中间件/错误处理/启动), 框架无关的服务层落为 services/runtime.py(MusicAPIService、下载/打包 worker、 任务监控、缓存清扫),零 Flask 依赖 - 删除 main.py(4527 行)与 auth_routes.py(1050 行) - auth_system.py 移除会话/请求上下文层(login_session/current_user/login_required/ admin_required/_client_ip 等),保留数据与业务层 - 新增 core/net.py(IP 解析)、web/auth_state.py(登录锁定/2FA 票据/发码限流) - FastAPI 侧改直接依赖 services.runtime,不再 import main - web/app.py 去掉 Flask fallback;入口默认根路径模式(API_PREFIX 可选) ## 删除 requests 技术债 - 新增 core/sync_http.py:requests 风格的同步门面(底层 aiohttp 连接池, 流式读取经有界队列背压),含 Session/headers/(connect,read) 元组超时兼容 - core/utils、auth_system、podcast_api、music_downloader、services 全部切换 - 实测真实下载 5.2MB 音频完整成功 ## 质量门禁归零 - ruff:0 错误(B904 全部修复,不再忽略) - pyright:0 错误(类型收窄修复) - pytest:140/140(含 golden 契约回归、root 模式 66 项路径覆盖) - 真实 uvicorn 启动:/health、/、/static、受保护端鉴权全部正确 新增 tests/golden/responses.json:删除 Flask 前捕获的 32 例响应快照, FastAPI 逐字段复现——删除巨石后行为回归仍有硬证据。
This commit is contained in:
1 parent
f5d1e3c3a9
commit
8446166120
41 files changed
+4836
-6060
No files matched your search
@@ -19,10 +19,7 @@ user_cookie.*.txt
|
||||
downloads/
|
||||
*.log
|
||||
music_api.log*
|
||||
_pkg_ok.txt
|
||||
_css_ok.txt
|
||||
_audit_work/
|
||||
.zcode/
|
||||
.env
|
||||
_smoke_test_downloader.py
|
||||
云村小助手-网站开关.bat
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+5
-6
@@ -6,12 +6,11 @@ WORKDIR /app
|
||||
# (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响)
|
||||
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories && \
|
||||
apk add --no-cache tzdata ffmpeg
|
||||
# 目标栈:FastAPI + uvicorn(Flask 作为迁移期兜底仍随包)
|
||||
COPY requirements-fastapi.txt requirements-fastapi.txt
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||
pip3 install --no-cache-dir -r requirements-fastapi.txt && \
|
||||
pip3 install --no-cache-dir -r requirements.txt
|
||||
# 依赖由 uv 从 uv.lock 精确安装(可复现构建);uv 二进制仅存在于构建阶段
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
ENV UV_PROJECT_ENVIRONMENT=/usr/local UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
COPY . .
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
# 非 root 运行(容器安全基线)。注:挂载卷 ./downloads ./data 的宿主机目录
|
||||
|
||||
@@ -50,7 +50,7 @@ if public:
|
||||
'待修问题清单_第四轮深挖.md',
|
||||
# 第 87 条:第五轮深挖清单同样含全部漏洞细节,公开包必须剔除
|
||||
'待修问题清单_第五轮深挖.md',
|
||||
'_pkg_ok.txt', '_css_ok.txt'):
|
||||
'_pkg_ok.txt',):
|
||||
p = os.path.join(DST, rel)
|
||||
try:
|
||||
if os.path.isdir(p):
|
||||
|
||||
-1047
File diff suppressed because it is too large.
Load diff
+2
-250
@@ -27,12 +27,9 @@ import time
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import g, request, session
|
||||
|
||||
# ============ 路径与常量 ============
|
||||
DATA_DIR = Path('data')
|
||||
DB_PATH = DATA_DIR / 'site.db'
|
||||
@@ -465,18 +462,6 @@ def _is_private_ip(ip: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_loopback_ip(ip: str) -> bool:
|
||||
"""可信反代判定(第 91 条 P2-4:从"所有内网源"收窄为仅回环地址)。
|
||||
服务绑 0.0.0.0 时内网任意主机都可直连伪造 XFF/X-Real-IP 轮换绕过限流与
|
||||
登录锁定(实测实锤);同机 Caddy 反代(127.0.0.1 → Flask)不受影响。"""
|
||||
try:
|
||||
import ipaddress
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return addr.is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def query_ip_province(ip: str) -> str:
|
||||
"""查询 IP 归属省份(内网/失败返回空串),带进程内缓存
|
||||
F47:改用官方路径 /json/{query} 与 zh-CN 语言参数"""
|
||||
@@ -489,7 +474,7 @@ def query_ip_province(ip: str) -> str:
|
||||
try:
|
||||
import urllib.parse as _ul
|
||||
|
||||
import requests as _rq
|
||||
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)
|
||||
@@ -598,142 +583,6 @@ def _last_province(r: sqlite3.Row) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
# ============ 会话 ============
|
||||
|
||||
|
||||
def login_session(user: sqlite3.Row) -> None:
|
||||
"""登录会话:数据库侧原子递增 login_seq 实现顶号(旧会话自动失效)(F04)"""
|
||||
session.permanent = True
|
||||
with _DB_LOCK:
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute('UPDATE users SET last_active = ?, last_login = ?, '
|
||||
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
|
||||
(int(time.time()), int(time.time()), user['id']))
|
||||
row = conn.execute('SELECT login_seq FROM users WHERE id = ?', (user['id'],)).fetchone()
|
||||
conn.commit()
|
||||
seq = int(row['login_seq']) if row else 1
|
||||
finally:
|
||||
conn.close()
|
||||
session[SESSION_KEY] = user['id']
|
||||
session[SESSION_KEY + '_seq'] = seq
|
||||
# P3-23:记录本次签发时间,供滚动续期判断(活跃用户不再登录满 7 天被硬登出)
|
||||
session[SESSION_KEY + '_ts'] = int(time.time())
|
||||
|
||||
|
||||
def _clear_session_only() -> None:
|
||||
"""仅清除本浏览器会话字段(不动数据库 login_seq)——
|
||||
用于顶号/失效检测路径:清掉旧会话即可,绝不能递增 seq,
|
||||
否则会把"刚刚在别处合法登录"的新会话一并踢下线"""
|
||||
session.pop(SESSION_KEY, None)
|
||||
session.pop(SESSION_KEY + '_seq', None)
|
||||
|
||||
|
||||
def logout_session() -> None:
|
||||
"""退出登录(F03:递增 login_seq,使本账号全部已签发会话 Cookie 立即失效)"""
|
||||
uid = session.get(SESSION_KEY)
|
||||
_clear_session_only()
|
||||
if uid:
|
||||
try:
|
||||
_x('UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (int(uid),))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def current_user() -> sqlite3.Row | None:
|
||||
"""当前登录用户(每请求只查一次库:结果缓存到 flask.g。
|
||||
一次下载流程会调用本函数 5-6 次——原实现每次都新建 SQLite 连接,公网下是纯浪费。
|
||||
缓存含 None:未登录同样只查一次。请求内登录态不会变化(登录/注册路由不走本函数)。)"""
|
||||
cached = getattr(g, '_site_current_user', None)
|
||||
if cached is not None:
|
||||
return cached[0]
|
||||
user = _load_current_user()
|
||||
g._site_current_user = (user,)
|
||||
return user
|
||||
|
||||
|
||||
def _load_current_user() -> sqlite3.Row | None:
|
||||
uid = session.get(SESSION_KEY)
|
||||
if not uid:
|
||||
return None
|
||||
user = get_user_by_id(int(uid))
|
||||
if not user:
|
||||
return None
|
||||
status = user['status'] if 'status' in user.keys() else 1
|
||||
if status == 0 or status == 2:
|
||||
# 被封禁账号的残留会话:清除并记录一次(只清本会话,不动 seq)
|
||||
if status == 2:
|
||||
_clear_session_only()
|
||||
add_log('fail', user['username'], _client_ip(), '封禁账号访问',
|
||||
user['ban_reason'] if 'ban_reason' in user.keys() else '')
|
||||
return None
|
||||
# 顶号校验(F04):数据库已有版本号时,会话版本必须严格一致;
|
||||
# 缺失/为 0 的会话版本不再放行(防绕过)。检测路径只清本会话,不动 seq。
|
||||
db_seq = int(user['login_seq'] if 'login_seq' in user.keys() and user['login_seq'] else 0)
|
||||
sess_seq = int(session.get(SESSION_KEY + '_seq') or 0)
|
||||
if db_seq and sess_seq != db_seq:
|
||||
_clear_session_only()
|
||||
add_log('fail', user['username'], _client_ip(), '顶号下线',
|
||||
'账号已在其他设备登录或会话已失效,本机会话已下线')
|
||||
return None
|
||||
# P3-23:滚动续期——会话签发超过一半寿命(3.5 天)时刷新签发时间并 touch 会话,
|
||||
# 活跃用户的 Cookie 有效期自动顺延;只有这半天窗口内的请求会带一次 Set-Cookie
|
||||
try:
|
||||
issued = int(session.get(SESSION_KEY + '_ts') or 0)
|
||||
if issued and time.time() - issued > 3.5 * 86400:
|
||||
session[SESSION_KEY + '_ts'] = int(time.time())
|
||||
session.permanent = True # 触发 cookie 重发(刷新 7 天窗口)
|
||||
except Exception:
|
||||
pass
|
||||
return user
|
||||
|
||||
|
||||
def _trusted_proxy_ip(header_value: str) -> str:
|
||||
"""第 97 条(P2-1):从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。
|
||||
最右段是可信反代(Caddy)追加的直连方地址;最左段与 X-Real-IP 均可被客户端
|
||||
伪造(Caddy 默认不改写客户端自带的 X-Real-IP),采信它们等于给限流/锁定/省份
|
||||
记录开了伪造口。无有效值返回空串,调用方回落 remote_addr。"""
|
||||
if not header_value:
|
||||
return ''
|
||||
import ipaddress as _ipa
|
||||
for part in reversed(header_value.split(',')):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
return str(_ipa.ip_address(part))
|
||||
except ValueError:
|
||||
continue
|
||||
return ''
|
||||
|
||||
|
||||
def _client_ip_from_headers(remote: str) -> str:
|
||||
"""第 97 条:可信反代场景的客户端 IP 解析(_client_ip 与 main._rate_client_ip 共用)。
|
||||
XFF 最右段优先(唯一由可信反代追加的值,客户端伪造的最左段无效);最右段为
|
||||
回环(PassNAT 纯 TCP 隧道下 Caddy 只能看到隧道本机端)时回落采信 X-Real-IP——
|
||||
该场景要求隧道开启真实 IP 透传(覆写 X-Real-IP,部署闸 §5.7 B2 必须实测);
|
||||
客户端伪造面已由"Caddy 恒追加 XFF + 仅回环信任"收口。全部无效回落 remote。"""
|
||||
fwd = _trusted_proxy_ip(request.headers.get('X-Forwarded-For') or '')
|
||||
if fwd and not _is_loopback_ip(fwd):
|
||||
return fwd
|
||||
xrip = _trusted_proxy_ip(request.headers.get('X-Real-IP') or '')
|
||||
if xrip:
|
||||
return xrip
|
||||
return remote
|
||||
|
||||
|
||||
def _client_ip() -> str:
|
||||
"""客户端 IP(F08:仅当直连方是可信反代时才信任代理头,防伪造绕过限流。
|
||||
第 91 条 P2-4:可信范围从"本机/内网"收窄为"仅回环"——绑 0.0.0.0 时内网任意
|
||||
主机直连可伪造 XFF 轮换绕过限流/登录锁定(动态实测实锤)。
|
||||
第 97 条 P2-1:解析规则见 _client_ip_from_headers——XFF 最右段优先,
|
||||
最右段为回环时回落 X-Real-IP(PassNAT 隧道场景),不再无条件读 X-Real-IP)"""
|
||||
remote = request.remote_addr or '-'
|
||||
if remote != '-' and _is_loopback_ip(remote):
|
||||
return _client_ip_from_headers(remote)
|
||||
return remote
|
||||
|
||||
|
||||
# ============ 使用次数(按次计费:一首歌 = 1 次) ============
|
||||
|
||||
|
||||
@@ -1017,103 +866,6 @@ def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ============ 装饰器 ============
|
||||
|
||||
|
||||
def _deny(message: str, code: int = 401, error_code: str = 'unauthorized') -> tuple[dict[str, Any], int]:
|
||||
return ({'status': code, 'success': False, 'message': message, 'error_code': error_code}, code)
|
||||
|
||||
|
||||
def login_required(action: str = '', cost=None, free: bool = False):
|
||||
"""强制登录装饰器:
|
||||
- 未登录 401;剩余次数不足本次消耗时 401/expired
|
||||
- free=True:解析/试听类接口,仅要求登录,不检查也不扣减次数
|
||||
- cost: 可选 callable,返回本次请求消耗次数(下载类:单曲 1 次,批量按歌曲数)
|
||||
- A2/B8 统一账本通道:预扣与开账同事务(reserve_and_open,临时键挂在 flask.g);
|
||||
路由创建任务后用 ledger_rename 把账本改挂 task_id,worker 结算唯一入口走账本;
|
||||
请求失败(HTTP>=400/异常)时装饰器只对"仍 open 的账本行"结算退款——
|
||||
任何路径(含线程启动失败、json 模式)都恰好退一次,杜绝双退/漏退。
|
||||
"""
|
||||
def deco(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user:
|
||||
return _deny('请先登录后再使用解析功能')
|
||||
fresh = get_user_by_id(user['id'])
|
||||
if fresh is None:
|
||||
# 极小竞态:会话校验通过后、预扣前用户被删除——按未登录收敛,不再 None 解引用 500
|
||||
return _deny('登录已失效,请重新登录')
|
||||
credits = int(fresh['duration_seconds'] or 0)
|
||||
unlimited = fresh['role'] == 'admin' or credits == -1
|
||||
need = 0
|
||||
reserved = False
|
||||
from flask import g as _flask_g
|
||||
if not free:
|
||||
try:
|
||||
need = max(1, int(cost() if cost is not None else 1))
|
||||
except Exception:
|
||||
need = 1
|
||||
if not unlimited:
|
||||
key = 'req-' + secrets.token_hex(8)
|
||||
remaining = reserve_and_open(fresh['id'], need, key)
|
||||
if remaining is None:
|
||||
add_log('fail', fresh['username'], _client_ip(), action or request.path,
|
||||
f'剩余次数不足(本次需 {need} 次)')
|
||||
return _deny(f'下载次数已用尽(本次需 {need} 次,当前 {credits} 次),'
|
||||
'请充值卡密后继续下载', error_code='expired')
|
||||
_flask_g.billing_ledger_key = key
|
||||
reserved = True
|
||||
def _refund_open(reason: str) -> None:
|
||||
"""A2:仅对仍 open 的账本行全额结算退款(被任务 rename/结算过则跳过)"""
|
||||
key = getattr(_flask_g, 'billing_ledger_key', None)
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
row = ledger_get(key)
|
||||
if row and row['status'] == 'open':
|
||||
ledger_settle(key, 0, reason)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
resp = fn(*args, **kwargs)
|
||||
except Exception:
|
||||
# 路由内部异常:预扣的次数经账本退回
|
||||
if reserved:
|
||||
_refund_open('rejected')
|
||||
raise
|
||||
status = resp[1] if isinstance(resp, tuple) else 200
|
||||
if status < 400:
|
||||
# P2-15:free 端点(搜索/解析等高频动作)成功不再写逐次 success 日志——
|
||||
# 它们与卡密充值/退费/封禁等审计关键日志同表竞争 5000 行配额,
|
||||
# 忙站点几小时就把追账依据冲掉;下载/计费/失败路径照常记录
|
||||
if action and not free:
|
||||
detail = (f'-{need} 次,剩余 {credits - need} 次' if reserved and need > 0 else '')
|
||||
add_log('success', fresh['username'], _client_ip(), action, detail)
|
||||
elif action and free:
|
||||
# 解析类动作改记到 REQ_LOG(管理后台"操作记录"页),不入 logs 表
|
||||
pass
|
||||
elif action:
|
||||
add_log('fail', fresh['username'], _client_ip(), action, f'HTTP {status}')
|
||||
if status >= 400 and reserved:
|
||||
# 路由失败(404/版权限制等):预扣的次数经账本退回
|
||||
_refund_open('rejected')
|
||||
return resp
|
||||
return wrapper
|
||||
return deco
|
||||
|
||||
|
||||
def admin_required(fn):
|
||||
"""仅站点管理员可用"""
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user or user['role'] != 'admin':
|
||||
return _deny('需要管理员权限', 403, 'forbidden')
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
# ============ 日志 ============
|
||||
|
||||
|
||||
@@ -1697,7 +1449,7 @@ def restore_backup(name: str) -> None:
|
||||
# 替换后初始化失败:回滚安全副本,恢复原库
|
||||
shutil.copy2(str(safety), str(DB_PATH))
|
||||
init_db()
|
||||
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态')
|
||||
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None
|
||||
# 第 94 条(P3-4):裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写
|
||||
try:
|
||||
prune_backups(20, 5)
|
||||
|
||||
@@ -32,6 +32,11 @@ def _get_loop() -> asyncio.AbstractEventLoop:
|
||||
return _loop
|
||||
|
||||
|
||||
def get_loop() -> asyncio.AbstractEventLoop:
|
||||
"""后台事件循环(供需要直接提交协程的调用方使用)。"""
|
||||
return _get_loop()
|
||||
|
||||
|
||||
def run_sync[T](coro: Coroutine[Any, Any, T], timeout: float | None = None) -> T:
|
||||
"""在同步代码中运行协程并等待结果。
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""客户端 IP 解析工具(框架无关)。
|
||||
|
||||
从 auth_system 抽出:可信反代判定与 XFF/X-Real-IP 解析规则。
|
||||
Flask 侧原本经 request.headers 取头;FastAPI 侧由 web.ratelimit 传入 header 值调用,
|
||||
故此处只保留纯函数(不依赖任何 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)不受影响。"""
|
||||
try:
|
||||
import ipaddress
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return addr.is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def trusted_proxy_ip(header_value: str) -> str:
|
||||
"""第 97 条(P2-1):从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。
|
||||
最右段是可信反代(Caddy)追加的直连方地址;最左段与 X-Real-IP 均可被客户端
|
||||
伪造(Caddy 默认不改写客户端自带的 X-Real-IP),采信它们等于给限流/锁定/省份
|
||||
记录开了伪造口。无有效值返回空串,调用方回落 remote_addr。"""
|
||||
if not header_value:
|
||||
return ''
|
||||
import ipaddress as _ipa
|
||||
for part in reversed(header_value.split(',')):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
return str(_ipa.ip_address(part))
|
||||
except ValueError:
|
||||
continue
|
||||
return ''
|
||||
|
||||
|
||||
# 兼容原名(auth_system 等历史调用点沿用下划线命名)
|
||||
_is_loopback_ip = is_loopback_ip
|
||||
_trusted_proxy_ip = trusted_proxy_ip
|
||||
@@ -0,0 +1,202 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""同步 HTTP 门面(底层 aiohttp)——彻底移除 requests 依赖。
|
||||
|
||||
为什么需要它:部分代码天然是同步的(worker 线程里的流式下载、纯函数里的短链展开/
|
||||
IP 归属地查询)。本模块提供 requests 风格的同步 API,但底层复用 aiohttp 连接池,
|
||||
不再引入 requests。
|
||||
|
||||
流式读取的实现:在后台事件循环起一个读取协程,把块推入有界队列(背压),
|
||||
同步侧从队列消费——每次搬运只过一次队列,不做「每块一次跨线程调度」。
|
||||
"""
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from core import async_bridge
|
||||
from core.http_client import client as _client
|
||||
|
||||
_DEFAULT_TIMEOUT = 30
|
||||
|
||||
|
||||
class RequestException(Exception):
|
||||
"""请求异常基类(对齐 requests 命名,便于迁移)。"""
|
||||
|
||||
|
||||
class HTTPError(RequestException):
|
||||
"""raise_for_status() 抛出的 HTTP 状态异常。"""
|
||||
|
||||
def __init__(self, message: str, response: 'SyncResponse | None' = None):
|
||||
super().__init__(message)
|
||||
self.response = response
|
||||
|
||||
|
||||
class SyncResponse:
|
||||
"""requests.Response 的同步子集(底层 aiohttp)。"""
|
||||
|
||||
def __init__(self, status: int, headers: Mapping[str, str], content: bytes | None,
|
||||
url: str, stream_state: dict | None = None):
|
||||
self.status_code = status
|
||||
self.headers = dict(headers)
|
||||
self.url = url
|
||||
self._content = content
|
||||
self._stream = stream_state
|
||||
|
||||
@property
|
||||
def content(self) -> bytes:
|
||||
if self._content is None:
|
||||
self._content = b''.join(self.iter_content(64 * 1024))
|
||||
return self._content
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.content.decode('utf-8', errors='replace')
|
||||
|
||||
def json(self, **kw) -> Any:
|
||||
import json as _json
|
||||
return _json.loads(self.text, **kw)
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise HTTPError(f'HTTP {self.status_code}', self)
|
||||
|
||||
def iter_content(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
||||
"""流式读取响应体(大小写与 requests 一致:chunk_size 为字节数)。"""
|
||||
if self._content is not None:
|
||||
for i in range(0, len(self._content), chunk_size):
|
||||
yield self._content[i:i + chunk_size]
|
||||
return
|
||||
st = self._stream
|
||||
assert st is not None
|
||||
q: queue.Queue = st['queue']
|
||||
while True:
|
||||
item = q.get()
|
||||
if item is None: # 正常结束
|
||||
break
|
||||
if isinstance(item, BaseException): # 读取出错
|
||||
raise item
|
||||
yield item
|
||||
|
||||
def close(self) -> None:
|
||||
st = self._stream
|
||||
if st and st.get('cancel') is not None:
|
||||
try:
|
||||
async_bridge.run_sync(_cancel(st))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> 'SyncResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
async def _cancel(st: dict) -> None:
|
||||
st['cancelled'] = True
|
||||
resp = st.get('resp')
|
||||
if resp is not None:
|
||||
resp.close()
|
||||
|
||||
|
||||
def _stream_reader(st: dict, chunk_size: int) -> None:
|
||||
"""在后台事件循环读块 → 有界队列(背压),同步侧消费。"""
|
||||
async def _run():
|
||||
try:
|
||||
async for chunk in st['resp'].content.iter_chunked(chunk_size):
|
||||
if st.get('cancelled'):
|
||||
break
|
||||
while True: # 背压:队列满则稍等,避免内存无界
|
||||
try:
|
||||
st['queue'].put(chunk, timeout=0.5)
|
||||
break
|
||||
except queue.Full:
|
||||
if st.get('cancelled'):
|
||||
return
|
||||
except Exception as e:
|
||||
st['queue'].put(e)
|
||||
finally:
|
||||
try:
|
||||
st['resp'].close()
|
||||
except Exception:
|
||||
pass
|
||||
st['queue'].put(None)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_run(), async_bridge.get_loop())
|
||||
|
||||
|
||||
class Session:
|
||||
"""requests.Session 的同步子集(连接池由底层 aiohttp 统一维护)。"""
|
||||
|
||||
trust_env = False
|
||||
|
||||
def __init__(self, **kw) -> None:
|
||||
# headers:会话级默认头(与 requests.Session().headers.update 等价)
|
||||
self.headers: dict[str, str] = dict(kw.pop('headers', {}))
|
||||
|
||||
def _merge(self, kw: dict) -> dict:
|
||||
h = dict(self.headers)
|
||||
h.update(kw.pop('headers', None) or {})
|
||||
if h:
|
||||
kw['headers'] = h
|
||||
return kw
|
||||
|
||||
def get(self, url: str, **kw) -> SyncResponse:
|
||||
return _request('GET', url, **self._merge(kw))
|
||||
|
||||
def post(self, url: str, **kw) -> SyncResponse:
|
||||
return _request('POST', url, **self._merge(kw))
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _norm_timeout(timeout):
|
||||
"""requests 允许 (connect, read) 元组;aiohttp 只接受单一总时长——取较大者。"""
|
||||
if isinstance(timeout, (tuple, list)):
|
||||
vals = [float(v) for v in timeout if isinstance(v, (int, float))]
|
||||
return max(vals) if vals else _DEFAULT_TIMEOUT
|
||||
return timeout
|
||||
|
||||
|
||||
def _request(method: str, url: str, *, headers: Mapping[str, str] | None = None,
|
||||
cookies: Mapping[str, str] | None = None, params: Mapping[str, Any] | None = None,
|
||||
data: Any = None, json: Any = None, timeout: float | None = _DEFAULT_TIMEOUT,
|
||||
allow_redirects: bool = True, stream: bool = False) -> SyncResponse:
|
||||
timeout = _norm_timeout(timeout)
|
||||
if stream:
|
||||
return _open_stream(method, url, headers=headers, cookies=cookies, params=params,
|
||||
data=data, json=json, timeout=timeout,
|
||||
allow_redirects=allow_redirects)
|
||||
resp = async_bridge.run_sync(_client.request(
|
||||
method, url, headers=headers, cookies=cookies, params=params, data=data,
|
||||
json_body=json, timeout=timeout, allow_redirects=allow_redirects))
|
||||
return SyncResponse(resp.status, resp.headers, resp.text.encode('utf-8'),
|
||||
resp.url)
|
||||
|
||||
|
||||
def _open_stream(method: str, url: str, **kw) -> SyncResponse:
|
||||
timeout = _norm_timeout(kw.pop('timeout', _DEFAULT_TIMEOUT))
|
||||
# AsyncHTTPClient.open 只接受 headers/cookies/timeout/allow_redirects;
|
||||
# 流式场景不支持 params/data/json(调用方直接把查询串拼进 URL)
|
||||
for unsupported in ('params', 'data', 'json'):
|
||||
kw.pop(unsupported, None)
|
||||
resp = async_bridge.run_sync(_client.open(method, url, timeout=timeout, **kw))
|
||||
st = {'resp': resp, 'queue': queue.Queue(maxsize=8), 'cancel': object(), 'cancelled': False}
|
||||
th = threading.Thread(target=_stream_reader, args=(st, 64 * 1024), daemon=True)
|
||||
th.start()
|
||||
return SyncResponse(resp.status, resp.headers, None, str(resp.url), stream_state=st)
|
||||
|
||||
|
||||
# 模块级便捷函数(等价于共享 Session 的 get/post)
|
||||
_default_session = Session()
|
||||
|
||||
|
||||
def get(url: str, **kw) -> SyncResponse:
|
||||
return _default_session.get(url, **kw)
|
||||
|
||||
|
||||
def post(url: str, **kw) -> SyncResponse:
|
||||
return _default_session.post(url, **kw)
|
||||
+2
-2
@@ -105,9 +105,9 @@ def expand_shortlink(url: str) -> str:
|
||||
return hit
|
||||
expanded = url
|
||||
try:
|
||||
import requests
|
||||
from core import sync_http
|
||||
|
||||
response = requests.get(url, allow_redirects=False, timeout=10)
|
||||
response = sync_http.get(url, allow_redirects=False, timeout=10)
|
||||
expanded = response.headers.get('Location', url)
|
||||
except Exception:
|
||||
expanded = url
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ services:
|
||||
- TZ=Asia/Shanghai # 未提供 .env 时的默认时区(F59:文档"默认即可运行"现在成立)
|
||||
- ENABLE_SECURE_COOKIE=${ENABLE_SECURE_COOKIE:-} # HTTPS 反代上线后设 1(见 .env.example)
|
||||
ports:
|
||||
- "5001:5000" # 对外 5001 端口(容器内 Flask 监听 5000)
|
||||
- "5001:5000" # 对外 5001 端口(容器内服务监听 5000)
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./cookie.txt:/app/cookie.txt # 全局管理员 Cookie(持久化,重建不丢失;需先创建文件)
|
||||
|
||||
+1
-5
@@ -10,9 +10,5 @@ for f in user_cookie.txt cookie.txt; do
|
||||
fi
|
||||
[ -f "/app/$f" ] || touch "/app/$f"
|
||||
done
|
||||
# 生产服务器改为 uvicorn(FastAPI + Flask 兜底,见 web/main.py)
|
||||
# WEB_STACK=flask 可回退到旧入口 main.py(waitress/Werkzeug)以应急
|
||||
if [ "${WEB_STACK:-fastapi}" = "flask" ]; then
|
||||
exec python3 main.py
|
||||
fi
|
||||
# 生产服务器:uvicorn(FastAPI)。
|
||||
exec python3 web/main.py
|
||||
+29
-29
@@ -207,7 +207,7 @@ class HTTPClient:
|
||||
except requests.HTTPError as e:
|
||||
# 第 91 条(P2-6):4xx(429/460 风控)不重试——原实现 HTTPError 落入
|
||||
# 下方 RequestException 分支被重试一次,与 R5-20 意图相反
|
||||
raise APIException(f"HTTP请求失败: {e}")
|
||||
raise APIException(f"HTTP请求失败: {e}") from e
|
||||
except requests.RequestException as e:
|
||||
last_err = e
|
||||
if attempt == 0:
|
||||
@@ -240,7 +240,7 @@ class HTTPClient:
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
# 第 91 条(P2-6):4xx 不重试(同上)
|
||||
raise APIException(f"HTTP请求失败: {e}")
|
||||
raise APIException(f"HTTP请求失败: {e}") from e
|
||||
except requests.RequestException as e:
|
||||
last_err = e
|
||||
if attempt == 0:
|
||||
@@ -306,7 +306,7 @@ class NeteaseAPI:
|
||||
|
||||
return result
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析响应数据失败: {e}")
|
||||
raise APIException(f"解析响应数据失败: {e}") from e
|
||||
|
||||
async def get_song_detail(self, song_id: int) -> dict[str, Any]:
|
||||
"""获取歌曲详细信息
|
||||
@@ -340,9 +340,9 @@ class NeteaseAPI:
|
||||
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取歌曲详情请求失败: {e}")
|
||||
raise APIException(f"获取歌曲详情请求失败: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析歌曲详情响应失败: {e}")
|
||||
raise APIException(f"解析歌曲详情响应失败: {e}") from e
|
||||
|
||||
async def get_lyric(self, song_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||
"""获取歌词信息
|
||||
@@ -386,9 +386,9 @@ class NeteaseAPI:
|
||||
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取歌词请求失败: {e}")
|
||||
raise APIException(f"获取歌词请求失败: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析歌词响应失败: {e}")
|
||||
raise APIException(f"解析歌词响应失败: {e}") from e
|
||||
|
||||
async def search_music(self, keywords: str, cookies: dict[str, str], limit: int = 10,
|
||||
search_type: int = 1, offset: int = 0) -> list[dict[str, Any]]:
|
||||
@@ -488,9 +488,9 @@ class NeteaseAPI:
|
||||
|
||||
return items
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"搜索请求失败: {e}")
|
||||
raise APIException(f"搜索请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析搜索响应失败: {e}")
|
||||
raise APIException(f"解析搜索响应失败: {e}") from e
|
||||
|
||||
async def get_playlist_detail(self, playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||
"""获取歌单详情
|
||||
@@ -583,9 +583,9 @@ class NeteaseAPI:
|
||||
|
||||
return info
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取歌单详情请求失败: {e}")
|
||||
raise APIException(f"获取歌单详情请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析歌单详情响应失败: {e}")
|
||||
raise APIException(f"解析歌单详情响应失败: {e}") from e
|
||||
|
||||
async def get_album_detail(self, album_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||
"""获取专辑详情
|
||||
@@ -639,9 +639,9 @@ class NeteaseAPI:
|
||||
|
||||
return info
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取专辑详情请求失败: {e}")
|
||||
raise APIException(f"获取专辑详情请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析专辑详情响应失败: {e}")
|
||||
raise APIException(f"解析专辑详情响应失败: {e}") from e
|
||||
|
||||
async def get_toplist(self) -> list[dict[str, Any]]:
|
||||
"""获取网易云官方榜单列表
|
||||
@@ -680,9 +680,9 @@ class NeteaseAPI:
|
||||
})
|
||||
return toplists
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取榜单请求失败: {e}")
|
||||
raise APIException(f"获取榜单请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析榜单响应失败: {e}")
|
||||
raise APIException(f"解析榜单响应失败: {e}") from e
|
||||
|
||||
async def get_artist_hot_songs(self, artist_id: int) -> dict[str, Any]:
|
||||
"""获取歌手信息和热门歌曲
|
||||
@@ -734,9 +734,9 @@ class NeteaseAPI:
|
||||
'songs': songs
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取歌手信息请求失败: {e}")
|
||||
raise APIException(f"获取歌手信息请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析歌手信息响应失败: {e}")
|
||||
raise APIException(f"解析歌手信息响应失败: {e}") from e
|
||||
|
||||
async def get_song_comments(self, song_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
|
||||
"""获取歌曲评论
|
||||
@@ -801,9 +801,9 @@ class NeteaseAPI:
|
||||
'comments': [_comment_dict(c) for c in (result.get('comments') or [])],
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取评论请求失败: {e}")
|
||||
raise APIException(f"获取评论请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析评论响应失败: {e}")
|
||||
raise APIException(f"解析评论响应失败: {e}") from e
|
||||
|
||||
async def get_account_info(self, cookies: dict[str, str]) -> dict[str, Any] | None:
|
||||
"""获取当前账号信息(用于个人登录状态展示)
|
||||
@@ -905,9 +905,9 @@ class NeteaseAPI:
|
||||
offset += page_size
|
||||
return playlists
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取用户歌单请求失败: {e}")
|
||||
raise APIException(f"获取用户歌单请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析用户歌单响应失败: {e}")
|
||||
raise APIException(f"解析用户歌单响应失败: {e}") from e
|
||||
|
||||
async def detect_vip_by_probe(self, cookies: dict[str, str],
|
||||
probe_song_id: int = 347230) -> dict[str, Any]:
|
||||
@@ -997,9 +997,9 @@ class NeteaseAPI:
|
||||
raise APIException(f"收藏歌单失败: {result.get('message', '未知错误')}")
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"收藏歌单请求失败: {e}")
|
||||
raise APIException(f"收藏歌单请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析收藏歌单响应失败: {e}")
|
||||
raise APIException(f"解析收藏歌单响应失败: {e}") from e
|
||||
|
||||
async def subscribe_album(self, album_id, cookies: dict[str, str],
|
||||
action: int = 1) -> dict[str, Any]:
|
||||
@@ -1031,9 +1031,9 @@ class NeteaseAPI:
|
||||
raise APIException(f"收藏专辑失败: {result.get('message', '未知错误')}")
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"收藏专辑请求失败: {e}")
|
||||
raise APIException(f"收藏专辑请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析收藏专辑响应失败: {e}")
|
||||
raise APIException(f"解析收藏专辑响应失败: {e}") from e
|
||||
|
||||
async def post_comment(self, thread_id: str, content: str, action: int = 1,
|
||||
comment_id: int | None = None,
|
||||
@@ -1088,9 +1088,9 @@ class NeteaseAPI:
|
||||
raise APIException(f"评论失败: {result.get('message', '未知错误')}")
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"评论请求失败: {e}")
|
||||
raise APIException(f"评论请求失败: {e}") from e
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析评论响应失败: {e}")
|
||||
raise APIException(f"解析评论响应失败: {e}") from e
|
||||
|
||||
def netease_encrypt_id(self, id_str: str) -> str:
|
||||
"""网易云加密图片ID算法
|
||||
@@ -1170,7 +1170,7 @@ class QRLoginManager:
|
||||
else:
|
||||
raise APIException(f"生成二维码key失败: {result.get('message', '未知错误')}")
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析二维码key响应失败: {e}")
|
||||
raise APIException(f"解析二维码key响应失败: {e}") from e
|
||||
|
||||
async def create_qr_login(self) -> str | None:
|
||||
"""创建登录二维码并在控制台显示
|
||||
@@ -1251,7 +1251,7 @@ class QRLoginManager:
|
||||
|
||||
return result.get('code', -1), cookie_dict
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise APIException(f"解析登录状态响应失败: {e}")
|
||||
raise APIException(f"解析登录状态响应失败: {e}") from e
|
||||
|
||||
async def qr_login(self) -> str | None:
|
||||
"""完整的二维码登录流程
|
||||
|
||||
+4
-9
@@ -28,9 +28,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
|
||||
from core.async_bridge import run_sync
|
||||
from music_api import APIException, NeteaseAPI, load_cookies
|
||||
|
||||
@@ -77,13 +75,10 @@ def _sniff_audio_header(path) -> bool:
|
||||
|
||||
|
||||
def get_http_session() -> requests.Session:
|
||||
"""获取当前线程的共享 requests.Session(带连接池 + keep-alive)"""
|
||||
"""获取当前线程的共享同步会话(连接池由底层 aiohttp 统一维护)。"""
|
||||
session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None)
|
||||
if session is None:
|
||||
session = requests.Session()
|
||||
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
session.headers.update({"User-Agent": DEFAULT_UA})
|
||||
_SESSION_LOCAL.session = session
|
||||
return session
|
||||
@@ -555,9 +550,9 @@ class MusicDownloader:
|
||||
)
|
||||
|
||||
except APIException as e:
|
||||
raise DownloadException(f"API调用失败: {e}")
|
||||
raise DownloadException(f"API调用失败: {e}") from e
|
||||
except Exception as e:
|
||||
raise DownloadException(f"获取音乐信息时发生错误: {e}")
|
||||
raise DownloadException(f"获取音乐信息时发生错误: {e}") from e
|
||||
|
||||
# ----------- 文件路径 -----------
|
||||
|
||||
|
||||
+8
-9
@@ -21,8 +21,7 @@ import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from core import sync_http as requests # 同步门面(底层 aiohttp)
|
||||
from music_api import APIConstants, APIException, url_v1
|
||||
|
||||
|
||||
@@ -69,9 +68,9 @@ def _post_json(url: str, data: dict[str, Any], cookies: dict[str, str] | None =
|
||||
if not isinstance(result, dict):
|
||||
raise APIException('播客接口响应异常(非标准 JSON 对象)')
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"播客接口请求失败: {e}")
|
||||
raise APIException(f"播客接口请求失败: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析播客接口响应失败: {e}")
|
||||
raise APIException(f"解析播客接口响应失败: {e}") from e
|
||||
if result.get('code') not in (200, None):
|
||||
raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}")
|
||||
return result
|
||||
@@ -300,7 +299,7 @@ def get_radio_detail(radio_id: Any, cookies: dict[str, str] | None = None) -> di
|
||||
try:
|
||||
rid = int(str(radio_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的电台ID')
|
||||
raise APIException('无效的电台ID') from None
|
||||
result = _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies)
|
||||
# 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空
|
||||
data = result.get('djRadio') or result.get('data') or {}
|
||||
@@ -325,7 +324,7 @@ def get_radio_programs(radio_id: Any, cookies: dict[str, str] | None = None,
|
||||
try:
|
||||
rid = int(str(radio_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的电台ID')
|
||||
raise APIException('无效的电台ID') from None
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
result = _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API,
|
||||
{'radioId': rid, 'limit': limit, 'offset': max(0, int(offset or 0)),
|
||||
@@ -346,7 +345,7 @@ def get_program_detail(program_id: Any, cookies: dict[str, str] | None = None) -
|
||||
try:
|
||||
pid = int(str(program_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的单集ID')
|
||||
raise APIException('无效的单集ID') from None
|
||||
result = _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies)
|
||||
program = result.get('program') or {}
|
||||
if not program:
|
||||
@@ -372,9 +371,9 @@ def get_categories() -> list[dict[str, Any]]:
|
||||
raise APIException('播客接口响应体异常(超过大小上限)')
|
||||
result = response.json()
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取播客分类失败: {e}")
|
||||
raise APIException(f"获取播客分类失败: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析播客分类响应失败: {e}")
|
||||
raise APIException(f"解析播客分类响应失败: {e}") from e
|
||||
if not isinstance(result, dict):
|
||||
# 第 97 条(P3):非 dict 响应(null/数组)显式报错,不再 AttributeError
|
||||
raise APIException('解析播客分类响应失败(非标准 JSON 对象)')
|
||||
|
||||
+33
-11
@@ -4,6 +4,31 @@ name = "yunmusic-assistant"
|
||||
version = "3.0.0"
|
||||
description = "云村小助手 · 网易云音乐解析/下载服务(FastAPI + asyncio + aiohttp)"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1.0",
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
"aiohttp>=3.10,<4.0",
|
||||
"aiosqlite>=0.20,<1.0",
|
||||
"aiofiles>=24.1",
|
||||
"itsdangerous>=2.2,<3.0",
|
||||
"cryptography>=50.0,<51.0",
|
||||
"qrcode>=7.0,<8.3",
|
||||
"python-multipart>=0.0.9",
|
||||
"jinja2>=3.1,<4.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.6",
|
||||
"pyright>=1.1.390",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
# 应用工程(非库):不把自身装进环境,避免 uv 扫描顶层模块
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
# ============ Ruff:静态检查 + 导入排序 + 格式化 ============
|
||||
[tool.ruff]
|
||||
@@ -12,52 +37,49 @@ line-length = 120
|
||||
extend-exclude = [".venv", ".zcode", "templates", "static", "data"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
# E/F 基础错误、I 导入排序、B bugbear、UP 升级、ASYNC 异步陷阱、S 安全、RUF
|
||||
# E/F 基础错误、I 导入排序、B bugbear、UP 升级、C4 推导式、ASYNC 异步陷阱、S 安全、RUF
|
||||
select = ["E", "F", "W", "I", "B", "UP", "C4", "ASYNC", "S", "RUF"]
|
||||
ignore = [
|
||||
"E501", # 行宽交给 formatter,不强制
|
||||
"E402", # main.py 存在「先定义后 import」的历史结构,迁移期暂放行
|
||||
"S101", # 生产代码外的 assert(测试/断言脚本)
|
||||
"S104", # 绑定 0.0.0.0(服务端有意为之)
|
||||
"S110", # try/except/pass(本项目大量「失败静默降级」策略,见交接文档)
|
||||
"S110", # try/except/pass(本项目「失败静默降级」策略)
|
||||
"S112", # try/except/continue
|
||||
"S324", # hashlib.md5 用于非安全场景(上游签名口径)
|
||||
"S603", "S607", # subprocess(打包/运维脚本)
|
||||
"UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定)
|
||||
"RUF001", "RUF002", "RUF003", # 全角字符(中文注释/文案,误报)
|
||||
# ---- 以下为「有意保留」,随文件在迁移中重写时再行处理,非待修缺陷 ----
|
||||
"B904", # raise-from 异常链:纯风格,40 处均在历史巨石文件,逐处改写收益低风险高
|
||||
# ---- 以下为「有意保留」,已被逐条论证为协议/设计约束,非待修缺陷 ----
|
||||
"RUF012", # 常量类可变默认值:APIConstants/PodcastConstants 是常量表,非实例状态
|
||||
"S305", # AES-ECB:网易云 weapi 协议**要求**的加密模式,非可自由选择
|
||||
"S311", # randrange:用于生成上游 requestId(协议随机数),非安全用途
|
||||
"S311", # randrange:生成上游 requestId(协议随机数),非安全用途
|
||||
"ASYNC250", # input():qr_login 交互式 CLI 的人类输入,异步化无意义
|
||||
"S310", # urllib:仅用于音频 Range 代理的 URL 校验路径
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["S", "ASYNC"]
|
||||
".zcode/**" = ["S", "ASYNC", "F"]
|
||||
"_mkpkg.py" = ["S"]
|
||||
# ASYNC109:timeout 参数是有意 API——直接转发给 aiohttp 的 ClientTimeout,
|
||||
# 不是「用 sleep 当超时」的反模式。
|
||||
"core/http_client.py" = ["ASYNC109"]
|
||||
"core/aiohttp_compat.py" = ["ASYNC109"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["core", "web", "music_api", "auth_system"]
|
||||
known-first-party = ["core", "web", "services", "music_api", "auth_system"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "preserve"
|
||||
|
||||
# ============ Pyright:类型检查 ============
|
||||
[tool.pyright]
|
||||
include = ["core", "web"]
|
||||
# 全量纳入(Flask 巨石删除后不再有例外清单)
|
||||
include = ["core", "web", "services"]
|
||||
exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
pythonVersion = "3.12"
|
||||
typeCheckingMode = "basic"
|
||||
# 迁移期口径:存量 Flask 巨石(main.py/auth_*.py)暂不纳入严格检查,
|
||||
# 新写的 core/ 与 web/ 要求类型干净。随迁移推进逐步把模块移入 include。
|
||||
reportMissingImports = true
|
||||
reportOptionalMemberAccess = "warning"
|
||||
reportAttributeAccessIssue = "warning"
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# 开发依赖(开发/CI 专用,不进生产镜像)
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
ruff>=0.6
|
||||
pyright>=1.1.390
|
||||
httpx>=0.27
|
||||
@@ -1,14 +0,0 @@
|
||||
# 生产运行时依赖(FastAPI 版)
|
||||
# 口径同旧 requirements.txt:加版本上界,防上游大版本静默破坏(第 95 条 P2-6)。
|
||||
#
|
||||
# 迁移说明:本文件是 FASTAPI 目标栈。当前仓库同时保留 requirements.txt(Flask 旧栈)
|
||||
# 直到路由全部迁移完成、生产服务器切换为止,两者可并存。
|
||||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
aiohttp>=3.10,<4.0
|
||||
aiosqlite>=0.20,<1.0
|
||||
aiofiles>=24.1
|
||||
itsdangerous>=2.2,<3.0
|
||||
cryptography>=50.0,<51.0
|
||||
qrcode>=7.0,<8.3
|
||||
python-multipart>=0.0.9
|
||||
@@ -1,13 +0,0 @@
|
||||
# F68/F62:与实测运行环境一致的依赖清单(Python 3.12 / 2026-09 实测版本)。
|
||||
# 代码实际直接依赖为以下 5 个包(其余为传递依赖,随包管理自动解析)。
|
||||
# 第 95 条(P2-6):加上界——防上游大版本静默破坏生产(Flask 3→4、Werkzeug 接口变更等)。
|
||||
# 升级前先在开发机全量验证后再放宽上界。
|
||||
# 第 102 条(owner 拍板):新增 waitress 生产服务器(未安装时 main.py 自动回退
|
||||
# Werkzeug 开发服务器,开发机可不装)。
|
||||
# 第 100 条(P2-5):cryptography 区间以开发机 venv 实装 50.0.1 为准重定——旧 <44
|
||||
# 与实测环境矛盾,新机按旧清单会装到未验证的 43.x。
|
||||
Flask>=3.0,<4.0
|
||||
requests>=2.31,<3.0
|
||||
cryptography>=50.0,<51.0
|
||||
qrcode>=7.0,<8.3
|
||||
waitress>=3.0,<4.0
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""服务层:从原 Flask 巨石中剥离的框架无关实现。
|
||||
|
||||
包含 MusicAPIService、下载/打包 worker、任务登记与监控、下载缓存清扫等。
|
||||
不含任何 Flask 依赖(路由层已全部迁至 web/routers)。
|
||||
"""
|
||||
+1957
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,540 @@
|
||||
[
|
||||
{
|
||||
"name": "health",
|
||||
"auth": "none",
|
||||
"method": "GET",
|
||||
"path": "/health",
|
||||
"payload": null,
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"cookie_count": 3,
|
||||
"cookie_status": "valid",
|
||||
"downloads_dir": "configured",
|
||||
"service": "running",
|
||||
"timestamp": 1790391125,
|
||||
"version": "2.4.0"
|
||||
},
|
||||
"message": "API服务运行正常",
|
||||
"status": 200,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "api_info",
|
||||
"auth": "none",
|
||||
"method": "GET",
|
||||
"path": "/api/info",
|
||||
"payload": null,
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"config": {
|
||||
"downloads_dir": "configured",
|
||||
"max_body_bytes": "2MB(备份上传 200MB)"
|
||||
},
|
||||
"description": "云村小助手 · 网易云音乐解析/下载服务",
|
||||
"endpoints": {
|
||||
"/album": "GET/POST - 获取专辑详情",
|
||||
"/api/info": "GET - API信息",
|
||||
"/api/playlist/play": "GET/POST - 公开歌单播放API(id/level/limit)",
|
||||
"/download": "POST - 下载音乐(付费动作仅接受 POST)",
|
||||
"/global/playlist": "GET/POST - 全局账号歌单详情",
|
||||
"/global/playlists": "GET/POST - 全局账号歌单列表",
|
||||
"/health": "GET - 健康检查",
|
||||
"/playlist": "GET/POST - 获取歌单详情",
|
||||
"/podcast/download": "POST - 播客单集下载",
|
||||
"/podcast/download/batch_zip": "POST - 整台播客打包ZIP",
|
||||
"/podcast/hot": "GET/POST - 热门播客与分类",
|
||||
"/podcast/parse": "GET/POST - 播客解析(电台/单集)",
|
||||
"/podcast/programs": "GET/POST - 播客单集列表(分页)",
|
||||
"/podcast/search": "GET/POST - 播客搜索",
|
||||
"/search": "GET/POST - 搜索音乐",
|
||||
"/song": "GET/POST - 获取歌曲信息"
|
||||
},
|
||||
"name": "云村小助手API服务",
|
||||
"supported_qualities": [
|
||||
"standard",
|
||||
"exhigh",
|
||||
"lossless",
|
||||
"hires",
|
||||
"sky",
|
||||
"jyeffect",
|
||||
"jymaster",
|
||||
"dolby"
|
||||
],
|
||||
"version": "2.4.0"
|
||||
},
|
||||
"message": "API信息获取成功",
|
||||
"status": 200,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "toplist_unauth",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/toplist",
|
||||
"payload": {},
|
||||
"status": 401,
|
||||
"body": {
|
||||
"error_code": "unauthorized",
|
||||
"message": "请先登录后再使用解析功能",
|
||||
"status": 401,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_status_unauth",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/user/status",
|
||||
"payload": {},
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"cookie": {
|
||||
"is_valid": false
|
||||
},
|
||||
"user": null
|
||||
},
|
||||
"message": "个人账号未登录",
|
||||
"status": 200,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_logout",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/user/logout",
|
||||
"payload": {},
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"is_valid": false
|
||||
},
|
||||
"message": "已退出登录",
|
||||
"status": 200,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auth_trial",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/auth/trial",
|
||||
"payload": {},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "注册即送 3 次免费下载次数,请直接注册账号",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auth_sendcode_badpurpose",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/auth/send_code",
|
||||
"payload": {
|
||||
"email": "x@y.com",
|
||||
"purpose": "bad"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "验证码用途不正确",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auth_register_shortuser",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/auth/register",
|
||||
"payload": {
|
||||
"username": "ab",
|
||||
"password": "12345678",
|
||||
"email": "a@b.com",
|
||||
"code": "x",
|
||||
"agree": "1"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auth_register_noagree",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/auth/register",
|
||||
"payload": {
|
||||
"username": "abcdef",
|
||||
"password": "12345678",
|
||||
"email": "a@b.com",
|
||||
"code": "x",
|
||||
"agree": "0"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "请先阅读并同意《用户协议》与《隐私政策》",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auth_register_badcode",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/auth/register",
|
||||
"payload": {
|
||||
"username": "abcdef",
|
||||
"password": "12345678",
|
||||
"email": "a@b.com",
|
||||
"code": "zz",
|
||||
"agree": "1"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "邮箱验证码错误或已过期",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auth_redeem_unauth",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/auth/redeem",
|
||||
"payload": {
|
||||
"code": "X"
|
||||
},
|
||||
"status": 401,
|
||||
"body": {
|
||||
"error_code": "unauthorized",
|
||||
"message": "请先登录",
|
||||
"status": 401,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auth_forgot_shortpwd",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/auth/forgot_password",
|
||||
"payload": {
|
||||
"email": "a@b.com",
|
||||
"code": "x",
|
||||
"password": "short"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "新密码长度需在 8-128 位之间",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "admin_logs_forbidden",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/admin/logs",
|
||||
"payload": {},
|
||||
"status": 403,
|
||||
"body": {
|
||||
"message": "需要管理员权限,请使用管理员账号登录",
|
||||
"status": 403,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "admin_site_users_forbidden",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/admin/site/users",
|
||||
"payload": {},
|
||||
"status": 403,
|
||||
"body": {
|
||||
"error_code": "forbidden",
|
||||
"message": "需要管理员权限",
|
||||
"status": 403,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "admin_qr_forbidden",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/admin/qr/create",
|
||||
"payload": {},
|
||||
"status": 403,
|
||||
"body": {
|
||||
"message": "需要管理员权限,请使用管理员账号登录",
|
||||
"status": 403,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "global_playlists_forbidden",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/global/playlists",
|
||||
"payload": {},
|
||||
"status": 403,
|
||||
"body": {
|
||||
"error_code": "forbidden",
|
||||
"message": "需要管理员权限",
|
||||
"status": 403,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "download_unauth",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/download",
|
||||
"payload": {
|
||||
"id": "1"
|
||||
},
|
||||
"status": 401,
|
||||
"body": {
|
||||
"error_code": "unauthorized",
|
||||
"message": "请先登录后再使用解析功能",
|
||||
"status": 401,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "download_badid",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/download",
|
||||
"payload": {
|
||||
"id": "abc"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "无效的歌曲 ID",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "download_badquality",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/download",
|
||||
"payload": {
|
||||
"id": "347230",
|
||||
"quality": "nope"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "无效的音质参数,支持: standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_list_unauth",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/api/task/list",
|
||||
"payload": {
|
||||
"device_id": "x"
|
||||
},
|
||||
"status": 401,
|
||||
"body": {
|
||||
"error_code": "unauthorized",
|
||||
"message": "请先登录后再使用解析功能",
|
||||
"status": 401,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_cancel_unauth",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/api/task/cancel",
|
||||
"payload": {
|
||||
"task_id": "x"
|
||||
},
|
||||
"status": 401,
|
||||
"body": {
|
||||
"error_code": "unauthorized",
|
||||
"message": "请先登录后再使用解析功能",
|
||||
"status": 401,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "queue",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/api/queue",
|
||||
"payload": {},
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"avg_batch_seconds": null,
|
||||
"batch_active": 0,
|
||||
"batch_capacity": 3,
|
||||
"batch_waiting": 0,
|
||||
"download_active": 0,
|
||||
"download_capacity": 10,
|
||||
"download_waiting": 0
|
||||
},
|
||||
"message": "获取排队状态成功",
|
||||
"status": 200,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delivery_404",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/download/result",
|
||||
"payload": {
|
||||
"task_id": "nope"
|
||||
},
|
||||
"status": 404,
|
||||
"body": {
|
||||
"message": "任务不存在或已过期",
|
||||
"status": 404,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delivery_batch_404",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/download/batch_zip/result",
|
||||
"payload": {
|
||||
"task_id": "nope"
|
||||
},
|
||||
"status": 404,
|
||||
"body": {
|
||||
"message": "任务不存在或已过期",
|
||||
"status": 404,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fe_error_nomsg",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/api/fe_error",
|
||||
"payload": {},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "缺少 msg 参数",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "play_badkey",
|
||||
"auth": "none",
|
||||
"method": "POST",
|
||||
"path": "/api/playlist/play",
|
||||
"payload": {
|
||||
"id": 3778678
|
||||
},
|
||||
"status": 401,
|
||||
"body": {
|
||||
"message": "需要有效的 API 密钥(请联系站点管理员获取)",
|
||||
"status": 401,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "podcast_programs_badid",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/podcast/programs",
|
||||
"payload": {
|
||||
"radio_id": "abc"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "无效的电台 ID",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "song_badid",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/song",
|
||||
"payload": {
|
||||
"id": "abc"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "无效的歌曲ID,请输入数字ID或分享链接",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "comments_badid",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/comments",
|
||||
"payload": {
|
||||
"id": "abc"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "无效的歌曲ID,请输入数字ID或分享链接",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "playlist_badid",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/playlist",
|
||||
"payload": {
|
||||
"id": ""
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "参数 'playlist_id' 不能为空",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "album_badid",
|
||||
"auth": "user",
|
||||
"method": "POST",
|
||||
"path": "/album",
|
||||
"payload": {
|
||||
"id": "abc"
|
||||
},
|
||||
"status": 400,
|
||||
"body": {
|
||||
"message": "无效的专辑ID,请输入数字ID或分享链接",
|
||||
"status": 400,
|
||||
"success": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "index",
|
||||
"auth": "none",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"payload": null,
|
||||
"status": 200,
|
||||
"body": null,
|
||||
"html_len": 466261,
|
||||
"html_sha256": "5ba897c2f07b43b6c34e998505fea385cb6a6c8bfd5115d014f80044a38c6200"
|
||||
}
|
||||
]
|
||||
+10
-10
@@ -42,7 +42,7 @@ def app_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
|
||||
# 清空登录失败锁定/2FA 的模块级内存态,避免测试间串扰
|
||||
import auth_routes
|
||||
import web.auth_state as auth_routes
|
||||
auth_routes._LOGIN_FAILS.clear()
|
||||
auth_routes._LOGIN_FAIL_LOGGED.clear()
|
||||
auth_routes._ADMIN_2FA.clear()
|
||||
@@ -52,14 +52,14 @@ def app_env(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_wrong_password(app_env):
|
||||
r = app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': 'nope'})
|
||||
r = app_env.post('/auth/login', json={'username': 'normaluser', 'password': 'nope'})
|
||||
assert r.status_code == 400
|
||||
assert r.json()['success'] is False
|
||||
assert 'session' not in r.cookies
|
||||
|
||||
|
||||
def test_user_login_issues_working_cookie(app_env):
|
||||
r = app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': USER_PW})
|
||||
r = app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body['success'] is True
|
||||
@@ -67,13 +67,13 @@ def test_user_login_issues_working_cookie(app_env):
|
||||
assert 'session' in r.cookies
|
||||
|
||||
# 用签发的 Cookie 访问受保护端点,应放行
|
||||
r2 = app_env.post('/api/v1/auth/heartbeat', json={})
|
||||
r2 = app_env.post('/auth/heartbeat', json={})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()['data']['duration_text'] == '97 次'
|
||||
|
||||
|
||||
def test_admin_login_returns_2fa_ticket(app_env):
|
||||
r = app_env.post('/api/v1/auth/login', json={'username': 'bossuser', 'password': ADMIN_PW})
|
||||
r = app_env.post('/auth/login', json={'username': 'bossuser', 'password': ADMIN_PW})
|
||||
assert r.status_code == 200
|
||||
data = r.json()['data']
|
||||
assert data['admin_2fa'] is True
|
||||
@@ -82,12 +82,12 @@ def test_admin_login_returns_2fa_ticket(app_env):
|
||||
|
||||
|
||||
def test_logout_bumps_login_seq_and_clears(app_env):
|
||||
app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': USER_PW})
|
||||
app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW})
|
||||
conn = auth.get_conn()
|
||||
seq_before = conn.execute(
|
||||
"SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0]
|
||||
conn.close()
|
||||
r = app_env.post('/api/v1/auth/logout', json={})
|
||||
r = app_env.post('/auth/logout', json={})
|
||||
assert r.status_code == 200
|
||||
conn = auth.get_conn()
|
||||
seq_after = conn.execute(
|
||||
@@ -95,7 +95,7 @@ def test_logout_bumps_login_seq_and_clears(app_env):
|
||||
conn.close()
|
||||
assert seq_after == seq_before + 1
|
||||
# 旧 Cookie 失效
|
||||
r2 = app_env.post('/api/v1/auth/heartbeat', json={})
|
||||
r2 = app_env.post('/auth/heartbeat', json={})
|
||||
assert r2.status_code == 401
|
||||
|
||||
|
||||
@@ -104,8 +104,8 @@ def test_heartbeat_expired_credits(app_env):
|
||||
conn.execute("UPDATE users SET duration_seconds=0 WHERE username='normaluser'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
r = app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': USER_PW})
|
||||
r = app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW})
|
||||
assert r.status_code == 200
|
||||
r2 = app_env.post('/api/v1/auth/heartbeat', json={})
|
||||
r2 = app_env.post('/auth/heartbeat', json={})
|
||||
assert r2.status_code == 401
|
||||
assert r2.json().get('error_code') == 'expired'
|
||||
@@ -48,7 +48,7 @@ def _login(env, username):
|
||||
base64 padding('=') 的值加双引号,导致服务端签名解析失败(真实浏览器不会),
|
||||
故测试避开 jar。
|
||||
"""
|
||||
r = env.post('/api/v1/auth/login', json={'username': username, 'password': PW})
|
||||
r = env.post('/auth/login', json={'username': username, 'password': PW})
|
||||
assert r.status_code == 200, r.text
|
||||
raw = r.headers.get('set-cookie', '')
|
||||
cookie = ''
|
||||
@@ -73,7 +73,7 @@ def _open_ledger_rows():
|
||||
|
||||
|
||||
def test_download_requires_login(env):
|
||||
r = env.post('/api/v1/download', json={'id': '347230'})
|
||||
r = env.post('/download', json={'id': '347230'})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_download_bad_id_refunds(env):
|
||||
ck, _ = _login(env, 'payer')
|
||||
H = {'Cookie': f'session={ck}'}
|
||||
before = _balance('payer')
|
||||
r = env.post('/api/v1/download', json={'id': 'not-a-number'}, headers=H)
|
||||
r = env.post('/download', json={'id': 'not-a-number'}, headers=H)
|
||||
assert r.status_code == 400
|
||||
assert '无效' in r.json()['message']
|
||||
# 预扣已退回,且无 open 账本行残留
|
||||
@@ -93,7 +93,7 @@ def test_download_bad_quality_refunds(env):
|
||||
ck, _ = _login(env, 'payer')
|
||||
H = {'Cookie': f'session={ck}'}
|
||||
before = _balance('payer')
|
||||
r = env.post('/api/v1/download', json={'id': '347230', 'quality': 'nope'}, headers=H)
|
||||
r = env.post('/download', json={'id': '347230', 'quality': 'nope'}, headers=H)
|
||||
assert r.status_code == 400
|
||||
assert _balance('payer') == before
|
||||
assert _open_ledger_rows() == []
|
||||
@@ -103,7 +103,7 @@ def test_download_bad_format_refunds(env):
|
||||
ck, _ = _login(env, 'payer')
|
||||
H = {'Cookie': f'session={ck}'}
|
||||
before = _balance('payer')
|
||||
r = env.post('/api/v1/download', json={'id': '347230', 'format': 'xml'}, headers=H)
|
||||
r = env.post('/download', json={'id': '347230', 'format': 'xml'}, headers=H)
|
||||
assert r.status_code == 400
|
||||
assert _balance('payer') == before
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_download_bad_format_refunds(env):
|
||||
def test_download_insufficient_credits(env):
|
||||
ck, _ = _login(env, 'broke')
|
||||
H = {'Cookie': f'session={ck}'}
|
||||
r = env.post('/api/v1/download', json={'id': '347230'}, headers=H)
|
||||
r = env.post('/download', json={'id': '347230'}, headers=H)
|
||||
assert r.status_code == 401
|
||||
assert r.json().get('error_code') == 'expired'
|
||||
# 未开账
|
||||
@@ -124,7 +124,7 @@ def test_batch_zip_too_many_refunds(env):
|
||||
H = {'Cookie': f'session={ck}'}
|
||||
before = _balance('rich')
|
||||
ids = ','.join(str(1000 + i) for i in range(501))
|
||||
r = env.post('/api/v1/download/batch_zip', json={'ids': ids}, headers=H)
|
||||
r = env.post('/download/batch_zip', json={'ids': ids}, headers=H)
|
||||
assert r.status_code == 400
|
||||
assert '500' in r.json()['message']
|
||||
# 预扣(封顶 500)已全额退回,余额回到 600
|
||||
@@ -136,6 +136,6 @@ def test_batch_zip_missing_ids_refunds(env):
|
||||
ck, _ = _login(env, 'payer')
|
||||
H = {'Cookie': f'session={ck}'}
|
||||
before = _balance('payer')
|
||||
r = env.post('/api/v1/download/batch_zip', json={}, headers=H)
|
||||
r = env.post('/download/batch_zip', json={}, headers=H)
|
||||
assert r.status_code == 400
|
||||
assert _balance('payer') == before
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""黄金契约回归:FastAPI 严格复现 Flask 时代的响应。
|
||||
|
||||
tests/golden/responses.json 是**删除 Flask 之前**从 Flask 应用捕获的快照
|
||||
(状态码 + 响应体)。本测试让 FastAPI(根路径模式)跑同一批请求,
|
||||
逐字段断言一致——这样删除 Flask 巨石后,行为回归仍有硬证据。
|
||||
|
||||
说明:
|
||||
- 会话 Cookie 在运行时用当前库的 login_seq 动态构造(快照不存 Cookie)。
|
||||
- 请求为只读/确定性调用,不含实时上游数据(那些由 parity/单测覆盖)。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import auth_system
|
||||
from core import session as cs
|
||||
|
||||
_GOLDEN = Path(__file__).parent / 'golden' / 'responses.json'
|
||||
SECRET = 'golden-secret'
|
||||
|
||||
|
||||
def _load_cases():
|
||||
return json.loads(_GOLDEN.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def _session_for(role):
|
||||
"""按当前库中该角色用户的 login_seq 造一份合法会话 Cookie。"""
|
||||
conn = sqlite3.connect('data/site.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
where = "role = 'admin'" if role == 'admin' else "role != 'admin' AND status = 1"
|
||||
row = conn.execute(
|
||||
f'SELECT id, login_seq FROM users WHERE {where} AND login_seq IS NOT NULL LIMIT 1'
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row is None:
|
||||
return None
|
||||
return cs.dumps(SECRET, {
|
||||
'site_uid': int(row['id']), 'site_uid_seq': int(row['login_seq']),
|
||||
'site_uid_ts': int(time.time()),
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
# 站点库仍用生产 data/site.db(只读),密钥 patch 成固定值以便造 Cookie
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr(auth_system, 'get_secret_key', lambda: SECRET)
|
||||
from web.app import create_app
|
||||
return TestClient(create_app(api_prefix=''))
|
||||
|
||||
|
||||
# 响应对比前剔除的易变字段(时钟)
|
||||
_VOLATILE = {'timestamp'}
|
||||
|
||||
|
||||
def _strip(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: _strip(v) for k, v in obj.items() if k not in _VOLATILE}
|
||||
if isinstance(obj, list):
|
||||
return [_strip(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _request(client, method, path, payload, cookie):
|
||||
headers = {'Cookie': f'session={cookie}'} if cookie else {}
|
||||
if method == 'GET':
|
||||
return client.get(path, headers=headers)
|
||||
return client.post(path, json=payload or {}, headers=headers)
|
||||
|
||||
|
||||
def test_golden_contract(client):
|
||||
"""逐例复现 Flask 快照。"""
|
||||
cases = _load_cases()
|
||||
failures = []
|
||||
for c in cases:
|
||||
if c.get('html_len'):
|
||||
r = client.get(c['path'])
|
||||
if r.status_code != c['status'] or len(r.content) != c['html_len']:
|
||||
failures.append(f"{c['name']}: html {r.status_code}/{len(r.content)} "
|
||||
f"!= {c['status']}/{c['html_len']}")
|
||||
continue
|
||||
cookie = None
|
||||
if c['auth'] == 'admin':
|
||||
cookie = _session_for('admin')
|
||||
elif c['auth'] == 'user':
|
||||
cookie = _session_for('user')
|
||||
r = _request(client, c['method'], c['path'], c['payload'], cookie)
|
||||
body = r.json() if r.headers.get('content-type', '').startswith('application/json') else None
|
||||
if r.status_code != c['status']:
|
||||
failures.append(f"{c['name']}: status {r.status_code} != {c['status']}")
|
||||
elif _strip(body) != _strip(c['body']):
|
||||
failures.append(f"{c['name']}: body\n fastapi={body}\n golden ={c['body']}")
|
||||
assert not failures, '黄金契约回归失败:\n' + '\n'.join(failures)
|
||||
|
||||
|
||||
def test_index_html_stable(client):
|
||||
"""首页渲染逐字节稳定(模板未改时)。"""
|
||||
cases = {c['name']: c for c in _load_cases()}
|
||||
c = cases['index']
|
||||
r = client.get('/')
|
||||
assert hashlib.sha256(r.content).hexdigest() == c['html_sha256']
|
||||
+11
-11
@@ -30,7 +30,7 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
||||
auth.init_db()
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
import auth_routes
|
||||
import web.auth_state as auth_routes
|
||||
auth_routes._SEND_CODE_IP.clear()
|
||||
from web.app import create_app
|
||||
return TestClient(create_app())
|
||||
@@ -47,7 +47,7 @@ def _seed_code(email: str, code: str, purpose: str = 'register'):
|
||||
|
||||
|
||||
def test_register_requires_agreement(env):
|
||||
r = env.post('/api/v1/auth/register', json={
|
||||
r = env.post('/auth/register', json={
|
||||
'username': 'newuser', 'password': 'Passw0rd12', 'email': 'n@example.com',
|
||||
'code': '000000', 'agree': '0'})
|
||||
assert r.json()['message'].startswith('请先阅读并同意')
|
||||
@@ -55,7 +55,7 @@ def test_register_requires_agreement(env):
|
||||
|
||||
def test_register_success(env):
|
||||
_seed_code('new@example.com', '123456')
|
||||
r = env.post('/api/v1/auth/register', json={
|
||||
r = env.post('/auth/register', json={
|
||||
'username': 'newuser', 'password': 'Passw0rd12', 'email': 'new@example.com',
|
||||
'code': '123456', 'agree': '1', 'device_id': 'dev-1'})
|
||||
assert r.status_code == 200
|
||||
@@ -65,18 +65,18 @@ def test_register_success(env):
|
||||
assert 'session' in r.cookies
|
||||
|
||||
# 会话可用
|
||||
assert env.post('/api/v1/auth/heartbeat', json={}).status_code == 200
|
||||
assert env.post('/auth/heartbeat', json={}).status_code == 200
|
||||
|
||||
# 用户名/邮箱已被占用
|
||||
_seed_code('new@example.com', '222222')
|
||||
r2 = env.post('/api/v1/auth/register', json={
|
||||
r2 = env.post('/auth/register', json={
|
||||
'username': 'newuser', 'password': 'Passw0rd12', 'email': 'new@example.com',
|
||||
'code': '222222', 'agree': '1'})
|
||||
assert '已被注册' in r2.json()['message']
|
||||
|
||||
|
||||
def test_register_bad_code(env):
|
||||
r = env.post('/api/v1/auth/register', json={
|
||||
r = env.post('/auth/register', json={
|
||||
'username': 'another', 'password': 'Passw0rd12', 'email': 'a@example.com',
|
||||
'code': '999999', 'agree': '1'})
|
||||
assert r.json()['message'] == '邮箱验证码错误或已过期'
|
||||
@@ -86,12 +86,12 @@ def test_forgot_password_resets(env):
|
||||
# 建一个用户
|
||||
auth.create_user('forgetme', 'OldPass123', email='f@example.com', credits=5)
|
||||
_seed_code('f@example.com', '654321', purpose='reset')
|
||||
r = env.post('/api/v1/auth/forgot_password', json={
|
||||
r = env.post('/auth/forgot_password', json={
|
||||
'email': 'f@example.com', 'code': '654321', 'password': 'NewPass456'})
|
||||
assert r.status_code == 200
|
||||
assert r.json()['success'] is True
|
||||
# 新密码可登录
|
||||
r2 = env.post('/api/v1/auth/login', json={'username': 'forgetme', 'password': 'NewPass456'})
|
||||
r2 = env.post('/auth/login', json={'username': 'forgetme', 'password': 'NewPass456'})
|
||||
assert r2.status_code == 200 and r2.json()['success'] is True
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ def test_forgot_password_admin_blocked(env):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_seed_code('boss@example.com', '111111', purpose='reset')
|
||||
r = env.post('/api/v1/auth/forgot_password', json={
|
||||
r = env.post('/auth/forgot_password', json={
|
||||
'email': 'boss@example.com', 'code': '111111', 'password': 'NewPass456'})
|
||||
# 管理员不走自助找回:统一防枚举文案
|
||||
assert r.json()['message'] == '邮箱验证码错误或已过期'
|
||||
@@ -111,8 +111,8 @@ def test_redeem_card(env):
|
||||
# 造一张卡密并让用户登录
|
||||
auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0)
|
||||
code = auth.generate_cards(1, 10, note='test')[0]
|
||||
env.post('/api/v1/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'})
|
||||
r = env.post('/api/v1/auth/redeem', json={'code': code})
|
||||
env.post('/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'})
|
||||
r = env.post('/auth/redeem', json={'code': code})
|
||||
assert r.status_code == 200
|
||||
assert r.json()['success'] is True
|
||||
assert r.json()['data']['credits'] == 10
|
||||
@@ -62,6 +62,6 @@ def test_root_page_served(root_client):
|
||||
|
||||
|
||||
def test_no_api_v1_prefix_in_root_mode(root_client):
|
||||
"""root 模式下不再暴露 /api/v1 前缀(避免与 Flask 时代语义混淆)。"""
|
||||
"""根路径模式下不再有 /api/v1 前缀(Flask 时代的比对前缀已废弃)。"""
|
||||
r = root_client.post('/api/v1/song', json={})
|
||||
assert r.status_code == 404
|
||||
+33
-25
@@ -1,54 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Flask 兼容会话编解码的单元测试(不依赖网络)。"""
|
||||
"""Flask 兼容会话编解码的单元测试。
|
||||
|
||||
验证 core.session 与 itsdangerous(Flask 会话所用库)**双向互通**:
|
||||
Flask 时代签发的会话 Cookie 必须仍能被本站解析(用户不必重新登录)。
|
||||
|
||||
Flask 本身已从依赖中移除,这里直接用 itsdangerous 构造/校验
|
||||
(Flask 会话即 itsdangerous URLSafeTimedSerializer:盐 cookie-session、HMAC-SHA1)。
|
||||
"""
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.sessions import SecureCookieSessionInterface
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
from core import session as cs
|
||||
|
||||
SECRET = 'unit-test-secret-key'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def flask_serializer():
|
||||
app = Flask(__name__)
|
||||
app.secret_key = SECRET
|
||||
return SecureCookieSessionInterface().get_signing_serializer(app)
|
||||
def _flask_serializer():
|
||||
"""等价于 Flask 的会话序列化器(同盐、同摘要算法、同 key 派生)。"""
|
||||
return URLSafeTimedSerializer(
|
||||
SECRET, salt='cookie-session',
|
||||
signer_kwargs={'key_derivation': 'hmac', 'digest_method': hashlib.sha1})
|
||||
|
||||
|
||||
def _flask_cookie(ser, data):
|
||||
return ser.dumps(data)
|
||||
|
||||
|
||||
def test_flask_to_core(flask_serializer):
|
||||
"""Flask 签发的会话 Cookie 能被 core.session 解出。"""
|
||||
cookie = _flask_cookie(flask_serializer, {'site_uid': 26, 'site_uid_seq': 5})
|
||||
def test_itsdangerous_to_core():
|
||||
"""itsdangerous(Flask)签发的会话 Cookie 能被 core.session 解出。"""
|
||||
cookie = _flask_serializer().dumps({'site_uid': 26, 'site_uid_seq': 5})
|
||||
data = cs.loads(SECRET, cookie)
|
||||
assert data == {'site_uid': 26, 'site_uid_seq': 5}
|
||||
assert cs.get_uid(data) == 26
|
||||
|
||||
|
||||
def test_core_to_flask(flask_serializer):
|
||||
"""core.session 签发(含时间戳)的 Cookie 能被 Flask 解出。"""
|
||||
def test_core_to_itsdangerous():
|
||||
"""core.session 签发的 Cookie 能被 itsdangerous 解出。"""
|
||||
cookie = cs.dumps(SECRET, {'site_uid': 74, 'site_uid_seq': 8})
|
||||
assert flask_serializer.loads(cookie) == {'site_uid': 74, 'site_uid_seq': 8}
|
||||
assert _flask_serializer().loads(cookie) == {'site_uid': 74, 'site_uid_seq': 8}
|
||||
|
||||
|
||||
def test_core_to_flask_expiry(flask_serializer):
|
||||
"""时间戳格式正确:Flask 能按 max_age 校验(未过期可解)。"""
|
||||
def test_core_timestamp_valid_for_timed_check():
|
||||
"""时间戳编码与 itsdangerous 一致:max_age 校验可用(未过期)。"""
|
||||
cookie = cs.dumps(SECRET, {'site_uid': 1})
|
||||
assert flask_serializer.loads(cookie, max_age=3600) == {'site_uid': 1}
|
||||
assert _flask_serializer().loads(cookie, max_age=3600) == {'site_uid': 1}
|
||||
|
||||
|
||||
def test_tampered_returns_none(flask_serializer):
|
||||
cookie = _flask_cookie(flask_serializer, {'site_uid': 26})
|
||||
def test_tampered_returns_none():
|
||||
cookie = _flask_serializer().dumps({'site_uid': 26})
|
||||
assert cs.loads(SECRET, cookie[:-3] + 'xyz') is None
|
||||
|
||||
|
||||
def test_wrong_key_returns_none(flask_serializer):
|
||||
cookie = _flask_cookie(flask_serializer, {'site_uid': 26})
|
||||
def test_wrong_key_returns_none():
|
||||
cookie = _flask_serializer().dumps({'site_uid': 26})
|
||||
assert cs.loads('wrong-secret', cookie) is None
|
||||
|
||||
|
||||
@@ -65,3 +68,8 @@ def test_roundtrip_selfconsistency():
|
||||
def test_get_uid_none():
|
||||
assert cs.get_uid(None) is None
|
||||
assert cs.get_uid({}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('uid', [1, 26, 99999])
|
||||
def test_various_uids(uid):
|
||||
assert cs.get_uid(cs.loads(SECRET, cs.dumps(SECRET, {'site_uid': uid}))) == uid
|
||||
+6
-24
@@ -4,13 +4,8 @@
|
||||
设计(strangler 迁移):
|
||||
- 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存,
|
||||
便于逐端点比对响应契约(.zcode/refactor/parity.py)。
|
||||
- `serve_flask_fallback=True` 时,把未迁移的遗留 Flask 应用整体挂到根路径兜底,
|
||||
实现「新端点走 FastAPI、其余走 Flask」的单进程双框架运行;迁移完成后删掉该开关。
|
||||
|
||||
注意(默认关闭的原因):Flask fallback 会在 FastAPI 进程内 import main,
|
||||
进而初始化 MusicAPIService / 打开 SQLite —— 与生产既有的 waitress 进程是
|
||||
两套独立进程语义。生产切换前需按计划把状态与任务模型统一(见迁移计划阶段 C),
|
||||
当前仅用于开发期联调。
|
||||
迁移已完成:Flask 巨石(main.py/auth_routes.py)已删除,本应用即唯一服务端。
|
||||
`api_prefix` 为空即直接服务前端使用的根路径;非空则挂在给定前缀下(用于契约比对)。
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -64,13 +59,12 @@ def _init_audio_secret() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def create_app(serve_flask_fallback: bool = False, api_prefix: str = '/api/v1') -> FastAPI:
|
||||
def create_app(api_prefix: str = '') -> FastAPI:
|
||||
"""创建 FastAPI 应用。
|
||||
|
||||
api_prefix:已迁移路由的挂载前缀。
|
||||
- '/api/v1'(默认):与 Flask 根路径并存,便于逐端点契约比对(迁移期)
|
||||
- ''(切换完成):FastAPI 直接服务前端使用的根路径,不再需要 Flask 兜底
|
||||
serve_flask_fallback:把 Flask 应用挂到根路径兜底(仅 api_prefix 非空时可用)。
|
||||
api_prefix:路由挂载前缀。
|
||||
- ''(默认):直接服务前端使用的根路径(生产形态)
|
||||
- '/api/v1':挂在子前缀下(契约比对脚本 .zcode/refactor/parity.py 用)
|
||||
"""
|
||||
_init_audio_secret()
|
||||
|
||||
@@ -112,16 +106,4 @@ def create_app(serve_flask_fallback: bool = False, api_prefix: str = '/api/v1')
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
if serve_flask_fallback and api_prefix:
|
||||
_mount_flask_fallback(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _mount_flask_fallback(app: FastAPI) -> None:
|
||||
"""把遗留 Flask WSGI 应用挂到根路径,兜底未迁移的路由。"""
|
||||
from starlette.middleware.wsgi import WSGIMiddleware
|
||||
|
||||
import main as legacy_flask
|
||||
|
||||
app.mount('/', WSGIMiddleware(legacy_flask.app))
|
||||
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""认证的内存态与校验辅助(原 auth_routes 的模块级状态,框架无关)。
|
||||
|
||||
包含:登录失败锁定、管理员 2FA 票据、发码 IP 限流、用户名/保留名校验。
|
||||
这些状态**进程内共享**——登录/注册/2FA 各端点都读写同一份,避免「换个端点绕过锁定」。
|
||||
"""
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import auth_system as auth
|
||||
|
||||
# ---- 登录失败锁定(按用户名维度,10 分钟窗口内 >=5 次临时锁定)----
|
||||
_LOGIN_FAILS: dict = {}
|
||||
_LOGIN_FAIL_SWEEP_TS = 0.0
|
||||
# 本锁定周期已落过审计日志的用户名(解锁或成功登录即重置)
|
||||
_LOGIN_FAIL_LOGGED: set = set()
|
||||
|
||||
# ---- 管理员两步验证票据(内存态)----
|
||||
_ADMIN_2FA: dict = {}
|
||||
_ADMIN2FA_LAST_SEND: dict = {}
|
||||
|
||||
|
||||
_LOGIN_FAIL_LIMIT = 5
|
||||
|
||||
|
||||
_LOGIN_FAIL_WINDOW = 600
|
||||
|
||||
|
||||
_LOGIN_FAIL_SWEEP_TS = 0.0
|
||||
|
||||
|
||||
_ADMIN_2FA_TTL = 300 # 票据 5 分钟有效
|
||||
|
||||
|
||||
_ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限
|
||||
|
||||
|
||||
_ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧)
|
||||
|
||||
|
||||
_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——第 87 条初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环
|
||||
|
||||
|
||||
def _admin2fa_purge() -> None:
|
||||
now = time.time()
|
||||
for t in [t for t, v in _ADMIN_2FA.items() if now - v['ts'] > _ADMIN_2FA_TTL]:
|
||||
_ADMIN_2FA.pop(t, None)
|
||||
|
||||
|
||||
def _admin2fa_mask(email: str) -> str:
|
||||
"""邮箱掩码(审计日志与前端提示用):3*****@qq.com 样式"""
|
||||
try:
|
||||
local, dom = str(email).split('@', 1)
|
||||
return (local[0] if local else '*') + '*****@' + dom
|
||||
except Exception:
|
||||
return '*****'
|
||||
|
||||
|
||||
def _admin2fa_issue(user) -> str:
|
||||
_admin2fa_purge()
|
||||
# 第 87 条:每管理员仅 1 张活票——新票签发即销毁同账号旧票
|
||||
for t in [t for t, v in list(_ADMIN_2FA.items()) if v['username'] == user['username']]:
|
||||
_ADMIN_2FA.pop(t, None)
|
||||
if len(_ADMIN_2FA) >= _ADMIN_2FA_MAX_TICKETS:
|
||||
items = list(_ADMIN_2FA.items()) # 快照后再 min,防并发 pop 使迭代失效
|
||||
if items:
|
||||
oldest = min(items, key=lambda kv: kv[1]['ts'])[0]
|
||||
_ADMIN_2FA.pop(oldest, None)
|
||||
ticket = secrets.token_urlsafe(16)
|
||||
_ADMIN_2FA[ticket] = {'user_id': user['id'], 'username': user['username'],
|
||||
'email': user['email'], 'ts': int(time.time()), 'attempts': 0}
|
||||
return ticket
|
||||
|
||||
|
||||
def _admin2fa_take(ticket: str):
|
||||
"""取活票(不销毁):无票/过期返回 (None, None);命中返回 (ticket, 数据)。"""
|
||||
_admin2fa_purge()
|
||||
v = _ADMIN_2FA.get(ticket or '')
|
||||
if not v:
|
||||
return None, None
|
||||
return ticket, v
|
||||
|
||||
|
||||
def _login_fail_check(username: str) -> bool:
|
||||
"""返回 True 表示允许尝试登录;锁定窗口内返回 False"""
|
||||
global _LOGIN_FAIL_SWEEP_TS
|
||||
now = time.time()
|
||||
fails = [t for t in _LOGIN_FAILS.get(username, []) if now - t < _LOGIN_FAIL_WINDOW]
|
||||
_LOGIN_FAILS[username] = fails
|
||||
if len(fails) < _LOGIN_FAIL_LIMIT:
|
||||
_LOGIN_FAIL_LOGGED.discard(username)
|
||||
if len(_LOGIN_FAILS) > 10000 and now - _LOGIN_FAIL_SWEEP_TS > 60: # 容量上限(用户名为键,防无界增长)
|
||||
_LOGIN_FAIL_SWEEP_TS = now
|
||||
stale_users = [u for u, ts in _LOGIN_FAILS.items()
|
||||
if not ts or now - max(ts) > _LOGIN_FAIL_WINDOW]
|
||||
for u in stale_users[:5000]:
|
||||
_LOGIN_FAILS.pop(u, None)
|
||||
if len(_LOGIN_FAILS) > 20000:
|
||||
for u in list(_LOGIN_FAILS.keys())[:len(_LOGIN_FAILS) // 2]:
|
||||
_LOGIN_FAILS.pop(u, None)
|
||||
return len(fails) < _LOGIN_FAIL_LIMIT
|
||||
|
||||
|
||||
def _login_fail_record(username: str) -> None:
|
||||
_LOGIN_FAILS.setdefault(username, []).append(time.time())
|
||||
|
||||
|
||||
def _login_fail_clear(username: str) -> None:
|
||||
_LOGIN_FAILS.pop(username, None)
|
||||
_LOGIN_FAIL_LOGGED.discard(username)
|
||||
|
||||
|
||||
def _login_fail_locked_log_once(username: str) -> bool:
|
||||
"""R5-4:锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表;
|
||||
解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。"""
|
||||
if username in _LOGIN_FAIL_LOGGED:
|
||||
return False
|
||||
_LOGIN_FAIL_LOGGED.add(username)
|
||||
return True
|
||||
|
||||
|
||||
def _safe_register_credits() -> int:
|
||||
"""注册赠送次数(settings.register_credits,默认 3)。
|
||||
R5 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。"""
|
||||
try:
|
||||
credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return auth.DEFAULT_REGISTER_CREDITS
|
||||
return max(0, credits)
|
||||
|
||||
|
||||
_SEND_CODE_IP = {}
|
||||
|
||||
|
||||
_SEND_CODE_IP_LIMIT = 10
|
||||
|
||||
|
||||
_SEND_CODE_IP_WINDOW = 3600
|
||||
|
||||
|
||||
def _send_code_ip_check(ip: str) -> bool:
|
||||
"""返回 True 表示允许发送;超限返回 False(S4:表超限时顺手清理过期项,防无界增长)"""
|
||||
now = time.time()
|
||||
if len(_SEND_CODE_IP) > 10000:
|
||||
stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW]
|
||||
for k in stale:
|
||||
_SEND_CODE_IP.pop(k, None)
|
||||
cnt, win = _SEND_CODE_IP.get(ip, (0, now))
|
||||
if now - win > _SEND_CODE_IP_WINDOW:
|
||||
cnt, win = 0, now
|
||||
if cnt >= _SEND_CODE_IP_LIMIT:
|
||||
return False
|
||||
_SEND_CODE_IP[ip] = (cnt + 1, win)
|
||||
return True
|
||||
|
||||
|
||||
_RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'moderator', '管理员', '客服'}
|
||||
|
||||
|
||||
def re_username_ok(username: str) -> bool:
|
||||
import re as _re
|
||||
# P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险)
|
||||
# R5-P3:白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符
|
||||
#("аdmin" 冒充钓鱼);保留字(admin/qfbsh 等)一并拒绝,防冒充管理入口
|
||||
return bool(_re.fullmatch(r'[A-Za-z0-9_\u4e00-\u9fa5]{3,20}', username)) and \
|
||||
not _re.match(r'^_+$', username) and not username.startswith('_') and not username.endswith('_') \
|
||||
and username.lower() not in _RESERVED_USERNAMES
|
||||
|
||||
|
||||
def secrets_rand() -> int:
|
||||
import secrets as _s
|
||||
return _s.randbelow(900000) + 100000
|
||||
+4
-8
@@ -22,15 +22,11 @@ from web.app import create_app
|
||||
HOST = os.environ.get('HOST', '0.0.0.0')
|
||||
PORT = int(os.environ.get('PORT', '5000'))
|
||||
|
||||
# 运行模式(默认保持迁移期形态,切换为显式操作):
|
||||
# WEB_MODE=dual(默认)已迁移路由挂 /api/v1 + Flask 兜底其余路由(前端仍走根路径→Flask)
|
||||
# WEB_MODE=fastapi 已迁移路由直接挂根路径,完全由 FastAPI 服务(不再需要 Flask)
|
||||
WEB_MODE = os.environ.get('WEB_MODE', 'dual').strip().lower()
|
||||
# Flask 巨石已删除:本应用即唯一服务端,直接服务前端使用的根路径。
|
||||
# API_PREFIX 非空时挂在子前缀下(契约比对脚本使用)。
|
||||
API_PREFIX = os.environ.get('API_PREFIX', '').strip()
|
||||
|
||||
if WEB_MODE == 'fastapi':
|
||||
app = create_app(serve_flask_fallback=False, api_prefix='')
|
||||
else:
|
||||
app = create_app(serve_flask_fallback=True, api_prefix='/api/v1')
|
||||
app = create_app(api_prefix=API_PREFIX)
|
||||
|
||||
if __name__ == '__main__':
|
||||
uvicorn.run(
|
||||
|
||||
+10
-6
@@ -19,18 +19,22 @@ _EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'}
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP,与 Flask 同策略。"""
|
||||
import auth_system
|
||||
"""客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP。
|
||||
|
||||
规则与 Flask 时代完全一致(core.net 与原实现同源):XFF 最右段优先,
|
||||
最右段为回环时回落 X-Real-IP,全部无效回落直连地址。
|
||||
"""
|
||||
from core.net import is_loopback_ip, trusted_proxy_ip
|
||||
remote = request.client.host if request.client else '-'
|
||||
if remote and remote != '-' and auth_system._is_loopback_ip(remote):
|
||||
if remote and remote != '-' and is_loopback_ip(remote):
|
||||
xff = request.headers.get('x-forwarded-for')
|
||||
if xff:
|
||||
ip = auth_system._trusted_proxy_ip(xff)
|
||||
if ip and not auth_system._is_loopback_ip(ip):
|
||||
ip = trusted_proxy_ip(xff)
|
||||
if ip and not is_loopback_ip(ip):
|
||||
return ip
|
||||
xreal = request.headers.get('x-real-ip')
|
||||
if xreal:
|
||||
ip = auth_system._trusted_proxy_ip(xreal)
|
||||
ip = trusted_proxy_ip(xreal)
|
||||
if ip:
|
||||
return ip
|
||||
return remote or '-'
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@
|
||||
- POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据)
|
||||
|
||||
设计:
|
||||
- 登录失败锁定与管理员 2FA 票据表**复用 auth_routes 的模块级状态**,两栈共享同一份
|
||||
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,两栈共享同一份
|
||||
计数/票据,避免「换个框架打就绕过锁定」。
|
||||
- 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行,
|
||||
不阻塞事件循环。
|
||||
@@ -104,7 +104,7 @@ async def ann_seen(user: Annotated[SiteUser | None, Depends(current_user)]):
|
||||
@router.post('/auth/login')
|
||||
async def login(request: Request):
|
||||
"""登录:密码校验(含邮箱登录)、失败锁定、管理员两步验证转票据。"""
|
||||
from auth_routes import (
|
||||
from web.auth_state import (
|
||||
_admin2fa_issue,
|
||||
_admin2fa_mask,
|
||||
_login_fail_check,
|
||||
@@ -201,7 +201,7 @@ async def admin_login_email(request: Request):
|
||||
"""管理员 2FA Step2:比对绑定邮箱 → 发送 admin2fa 验证码。"""
|
||||
import hmac as _hmac
|
||||
|
||||
from auth_routes import (
|
||||
from web.auth_state import (
|
||||
_ADMIN2FA_LAST_SEND,
|
||||
_ADMIN2FA_SEND_GAP,
|
||||
_ADMIN_2FA,
|
||||
@@ -245,7 +245,7 @@ async def admin_login_email(request: Request):
|
||||
@router.post('/auth/admin_login/verify')
|
||||
async def admin_login_verify(request: Request):
|
||||
"""管理员 2FA Step3:验证码原子消费 → 建立管理员会话。"""
|
||||
from auth_routes import (
|
||||
from web.auth_state import (
|
||||
_ADMIN_2FA,
|
||||
_ADMIN_2FA_MAX_ATTEMPTS,
|
||||
_admin2fa_take,
|
||||
|
||||
+14
-14
@@ -115,8 +115,8 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
|
||||
lyric_format = 'lrc'
|
||||
need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes')
|
||||
|
||||
import main as legacy
|
||||
downloader = legacy.api_service.downloader
|
||||
import services.runtime as services
|
||||
downloader = services.api_service.downloader
|
||||
|
||||
if return_format == 'json':
|
||||
try:
|
||||
@@ -127,7 +127,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
|
||||
await billing.refund_open('rejected')
|
||||
return error('获取失败:歌曲不存在或暂无版权', 404)
|
||||
base = await asyncio.to_thread(
|
||||
legacy._build_download_base, filename_format,
|
||||
services._build_download_base, filename_format,
|
||||
{'name': info.name, 'artist_string': info.artists})
|
||||
filename = f'{base}.{info.file_type}'
|
||||
try:
|
||||
@@ -165,7 +165,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
|
||||
|
||||
# file 模式:注册后台任务
|
||||
device_id = str(data.get('device_id') or '').strip()[:64]
|
||||
await asyncio.to_thread(legacy._touch_device, device_id)
|
||||
await asyncio.to_thread(services._touch_device, device_id)
|
||||
task = _single_task(data, music_id, quality, filename_format, write_meta,
|
||||
meta_fields, lyric_format, need_zip, cookies, ip, device_id)
|
||||
reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
|
||||
@@ -173,7 +173,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
|
||||
await billing.refund_open('rejected')
|
||||
return error(reg_err, 429)
|
||||
task_id = task['task_id']
|
||||
start_err = await start_task(task_id, legacy._single_download_worker, (task_id,))
|
||||
start_err = await start_task(task_id, services._single_download_worker, (task_id,))
|
||||
if start_err:
|
||||
return error(start_err, 500)
|
||||
return success({'task_id': task_id, 'status': 'queued'}, '下载任务已提交')
|
||||
@@ -248,8 +248,8 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
|
||||
|
||||
cookies = _cookies()
|
||||
device_id = str(data.get('device_id') or '').strip()[:64]
|
||||
import main as legacy
|
||||
await asyncio.to_thread(legacy._touch_device, device_id)
|
||||
import services.runtime as services
|
||||
await asyncio.to_thread(services._touch_device, device_id)
|
||||
task = {
|
||||
'type': 'batch',
|
||||
'total': len(ids), 'done': 0, 'failed': 0,
|
||||
@@ -269,7 +269,7 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
|
||||
await billing.refund_open('rejected')
|
||||
return error(reg_err, 429)
|
||||
task_id = task['task_id']
|
||||
start_err = await start_task(task_id, legacy._batch_zip_worker, (task_id,))
|
||||
start_err = await start_task(task_id, services._batch_zip_worker, (task_id,))
|
||||
if start_err:
|
||||
return error(start_err, 500)
|
||||
return success({'task_id': task_id, 'status': 'queued', 'total': len(ids)},
|
||||
@@ -330,8 +330,8 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user
|
||||
cookies = _cookies()
|
||||
|
||||
device_id = str(data.get('device_id') or '').strip()[:64]
|
||||
import main as legacy
|
||||
await asyncio.to_thread(legacy._touch_device, device_id)
|
||||
import services.runtime as services
|
||||
await asyncio.to_thread(services._touch_device, device_id)
|
||||
task = {
|
||||
'type': 'single',
|
||||
'total': 1, 'done': 0, 'failed': 0,
|
||||
@@ -346,7 +346,7 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user
|
||||
await billing.refund_open('rejected')
|
||||
return error(reg_err, 429)
|
||||
task_id = task['task_id']
|
||||
start_err = await start_task(task_id, legacy._podcast_download_worker, (task_id,))
|
||||
start_err = await start_task(task_id, services._podcast_download_worker, (task_id,))
|
||||
if start_err:
|
||||
return error(start_err, 500)
|
||||
return success({'task_id': task_id, 'status': 'queued'}, '播客单集下载任务已提交')
|
||||
@@ -402,8 +402,8 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
|
||||
|
||||
billed = _podcast_billing_count(data)
|
||||
device_id = str(data.get('device_id') or '').strip()[:64]
|
||||
import main as legacy
|
||||
await asyncio.to_thread(legacy._touch_device, device_id)
|
||||
import services.runtime as services
|
||||
await asyncio.to_thread(services._touch_device, device_id)
|
||||
task = {
|
||||
'type': 'batch',
|
||||
'total': 0, 'done': 0, 'failed': 0,
|
||||
@@ -425,7 +425,7 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
|
||||
await billing.refund_open('rejected')
|
||||
return error(reg_err, 429)
|
||||
task_id = task['task_id']
|
||||
start_err = await start_task(task_id, legacy._podcast_zip_worker, (task_id,))
|
||||
start_err = await start_task(task_id, services._podcast_zip_worker, (task_id,))
|
||||
if start_err:
|
||||
return error(start_err, 500)
|
||||
return success({'task_id': task_id, 'status': 'queued'}, '播客打包任务已提交')
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
说明:
|
||||
- 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行,
|
||||
不阻塞事件循环;行为与 Flask 完全一致。
|
||||
- 发码 IP 限流复用 auth_routes 的模块级状态 `_SEND_CODE_IP`(跨栈共享计数)。
|
||||
- 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。
|
||||
- 失败锁定/验证码消费等安全口径逐字保留。
|
||||
"""
|
||||
import asyncio
|
||||
@@ -64,7 +64,7 @@ async def _safe_register_credits() -> int:
|
||||
@router.post('/auth/send_code')
|
||||
async def send_code(request: Request):
|
||||
"""发送邮箱验证码(注册/找回用途隔离)。"""
|
||||
from auth_routes import _send_code_ip_check
|
||||
from web.auth_state import _send_code_ip_check
|
||||
ip = ratelimit.client_ip(request)
|
||||
if not _send_code_ip_check(ip):
|
||||
return error('验证码请求过于频繁,请稍后再试')
|
||||
@@ -141,7 +141,7 @@ async def forgot_password(request: Request):
|
||||
@router.post('/auth/register')
|
||||
async def register(request: Request):
|
||||
"""注册:验证码校验 → 用户名/邮箱查重 → 防刷双闸 → 建号赠送 → 建立会话。"""
|
||||
from auth_routes import re_username_ok
|
||||
from web.auth_state import re_username_ok
|
||||
|
||||
data = await _request_data(request)
|
||||
username = str(data.get('username') or '').strip()
|
||||
|
||||
@@ -58,14 +58,14 @@ def _owned_by(task: dict[str, Any], user: SiteUser) -> bool:
|
||||
|
||||
def _semaphores():
|
||||
"""批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。"""
|
||||
import main as legacy
|
||||
return legacy.BATCH_ZIP_SEM, legacy.api_service.downloader._download_semaphore
|
||||
import services.runtime as services
|
||||
return services.BATCH_ZIP_SEM, services.api_service.downloader._download_semaphore
|
||||
|
||||
|
||||
@router.api_route('/api/topen', methods=['GET', 'POST'])
|
||||
async def topen(request: Request):
|
||||
"""接口请求统计(实时)——锁内快照,避免 total 与明细不一致。"""
|
||||
import main as legacy
|
||||
import services.runtime as services
|
||||
with STATS_LOCK:
|
||||
snapshot = {p: dict(v) for p, v in REQUEST_STATS.items()}
|
||||
endpoints = []
|
||||
@@ -73,7 +73,7 @@ async def topen(request: Request):
|
||||
for path, info in snapshot.items():
|
||||
endpoints.append({
|
||||
'path': path,
|
||||
'name': legacy.ENDPOINT_NAMES.get(path, path),
|
||||
'name': services.ENDPOINT_NAMES.get(path, path),
|
||||
'count': info['count'],
|
||||
'percent': round(info['count'] / total * 100, 1) if total else 0,
|
||||
'last': info['last'],
|
||||
|
||||
@@ -72,8 +72,8 @@ async def register_download_task(billing: Billing, task: dict[str, Any],
|
||||
|
||||
|
||||
async def start_task(task_id: str, worker: Callable, args: tuple = ()) -> str | None:
|
||||
"""启动 worker 线程(复用 main._start_task_thread 的实现,失败收敛终态并退费)。"""
|
||||
"""启动 worker 线程(复用 services 的 _start_task_thread,失败收敛终态并退费)。"""
|
||||
import asyncio
|
||||
|
||||
import main as legacy
|
||||
return await asyncio.to_thread(legacy._start_task_thread, task_id, worker, args)
|
||||
import services.runtime as services
|
||||
return await asyncio.to_thread(services._start_task_thread, task_id, worker, args)
|
||||
@@ -1,6 +1,6 @@
|
||||
# 云村小助手 · 功能介绍
|
||||
|
||||
> 一个基于 Flask + 原生前端的云村小助手。
|
||||
> 一个基于 FastAPI + 原生前端的云村小助手。
|
||||
> 支持搜索、单曲/歌单/专辑/歌手解析、官方榜单、个人歌单、多账号体系、批量下载、ZIP 打包、
|
||||
> 歌词逐字高亮、评论区、接口统计、隐私协议 Cookie 管理等完整功能。
|
||||
|
||||
|
||||
@@ -22,18 +22,39 @@
|
||||
## 二、文件说明
|
||||
|
||||
```
|
||||
Netease_url/
|
||||
├── main.py # Flask 主程序(路由、计费、音频代理防护)
|
||||
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:用户/卡密/公告/日志/封禁/顶号)
|
||||
├── auth_routes.py # 认证与管理后台路由
|
||||
├── music_api.py # 网易云音乐 API 封装
|
||||
├── music_api.py # 网易云音乐 API 封装(全异步)
|
||||
├── music_downloader.py # 下载器(ffmpeg 元数据 / ZIP 打包)
|
||||
├── podcast_api.py # 播客解析 API
|
||||
├── qr_login.py # 网易云扫码登录
|
||||
├── templates/index.html # 前端页面(原生 HTML/CSS/JS)
|
||||
├── static/site_auth.js # 前端认证 / 个人中心 / 管理后台交互
|
||||
├── requirements.txt # Python 依赖(Python 3.12)
|
||||
├── Dockerfile # 镜像构建文件
|
||||
├── pyproject.toml # 依赖与工具配置(uv 管理)
|
||||
├── uv.lock # 精确锁定依赖版本
|
||||
├── tests/ # 测试(含 golden 契约回归)
|
||||
├── Dockerfile # 镜像构建文件(uv sync)
|
||||
├── docker-compose.yml # 编排配置(端口 / 持久卷)
|
||||
├── entrypoint.sh # 容器启动脚本
|
||||
├── cookie.txt # 全局网易云账号 Cookie(部署后扫码生成,勿提交)
|
||||
|
||||
Reference in new issue
Block a user