Files
Network-tools/core/Function/ip_conflict_fun.py
T
2026-06-08 13:39:59 +08:00

697 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import concurrent.futures
import csv
import ipaddress
import json
import re
import threading
import time
from dataclasses import asdict, dataclass, field
from typing import Callable, Optional
from core.Function.common import run_hidden
from core.Function.loop_fun import normalize_mac
from core.Function.network_fun import NetworkManager
OutputCallback = Callable[[str, Optional[str]], None]
DoneCallback = Callable[[], None]
StatusCallback = Callable[[dict], None]
ResultCallback = Callable[[dict], None]
ALL_ADAPTERS = "全部活动网卡"
MODE_BOTH = "两者都做"
MODE_LOCAL = "本机 IP 检测"
MODE_SUBNET = "网段扫描"
@dataclass
class IpConflictOptions:
mode: str = MODE_BOTH
scan_range: str = ""
workers: int = 64
timeout_ms: int = 500
max_hosts: int = 254
neighbor_samples: int = 3
@dataclass
class IpConflictEvidence:
address_state: str = ""
local_mac: str = ""
observed_macs: list[str] = field(default_factory=list)
conflict_macs: list[str] = field(default_factory=list)
tcpip_event_count: int = 0
gateway_mac_changes: int = 0
ip_mac_changes: int = 0
shared_mac_ips: int = 0
scanned_hosts: int = 0
notes: str = ""
def to_dict(self) -> dict:
data = asdict(self)
data["observed_macs"] = ", ".join(self.observed_macs)
data["conflict_macs"] = ", ".join(self.conflict_macs)
return data
@dataclass
class IpConflictResult:
adapter: str
ip: str
mac: str
conflict_macs: str
evidence_type: str
risk_level: str
verdict: str
evidence: IpConflictEvidence
checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
def to_dict(self) -> dict:
data = {
"adapter": self.adapter,
"ip": self.ip,
"mac": self.mac,
"conflict_macs": self.conflict_macs,
"evidence_type": self.evidence_type,
"risk_level": self.risk_level,
"verdict": self.verdict,
"checked_at": self.checked_at,
}
data.update(self.evidence.to_dict())
return data
class IpConflictDetector:
def __init__(
self,
output: OutputCallback,
done: Optional[DoneCallback] = None,
status: Optional[StatusCallback] = None,
result: Optional[ResultCallback] = None,
):
self.output = output
self.done = done or (lambda: None)
self.status = status or (lambda _stats: None)
self.result = result or (lambda _row: None)
self.network = NetworkManager(lambda _text, _tag=None: None)
self.stop_event = threading.Event()
self.worker = None
self.last_results: list[dict] = []
self.last_summary = ""
def get_adapter_choices(self) -> list[str]:
adapters = self._active_adapters(self.network.get_network_info())
return [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
def default_scan_range(self, adapter_name: str = ALL_ADAPTERS) -> str:
adapters = self._select_adapters(adapter_name)
if not adapters:
return ""
return adapter_to_safe_range(adapters[0])
def start_detection(self, adapter_name: str = ALL_ADAPTERS, options: Optional[dict] = None) -> None:
if self.is_running():
raise RuntimeError("IP 冲突检测正在运行,请先停止当前任务")
conflict_options = self.normalize_options(options)
adapters = self._select_adapters(adapter_name)
if not adapters:
raise ValueError("没有找到可检测的活动网卡")
self.stop_event.clear()
self.last_results = []
self.last_summary = ""
self.output(f"开始 IP 冲突检测: {adapter_name or ALL_ADAPTERS},模式: {conflict_options.mode}\n", "muted")
self.output("说明: 检测结果基于本机可见 ARP/邻居表、地址状态和系统事件,不替代交换机侧排查。\n\n", "muted")
self.status(self._status("运行中", len(adapters), "", 0, 0, 0, 0))
self.worker = threading.Thread(target=self._run_detection, args=(adapters, conflict_options), daemon=True)
self.worker.start()
def _run_detection(self, adapters: list[dict], options: IpConflictOptions) -> None:
started = time.perf_counter()
completed = 0
max_risk = "正常"
try:
address_states = self.get_address_states()
events = self.get_tcpip_conflict_events()
neighbor_samples = [self.get_neighbors()]
if options.mode in (MODE_BOTH, MODE_SUBNET):
targets = self.build_scan_targets(adapters, options)
self.output(f"准备刷新 ARP: {len(targets)} 个地址,并发 {options.workers},超时 {options.timeout_ms}ms\n", "muted")
self.status(self._status("扫描中", len(adapters), "", 0, len(targets), 0, time.perf_counter() - started))
self.refresh_arp_targets(targets, options)
neighbor_samples.append(self.get_neighbors())
for _index in range(max(options.neighbor_samples - len(neighbor_samples), 0)):
if self.stop_event.is_set():
break
self.stop_event.wait(0.3)
neighbor_samples.append(self.get_neighbors())
rows = []
for adapter in adapters:
if self.stop_event.is_set():
break
completed += 1
self.status(self._status("分析中", len(adapters), adapter["name"], 0, 0, completed, time.perf_counter() - started))
results = self.evaluate_adapter(adapter, address_states, events, neighbor_samples, options)
if not results:
results = [normal_result(adapter, "未发现本机 IP 冲突证据")]
for item in results:
row = item.to_dict()
rows.append(row)
self.last_results.append(row)
max_risk = worse_risk(max_risk, item.risk_level)
self.result(row)
self.last_summary = build_summary(rows, stopped=self.stop_event.is_set())
self.output("\nIP 冲突检测已停止\n" if self.stop_event.is_set() else "\nIP 冲突检测完成\n", "warning" if self.stop_event.is_set() else "success")
self.output(self.last_summary + "\n", "success" if max_risk == "正常" else "warning")
self.status(self._status("已停止" if self.stop_event.is_set() else "已完成", len(adapters), "", risk_rank(max_risk), 0, completed, time.perf_counter() - started))
except Exception as exc:
self.output(f"\nIP 冲突检测失败: {exc}\n", "error")
self.status(self._status("失败", len(adapters), "", 0, 0, completed, time.perf_counter() - started))
finally:
self.done()
def evaluate_adapter(
self,
adapter: dict,
address_states: dict,
events: list[dict],
neighbor_samples: list[list[dict]],
options: IpConflictOptions,
) -> list[IpConflictResult]:
local_mac = normalize_mac(adapter.get("mac", ""))
local_ip = adapter.get("ipv4", "")
gateway = adapter.get("gateway", "")
interface_index = str(adapter.get("interface_index", ""))
local_neighbors = filter_neighbors(neighbor_samples, interface_index, local_ip)
ip_to_macs, mac_to_ips = build_neighbor_maps(local_neighbors)
results = []
state = address_states.get(local_ip, "")
event_hits = event_count_for_ip(events, local_ip)
observed_macs = sorted(ip_to_macs.get(local_ip, set()))
conflict_macs = sorted([mac for mac in observed_macs if local_mac and mac != local_mac and not is_special_mac(mac)])
if state in {"Duplicate", "Tentative", "DadFailed"} or event_hits or conflict_macs:
evidence = IpConflictEvidence(
address_state=state,
local_mac=local_mac,
observed_macs=observed_macs,
conflict_macs=conflict_macs,
tcpip_event_count=event_hits,
scanned_hosts=options.max_hosts,
notes="本机 IP 冲突证据",
)
level = "冲突风险"
reasons = []
if state:
reasons.append(f"地址状态 {state}")
if event_hits:
reasons.append(f"系统 Tcpip 冲突事件 {event_hits} 条")
if conflict_macs:
reasons.append("本机 IP 被其它 MAC 响应")
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=local_ip,
mac=local_mac,
conflict_macs=", ".join(conflict_macs),
evidence_type="本机 IP",
risk_level=level,
verdict="疑似本机 IP 冲突,请检查是否有其它设备使用相同地址。证据: " + "、".join(reasons),
evidence=evidence,
)
)
multi_mac_ips = sorted(ip for ip, macs in ip_to_macs.items() if len([mac for mac in macs if not is_special_mac(mac)]) > 1)
for ip in multi_mac_ips:
if ip in {local_ip, gateway}:
continue
macs = sorted(ip_to_macs[ip])
evidence = IpConflictEvidence(
local_mac=local_mac,
observed_macs=macs,
conflict_macs=macs,
ip_mac_changes=1,
scanned_hosts=options.max_hosts,
notes="同一 IP 出现多个 MAC",
)
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=ip,
mac="",
conflict_macs=", ".join(macs),
evidence_type="网段 IP",
risk_level="冲突风险",
verdict="同一 IP 在邻居表中出现多个 MAC,疑似 IP 地址冲突。",
evidence=evidence,
)
)
if gateway:
gateway_macs = sorted(ip_to_macs.get(gateway, set()))
if len([mac for mac in gateway_macs if not is_special_mac(mac)]) > 1:
evidence = IpConflictEvidence(
local_mac=local_mac,
observed_macs=gateway_macs,
conflict_macs=gateway_macs,
gateway_mac_changes=max(len(gateway_macs) - 1, 0),
scanned_hosts=options.max_hosts,
notes="网关 MAC 发生变化",
)
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=gateway,
mac="",
conflict_macs=", ".join(gateway_macs),
evidence_type="网关",
risk_level="可疑",
verdict="网关 IP 对应多个 MAC,可能是网关冗余、ARP 欺骗或地址冲突,建议复核网关设备。",
evidence=evidence,
)
)
proxy_rows = []
for mac, ips in mac_to_ips.items():
clean_ips = [ip for ip in ips if not is_multicast_or_broadcast_ip(ip)]
if len(clean_ips) >= 8 and mac != local_mac:
proxy_rows.append((mac, sorted(clean_ips)))
for mac, ips in proxy_rows[:3]:
evidence = IpConflictEvidence(
local_mac=local_mac,
observed_macs=[mac],
shared_mac_ips=len(ips),
scanned_hosts=options.max_hosts,
notes="同一 MAC 对应大量 IP,可能是 Proxy ARP 或网关代理,不直接判定为 IP 冲突",
)
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=", ".join(ips[:5]) + ("..." if len(ips) > 5 else ""),
mac=mac,
conflict_macs="",
evidence_type="Proxy ARP 提示",
risk_level="可疑",
verdict="同一 MAC 对应大量 IP,更像 Proxy ARP/网关代理,需结合网络拓扑判断。",
evidence=evidence,
)
)
return results
def build_scan_targets(self, adapters: list[dict], options: IpConflictOptions) -> list[str]:
if options.scan_range:
return parse_target_range(options.scan_range, options.max_hosts)
targets = []
for adapter in adapters:
targets.extend(parse_target_range(adapter_to_safe_range(adapter), options.max_hosts))
return list(dict.fromkeys(targets))[: options.max_hosts]
def refresh_arp_targets(self, targets: list[str], options: IpConflictOptions) -> None:
done = 0
def task(ip: str) -> None:
if self.stop_event.is_set():
return
run_hidden(["ping", ip, "-n", "1", "-w", str(options.timeout_ms)], timeout=max(2, options.timeout_ms / 1000 + 2))
with concurrent.futures.ThreadPoolExecutor(max_workers=options.workers) as executor:
futures = {executor.submit(task, ip): ip for ip in targets}
for future in concurrent.futures.as_completed(futures):
done += 1
if self.stop_event.is_set():
for item in futures:
item.cancel()
break
try:
future.result()
except Exception:
pass
if done % 20 == 0 or done == len(targets):
self.status(self._status("扫描中", 0, futures[future], risk_rank("正常"), len(targets), done, 0))
def get_address_states(self) -> dict[str, str]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
Get-NetIPAddress -AddressFamily IPv4 | Select-Object IPAddress,InterfaceIndex,AddressState,PrefixLength | ConvertTo-Json -Depth 4 -Compress
"""
try:
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=10)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip())
data = extract_json(result.stdout)
if isinstance(data, dict):
data = [data]
return {item.get("IPAddress", ""): str(item.get("AddressState", "")) for item in data if item.get("IPAddress")}
except Exception as exc:
self.output(f"读取本机地址状态失败: {exc}\n", "warning")
return {}
def get_tcpip_conflict_events(self) -> list[dict]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
Get-WinEvent -FilterHashtable @{LogName='System'; ProviderName='Tcpip'; StartTime=(Get-Date).AddDays(-7)} -MaxEvents 80 |
Select-Object TimeCreated,Id,ProviderName,Message | ConvertTo-Json -Depth 4 -Compress
"""
try:
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=12)
if result.returncode != 0 or not result.stdout.strip():
return []
data = extract_json(result.stdout)
if isinstance(data, dict):
data = [data]
events = []
for item in data:
message = str(item.get("Message", ""))
if re.search(r"conflict|duplicate|冲突|重复", message, re.IGNORECASE):
events.append(
{
"time": str(item.get("TimeCreated", "")),
"id": str(item.get("Id", "")),
"message": message,
}
)
return events
except Exception as exc:
self.output(f"读取 Tcpip 系统事件失败: {exc}\n", "warning")
return []
def get_neighbors(self) -> list[dict]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerAddress,State | ConvertTo-Json -Depth 4 -Compress
"""
try:
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=10)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip())
data = extract_json(result.stdout)
if isinstance(data, dict):
data = [data]
return [normalize_neighbor(item) for item in data]
except Exception as exc:
self.output(f"PowerShell 邻居表读取失败,尝试 arp -a: {exc}\n", "warning")
return self.get_neighbors_from_arp()
def get_neighbors_from_arp(self) -> list[dict]:
result = run_hidden(["arp", "-a"], timeout=10)
if result.returncode != 0:
return []
neighbors = []
current_interface = ""
for line in result.stdout.splitlines():
header = re.search(r"Interface:\s+([^\s]+)", line, re.IGNORECASE)
if header:
current_interface = header.group(1)
continue
match = re.match(r"\s*(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-]+)\s+(\w+)", line)
if match:
neighbors.append(
{
"ifIndex": "",
"interface_ip": current_interface,
"ip": match.group(1),
"mac": normalize_mac(match.group(2)),
"state": match.group(3),
}
)
return neighbors
def stop_detection(self) -> None:
if not self.is_running():
raise RuntimeError("当前没有正在运行的 IP 冲突检测")
self.stop_event.set()
self.output("\n正在停止 IP 冲突检测...\n", "warning")
def export_results(self, path: str) -> None:
if not self.last_results:
raise RuntimeError("还没有可导出的 IP 冲突检测结果")
fields = [
"adapter",
"ip",
"mac",
"conflict_macs",
"evidence_type",
"risk_level",
"verdict",
"address_state",
"local_mac",
"observed_macs",
"tcpip_event_count",
"gateway_mac_changes",
"ip_mac_changes",
"shared_mac_ips",
"scanned_hosts",
"notes",
"checked_at",
]
with open(path, "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for row in self.last_results:
writer.writerow({field_name: row.get(field_name, "") for field_name in fields})
def copy_summary(self) -> str:
return self.last_summary
def normalize_options(self, options: Optional[dict]) -> IpConflictOptions:
options = options or {}
mode = options.get("mode", MODE_BOTH)
if mode not in {MODE_BOTH, MODE_LOCAL, MODE_SUBNET}:
mode = MODE_BOTH
return IpConflictOptions(
mode=mode,
scan_range=str(options.get("scan_range", "")).strip(),
workers=clamp_int(options.get("workers", 64), 1, 256, "并发数"),
timeout_ms=clamp_int(options.get("timeout_ms", 500), 100, 10000, "超时"),
max_hosts=clamp_int(options.get("max_hosts", 254), 1, 254, "最大扫描地址数"),
neighbor_samples=clamp_int(options.get("neighbor_samples", 3), 1, 5, "邻居表采样次数"),
)
def _select_adapters(self, adapter_name: str) -> list[dict]:
adapters = self._active_adapters(self.network.get_network_info())
if not adapter_name or adapter_name == ALL_ADAPTERS:
return adapters
return [adapter for adapter in adapters if adapter.get("name") == adapter_name]
def _active_adapters(self, adapters: list[dict]) -> list[dict]:
active = []
for adapter in adapters:
if not adapter.get("ipv4"):
continue
status = str(adapter.get("status", "")).lower()
if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")):
continue
active.append(adapter)
return active
def _status(self, state: str, total: int, current: str, max_risk_rank: int, total_targets: int, completed: int, elapsed: float) -> dict:
return {
"state": state,
"total_adapters": total,
"current": current,
"max_risk": risk_from_rank(max_risk_rank),
"total_targets": total_targets,
"completed": completed,
"elapsed": elapsed,
}
def is_running(self) -> bool:
return bool(self.worker and self.worker.is_alive())
def normal_result(adapter: dict, verdict: str) -> IpConflictResult:
evidence = IpConflictEvidence(address_state="", local_mac=normalize_mac(adapter.get("mac", "")), notes="未发现冲突证据")
return IpConflictResult(
adapter=adapter.get("name", ""),
ip=adapter.get("ipv4", ""),
mac=normalize_mac(adapter.get("mac", "")),
conflict_macs="",
evidence_type="本机 IP",
risk_level="正常",
verdict=verdict,
evidence=evidence,
)
def filter_neighbors(samples: list[list[dict]], interface_index: str, interface_ip: str) -> list[dict]:
rows = []
for sample in samples:
for item in sample:
if item.get("ifIndex"):
if str(item.get("ifIndex")) == str(interface_index):
rows.append(item)
elif item.get("interface_ip") == interface_ip:
rows.append(item)
return rows
def build_neighbor_maps(neighbors: list[dict]) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
ip_to_macs: dict[str, set[str]] = {}
mac_to_ips: dict[str, set[str]] = {}
for item in neighbors:
ip = item.get("ip", "")
mac = normalize_mac(item.get("mac", ""))
if not ip or not mac or is_special_mac(mac):
continue
ip_to_macs.setdefault(ip, set()).add(mac)
mac_to_ips.setdefault(mac, set()).add(ip)
return ip_to_macs, mac_to_ips
def parse_target_range(text: str, max_hosts: int = 254) -> list[str]:
raw = text.strip()
if not raw:
return []
targets = []
for part in re.split(r"[,;\s]+", raw):
item = part.strip()
if not item:
continue
if "/" in item:
network = ipaddress.ip_network(item, strict=False)
targets.extend(str(ip) for ip in network.hosts())
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}$", item):
prefix, tail = item.rsplit(".", 1)
start_text, end_text = tail.split("-", 1)
start = int(start_text)
end = int(end_text)
if start > end:
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(f"{prefix}.{value}")) for value in range(start, end + 1))
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}(?:\.\d{1,3}){3}$", item):
start_text, end_text = item.split("-", 1)
start_ip = ipaddress.ip_address(start_text)
end_ip = ipaddress.ip_address(end_text)
if start_ip.version != end_ip.version:
raise ValueError("IP 范围两端必须是同一 IP 版本")
if int(start_ip) > int(end_ip):
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(value)) for value in range(int(start_ip), int(end_ip) + 1))
else:
targets.append(str(ipaddress.ip_address(item)))
return list(dict.fromkeys(targets))[:max_hosts]
def adapter_to_safe_range(adapter: dict) -> str:
ip = adapter.get("ipv4", "")
if not ip:
return ""
prefix = adapter.get("prefix_length")
if prefix in ("", None):
prefix = netmask_to_prefix(adapter.get("netmask", "")) or 24
try:
prefix = int(prefix)
if prefix < 24:
prefix = 24
network = ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
return str(network)
except Exception:
parts = ip.split(".")
return ".".join(parts[:3]) + ".0/24" if len(parts) == 4 else ""
def netmask_to_prefix(netmask: str) -> Optional[int]:
if not netmask:
return None
try:
return ipaddress.IPv4Network(f"0.0.0.0/{netmask}").prefixlen
except Exception:
return None
def event_count_for_ip(events: list[dict], ip: str) -> int:
return len([event for event in events if ip and ip in event.get("message", "")])
def build_summary(rows: list[dict], stopped: bool = False) -> str:
if stopped:
return "检测已停止,当前结果仅代表已完成部分。"
if not rows:
return "没有生成 IP 冲突检测结果。"
ordered = sorted(rows, key=lambda row: risk_rank(row.get("risk_level", "正常")), reverse=True)
highest = ordered[0]
conflict = [row for row in ordered if row.get("risk_level") == "冲突风险"]
suspicious = [row for row in ordered if row.get("risk_level") == "可疑"]
lines = [
"==== IP 冲突诊断摘要 ====",
f"最高风险: {highest.get('adapter', '')} {highest.get('ip', '')} {highest.get('risk_level', '')}",
"结论基于本机 Windows 可见证据,建议结合交换机 MAC 地址表和现场设备确认。",
]
if conflict:
lines.append(f"发现 {len(conflict)} 条冲突风险。")
for row in conflict[:5]:
lines.append(f"{row['adapter']} {row['ip']}: {row['verdict']}")
elif suspicious:
lines.append(f"发现 {len(suspicious)} 条可疑信号,建议复测或结合交换机侧信息确认。")
for row in suspicious[:5]:
lines.append(f"{row['adapter']} {row['ip']}: {row['verdict']}")
else:
lines.append("整体正常,未发现本机 IP 被其它 MAC 占用、同 IP 多 MAC 或 Tcpip 冲突事件。")
return "\n".join(lines)
def normalize_neighbor(item: dict) -> dict:
return {
"ifIndex": str(item.get("ifIndex", "")),
"interface_ip": "",
"ip": str(item.get("IPAddress", "")),
"mac": normalize_mac(item.get("LinkLayerAddress", "")),
"state": str(item.get("State", "")),
}
def extract_json(text: str):
output = text.strip()
json_start = min([idx for idx in (output.find("["), output.find("{")) if idx >= 0], default=-1)
if json_start < 0:
raise RuntimeError("未获取到 JSON 输出")
return json.loads(output[json_start:])
def is_special_mac(mac: str) -> bool:
if not mac or mac == "00-00-00-00-00-00":
return True
return mac.startswith("FF-FF-FF") or mac.startswith("01-00-5E")
def is_multicast_or_broadcast_ip(ip: str) -> bool:
try:
parsed = ipaddress.ip_address(ip)
return parsed.is_multicast or str(parsed).endswith(".255")
except ValueError:
return False
def worse_risk(left: str, right: str) -> str:
return left if risk_rank(left) >= risk_rank(right) else right
def risk_rank(level: str) -> int:
return {"正常": 0, "可疑": 1, "冲突风险": 2}.get(level, 0)
def risk_from_rank(value: int) -> str:
if value >= 2:
return "冲突风险"
if value == 1:
return "可疑"
return "正常"
def clamp_int(value, min_value: int, max_value: int, label: str) -> int:
try:
number = int(str(value).strip())
except ValueError as exc:
raise ValueError(f"{label}必须是整数") from exc
if number < min_value or number > max_value:
raise ValueError(f"{label}必须在 {min_value}-{max_value} 之间")
return number