Files
H3ConuMS-v2/backend/app/api/v1/olt.py
T
v6ole dfa8fa62a8 ```
feat(api): 添加OLT端口管理和设备发现功能

- 在check.py中更新discover_olt方法,使用scan_and_discover替代check_olt_devices
- 在olt.py中添加get_olt_ports和toggle_olt_port接口,支持获取和控制OLT端口状态
- 添加TogglePortRequest模型用于端口操作请求验证
- 更新SSHService类,新增get_olt_ports和toggle_olt_port方法
- 优化CheckService中的MAC地址索引逻辑,支持模型信息更新
```
2026-04-04 22:13:22 +08:00

446 lines
16 KiB
Python

"""OLT 设备管理 API"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.database import get_db
from app.models.device import OLTDevice
import pandas as pd
import io
router = APIRouter(prefix="/api/olt", tags=["OLT设备"])
class OLTCreate(BaseModel):
ip_address: str
username: str
password: str
slot_command: str = "display onu slot"
location: str = ""
description: str = ""
class OLTEdit(BaseModel):
username: str
password: str = None
slot_command: str = "display onu slot"
location: str = ""
description: str = ""
@router.get("/devices")
def get_devices(db: Session = Depends(get_db)):
return db.query(OLTDevice).all()
@router.post("/devices")
def create_device(device: OLTCreate, db: Session = Depends(get_db)):
db_device = OLTDevice(**device.dict())
db.add(db_device)
db.commit()
return {"message": "创建成功"}
@router.put("/devices/{ip_address}")
def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db)):
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not db_device:
raise HTTPException(status_code=404, detail="设备不存在")
db_device.username = device.username
if device.password:
db_device.password = device.password
db_device.slot_command = device.slot_command
db_device.location = device.location
db_device.description = device.description
db.commit()
return {"message": "更新成功"}
@router.delete("/devices/{ip_address}")
def delete_device(ip_address: str, db: Session = Depends(get_db)):
from app.models.device import ONUDevice
device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
# 检查是否有关联的 ONU 设备
onu_count = db.query(ONUDevice).filter(ONUDevice.olt_id == device.id).count()
if onu_count > 0:
raise HTTPException(status_code=400, detail=f"该 OLT 设备下还有 {onu_count} 个 ONU 设备,无法删除")
db.delete(device)
db.commit()
return {"message": "删除成功"}
@router.post("/import")
async def import_devices(file: UploadFile = File(...), db: Session = Depends(get_db)):
try:
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
# 标准化列名
df.columns = [str(c).strip() for c in df.columns]
except Exception as e:
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
required_cols = ['IP地址', '用户名', '密码']
missing = [c for c in required_cols if c not in df.columns]
if missing:
raise HTTPException(status_code=400, detail=f"缺少必填列: {', '.join(missing)},当前列: {', '.join(df.columns.tolist())}")
success, failed = 0, []
for idx, row in df.iterrows():
try:
ip = str(row['IP地址']).strip()
if not ip or ip == 'nan':
continue
existing = db.query(OLTDevice).filter(OLTDevice.ip_address == ip).first()
if existing:
failed.append({"row": idx + 2, "ip": ip, "reason": "IP 已存在"})
continue
device = OLTDevice(
ip_address=ip,
username=str(row['用户名']).strip(),
password=str(row['密码']).strip(),
slot_command=str(row['槽位命令']).strip() if '槽位命令' in df.columns and str(row['槽位命令']) != 'nan' else 'display onu slot',
location=str(row['安装位置']).strip() if '安装位置' in df.columns and str(row['安装位置']) != 'nan' else '',
description=str(row['描述']).strip() if '描述' in df.columns and str(row['描述']) != 'nan' else '',
)
db.add(device)
success += 1
except Exception as e:
failed.append({"row": idx + 2, "ip": str(row.get('IP地址', '')), "reason": str(e)})
db.commit()
return {"message": f"成功导入 {success} 条记录", "success": success, "failed": failed}
@router.get("/template")
def download_template():
from fastapi.responses import FileResponse
return FileResponse(
path="/home/v6ole/pyproject/H3ConuMS2/backend/templates/OLT设备导入模板.xlsx",
filename="OLT设备导入模板.xlsx"
)
@router.get("/duplicate-macs")
def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
"""查询重复 MAC 地址记录"""
from app.models.device import DuplicateMac
query = db.query(DuplicateMac)
if olt_id:
query = query.filter(DuplicateMac.olt_id == olt_id)
records = query.order_by(DuplicateMac.last_seen_at.desc()).all()
return [
{
"id": r.id,
"olt_id": r.olt_id,
"mac_address": r.mac_address,
"ports": r.ports,
"first_seen_at": r.first_seen_at,
"last_seen_at": r.last_seen_at,
}
for r in records
]
@router.delete("/duplicate-macs/{record_id}")
def delete_duplicate_mac(record_id: int, db: Session = Depends(get_db)):
"""删除重复 MAC 记录(已处理后清除)"""
from app.models.device import DuplicateMac
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(record)
db.commit()
return {"message": "已删除"}
class ClearPortRequest(BaseModel):
port_id: str # 如 "1/0/1:3"
@router.post("/duplicate-macs/{record_id}/clear-port")
def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends(get_db)):
"""通过 SSH 清除指定端口的 ONU 配置,并从 ports 列表中移除该端口"""
from app.models.device import DuplicateMac
from app.services.ssh_service import SSHService
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
olt = db.query(OLTDevice).filter(OLTDevice.id == record.olt_id).first()
if not olt:
raise HTTPException(status_code=404, detail="OLT 设备不存在")
# 验证 port_id 在记录中
port_ids = [p["port_id"] for p in (record.ports or [])]
if body.port_id not in port_ids:
raise HTTPException(status_code=400, detail="端口不在重复记录中")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ssh.clear_onu_port(body.port_id)
except Exception as e:
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
finally:
ssh.close()
# 从 ports 列表移除已清除的端口
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
if remaining:
record.ports = remaining
else:
# 所有端口都清除了,删除整条记录
db.delete(record)
db.commit()
return {"message": f"端口 Onu{body.port_id} 已清除", "remaining_ports": remaining}
@router.get("/new-devices")
def get_new_devices(db: Session = Depends(get_db)):
"""查询新发现的设备列表(待补全信息)"""
from app.models.device import NewDevice, ONUDevice
rows = (
db.query(NewDevice, ONUDevice)
.join(ONUDevice, NewDevice.onu_device_id == ONUDevice.id)
.all()
)
return [
{
"id": nd.id,
"onu_device_id": nd.onu_device_id,
"olt_id": nd.olt_id,
"discovered_at": nd.discovered_at,
"mac_address": onu.mac_address,
"port_id": f"{onu.slot_number}/{onu.port_number}" if onu.slot_number else None,
"loid": onu.loid,
"model": onu.model,
"region": onu.region,
"school_name": onu.school_name,
"building": onu.building,
"room_number": onu.room_number,
}
for nd, onu in rows
]
class NewDeviceUpdate(BaseModel):
region: str
school_name: str
building: str = ""
place_type: str = ""
room_number: str = ""
notes: str = ""
@router.put("/new-devices/{record_id}")
def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depends(get_db)):
"""补全新设备信息,完成后从 new_devices 移除"""
from app.models.device import NewDevice, ONUDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
onu = db.query(ONUDevice).filter(ONUDevice.id == record.onu_device_id).first()
if not onu:
raise HTTPException(status_code=404, detail="ONU 设备不存在")
onu.region = body.region
onu.school_name = body.school_name
onu.building = body.building or None
onu.place_type = body.place_type or None
onu.room_number = body.room_number or None
onu.notes = body.notes or None
db.delete(record)
db.commit()
return {"message": "信息已补全"}
@router.delete("/new-devices/{record_id}")
def dismiss_new_device(record_id: int, db: Session = Depends(get_db)):
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
from app.models.device import NewDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(record)
db.commit()
return {"message": "已忽略"}
@router.post("/quick-scan")
def quick_scan(db: Session = Depends(get_db)):
"""多线程对所有 OLT 同时执行扫描,更新已有设备状态"""
from app.services.check_service import CheckService
from concurrent.futures import ThreadPoolExecutor, as_completed
olts = db.query(OLTDevice).all()
def scan_one(olt):
from app.core.database import SessionLocal
thread_db = SessionLocal()
try:
service = CheckService(thread_db)
result = service.scan_and_discover(olt.id)
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
"online": result.get("online", 0), "offline": result.get("offline", 0),
"new_discovered": result.get("new_discovered", 0),
"error": None}
except Exception as e:
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
"online": 0, "offline": 0, "new_discovered": 0, "error": str(e)}
finally:
thread_db.close()
olt_results = {}
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
futures = {executor.submit(scan_one, olt): olt.id for olt in olts}
for future in as_completed(futures):
r = future.result()
olt_results[r["olt_id"]] = r
results = []
errors = []
total_online = 0
total_offline = 0
total_new = 0
for olt in olts:
r = olt_results.get(olt.id, {})
if r.get("error"):
errors.append({"olt_location": r["olt_location"], "error": r["error"]})
else:
total_online += r.get("online", 0)
total_offline += r.get("offline", 0)
total_new += r.get("new_discovered", 0)
results.append({
"olt_location": r.get("olt_location", olt.location or olt.ip_address),
"online": r.get("online", 0),
"offline": r.get("offline", 0),
"new_discovered": r.get("new_discovered", 0),
"success": not r.get("error"),
"error": r.get("error"),
})
return {
"total_online": total_online,
"total_offline": total_offline,
"total_new": total_new,
"results": results,
"errors": errors,
}
@router.post("/loopback-detection")
def loopback_detection(db: Session = Depends(get_db)):
"""对所有 OLT 并发执行环路检测,返回有环路的端口及对应设备信息"""
from app.models.device import ONUDevice
from app.services.ssh_service import SSHService
from concurrent.futures import ThreadPoolExecutor, as_completed
olts = db.query(OLTDevice).all()
# 预加载所有 ONU 设备,按 (olt_id, port_id) 索引,避免多线程操作 Session
all_onus = db.query(ONUDevice).all()
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
def check_one(olt):
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
detection = ssh.detect_loopback()
except Exception as e:
return {
"olt_id": olt.id,
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"error": str(e),
"has_loop": False,
"loop_interfaces": [],
}
finally:
ssh.close()
loop_interfaces = []
for iface in detection.get("interfaces", []):
port_id = iface.removeprefix("Onu")
onu = onu_map.get((olt.id, port_id))
loop_interfaces.append({
"interface": iface,
"port_id": port_id,
"mac_address": onu.mac_address if onu else None,
"region": onu.region if onu else None,
"school_name": onu.school_name if onu else None,
"building": onu.building if onu else None,
"room_number": onu.room_number if onu else None,
})
return {
"olt_id": olt.id,
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"has_loop": detection["has_loop"],
"loop_interfaces": loop_interfaces,
"error": None,
}
results_map = {}
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
futures = {executor.submit(check_one, olt): olt.id for olt in olts}
for future in as_completed(futures):
olt_id = futures[future]
results_map[olt_id] = future.result()
# 按原始顺序返回
return [results_map[olt.id] for olt in olts]
class TogglePortRequest(BaseModel):
action: str # "shutdown" 或 "undo shutdown"
@router.get("/devices/{olt_id}/ports")
def get_olt_ports(olt_id: int, db: Session = Depends(get_db)):
"""获取指定 OLT 的所有 Olt 端口状态"""
from app.services.ssh_service import SSHService
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise HTTPException(status_code=404, detail="OLT 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ports = ssh.get_olt_ports()
return {"ports": ports}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()
@router.post("/devices/{olt_id}/ports/toggle")
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db)):
"""开启或关闭指定 OLT 端口"""
from app.services.ssh_service import SSHService
if body.action not in ("shutdown", "undo shutdown"):
raise HTTPException(status_code=400, detail="action 必须为 shutdown 或 undo shutdown")
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise HTTPException(status_code=404, detail="OLT 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ssh.toggle_olt_port(port_name, body.action)
return {"message": f"端口 {port_name}{'关闭' if body.action == 'shutdown' else '开启'}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()