Files
H3ConuMS-v2/backend/app/api/v1/olt.py
T
v6ole fcfa5af614 feat: v0.10.0 生产环境优化 — HTTPS、前端生产构建、安全加固
- feat(deploy): 前端多阶段构建 (vite build + nginx:alpine),移除 Vite 开发模式
- feat(deploy): OpenResty HTTPS 配置 (SSL + HSTS + 安全头)
- fix(ws): WebSocket 路由添加 /api 前缀,修正前后端路径不匹配
- security: SSH AutoAddPolicy → WarningPolicy
- security: CORS 来源环境变量化 (CORS_ORIGINS)
- security: 限流器使用 X-Forwarded-For 真实客户端 IP
- perf(db): 数据库连接池配置 (pool_size=20, max_overflow=40)
- refactor: 移除硬编码 URL/IP (NTP、域名、微信代理),改为环境变量
- chore: 更新 .env.example 模板,补充新增配置项
- chore: 清理 .reasonix/、scripts/、guide.md 无用文件
- docs: 更新 CLAUDE.md 至 v0.10.0,补充生产架构文档

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:00:08 +08:00

607 lines
21 KiB
Python

"""OLT 设备管理 API"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import distinct
from pydantic import BaseModel
from app.core.database import get_db
from app.core.config import settings
from app.middleware.permission_middleware import require_permission
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"
region: str = "城区"
location: str = ""
description: str = ""
class OLTEdit(BaseModel):
username: str
password: str = None
slot_command: str = "display onu slot"
region: str = "城区"
location: str = ""
description: str = ""
@router.get("/regions")
def get_olt_regions(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.view')),
):
"""获取 OLT 设备的所有区域(受角色数据范围限制)"""
query = db.query(distinct(OLTDevice.region)).filter(
OLTDevice.region.isnot(None),
OLTDevice.region != ''
)
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if areas:
query = query.filter(OLTDevice.region.in_(areas))
else:
return []
return sorted([r[0] for r in query.all()])
@router.get("/devices")
def get_devices(
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.view')),
):
q = db.query(OLTDevice)
# 区域管理员只能看自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if areas:
q = q.filter(OLTDevice.region.in_(areas))
else:
return []
return q.all()
@router.post("/devices")
def create_device(
device: OLTCreate,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
# 区域管理员只能创建自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
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),
current: dict = Depends(require_permission('olt.manage')),
):
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not db_device:
raise HTTPException(status_code=404, detail="设备不存在")
# 区域管理员只能管理自己区域的 OLT
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if db_device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
db_device.username = device.username
if device.password:
db_device.password = device.password
db_device.slot_command = device.slot_command
db_device.region = device.region
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),
current: dict = Depends(require_permission('olt.manage')),
):
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="设备不存在")
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
if device.region not in areas:
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
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),
_: dict = Depends(require_permission('olt.manage')),
):
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',
region=str(row['区域']).strip() if '区域' in df.columns and str(row['区域']) != 'nan' else '城区',
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
import os
# __file__ is at <root>/app/api/v1/olt.py → go up 4 levels to reach <root>
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
path = os.path.join(base, "templates", "OLT设备导入模板.xlsx")
return FileResponse(path=path, filename="OLT设备导入模板.xlsx")
@router.get("/duplicate-macs")
def get_duplicate_macs(
olt_id: int = None,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.view')),
):
"""查询重复 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),
_: dict = Depends(require_permission('olt.manage')),
):
"""删除重复 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),
_: dict = Depends(require_permission('olt.manage')),
):
"""通过 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),
_: dict = Depends(require_permission('olt.view')),
):
"""查询新发现的设备列表(待补全信息)"""
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),
_: dict = Depends(require_permission('olt.manage')),
):
"""补全新设备信息,完成后从 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),
_: dict = Depends(require_permission('olt.manage')),
):
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
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),
_: dict = Depends(require_permission('olt.discover')),
):
"""多线程对所有 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),
_: dict = Depends(require_permission('olt.loopback')),
):
"""对所有 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]
class SyncNTPRequest(BaseModel):
old_server: str = settings.NTP_OLD_SERVER
new_server: str = settings.NTP_NEW_SERVER
@router.post("/sync-ntp")
def sync_ntp(
body: SyncNTPRequest,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('olt.manage')),
):
"""对所有 OLT 并发执行 NTP 时间服务器同步"""
from app.services.ssh_service import SSHService
from concurrent.futures import ThreadPoolExecutor, as_completed
olts = db.query(OLTDevice).all()
if current.get('role') == 'area_admin' and current.get('assigned_area'):
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
olts = [o for o in olts if o.region in areas] if areas else []
def sync_one(olt):
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ssh.sync_ntp(body.old_server, body.new_server)
return {
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"success": True,
"error": None,
}
except Exception as e:
return {
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"success": False,
"error": str(e),
}
finally:
ssh.close()
results_map = {}
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
futures = {executor.submit(sync_one, olt): olt.id for olt in olts}
for future in as_completed(futures):
r = future.result()
results_map[r["olt_ip"]] = r
results = [results_map[olt.ip_address] for olt in olts]
success_count = sum(1 for r in results if r["success"])
return {
"total": len(results),
"success": success_count,
"failed": len(results) - success_count,
"results": results,
}
class TogglePortRequest(BaseModel):
action: str # "shutdown" 或 "undo shutdown"
@router.get("/devices/{olt_id}/ports")
def get_olt_ports(
olt_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('olt.port_manage')),
):
"""获取指定 OLT 的所有 Olt 端口状态"""
from app.services.ssh_service import SSHService
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise HTTPException(status_code=404, detail="OLT 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ports = ssh.get_olt_ports()
return {"ports": ports}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()
@router.post("/devices/{olt_id}/ports/toggle")
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db), _: dict = Depends(require_permission('olt.port_manage'))):
"""开启或关闭指定 OLT 端口"""
from app.services.ssh_service import SSHService
if body.action not in ("shutdown", "undo shutdown"):
raise HTTPException(status_code=400, detail="action 必须为 shutdown 或 undo shutdown")
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise HTTPException(status_code=404, detail="OLT 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ssh.toggle_olt_port(port_name, body.action)
return {"message": f"端口 {port_name}{'关闭' if body.action == 'shutdown' else '开启'}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()