初始化
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""认证 API"""
|
||||
import base64
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.casdoor import casdoor_sdk
|
||||
from app.core.security import create_access_token
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import Token, UserInfo
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict:
|
||||
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
|
||||
payload_b64 = token.split(".")[1]
|
||||
rem = len(payload_b64) % 4
|
||||
if rem:
|
||||
payload_b64 += "=" * (4 - rem)
|
||||
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login():
|
||||
"""获取 Casdoor 登录 URL"""
|
||||
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
|
||||
|
||||
|
||||
class CallbackRequest(BaseModel):
|
||||
code: str
|
||||
state: str
|
||||
|
||||
|
||||
@router.post("/callback", response_model=Token)
|
||||
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
||||
"""Casdoor 登录回调"""
|
||||
try:
|
||||
token_response = casdoor_sdk.get_oauth_token(code=body.code)
|
||||
if isinstance(token_response, dict) and "error" in token_response:
|
||||
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
|
||||
|
||||
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
|
||||
|
||||
casdoor_user = decode_jwt_payload(access_token)
|
||||
|
||||
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
|
||||
if not user:
|
||||
user = User(
|
||||
casdoor_id=casdoor_user["sub"],
|
||||
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
|
||||
email=casdoor_user.get("email"),
|
||||
role="user"
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
jwt_token = create_access_token({"sub": str(user.id), "username": user.username})
|
||||
return {"access_token": jwt_token}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserInfo)
|
||||
def get_profile(token: str, db: Session = Depends(get_db)):
|
||||
"""获取当前用户信息"""
|
||||
from app.core.security import verify_token
|
||||
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="无效的令牌")
|
||||
|
||||
user = db.query(User).filter(User.id == int(payload["sub"])).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""状态检查 API"""
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from celery.result import AsyncResult
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.tasks.check_tasks import check_all_devices
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import get_db
|
||||
from app.services.check_service import CheckService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
||||
|
||||
|
||||
class CheckResult(BaseModel):
|
||||
olt_id: int
|
||||
olt_name: str
|
||||
online: Optional[int] = 0
|
||||
offline: Optional[int] = 0
|
||||
success: bool
|
||||
|
||||
|
||||
class CheckError(BaseModel):
|
||||
olt_id: int
|
||||
olt_name: str
|
||||
error: str
|
||||
|
||||
|
||||
@router.post("/status")
|
||||
def trigger_check():
|
||||
"""手动触发状态检查"""
|
||||
try:
|
||||
task = check_all_devices.delay()
|
||||
return {"task_id": task.id, "status": "started"}
|
||||
except Exception as e:
|
||||
logger.error(f"触发状态检查失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/status/{task_id}")
|
||||
def get_check_status(task_id: str):
|
||||
"""查询状态检查任务进度和结果"""
|
||||
task_result = AsyncResult(task_id, app=celery_app)
|
||||
state = task_result.state
|
||||
|
||||
result = {
|
||||
"task_id": task_id,
|
||||
"status": state,
|
||||
"progress": None,
|
||||
"result": None
|
||||
}
|
||||
|
||||
if state == 'PROGRESS':
|
||||
result["progress"] = task_result.info
|
||||
|
||||
if state == 'SUCCESS':
|
||||
result["result"] = task_result.result
|
||||
elif state == 'FAILURE':
|
||||
result["error"] = str(task_result.info)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/scan/{olt_id}")
|
||||
def scan_olt(olt_id: int, db: Session = Depends(get_db)):
|
||||
"""扫描单台 OLT,预览发现的设备(不写入数据库)"""
|
||||
try:
|
||||
service = CheckService(db)
|
||||
result = asyncio.run(service.scan_olt(olt_id))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/discover/{olt_id}")
|
||||
def discover_olt(olt_id: int, db: Session = Depends(get_db)):
|
||||
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
|
||||
try:
|
||||
service = CheckService(db)
|
||||
result = asyncio.run(service.check_olt_devices(olt_id))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,231 @@
|
||||
"""设备管理 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.models.device import ONUDevice, DeviceStatusHistory, OLTDevice
|
||||
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, # online / offline
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取设备列表"""
|
||||
# 子查询:每台设备最新一条状态记录
|
||||
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)
|
||||
|
||||
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)):
|
||||
"""获取所有区域列表"""
|
||||
regions = db.query(distinct(ONUDevice.region)).filter(
|
||||
ONUDevice.region.isnot(None),
|
||||
ONUDevice.region != ''
|
||||
).order_by(ONUDevice.region).all()
|
||||
return [r[0] for r in regions]
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||
def get_device(device_id: int, db: Session = Depends(get_db)):
|
||||
"""获取设备详情"""
|
||||
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)):
|
||||
"""通过 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
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@router.delete("/status/all")
|
||||
def clear_all_status(db: Session = Depends(get_db)):
|
||||
"""清空所有设备状态历史记录"""
|
||||
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)):
|
||||
"""更新设备信息(区域、学校、楼宇、房间号、备注)"""
|
||||
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.notes = body.notes or None
|
||||
db.commit()
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""数据导入 API"""
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.services.import_service import ImportService
|
||||
import shutil
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["数据导入"])
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template():
|
||||
"""下载导入数据模板"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ONU设备导入模板"
|
||||
|
||||
# 表头:序号|区域|学校名称|楼宇|场所类型|房间号|MAC地址|备注
|
||||
headers = ["mac_address", "region", "school_name", "building", "place_type", "room_number", "notes"]
|
||||
ws.append(headers)
|
||||
|
||||
# 示例数据
|
||||
example_data = [
|
||||
["AA:BB:CC:DD:EE:01", "区域1", "学校1", "1号楼", "教室", "101", ""],
|
||||
["AA:BB:CC:DD:EE:02", "区域1", "学校1", "1号楼", "办公室", "102", ""],
|
||||
]
|
||||
for row in example_data:
|
||||
ws.append(row)
|
||||
|
||||
# 设置列宽
|
||||
ws.column_dimensions['A'].width = 20 # mac_address
|
||||
ws.column_dimensions['B'].width = 12 # region
|
||||
ws.column_dimensions['C'].width = 18 # school_name
|
||||
ws.column_dimensions['D'].width = 12 # building
|
||||
ws.column_dimensions['E'].width = 12 # place_type
|
||||
ws.column_dimensions['F'].width = 12 # room_number
|
||||
ws.column_dimensions['G'].width = 20 # notes
|
||||
|
||||
# 保存到内存
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=onu_import_template.xlsx"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_excel(
|
||||
file: UploadFile = File(...),
|
||||
olt_id: int = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)"""
|
||||
file_path = f"/tmp/{file.filename}"
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
service = ImportService(db)
|
||||
records = service.parse_excel(file_path)
|
||||
validation = service.validate_data(records)
|
||||
|
||||
success_count = 0
|
||||
if validation['valid']:
|
||||
result = service.import_devices(validation['valid'], olt_id)
|
||||
success_count = result['success']
|
||||
|
||||
return {
|
||||
"success": success_count,
|
||||
"failed": validation['invalid']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""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]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""业务下发 API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from app.core.database import get_db
|
||||
from app.services.provision_service import ProvisionService
|
||||
|
||||
router = APIRouter(prefix="/api/provision", tags=["业务下发"])
|
||||
|
||||
|
||||
class ProvisionRequest(BaseModel):
|
||||
device_id: int
|
||||
|
||||
|
||||
class BatchProvisionRequest(BaseModel):
|
||||
device_ids: List[int]
|
||||
|
||||
|
||||
class ProvisionResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
device_id: Optional[int] = None
|
||||
mac_address: Optional[str] = None
|
||||
olt_ip: Optional[str] = None
|
||||
port: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/service", response_model=ProvisionResponse)
|
||||
def provision_single_device(
|
||||
request: ProvisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""下发业务到单个设备"""
|
||||
service = ProvisionService(db)
|
||||
result = service.provision_device(request.device_id)
|
||||
|
||||
if result.get("success"):
|
||||
return ProvisionResponse(
|
||||
success=True,
|
||||
message=f"业务下发成功,MAC: {result['mac_address']}, 端口: {result['port']}",
|
||||
device_id=result["device_id"],
|
||||
mac_address=result["mac_address"],
|
||||
olt_ip=result.get("olt_ip"),
|
||||
port=result.get("port")
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "业务下发失败"))
|
||||
|
||||
|
||||
@router.post("/batch", response_model=dict)
|
||||
def provision_batch_devices(
|
||||
request: BatchProvisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""批量下发业务"""
|
||||
service = ProvisionService(db)
|
||||
result = service.batch_provision(request.device_ids)
|
||||
return result
|
||||
@@ -0,0 +1,118 @@
|
||||
"""统计 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, case
|
||||
from app.core.database import get_db
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["统计"])
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def get_dashboard(db: Session = Depends(get_db)):
|
||||
"""仪表板统计:总体、城区、城郊、乡镇在线率"""
|
||||
# 每台设备最新状态子查询
|
||||
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_status_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
DeviceStatusHistory.status
|
||||
)
|
||||
.join(
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.region,
|
||||
ONUDevice.school_name,
|
||||
func.count().label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.group_by(ONUDevice.region, ONUDevice.school_name)
|
||||
.order_by(ONUDevice.region, ONUDevice.school_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
overall = {"total": 0, "online": 0, "offline": 0}
|
||||
urban_schools, suburban_schools = [], []
|
||||
rural_towns = {}
|
||||
|
||||
for row in rows:
|
||||
region = row.region or ""
|
||||
total = int(row.total or 0)
|
||||
online = int(row.online or 0)
|
||||
offline = int(row.offline or 0)
|
||||
overall["total"] += total
|
||||
overall["online"] += online
|
||||
overall["offline"] += offline
|
||||
|
||||
school_stat = {"name": row.school_name or "未知", "total": total, "online": online, "offline": offline}
|
||||
|
||||
if region == "城区":
|
||||
urban_schools.append(school_stat)
|
||||
elif region == "城郊":
|
||||
suburban_schools.append(school_stat)
|
||||
else:
|
||||
if region not in rural_towns:
|
||||
rural_towns[region] = {"region": region, "total": 0, "online": 0, "offline": 0, "schools": []}
|
||||
rural_towns[region]["total"] += total
|
||||
rural_towns[region]["online"] += online
|
||||
rural_towns[region]["offline"] += offline
|
||||
rural_towns[region]["schools"].append(school_stat)
|
||||
|
||||
def sort_by_rate(items):
|
||||
return sorted(items, key=lambda x: x["online"] / x["total"] if x["total"] > 0 else 0)
|
||||
|
||||
def agg(items):
|
||||
return {"total": sum(s["total"] for s in items), "online": sum(s["online"] for s in items), "offline": sum(s["offline"] for s in items)}
|
||||
|
||||
rural_list = sort_by_rate(list(rural_towns.values()))
|
||||
|
||||
return {
|
||||
"overall": overall,
|
||||
"urban": {**agg(urban_schools), "schools": sort_by_rate(urban_schools)},
|
||||
"suburban": {**agg(suburban_schools), "schools": sort_by_rate(suburban_schools)},
|
||||
"rural": {**agg(list(rural_towns.values())), "towns": rural_list},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def get_summary(db: Session = Depends(get_db)):
|
||||
"""获取统计摘要"""
|
||||
total = db.query(ONUDevice).count()
|
||||
latest_status = db.query(
|
||||
DeviceStatusHistory.status,
|
||||
func.count(DeviceStatusHistory.id)
|
||||
).group_by(DeviceStatusHistory.status).all()
|
||||
status_dict = dict(latest_status)
|
||||
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
|
||||
|
||||
|
||||
@router.get("/trend")
|
||||
def get_trend(days: int = 7, db: Session = Depends(get_db)):
|
||||
"""获取状态趋势数据"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
history = db.query(
|
||||
func.date(DeviceStatusHistory.checked_at).label('date'),
|
||||
func.sum(func.case((DeviceStatusHistory.status == 'online', 1), else_=0)).label('online'),
|
||||
func.sum(func.case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label('offline')
|
||||
).filter(DeviceStatusHistory.checked_at >= start_date).group_by(
|
||||
func.date(DeviceStatusHistory.checked_at)
|
||||
).all()
|
||||
return [{"date": str(h.date), "online": h.online, "offline": h.offline} for h in history]
|
||||
|
||||
Reference in New Issue
Block a user