Files
FastapiAdmin/backend/app/utils/ip_local_util.py
T
zhangtao cf88ab8897 refactor: 整合仪表盘功能到监控模块,清理冗余代码
- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块
- 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name
- 优化搜索工具函数,移除重复导入
- 调整参数配置模型字段长度限制,移除config_value的max_length约束
- 清理冗余的常量定义和导入语句
- 修复批量状态设置接口的redis依赖注入
- 增强OAuth登录安全性,添加租户默认归属和state一次性消费
- 优化资源目录缓存逻辑,减少重复计算
- 新增API Token模块基础框架
- 完善用户token版本管理,支持主动失效JWT
- 调整AI模型配置缓存过期时间
- 修复菜单类型字段索引,提升查询性能
- 简化前端刷新token调用逻辑
- 新增滑块验证完成接口和忘记密码验证码校验
- 调整系统配置默认值,添加操作日志保留天数和接口白名单配置
- 限制Mock支付回调仅在开发环境可用
- 重构websocket认证方式,支持更安全的subprotocol传参
2026-07-13 01:14:20 +08:00

144 lines
5.0 KiB
Python

import ipaddress
import httpx
from starlette.requests import Request
from app.config.setting import settings
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
# 归属地缓存:IP 几乎不变化,缓存 7 天可显著减少外网请求
_IP_CACHE_TTL: int = settings.IP_LOCATION_CACHE_TTL
# 硬超时(秒),避免外网查询阻塞主流程
_IP_QUERY_TIMEOUT: float = settings.IP_LOCATION_QUERY_TIMEOUT
def get_client_ip(request: Request) -> str:
"""从请求中提取客户端真实 IP(优先取反向代理透传的头部,返回空字串表示无法识别)。"""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP", "")
if real_ip:
return real_ip.strip()
if request.client:
return request.client.host or ""
return ""
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 = [
("http://ip-api.com/json", cls._parse_ipapi, {"lang": "zh-CN"}),
("https://whois.pconline.com.cn/ipJson.jsp", cls._parse_pconline, {"ip": ip, "json": "true"}),
]
for url, parser, params in apis:
try:
async with httpx.AsyncClient(timeout=_IP_QUERY_TIMEOUT) as client:
resp = await client.get(f"{url}/{ip}" if "ip-api" in url else url, params=params)
if resp.status_code == 200:
data = resp.json() if "ip-api" in url else resp.text
location = parser(data)
if location:
return location
except Exception as e:
logger.warning(f"IP 归属地 API 失败: {url} - {e}")
return "未知"
@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
def _parse_pconline(text: str) -> str | None:
"""解析 pconline 返回的 JSONP 文本,格式如 'if( {\"ip\":\"...\",\"pro\":\"\",\"city\":\"\"} )'。"""
try:
import re
match = re.search(r"\{.*\}", text)
if not match:
return None
import json
data = json.loads(match.group())
parts = [data.get("pro"), data.get("city"), data.get("addr")]
joined = " ".join(filter(None, parts))
return joined or None
except Exception:
return None
@staticmethod
async def _cache_get(redis, ip: str) -> str | None:
try:
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:
await RedisCURD(redis).set(f"ip:location:{ip}", value, expire=_IP_CACHE_TTL)
except Exception:
pass