Files
FastapiAdmin/backend/app/utils/ip_local_util.py
T
zhangtao f57d4f8cfb refactor: 清理冗余代码与初始化数据,优化配置与调度器
本次提交包含多项优化和清理工作:
1.  新增订单类型枚举BUY,调整定时任务状态显示逻辑
2.  优化Redis连接配置,重构认证逻辑减少重复代码
3.  移除大量初始化演示数据、过期的租户与角色配置
4.  简化调度器状态存储,不再写入配置表而是内存缓存
5.  修复插件开关状态存储逻辑,调整字典数据标签与值
6.  优化WebSocket聊天处理,完善错误处理流程
7.  清理冗余导入与废弃的操作日志过滤逻辑
2026-06-24 01:41:04 +08:00

136 lines
4.7 KiB
Python

import ipaddress
import httpx
from starlette.requests import Request
from app.config.setting import settings
from app.core.logger import logger
# 归属地缓存:IP 几乎不变化,缓存 7 天可显著减少外网请求
_IP_CACHE_TTL = 7 * 24 * 3600
# 硬超时(秒),避免外网查询阻塞主流程
_IP_QUERY_TIMEOUT = 3.0
def get_client_ip(request: Request) -> str | None:
"""从请求中解析客户端真实 IP。优先取 X-Forwarded-For 第一个,回退到 ``request.client.host``。
返回 None 表示客户端不存在(如 UNIX socket 场景)。
"""
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else None
class IpLocalUtil:
"""获取 IP 归属地工具类(带 Redis 缓存、硬超时、降级)。"""
@classmethod
def is_valid_ip(cls, ip: str) -> bool:
try:
ipaddress.ip_address(ip)
return True
except ValueError:
return False
@classmethod
def is_private_ip(cls, ip: str) -> bool:
try:
return ipaddress.ip_address(ip).is_private
except ValueError:
return False
@classmethod
async def resolve_location_for_log(cls, redis, ip: str | None) -> str | None:
"""登录日志写入入口:仅返回可同步获取的值(内网/缓存/降级),
外网查询由后台任务异步执行(见 ``resolve_location_async``)。
"""
if not ip:
return None
if not settings.IP_LOCATION_ENABLE:
return "内网IP" if cls.is_private_ip(ip) else "未解析(已关闭归属地查询)"
if cls.is_private_ip(ip):
return "内网IP"
if redis:
cached = await cls._cache_get(redis, ip)
if cached is not None:
return cached
return "归属地查询中"
@classmethod
async def resolve_location_async(cls, redis, ip: str) -> str:
"""异步查询归属地(含缓存、降级、硬超时)。"""
if not cls.is_valid_ip(ip):
return "未知"
if cls.is_private_ip(ip):
return "内网IP"
cached = await cls._cache_get(redis, ip) if redis else None
if cached is not None:
return cached
result = await cls._query_with_timeout(ip)
if redis:
await cls._cache_set(redis, ip, result)
return result
@classmethod
async def _query_with_timeout(cls, ip: str) -> str:
"""在硬超时内尝试主备两个 API,全部失败返回未知。"""
apis = [
("https://ip9.com.cn/get", cls._parse_ip9),
("http://ip-api.com/json", cls._parse_ipapi),
]
for url, parser in apis:
try:
async with httpx.AsyncClient(timeout=_IP_QUERY_TIMEOUT) as client:
resp = await client.get(
url if "ip-api" in url else f"{url}?ip={ip}",
params={} if "ip-api" in url else None,
)
if resp.status_code == 200:
location = parser(resp.json())
if location:
return location
except Exception as e:
logger.warning(f"IP 归属地 API 失败: {url} - {e}")
return "未知"
@staticmethod
def _parse_ip9(data: dict) -> str | None:
if data.get("ret") != 200:
return None
d = data.get("data") or {}
parts = [d.get("country"), d.get("prov"), d.get("city"), d.get("area"), d.get("isp")]
joined = "-".join(filter(None, parts))
return joined or None
@staticmethod
def _parse_ipapi(data: dict) -> str | None:
if data.get("status") != "success":
return None
parts = [data.get("country"), data.get("regionName"), data.get("city"), data.get("isp")]
joined = "-".join(filter(None, parts))
return joined or None
@staticmethod
async def _cache_get(redis, ip: str) -> str | None:
try:
from app.core.redis_crud import RedisCURD
value = await RedisCURD(redis).get(f"ip:location:{ip}")
if value is None:
return None
return value.decode("utf-8") if isinstance(value, bytes) else str(value)
except Exception:
return None
@staticmethod
async def _cache_set(redis, ip: str, value: str) -> None:
try:
from app.core.redis_crud import RedisCURD
await RedisCURD(redis).set(f"ip:location:{ip}", value, expire=_IP_CACHE_TTL)
except Exception:
pass