"""设备管理 API""" import csv import io from fastapi import APIRouter, Depends, Query, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload from sqlalchemy import asc, desc, distinct, or_ from pydantic import BaseModel from typing import Optional from app.core.database import get_db from app.middleware.permission_middleware import require_permission from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse router = APIRouter(prefix="/api/devices", tags=["设备管理"]) @router.get("", response_model=DeviceListResponse) def get_devices( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), region: str = None, school_name: str = None, keyword: str = None, status: str = None, tag: str = None, db: Session = Depends(get_db), current: dict = Depends(require_permission('device.view')), ): """获取设备列表""" # 子查询:每台设备最新一条状态记录 from sqlalchemy import func latest_subq = ( db.query( DeviceStatusHistory.onu_device_id, func.max(DeviceStatusHistory.checked_at).label("max_checked_at") ) .group_by(DeviceStatusHistory.onu_device_id) .subquery() ) latest_history = ( db.query(DeviceStatusHistory) .join( latest_subq, (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) & (DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at) ) .subquery() ) query = db.query(ONUDevice) # 数据范围过滤:区域管理员只能看自己分配的区域,学校管理员只能看自己分配的学校 role = current.get('role', 'user') if role == 'area_admin': assigned = current.get('assigned_area') or '' areas = [a.strip() for a in assigned.split(',') if a.strip()] if areas: query = query.filter(ONUDevice.region.in_(areas)) else: # 未分配区域则看不到任何设备 query = query.filter(False) elif role == 'school_admin': assigned = current.get('assigned_school') or '' schools = [s.strip() for s in assigned.split(',') if s.strip()] if schools: query = query.filter(ONUDevice.school_name.in_(schools)) else: query = query.filter(False) if region: query = query.filter(ONUDevice.region == region) if school_name: query = query.filter(ONUDevice.school_name.contains(school_name)) if tag: query = query.filter(ONUDevice.tags.contains(tag)) if keyword: query = query.filter( or_( ONUDevice.mac_address.contains(keyword.lower()), ONUDevice.building.contains(keyword), ONUDevice.place_type.contains(keyword), ONUDevice.school_name.contains(keyword), ONUDevice.region.contains(keyword), ONUDevice.room_number.contains(keyword), ) ) if status in ("online", "offline", "unknown"): if status in ("online", "offline"): query = query.join( latest_history, ONUDevice.id == latest_history.c.onu_device_id ).filter(latest_history.c.status == status) else: # unknown:最新状态为 unknown,或没有任何状态记录 query = query.outerjoin( latest_history, ONUDevice.id == latest_history.c.onu_device_id ).filter( or_( latest_history.c.onu_device_id == None, latest_history.c.status == "unknown" ) ) # 多级排序:区域 > 学校名称 > 楼宇 > 房间号(均为升序) query = query.order_by( asc(ONUDevice.region), asc(ONUDevice.school_name), asc(ONUDevice.building), asc(ONUDevice.room_number) ) total = query.count() items = query.offset(skip).limit(limit).all() # 获取每个设备最新的状态(批量,避免 N+1) device_ids = [item.id for item in items] history_map = {} if device_ids: histories = ( db.query(DeviceStatusHistory) .join( latest_subq, (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) & (DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at) ) .filter(DeviceStatusHistory.onu_device_id.in_(device_ids)) .all() ) history_map = {h.onu_device_id: h for h in histories} # 批量加载 OLT 信息 olt_ids = {item.olt_id for item in items if item.olt_id} olt_map = {} if olt_ids: olts = db.query(OLTDevice).filter(OLTDevice.id.in_(olt_ids)).all() olt_map = {o.id: o for o in olts} result_items = [] for item in items: latest_status = history_map.get(item.id) olt = olt_map.get(item.olt_id) item_dict = { "id": item.id, "mac_address": item.mac_address, "olt_id": item.olt_id, "region": item.region, "school_name": item.school_name, "building": item.building, "place_type": item.place_type, "room_number": item.room_number, "notes": item.notes, "status": latest_status.status if latest_status else None, "distance_m": latest_status.distance_m if latest_status else None, "slot_number": item.slot_number, "port_number": item.port_number, "port_id": item.port_id, "model": item.model, "olt_location": olt.location if olt else None, "created_at": item.created_at } result_items.append(ONUDeviceResponse(**item_dict)) return {"total": total, "items": result_items} @router.get("/regions") def get_regions( db: Session = Depends(get_db), current: dict = Depends(require_permission('device.view')), ): """获取所有区域列表(受角色数据范围限制)""" role = current.get('role', 'user') query = db.query(distinct(ONUDevice.region)).filter( ONUDevice.region.isnot(None), ONUDevice.region != '' ) if role == 'area_admin': assigned = current.get('assigned_area') or '' areas = [a.strip() for a in assigned.split(',') if a.strip()] if areas: query = query.filter(ONUDevice.region.in_(areas)) else: return [] elif role == 'school_admin': assigned = current.get('assigned_school') or '' schools = [s.strip() for s in assigned.split(',') if s.strip()] if schools: query = query.filter(ONUDevice.school_name.in_(schools)) else: return [] return [r[0] for r in query.order_by(ONUDevice.region).all()] @router.get("/schools") def get_schools( region: str = None, db: Session = Depends(get_db), _: dict = Depends(require_permission('device.view')), ): """获取所有学校列表(可按区域筛选)""" query = db.query(distinct(ONUDevice.school_name)).filter( ONUDevice.school_name.isnot(None), ONUDevice.school_name != '' ) if region: query = query.filter(ONUDevice.region == region) return [r[0] for r in query.order_by(ONUDevice.school_name).all()] @router.get("/replacements") def get_all_replacements( region: Optional[str] = Query(None), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), keyword: Optional[str] = Query(None), skip: int = Query(0, ge=0), limit: int = Query(200, ge=1, le=1000), db: Session = Depends(get_db), current: dict = Depends(require_permission('device.view')), ): """获取全量设备更换记录(带位置信息),支持筛选""" from datetime import datetime query = ( db.query(DeviceReplacement, ONUDevice) .join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id) ) 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(ONUDevice.region.in_(areas)) else: return {"total": 0, "items": []} if region: query = query.filter(ONUDevice.region == region) if start_date: query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date)) if end_date: query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59')) if keyword: kw = f'%{keyword}%' query = query.filter(or_( ONUDevice.school_name.ilike(kw), ONUDevice.region.ilike(kw), DeviceReplacement.old_mac.ilike(kw), DeviceReplacement.new_mac.ilike(kw), DeviceReplacement.operator_name.ilike(kw), )) total = query.count() rows = query.order_by(DeviceReplacement.replaced_at.desc()).offset(skip).limit(limit).all() return { "total": total, "items": [ { "id": r.id, "replaced_at": r.replaced_at, "old_mac": r.old_mac, "new_mac": r.new_mac, "reason": r.reason, "operator_name": r.operator_name, "region": d.region, "school_name": d.school_name, "building": d.building, "room_number": d.room_number, "onu_device_id": r.onu_device_id, } for r, d in rows ], } @router.get("/replacements/export") def export_replacements( region: Optional[str] = Query(None), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), keyword: Optional[str] = Query(None), db: Session = Depends(get_db), current: dict = Depends(require_permission('device.view')), ): """导出更换记录为 CSV""" import csv, io from datetime import datetime, timedelta from fastapi.responses import StreamingResponse query = ( db.query(DeviceReplacement, ONUDevice) .join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id) ) 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(ONUDevice.region.in_(areas)) else: query = query.filter(False) if region: query = query.filter(ONUDevice.region == region) if start_date: query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date)) if end_date: query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59')) if keyword: kw = f'%{keyword}%' query = query.filter(or_( ONUDevice.school_name.ilike(kw), ONUDevice.region.ilike(kw), DeviceReplacement.old_mac.ilike(kw), DeviceReplacement.new_mac.ilike(kw), DeviceReplacement.operator_name.ilike(kw), )) rows = query.order_by(DeviceReplacement.replaced_at.desc()).all() output = io.StringIO() writer = csv.writer(output) writer.writerow(['更换时间(北京)', '区域', '学校', '楼宇', '房间', '旧MAC', '新MAC', '更换原因', '操作人']) for r, d in rows: bj_time = (r.replaced_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S') if r.replaced_at else '' writer.writerow([ bj_time, d.region or '', d.school_name or '', d.building or '', d.room_number or '', r.old_mac, r.new_mac, r.reason or '', r.operator_name or '', ]) output.seek(0) filename = f"replacement_records_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv" return StreamingResponse( iter([output.getvalue().encode('utf-8-sig')]), media_type='text/csv', headers={'Content-Disposition': f'attachment; filename="{filename}"'}, ) @router.get("/tags") def get_all_tags(db: Session = Depends(get_db)): """获取所有不重复的设备标签""" from sqlalchemy import func as _func rows = db.query(ONUDevice.tags).filter( ONUDevice.tags.isnot(None), ONUDevice.tags != '' ).all() tags = set() for (tag_str,) in rows: for t in tag_str.split(','): t = t.strip() if t: tags.add(t) return sorted(tags) @router.get("/export/csv") def export_devices_csv( region: Optional[str] = Query(None), school_name: Optional[str] = Query(None), db: Session = Depends(get_db), _: dict = Depends(require_permission('device.view')), ): """导出设备列表为 CSV""" from sqlalchemy import func as _func query = db.query(ONUDevice) if region: query = query.filter(ONUDevice.region == region) if school_name: query = query.filter(ONUDevice.school_name == school_name) devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all() output = io.StringIO() writer = csv.writer(output) writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"]) for d in devices: writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "", d.place_type or "", d.room_number or "", d.port_id or "", d.model or "", d.loid or "", d.distance_m or "", d.notes or ""]) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=onu_devices.csv"} ) @router.get("/{device_id}", response_model=ONUDeviceResponse) def get_device( device_id: int, db: Session = Depends(get_db), _: dict = Depends(require_permission('device.view')), ): """获取设备详情""" device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() if not device: from fastapi import HTTPException raise HTTPException(status_code=404, detail="设备不存在") latest_status = db.query(DeviceStatusHistory).filter( DeviceStatusHistory.onu_device_id == device.id ).order_by(desc(DeviceStatusHistory.checked_at)).first() olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() if device.olt_id else None return ONUDeviceResponse( id=device.id, mac_address=device.mac_address, olt_id=device.olt_id, region=device.region, school_name=device.school_name, building=device.building, place_type=device.place_type, room_number=device.room_number, notes=device.notes, status=latest_status.status if latest_status else None, distance_m=latest_status.distance_m if latest_status else None, slot_number=device.slot_number, port_number=device.port_number, port_id=device.port_id, model=device.model, olt_location=olt.location if olt else None, created_at=device.created_at ) @router.post("/{device_id}/refresh") def refresh_device_status( device_id: int, db: Session = Depends(get_db), _: dict = Depends(require_permission('device.check')), ): """通过 SSH 单独更新一台设备的状态和距离""" from app.services.check_service import CheckService try: service = CheckService(db) result = service.check_single_device(device_id) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) class DeviceUpdate(BaseModel): region: Optional[str] = None school_name: Optional[str] = None building: Optional[str] = None room_number: Optional[str] = None place_type: Optional[str] = None notes: Optional[str] = None tags: Optional[str] = None class DeviceReplaceRequest(BaseModel): new_mac: str reason: Optional[str] = None @router.delete("/status/all") def clear_all_status( db: Session = Depends(get_db), _: dict = Depends(require_permission('device.delete')), ): """清空所有设备状态历史记录""" db.query(DeviceStatusHistory).delete() db.commit() return {"message": "已清空所有设备状态"} @router.put("/{device_id}") def update_device( device_id: int, body: DeviceUpdate, db: Session = Depends(get_db), _: dict = Depends(require_permission('device.edit')), ): """更新设备信息(区域、学校、楼宇、房间号、备注)""" device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() if not device: raise HTTPException(status_code=404, detail="设备不存在") device.region = body.region device.school_name = body.school_name device.building = body.building or None device.room_number = body.room_number or None device.place_type = body.place_type or None device.notes = body.notes or None device.tags = body.tags or None # 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息) from app.models.device import NewDevice dup_new = db.query(NewDevice).join( ONUDevice, NewDevice.onu_device_id == ONUDevice.id ).filter(ONUDevice.mac_address == device.mac_address).all() for nd in dup_new: db.delete(nd) db.commit() return {"message": "更新成功"} @router.post("/{device_id}/replace") def replace_device( device_id: int, body: DeviceReplaceRequest, db: Session = Depends(get_db), current: dict = Depends(require_permission('device.edit')), ): """更换设备 MAC 地址,并记录更换历史""" from datetime import datetime device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() if not device: raise HTTPException(status_code=404, detail="设备不存在") new_mac_raw = body.new_mac.strip() # 接受 xxxx-xxxx-xxxx、xx:xx:xx:xx:xx:xx、xxxxxxxxxxxx 三种格式,统一标准化为小写 xxxx-xxxx-xxxx import re hex_only = re.sub(r'[:\-]', '', new_mac_raw).lower() if not re.match(r'^[0-9a-f]{12}$', hex_only): raise HTTPException(status_code=400, detail="MAC 地址格式不正确,支持 xxxx-xxxx-xxxx、xx:xx:xx:xx:xx:xx 或 xxxxxxxxxxxx") new_mac = f"{hex_only[0:4]}-{hex_only[4:8]}-{hex_only[8:12]}" # 同时匹配大小写和各种分隔符格式,兼容数据库旧数据 hex_variants = [ new_mac, hex_only, ':'.join(hex_only[i:i+2] for i in range(0, 12, 2)), '-'.join(hex_only[i:i+2] for i in range(0, 12, 2)), new_mac.upper(), hex_only.upper(), ':'.join(hex_only[i:i+2].upper() for i in range(0, 12, 2)), '-'.join(hex_only[i:i+2].upper() for i in range(0, 12, 2)), ] # 若新 MAC 在 new_devices 待入库列表中,先删除(更换后该记录已无意义) from app.models.device import NewDevice conflict_news = db.query(NewDevice).join( ONUDevice, NewDevice.onu_device_id == ONUDevice.id ).filter( ONUDevice.mac_address.in_(hex_variants), ONUDevice.id != device_id ).all() conflict_new_onu_ids = {nd.onu_device_id for nd in conflict_news} for nd in conflict_news: db.delete(nd) # 先 flush,让 NewDevice 的 ORM 删除落库,解除对 onu_devices 的外键引用 db.flush() # 同时删除对应的空白 ONU 记录,避免设备列表出现重复 MAC if conflict_new_onu_ids: from app.models.device import DeviceStatusHistory db.query(DeviceStatusHistory).filter( DeviceStatusHistory.onu_device_id.in_(conflict_new_onu_ids) ).delete(synchronize_session=False) db.query(ONUDevice).filter(ONUDevice.id.in_(conflict_new_onu_ids)).delete(synchronize_session=False) db.flush() # 检查新 MAC 是否已被其他 ONU 设备使用(排除刚刚从 new_devices 删除的临时 ONU) existing = db.query(ONUDevice).filter( ONUDevice.mac_address.in_(hex_variants), ONUDevice.id != device_id, ONUDevice.id.notin_(conflict_new_onu_ids) if conflict_new_onu_ids else True, ).first() if existing: raise HTTPException(status_code=400, detail="该 MAC 地址已被其他设备使用") # 同步更新库存序列号设备的 onu_device_id 关联(如有) from app.models.inventory import SerialDevice old_serial = db.query(SerialDevice).filter(SerialDevice.onu_device_id == device_id).first() if old_serial: old_serial.onu_device_id = None old_serial.status = "returned" new_serial = db.query(SerialDevice).filter(SerialDevice.mac_address == new_mac).first() if new_serial: new_serial.onu_device_id = device_id new_serial.status = "in_use" record = DeviceReplacement( onu_device_id=device_id, old_mac=device.mac_address, new_mac=new_mac, reason=body.reason or None, operator_id=current.get("sub", ""), operator_name=current.get("username", ""), replaced_at=datetime.utcnow(), ) db.add(record) device.mac_address = new_mac db.commit() return {"message": "更换成功", "old_mac": record.old_mac, "new_mac": new_mac} @router.get("/{device_id}/replacements") def get_device_replacements( device_id: int, db: Session = Depends(get_db), _: dict = Depends(require_permission('device.view')), ): """获取设备更换历史""" records = db.query(DeviceReplacement).filter( DeviceReplacement.onu_device_id == device_id ).order_by(DeviceReplacement.replaced_at.desc()).all() return [ { "id": r.id, "old_mac": r.old_mac, "new_mac": r.new_mac, "reason": r.reason, "operator_name": r.operator_name, "replaced_at": r.replaced_at, } for r in records ] @router.post("/{device_id}/reboot", response_model=RebootResponse) def reboot_device( device_id: int, db: Session = Depends(get_db), current: dict = Depends(require_permission('device.check')), ): """远程重启 ONU 设备(通过 iMC REST API)""" device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() if not device: raise HTTPException(status_code=404, detail="设备不存在") role = current.get('role', 'user') if role == 'area_admin': areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()] if device.region not in areas: raise HTTPException(status_code=403, detail="无权限操作此区域的设备") elif role == 'school_admin': schools = [s.strip() for s in (current.get('assigned_school') or '').split(',') if s.strip()] if device.school_name not in schools: raise HTTPException(status_code=403, detail="无权限操作此学校的设备") try: from app.services.imc_service import IMCService result = IMCService().reboot_onu(device.mac_address) return RebootResponse(**result) except Exception as e: raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}") @router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse) def get_device_optical_power( device_id: int, db: Session = Depends(get_db), current: dict = Depends(require_permission('device.view')), ): """获取 ONU 设备光功率信息(通过 iMC REST API)""" device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() if not device: raise HTTPException(status_code=404, detail="设备不存在") role = current.get('role', 'user') if role == 'area_admin': areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()] if device.region not in areas: raise HTTPException(status_code=403, detail="无权限操作此区域的设备") elif role == 'school_admin': schools = [s.strip() for s in (current.get('assigned_school') or '').split(',') if s.strip()] if device.school_name not in schools: raise HTTPException(status_code=403, detail="无权限操作此学校的设备") try: from app.services.imc_service import IMCService data = IMCService().get_optical_power(device.mac_address) if data is None: raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应") # 记录光功率历史 try: from app.models.device import OpticalPowerHistory db.add(OpticalPowerHistory( onu_device_id=device_id, power_in=data.get("powerIn"), power_out=data.get("powerOut"), )) db.commit() except Exception: pass return OpticalPowerResponse( power_in=data.get("powerIn"), power_out=data.get("powerOut"), bind_mac=data.get("bindMac"), dev_id=data.get("devId"), epon_dev_name=data.get("eponDevName"), olt_if_name=data.get("oltIfName"), onu_if_desc=data.get("onuIfDesc"), ) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}") @router.get("/{device_id}/onu-events") def get_onu_events( device_id: int, db: Session = Depends(get_db), _: dict = Depends(require_permission('device.view')), ): """查询 ONU 上下线事件记录(SSH 到所属 OLT 执行命令)""" device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() if not device: raise HTTPException(status_code=404, detail="设备不存在") if not device.olt_id: raise HTTPException(status_code=400, detail="该设备未关联 OLT,无法查询") if not device.port_id: raise HTTPException(status_code=400, detail="端口信息缺失,请先更新设备状态") olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() if not olt: raise HTTPException(status_code=404, detail="关联的 OLT 不存在") from app.services.ssh_service import SSHService try: with SSHService(olt.ip_address, olt.username, olt.password) as ssh: events = ssh.get_onu_events(device.port_id) return { "interface": f"Onu{device.port_id}", "olt_location": olt.location, "events": events, } except Exception as e: raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") @router.get("/{device_id}/optical-power-history") def get_optical_power_history( device_id: int, limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), _: dict = Depends(require_permission('device.view')), ): """获取设备光功率历史记录""" from app.models.device import OpticalPowerHistory rows = ( db.query(OpticalPowerHistory) .filter(OpticalPowerHistory.onu_device_id == device_id) .order_by(OpticalPowerHistory.recorded_at.desc()) .limit(limit) .all() ) return [ {"power_in": r.power_in, "power_out": r.power_out, "recorded_at": r.recorded_at.isoformat() if r.recorded_at else None} for r in rows ]