初始化

This commit is contained in:
2026-04-02 23:40:04 +08:00
commit 381ea7085d
107 changed files with 11858 additions and 0 deletions
View File
View File
+88
View File
@@ -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
+86
View File
@@ -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))
+231
View File
@@ -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": "更新成功"}
+77
View File
@@ -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']
}
+402
View File
@@ -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]
+59
View File
@@ -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
+118
View File
@@ -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]