feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""Bounded third-party coordinate fallback for public source IPs.
|
||||
|
||||
IPAddress.my identifies IP2Location.io as its data provider. We use the
|
||||
provider's documented JSON API instead of scraping the public HTML page.
|
||||
Only globally routable addresses are eligible, and results are cached so the
|
||||
monitoring UI does not turn into a per-refresh third-party lookup fan-out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.geo_location_metadata import GeoLocationMetadata
|
||||
from app.services.ip_location import IpLocation
|
||||
|
||||
logger = logging.getLogger("ctms.ip_geolocation_fallback")
|
||||
|
||||
_API_URL = "https://api.ip2location.io/"
|
||||
_MAX_RESPONSE_BYTES = 64 * 1024
|
||||
_MAX_CACHE_ENTRIES = 4096
|
||||
_NEGATIVE_CACHE_SECONDS = 3600
|
||||
_cache: dict[str, tuple[float, "ExternalIpLocation | None"]] = {}
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = 160) -> str:
|
||||
candidate = str(value or "").strip()
|
||||
return "" if candidate in {"", "-", "0"} else candidate[:limit]
|
||||
|
||||
|
||||
def _coordinate(value: Any, *, minimum: float, maximum: float) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) and minimum <= number <= maximum else None
|
||||
|
||||
|
||||
def _global_ip(value: str | None) -> str | None:
|
||||
try:
|
||||
address = ipaddress.ip_address((value or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return str(address) if address.is_global else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalIpLocation:
|
||||
ip_address: str
|
||||
country: str
|
||||
country_code: str
|
||||
region: str
|
||||
city: str
|
||||
isp: str
|
||||
longitude: float
|
||||
latitude: float
|
||||
|
||||
def merge_ip_location(self, local: IpLocation) -> IpLocation:
|
||||
country = self.country or local.country
|
||||
province = self.region or local.province
|
||||
city = self.city or local.city
|
||||
isp = self.isp or local.isp
|
||||
location = " / ".join(part for part in [country, province, city, isp] if part) or local.location
|
||||
return IpLocation(location=location, country=country, province=province, city=city, isp=isp)
|
||||
|
||||
def to_metadata(self) -> GeoLocationMetadata:
|
||||
return GeoLocationMetadata(
|
||||
country=self.country,
|
||||
country_code=self.country_code,
|
||||
region_code="",
|
||||
longitude=self.longitude,
|
||||
latitude=self.latitude,
|
||||
accuracy_level="city" if self.city else "country",
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(expected_ip: str, payload: Any) -> ExternalIpLocation | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
response_ip = _global_ip(_text(payload.get("ip"), 45))
|
||||
if response_ip != expected_ip:
|
||||
return None
|
||||
latitude = _coordinate(payload.get("latitude"), minimum=-90, maximum=90)
|
||||
longitude = _coordinate(payload.get("longitude"), minimum=-180, maximum=180)
|
||||
if latitude is None or longitude is None:
|
||||
return None
|
||||
country_code = _text(payload.get("country_code"), 2).upper()
|
||||
if len(country_code) != 2:
|
||||
country_code = ""
|
||||
return ExternalIpLocation(
|
||||
ip_address=expected_ip,
|
||||
country=_text(payload.get("country_name"), 100),
|
||||
country_code=country_code,
|
||||
region=_text(payload.get("region_name"), 100),
|
||||
city=_text(payload.get("city_name"), 100),
|
||||
isp=_text(payload.get("isp"), 160),
|
||||
longitude=longitude,
|
||||
latitude=latitude,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_one(
|
||||
client: httpx.AsyncClient,
|
||||
semaphore: asyncio.Semaphore,
|
||||
ip_address: str,
|
||||
) -> ExternalIpLocation | None:
|
||||
async with semaphore:
|
||||
try:
|
||||
response = await client.get(_API_URL, params={"ip": ip_address, "format": "json"})
|
||||
response.raise_for_status()
|
||||
if len(response.content) > _MAX_RESPONSE_BYTES:
|
||||
return None
|
||||
return _parse_response(ip_address, response.json())
|
||||
except (httpx.HTTPError, ValueError):
|
||||
logger.warning("External IP coordinate fallback unavailable")
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_external_ip_locations(ip_addresses: Iterable[str]) -> dict[str, ExternalIpLocation]:
|
||||
if not settings.MONITORING_IP_GEO_FALLBACK_ENABLED or settings.ENV == "test":
|
||||
return {}
|
||||
|
||||
candidates = list(dict.fromkeys(filter(None, (_global_ip(value) for value in ip_addresses))))
|
||||
if not candidates:
|
||||
return {}
|
||||
|
||||
now = time.monotonic()
|
||||
resolved: dict[str, ExternalIpLocation] = {}
|
||||
pending: list[str] = []
|
||||
async with _cache_lock:
|
||||
for ip_address in candidates:
|
||||
cached = _cache.get(ip_address)
|
||||
if cached and cached[0] > now:
|
||||
if cached[1] is not None:
|
||||
resolved[ip_address] = cached[1]
|
||||
continue
|
||||
pending.append(ip_address)
|
||||
|
||||
pending = pending[: settings.MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS]
|
||||
if not pending:
|
||||
return resolved
|
||||
|
||||
headers = {"Accept": "application/json", "User-Agent": "CTMS-IP-Coordinate-Fallback/1.0"}
|
||||
api_key = (settings.MONITORING_IP_GEO_FALLBACK_API_KEY or "").strip()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
timeout = httpx.Timeout(settings.MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS)
|
||||
semaphore = asyncio.Semaphore(min(5, len(pending)))
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers) as client:
|
||||
fetched = await asyncio.gather(*(_fetch_one(client, semaphore, item) for item in pending))
|
||||
|
||||
now = time.monotonic()
|
||||
async with _cache_lock:
|
||||
for ip_address, item in zip(pending, fetched):
|
||||
ttl = settings.MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS if item else _NEGATIVE_CACHE_SECONDS
|
||||
_cache[ip_address] = (now + ttl, item)
|
||||
if item is not None:
|
||||
resolved[ip_address] = item
|
||||
while len(_cache) > _MAX_CACHE_ENTRIES:
|
||||
_cache.pop(next(iter(_cache)))
|
||||
return resolved
|
||||
|
||||
|
||||
def reset_ip_geolocation_fallback_cache() -> None:
|
||||
_cache.clear()
|
||||
Reference in New Issue
Block a user