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

+2 -250
View File
@@ -27,12 +27,9 @@ import time
from email.header import Header
from email.mime.text import MIMEText
from email.utils import formataddr
from functools import wraps
from pathlib import Path
from typing import Any
from flask import g, request, session
# ============ 路径与常量 ============
DATA_DIR = Path('data')
DB_PATH = DATA_DIR / 'site.db'
@@ -465,18 +462,6 @@ def _is_private_ip(ip: str) -> bool:
return False
def _is_loopback_ip(ip: str) -> bool:
"""可信反代判定(第 91 条 P2-4:从"所有内网源"收窄为仅回环地址)。
服务绑 0.0.0.0 时内网任意主机都可直连伪造 XFF/X-Real-IP 轮换绕过限流与
登录锁定(实测实锤);同机 Caddy 反代(127.0.0.1 → Flask)不受影响。"""
try:
import ipaddress
addr = ipaddress.ip_address(ip)
return addr.is_loopback
except ValueError:
return False
def query_ip_province(ip: str) -> str:
"""查询 IP 归属省份(内网/失败返回空串),带进程内缓存
F47:改用官方路径 /json/{query} 与 zh-CN 语言参数"""
@@ -489,7 +474,7 @@ def query_ip_province(ip: str) -> str:
try:
import urllib.parse as _ul
import requests as _rq
from core import sync_http as _rq
r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip),
params={'lang': 'zh-CN', 'fields': 'status,regionName'},
timeout=5)
@@ -598,142 +583,6 @@ def _last_province(r: sqlite3.Row) -> str:
return ''
# ============ 会话 ============
def login_session(user: sqlite3.Row) -> None:
"""登录会话:数据库侧原子递增 login_seq 实现顶号(旧会话自动失效)(F04)"""
session.permanent = True
with _DB_LOCK:
conn = get_conn()
try:
conn.execute('UPDATE users SET last_active = ?, last_login = ?, '
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(int(time.time()), int(time.time()), user['id']))
row = conn.execute('SELECT login_seq FROM users WHERE id = ?', (user['id'],)).fetchone()
conn.commit()
seq = int(row['login_seq']) if row else 1
finally:
conn.close()
session[SESSION_KEY] = user['id']
session[SESSION_KEY + '_seq'] = seq
# P3-23:记录本次签发时间,供滚动续期判断(活跃用户不再登录满 7 天被硬登出)
session[SESSION_KEY + '_ts'] = int(time.time())
def _clear_session_only() -> None:
"""仅清除本浏览器会话字段(不动数据库 login_seq)——
用于顶号/失效检测路径:清掉旧会话即可,绝不能递增 seq,
否则会把"刚刚在别处合法登录"的新会话一并踢下线"""
session.pop(SESSION_KEY, None)
session.pop(SESSION_KEY + '_seq', None)
def logout_session() -> None:
"""退出登录(F03:递增 login_seq,使本账号全部已签发会话 Cookie 立即失效)"""
uid = session.get(SESSION_KEY)
_clear_session_only()
if uid:
try:
_x('UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (int(uid),))
except Exception:
pass
def current_user() -> sqlite3.Row | None:
"""当前登录用户(每请求只查一次库:结果缓存到 flask.g。
一次下载流程会调用本函数 5-6 次——原实现每次都新建 SQLite 连接,公网下是纯浪费。
缓存含 None:未登录同样只查一次。请求内登录态不会变化(登录/注册路由不走本函数)。)"""
cached = getattr(g, '_site_current_user', None)
if cached is not None:
return cached[0]
user = _load_current_user()
g._site_current_user = (user,)
return user
def _load_current_user() -> sqlite3.Row | None:
uid = session.get(SESSION_KEY)
if not uid:
return None
user = get_user_by_id(int(uid))
if not user:
return None
status = user['status'] if 'status' in user.keys() else 1
if status == 0 or status == 2:
# 被封禁账号的残留会话:清除并记录一次(只清本会话,不动 seq)
if status == 2:
_clear_session_only()
add_log('fail', user['username'], _client_ip(), '封禁账号访问',
user['ban_reason'] if 'ban_reason' in user.keys() else '')
return None
# 顶号校验(F04):数据库已有版本号时,会话版本必须严格一致;
# 缺失/为 0 的会话版本不再放行(防绕过)。检测路径只清本会话,不动 seq。
db_seq = int(user['login_seq'] if 'login_seq' in user.keys() and user['login_seq'] else 0)
sess_seq = int(session.get(SESSION_KEY + '_seq') or 0)
if db_seq and sess_seq != db_seq:
_clear_session_only()
add_log('fail', user['username'], _client_ip(), '顶号下线',
'账号已在其他设备登录或会话已失效,本机会话已下线')
return None
# P3-23:滚动续期——会话签发超过一半寿命(3.5 天)时刷新签发时间并 touch 会话,
# 活跃用户的 Cookie 有效期自动顺延;只有这半天窗口内的请求会带一次 Set-Cookie
try:
issued = int(session.get(SESSION_KEY + '_ts') or 0)
if issued and time.time() - issued > 3.5 * 86400:
session[SESSION_KEY + '_ts'] = int(time.time())
session.permanent = True # 触发 cookie 重发(刷新 7 天窗口)
except Exception:
pass
return user
def _trusted_proxy_ip(header_value: str) -> str:
"""第 97 条(P2-1):从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。
最右段是可信反代(Caddy)追加的直连方地址;最左段与 X-Real-IP 均可被客户端
伪造(Caddy 默认不改写客户端自带的 X-Real-IP),采信它们等于给限流/锁定/省份
记录开了伪造口。无有效值返回空串,调用方回落 remote_addr。"""
if not header_value:
return ''
import ipaddress as _ipa
for part in reversed(header_value.split(',')):
part = part.strip()
if not part:
continue
try:
return str(_ipa.ip_address(part))
except ValueError:
continue
return ''
def _client_ip_from_headers(remote: str) -> str:
"""第 97 条:可信反代场景的客户端 IP 解析(_client_ip 与 main._rate_client_ip 共用)。
XFF 最右段优先(唯一由可信反代追加的值,客户端伪造的最左段无效);最右段为
回环(PassNAT 纯 TCP 隧道下 Caddy 只能看到隧道本机端)时回落采信 X-Real-IP——
该场景要求隧道开启真实 IP 透传(覆写 X-Real-IP,部署闸 §5.7 B2 必须实测);
客户端伪造面已由"Caddy 恒追加 XFF + 仅回环信任"收口。全部无效回落 remote。"""
fwd = _trusted_proxy_ip(request.headers.get('X-Forwarded-For') or '')
if fwd and not _is_loopback_ip(fwd):
return fwd
xrip = _trusted_proxy_ip(request.headers.get('X-Real-IP') or '')
if xrip:
return xrip
return remote
def _client_ip() -> str:
"""客户端 IP(F08:仅当直连方是可信反代时才信任代理头,防伪造绕过限流。
第 91 条 P2-4:可信范围从"本机/内网"收窄为"仅回环"——绑 0.0.0.0 时内网任意
主机直连可伪造 XFF 轮换绕过限流/登录锁定(动态实测实锤)。
第 97 条 P2-1:解析规则见 _client_ip_from_headers——XFF 最右段优先,
最右段为回环时回落 X-Real-IP(PassNAT 隧道场景),不再无条件读 X-Real-IP)"""
remote = request.remote_addr or '-'
if remote != '-' and _is_loopback_ip(remote):
return _client_ip_from_headers(remote)
return remote
# ============ 使用次数(按次计费:一首歌 = 1 次) ============
@@ -1017,103 +866,6 @@ def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int:
return 0
# ============ 装饰器 ============
def _deny(message: str, code: int = 401, error_code: str = 'unauthorized') -> tuple[dict[str, Any], int]:
return ({'status': code, 'success': False, 'message': message, 'error_code': error_code}, code)
def login_required(action: str = '', cost=None, free: bool = False):
"""强制登录装饰器:
- 未登录 401;剩余次数不足本次消耗时 401/expired
- free=True:解析/试听类接口,仅要求登录,不检查也不扣减次数
- cost: 可选 callable,返回本次请求消耗次数(下载类:单曲 1 次,批量按歌曲数)
- A2/B8 统一账本通道:预扣与开账同事务(reserve_and_open,临时键挂在 flask.g);
路由创建任务后用 ledger_rename 把账本改挂 task_id,worker 结算唯一入口走账本;
请求失败(HTTP>=400/异常)时装饰器只对"仍 open 的账本行"结算退款——
任何路径(含线程启动失败、json 模式)都恰好退一次,杜绝双退/漏退。
"""
def deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
user = current_user()
if not user:
return _deny('请先登录后再使用解析功能')
fresh = get_user_by_id(user['id'])
if fresh is None:
# 极小竞态:会话校验通过后、预扣前用户被删除——按未登录收敛,不再 None 解引用 500
return _deny('登录已失效,请重新登录')
credits = int(fresh['duration_seconds'] or 0)
unlimited = fresh['role'] == 'admin' or credits == -1
need = 0
reserved = False
from flask import g as _flask_g
if not free:
try:
need = max(1, int(cost() if cost is not None else 1))
except Exception:
need = 1
if not unlimited:
key = 'req-' + secrets.token_hex(8)
remaining = reserve_and_open(fresh['id'], need, key)
if remaining is None:
add_log('fail', fresh['username'], _client_ip(), action or request.path,
f'剩余次数不足(本次需 {need} 次)')
return _deny(f'下载次数已用尽(本次需 {need} 次,当前 {credits} 次),'
'请充值卡密后继续下载', error_code='expired')
_flask_g.billing_ledger_key = key
reserved = True
def _refund_open(reason: str) -> None:
"""A2:仅对仍 open 的账本行全额结算退款(被任务 rename/结算过则跳过)"""
key = getattr(_flask_g, 'billing_ledger_key', None)
if not key:
return
try:
row = ledger_get(key)
if row and row['status'] == 'open':
ledger_settle(key, 0, reason)
except Exception:
pass
try:
resp = fn(*args, **kwargs)
except Exception:
# 路由内部异常:预扣的次数经账本退回
if reserved:
_refund_open('rejected')
raise
status = resp[1] if isinstance(resp, tuple) else 200
if status < 400:
# P2-15:free 端点(搜索/解析等高频动作)成功不再写逐次 success 日志——
# 它们与卡密充值/退费/封禁等审计关键日志同表竞争 5000 行配额,
# 忙站点几小时就把追账依据冲掉;下载/计费/失败路径照常记录
if action and not free:
detail = (f'-{need} 次,剩余 {credits - need} 次' if reserved and need > 0 else '')
add_log('success', fresh['username'], _client_ip(), action, detail)
elif action and free:
# 解析类动作改记到 REQ_LOG(管理后台"操作记录"页),不入 logs 表
pass
elif action:
add_log('fail', fresh['username'], _client_ip(), action, f'HTTP {status}')
if status >= 400 and reserved:
# 路由失败(404/版权限制等):预扣的次数经账本退回
_refund_open('rejected')
return resp
return wrapper
return deco
def admin_required(fn):
"""仅站点管理员可用"""
@wraps(fn)
def wrapper(*args, **kwargs):
user = current_user()
if not user or user['role'] != 'admin':
return _deny('需要管理员权限', 403, 'forbidden')
return fn(*args, **kwargs)
return wrapper
# ============ 日志 ============
@@ -1697,7 +1449,7 @@ def restore_backup(name: str) -> None:
# 替换后初始化失败:回滚安全副本,恢复原库
shutil.copy2(str(safety), str(DB_PATH))
init_db()
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态')
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None
# 第 94 条(P3-4):裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写
try:
prune_backups(20, 5)