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:
2026-05-12 10:36:06 +08:00
parent eaabebbceb
commit 5aadbc78c6
24 changed files with 2830 additions and 45 deletions
+4 -3
View File
@@ -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,
+241 -2
View File
@@ -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()
+6 -3
View File
@@ -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']
}
+58
View File
@@ -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"