Archived
refactor: geo 走原生异步 HTTP,清掉短链展开死链
「同步转异步」的残留清理: 1) auth_system/geo.query_ip_province 原经 core.sync_http 同步门面(在后台 事件循环桥接)查询归属地,但其唯一调用方 record_login_location 的入口 本来就是 async 的登录链路(web/routers/auth 里还套了一层 to_thread)。 改为直接 await core.http_client——桥接纯属多余,顺带把 record_login_location 转 async,落库仍走同步 sqlite 但经 asyncio.to_thread 卸载,避免 sqlite busy 等待(最长 15s)冻结事件循环。 调用方两处(普通用户/管理员登录)去掉 to_thread 包装,直接 await。 2) MusicAPIService._extract_music_id 是死代码:ID 归一化早已迁到 web.request_utils(注释里明说是它的迁移口径),全仓零调用。它也是 core.utils.expand_shortlink 的唯一调用方——一并删除,core.utils 由此 彻底不再 import core.sync_http。 门禁:ruff 全过 / pyright 0-0 / pytest 387 项全绿(0 失败)。
This commit is contained in:
1 parent
064a89d726
commit
b089dadac2
5 files changed
+26
-78
No files matched your search
+22
-10
@@ -4,10 +4,13 @@
|
||||
(自 auth_system.py 拆出,行为与原实现一致。)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from core.http_client import client as _http
|
||||
|
||||
from .db import (
|
||||
_x,
|
||||
)
|
||||
@@ -31,9 +34,13 @@ def _is_private_ip(ip: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def query_ip_province(ip: str) -> str:
|
||||
async def query_ip_province(ip: str) -> str:
|
||||
"""查询 IP 归属省份(内网/失败返回空串),带进程内缓存
|
||||
改用官方路径 /json/{query} 与 zh-CN 语言参数"""
|
||||
改用官方路径 /json/{query} 与 zh-CN 语言参数。
|
||||
|
||||
走 core.http_client 的原生异步通道(原实现经 core.sync_http 同步门面
|
||||
在后台事件循环桥接——调用方本就是 async 的登录链路,无需再桥一次)。
|
||||
"""
|
||||
ip = (ip or '').split(',')[0].strip()
|
||||
if not ip or ip in ('-', '127.0.0.1', '0.0.0.0') or _is_private_ip(ip):
|
||||
return ''
|
||||
@@ -43,8 +50,8 @@ def query_ip_province(ip: str) -> str:
|
||||
try:
|
||||
import urllib.parse as _ul
|
||||
|
||||
from core import sync_http as _rq
|
||||
r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip),
|
||||
r = await _http.request(
|
||||
'GET', 'http://ip-api.com/json/' + _ul.quote(ip),
|
||||
params={'lang': 'zh-CN', 'fields': 'status,regionName'},
|
||||
timeout=5)
|
||||
d = r.json()
|
||||
@@ -67,12 +74,16 @@ PROVINCE_WINDOW = 24 * 3600 # 省份变化检测窗口:24 小时
|
||||
PROVINCE_BAN_LIMIT = 3 # 窗口内出现 >=3 个不同省份即封禁
|
||||
|
||||
|
||||
def record_login_location(user: sqlite3.Row, ip: str) -> tuple:
|
||||
async def record_login_location(user: sqlite3.Row, ip: str) -> tuple:
|
||||
"""记录本次登录省份并检测异常:24h 内出现 >=3 个不同省份自动封禁。
|
||||
返回 (是否被封禁, 说明)"""
|
||||
返回 (是否被封禁, 说明)
|
||||
|
||||
落库仍走同步 sqlite(auth_system 是同步 DB 层),但调用方在事件循环里,
|
||||
故经 asyncio.to_thread 卸载——避免 sqlite 的 busy 等待(最长 15s)冻结整个服务。
|
||||
"""
|
||||
if user['role'] == 'admin':
|
||||
return False, ''
|
||||
province = query_ip_province(ip)
|
||||
province = await query_ip_province(ip)
|
||||
if not province:
|
||||
return False, ''
|
||||
now = int(time.time())
|
||||
@@ -86,11 +97,12 @@ def record_login_location(user: sqlite3.Row, ip: str) -> tuple:
|
||||
for h in history:
|
||||
if h.get('p') and h['p'] not in provinces:
|
||||
provinces.append(h['p'])
|
||||
_x('UPDATE users SET recent_provinces = ? WHERE id = ?',
|
||||
await asyncio.to_thread(
|
||||
_x, 'UPDATE users SET recent_provinces = ? WHERE id = ?',
|
||||
(json.dumps(history, ensure_ascii=False), user['id']))
|
||||
if len(provinces) >= PROVINCE_BAN_LIMIT:
|
||||
reason = f'24小时内检测到 {len(provinces)} 个不同省份登录({"/".join(provinces[-3:])}),疑似账号倒卖,已自动封禁'
|
||||
ban_user(user['id'], reason)
|
||||
add_log('fail', user['username'], ip, '自动封禁', reason)
|
||||
await asyncio.to_thread(ban_user, user['id'], reason)
|
||||
await asyncio.to_thread(add_log, 'fail', user['username'], ip, '自动封禁', reason)
|
||||
return True, reason
|
||||
return False, province
|
||||
+1
-31
@@ -1,11 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""纯函数工具(框架无关,无 Web 框架依赖)。
|
||||
|
||||
SRT 时间轴、LRC→SRT 转换、字段参数解析、163cn.tv 短链展开(含私有缓存)。
|
||||
SRT 时间轴、LRC→SRT 转换、字段参数解析。
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
|
||||
|
||||
def srt_time(t: float) -> str:
|
||||
@@ -69,32 +68,3 @@ def parse_meta_fields(raw) -> list[str] | None:
|
||||
return list(raw) or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# 短链展开结果缓存:用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀)
|
||||
_SHORTLINK_CACHE: dict[str, str] = {}
|
||||
_SHORTLINK_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def expand_shortlink(url: str) -> str:
|
||||
"""展开 163cn.tv 短链(带进程内缓存;失败原样返回)"""
|
||||
with _SHORTLINK_CACHE_LOCK:
|
||||
hit = _SHORTLINK_CACHE.get(url)
|
||||
if hit is not None:
|
||||
return hit
|
||||
expanded = url
|
||||
try:
|
||||
from core import sync_http
|
||||
|
||||
response = sync_http.get(url, allow_redirects=False, timeout=10)
|
||||
expanded = response.headers.get('Location', url)
|
||||
except Exception:
|
||||
expanded = url
|
||||
# 展开失败(Location 缺失/网络异常,expanded 原样等于 url)不写
|
||||
# 缓存——旧口径一次网络抖动会让该分享链接在整个缓存存续期内必然解析失败
|
||||
if expanded != url:
|
||||
with _SHORTLINK_CACHE_LOCK:
|
||||
if len(_SHORTLINK_CACHE) > 500:
|
||||
_SHORTLINK_CACHE.clear()
|
||||
_SHORTLINK_CACHE[url] = expanded
|
||||
return expanded
|
||||
@@ -3,14 +3,11 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from core.config import (
|
||||
Settings,
|
||||
)
|
||||
from core.utils import expand_shortlink as _expand_shortlink
|
||||
from music_api import (
|
||||
NeteaseAPI,
|
||||
load_cookies,
|
||||
@@ -80,37 +77,6 @@ class MusicAPIService:
|
||||
self.logger.error(f"读取 cookie.txt 异常: {e}")
|
||||
return {}
|
||||
|
||||
def _extract_music_id(self, id_or_url: str) -> str:
|
||||
"""提取音乐ID(正规 URL 解析替代子串/删除非数字启发式)
|
||||
- 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF)
|
||||
- music.163.com 链接用 parse_qs 取 id 参数(参数顺序无关)
|
||||
"""
|
||||
raw = str(id_or_url or '').strip()
|
||||
try:
|
||||
# 短链展开:仅接受"整个输入就是 163cn.tv 链接"(子串包含不算)
|
||||
if re.match(r'^https?://(www\.)?163cn\.tv/\S+$', raw, re.IGNORECASE):
|
||||
raw = _expand_shortlink(raw)
|
||||
|
||||
# 网易云链接:正规解析 query 取 id(不再 find('id=') 误吃 userid= 尾部)
|
||||
if 'music.163.com' in raw:
|
||||
try:
|
||||
q = parse_qs(urlsplit(raw).query)
|
||||
for key in ('id', 'songid'):
|
||||
if q.get(key):
|
||||
return str(q[key][0]).strip()
|
||||
except Exception:
|
||||
pass
|
||||
# 兜底:老格式 #/song?id=xxx 的片段部分
|
||||
m = re.search(r'[?&#]id=(\d+)', raw)
|
||||
if m:
|
||||
return m.group(1)
|
||||
|
||||
# 纯数字/普通文本直接返回
|
||||
return raw
|
||||
except Exception as e:
|
||||
self.logger.error(f"提取音乐ID失败: {e}")
|
||||
return str(id_or_url or '').strip()
|
||||
|
||||
def _format_file_size(self, size_bytes) -> str:
|
||||
"""格式化文件大小(上游 size 可能为 null/垃圾值:容错返回"大小未知"而非 500)"""
|
||||
try:
|
||||
|
||||
@@ -133,7 +133,7 @@ def test_admin_without_email_blocked_fail_closed(env):
|
||||
|
||||
def test_geo_ban_bumps_seq_again(env, monkeypatch):
|
||||
"""异地多省封禁:login_seq 再递增一次(已签发中的会话全部顶下线)。"""
|
||||
def _banned(user, ip):
|
||||
async def _banned(user, ip):
|
||||
return True, {'province': 'X'}
|
||||
|
||||
monkeypatch.setattr(auth, 'record_login_location', _banned)
|
||||
|
||||
+2
-2
@@ -173,7 +173,7 @@ async def _user_session_response(user, ip: str) -> JSONResponse:
|
||||
seq_row = await repo.get_user_by_id(uid)
|
||||
seq = int((seq_row or {}).get('login_seq') or 1)
|
||||
|
||||
banned, _info = await asyncio.to_thread(auth.record_login_location, user, ip)
|
||||
banned, _info = await auth.record_login_location(user, ip)
|
||||
if banned:
|
||||
await db.execute(
|
||||
'UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (uid,))
|
||||
@@ -320,7 +320,7 @@ async def admin_login_verify(request: Request):
|
||||
seq_row = await repo.get_user_by_id(uid)
|
||||
seq = int((seq_row or {}).get('login_seq') or 1)
|
||||
|
||||
banned, _info = await asyncio.to_thread(auth.record_login_location, user, ip)
|
||||
banned, _info = await auth.record_login_location(user, ip)
|
||||
if banned:
|
||||
await db.execute('UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (uid,))
|
||||
return error('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
|
||||
|
||||
Reference in new issue
Block a user