"""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)