"""OLT 设备管理 API""" from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.orm import Session from sqlalchemy import distinct from pydantic import BaseModel from app.core.database import get_db from app.core.config import settings from app.middleware.permission_middleware import require_permission 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" region: str = "城区" location: str = "" description: str = "" class OLTEdit(BaseModel): username: str password: str = None slot_command: str = "display onu slot" region: str = "城区" location: str = "" description: str = "" @router.get("/regions") def get_olt_regions( db: Session = Depends(get_db), current: dict = Depends(require_permission('olt.view')), ): """获取 OLT 设备的所有区域(受角色数据范围限制)""" query = db.query(distinct(OLTDevice.region)).filter( OLTDevice.region.isnot(None), OLTDevice.region != '' ) if current.get('role') == 'area_admin' and current.get('assigned_area'): areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()] if areas: query = query.filter(OLTDevice.region.in_(areas)) else: return [] return sorted([r[0] for r in query.all()]) @router.get("/devices") def get_devices( db: Session = Depends(get_db), current: dict = Depends(require_permission('olt.view')), ): q = db.query(OLTDevice) # 区域管理员只能看自己区域的 OLT if current.get('role') == 'area_admin' and current.get('assigned_area'): areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()] if areas: q = q.filter(OLTDevice.region.in_(areas)) else: return [] return q.all() @router.post("/devices") def create_device( device: OLTCreate, db: Session = Depends(get_db), current: dict = Depends(require_permission('olt.manage')), ): # 区域管理员只能创建自己区域的 OLT if current.get('role') == 'area_admin' and current.get('assigned_area'): areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()] if device.region not in areas: raise HTTPException(status_code=403, detail="只能管理本区域的 OLT") 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), current: dict = Depends(require_permission('olt.manage')), ): db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first() if not db_device: raise HTTPException(status_code=404, detail="设备不存在") # 区域管理员只能管理自己区域的 OLT if current.get('role') == 'area_admin' and current.get('assigned_area'): areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()] if db_device.region not in areas: raise HTTPException(status_code=403, detail="只能管理本区域的 OLT") db_device.username = device.username if device.password: db_device.password = device.password db_device.slot_command = device.slot_command db_device.region = device.region 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), current: dict = Depends(require_permission('olt.manage')), ): 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="设备不存在") if current.get('role') == 'area_admin' and current.get('assigned_area'): areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()] if device.region not in areas: raise HTTPException(status_code=403, detail="只能管理本区域的 OLT") 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), _: dict = Depends(require_permission('olt.manage')), ): 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', region=str(row['区域']).strip() if '区域' in df.columns and str(row['区域']) != 'nan' else '城区', 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 import os # __file__ is at /app/api/v1/olt.py → go up 4 levels to reach base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) path = os.path.join(base, "templates", "OLT设备导入模板.xlsx") return FileResponse(path=path, filename="OLT设备导入模板.xlsx") @router.get("/duplicate-macs") def get_duplicate_macs( olt_id: int = None, db: Session = Depends(get_db), _: dict = Depends(require_permission('olt.view')), ): """查询重复 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), _: dict = Depends(require_permission('olt.manage')), ): """删除重复 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), _: dict = Depends(require_permission('olt.manage')), ): """通过 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="端口不在重复记录中") try: with SSHService(olt.ip_address, olt.username, olt.password) as ssh: ssh.clear_onu_port(body.port_id) except Exception as e: raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}") # 从 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), _: dict = Depends(require_permission('olt.view')), ): """查询新发现的设备列表(待补全信息)""" 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), _: dict = Depends(require_permission('olt.manage')), ): """补全新设备信息,完成后从 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), _: dict = Depends(require_permission('olt.manage')), ): """忽略新设备(不补全信息,仅从待处理列表移除)""" 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), _: dict = Depends(require_permission('olt.discover')), ): """多线程对所有 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), _: dict = Depends(require_permission('olt.loopback')), ): """对所有 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): try: with SSHService(olt.ip_address, olt.username, olt.password) as ssh: 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": [], } 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, "raw": detection.get("raw", ""), } 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 SyncNTPRequest(BaseModel): old_server: str = settings.NTP_OLD_SERVER new_server: str = settings.NTP_NEW_SERVER @router.post("/sync-ntp") def sync_ntp( body: SyncNTPRequest, db: Session = Depends(get_db), current: dict = Depends(require_permission('olt.manage')), ): """对所有 OLT 并发执行 NTP 时间服务器同步""" from app.services.ssh_service import SSHService from concurrent.futures import ThreadPoolExecutor, as_completed olts = db.query(OLTDevice).all() if current.get('role') == 'area_admin' and current.get('assigned_area'): areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()] olts = [o for o in olts if o.region in areas] if areas else [] def sync_one(olt): try: with SSHService(olt.ip_address, olt.username, olt.password) as ssh: ssh.sync_ntp(body.old_server, body.new_server) return { "olt_ip": olt.ip_address, "olt_location": olt.location or olt.ip_address, "success": True, "error": None, } except Exception as e: return { "olt_ip": olt.ip_address, "olt_location": olt.location or olt.ip_address, "success": False, "error": str(e), } results_map = {} with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor: futures = {executor.submit(sync_one, olt): olt.id for olt in olts} for future in as_completed(futures): r = future.result() results_map[r["olt_ip"]] = r results = [results_map[olt.ip_address] for olt in olts] success_count = sum(1 for r in results if r["success"]) return { "total": len(results), "success": success_count, "failed": len(results) - success_count, "results": results, } 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), _: dict = Depends(require_permission('olt.port_manage')), ): """获取指定 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 不存在") try: with SSHService(olt.ip_address, olt.username, olt.password) as ssh: ports = ssh.get_olt_ports() return {"ports": ports} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/devices/{olt_id}/ports/toggle") def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db), _: dict = Depends(require_permission('olt.port_manage'))): """开启或关闭指定 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 不存在") try: with SSHService(olt.ip_address, olt.username, olt.password) as ssh: 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))