diff --git a/.gitignore b/.gitignore index ce855da..f2b21cf 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ logs/ # Temp /tmp/ *.tmp +# Added by code-review-graph +.code-review-graph/ + +# Claude Code +.claude/ +.mcp.json diff --git a/CLAUDE.md b/CLAUDE.md index b896b56..d9712a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -490,3 +490,42 @@ alembic downgrade -1 - [Casdoor 文档](https://casdoor.org/) - [项目详细设计](./系统设计文档.md) - [开发计划](./开发计划.md) + + +## MCP Tools: code-review-graph + +**IMPORTANT: This project has a knowledge graph. ALWAYS use the +code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore +the codebase.** The graph is faster, cheaper (fewer tokens), and gives +you structural context (callers, dependents, test coverage) that file +scanning cannot. + +### When to use graph tools FIRST + +- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep +- **Understanding impact**: `get_impact_radius` instead of manually tracing imports +- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files +- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for +- **Architecture questions**: `get_architecture_overview` + `list_communities` + +Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. + +### Key Tools + +| Tool | Use when | +| ------ | ---------- | +| `detect_changes` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context` | Need source snippets for review — token-efficient | +| `get_impact_radius` | Understanding blast radius of a change | +| `get_affected_flows` | Finding which execution paths are impacted | +| `query_graph` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes` | Finding functions/classes by name or keyword | +| `get_architecture_overview` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | + +### Workflow + +1. The graph auto-updates on file changes (via hooks). +2. Use `detect_changes` for code review. +3. Use `get_affected_flows` to understand impact. +4. Use `query_graph` pattern="tests_for" to check coverage. diff --git a/OLT时间同步.md b/OLT时间同步.md new file mode 100644 index 0000000..f7a3a1f --- /dev/null +++ b/OLT时间同步.md @@ -0,0 +1,21 @@ +H3C OLT 批量配置脚本(直接复制执行) +``` +system-view + undo ntp-service unicast-server 172.16.0.254 + ntp-service unicast-server 172.16.1.252 + clock timezone Beijing add 08:00:00 + quit + save force + ``` +作用说明: +第2行:删掉旧的内网 NTP(172.16.0.254,因为它用的是本地假时间)。 +第3行:指向新配好的 Windows 时间服务器(172.16.1.252,它直连国家授时中心)。 +第4行:把设备显示时区改成北京时间(东八区),解决时间少8小时的问题。 +第6行:强制保存配置,防止重启丢失(加 force 是为了跳过确认提示,方便批量执行)。 +验证方法: +全部刷完等大概 1到2分钟后,执行以下两条命令看结果: +display clock +display ntp-service sessions +display clock 必须看到带有 Beijing 字样,且时间与当前实际北京时间一致。 +display ntp-service sessions 必须看到 172.16.1.252 前面带有 [12345] 标记,且 offset(偏差)在几毫秒以内。 +注意:如果某些 OLT 之前没有配过 172.16.0.254,执行第2行时可能会报错提示“找不到该配置”,直接忽略该报错即可,不影响后续命令执行。 \ No newline at end of file diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py index 4413062..d3f578c 100644 --- a/backend/app/api/v1/audit.py +++ b/backend/app/api/v1/audit.py @@ -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, diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py index 59c610d..328c7a0 100644 --- a/backend/app/api/v1/devices.py +++ b/backend/app/api/v1/devices.py @@ -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() diff --git a/backend/app/api/v1/import_data.py b/backend/app/api/v1/import_data.py index eb7abcd..2602075 100644 --- a/backend/app/api/v1/import_data.py +++ b/backend/app/api/v1/import_data.py @@ -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'] } diff --git a/backend/app/api/v1/olt.py b/backend/app/api/v1/olt.py index 1683bc3..bed40e5 100644 --- a/backend/app/api/v1/olt.py +++ b/backend/app/api/v1/olt.py @@ -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" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 10abf6a..18ffbf6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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") diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index fd4aa91..0a27162 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -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 + diff --git a/backend/app/services/imc_service.py b/backend/app/services/imc_service.py new file mode 100644 index 0000000..61f7201 --- /dev/null +++ b/backend/app/services/imc_service.py @@ -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"(\d+)", 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 diff --git a/backend/app/services/import_service.py b/backend/app/services/import_service.py index 0f9e765..5c0e0f8 100644 --- a/backend/app/services/import_service.py +++ b/backend/app/services/import_service.py @@ -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} diff --git a/backend/app/services/ssh_service.py b/backend/app/services/ssh_service.py index 0a28e6b..fb8d18a 100644 --- a/backend/app/services/ssh_service.py +++ b/backend/app/services/ssh_service.py @@ -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: diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 4f82403..6ddd82d 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -292,7 +292,7 @@ const allNavItems = [ }, { path: '/audit', - label: '审计日志', + label: '记录查询', adminOnly: true, icon: ` @@ -322,7 +322,7 @@ const mobileNavItems = computed(() => navItems.value.slice(0, 3)) const extraNavItems = computed(() => navItems.value.slice(3).map((item, i) => ({ ...item, - desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '查看操作审计记录', '系统信息与说明'][i] || '' + desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '更换台账与审计日志', '系统信息与说明'][i] || '' })) ) @@ -337,7 +337,8 @@ const pageNameMap = { '/users': '用户管理', '/roles': '角色权限', '/settings': '系统设置', - '/audit': '审计日志', + '/replacements': '记录查询', + '/audit': '记录查询', } const currentPageName = computed(() => pageNameMap[route.path] || '页面') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2da15be..7ae2b22 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -64,8 +64,8 @@ const routes = [ }, { path: '/audit', - name: 'AuditLog', - component: () => import('../views/AuditLog.vue'), + name: 'RecordsLog', + component: () => import('../views/RecordsLog.vue'), meta: { requiresRole: 'admin' } }, { diff --git a/frontend/src/utils/datetime.js b/frontend/src/utils/datetime.js new file mode 100644 index 0000000..b73d8b3 --- /dev/null +++ b/frontend/src/utils/datetime.js @@ -0,0 +1,14 @@ +/** + * 格式化后端返回的 UTC 时间戳为北京时间(UTC+8) + * + * 后端用 datetime.utcnow() 存储,JSON 序列化无时区后缀, + * JavaScript 把无后缀字符串当本地时间处理,导致少 8 小时。 + * 补上 'Z' 告知 JS 这是 UTC,浏览器会自动转成本地时间(北京 = UTC+8)。 + */ +export function fmtTime(t, { slice } = {}) { + if (!t) return '—' + const s = String(t) + const iso = /[Z+]/.test(s) ? s : s + 'Z' + const result = new Date(iso).toLocaleString('zh-CN', { hour12: false }) + return slice ? result.slice(0, slice) : result +} diff --git a/frontend/src/views/AuditLog.vue b/frontend/src/views/AuditLog.vue index 6e14e69..751cbd6 100644 --- a/frontend/src/views/AuditLog.vue +++ b/frontend/src/views/AuditLog.vue @@ -113,6 +113,7 @@ import { ref, computed, onMounted } from 'vue' import { ElMessage } from '../utils/message' import { getAuditLogs, getAuditLogDetail, exportAuditLogs } from '../api/audit' +import { fmtTime } from '../utils/datetime' const logs = ref([]) const total = ref(0) @@ -137,7 +138,7 @@ const actionTypes = [ const typeLabel = (t) => actionTypes.find(x => x.value === t)?.label || t const statusLabel = (s) => ({ success: '成功', failed: '失败', error: '错误' }[s] || s) -const fmtTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' +// fmtTime 从 utils/datetime 导入,已在文件顶部 const buildParams = () => { const p = { page: page.value, page_size: pageSize.value } diff --git a/frontend/src/views/DeviceList.vue b/frontend/src/views/DeviceList.vue index 9de7afe..4bf2324 100644 --- a/frontend/src/views/DeviceList.vue +++ b/frontend/src/views/DeviceList.vue @@ -185,6 +185,24 @@ 更新 + + +