Files
ctms/backend/app/services/ip_location.py
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

132 lines
4.2 KiB
Python

"""IP 属地解析服务。
优先使用 ip2region xdb 离线库;缺少依赖或数据文件时降级为基础地址分类。
"""
from __future__ import annotations
import ipaddress
import logging
import sys
from dataclasses import dataclass
from functools import lru_cache
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()
@lru_cache(maxsize=8192)
def resolve_ip_location(ip: str | None) -> IpLocation:
return _resolver.lookup(ip)