6cefa620e4
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效: - 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式 - 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充 - 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮 - IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格 - 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片 - 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器 - 角色概览卡片:加进度条可视化,hover 微动效 - 接口权限矩阵:工具栏分离布局,表格圆角包裹 - 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
130 lines
4.2 KiB
Python
130 lines
4.2 KiB
Python
"""IP 属地解析服务。
|
|
|
|
优先使用 ip2region xdb 离线库;缺少依赖或数据文件时降级为基础地址分类。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import logging
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger("ctms.ip_location")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IpLocation:
|
|
location: str
|
|
country: str = ""
|
|
province: str = ""
|
|
city: str = ""
|
|
isp: str = ""
|
|
|
|
|
|
def _default_xdb_path(version: int) -> Path:
|
|
filename = "ip2region_v6.xdb" if version == 6 else "ip2region_v4.xdb"
|
|
return Path(__file__).resolve().parents[1] / "data" / filename
|
|
|
|
|
|
def _normalize_part(value: str | None) -> str:
|
|
part = (value or "").strip()
|
|
return "" if part in {"", "0", "-"} else part
|
|
|
|
|
|
def _format_location(country: str, province: str, city: str, isp: str) -> str:
|
|
parts = [part for part in [country, province, city, isp] if part]
|
|
return " / ".join(parts) if parts else "公网"
|
|
|
|
|
|
class Ip2RegionResolver:
|
|
def __init__(self, db_path: Optional[str] = None, ipv6_db_path: Optional[str] = None) -> None:
|
|
self.db_paths = {
|
|
4: Path(db_path or settings.IP2REGION_XDB_PATH or _default_xdb_path(4)),
|
|
6: Path(ipv6_db_path or settings.IP2REGION_IPV6_XDB_PATH or _default_xdb_path(6)),
|
|
}
|
|
self._searchers = {}
|
|
self._load_failed: set[int] = set()
|
|
|
|
def lookup(self, ip: str | None) -> IpLocation:
|
|
addr = self._classify_address(ip)
|
|
if addr is None:
|
|
return IpLocation(location="未知")
|
|
if addr.is_loopback:
|
|
return IpLocation(location="本机")
|
|
if addr.is_private or addr.is_link_local:
|
|
return IpLocation(location="局域网")
|
|
|
|
searcher = self._get_searcher(addr.version)
|
|
if not searcher:
|
|
return IpLocation(location="公网")
|
|
|
|
try:
|
|
region = searcher.search(str(addr))
|
|
except Exception:
|
|
logger.exception("Failed to lookup IP location: %s", addr)
|
|
return IpLocation(location="公网")
|
|
return self._parse_region(region)
|
|
|
|
@staticmethod
|
|
def _classify_address(ip: str | None):
|
|
if not ip:
|
|
return None
|
|
try:
|
|
return ipaddress.ip_address(ip)
|
|
except ValueError:
|
|
return None
|
|
|
|
def _get_searcher(self, version: int):
|
|
if version in self._searchers or version in self._load_failed:
|
|
return self._searchers.get(version)
|
|
db_path = self.db_paths[version]
|
|
if not db_path.exists():
|
|
self._load_failed.add(version)
|
|
logger.info("ip2region xdb file not found: %s", db_path)
|
|
return None
|
|
try:
|
|
try:
|
|
import ip2region.searcher as searcher
|
|
import ip2region.util as util
|
|
except ModuleNotFoundError:
|
|
vendor_path = Path(__file__).resolve().parents[1] / "vendor"
|
|
if str(vendor_path) not in sys.path:
|
|
sys.path.insert(0, str(vendor_path))
|
|
import ip2region.searcher as searcher
|
|
import ip2region.util as util
|
|
|
|
ip_version = util.IPv6 if version == 6 else util.IPv4
|
|
buffer = util.load_content_from_file(str(db_path))
|
|
self._searchers[version] = searcher.new_with_buffer(ip_version, buffer)
|
|
except Exception:
|
|
self._load_failed.add(version)
|
|
logger.exception("Failed to initialize ip2region searcher: %s", db_path)
|
|
return self._searchers.get(version)
|
|
|
|
@staticmethod
|
|
def _parse_region(region: str | None) -> IpLocation:
|
|
parts = [*str(region or "").split("|"), "", "", "", ""]
|
|
country = _normalize_part(parts[0])
|
|
province = _normalize_part(parts[1])
|
|
city = _normalize_part(parts[2])
|
|
isp = _normalize_part(parts[3])
|
|
return IpLocation(
|
|
location=_format_location(country, province, city, isp),
|
|
country=country,
|
|
province=province,
|
|
city=city,
|
|
isp=isp,
|
|
)
|
|
|
|
|
|
_resolver = Ip2RegionResolver()
|
|
|
|
|
|
def resolve_ip_location(ip: str | None) -> IpLocation:
|
|
return _resolver.lookup(ip)
|