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:
snowy-yang committed 2026-09-26 11:10:17 +08:00
1 parent f5d1e3c3a9
commit 8446166120
41 files changed
+4836 -6060

No files matched your search

-3
View File
@@ -19,10 +19,7 @@ user_cookie.*.txt
downloads/ downloads/
*.log *.log
music_api.log* music_api.log*
_pkg_ok.txt
_css_ok.txt
_audit_work/ _audit_work/
.zcode/ .zcode/
.env .env
_smoke_test_downloader.py
云村小助手-网站开关.bat 云村小助手-网站开关.bat
+1
View File
@@ -0,0 +1 @@
3.12
+5 -6
View File
@@ -6,12 +6,11 @@ WORKDIR /app
# (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响) # (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响)
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories && \ RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories && \
apk add --no-cache tzdata ffmpeg apk add --no-cache tzdata ffmpeg
# 目标栈:FastAPI + uvicorn(Flask 作为迁移期兜底仍随包) # 依赖由 uv 从 uv.lock 精确安装(可复现构建);uv 二进制仅存在于构建阶段
COPY requirements-fastapi.txt requirements-fastapi.txt COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY requirements.txt requirements.txt ENV UV_PROJECT_ENVIRONMENT=/usr/local UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
RUN pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ COPY pyproject.toml uv.lock ./
pip3 install --no-cache-dir -r requirements-fastapi.txt && \ RUN uv sync --frozen --no-dev
pip3 install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
# 非 root 运行(容器安全基线)。注:挂载卷 ./downloads ./data 的宿主机目录 # 非 root 运行(容器安全基线)。注:挂载卷 ./downloads ./data 的宿主机目录
+1 -1
View File
@@ -50,7 +50,7 @@ if public:
'待修问题清单_第四轮深挖.md', '待修问题清单_第四轮深挖.md',
# 第 87 条:第五轮深挖清单同样含全部漏洞细节,公开包必须剔除 # 第 87 条:第五轮深挖清单同样含全部漏洞细节,公开包必须剔除
'待修问题清单_第五轮深挖.md', '待修问题清单_第五轮深挖.md',
'_pkg_ok.txt', '_css_ok.txt'): '_pkg_ok.txt',):
p = os.path.join(DST, rel) p = os.path.join(DST, rel)
try: try:
if os.path.isdir(p): if os.path.isdir(p):
-1047
View File
File diff suppressed because it is too large. Load diff
+2 -250
View File
@@ -27,12 +27,9 @@ import time
from email.header import Header from email.header import Header
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr from email.utils import formataddr
from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from flask import g, request, session
# ============ 路径与常量 ============ # ============ 路径与常量 ============
DATA_DIR = Path('data') DATA_DIR = Path('data')
DB_PATH = DATA_DIR / 'site.db' DB_PATH = DATA_DIR / 'site.db'
@@ -465,18 +462,6 @@ def _is_private_ip(ip: str) -> bool:
return False 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: def query_ip_province(ip: str) -> str:
"""查询 IP 归属省份(内网/失败返回空串),带进程内缓存 """查询 IP 归属省份(内网/失败返回空串),带进程内缓存
F47:改用官方路径 /json/{query} 与 zh-CN 语言参数""" F47:改用官方路径 /json/{query} 与 zh-CN 语言参数"""
@@ -489,7 +474,7 @@ def query_ip_province(ip: str) -> str:
try: try:
import urllib.parse as _ul 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), r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip),
params={'lang': 'zh-CN', 'fields': 'status,regionName'}, params={'lang': 'zh-CN', 'fields': 'status,regionName'},
timeout=5) timeout=5)
@@ -598,142 +583,6 @@ def _last_province(r: sqlite3.Row) -> str:
return '' 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 次) ============ # ============ 使用次数(按次计费:一首歌 = 1 次) ============
@@ -1017,103 +866,6 @@ def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int:
return 0 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)) shutil.copy2(str(safety), str(DB_PATH))
init_db() init_db()
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None
# 第 94 条(P3-4):裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写 # 第 94 条(P3-4):裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写
try: try:
prune_backups(20, 5) prune_backups(20, 5)
+5
View File
@@ -32,6 +32,11 @@ def _get_loop() -> asyncio.AbstractEventLoop:
return _loop return _loop
def get_loop() -> asyncio.AbstractEventLoop:
"""后台事件循环(供需要直接提交协程的调用方使用)。"""
return _get_loop()
def run_sync[T](coro: Coroutine[Any, Any, T], timeout: float | None = None) -> T: def run_sync[T](coro: Coroutine[Any, Any, T], timeout: float | None = None) -> T:
"""在同步代码中运行协程并等待结果。 """在同步代码中运行协程并等待结果。
+45
View File
@@ -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
+202
View File
@@ -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
View File
@@ -105,9 +105,9 @@ def expand_shortlink(url: str) -> str:
return hit return hit
expanded = url expanded = url
try: 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) expanded = response.headers.get('Location', url)
except Exception: except Exception:
expanded = url expanded = url
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- TZ=Asia/Shanghai # 未提供 .env 时的默认时区(F59:文档"默认即可运行"现在成立) - TZ=Asia/Shanghai # 未提供 .env 时的默认时区(F59:文档"默认即可运行"现在成立)
- ENABLE_SECURE_COOKIE=${ENABLE_SECURE_COOKIE:-} # HTTPS 反代上线后设 1(见 .env.example) - ENABLE_SECURE_COOKIE=${ENABLE_SECURE_COOKIE:-} # HTTPS 反代上线后设 1(见 .env.example)
ports: ports:
- "5001:5000" # 对外 5001 端口(容器内 Flask 监听 5000) - "5001:5000" # 对外 5001 端口(容器内服务监听 5000)
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./cookie.txt:/app/cookie.txt # 全局管理员 Cookie(持久化,重建不丢失;需先创建文件) - ./cookie.txt:/app/cookie.txt # 全局管理员 Cookie(持久化,重建不丢失;需先创建文件)
+1 -5
View File
@@ -10,9 +10,5 @@ for f in user_cookie.txt cookie.txt; do
fi fi
[ -f "/app/$f" ] || touch "/app/$f" [ -f "/app/$f" ] || touch "/app/$f"
done done
# 生产服务器改为 uvicorn(FastAPI + Flask 兜底,见 web/main.py) # 生产服务器:uvicorn(FastAPI)。
# WEB_STACK=flask 可回退到旧入口 main.py(waitress/Werkzeug)以应急
if [ "${WEB_STACK:-fastapi}" = "flask" ]; then
exec python3 main.py
fi
exec python3 web/main.py exec python3 web/main.py
-4526
View File
File diff suppressed because it is too large. Load diff
+29 -29
View File
@@ -207,7 +207,7 @@ class HTTPClient:
except requests.HTTPError as e: except requests.HTTPError as e:
# 第 91 条(P2-6):4xx(429/460 风控)不重试——原实现 HTTPError 落入 # 第 91 条(P2-6):4xx(429/460 风控)不重试——原实现 HTTPError 落入
# 下方 RequestException 分支被重试一次,与 R5-20 意图相反 # 下方 RequestException 分支被重试一次,与 R5-20 意图相反
raise APIException(f"HTTP请求失败: {e}") raise APIException(f"HTTP请求失败: {e}") from e
except requests.RequestException as e: except requests.RequestException as e:
last_err = e last_err = e
if attempt == 0: if attempt == 0:
@@ -240,7 +240,7 @@ class HTTPClient:
return response return response
except requests.HTTPError as e: except requests.HTTPError as e:
# 第 91 条(P2-6):4xx 不重试(同上) # 第 91 条(P2-6):4xx 不重试(同上)
raise APIException(f"HTTP请求失败: {e}") raise APIException(f"HTTP请求失败: {e}") from e
except requests.RequestException as e: except requests.RequestException as e:
last_err = e last_err = e
if attempt == 0: if attempt == 0:
@@ -306,7 +306,7 @@ class NeteaseAPI:
return result return result
except (json.JSONDecodeError, KeyError) as e: 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]: async def get_song_detail(self, song_id: int) -> dict[str, Any]:
"""获取歌曲详细信息 """获取歌曲详细信息
@@ -340,9 +340,9 @@ class NeteaseAPI:
return result return result
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取歌曲详情请求失败: {e}") raise APIException(f"获取歌曲详情请求失败: {e}") from e
except json.JSONDecodeError as 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]: async def get_lyric(self, song_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌词信息 """获取歌词信息
@@ -386,9 +386,9 @@ class NeteaseAPI:
return result return result
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取歌词请求失败: {e}") raise APIException(f"获取歌词请求失败: {e}") from e
except json.JSONDecodeError as 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, 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]]: search_type: int = 1, offset: int = 0) -> list[dict[str, Any]]:
@@ -488,9 +488,9 @@ class NeteaseAPI:
return items return items
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"搜索请求失败: {e}") raise APIException(f"搜索请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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]: async def get_playlist_detail(self, playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌单详情 """获取歌单详情
@@ -583,9 +583,9 @@ class NeteaseAPI:
return info return info
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取歌单详情请求失败: {e}") raise APIException(f"获取歌单详情请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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]: async def get_album_detail(self, album_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取专辑详情 """获取专辑详情
@@ -639,9 +639,9 @@ class NeteaseAPI:
return info return info
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取专辑详情请求失败: {e}") raise APIException(f"获取专辑详情请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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]]: async def get_toplist(self) -> list[dict[str, Any]]:
"""获取网易云官方榜单列表 """获取网易云官方榜单列表
@@ -680,9 +680,9 @@ class NeteaseAPI:
}) })
return toplists return toplists
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取榜单请求失败: {e}") raise APIException(f"获取榜单请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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]: async def get_artist_hot_songs(self, artist_id: int) -> dict[str, Any]:
"""获取歌手信息和热门歌曲 """获取歌手信息和热门歌曲
@@ -734,9 +734,9 @@ class NeteaseAPI:
'songs': songs 'songs': songs
} }
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取歌手信息请求失败: {e}") raise APIException(f"获取歌手信息请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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]: 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 [])], 'comments': [_comment_dict(c) for c in (result.get('comments') or [])],
} }
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取评论请求失败: {e}") raise APIException(f"获取评论请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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: async def get_account_info(self, cookies: dict[str, str]) -> dict[str, Any] | None:
"""获取当前账号信息(用于个人登录状态展示) """获取当前账号信息(用于个人登录状态展示)
@@ -905,9 +905,9 @@ class NeteaseAPI:
offset += page_size offset += page_size
return playlists return playlists
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取用户歌单请求失败: {e}") raise APIException(f"获取用户歌单请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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], async def detect_vip_by_probe(self, cookies: dict[str, str],
probe_song_id: int = 347230) -> dict[str, Any]: probe_song_id: int = 347230) -> dict[str, Any]:
@@ -997,9 +997,9 @@ class NeteaseAPI:
raise APIException(f"收藏歌单失败: {result.get('message', '未知错误')}") raise APIException(f"收藏歌单失败: {result.get('message', '未知错误')}")
return result return result
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"收藏歌单请求失败: {e}") raise APIException(f"收藏歌单请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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], async def subscribe_album(self, album_id, cookies: dict[str, str],
action: int = 1) -> dict[str, Any]: action: int = 1) -> dict[str, Any]:
@@ -1031,9 +1031,9 @@ class NeteaseAPI:
raise APIException(f"收藏专辑失败: {result.get('message', '未知错误')}") raise APIException(f"收藏专辑失败: {result.get('message', '未知错误')}")
return result return result
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"收藏专辑请求失败: {e}") raise APIException(f"收藏专辑请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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, async def post_comment(self, thread_id: str, content: str, action: int = 1,
comment_id: int | None = None, comment_id: int | None = None,
@@ -1088,9 +1088,9 @@ class NeteaseAPI:
raise APIException(f"评论失败: {result.get('message', '未知错误')}") raise APIException(f"评论失败: {result.get('message', '未知错误')}")
return result return result
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"评论请求失败: {e}") raise APIException(f"评论请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as 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: def netease_encrypt_id(self, id_str: str) -> str:
"""网易云加密图片ID算法 """网易云加密图片ID算法
@@ -1170,7 +1170,7 @@ class QRLoginManager:
else: else:
raise APIException(f"生成二维码key失败: {result.get('message', '未知错误')}") raise APIException(f"生成二维码key失败: {result.get('message', '未知错误')}")
except (json.JSONDecodeError, KeyError) as e: 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: async def create_qr_login(self) -> str | None:
"""创建登录二维码并在控制台显示 """创建登录二维码并在控制台显示
@@ -1251,7 +1251,7 @@ class QRLoginManager:
return result.get('code', -1), cookie_dict return result.get('code', -1), cookie_dict
except (json.JSONDecodeError, KeyError) as e: except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析登录状态响应失败: {e}") raise APIException(f"解析登录状态响应失败: {e}") from e
async def qr_login(self) -> str | None: async def qr_login(self) -> str | None:
"""完整的二维码登录流程 """完整的二维码登录流程
+4 -9
View File
@@ -28,9 +28,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
import requests from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
from requests.adapters import HTTPAdapter
from core.async_bridge import run_sync from core.async_bridge import run_sync
from music_api import APIException, NeteaseAPI, load_cookies from music_api import APIException, NeteaseAPI, load_cookies
@@ -77,13 +75,10 @@ def _sniff_audio_header(path) -> bool:
def get_http_session() -> requests.Session: def get_http_session() -> requests.Session:
"""获取当前线程的共享 requests.Session(带连接池 + keep-alive)""" """获取当前线程的共享同步会话(连接池由底层 aiohttp 统一维护)。"""
session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None) session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None)
if session is None: if session is None:
session = requests.Session() 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.headers.update({"User-Agent": DEFAULT_UA})
_SESSION_LOCAL.session = session _SESSION_LOCAL.session = session
return session return session
@@ -555,9 +550,9 @@ class MusicDownloader:
) )
except APIException as e: except APIException as e:
raise DownloadException(f"API调用失败: {e}") raise DownloadException(f"API调用失败: {e}") from e
except Exception as e: except Exception as e:
raise DownloadException(f"获取音乐信息时发生错误: {e}") raise DownloadException(f"获取音乐信息时发生错误: {e}") from e
# ----------- 文件路径 ----------- # ----------- 文件路径 -----------
+8 -9
View File
@@ -21,8 +21,7 @@ import re
import time import time
from typing import Any from typing import Any
import requests from core import sync_http as requests # 同步门面(底层 aiohttp)
from music_api import APIConstants, APIException, url_v1 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): if not isinstance(result, dict):
raise APIException('播客接口响应异常(非标准 JSON 对象)') raise APIException('播客接口响应异常(非标准 JSON 对象)')
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"播客接口请求失败: {e}") raise APIException(f"播客接口请求失败: {e}") from e
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise APIException(f"解析播客接口响应失败: {e}") raise APIException(f"解析播客接口响应失败: {e}") from e
if result.get('code') not in (200, None): if result.get('code') not in (200, None):
raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}") raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}")
return result return result
@@ -300,7 +299,7 @@ def get_radio_detail(radio_id: Any, cookies: dict[str, str] | None = None) -> di
try: try:
rid = int(str(radio_id).strip()) rid = int(str(radio_id).strip())
except (TypeError, ValueError): except (TypeError, ValueError):
raise APIException('无效的电台ID') raise APIException('无效的电台ID') from None
result = _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies) result = _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies)
# 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空 # 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空
data = result.get('djRadio') or result.get('data') or {} 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: try:
rid = int(str(radio_id).strip()) rid = int(str(radio_id).strip())
except (TypeError, ValueError): except (TypeError, ValueError):
raise APIException('无效的电台ID') raise APIException('无效的电台ID') from None
limit = max(1, min(int(limit or 30), 100)) limit = max(1, min(int(limit or 30), 100))
result = _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API, result = _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API,
{'radioId': rid, 'limit': limit, 'offset': max(0, int(offset or 0)), {'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: try:
pid = int(str(program_id).strip()) pid = int(str(program_id).strip())
except (TypeError, ValueError): except (TypeError, ValueError):
raise APIException('无效的单集ID') raise APIException('无效的单集ID') from None
result = _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies) result = _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies)
program = result.get('program') or {} program = result.get('program') or {}
if not program: if not program:
@@ -372,9 +371,9 @@ def get_categories() -> list[dict[str, Any]]:
raise APIException('播客接口响应体异常(超过大小上限)') raise APIException('播客接口响应体异常(超过大小上限)')
result = response.json() result = response.json()
except requests.RequestException as e: except requests.RequestException as e:
raise APIException(f"获取播客分类失败: {e}") raise APIException(f"获取播客分类失败: {e}") from e
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise APIException(f"解析播客分类响应失败: {e}") raise APIException(f"解析播客分类响应失败: {e}") from e
if not isinstance(result, dict): if not isinstance(result, dict):
# 第 97 条(P3):非 dict 响应(null/数组)显式报错,不再 AttributeError # 第 97 条(P3):非 dict 响应(null/数组)显式报错,不再 AttributeError
raise APIException('解析播客分类响应失败(非标准 JSON 对象)') raise APIException('解析播客分类响应失败(非标准 JSON 对象)')
+33 -11
View File
@@ -4,6 +4,31 @@ name = "yunmusic-assistant"
version = "3.0.0" version = "3.0.0"
description = "云村小助手 · 网易云音乐解析/下载服务(FastAPI + asyncio + aiohttp)" description = "云村小助手 · 网易云音乐解析/下载服务(FastAPI + asyncio + aiohttp)"
requires-python = ">=3.12" 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:静态检查 + 导入排序 + 格式化 ============ # ============ Ruff:静态检查 + 导入排序 + 格式化 ============
[tool.ruff] [tool.ruff]
@@ -12,52 +37,49 @@ line-length = 120
extend-exclude = [".venv", ".zcode", "templates", "static", "data"] extend-exclude = [".venv", ".zcode", "templates", "static", "data"]
[tool.ruff.lint] [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"] select = ["E", "F", "W", "I", "B", "UP", "C4", "ASYNC", "S", "RUF"]
ignore = [ ignore = [
"E501", # 行宽交给 formatter,不强制 "E501", # 行宽交给 formatter,不强制
"E402", # main.py 存在「先定义后 import」的历史结构,迁移期暂放行
"S101", # 生产代码外的 assert(测试/断言脚本) "S101", # 生产代码外的 assert(测试/断言脚本)
"S104", # 绑定 0.0.0.0(服务端有意为之) "S104", # 绑定 0.0.0.0(服务端有意为之)
"S110", # try/except/pass(本项目大量「失败静默降级」策略,见交接文档) "S110", # try/except/pass(本项目「失败静默降级」策略)
"S112", # try/except/continue "S112", # try/except/continue
"S324", # hashlib.md5 用于非安全场景(上游签名口径) "S324", # hashlib.md5 用于非安全场景(上游签名口径)
"S603", "S607", # subprocess(打包/运维脚本) "S603", "S607", # subprocess(打包/运维脚本)
"UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定) "UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定)
"RUF001", "RUF002", "RUF003", # 全角字符(中文注释/文案,误报) "RUF001", "RUF002", "RUF003", # 全角字符(中文注释/文案,误报)
# ---- 以下为「有意保留」,随文件在迁移中重写时再行处理,非待修缺陷 ---- # ---- 以下为「有意保留」,已被逐条论证为协议/设计约束,非待修缺陷 ----
"B904", # raise-from 异常链:纯风格,40 处均在历史巨石文件,逐处改写收益低风险高
"RUF012", # 常量类可变默认值:APIConstants/PodcastConstants 是常量表,非实例状态 "RUF012", # 常量类可变默认值:APIConstants/PodcastConstants 是常量表,非实例状态
"S305", # AES-ECB:网易云 weapi 协议**要求**的加密模式,非可自由选择 "S305", # AES-ECB:网易云 weapi 协议**要求**的加密模式,非可自由选择
"S311", # randrange:用于生成上游 requestId(协议随机数),非安全用途 "S311", # randrange:生成上游 requestId(协议随机数),非安全用途
"ASYNC250", # input():qr_login 交互式 CLI 的人类输入,异步化无意义 "ASYNC250", # input():qr_login 交互式 CLI 的人类输入,异步化无意义
"S310", # urllib:仅用于音频 Range 代理的 URL 校验路径
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/**" = ["S", "ASYNC"] "tests/**" = ["S", "ASYNC"]
".zcode/**" = ["S", "ASYNC", "F"] ".zcode/**" = ["S", "ASYNC", "F"]
"_mkpkg.py" = ["S"]
# ASYNC109:timeout 参数是有意 API——直接转发给 aiohttp 的 ClientTimeout, # ASYNC109:timeout 参数是有意 API——直接转发给 aiohttp 的 ClientTimeout,
# 不是「用 sleep 当超时」的反模式。 # 不是「用 sleep 当超时」的反模式。
"core/http_client.py" = ["ASYNC109"] "core/http_client.py" = ["ASYNC109"]
"core/aiohttp_compat.py" = ["ASYNC109"] "core/aiohttp_compat.py" = ["ASYNC109"]
[tool.ruff.lint.isort] [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] [tool.ruff.format]
quote-style = "preserve" quote-style = "preserve"
# ============ Pyright:类型检查 ============ # ============ Pyright:类型检查 ============
[tool.pyright] [tool.pyright]
include = ["core", "web"] # 全量纳入(Flask 巨石删除后不再有例外清单)
include = ["core", "web", "services"]
exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"] exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"]
venvPath = "." venvPath = "."
venv = ".venv" venv = ".venv"
pythonVersion = "3.12" pythonVersion = "3.12"
typeCheckingMode = "basic" typeCheckingMode = "basic"
# 迁移期口径:存量 Flask 巨石(main.py/auth_*.py)暂不纳入严格检查,
# 新写的 core/ 与 web/ 要求类型干净。随迁移推进逐步把模块移入 include。
reportMissingImports = true reportMissingImports = true
reportOptionalMemberAccess = "warning" reportOptionalMemberAccess = "warning"
reportAttributeAccessIssue = "warning" reportAttributeAccessIssue = "warning"
-6
View File
@@ -1,6 +0,0 @@
# 开发依赖(开发/CI 专用,不进生产镜像)
pytest>=8.0
pytest-asyncio>=0.24
ruff>=0.6
pyright>=1.1.390
httpx>=0.27
-14
View File
@@ -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
-13
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""服务层:从原 Flask 巨石中剥离的框架无关实现。
包含 MusicAPIService、下载/打包 worker、任务登记与监控、下载缓存清扫等。
不含任何 Flask 依赖(路由层已全部迁至 web/routers)。
"""
+1957
View File
File diff suppressed because it is too large. Load diff
+540
View File
@@ -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
View File
@@ -42,7 +42,7 @@ def app_env(tmp_path, monkeypatch):
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
# 清空登录失败锁定/2FA 的模块级内存态,避免测试间串扰 # 清空登录失败锁定/2FA 的模块级内存态,避免测试间串扰
import auth_routes import web.auth_state as auth_routes
auth_routes._LOGIN_FAILS.clear() auth_routes._LOGIN_FAILS.clear()
auth_routes._LOGIN_FAIL_LOGGED.clear() auth_routes._LOGIN_FAIL_LOGGED.clear()
auth_routes._ADMIN_2FA.clear() auth_routes._ADMIN_2FA.clear()
@@ -52,14 +52,14 @@ def app_env(tmp_path, monkeypatch):
def test_wrong_password(app_env): 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.status_code == 400
assert r.json()['success'] is False assert r.json()['success'] is False
assert 'session' not in r.cookies assert 'session' not in r.cookies
def test_user_login_issues_working_cookie(app_env): 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 assert r.status_code == 200
body = r.json() body = r.json()
assert body['success'] is True assert body['success'] is True
@@ -67,13 +67,13 @@ def test_user_login_issues_working_cookie(app_env):
assert 'session' in r.cookies assert 'session' in r.cookies
# 用签发的 Cookie 访问受保护端点,应放行 # 用签发的 Cookie 访问受保护端点,应放行
r2 = app_env.post('/api/v1/auth/heartbeat', json={}) r2 = app_env.post('/auth/heartbeat', json={})
assert r2.status_code == 200 assert r2.status_code == 200
assert r2.json()['data']['duration_text'] == '97 次' assert r2.json()['data']['duration_text'] == '97 次'
def test_admin_login_returns_2fa_ticket(app_env): 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 assert r.status_code == 200
data = r.json()['data'] data = r.json()['data']
assert data['admin_2fa'] is True 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): 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() conn = auth.get_conn()
seq_before = conn.execute( seq_before = conn.execute(
"SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0] "SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0]
conn.close() conn.close()
r = app_env.post('/api/v1/auth/logout', json={}) r = app_env.post('/auth/logout', json={})
assert r.status_code == 200 assert r.status_code == 200
conn = auth.get_conn() conn = auth.get_conn()
seq_after = conn.execute( seq_after = conn.execute(
@@ -95,7 +95,7 @@ def test_logout_bumps_login_seq_and_clears(app_env):
conn.close() conn.close()
assert seq_after == seq_before + 1 assert seq_after == seq_before + 1
# 旧 Cookie 失效 # 旧 Cookie 失效
r2 = app_env.post('/api/v1/auth/heartbeat', json={}) r2 = app_env.post('/auth/heartbeat', json={})
assert r2.status_code == 401 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.execute("UPDATE users SET duration_seconds=0 WHERE username='normaluser'")
conn.commit() conn.commit()
conn.close() 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 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.status_code == 401
assert r2.json().get('error_code') == 'expired' assert r2.json().get('error_code') == 'expired'
+8 -8
View File
@@ -48,7 +48,7 @@ def _login(env, username):
base64 padding('=') 的值加双引号,导致服务端签名解析失败(真实浏览器不会), base64 padding('=') 的值加双引号,导致服务端签名解析失败(真实浏览器不会),
故测试避开 jar。 故测试避开 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 assert r.status_code == 200, r.text
raw = r.headers.get('set-cookie', '') raw = r.headers.get('set-cookie', '')
cookie = '' cookie = ''
@@ -73,7 +73,7 @@ def _open_ledger_rows():
def test_download_requires_login(env): 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 assert r.status_code == 401
@@ -81,7 +81,7 @@ def test_download_bad_id_refunds(env):
ck, _ = _login(env, 'payer') ck, _ = _login(env, 'payer')
H = {'Cookie': f'session={ck}'} H = {'Cookie': f'session={ck}'}
before = _balance('payer') 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 r.status_code == 400
assert '无效' in r.json()['message'] assert '无效' in r.json()['message']
# 预扣已退回,且无 open 账本行残留 # 预扣已退回,且无 open 账本行残留
@@ -93,7 +93,7 @@ def test_download_bad_quality_refunds(env):
ck, _ = _login(env, 'payer') ck, _ = _login(env, 'payer')
H = {'Cookie': f'session={ck}'} H = {'Cookie': f'session={ck}'}
before = _balance('payer') 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 r.status_code == 400
assert _balance('payer') == before assert _balance('payer') == before
assert _open_ledger_rows() == [] assert _open_ledger_rows() == []
@@ -103,7 +103,7 @@ def test_download_bad_format_refunds(env):
ck, _ = _login(env, 'payer') ck, _ = _login(env, 'payer')
H = {'Cookie': f'session={ck}'} H = {'Cookie': f'session={ck}'}
before = _balance('payer') 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 r.status_code == 400
assert _balance('payer') == before assert _balance('payer') == before
@@ -111,7 +111,7 @@ def test_download_bad_format_refunds(env):
def test_download_insufficient_credits(env): def test_download_insufficient_credits(env):
ck, _ = _login(env, 'broke') ck, _ = _login(env, 'broke')
H = {'Cookie': f'session={ck}'} 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.status_code == 401
assert r.json().get('error_code') == 'expired' assert r.json().get('error_code') == 'expired'
# 未开账 # 未开账
@@ -124,7 +124,7 @@ def test_batch_zip_too_many_refunds(env):
H = {'Cookie': f'session={ck}'} H = {'Cookie': f'session={ck}'}
before = _balance('rich') before = _balance('rich')
ids = ','.join(str(1000 + i) for i in range(501)) 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 r.status_code == 400
assert '500' in r.json()['message'] assert '500' in r.json()['message']
# 预扣(封顶 500)已全额退回,余额回到 600 # 预扣(封顶 500)已全额退回,余额回到 600
@@ -136,6 +136,6 @@ def test_batch_zip_missing_ids_refunds(env):
ck, _ = _login(env, 'payer') ck, _ = _login(env, 'payer')
H = {'Cookie': f'session={ck}'} H = {'Cookie': f'session={ck}'}
before = _balance('payer') 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 r.status_code == 400
assert _balance('payer') == before assert _balance('payer') == before
+109
View File
@@ -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
View File
@@ -30,7 +30,7 @@ def env(tmp_path, monkeypatch):
monkeypatch.setattr(db, 'DB_PATH', db_path) monkeypatch.setattr(db, 'DB_PATH', db_path)
auth.init_db() auth.init_db()
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
import auth_routes import web.auth_state as auth_routes
auth_routes._SEND_CODE_IP.clear() auth_routes._SEND_CODE_IP.clear()
from web.app import create_app from web.app import create_app
return TestClient(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): 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', 'username': 'newuser', 'password': 'Passw0rd12', 'email': 'n@example.com',
'code': '000000', 'agree': '0'}) 'code': '000000', 'agree': '0'})
assert r.json()['message'].startswith('请先阅读并同意') assert r.json()['message'].startswith('请先阅读并同意')
@@ -55,7 +55,7 @@ def test_register_requires_agreement(env):
def test_register_success(env): def test_register_success(env):
_seed_code('new@example.com', '123456') _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', 'username': 'newuser', 'password': 'Passw0rd12', 'email': 'new@example.com',
'code': '123456', 'agree': '1', 'device_id': 'dev-1'}) 'code': '123456', 'agree': '1', 'device_id': 'dev-1'})
assert r.status_code == 200 assert r.status_code == 200
@@ -65,18 +65,18 @@ def test_register_success(env):
assert 'session' in r.cookies 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') _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', 'username': 'newuser', 'password': 'Passw0rd12', 'email': 'new@example.com',
'code': '222222', 'agree': '1'}) 'code': '222222', 'agree': '1'})
assert '已被注册' in r2.json()['message'] assert '已被注册' in r2.json()['message']
def test_register_bad_code(env): 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', 'username': 'another', 'password': 'Passw0rd12', 'email': 'a@example.com',
'code': '999999', 'agree': '1'}) 'code': '999999', 'agree': '1'})
assert r.json()['message'] == '邮箱验证码错误或已过期' 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) auth.create_user('forgetme', 'OldPass123', email='f@example.com', credits=5)
_seed_code('f@example.com', '654321', purpose='reset') _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'}) 'email': 'f@example.com', 'code': '654321', 'password': 'NewPass456'})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()['success'] is True 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 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.commit()
conn.close() conn.close()
_seed_code('boss@example.com', '111111', purpose='reset') _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'}) 'email': 'boss@example.com', 'code': '111111', 'password': 'NewPass456'})
# 管理员不走自助找回:统一防枚举文案 # 管理员不走自助找回:统一防枚举文案
assert r.json()['message'] == '邮箱验证码错误或已过期' assert r.json()['message'] == '邮箱验证码错误或已过期'
@@ -111,8 +111,8 @@ def test_redeem_card(env):
# 造一张卡密并让用户登录 # 造一张卡密并让用户登录
auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0) auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0)
code = auth.generate_cards(1, 10, note='test')[0] code = auth.generate_cards(1, 10, note='test')[0]
env.post('/api/v1/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'}) env.post('/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'})
r = env.post('/api/v1/auth/redeem', json={'code': code}) r = env.post('/auth/redeem', json={'code': code})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()['success'] is True assert r.json()['success'] is True
assert r.json()['data']['credits'] == 10 assert r.json()['data']['credits'] == 10
+1 -1
View File
@@ -62,6 +62,6 @@ def test_root_page_served(root_client):
def test_no_api_v1_prefix_in_root_mode(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={}) r = root_client.post('/api/v1/song', json={})
assert r.status_code == 404 assert r.status_code == 404
+33 -25
View File
@@ -1,54 +1,57 @@
# -*- coding: utf-8 -*- # -*- 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 time
import pytest import pytest
from flask import Flask from itsdangerous import URLSafeTimedSerializer
from flask.sessions import SecureCookieSessionInterface
from core import session as cs from core import session as cs
SECRET = 'unit-test-secret-key' SECRET = 'unit-test-secret-key'
@pytest.fixture() def _flask_serializer():
def flask_serializer(): """等价于 Flask 的会话序列化器(同盐、同摘要算法、同 key 派生)。"""
app = Flask(__name__) return URLSafeTimedSerializer(
app.secret_key = SECRET SECRET, salt='cookie-session',
return SecureCookieSessionInterface().get_signing_serializer(app) signer_kwargs={'key_derivation': 'hmac', 'digest_method': hashlib.sha1})
def _flask_cookie(ser, data): def test_itsdangerous_to_core():
return ser.dumps(data) """itsdangerous(Flask)签发的会话 Cookie 能被 core.session 解出。"""
cookie = _flask_serializer().dumps({'site_uid': 26, 'site_uid_seq': 5})
def test_flask_to_core(flask_serializer):
"""Flask 签发的会话 Cookie 能被 core.session 解出。"""
cookie = _flask_cookie(flask_serializer, {'site_uid': 26, 'site_uid_seq': 5})
data = cs.loads(SECRET, cookie) data = cs.loads(SECRET, cookie)
assert data == {'site_uid': 26, 'site_uid_seq': 5} assert data == {'site_uid': 26, 'site_uid_seq': 5}
assert cs.get_uid(data) == 26 assert cs.get_uid(data) == 26
def test_core_to_flask(flask_serializer): def test_core_to_itsdangerous():
"""core.session 签发(含时间戳)的 Cookie 能被 Flask 解出。""" """core.session 签发的 Cookie 能被 itsdangerous 解出。"""
cookie = cs.dumps(SECRET, {'site_uid': 74, 'site_uid_seq': 8}) 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): def test_core_timestamp_valid_for_timed_check():
"""时间戳格式正确:Flask 能按 max_age 校验(未过期可解)。""" """时间戳编码与 itsdangerous 一致:max_age 校验可用(未过期)。"""
cookie = cs.dumps(SECRET, {'site_uid': 1}) 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): def test_tampered_returns_none():
cookie = _flask_cookie(flask_serializer, {'site_uid': 26}) cookie = _flask_serializer().dumps({'site_uid': 26})
assert cs.loads(SECRET, cookie[:-3] + 'xyz') is None assert cs.loads(SECRET, cookie[:-3] + 'xyz') is None
def test_wrong_key_returns_none(flask_serializer): def test_wrong_key_returns_none():
cookie = _flask_cookie(flask_serializer, {'site_uid': 26}) cookie = _flask_serializer().dumps({'site_uid': 26})
assert cs.loads('wrong-secret', cookie) is None assert cs.loads('wrong-secret', cookie) is None
@@ -65,3 +68,8 @@ def test_roundtrip_selfconsistency():
def test_get_uid_none(): def test_get_uid_none():
assert cs.get_uid(None) is None assert cs.get_uid(None) is None
assert cs.get_uid({}) 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
Generated
+1574
View File
File diff suppressed because it is too large. Load diff
+6 -24
View File
@@ -4,13 +4,8 @@
设计(strangler 迁移): 设计(strangler 迁移):
- 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存, - 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存,
便于逐端点比对响应契约(.zcode/refactor/parity.py)。 便于逐端点比对响应契约(.zcode/refactor/parity.py)。
- `serve_flask_fallback=True` 时,把未迁移的遗留 Flask 应用整体挂到根路径兜底, 迁移已完成:Flask 巨石(main.py/auth_routes.py)已删除,本应用即唯一服务端。
实现「新端点走 FastAPI、其余走 Flask」的单进程双框架运行;迁移完成后删掉该开关。 `api_prefix` 为空即直接服务前端使用的根路径;非空则挂在给定前缀下(用于契约比对)。
注意(默认关闭的原因):Flask fallback 会在 FastAPI 进程内 import main,
进而初始化 MusicAPIService / 打开 SQLite —— 与生产既有的 waitress 进程是
两套独立进程语义。生产切换前需按计划把状态与任务模型统一(见迁移计划阶段 C),
当前仅用于开发期联调。
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -64,13 +59,12 @@ def _init_audio_secret() -> None:
pass pass
def create_app(serve_flask_fallback: bool = False, api_prefix: str = '/api/v1') -> FastAPI: def create_app(api_prefix: str = '') -> FastAPI:
"""创建 FastAPI 应用。 """创建 FastAPI 应用。
api_prefix:已迁移路由的挂载前缀。 api_prefix:路由挂载前缀。
- '/api/v1'(默认):与 Flask 根路径并存,便于逐端点契约比对(迁移期) - ''(默认):直接服务前端使用的根路径(生产形态)
- ''(切换完成):FastAPI 直接服务前端使用的根路径,不再需要 Flask 兜底 - '/api/v1':挂在子前缀下(契约比对脚本 .zcode/refactor/parity.py 用)
serve_flask_fallback:把 Flask 应用挂到根路径兜底(仅 api_prefix 非空时可用)。
""" """
_init_audio_secret() _init_audio_secret()
@@ -112,16 +106,4 @@ def create_app(serve_flask_fallback: bool = False, api_prefix: str = '/api/v1')
install_error_handlers(app) install_error_handlers(app)
if serve_flask_fallback and api_prefix:
_mount_flask_fallback(app)
return 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))
+172
View File
@@ -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
View File
@@ -22,15 +22,11 @@ from web.app import create_app
HOST = os.environ.get('HOST', '0.0.0.0') HOST = os.environ.get('HOST', '0.0.0.0')
PORT = int(os.environ.get('PORT', '5000')) PORT = int(os.environ.get('PORT', '5000'))
# 运行模式(默认保持迁移期形态,切换为显式操作): # Flask 巨石已删除:本应用即唯一服务端,直接服务前端使用的根路径。
# WEB_MODE=dual(默认)已迁移路由挂 /api/v1 + Flask 兜底其余路由(前端仍走根路径→Flask) # API_PREFIX 非空时挂在子前缀下(契约比对脚本使用)。
# WEB_MODE=fastapi 已迁移路由直接挂根路径,完全由 FastAPI 服务(不再需要 Flask) API_PREFIX = os.environ.get('API_PREFIX', '').strip()
WEB_MODE = os.environ.get('WEB_MODE', 'dual').strip().lower()
if WEB_MODE == 'fastapi': app = create_app(api_prefix=API_PREFIX)
app = create_app(serve_flask_fallback=False, api_prefix='')
else:
app = create_app(serve_flask_fallback=True, api_prefix='/api/v1')
if __name__ == '__main__': if __name__ == '__main__':
uvicorn.run( uvicorn.run(
+10 -6
View File
@@ -19,18 +19,22 @@ _EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'}
def client_ip(request: Request) -> str: def client_ip(request: Request) -> str:
"""客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP,与 Flask 同策略。""" """客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP。
import auth_system
规则与 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 '-' 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') xff = request.headers.get('x-forwarded-for')
if xff: if xff:
ip = auth_system._trusted_proxy_ip(xff) ip = trusted_proxy_ip(xff)
if ip and not auth_system._is_loopback_ip(ip): if ip and not is_loopback_ip(ip):
return ip return ip
xreal = request.headers.get('x-real-ip') xreal = request.headers.get('x-real-ip')
if xreal: if xreal:
ip = auth_system._trusted_proxy_ip(xreal) ip = trusted_proxy_ip(xreal)
if ip: if ip:
return ip return ip
return remote or '-' return remote or '-'
+4 -4
View File
@@ -8,7 +8,7 @@
- POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据) - POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据)
设计: 设计:
- 登录失败锁定与管理员 2FA 票据表**复用 auth_routes 的模块级状态**,两栈共享同一份 - 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,两栈共享同一份
计数/票据,避免「换个框架打就绕过锁定」。 计数/票据,避免「换个框架打就绕过锁定」。
- 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行, - 密码校验(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') @router.post('/auth/login')
async def login(request: Request): async def login(request: Request):
"""登录:密码校验(含邮箱登录)、失败锁定、管理员两步验证转票据。""" """登录:密码校验(含邮箱登录)、失败锁定、管理员两步验证转票据。"""
from auth_routes import ( from web.auth_state import (
_admin2fa_issue, _admin2fa_issue,
_admin2fa_mask, _admin2fa_mask,
_login_fail_check, _login_fail_check,
@@ -201,7 +201,7 @@ async def admin_login_email(request: Request):
"""管理员 2FA Step2:比对绑定邮箱 → 发送 admin2fa 验证码。""" """管理员 2FA Step2:比对绑定邮箱 → 发送 admin2fa 验证码。"""
import hmac as _hmac import hmac as _hmac
from auth_routes import ( from web.auth_state import (
_ADMIN2FA_LAST_SEND, _ADMIN2FA_LAST_SEND,
_ADMIN2FA_SEND_GAP, _ADMIN2FA_SEND_GAP,
_ADMIN_2FA, _ADMIN_2FA,
@@ -245,7 +245,7 @@ async def admin_login_email(request: Request):
@router.post('/auth/admin_login/verify') @router.post('/auth/admin_login/verify')
async def admin_login_verify(request: Request): async def admin_login_verify(request: Request):
"""管理员 2FA Step3:验证码原子消费 → 建立管理员会话。""" """管理员 2FA Step3:验证码原子消费 → 建立管理员会话。"""
from auth_routes import ( from web.auth_state import (
_ADMIN_2FA, _ADMIN_2FA,
_ADMIN_2FA_MAX_ATTEMPTS, _ADMIN_2FA_MAX_ATTEMPTS,
_admin2fa_take, _admin2fa_take,
+14 -14
View File
@@ -115,8 +115,8 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
lyric_format = 'lrc' lyric_format = 'lrc'
need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes')
import main as legacy import services.runtime as services
downloader = legacy.api_service.downloader downloader = services.api_service.downloader
if return_format == 'json': if return_format == 'json':
try: try:
@@ -127,7 +127,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
await billing.refund_open('rejected') await billing.refund_open('rejected')
return error('获取失败:歌曲不存在或暂无版权', 404) return error('获取失败:歌曲不存在或暂无版权', 404)
base = await asyncio.to_thread( base = await asyncio.to_thread(
legacy._build_download_base, filename_format, services._build_download_base, filename_format,
{'name': info.name, 'artist_string': info.artists}) {'name': info.name, 'artist_string': info.artists})
filename = f'{base}.{info.file_type}' filename = f'{base}.{info.file_type}'
try: try:
@@ -165,7 +165,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
# file 模式:注册后台任务 # file 模式:注册后台任务
device_id = str(data.get('device_id') or '').strip()[:64] 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, task = _single_task(data, music_id, quality, filename_format, write_meta,
meta_fields, lyric_format, need_zip, cookies, ip, device_id) 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()) 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') await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] 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: if start_err:
return error(start_err, 500) return error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '下载任务已提交') 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() cookies = _cookies()
device_id = str(data.get('device_id') or '').strip()[:64] device_id = str(data.get('device_id') or '').strip()[:64]
import main as legacy import services.runtime as services
await asyncio.to_thread(legacy._touch_device, device_id) await asyncio.to_thread(services._touch_device, device_id)
task = { task = {
'type': 'batch', 'type': 'batch',
'total': len(ids), 'done': 0, 'failed': 0, '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') await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] 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: if start_err:
return error(start_err, 500) return error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued', 'total': len(ids)}, 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() cookies = _cookies()
device_id = str(data.get('device_id') or '').strip()[:64] device_id = str(data.get('device_id') or '').strip()[:64]
import main as legacy import services.runtime as services
await asyncio.to_thread(legacy._touch_device, device_id) await asyncio.to_thread(services._touch_device, device_id)
task = { task = {
'type': 'single', 'type': 'single',
'total': 1, 'done': 0, 'failed': 0, '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') await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] 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: if start_err:
return error(start_err, 500) return error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '播客单集下载任务已提交') 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) billed = _podcast_billing_count(data)
device_id = str(data.get('device_id') or '').strip()[:64] device_id = str(data.get('device_id') or '').strip()[:64]
import main as legacy import services.runtime as services
await asyncio.to_thread(legacy._touch_device, device_id) await asyncio.to_thread(services._touch_device, device_id)
task = { task = {
'type': 'batch', 'type': 'batch',
'total': 0, 'done': 0, 'failed': 0, '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') await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] 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: if start_err:
return error(start_err, 500) return error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '播客打包任务已提交') return success({'task_id': task_id, 'status': 'queued'}, '播客打包任务已提交')
+3 -3
View File
@@ -11,7 +11,7 @@
说明: 说明:
- 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行, - 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行,
不阻塞事件循环;行为与 Flask 完全一致。 不阻塞事件循环;行为与 Flask 完全一致。
- 发码 IP 限流复用 auth_routes 的模块级状态 `_SEND_CODE_IP`(跨栈共享计数)。 - 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。
- 失败锁定/验证码消费等安全口径逐字保留。 - 失败锁定/验证码消费等安全口径逐字保留。
""" """
import asyncio import asyncio
@@ -64,7 +64,7 @@ async def _safe_register_credits() -> int:
@router.post('/auth/send_code') @router.post('/auth/send_code')
async def send_code(request: Request): 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) ip = ratelimit.client_ip(request)
if not _send_code_ip_check(ip): if not _send_code_ip_check(ip):
return error('验证码请求过于频繁,请稍后再试') return error('验证码请求过于频繁,请稍后再试')
@@ -141,7 +141,7 @@ async def forgot_password(request: Request):
@router.post('/auth/register') @router.post('/auth/register')
async def register(request: Request): 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) data = await _request_data(request)
username = str(data.get('username') or '').strip() username = str(data.get('username') or '').strip()
+4 -4
View File
@@ -58,14 +58,14 @@ def _owned_by(task: dict[str, Any], user: SiteUser) -> bool:
def _semaphores(): def _semaphores():
"""批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。""" """批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。"""
import main as legacy import services.runtime as services
return legacy.BATCH_ZIP_SEM, legacy.api_service.downloader._download_semaphore return services.BATCH_ZIP_SEM, services.api_service.downloader._download_semaphore
@router.api_route('/api/topen', methods=['GET', 'POST']) @router.api_route('/api/topen', methods=['GET', 'POST'])
async def topen(request: Request): async def topen(request: Request):
"""接口请求统计(实时)——锁内快照,避免 total 与明细不一致。""" """接口请求统计(实时)——锁内快照,避免 total 与明细不一致。"""
import main as legacy import services.runtime as services
with STATS_LOCK: with STATS_LOCK:
snapshot = {p: dict(v) for p, v in REQUEST_STATS.items()} snapshot = {p: dict(v) for p, v in REQUEST_STATS.items()}
endpoints = [] endpoints = []
@@ -73,7 +73,7 @@ async def topen(request: Request):
for path, info in snapshot.items(): for path, info in snapshot.items():
endpoints.append({ endpoints.append({
'path': path, 'path': path,
'name': legacy.ENDPOINT_NAMES.get(path, path), 'name': services.ENDPOINT_NAMES.get(path, path),
'count': info['count'], 'count': info['count'],
'percent': round(info['count'] / total * 100, 1) if total else 0, 'percent': round(info['count'] / total * 100, 1) if total else 0,
'last': info['last'], 'last': info['last'],
+3 -3
View File
@@ -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: 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 asyncio
import main as legacy import services.runtime as services
return await asyncio.to_thread(legacy._start_task_thread, task_id, worker, args) return await asyncio.to_thread(services._start_task_thread, task_id, worker, args)
+1 -1
View File
@@ -1,6 +1,6 @@
# 云村小助手 · 功能介绍 # 云村小助手 · 功能介绍
> 一个基于 Flask + 原生前端的云村小助手。 > 一个基于 FastAPI + 原生前端的云村小助手。
> 支持搜索、单曲/歌单/专辑/歌手解析、官方榜单、个人歌单、多账号体系、批量下载、ZIP 打包、 > 支持搜索、单曲/歌单/专辑/歌手解析、官方榜单、个人歌单、多账号体系、批量下载、ZIP 打包、
> 歌词逐字高亮、评论区、接口统计、隐私协议 Cookie 管理等完整功能。 > 歌词逐字高亮、评论区、接口统计、隐私协议 Cookie 管理等完整功能。
+27 -6
View File
@@ -22,18 +22,39 @@
## 二、文件说明 ## 二、文件说明
``` ```
Netease_url/ NeteaseUrl/
├── main.py # Flask 主程序(路由、计费、音频代理防护) ├── 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_system.py # 站点账号系统(SQLite:用户/卡密/公告/日志/封禁/顶号)
├── auth_routes.py # 认证与管理后台路由 ├── music_api.py # 网易云音乐 API 封装(全异步)
├── music_api.py # 网易云音乐 API 封装
├── music_downloader.py # 下载器(ffmpeg 元数据 / ZIP 打包) ├── music_downloader.py # 下载器(ffmpeg 元数据 / ZIP 打包)
├── podcast_api.py # 播客解析 API ├── podcast_api.py # 播客解析 API
├── qr_login.py # 网易云扫码登录 ├── qr_login.py # 网易云扫码登录
├── templates/index.html # 前端页面(原生 HTML/CSS/JS) ├── templates/index.html # 前端页面(原生 HTML/CSS/JS)
├── static/site_auth.js # 前端认证 / 个人中心 / 管理后台交互 ├── static/site_auth.js # 前端认证 / 个人中心 / 管理后台交互
├── requirements.txt # Python 依赖(Python 3.12) ├── pyproject.toml # 依赖与工具配置(uv 管理)
├── Dockerfile # 镜像构建文件 ├── uv.lock # 精确锁定依赖版本
├── tests/ # 测试(含 golden 契约回归)
├── Dockerfile # 镜像构建文件(uv sync)
├── docker-compose.yml # 编排配置(端口 / 持久卷) ├── docker-compose.yml # 编排配置(端口 / 持久卷)
├── entrypoint.sh # 容器启动脚本 ├── entrypoint.sh # 容器启动脚本
├── cookie.txt # 全局网易云账号 Cookie(部署后扫码生成,勿提交) ├── cookie.txt # 全局网易云账号 Cookie(部署后扫码生成,勿提交)