"""设备管理 API""" from fastapi import APIRouter, Depends, Query, HTTPException 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 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, 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 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("/{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 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 # 若该设备 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) # 同时删除对应的空白 ONU 记录,避免设备列表出现重复 MAC if conflict_new_onu_ids: 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 ]