This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/auth_system/geo.py
T
snowy-yang b089dadac2 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 失败)。
2026-09-28 19:10:45 +08:00

109 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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