feat: v0.9.0 新增记录日志、设备更换记录及IMC服务集成
- 新增 RecordsLog.vue 操作记录日志页面 - 新增 ReplacementRecords.vue 设备更换记录页面 - 新增 imc_service.py IMC网管系统集成服务 - 新增 datetime.js 前端日期时间工具函数 - 新增 OLT时间同步.md 文档 - 扩展 devices.py API:设备更换记录、批量操作等 - 扩展 ssh_service.py:OLT时间同步功能 - 扩展 olt.py:新增时间同步相关接口 - 更新 DeviceList.vue:增强设备列表功能 - 更新路由和导航菜单 - 将 .claude/ 和 .mcp.json 加入 .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""审计日志 API"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -113,8 +113,9 @@ def export_audit_logs(
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["时间", "用户", "角色", "操作类型", "子类型", "路径", "状态码", "状态", "IP", "描述"])
|
||||
for r in items:
|
||||
t_cst = (r.action_time + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S") if r.action_time else ""
|
||||
writer.writerow([
|
||||
r.action_time.strftime("%Y-%m-%d %H:%M:%S") if r.action_time else "",
|
||||
t_cst,
|
||||
r.username, r.user_role, r.action_type, r.action_subtype or "",
|
||||
f"{r.request_method} {r.request_path}", r.status_code, r.status,
|
||||
r.ip_address or "", r.description,
|
||||
@@ -132,7 +133,7 @@ def export_audit_logs(
|
||||
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
||||
base = {
|
||||
"id": r.id,
|
||||
"action_time": r.action_time.isoformat() if r.action_time else None,
|
||||
"action_time": (r.action_time.isoformat() + "Z") if r.action_time else None,
|
||||
"user_id": r.user_id,
|
||||
"username": r.username,
|
||||
"user_role": r.user_role,
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
from app.core.database import get_db
|
||||
from app.middleware.permission_middleware import require_permission
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
|
||||
from app.schemas.device import DeviceListResponse, ONUDeviceResponse
|
||||
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
|
||||
|
||||
@@ -204,6 +204,134 @@ def get_schools(
|
||||
return [r[0] for r in query.order_by(ONUDevice.school_name).all()]
|
||||
|
||||
|
||||
@router.get("/replacements")
|
||||
def get_all_replacements(
|
||||
region: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
keyword: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取全量设备更换记录(带位置信息),支持筛选"""
|
||||
from datetime import datetime
|
||||
query = (
|
||||
db.query(DeviceReplacement, ONUDevice)
|
||||
.join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id)
|
||||
)
|
||||
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(ONUDevice.region.in_(areas))
|
||||
else:
|
||||
return {"total": 0, "items": []}
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if start_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date))
|
||||
if end_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59'))
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(or_(
|
||||
ONUDevice.school_name.ilike(kw),
|
||||
ONUDevice.region.ilike(kw),
|
||||
DeviceReplacement.old_mac.ilike(kw),
|
||||
DeviceReplacement.new_mac.ilike(kw),
|
||||
DeviceReplacement.operator_name.ilike(kw),
|
||||
))
|
||||
total = query.count()
|
||||
rows = query.order_by(DeviceReplacement.replaced_at.desc()).offset(skip).limit(limit).all()
|
||||
return {
|
||||
"total": total,
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"replaced_at": r.replaced_at,
|
||||
"old_mac": r.old_mac,
|
||||
"new_mac": r.new_mac,
|
||||
"reason": r.reason,
|
||||
"operator_name": r.operator_name,
|
||||
"region": d.region,
|
||||
"school_name": d.school_name,
|
||||
"building": d.building,
|
||||
"room_number": d.room_number,
|
||||
"onu_device_id": r.onu_device_id,
|
||||
}
|
||||
for r, d in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/replacements/export")
|
||||
def export_replacements(
|
||||
region: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
keyword: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""导出更换记录为 CSV"""
|
||||
import csv, io
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
query = (
|
||||
db.query(DeviceReplacement, ONUDevice)
|
||||
.join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id)
|
||||
)
|
||||
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(ONUDevice.region.in_(areas))
|
||||
else:
|
||||
query = query.filter(False)
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if start_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date))
|
||||
if end_date:
|
||||
query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59'))
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(or_(
|
||||
ONUDevice.school_name.ilike(kw),
|
||||
ONUDevice.region.ilike(kw),
|
||||
DeviceReplacement.old_mac.ilike(kw),
|
||||
DeviceReplacement.new_mac.ilike(kw),
|
||||
DeviceReplacement.operator_name.ilike(kw),
|
||||
))
|
||||
rows = query.order_by(DeviceReplacement.replaced_at.desc()).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['更换时间(北京)', '区域', '学校', '楼宇', '房间', '旧MAC', '新MAC', '更换原因', '操作人'])
|
||||
for r, d in rows:
|
||||
bj_time = (r.replaced_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S') if r.replaced_at else ''
|
||||
writer.writerow([
|
||||
bj_time,
|
||||
d.region or '',
|
||||
d.school_name or '',
|
||||
d.building or '',
|
||||
d.room_number or '',
|
||||
r.old_mac,
|
||||
r.new_mac,
|
||||
r.reason or '',
|
||||
r.operator_name or '',
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
filename = f"replacement_records_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue().encode('utf-8-sig')]),
|
||||
media_type='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||
def get_device(
|
||||
device_id: int,
|
||||
@@ -359,10 +487,16 @@ def replace_device(
|
||||
conflict_new_onu_ids = {nd.onu_device_id for nd in conflict_news}
|
||||
for nd in conflict_news:
|
||||
db.delete(nd)
|
||||
# 先 flush,让 NewDevice 的 ORM 删除落库,解除对 onu_devices 的外键引用
|
||||
db.flush()
|
||||
# 同时删除对应的空白 ONU 记录,避免设备列表出现重复 MAC
|
||||
if conflict_new_onu_ids:
|
||||
from app.models.device import DeviceStatusHistory
|
||||
db.query(DeviceStatusHistory).filter(
|
||||
DeviceStatusHistory.onu_device_id.in_(conflict_new_onu_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(ONUDevice).filter(ONUDevice.id.in_(conflict_new_onu_ids)).delete(synchronize_session=False)
|
||||
db.flush()
|
||||
db.flush()
|
||||
|
||||
# 检查新 MAC 是否已被其他 ONU 设备使用(排除刚刚从 new_devices 删除的临时 ONU)
|
||||
existing = db.query(ONUDevice).filter(
|
||||
@@ -422,3 +556,108 @@ def get_device_replacements(
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{device_id}/reboot", response_model=RebootResponse)
|
||||
def reboot_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.check')),
|
||||
):
|
||||
"""远程重启 ONU 设备(通过 iMC REST API)"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
schools = [s.strip() for s in (current.get('assigned_school') or '').split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
result = IMCService().reboot_onu(device.mac_address)
|
||||
return RebootResponse(**result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||
def get_device_optical_power(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取 ONU 设备光功率信息(通过 iMC REST API)"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
schools = [s.strip() for s in (current.get('assigned_school') or '').split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
data = IMCService().get_optical_power(device.mac_address)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||
return OpticalPowerResponse(
|
||||
power_in=data.get("powerIn"),
|
||||
power_out=data.get("powerOut"),
|
||||
bind_mac=data.get("bindMac"),
|
||||
dev_id=data.get("devId"),
|
||||
epon_dev_name=data.get("eponDevName"),
|
||||
olt_if_name=data.get("oltIfName"),
|
||||
onu_if_desc=data.get("onuIfDesc"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{device_id}/onu-events")
|
||||
def get_onu_events(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""查询 ONU 上下线事件记录(SSH 到所属 OLT 执行命令)"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
if not device.olt_id:
|
||||
raise HTTPException(status_code=400, detail="该设备未关联 OLT,无法查询")
|
||||
if not device.port_id:
|
||||
raise HTTPException(status_code=400, detail="端口信息缺失,请先更新设备状态")
|
||||
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||
|
||||
from app.services.ssh_service import SSHService
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
events = ssh.get_onu_events(device.port_id)
|
||||
return {
|
||||
"interface": f"Onu{device.port_id}",
|
||||
"olt_location": olt.location,
|
||||
"events": events,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
@@ -67,13 +67,16 @@ async def upload_excel(
|
||||
records = service.parse_excel(file_path)
|
||||
validation = service.validate_data(records)
|
||||
|
||||
success_count = 0
|
||||
created = updated = 0
|
||||
if validation['valid']:
|
||||
result = service.import_devices(validation['valid'], olt_id)
|
||||
success_count = result['success']
|
||||
created = result['created']
|
||||
updated = result['updated']
|
||||
|
||||
return {
|
||||
"success": success_count,
|
||||
"success": created + updated,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"failed": validation['invalid']
|
||||
}
|
||||
|
||||
|
||||
@@ -500,6 +500,64 @@ def loopback_detection(
|
||||
return [results_map[olt.id] for olt in olts]
|
||||
|
||||
|
||||
class SyncNTPRequest(BaseModel):
|
||||
old_server: str = "172.16.0.254"
|
||||
new_server: str = "172.16.1.252"
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ class Settings(BaseSettings):
|
||||
CHECK_INTERVAL: int = 1800
|
||||
MANUAL_COOLDOWN: int = 300
|
||||
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL: str = ""
|
||||
IMC_API_USERNAME: str = ""
|
||||
IMC_API_PASSWORD: str = ""
|
||||
IMC_API_VERIFY_SSL: bool = False
|
||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||
IMC_READ_TIMEOUT: float = 20.0
|
||||
|
||||
class Config:
|
||||
env_file = str(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
@@ -34,3 +34,18 @@ class DeviceListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[ONUDeviceResponse]
|
||||
|
||||
|
||||
class RebootResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class OpticalPowerResponse(BaseModel):
|
||||
power_in: Optional[str] = None
|
||||
power_out: Optional[str] = None
|
||||
bind_mac: Optional[str] = None
|
||||
dev_id: Optional[int] = None
|
||||
epon_dev_name: Optional[str] = None
|
||||
olt_if_name: Optional[str] = None
|
||||
onu_if_desc: Optional[str] = None
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
iMC REST API 服务
|
||||
- 使用 HTTP Digest Access Authentication (RFC 2617)
|
||||
- 支持 nonce 过期自动续约(401 时自动重新握手)
|
||||
- 功能:ONU 远程重启、光功率查询
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
import requests
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# iMC 重启错误码映射
|
||||
REBOOT_ERROR_CODES = {
|
||||
'103': 'ONU不存在',
|
||||
'119': 'SNMP连接超时',
|
||||
'120': '业务割接失败',
|
||||
'121': 'ONU未运行',
|
||||
'122': '重启失败',
|
||||
}
|
||||
|
||||
# 并发控制:防止同一设备被重复重启
|
||||
_reboot_locks: dict = {}
|
||||
_reboot_lock = threading.Lock()
|
||||
|
||||
|
||||
def _normalize_mac(mac: str) -> str:
|
||||
"""标准化 MAC 为 iMC 要求的格式:1484-7790-4840(大写,4位分组)"""
|
||||
clean = mac.replace(':', '').replace('-', '').replace('.', '').upper()
|
||||
return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}"
|
||||
|
||||
|
||||
class IMCService:
|
||||
"""iMC REST API 服务封装"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
||||
self.username = settings.IMC_API_USERNAME
|
||||
self.password = settings.IMC_API_PASSWORD
|
||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
||||
self.connect_timeout = settings.IMC_CONNECT_TIMEOUT
|
||||
self.read_timeout = settings.IMC_READ_TIMEOUT
|
||||
self.session = requests.Session()
|
||||
self.realm = "iMC RESTful Web Services"
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ── Digest 认证 ──────────────────────────────────────────────────────────
|
||||
|
||||
def _get_digest_auth_header(self, method: str, uri: str) -> str | None:
|
||||
"""构建 HTTP Digest 认证头,首次调用自动握手获取 nonce"""
|
||||
if not self.nonce:
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
verify=self.verify_ssl,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
if resp.status_code == 401 and 'WWW-Authenticate' in resp.headers:
|
||||
auth_parts = {}
|
||||
for part in resp.headers['WWW-Authenticate'].split(','):
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
auth_parts[k.strip()] = v.strip(' "')
|
||||
self.nonce = auth_parts.get('nonce', '')
|
||||
self.realm = auth_parts.get('realm', self.realm)
|
||||
else:
|
||||
logger.error(f"获取 nonce 失败,状态码: {resp.status_code}")
|
||||
return None
|
||||
except requests.Timeout:
|
||||
raise TimeoutError("iMC 认证超时")
|
||||
except Exception as e:
|
||||
logger.error(f"获取 nonce 异常: {e}")
|
||||
return None
|
||||
|
||||
cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
|
||||
ha1 = hashlib.md5(f"{self.username}:{self.realm}:{self.password}".encode()).hexdigest()
|
||||
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
|
||||
response_hash = hashlib.md5(
|
||||
f"{ha1}:{self.nonce}:{self.nc:08d}:{cnonce}:auth:{ha2}".encode()
|
||||
).hexdigest()
|
||||
|
||||
auth_value = (
|
||||
f'Digest username="{self.username}", realm="{self.realm}", '
|
||||
f'nonce="{self.nonce}", uri="{uri}", response="{response_hash}", '
|
||||
f'qop=auth, nc={self.nc:08d}, cnonce="{cnonce}"'
|
||||
)
|
||||
self.nc += 1
|
||||
return auth_value
|
||||
|
||||
def _clear_auth(self):
|
||||
"""清除认证状态(nonce 过期时调用,下次请求自动重新握手)"""
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ── 重启 ONU ─────────────────────────────────────────────────────────────
|
||||
|
||||
def reboot_onu(self, mac: str) -> dict:
|
||||
"""
|
||||
远程重启 ONU 设备。
|
||||
使用 per-MAC 锁防止同一设备并发重启。
|
||||
"""
|
||||
imc_mac = _normalize_mac(mac)
|
||||
|
||||
# 并发锁
|
||||
with _reboot_lock:
|
||||
if imc_mac not in _reboot_locks:
|
||||
_reboot_locks[imc_mac] = threading.Lock()
|
||||
lock = _reboot_locks[imc_mac]
|
||||
|
||||
if not lock.acquire(blocking=False):
|
||||
return {"success": False, "message": "该设备正在重启中,请稍后再试"}
|
||||
|
||||
try:
|
||||
for retry in range(2):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/reboot?mac={imc_mac}"
|
||||
auth = self._get_digest_auth_header("POST", uri)
|
||||
if not auth:
|
||||
return {"success": False, "message": "认证失败,无法发送重启请求"}
|
||||
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}{uri}",
|
||||
headers={
|
||||
"Accept": "application/xml",
|
||||
"Content-Type": "application/xml",
|
||||
"Content-Length": "0",
|
||||
"Authorization": auth,
|
||||
},
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
m = re.search(r"<errorCode>(\d+)</errorCode>", resp.text)
|
||||
if m:
|
||||
code = m.group(1)
|
||||
msg = REBOOT_ERROR_CODES.get(code, f"未知错误(代码: {code})")
|
||||
return {"success": False, "message": f"重启失败: {msg}"}
|
||||
return {"success": True, "message": "设备正在重启,请稍后..."}
|
||||
|
||||
elif resp.status_code == 401:
|
||||
self._clear_auth()
|
||||
continue
|
||||
|
||||
return {"success": False, "message": f"重启请求失败(HTTP {resp.status_code})"}
|
||||
|
||||
except TimeoutError:
|
||||
return {"success": False, "message": "iMC 接口超时,请稍后重试"}
|
||||
except Exception as e:
|
||||
logger.error(f"重启异常 (retry={retry}): {e}")
|
||||
if retry == 0:
|
||||
time.sleep(2)
|
||||
continue
|
||||
return {"success": False, "message": f"重启异常: {e}"}
|
||||
|
||||
return {"success": False, "message": "重启失败,已达最大重试次数"}
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
# ── 光功率查询 ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_optical_power(self, mac: str) -> dict | None:
|
||||
"""
|
||||
获取 ONU 设备光功率信息。
|
||||
返回 dict 或 None(失败时)。
|
||||
"""
|
||||
imc_mac = _normalize_mac(mac)
|
||||
|
||||
for retry in range(2):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={imc_mac}"
|
||||
auth = self._get_digest_auth_header("GET", uri)
|
||||
if not auth:
|
||||
logger.error("生成认证头失败,无法获取光功率")
|
||||
return None
|
||||
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth,
|
||||
},
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
return {
|
||||
"powerIn": data.get("powerIn"),
|
||||
"powerOut": data.get("powerOut"),
|
||||
"bindMac": data.get("bindMac"),
|
||||
"devId": data.get("devId"),
|
||||
"eponDevName": data.get("eponDevName"),
|
||||
"oltIfName": data.get("oltIfName"),
|
||||
"onuIfDesc": data.get("onuIfDesc"),
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析光功率 JSON 失败: {e}, 内容: {resp.text}")
|
||||
return None
|
||||
|
||||
elif resp.status_code == 401:
|
||||
self._clear_auth()
|
||||
continue
|
||||
|
||||
logger.error(f"光功率 API 失败,状态码: {resp.status_code}")
|
||||
return None
|
||||
|
||||
except requests.Timeout:
|
||||
raise TimeoutError("iMC 光功率接口请求超时")
|
||||
except Exception as e:
|
||||
logger.error(f"获取光功率异常 (retry={retry}): {e}")
|
||||
if retry == 0:
|
||||
time.sleep(2)
|
||||
continue
|
||||
return None
|
||||
|
||||
return None
|
||||
@@ -75,28 +75,26 @@ class ImportService:
|
||||
|
||||
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
|
||||
"""批量导入设备,存在则更新,不存在则新增"""
|
||||
success_count = 0
|
||||
skip_count = 0
|
||||
invalid_count = 0
|
||||
created_count = 0
|
||||
updated_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:
|
||||
# 更新现有记录
|
||||
# 更新现有记录(MAC 地址不变)
|
||||
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
|
||||
updated_count += 1
|
||||
else:
|
||||
# 新增记录
|
||||
device = ONUDevice(
|
||||
@@ -110,8 +108,8 @@ class ImportService:
|
||||
notes=record.get('notes') or None
|
||||
)
|
||||
self.db.add(device)
|
||||
success_count += 1
|
||||
created_count += 1
|
||||
|
||||
self.db.commit()
|
||||
return {'success': success_count}
|
||||
return {'success': created_count + updated_count, 'created': created_count, 'updated': updated_count}
|
||||
|
||||
|
||||
@@ -348,6 +348,85 @@ class SSHService:
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
return True
|
||||
|
||||
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||
"""同步 NTP 时间服务器配置
|
||||
流程: system-view -> undo ntp old -> ntp new -> clock timezone -> quit -> save force
|
||||
old_server 若不存在会报错,直接忽略继续执行。
|
||||
"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> 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 失败")
|
||||
|
||||
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
||||
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||
|
||||
# 添加新 NTP 服务器
|
||||
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
||||
|
||||
# 设置时区为北京时间
|
||||
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("配置时区失败")
|
||||
|
||||
# 退出系统视图
|
||||
send_and_wait("quit", ">")
|
||||
|
||||
# 强制保存配置
|
||||
send_and_wait("save force", ">", timeout=30)
|
||||
|
||||
return True
|
||||
|
||||
def get_onu_events(self, port_id: str) -> list:
|
||||
"""
|
||||
查询 ONU 上下线事件记录
|
||||
命令: display epon onu-event interface Onu{port_id}
|
||||
返回: [{'date', 'time', 'event', 'status', 'datetime_str'}, ...]
|
||||
时间按倒序(最新在前)返回
|
||||
"""
|
||||
output = self.execute_command(
|
||||
f"display epon onu-event interface Onu{port_id}"
|
||||
)
|
||||
output = self._clean_output(output)
|
||||
events = []
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
m = re.match(r'(\d{4}/\d{2}/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(.+)', line)
|
||||
if not m:
|
||||
continue
|
||||
date_str, time_str, rest = m.group(1), m.group(2), m.group(3).strip()
|
||||
# 最后一个单词是 ONU Status(Up/Offline),前面整体是 Event 名称
|
||||
parts = rest.rsplit(None, 1)
|
||||
if len(parts) == 2:
|
||||
event, status = parts[0].strip(), parts[1].strip()
|
||||
else:
|
||||
event, status = rest, ''
|
||||
events.append({
|
||||
'date': date_str,
|
||||
'time': time_str,
|
||||
'event': event,
|
||||
'status': status,
|
||||
'datetime_str': f"{date_str} {time_str}",
|
||||
})
|
||||
return events
|
||||
|
||||
def close(self):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
|
||||
Reference in New Issue
Block a user