"""设备管理 API""" import csv import io import logging from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy import select, func, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_db from app.core.auth import get_current_user, require_admin from app.models.device import Device, DeviceTypeEnum from app.models.user import User from app.schemas.device import DeviceCreate, DeviceUpdate, DeviceOut logger = logging.getLogger("pingwatch.devices") router = APIRouter(prefix="/api/devices", tags=["设备管理"]) # CSV 模板列 IMPORT_COLUMNS = [ ("name", "设备名称(必填)"), ("ip", "IP地址(必填)"), ("device_type", "设备类型(server/olt/switch/firewall/other)"), ("location", "物理位置"), ("project_name", "所属项目"), ("tags", "标签(逗号分隔)"), ("ping_interval", "Ping间隔(秒)"), ("alert_threshold", "告警阈值(次)"), ] @router.get("", response_model=list[DeviceOut]) async def list_devices( is_enabled: Optional[bool] = None, device_type: Optional[str] = None, search: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """获取设备列表,支持过滤和搜索""" query = select(Device) if is_enabled is not None: query = query.where(Device.is_enabled == is_enabled) if device_type: query = query.where(Device.device_type == device_type) if search: like = f"%{search}%" query = query.where( Device.name.ilike(like) | Device.ip.ilike(like) | Device.location.ilike(like) ) query = query.order_by(Device.id) result = await db.execute(query) return list(result.scalars().all()) @router.get("/{device_id}", response_model=DeviceOut) async def get_device( device_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute(select(Device).where(Device.id == device_id)) device = result.scalar_one_or_none() if not device: raise HTTPException(status_code=404, detail="设备不存在") return device @router.post("", response_model=DeviceOut) async def create_device( data: DeviceCreate, db: AsyncSession = Depends(get_db), admin: User = Depends(require_admin), ): device = Device(**data.model_dump()) db.add(device) await db.commit() await db.refresh(device) return device @router.put("/{device_id}", response_model=DeviceOut) async def update_device( device_id: int, data: DeviceUpdate, db: AsyncSession = Depends(get_db), admin: User = Depends(require_admin), ): result = await db.execute(select(Device).where(Device.id == device_id)) device = result.scalar_one_or_none() if not device: raise HTTPException(status_code=404, detail="设备不存在") update_data = data.model_dump(exclude_unset=True) if update_data: update_data["updated_at"] = datetime.now() await db.execute(update(Device).where(Device.id == device_id).values(**update_data)) await db.commit() await db.refresh(device) return device @router.delete("/{device_id}") async def delete_device( device_id: int, db: AsyncSession = Depends(get_db), admin: User = Depends(require_admin), ): result = await db.execute(select(Device).where(Device.id == device_id)) device = result.scalar_one_or_none() if not device: raise HTTPException(status_code=404, detail="设备不存在") await db.delete(device) await db.commit() return {"message": "删除成功"} @router.get("/template/download") async def download_template( admin: User = Depends(require_admin), ): """下载 CSV 导入模板""" output = io.StringIO() writer = csv.writer(output) writer.writerow([col[1] for col in IMPORT_COLUMNS]) # 写入一行示例数据 writer.writerow([ "示例设备", "192.168.1.1", "server", "机房A", "项目1", "核心,生产", "30", "5" ]) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv; charset=utf-8-sig", headers={"Content-Disposition": "attachment; filename=device_template.csv"}, ) @router.post("/import") async def import_devices( file: UploadFile = File(...), db: AsyncSession = Depends(get_db), admin: User = Depends(require_admin), ): """批量导入设备(CSV)""" if not file.filename or not file.filename.endswith(".csv"): raise HTTPException(status_code=400, detail="请上传 .csv 文件") content = await file.read() # 处理 BOM text = content.decode("utf-8-sig") reader = csv.reader(io.StringIO(text)) rows = list(reader) if len(rows) < 2: raise HTTPException(status_code=400, detail="CSV 至少需要表头行 + 一行数据") devices_added = 0 errors = [] for i, row in enumerate(rows[1:], start=1): if not any(cell.strip() for cell in row): continue # 跳过空行 if len(row) < 2: errors.append(f"第 {i + 1} 行: 缺少必填列") continue name = row[0].strip() if len(row) > 0 else "" ip = row[1].strip() if len(row) > 1 else "" if not name or not ip: errors.append(f"第 {i + 1} 行: 设备名称和 IP 地址为必填") continue # 检查 IP 是否重复 existing = await db.execute(select(Device).where(Device.ip == ip)) if existing.scalar_one_or_none(): errors.append(f"第 {i + 1} 行: IP {ip} 已存在") continue device_type = row[2].strip().lower() if len(row) > 2 and row[2].strip() else "other" if device_type not in (e.value for e in DeviceTypeEnum): device_type = "other" device = Device( name=name, ip=ip, device_type=device_type, location=row[3].strip() if len(row) > 3 else "", project_name=row[4].strip() if len(row) > 4 else "", tags=row[5].strip() if len(row) > 5 else "", ping_interval=int(row[6]) if len(row) > 6 and row[6].strip().isdigit() else 30, alert_threshold=int(row[7]) if len(row) > 7 and row[7].strip().isdigit() else 5, ) db.add(device) devices_added += 1 await db.commit() result = {"devices_added": devices_added} if errors: result["errors"] = errors logger.info(f"批量导入完成: 成功 {devices_added} 台, 错误 {len(errors)} 条") return result