初始化

This commit is contained in:
2026-04-02 23:40:04 +08:00
commit 381ea7085d
107 changed files with 11858 additions and 0 deletions
View File
+263
View File
@@ -0,0 +1,263 @@
"""设备状态检查服务"""
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice
from datetime import datetime
import re
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
if not distance_str:
return None
m = re.search(r'\d+', distance_str)
return int(m.group()) if m else None
class CheckService:
"""设备状态检查服务"""
def __init__(self, db: Session):
self.db = db
def update_status_only(self, olt_id: int) -> Dict:
"""扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。
不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。
"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, _ = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
# 全局 MAC → id 索引,只取需要的字段
existing = {
row.mac_address.lower(): row.id
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
}
for mac, onu_info in onu_info_dict.items():
onu_id = existing.get(mac)
if onu_id is None:
continue # 不在库中,跳过
status = onu_info.status
distance_m = parse_distance(onu_info.distance_str)
if status == 'online':
online_count += 1
else:
offline_count += 1
self.db.add(DeviceStatusHistory(
onu_device_id=onu_id,
status=status,
distance_m=distance_m,
checked_at=checked_at,
response_data=None,
))
self.db.commit()
return {
"online": online_count,
"offline": offline_count,
}
finally:
ssh.close()
def check_single_device(self, device_id: int) -> Dict:
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
命令格式: display onu slot {slot} | include {mac}
"""
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise Exception("设备不存在")
if not device.olt_id:
raise Exception("该设备未关联 OLT,无法查询")
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
if not olt:
raise Exception("关联的 OLT 不存在")
mac = device.mac_address.lower()
base_cmd = olt.slot_command # e.g. "display onu slot"
if device.slot_number is not None:
cmd = f"{base_cmd} {device.slot_number} | include {mac}"
else:
cmd = f"{base_cmd} | include {mac}"
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(cmd)
# 从输出中找到包含该 MAC 的行并解析
info = None
for line in output.splitlines():
if mac in line.lower():
info = ssh._parse_device_line(line, device.slot_number)
if info:
break
if info is None:
# 未找到该 MAC,视为离线
status = 'offline'
distance_m = None
else:
status = info.status
distance_m = parse_distance(info.distance_str)
self.db.add(DeviceStatusHistory(
onu_device_id=device.id,
status=status,
distance_m=distance_m,
checked_at=datetime.utcnow(),
response_data=None,
))
self.db.commit()
return {"status": status, "distance_m": distance_m}
finally:
ssh.close()
def scan_and_discover(self, olt_id: int) -> Dict:
"""扫描 OLT,更新已有设备状态,并将新发现的在线设备入库。
不标记 unknown,不修改已有设备的 olt_id/端口以外的字段。
"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
new_count = 0
existing = {
row.mac_address.lower(): row.id
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
}
for mac, onu_info in onu_info_dict.items():
onu_id = existing.get(mac)
if onu_id is None:
# 新设备:只入库在线的
if onu_info.status != 'online':
continue
onu = ONUDevice(
mac_address=mac,
olt_id=olt_id,
slot_number=onu_info.slot_number,
port_number=onu_info.port_number,
port_id=onu_info.port_id,
distance_m=parse_distance(onu_info.distance_str),
loid=onu_info.loid,
model=onu_info.model,
)
self.db.add(onu)
self.db.flush()
self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id))
onu_id = onu.id
new_count += 1
status = onu_info.status
distance_m = parse_distance(onu_info.distance_str)
if status == 'online':
online_count += 1
else:
offline_count += 1
self.db.add(DeviceStatusHistory(
onu_device_id=onu_id,
status=status,
distance_m=distance_m,
checked_at=checked_at,
response_data=None,
))
self._save_duplicate_macs(olt_id, duplicate_dict)
self.db.commit()
return {
"online": online_count,
"offline": offline_count,
"new_discovered": new_count,
}
finally:
ssh.close()
async def scan_olt(self, olt_id: int) -> Dict:
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
# 对比全局 MAC
existing_macs = {
onu.mac_address.lower()
for onu in self.db.query(ONUDevice.mac_address).all()
}
devices = []
for mac, info in onu_info_dict.items():
devices.append({
"mac_address": mac,
"status": info.status,
"distance_m": info.distance_str,
"slot_number": info.slot_number,
"port_number": info.port_number,
"port_id": info.port_id,
"loid": info.loid,
"model": info.model,
"is_new": mac not in existing_macs,
})
duplicates = []
for mac, records in duplicate_dict.items():
duplicates.append({
"mac_address": mac,
"ports": [{"port_id": r.port_id, "status": r.status} for r in records],
})
return {
"olt_id": olt_id,
"olt_ip": olt.ip_address,
"total": len(devices),
"new": sum(1 for d in devices if d["is_new"]),
"devices": devices,
"duplicates": duplicates,
}
finally:
ssh.close()
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
for mac, records in duplicate_dict.items():
ports = [{"port_id": r.port_id, "status": r.status} for r in records]
existing = self.db.query(DuplicateMac).filter(
DuplicateMac.olt_id == olt_id,
DuplicateMac.mac_address == mac
).first()
if existing:
existing.ports = ports
existing.last_seen_at = datetime.utcnow()
else:
self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports))
+117
View File
@@ -0,0 +1,117 @@
"""Excel 导入服务"""
import pandas as pd
from typing import List, Dict
from sqlalchemy.orm import Session
from app.models.device import ONUDevice
import math
# 定义字段映射:Excel列名 -> 数据库字段名
FIELD_MAPPING = {
'mac_address': 'mac_address',
'region': 'region',
'school_name': 'school_name',
'building': 'building',
'place_type': 'place_type',
'room_number': 'room_number',
'notes': 'notes',
}
def clean_value(value) -> str:
"""清理单元格值,处理NaN和None"""
if value is None:
return ''
if isinstance(value, float) and math.isnan(value):
return ''
return str(value).strip()
class ImportService:
def __init__(self, db: Session):
self.db = db
def parse_excel(self, file_path: str) -> List[Dict]:
"""解析 Excel 文件"""
df = pd.read_excel(file_path)
# 标准化列名(去除空格,转小写)
df.columns = [col.strip().lower() for col in df.columns]
# 转换为记录列表
records = []
for _, row in df.iterrows():
record = {}
for col_name, db_field in FIELD_MAPPING.items():
if col_name in row:
record[db_field] = clean_value(row[col_name])
records.append(record)
return records
def validate_data(self, records: List[Dict]) -> Dict:
"""验证数据"""
valid = []
invalid = []
for idx, record in enumerate(records):
mac = record.get('mac_address', '').strip()
if not mac:
invalid.append({'record': record, 'error': f'{idx+2}行: MAC地址缺失'})
continue
# 标准化MAC地址:统一使用横杠分隔小写格式
# 支持格式:AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF, aa:bb:cc:dd:ee:ff
mac_clean = mac.upper().replace(':', '-')
# 验证基本格式:12个十六进制字符(可能有分隔符)
hex_chars = mac_clean.replace('-', '')
if len(hex_chars) != 12 or not all(c in '0123456789ABCDEF' for c in hex_chars):
invalid.append({'record': record, 'error': f'{idx+2}行: MAC地址格式错误 "{mac}"'})
continue
# 转换为标准格式 34dc-99c8-56e0(小写4位分组)
mac_formatted = '-'.join([hex_chars[i:i+4].lower() for i in range(0, 12, 4)])
record['mac_address'] = mac_formatted
valid.append(record)
return {'valid': valid, 'invalid': invalid}
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
"""批量导入设备,存在则更新,不存在则新增"""
success_count = 0
skip_count = 0
invalid_count = 0
for record in records:
mac = record.get('mac_address', '')
if not mac:
skip_count += 1
continue
# 查询是否已存在该 MAC 地址
existing = self.db.query(ONUDevice).filter(ONUDevice.mac_address == mac).first()
if existing:
# 更新现有记录
existing.region = record.get('region', '')
existing.school_name = record.get('school_name', '')
existing.building = record.get('building') or None
existing.place_type = record.get('place_type') or None
existing.room_number = record.get('room_number') or None
existing.notes = record.get('notes') or None
success_count += 1
else:
# 新增记录
device = ONUDevice(
mac_address=mac,
olt_id=olt_id,
region=record.get('region', ''),
school_name=record.get('school_name', ''),
building=record.get('building') or None,
place_type=record.get('place_type') or None,
room_number=record.get('room_number') or None,
notes=record.get('notes') or None
)
self.db.add(device)
success_count += 1
self.db.commit()
return {'success': success_count}
+114
View File
@@ -0,0 +1,114 @@
"""业务下发服务"""
from typing import Dict
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
from app.models.device import ONUDevice, OLTDevice
import time
class ProvisionService:
"""业务下发服务"""
def __init__(self, db: Session):
self.db = db
def provision_device(self, device_id: int) -> Dict[str, any]:
"""
业务下发流程(参照下发业务流程.md):
1. system-view
2. interface Onu{port_id}
3. uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
4. port link-type trunk
5. undo port trunk permit vlan 1
6. port trunk permit vlan 2000 3000 to 3010 4094
7. save force
"""
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
return {"success": False, "error": f"设备不存在: {device_id}"}
if not device.olt_id:
return {"success": False, "error": "设备未关联 OLT,请先进行扫描"}
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
if not olt:
return {"success": False, "error": "关联的 OLT 设备不存在"}
if not device.port_id:
return {"success": False, "error": "设备端口信息不完整,请先对 OLT 执行扫描"}
port_name = f"Onu{device.port_id}"
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
ssh.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if ssh.shell.recv_ready():
buf += ssh.shell.recv(4096).decode("utf-8", errors="ignore")
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
# 进入系统视图
out = send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
# 进入端口
out = send_and_wait(f"interface {port_name}", "]")
if "]" not in out:
raise Exception(f"进入端口 {port_name} 失败")
# 配置 VLAN
send_and_wait("uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010", "]")
send_and_wait("port link-type trunk", "]")
send_and_wait("undo port trunk permit vlan 1", "]")
send_and_wait("port trunk permit vlan 2000 3000 to 3010 4094", "]")
# 保存配置(等待 "successfully" 出现)
save_out = send_and_wait("save force", "successfully", timeout=30)
if "successfully" not in save_out:
raise Exception("save force 未确认成功,请检查 OLT 日志")
return {
"success": True,
"device_id": device_id,
"mac_address": device.mac_address,
"olt_ip": olt.ip_address,
"port": port_name,
}
except Exception as e:
return {
"success": False,
"device_id": device_id,
"mac_address": device.mac_address,
"error": str(e),
}
finally:
ssh.close()
def batch_provision(self, device_ids: list) -> Dict[str, any]:
results = []
success_count = 0
fail_count = 0
for device_id in device_ids:
result = self.provision_device(device_id)
results.append(result)
if result.get("success"):
success_count += 1
else:
fail_count += 1
return {
"total": len(device_ids),
"success": success_count,
"failed": fail_count,
"results": results,
}
+282
View File
@@ -0,0 +1,282 @@
"""SSH 连接服务"""
import paramiko
import re
import time
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from app.core.config import settings
@dataclass
class ONUInfo:
"""ONU 设备完整信息"""
mac_address: str
status: str # online/offline
distance_m: Optional[int] = None # 距离(米)
distance_str: Optional[str] = None # 距离原始字符串,如 "<1000"
slot_number: Optional[int] = None # 插槽号
port_number: Optional[int] = None # 端口号
port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1"
loid: Optional[str] = None # LOID
model: Optional[str] = None # 设备型号
class SSHService:
"""SSH 连接和命令执行服务"""
def __init__(self, host: str, username: str, password: str, port: int = 22):
self.host = host
self.username = username
self.password = password
self.port = port
self.client: Optional[paramiko.SSHClient] = None
self.shell = None
def connect(self) -> bool:
"""建立 SSH 连接,等待初始 banner 输出完毕"""
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host,
port=self.port,
username=self.username,
password=self.password,
timeout=30
)
self.shell = self.client.invoke_shell(width=200, height=50)
# 等待登录 banner 输出完毕,直到出现命令提示符 ">"
deadline = time.time() + 10
buf = ""
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if ">" in buf:
break
else:
time.sleep(0.2)
return True
except Exception as e:
raise Exception(f"SSH 连接失败: {str(e)}")
def execute_command(self, command: str) -> str:
"""执行命令并处理 More 分页,等待命令提示符出现后返回"""
if not self.shell:
raise Exception("SSH 未连接")
self.shell.send(command + "\n")
output = ""
# 等待命令回显出现,再开始收集输出
time.sleep(0.5)
deadline = time.time() + 60
while time.time() < deadline:
if self.shell.recv_ready():
chunk = self.shell.recv(4096).decode('utf-8', errors='ignore')
output += chunk
if "---- More ----" in chunk:
self.shell.send(" ")
time.sleep(0.3)
elif ">" in chunk:
# 命令提示符出现,说明输出完毕
break
else:
time.sleep(0.2)
return output
def clear_onu_port(self, port_id: str) -> bool:
"""清除指定端口的 ONU 配置(恢复默认)
流程: system-view -> interface Onu{port_id} -> default -> Y
"""
if not self.shell:
raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
# 进入系统视图
out = send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
# 进入端口
out = send_and_wait(f"interface Onu{port_id}", "]")
if "]" not in out:
raise Exception(f"进入端口 Onu{port_id} 失败")
# 执行 default,等待确认提示
self.shell.send("default\n")
buf = ""
deadline = time.time() + 10
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if "[Y/N]" in buf or "[y/n]" in buf:
break
else:
time.sleep(0.2)
if "[Y/N]" not in buf and "[y/n]" not in buf:
raise Exception("未收到确认提示")
# 确认
self.shell.send("Y\n")
time.sleep(1)
# 排空缓冲区
if self.shell.recv_ready():
self.shell.recv(4096)
# 退出到用户视图
send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5)
return True
def detect_loopback(self) -> dict:
"""执行环路检测,返回 {has_loop: bool, interfaces: [str]}"""
output = self.execute_command("display loopback-detection")
has_loop = "Loop is detected on following interfaces" in output
interfaces = []
if has_loop:
for line in output.splitlines():
m = re.match(r'\s+(Onu\S+)\s+', line)
if m:
interfaces.append(m.group(1))
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
def parse_onu_status(self, output: str) -> Dict[str, str]:
"""解析 ONU 状态输出"""
devices = {}
lines = output.split('\n')
for line in lines:
# 匹配包含 MAC 地址的行
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
# 提取状态字段
if 'Up' in line:
devices[mac] = 'online'
elif 'Offline' in line:
devices[mac] = 'offline'
return devices
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
"""增强解析:提取完整 ONU 信息
返回: (unique_devices, duplicate_devices)
- unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口)
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
"""
all_records: Dict[str, List[ONUInfo]] = {}
lines = output.split('\n')
current_slot = None
for line in lines:
# 检测新的插槽区域: Olt1/0/1
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
if slot_match:
current_slot = int(slot_match.group(3))
continue
# 跳过表头行和空行
if 'MAC' in line and 'LOID' in line:
continue
if not line.strip():
continue
# 解析设备行
device = self._parse_device_line(line, current_slot)
if device:
if device.mac_address not in all_records:
all_records[device.mac_address] = []
all_records[device.mac_address].append(device)
unique_devices: Dict[str, ONUInfo] = {}
duplicate_devices: Dict[str, List[ONUInfo]] = {}
for mac, records in all_records.items():
if len(records) == 1:
unique_devices[mac] = records[0]
else:
duplicate_devices[mac] = records
# unique 中保留在线的,若都离线则保留最后一条
online = [r for r in records if r.status == 'online']
unique_devices[mac] = online[0] if online else records[-1]
return unique_devices, duplicate_devices
def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]:
"""解析单行设备信息"""
# 匹配 MAC 地址
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if not mac_match:
return None
mac = mac_match.group(1).lower()
# 提取状态
status = 'offline'
if 'Up' in line:
status = 'online'
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
slot_num, port_num, port_id = None, None, None
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
if port_match:
slot_num = int(port_match.group(3)) # 第三段数字为槽位
port_num = int(port_match.group(4)) # 冒号后为端口号
port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}"
# 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A"
distance_str = None
dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line)
if dist_match:
val = dist_match.group(1)
if val != 'N/A':
distance_str = val
# 提取 LOID - MAC 后第一个非空字段
loid = None
loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE)
if loid_match:
loid_val = loid_match.group(1)
if loid_val != 'N/A' and loid_val.isdigit():
loid = loid_val
# 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A"
model = None
model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line)
if model_match:
potential_model = model_match.group(1)
if potential_model != 'N/A':
model = potential_model
return ONUInfo(
mac_address=mac,
status=status,
distance_m=None,
distance_str=distance_str,
slot_number=slot_num or slot,
port_number=port_num,
port_id=port_id,
loid=loid,
model=model
)
def close(self):
"""关闭 SSH 连接"""
if self.client:
self.client.close()