init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.common.enums import RedisInitKeyConfig, SysParamKey
|
||||
from app.config.setting import settings
|
||||
from app.core.logger import logger
|
||||
from app.core.redis_crud import RedisCURD
|
||||
|
||||
# 归属地缓存:IP 几乎不变化,缓存 7 天可显著减少外网请求
|
||||
_IP_CACHE_TTL: int = settings.IP_LOCATION_CACHE_TTL
|
||||
# 硬超时(秒),避免外网查询阻塞主流程
|
||||
_IP_QUERY_TIMEOUT: float = settings.IP_LOCATION_QUERY_TIMEOUT
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""从请求中提取客户端真实 IP(返回空字串表示无法识别)。
|
||||
|
||||
- 仅当 settings.IP_TRUST_PROXY_HEADERS 为 true 时信任 X-Forwarded-For / X-Real-IP;
|
||||
此时要求后端只被可信代理(nginx)访问,否则攻击者可直接伪造该头。
|
||||
- 默认(不信任)返回直连 socket IP,杜绝伪造。
|
||||
"""
|
||||
if settings.IP_TRUST_PROXY_HEADERS:
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
real_ip = request.headers.get("X-Real-IP", "")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if request.client:
|
||||
return request.client.host or ""
|
||||
return ""
|
||||
|
||||
|
||||
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 _is_location_enabled(cls, redis) -> bool:
|
||||
"""从参数缓存读取 IP 归属地查询开关。"""
|
||||
if not redis:
|
||||
return False
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{SysParamKey.IP_LOCATION_ENABLE.value}"
|
||||
try:
|
||||
raw = await RedisCURD(redis).get(redis_key)
|
||||
if raw:
|
||||
payload = json.loads(raw)
|
||||
cv = payload.get("config_value", "off")
|
||||
return cv in (True, "true", "1", "yes", "on")
|
||||
except (json.JSONDecodeError, TypeError, Exception):
|
||||
pass
|
||||
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 await cls._is_location_enabled(redis):
|
||||
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 not await cls._is_location_enabled(redis):
|
||||
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: list[tuple[str, Callable, dict[str, str]]] = [
|
||||
("http://ip-api.com/json", cls._parse_ipapi, {"lang": "zh-CN"}),
|
||||
("https://whois.pconline.com.cn/ipJson.jsp", cls._parse_pconline, {"ip": ip, "json": "true"}),
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=_IP_QUERY_TIMEOUT) as client:
|
||||
for url, parser, params in apis:
|
||||
try:
|
||||
resp = await client.get(f"{url}/{ip}" if "ip-api" in url else url, params=params)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json() if "ip-api" in url else resp.text
|
||||
location = parser(data)
|
||||
if location:
|
||||
return location
|
||||
except Exception as e:
|
||||
logger.warning(f"IP 归属地 API 失败: {url} - {e}")
|
||||
return "未知"
|
||||
|
||||
@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
|
||||
def _parse_pconline(text: str) -> str | None:
|
||||
"""解析 pconline 返回的 JSONP 文本,格式如 'if( {\"ip\":\"...\",\"pro\":\"省\",\"city\":\"市\"} )'。"""
|
||||
try:
|
||||
match = re.search(r"\{.*\}", text)
|
||||
if not match:
|
||||
return None
|
||||
data = json.loads(match.group())
|
||||
parts = [data.get("pro"), data.get("city"), data.get("addr")]
|
||||
joined = " ".join(filter(None, parts))
|
||||
return joined or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _cache_get(redis, ip: str) -> str | None:
|
||||
try:
|
||||
value = await RedisCURD(redis).get(f"ip:location:{ip}")
|
||||
return value.decode("utf-8") if value else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _cache_set(redis, ip: str, value: str) -> None:
|
||||
try:
|
||||
await RedisCURD(redis).set(f"ip:location:{ip}", value, expire=_IP_CACHE_TTL)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user