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