import ipaddress import httpx from starlette.requests import Request from app.config.setting import settings from app.core.logger import logger # 归属地缓存:IP 几乎不变化,缓存 7 天可显著减少外网请求 _IP_CACHE_TTL = 7 * 24 * 3600 # 硬超时(秒),避免外网查询阻塞主流程 _IP_QUERY_TIMEOUT = 3.0 def get_client_ip(request: Request) -> str | None: """从请求中解析客户端真实 IP。优先取 X-Forwarded-For 第一个,回退到 ``request.client.host``。 返回 None 表示客户端不存在(如 UNIX socket 场景)。 """ xff = request.headers.get("X-Forwarded-For") if xff: return xff.split(",")[0].strip() return request.client.host if request.client else None 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 = [ ("https://ip9.com.cn/get", cls._parse_ip9), ("http://ip-api.com/json", cls._parse_ipapi), ] for url, parser in apis: try: async with httpx.AsyncClient(timeout=_IP_QUERY_TIMEOUT) as client: resp = await client.get( url if "ip-api" in url else f"{url}?ip={ip}", params={} if "ip-api" in url else None, ) if resp.status_code == 200: location = parser(resp.json()) if location: return location except Exception as e: logger.warning(f"IP 归属地 API 失败: {url} - {e}") return "未知" @staticmethod def _parse_ip9(data: dict) -> str | None: if data.get("ret") != 200: return None d = data.get("data") or {} parts = [d.get("country"), d.get("prov"), d.get("city"), d.get("area"), d.get("isp")] joined = "-".join(filter(None, parts)) return joined or None @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 async def _cache_get(redis, ip: str) -> str | None: try: from app.core.redis_crud import RedisCURD 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: from app.core.redis_crud import RedisCURD await RedisCURD(redis).set(f"ip:location:{ip}", value, expire=_IP_CACHE_TTL) except Exception: pass