Archived
「同步转异步」的残留清理: 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 失败)。
109 lines
3.8 KiB
Python
109 lines
3.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""登录 IP 归属地探测与异地自动封禁。
|
||
|
||
(自 auth_system.py 拆出,行为与原实现一致。)
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import sqlite3
|
||
import time
|
||
|
||
from core.http_client import client as _http
|
||
|
||
from .db import (
|
||
_x,
|
||
)
|
||
from .logs import (
|
||
add_log,
|
||
)
|
||
from .users import (
|
||
ban_user,
|
||
)
|
||
|
||
_PROVINCE_CACHE: dict[str, str] = {}
|
||
|
||
|
||
def _is_private_ip(ip: str) -> bool:
|
||
"""内网/回环地址判断(172.16-31.* 才是私网,不能把整个 172.* 误判)"""
|
||
try:
|
||
import ipaddress
|
||
addr = ipaddress.ip_address(ip)
|
||
return addr.is_private or addr.is_loopback or addr.is_link_local
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
async def query_ip_province(ip: str) -> str:
|
||
"""查询 IP 归属省份(内网/失败返回空串),带进程内缓存
|
||
改用官方路径 /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 ''
|
||
if ip in _PROVINCE_CACHE:
|
||
return _PROVINCE_CACHE[ip]
|
||
province = ''
|
||
try:
|
||
import urllib.parse as _ul
|
||
|
||
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()
|
||
if d.get('status') == 'success':
|
||
province = str(d.get('regionName') or '')
|
||
except Exception:
|
||
province = ''
|
||
# 简单容量上限,防止公网长期运行下无界增长
|
||
if len(_PROVINCE_CACHE) > 5000:
|
||
_PROVINCE_CACHE.clear()
|
||
# 查询失败(超时/限流/垃圾 JSON)的空省份不写缓存——旧口径一次
|
||
# ip-api 抖动窗口内登录的 IP 被永久标记"无省份",省份防倒卖静默失效
|
||
if province:
|
||
_PROVINCE_CACHE[ip] = province
|
||
return province
|
||
|
||
|
||
PROVINCE_WINDOW = 24 * 3600 # 省份变化检测窗口:24 小时
|
||
|
||
PROVINCE_BAN_LIMIT = 3 # 窗口内出现 >=3 个不同省份即封禁
|
||
|
||
|
||
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 = await query_ip_province(ip)
|
||
if not province:
|
||
return False, ''
|
||
now = int(time.time())
|
||
try:
|
||
history = json.loads(user['recent_provinces'] if 'recent_provinces' in user.keys() and user['recent_provinces'] else '[]')
|
||
except Exception:
|
||
history = []
|
||
history = [h for h in history if isinstance(h, dict) and now - int(h.get('t', 0)) <= PROVINCE_WINDOW]
|
||
history.append({'p': province, 't': now})
|
||
provinces = []
|
||
for h in history:
|
||
if h.get('p') and h['p'] not in provinces:
|
||
provinces.append(h['p'])
|
||
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:])}),疑似账号倒卖,已自动封禁'
|
||
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
|