6 Commits

Author SHA1 Message Date
v6ole ec1d9860ff refactor: 移除本地前端容器,仅保留远程部署
本地前端因 Casdoor OAuth 回调固定指向 onu.dhdx.fun,无法完成
登录流程,没有实际用途。转为仅远程服务器部署。

本地服务器现仅需 2 容器:backend + celery-worker(含beat)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:36:03 +08:00
v6ole 4488e0ef42 fix: 路由顺序导致 422 + 彻底消除 Header(...) 隐患
1. devices.py: /tags 和 /export/csv 移到 /{device_id}(int) 之前,
   避免 FastAPI 将 'tags'/'export' 当作 device_id 解析失败返回 422
2. audit.py: /logs/export/csv 移到 /logs/{log_id}(int) 之前,同上
3. auth.py: Header(...) → Header(None),统一为手动 401 返回,
   消除全局最后一个 Header(...) 导致的 422 隐患

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 11:49:53 +08:00
v6ole e87e3ebe4b refactor: 合并 celery-beat 到 celery-worker (4→3容器)
celery-worker 加 -B 参数启动嵌入式 Beat,省去独立 celery-beat 容器。
定时任务由代码中 beat_schedule 驱动,schedule 文件保持持久化。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 11:41:58 +08:00
v6ole 8dc66d7f88 fix: el-pagination 弃用属性 small → size="small"
消除 Element Plus 3.0 deprecated 控制台警告。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 11:33:17 +08:00
v6ole a9551d87f5 fix: 快速扫描前端超时 30s→180s
快速扫描需要 SSH 连接 30+ 台 OLT 并解析入库,后端实测 61 秒,
Axios 默认 30 秒超时导致请求在前端被中断。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 11:24:23 +08:00
v6ole 0d3fa8dd51 fix: 修复 /api/health 404 和扫描/发现 500 错误
1. /api/health 404: 前端 fetchVersion 调用 /api/health,但后端只有 /health。
   OpenResty 保留完整路径,需要在后端添加 /api/health 端点。

2. 扫描/发现 500: check_service.py 的连接池 _get_cached_ssh 缓存了
   SSH 连接,但调用方在 finally 中 ssh.close() 关闭连接。第二次调用
   从缓存拿到已关闭的连接导致执行失败。改为异常时清除缓存、正常时
   保留连接(5分钟TTL自动管理生命周期)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 11:15:01 +08:00
9 changed files with 115 additions and 126 deletions
+39 -39
View File
@@ -49,45 +49,6 @@ def get_audit_logs(
}
@router.get("/logs/{log_id}")
def get_audit_log_detail(
log_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取单条审计日志详情"""
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
if not log:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="日志不存在")
return _fmt(log, detail=True)
@router.get("/stats")
def get_audit_stats(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""审计日志统计(最近N天)"""
from datetime import timedelta
from sqlalchemy import func
since = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
.filter(AuditLog.action_time >= since)
.group_by(AuditLog.action_type, AuditLog.status)
.all()
)
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
by_type = {}
for row in rows:
if row.action_type not in by_type:
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
by_type[row.action_type][row.status] = row.cnt
return {"total": total, "days": days, "by_type": by_type}
@router.get("/logs/export/csv")
def export_audit_logs(
start_time: Optional[datetime] = Query(None),
@@ -130,6 +91,45 @@ def export_audit_logs(
)
@router.get("/logs/{log_id}")
def get_audit_log_detail(
log_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取单条审计日志详情"""
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
if not log:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="日志不存在")
return _fmt(log, detail=True)
@router.get("/stats")
def get_audit_stats(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""审计日志统计(最近N天)"""
from datetime import timedelta
from sqlalchemy import func
since = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
.filter(AuditLog.action_time >= since)
.group_by(AuditLog.action_type, AuditLog.status)
.all()
)
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
by_type = {}
for row in rows:
if row.action_type not in by_type:
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
by_type[row.action_type][row.status] = row.cnt
return {"total": total, "days": days, "by_type": by_type}
def _fmt(r: AuditLog, detail: bool = False) -> dict:
base = {
"id": r.id,
+4 -4
View File
@@ -84,12 +84,12 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
@router.get("/permissions")
def get_my_permissions(
authorization: str = Header(..., alias="Authorization"),
authorization: str = Header(None, alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户的权限码列表"""
from app.middleware.permission_middleware import get_role_permissions
if not authorization.startswith("Bearer "):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
@@ -102,11 +102,11 @@ def get_my_permissions(
@router.get("/profile")
def get_profile(
authorization: str = Header(..., alias="Authorization"),
authorization: str = Header(None, alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户信息"""
if not authorization.startswith("Bearer "):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
+49 -49
View File
@@ -338,6 +338,55 @@ def export_replacements(
)
@router.get("/tags")
def get_all_tags(db: Session = Depends(get_db)):
"""获取所有不重复的设备标签"""
from sqlalchemy import func as _func
rows = db.query(ONUDevice.tags).filter(
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
).all()
tags = set()
for (tag_str,) in rows:
for t in tag_str.split(','):
t = t.strip()
if t:
tags.add(t)
return sorted(tags)
@router.get("/export/csv")
def export_devices_csv(
region: Optional[str] = Query(None),
school_name: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""导出设备列表为 CSV"""
from sqlalchemy import func as _func
query = db.query(ONUDevice)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name == school_name)
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
for d in devices:
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
d.loid or "", d.distance_m or "", d.notes or ""])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
)
@router.get("/{device_id}", response_model=ONUDeviceResponse)
def get_device(
device_id: int,
@@ -701,52 +750,3 @@ def get_optical_power_history(
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
for r in rows
]
@router.get("/tags")
def get_all_tags(db: Session = Depends(get_db)):
"""获取所有不重复的设备标签"""
from sqlalchemy import func as _func
rows = db.query(ONUDevice.tags).filter(
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
).all()
tags = set()
for (tag_str,) in rows:
for t in tag_str.split(','):
t = t.strip()
if t:
tags.add(t)
return sorted(tags)
@router.get("/export/csv")
def export_devices_csv(
region: Optional[str] = Query(None),
school_name: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""导出设备列表为 CSV"""
from sqlalchemy import func as _func
query = db.query(ONUDevice)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name == school_name)
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
for d in devices:
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
d.loid or "", d.distance_m or "", d.notes or ""])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
)
+6
View File
@@ -112,3 +112,9 @@ def health_check():
status["db"] = "error"
status["status"] = "degraded"
return status
@app.get("/api/health")
def api_health_check():
"""API 路径下的健康检查(用于前端通过 /api/ 代理访问)"""
return health_check()
+10 -6
View File
@@ -103,8 +103,10 @@ class CheckService:
"online": online_count,
"offline": offline_count,
}
finally:
ssh.close()
except Exception:
# 连接异常时清除缓存,下次自动重连
_conn_pool.pop(olt_id, None)
raise
def check_single_device(self, device_id: int) -> Dict:
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
@@ -251,8 +253,9 @@ class CheckService:
"offline": offline_count,
"new_discovered": new_count,
}
finally:
ssh.close()
except Exception:
_conn_pool.pop(olt_id, None)
raise
async def scan_olt(self, olt_id: int) -> Dict:
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
@@ -300,8 +303,9 @@ class CheckService:
"devices": devices,
"duplicates": duplicates,
}
finally:
ssh.close()
except Exception:
_conn_pool.pop(olt_id, None)
raise
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
for mac, records in duplicate_dict.items():
+3 -24
View File
@@ -17,7 +17,7 @@ services:
celery-worker:
build: ./backend
command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms
command: celery -A celery_worker.celery_app worker -B --loglevel=info -Q h3c_onu_ms --schedule=/tmp/celerybeat-schedule
env_file:
- ./backend/.env
volumes:
@@ -27,26 +27,5 @@ services:
backend:
condition: service_healthy
celery-beat:
build: ./backend
command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
env_file:
- ./backend/.env
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
frontend:
build: ./frontend
ports:
- "18002:80"
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
interval: 15s
timeout: 10s
retries: 5
# frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动
# 部署命令见 .claude/rules/07-remote-operations.md
+1 -1
View File
@@ -83,7 +83,7 @@
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
size="small"
@change="fetchLogs"
/>
</div>
+1 -1
View File
@@ -938,7 +938,7 @@ const dismissNewDevice = async (id) => {
const runQuickScan = async () => {
quickScanning.value = true
try {
const { data } = await request.post('/olt/quick-scan')
const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
quickScanResult.value = data
quickScanVisible.value = true
} catch (error) {
+2 -2
View File
@@ -124,7 +124,7 @@
:total="repTotal"
:page-sizes="[50, 100, 200]"
layout="total, sizes, prev, pager, next"
small
size="small"
@size-change="repLoad"
@current-change="repLoad"
/>
@@ -203,7 +203,7 @@
:total="auditTotal"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
size="small"
@change="fetchAudit"
/>
</div>