初始化
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]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Casdoor 认证配置"""
|
||||
from casdoor import CasdoorSDK
|
||||
from app.core.config import settings
|
||||
|
||||
casdoor_sdk = CasdoorSDK(
|
||||
endpoint=settings.CASDOOR_ENDPOINT,
|
||||
client_id=settings.CASDOOR_CLIENT_ID,
|
||||
client_secret=settings.CASDOOR_CLIENT_SECRET,
|
||||
certificate=settings.casdoor_cert_content,
|
||||
org_name=settings.CASDOOR_ORG_NAME,
|
||||
application_name=settings.CASDOOR_APP_NAME,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Celery 配置"""
|
||||
from celery import Celery
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"h3c_onu_ms",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
result_serializer='json',
|
||||
accept_content=['json'],
|
||||
timezone='UTC',
|
||||
enable_utc=True,
|
||||
beat_schedule={
|
||||
'check-devices-every-30-minutes': {
|
||||
'task': 'app.tasks.check_tasks.check_all_devices',
|
||||
'schedule': settings.CHECK_INTERVAL,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""应用配置"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "H3C-ONU-MS"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str
|
||||
|
||||
DATABASE_URL: str
|
||||
REDIS_URL: str
|
||||
|
||||
CASDOOR_ENDPOINT: str
|
||||
CASDOOR_CLIENT_ID: str
|
||||
CASDOOR_CLIENT_SECRET: str
|
||||
CASDOOR_ORG_NAME: str
|
||||
CASDOOR_APP_NAME: str
|
||||
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
|
||||
|
||||
SSH_TIMEOUT: int = 30
|
||||
CHECK_INTERVAL: int = 1800
|
||||
MANUAL_COOLDOWN: int = 300
|
||||
|
||||
class Config:
|
||||
env_file = str(PROJECT_ROOT / ".env")
|
||||
|
||||
@property
|
||||
def casdoor_cert_content(self) -> str:
|
||||
"""读取证书文件内容或直接返回证书字符串"""
|
||||
cert = self.CASDOOR_CERTIFICATE
|
||||
if not cert:
|
||||
return ""
|
||||
cert_path = Path(cert)
|
||||
if cert_path.is_absolute():
|
||||
path = cert_path
|
||||
else:
|
||||
# 相对路径基于项目根目录解析
|
||||
path = PROJECT_ROOT / cert
|
||||
if path.is_file():
|
||||
return path.read_text()
|
||||
return cert # 直接是 PEM 内容
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""JWT 安全配置"""
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from app.core.config import settings
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
|
||||
|
||||
|
||||
def create_access_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -0,0 +1,28 @@
|
||||
"""FastAPI 主应用"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(devices.router)
|
||||
app.include_router(check.router)
|
||||
app.include_router(import_data.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(olt.router)
|
||||
app.include_router(provision.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""权限检查中间件"""
|
||||
from fastapi import HTTPException, Depends
|
||||
from app.core.security import verify_token
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
'admin': ['*'],
|
||||
'area_admin': ['device.view', 'device.check'],
|
||||
'school_admin': ['device.view'],
|
||||
'user': ['device.view']
|
||||
}
|
||||
|
||||
|
||||
def check_permission(required_permission: str):
|
||||
def permission_checker(token: str):
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
|
||||
role = payload.get('role', 'user')
|
||||
permissions = ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
if '*' in permissions or required_permission in permissions:
|
||||
return payload
|
||||
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
return permission_checker
|
||||
@@ -0,0 +1,82 @@
|
||||
"""设备数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OLTDevice(Base):
|
||||
__tablename__ = "olt_devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
ip_address = Column(String(45), nullable=False)
|
||||
username = Column(String(100), nullable=False)
|
||||
password = Column(Text, nullable=False)
|
||||
slot_command = Column(String(50), nullable=False)
|
||||
location = Column(String(200))
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ONUDevice(Base):
|
||||
__tablename__ = "onu_devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
mac_address = Column(String(17), nullable=False, index=True)
|
||||
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"))
|
||||
slot_number = Column(Integer)
|
||||
port_number = Column(Integer)
|
||||
port_id = Column(String(20)) # 完整端口标识,如 "1/0/2:4"
|
||||
# OLT 返回的额外信息
|
||||
loid = Column(String(50)) # LOID
|
||||
model = Column(String(100)) # 设备型号
|
||||
distance_m = Column(Integer) # 距离(米)
|
||||
region = Column(String(100), index=True)
|
||||
school_name = Column(String(200), index=True)
|
||||
building = Column(String(100))
|
||||
place_type = Column(String(50)) # 场所类型
|
||||
room_number = Column(String(50))
|
||||
notes = Column(Text) # 备注
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 关联状态历史
|
||||
status_history = relationship("DeviceStatusHistory", back_populates="device", lazy="selectin")
|
||||
|
||||
|
||||
class DeviceStatusHistory(Base):
|
||||
__tablename__ = "device_status_history"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"))
|
||||
status = Column(String(20), nullable=False) # online, offline
|
||||
distance_m = Column(Integer) # 距离(米)
|
||||
checked_at = Column(TIMESTAMP, nullable=False)
|
||||
response_data = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
device = relationship("ONUDevice", back_populates="status_history")
|
||||
|
||||
|
||||
class DuplicateMac(Base):
|
||||
"""重复 MAC 地址记录(同一 MAC 出现在多个端口)"""
|
||||
__tablename__ = "duplicate_macs"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
|
||||
mac_address = Column(String(17), nullable=False, index=True)
|
||||
# 所有出现的端口列表,JSON 格式: [{"port_id": "1/0/1:1", "status": "online"}, ...]
|
||||
ports = Column(JSON, nullable=False)
|
||||
first_seen_at = Column(TIMESTAMP, server_default=func.now())
|
||||
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class NewDevice(Base):
|
||||
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
|
||||
__tablename__ = "new_devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
|
||||
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
|
||||
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""权限数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Text, ForeignKey, Table
|
||||
from app.core.database import Base
|
||||
|
||||
role_permissions = Table(
|
||||
'role_permissions',
|
||||
Base.metadata,
|
||||
Column('role', String(50), primary_key=True),
|
||||
Column('permission_id', BigInteger, ForeignKey('permissions.id'), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
code = Column(String(50), unique=True, nullable=False)
|
||||
module = Column(String(50))
|
||||
description = Column(Text)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""用户数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Boolean, TIMESTAMP
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
casdoor_id = Column(String(100), unique=True, nullable=False)
|
||||
username = Column(String(100), nullable=False)
|
||||
email = Column(String(255))
|
||||
role = Column(String(50), default="user")
|
||||
assigned_area = Column(String(100))
|
||||
assigned_school = Column(String(200))
|
||||
is_active = Column(Boolean, default=True)
|
||||
last_login = Column(TIMESTAMP)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -0,0 +1,20 @@
|
||||
"""认证相关 Schema"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
assigned_area: Optional[str]
|
||||
assigned_school: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,36 @@
|
||||
"""设备相关 Schema"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ONUDeviceBase(BaseModel):
|
||||
mac_address: str
|
||||
olt_id: Optional[int] = None
|
||||
region: Optional[str] = None
|
||||
school_name: Optional[str] = None
|
||||
building: Optional[str] = None
|
||||
place_type: Optional[str] = None # 场所类型
|
||||
room_number: Optional[str] = None
|
||||
notes: Optional[str] = None # 备注
|
||||
|
||||
|
||||
class ONUDeviceResponse(ONUDeviceBase):
|
||||
id: int
|
||||
status: Optional[str] = None
|
||||
distance_m: Optional[int] = None
|
||||
slot_number: Optional[int] = None
|
||||
port_number: Optional[int] = None
|
||||
port_id: Optional[str] = None # 完整端口标识,如 "1/0/2:4"
|
||||
model: Optional[str] = None
|
||||
olt_location: Optional[str] = None # OLT 安装位置
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DeviceListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[ONUDeviceResponse]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""设备状态检查服务"""
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.ssh_service import SSHService
|
||||
from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
|
||||
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
|
||||
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
||||
if not distance_str:
|
||||
return None
|
||||
m = re.search(r'\d+', distance_str)
|
||||
return int(m.group()) if m else None
|
||||
|
||||
|
||||
class CheckService:
|
||||
"""设备状态检查服务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def update_status_only(self, olt_id: int) -> Dict:
|
||||
"""扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。
|
||||
不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。
|
||||
"""
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, _ = ssh.parse_onu_info(output)
|
||||
|
||||
checked_at = datetime.utcnow()
|
||||
online_count = 0
|
||||
offline_count = 0
|
||||
|
||||
# 全局 MAC → id 索引,只取需要的字段
|
||||
existing = {
|
||||
row.mac_address.lower(): row.id
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
|
||||
}
|
||||
|
||||
for mac, onu_info in onu_info_dict.items():
|
||||
onu_id = existing.get(mac)
|
||||
if onu_id is None:
|
||||
continue # 不在库中,跳过
|
||||
|
||||
status = onu_info.status
|
||||
distance_m = parse_distance(onu_info.distance_str)
|
||||
if status == 'online':
|
||||
online_count += 1
|
||||
else:
|
||||
offline_count += 1
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=onu_id,
|
||||
status=status,
|
||||
distance_m=distance_m,
|
||||
checked_at=checked_at,
|
||||
response_data=None,
|
||||
))
|
||||
|
||||
self.db.commit()
|
||||
return {
|
||||
"online": online_count,
|
||||
"offline": offline_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def check_single_device(self, device_id: int) -> Dict:
|
||||
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||
命令格式: display onu slot {slot} | include {mac}
|
||||
"""
|
||||
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise Exception("设备不存在")
|
||||
if not device.olt_id:
|
||||
raise Exception("该设备未关联 OLT,无法查询")
|
||||
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||
if not olt:
|
||||
raise Exception("关联的 OLT 不存在")
|
||||
|
||||
mac = device.mac_address.lower()
|
||||
base_cmd = olt.slot_command # e.g. "display onu slot"
|
||||
if device.slot_number is not None:
|
||||
cmd = f"{base_cmd} {device.slot_number} | include {mac}"
|
||||
else:
|
||||
cmd = f"{base_cmd} | include {mac}"
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(cmd)
|
||||
|
||||
# 从输出中找到包含该 MAC 的行并解析
|
||||
info = None
|
||||
for line in output.splitlines():
|
||||
if mac in line.lower():
|
||||
info = ssh._parse_device_line(line, device.slot_number)
|
||||
if info:
|
||||
break
|
||||
|
||||
if info is None:
|
||||
# 未找到该 MAC,视为离线
|
||||
status = 'offline'
|
||||
distance_m = None
|
||||
else:
|
||||
status = info.status
|
||||
distance_m = parse_distance(info.distance_str)
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=device.id,
|
||||
status=status,
|
||||
distance_m=distance_m,
|
||||
checked_at=datetime.utcnow(),
|
||||
response_data=None,
|
||||
))
|
||||
self.db.commit()
|
||||
return {"status": status, "distance_m": distance_m}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def scan_and_discover(self, olt_id: int) -> Dict:
|
||||
"""扫描 OLT,更新已有设备状态,并将新发现的在线设备入库。
|
||||
不标记 unknown,不修改已有设备的 olt_id/端口以外的字段。
|
||||
"""
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||
|
||||
checked_at = datetime.utcnow()
|
||||
online_count = 0
|
||||
offline_count = 0
|
||||
new_count = 0
|
||||
|
||||
existing = {
|
||||
row.mac_address.lower(): row.id
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
|
||||
}
|
||||
|
||||
for mac, onu_info in onu_info_dict.items():
|
||||
onu_id = existing.get(mac)
|
||||
|
||||
if onu_id is None:
|
||||
# 新设备:只入库在线的
|
||||
if onu_info.status != 'online':
|
||||
continue
|
||||
onu = ONUDevice(
|
||||
mac_address=mac,
|
||||
olt_id=olt_id,
|
||||
slot_number=onu_info.slot_number,
|
||||
port_number=onu_info.port_number,
|
||||
port_id=onu_info.port_id,
|
||||
distance_m=parse_distance(onu_info.distance_str),
|
||||
loid=onu_info.loid,
|
||||
model=onu_info.model,
|
||||
)
|
||||
self.db.add(onu)
|
||||
self.db.flush()
|
||||
self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id))
|
||||
onu_id = onu.id
|
||||
new_count += 1
|
||||
|
||||
status = onu_info.status
|
||||
distance_m = parse_distance(onu_info.distance_str)
|
||||
if status == 'online':
|
||||
online_count += 1
|
||||
else:
|
||||
offline_count += 1
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=onu_id,
|
||||
status=status,
|
||||
distance_m=distance_m,
|
||||
checked_at=checked_at,
|
||||
response_data=None,
|
||||
))
|
||||
|
||||
self._save_duplicate_macs(olt_id, duplicate_dict)
|
||||
self.db.commit()
|
||||
return {
|
||||
"online": online_count,
|
||||
"offline": offline_count,
|
||||
"new_discovered": new_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
async def scan_olt(self, olt_id: int) -> Dict:
|
||||
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||
|
||||
# 对比全局 MAC
|
||||
existing_macs = {
|
||||
onu.mac_address.lower()
|
||||
for onu in self.db.query(ONUDevice.mac_address).all()
|
||||
}
|
||||
|
||||
devices = []
|
||||
for mac, info in onu_info_dict.items():
|
||||
devices.append({
|
||||
"mac_address": mac,
|
||||
"status": info.status,
|
||||
"distance_m": info.distance_str,
|
||||
"slot_number": info.slot_number,
|
||||
"port_number": info.port_number,
|
||||
"port_id": info.port_id,
|
||||
"loid": info.loid,
|
||||
"model": info.model,
|
||||
"is_new": mac not in existing_macs,
|
||||
})
|
||||
|
||||
duplicates = []
|
||||
for mac, records in duplicate_dict.items():
|
||||
duplicates.append({
|
||||
"mac_address": mac,
|
||||
"ports": [{"port_id": r.port_id, "status": r.status} for r in records],
|
||||
})
|
||||
|
||||
return {
|
||||
"olt_id": olt_id,
|
||||
"olt_ip": olt.ip_address,
|
||||
"total": len(devices),
|
||||
"new": sum(1 for d in devices if d["is_new"]),
|
||||
"devices": devices,
|
||||
"duplicates": duplicates,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||
for mac, records in duplicate_dict.items():
|
||||
ports = [{"port_id": r.port_id, "status": r.status} for r in records]
|
||||
existing = self.db.query(DuplicateMac).filter(
|
||||
DuplicateMac.olt_id == olt_id,
|
||||
DuplicateMac.mac_address == mac
|
||||
).first()
|
||||
if existing:
|
||||
existing.ports = ports
|
||||
existing.last_seen_at = datetime.utcnow()
|
||||
else:
|
||||
self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports))
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Excel 导入服务"""
|
||||
import pandas as pd
|
||||
from typing import List, Dict
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.device import ONUDevice
|
||||
import math
|
||||
|
||||
# 定义字段映射:Excel列名 -> 数据库字段名
|
||||
FIELD_MAPPING = {
|
||||
'mac_address': 'mac_address',
|
||||
'region': 'region',
|
||||
'school_name': 'school_name',
|
||||
'building': 'building',
|
||||
'place_type': 'place_type',
|
||||
'room_number': 'room_number',
|
||||
'notes': 'notes',
|
||||
}
|
||||
|
||||
|
||||
def clean_value(value) -> str:
|
||||
"""清理单元格值,处理NaN和None"""
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, float) and math.isnan(value):
|
||||
return ''
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
class ImportService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def parse_excel(self, file_path: str) -> List[Dict]:
|
||||
"""解析 Excel 文件"""
|
||||
df = pd.read_excel(file_path)
|
||||
# 标准化列名(去除空格,转小写)
|
||||
df.columns = [col.strip().lower() for col in df.columns]
|
||||
# 转换为记录列表
|
||||
records = []
|
||||
for _, row in df.iterrows():
|
||||
record = {}
|
||||
for col_name, db_field in FIELD_MAPPING.items():
|
||||
if col_name in row:
|
||||
record[db_field] = clean_value(row[col_name])
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
def validate_data(self, records: List[Dict]) -> Dict:
|
||||
"""验证数据"""
|
||||
valid = []
|
||||
invalid = []
|
||||
|
||||
for idx, record in enumerate(records):
|
||||
mac = record.get('mac_address', '').strip()
|
||||
if not mac:
|
||||
invalid.append({'record': record, 'error': f'第{idx+2}行: MAC地址缺失'})
|
||||
continue
|
||||
|
||||
# 标准化MAC地址:统一使用横杠分隔小写格式
|
||||
# 支持格式:AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF, aa:bb:cc:dd:ee:ff
|
||||
mac_clean = mac.upper().replace(':', '-')
|
||||
|
||||
# 验证基本格式:12个十六进制字符(可能有分隔符)
|
||||
hex_chars = mac_clean.replace('-', '')
|
||||
if len(hex_chars) != 12 or not all(c in '0123456789ABCDEF' for c in hex_chars):
|
||||
invalid.append({'record': record, 'error': f'第{idx+2}行: MAC地址格式错误 "{mac}"'})
|
||||
continue
|
||||
|
||||
# 转换为标准格式 34dc-99c8-56e0(小写4位分组)
|
||||
mac_formatted = '-'.join([hex_chars[i:i+4].lower() for i in range(0, 12, 4)])
|
||||
record['mac_address'] = mac_formatted
|
||||
valid.append(record)
|
||||
|
||||
return {'valid': valid, 'invalid': invalid}
|
||||
|
||||
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
|
||||
"""批量导入设备,存在则更新,不存在则新增"""
|
||||
success_count = 0
|
||||
skip_count = 0
|
||||
invalid_count = 0
|
||||
|
||||
for record in records:
|
||||
mac = record.get('mac_address', '')
|
||||
if not mac:
|
||||
skip_count += 1
|
||||
continue
|
||||
|
||||
# 查询是否已存在该 MAC 地址
|
||||
existing = self.db.query(ONUDevice).filter(ONUDevice.mac_address == mac).first()
|
||||
|
||||
if existing:
|
||||
# 更新现有记录
|
||||
existing.region = record.get('region', '')
|
||||
existing.school_name = record.get('school_name', '')
|
||||
existing.building = record.get('building') or None
|
||||
existing.place_type = record.get('place_type') or None
|
||||
existing.room_number = record.get('room_number') or None
|
||||
existing.notes = record.get('notes') or None
|
||||
success_count += 1
|
||||
else:
|
||||
# 新增记录
|
||||
device = ONUDevice(
|
||||
mac_address=mac,
|
||||
olt_id=olt_id,
|
||||
region=record.get('region', ''),
|
||||
school_name=record.get('school_name', ''),
|
||||
building=record.get('building') or None,
|
||||
place_type=record.get('place_type') or None,
|
||||
room_number=record.get('room_number') or None,
|
||||
notes=record.get('notes') or None
|
||||
)
|
||||
self.db.add(device)
|
||||
success_count += 1
|
||||
|
||||
self.db.commit()
|
||||
return {'success': success_count}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""业务下发服务"""
|
||||
from typing import Dict
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.ssh_service import SSHService
|
||||
from app.models.device import ONUDevice, OLTDevice
|
||||
import time
|
||||
|
||||
|
||||
class ProvisionService:
|
||||
"""业务下发服务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def provision_device(self, device_id: int) -> Dict[str, any]:
|
||||
"""
|
||||
业务下发流程(参照下发业务流程.md):
|
||||
1. system-view
|
||||
2. interface Onu{port_id}
|
||||
3. uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
|
||||
4. port link-type trunk
|
||||
5. undo port trunk permit vlan 1
|
||||
6. port trunk permit vlan 2000 3000 to 3010 4094
|
||||
7. save force
|
||||
"""
|
||||
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
return {"success": False, "error": f"设备不存在: {device_id}"}
|
||||
|
||||
if not device.olt_id:
|
||||
return {"success": False, "error": "设备未关联 OLT,请先进行扫描"}
|
||||
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||
if not olt:
|
||||
return {"success": False, "error": "关联的 OLT 设备不存在"}
|
||||
|
||||
if not device.port_id:
|
||||
return {"success": False, "error": "设备端口信息不完整,请先对 OLT 执行扫描"}
|
||||
|
||||
port_name = f"Onu{device.port_id}"
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
|
||||
try:
|
||||
ssh.connect()
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
||||
ssh.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if ssh.shell.recv_ready():
|
||||
buf += ssh.shell.recv(4096).decode("utf-8", errors="ignore")
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
# 进入系统视图
|
||||
out = send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 进入端口
|
||||
out = send_and_wait(f"interface {port_name}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 {port_name} 失败")
|
||||
|
||||
# 配置 VLAN
|
||||
send_and_wait("uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010", "]")
|
||||
send_and_wait("port link-type trunk", "]")
|
||||
send_and_wait("undo port trunk permit vlan 1", "]")
|
||||
send_and_wait("port trunk permit vlan 2000 3000 to 3010 4094", "]")
|
||||
|
||||
# 保存配置(等待 "successfully" 出现)
|
||||
save_out = send_and_wait("save force", "successfully", timeout=30)
|
||||
if "successfully" not in save_out:
|
||||
raise Exception("save force 未确认成功,请检查 OLT 日志")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"device_id": device_id,
|
||||
"mac_address": device.mac_address,
|
||||
"olt_ip": olt.ip_address,
|
||||
"port": port_name,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"device_id": device_id,
|
||||
"mac_address": device.mac_address,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def batch_provision(self, device_ids: list) -> Dict[str, any]:
|
||||
results = []
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
for device_id in device_ids:
|
||||
result = self.provision_device(device_id)
|
||||
results.append(result)
|
||||
if result.get("success"):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
return {
|
||||
"total": len(device_ids),
|
||||
"success": success_count,
|
||||
"failed": fail_count,
|
||||
"results": results,
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"""SSH 连接服务"""
|
||||
import paramiko
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ONUInfo:
|
||||
"""ONU 设备完整信息"""
|
||||
mac_address: str
|
||||
status: str # online/offline
|
||||
distance_m: Optional[int] = None # 距离(米)
|
||||
distance_str: Optional[str] = None # 距离原始字符串,如 "<1000"
|
||||
slot_number: Optional[int] = None # 插槽号
|
||||
port_number: Optional[int] = None # 端口号
|
||||
port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1"
|
||||
loid: Optional[str] = None # LOID
|
||||
model: Optional[str] = None # 设备型号
|
||||
|
||||
|
||||
class SSHService:
|
||||
"""SSH 连接和命令执行服务"""
|
||||
|
||||
def __init__(self, host: str, username: str, password: str, port: int = 22):
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.port = port
|
||||
self.client: Optional[paramiko.SSHClient] = None
|
||||
self.shell = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
||||
try:
|
||||
self.client = paramiko.SSHClient()
|
||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
self.client.connect(
|
||||
hostname=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
timeout=30
|
||||
)
|
||||
self.shell = self.client.invoke_shell(width=200, height=50)
|
||||
# 等待登录 banner 输出完毕,直到出现命令提示符 ">"
|
||||
deadline = time.time() + 10
|
||||
buf = ""
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if ">" in buf:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"SSH 连接失败: {str(e)}")
|
||||
|
||||
def execute_command(self, command: str) -> str:
|
||||
"""执行命令并处理 More 分页,等待命令提示符出现后返回"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
self.shell.send(command + "\n")
|
||||
output = ""
|
||||
# 等待命令回显出现,再开始收集输出
|
||||
time.sleep(0.5)
|
||||
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
chunk = self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
output += chunk
|
||||
if "---- More ----" in chunk:
|
||||
self.shell.send(" ")
|
||||
time.sleep(0.3)
|
||||
elif ">" in chunk:
|
||||
# 命令提示符出现,说明输出完毕
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
return output
|
||||
|
||||
def clear_onu_port(self, port_id: str) -> bool:
|
||||
"""清除指定端口的 ONU 配置(恢复默认)
|
||||
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||
"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
# 进入系统视图
|
||||
out = send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 进入端口
|
||||
out = send_and_wait(f"interface Onu{port_id}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||
|
||||
# 执行 default,等待确认提示
|
||||
self.shell.send("default\n")
|
||||
buf = ""
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if "[Y/N]" in buf or "[y/n]" in buf:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
if "[Y/N]" not in buf and "[y/n]" not in buf:
|
||||
raise Exception("未收到确认提示")
|
||||
|
||||
# 确认
|
||||
self.shell.send("Y\n")
|
||||
time.sleep(1)
|
||||
# 排空缓冲区
|
||||
if self.shell.recv_ready():
|
||||
self.shell.recv(4096)
|
||||
|
||||
# 退出到用户视图
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
|
||||
return True
|
||||
|
||||
def detect_loopback(self) -> dict:
|
||||
"""执行环路检测,返回 {has_loop: bool, interfaces: [str]}"""
|
||||
output = self.execute_command("display loopback-detection")
|
||||
has_loop = "Loop is detected on following interfaces" in output
|
||||
interfaces = []
|
||||
if has_loop:
|
||||
for line in output.splitlines():
|
||||
m = re.match(r'\s+(Onu\S+)\s+', line)
|
||||
if m:
|
||||
interfaces.append(m.group(1))
|
||||
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||
|
||||
def parse_onu_status(self, output: str) -> Dict[str, str]:
|
||||
"""解析 ONU 状态输出"""
|
||||
devices = {}
|
||||
lines = output.split('\n')
|
||||
|
||||
for line in lines:
|
||||
# 匹配包含 MAC 地址的行
|
||||
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).lower()
|
||||
# 提取状态字段
|
||||
if 'Up' in line:
|
||||
devices[mac] = 'online'
|
||||
elif 'Offline' in line:
|
||||
devices[mac] = 'offline'
|
||||
|
||||
return devices
|
||||
|
||||
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
|
||||
"""增强解析:提取完整 ONU 信息
|
||||
返回: (unique_devices, duplicate_devices)
|
||||
- unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口)
|
||||
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
|
||||
"""
|
||||
all_records: Dict[str, List[ONUInfo]] = {}
|
||||
lines = output.split('\n')
|
||||
|
||||
current_slot = None
|
||||
|
||||
for line in lines:
|
||||
# 检测新的插槽区域: Olt1/0/1
|
||||
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
|
||||
if slot_match:
|
||||
current_slot = int(slot_match.group(3))
|
||||
continue
|
||||
|
||||
# 跳过表头行和空行
|
||||
if 'MAC' in line and 'LOID' in line:
|
||||
continue
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
# 解析设备行
|
||||
device = self._parse_device_line(line, current_slot)
|
||||
if device:
|
||||
if device.mac_address not in all_records:
|
||||
all_records[device.mac_address] = []
|
||||
all_records[device.mac_address].append(device)
|
||||
|
||||
unique_devices: Dict[str, ONUInfo] = {}
|
||||
duplicate_devices: Dict[str, List[ONUInfo]] = {}
|
||||
|
||||
for mac, records in all_records.items():
|
||||
if len(records) == 1:
|
||||
unique_devices[mac] = records[0]
|
||||
else:
|
||||
duplicate_devices[mac] = records
|
||||
# unique 中保留在线的,若都离线则保留最后一条
|
||||
online = [r for r in records if r.status == 'online']
|
||||
unique_devices[mac] = online[0] if online else records[-1]
|
||||
|
||||
return unique_devices, duplicate_devices
|
||||
|
||||
def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]:
|
||||
"""解析单行设备信息"""
|
||||
# 匹配 MAC 地址
|
||||
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||
if not mac_match:
|
||||
return None
|
||||
|
||||
mac = mac_match.group(1).lower()
|
||||
|
||||
# 提取状态
|
||||
status = 'offline'
|
||||
if 'Up' in line:
|
||||
status = 'online'
|
||||
|
||||
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
|
||||
slot_num, port_num, port_id = None, None, None
|
||||
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
|
||||
if port_match:
|
||||
slot_num = int(port_match.group(3)) # 第三段数字为槽位
|
||||
port_num = int(port_match.group(4)) # 冒号后为端口号
|
||||
port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}"
|
||||
|
||||
# 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A"
|
||||
distance_str = None
|
||||
dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line)
|
||||
if dist_match:
|
||||
val = dist_match.group(1)
|
||||
if val != 'N/A':
|
||||
distance_str = val
|
||||
|
||||
# 提取 LOID - MAC 后第一个非空字段
|
||||
loid = None
|
||||
loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE)
|
||||
if loid_match:
|
||||
loid_val = loid_match.group(1)
|
||||
if loid_val != 'N/A' and loid_val.isdigit():
|
||||
loid = loid_val
|
||||
|
||||
# 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A"
|
||||
model = None
|
||||
model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line)
|
||||
if model_match:
|
||||
potential_model = model_match.group(1)
|
||||
if potential_model != 'N/A':
|
||||
model = potential_model
|
||||
|
||||
return ONUInfo(
|
||||
mac_address=mac,
|
||||
status=status,
|
||||
distance_m=None,
|
||||
distance_str=distance_str,
|
||||
slot_number=slot_num or slot,
|
||||
port_number=port_num,
|
||||
port_id=port_id,
|
||||
loid=loid,
|
||||
model=model
|
||||
)
|
||||
|
||||
def close(self):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
self.client.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Celery 任务模块"""
|
||||
from app.tasks.check_tasks import check_all_devices # noqa: F401
|
||||
|
||||
__all__ = ['check_all_devices']
|
||||
@@ -0,0 +1,72 @@
|
||||
"""状态检查任务"""
|
||||
import traceback
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.check_service import CheckService
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def check_all_devices(self):
|
||||
"""检查所有设备状态"""
|
||||
db = SessionLocal()
|
||||
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
|
||||
try:
|
||||
service = CheckService(db)
|
||||
from app.models.device import OLTDevice
|
||||
olts = db.query(OLTDevice).all()
|
||||
|
||||
total = len(olts)
|
||||
self.update_state(state='PROGRESS', meta={'current': 0, 'total': total, 'status': f'准备检查 {total} 个OLT...'})
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
total_online = 0
|
||||
total_offline = 0
|
||||
|
||||
for idx, olt in enumerate(olts):
|
||||
self.update_state(state='PROGRESS', meta={
|
||||
'current': idx, 'total': total,
|
||||
'status': f'检查 OLT: {olt.location or olt.ip_address}...'
|
||||
})
|
||||
try:
|
||||
result = service.update_status_only(olt.id)
|
||||
total_online += result.get('online', 0)
|
||||
total_offline += result.get('offline', 0)
|
||||
results.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'online': result.get('online', 0),
|
||||
'offline': result.get('offline', 0),
|
||||
'success': True
|
||||
})
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'error': str(e)
|
||||
})
|
||||
results.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'total_olts': total,
|
||||
'total_online': total_online,
|
||||
'total_offline': total_offline,
|
||||
'results': results,
|
||||
'errors': errors
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user