初始化

This commit is contained in:
2026-04-02 23:40:04 +08:00
commit 381ea7085d
107 changed files with 11858 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# 应用配置
APP_NAME=H3C-ONU-MS
DEBUG=false
SECRET_KEY=your-secret-key-change-this
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
# Redis配置
REDIS_URL=redis://localhost:6379/0
# Casdoor配置
CASDOOR_ENDPOINT=https://casdoor.example.com
CASDOOR_CLIENT_ID=your_client_id
CASDOOR_CLIENT_SECRET=your_client_secret
CASDOOR_ORG_NAME=your_org
CASDOOR_APP_NAME=h3c-onu-ms
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
CASDOOR_REDIRECT_URL=http://localhost:5173/callback
# SSH配置
SSH_TIMEOUT=30
# 任务配置
CHECK_INTERVAL=1800
MANUAL_COOLDOWN=300
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
# 使用清华镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+40
View File
@@ -0,0 +1,40 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core.config import settings
from app.core.database import Base
from app.models import device
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,47 @@
"""add location to olt_devices
Revision ID: 32c84173ef05
Revises: 5c24b07e0f9c
Create Date: 2026-04-02 10:52:19.883293
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '32c84173ef05'
down_revision: Union[str, None] = '5c24b07e0f9c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.add_column('olt_devices', sa.Column('location', sa.String(length=200), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('olt_devices', 'location')
op.create_table('users',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
# ### end Alembic commands ###
@@ -0,0 +1,47 @@
"""add port_id to onu_devices
Revision ID: 3e108afa9bba
Revises: add_onu_extended_fields
Create Date: 2026-04-02 20:30:23.475918
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '3e108afa9bba'
down_revision: Union[str, None] = 'add_onu_extended_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.add_column('onu_devices', sa.Column('port_id', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('onu_devices', 'port_id')
op.create_table('users',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
# ### end Alembic commands ###
@@ -0,0 +1,66 @@
"""add place_type and notes fields
Revision ID: 5c24b07e0f9c
Revises:
Create Date: 2026-04-01 17:51:21.451465
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '5c24b07e0f9c'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('role_permissions')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.drop_table('permissions')
op.add_column('onu_devices', sa.Column('place_type', sa.String(length=50), nullable=True))
op.drop_column('onu_devices', 'location_type')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('onu_devices', sa.Column('location_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
op.drop_column('onu_devices', 'place_type')
op.create_table('role_permissions',
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('permission_id', sa.BIGINT(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('role_permissions_permission_id_fkey')),
sa.PrimaryKeyConstraint('role', 'permission_id', name=op.f('role_permissions_pkey'))
)
op.create_table('users',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_table('permissions',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('module', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('permissions_pkey')),
sa.UniqueConstraint('code', name=op.f('permissions_code_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""添加距离、LOID、型号等扩展字段
Revision ID: add_onu_extended_fields
Revises: 32c84173ef05
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'add_onu_extended_fields'
down_revision = '32c84173ef05'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ONUDevice 表新增字段
op.add_column('onu_devices', sa.Column('loid', sa.String(50), nullable=True))
op.add_column('onu_devices', sa.Column('model', sa.String(100), nullable=True))
op.add_column('onu_devices', sa.Column('distance_m', sa.Integer, nullable=True))
# DeviceStatusHistory 表新增字段
op.add_column('device_status_history', sa.Column('distance_m', sa.Integer, nullable=True))
def downgrade() -> None:
op.drop_column('device_status_history', 'distance_m')
op.drop_column('onu_devices', 'distance_m')
op.drop_column('onu_devices', 'model')
op.drop_column('onu_devices', 'loid')
View File
View File
View File
+88
View File
@@ -0,0 +1,88 @@
"""认证 API"""
import base64
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.casdoor import casdoor_sdk
from app.core.security import create_access_token
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token, UserInfo
from datetime import datetime
router = APIRouter(prefix="/api/auth", tags=["认证"])
def decode_jwt_payload(token: str) -> dict:
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
payload_b64 = token.split(".")[1]
rem = len(payload_b64) % 4
if rem:
payload_b64 += "=" * (4 - rem)
return json.loads(base64.urlsafe_b64decode(payload_b64))
@router.get("/login")
def login():
"""获取 Casdoor 登录 URL"""
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
class CallbackRequest(BaseModel):
code: str
state: str
@router.post("/callback", response_model=Token)
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
"""Casdoor 登录回调"""
try:
token_response = casdoor_sdk.get_oauth_token(code=body.code)
if isinstance(token_response, dict) and "error" in token_response:
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
if not access_token:
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
casdoor_user = decode_jwt_payload(access_token)
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
if not user:
user = User(
casdoor_id=casdoor_user["sub"],
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
email=casdoor_user.get("email"),
role="user"
)
db.add(user)
user.last_login = datetime.utcnow()
db.commit()
jwt_token = create_access_token({"sub": str(user.id), "username": user.username})
return {"access_token": jwt_token}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/profile", response_model=UserInfo)
def get_profile(token: str, db: Session = Depends(get_db)):
"""获取当前用户信息"""
from app.core.security import verify_token
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="无效的令牌")
user = db.query(User).filter(User.id == int(payload["sub"])).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
+86
View File
@@ -0,0 +1,86 @@
"""状态检查 API"""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Depends
from celery.result import AsyncResult
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.services.check_service import CheckService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/check", tags=["状态检查"])
class CheckResult(BaseModel):
olt_id: int
olt_name: str
online: Optional[int] = 0
offline: Optional[int] = 0
success: bool
class CheckError(BaseModel):
olt_id: int
olt_name: str
error: str
@router.post("/status")
def trigger_check():
"""手动触发状态检查"""
try:
task = check_all_devices.delay()
return {"task_id": task.id, "status": "started"}
except Exception as e:
logger.error(f"触发状态检查失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
@router.get("/status/{task_id}")
def get_check_status(task_id: str):
"""查询状态检查任务进度和结果"""
task_result = AsyncResult(task_id, app=celery_app)
state = task_result.state
result = {
"task_id": task_id,
"status": state,
"progress": None,
"result": None
}
if state == 'PROGRESS':
result["progress"] = task_result.info
if state == 'SUCCESS':
result["result"] = task_result.result
elif state == 'FAILURE':
result["error"] = str(task_result.info)
return result
@router.post("/scan/{olt_id}")
def scan_olt(olt_id: int, db: Session = Depends(get_db)):
"""扫描单台 OLT,预览发现的设备(不写入数据库)"""
try:
service = CheckService(db)
result = asyncio.run(service.scan_olt(olt_id))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/discover/{olt_id}")
def discover_olt(olt_id: int, db: Session = Depends(get_db)):
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
try:
service = CheckService(db)
result = asyncio.run(service.check_olt_devices(olt_id))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+231
View File
@@ -0,0 +1,231 @@
"""设备管理 API"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice
from app.schemas.device import DeviceListResponse, ONUDeviceResponse
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
@router.get("", response_model=DeviceListResponse)
def get_devices(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
region: str = None,
school_name: str = None,
keyword: str = None,
status: str = None, # online / offline
db: Session = Depends(get_db)
):
"""获取设备列表"""
# 子查询:每台设备最新一条状态记录
from sqlalchemy import func
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_history = (
db.query(DeviceStatusHistory)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
query = db.query(ONUDevice)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name.contains(school_name))
if keyword:
query = query.filter(
or_(
ONUDevice.mac_address.contains(keyword.lower()),
ONUDevice.building.contains(keyword),
ONUDevice.place_type.contains(keyword),
ONUDevice.school_name.contains(keyword),
ONUDevice.region.contains(keyword),
ONUDevice.room_number.contains(keyword),
)
)
if status in ("online", "offline", "unknown"):
if status in ("online", "offline"):
query = query.join(
latest_history,
ONUDevice.id == latest_history.c.onu_device_id
).filter(latest_history.c.status == status)
else:
# unknown:最新状态为 unknown,或没有任何状态记录
query = query.outerjoin(
latest_history,
ONUDevice.id == latest_history.c.onu_device_id
).filter(
or_(
latest_history.c.onu_device_id == None,
latest_history.c.status == "unknown"
)
)
# 多级排序:区域 > 学校名称 > 楼宇 > 房间号(均为升序)
query = query.order_by(
asc(ONUDevice.region),
asc(ONUDevice.school_name),
asc(ONUDevice.building),
asc(ONUDevice.room_number)
)
total = query.count()
items = query.offset(skip).limit(limit).all()
# 获取每个设备最新的状态(批量,避免 N+1)
device_ids = [item.id for item in items]
history_map = {}
if device_ids:
histories = (
db.query(DeviceStatusHistory)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.filter(DeviceStatusHistory.onu_device_id.in_(device_ids))
.all()
)
history_map = {h.onu_device_id: h for h in histories}
# 批量加载 OLT 信息
olt_ids = {item.olt_id for item in items if item.olt_id}
olt_map = {}
if olt_ids:
olts = db.query(OLTDevice).filter(OLTDevice.id.in_(olt_ids)).all()
olt_map = {o.id: o for o in olts}
result_items = []
for item in items:
latest_status = history_map.get(item.id)
olt = olt_map.get(item.olt_id)
item_dict = {
"id": item.id,
"mac_address": item.mac_address,
"olt_id": item.olt_id,
"region": item.region,
"school_name": item.school_name,
"building": item.building,
"place_type": item.place_type,
"room_number": item.room_number,
"notes": item.notes,
"status": latest_status.status if latest_status else None,
"distance_m": latest_status.distance_m if latest_status else None,
"slot_number": item.slot_number,
"port_number": item.port_number,
"port_id": item.port_id,
"model": item.model,
"olt_location": olt.location if olt else None,
"created_at": item.created_at
}
result_items.append(ONUDeviceResponse(**item_dict))
return {"total": total, "items": result_items}
@router.get("/regions")
def get_regions(db: Session = Depends(get_db)):
"""获取所有区域列表"""
regions = db.query(distinct(ONUDevice.region)).filter(
ONUDevice.region.isnot(None),
ONUDevice.region != ''
).order_by(ONUDevice.region).all()
return [r[0] for r in regions]
@router.get("/{device_id}", response_model=ONUDeviceResponse)
def get_device(device_id: int, db: Session = Depends(get_db)):
"""获取设备详情"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="设备不存在")
latest_status = db.query(DeviceStatusHistory).filter(
DeviceStatusHistory.onu_device_id == device.id
).order_by(desc(DeviceStatusHistory.checked_at)).first()
olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() if device.olt_id else None
return ONUDeviceResponse(
id=device.id,
mac_address=device.mac_address,
olt_id=device.olt_id,
region=device.region,
school_name=device.school_name,
building=device.building,
place_type=device.place_type,
room_number=device.room_number,
notes=device.notes,
status=latest_status.status if latest_status else None,
distance_m=latest_status.distance_m if latest_status else None,
slot_number=device.slot_number,
port_number=device.port_number,
port_id=device.port_id,
model=device.model,
olt_location=olt.location if olt else None,
created_at=device.created_at
)
@router.post("/{device_id}/refresh")
def refresh_device_status(device_id: int, db: Session = Depends(get_db)):
"""通过 SSH 单独更新一台设备的状态和距离"""
from app.services.check_service import CheckService
try:
service = CheckService(db)
result = service.check_single_device(device_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class DeviceUpdate(BaseModel):
region: Optional[str] = None
school_name: Optional[str] = None
building: Optional[str] = None
room_number: Optional[str] = None
notes: Optional[str] = None
@router.delete("/status/all")
def clear_all_status(db: Session = Depends(get_db)):
"""清空所有设备状态历史记录"""
db.query(DeviceStatusHistory).delete()
db.commit()
return {"message": "已清空所有设备状态"}
@router.put("/{device_id}")
def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_db)):
"""更新设备信息(区域、学校、楼宇、房间号、备注)"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
device.region = body.region
device.school_name = body.school_name
device.building = body.building or None
device.room_number = body.room_number or None
device.notes = body.notes or None
db.commit()
return {"message": "更新成功"}
+77
View File
@@ -0,0 +1,77 @@
"""数据导入 API"""
from fastapi import APIRouter, UploadFile, File, Depends, Response
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.import_service import ImportService
import shutil
import io
from openpyxl import Workbook
router = APIRouter(prefix="/api/import", tags=["数据导入"])
@router.get("/template")
def download_template():
"""下载导入数据模板"""
wb = Workbook()
ws = wb.active
ws.title = "ONU设备导入模板"
# 表头:序号|区域|学校名称|楼宇|场所类型|房间号|MAC地址|备注
headers = ["mac_address", "region", "school_name", "building", "place_type", "room_number", "notes"]
ws.append(headers)
# 示例数据
example_data = [
["AA:BB:CC:DD:EE:01", "区域1", "学校1", "1号楼", "教室", "101", ""],
["AA:BB:CC:DD:EE:02", "区域1", "学校1", "1号楼", "办公室", "102", ""],
]
for row in example_data:
ws.append(row)
# 设置列宽
ws.column_dimensions['A'].width = 20 # mac_address
ws.column_dimensions['B'].width = 12 # region
ws.column_dimensions['C'].width = 18 # school_name
ws.column_dimensions['D'].width = 12 # building
ws.column_dimensions['E'].width = 12 # place_type
ws.column_dimensions['F'].width = 12 # room_number
ws.column_dimensions['G'].width = 20 # notes
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
return Response(
content=output.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=onu_import_template.xlsx"}
)
@router.post("/upload")
async def upload_excel(
file: UploadFile = File(...),
olt_id: int = None,
db: Session = Depends(get_db)
):
"""上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)"""
file_path = f"/tmp/{file.filename}"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
service = ImportService(db)
records = service.parse_excel(file_path)
validation = service.validate_data(records)
success_count = 0
if validation['valid']:
result = service.import_devices(validation['valid'], olt_id)
success_count = result['success']
return {
"success": success_count,
"failed": validation['invalid']
}
+402
View File
@@ -0,0 +1,402 @@
"""OLT 设备管理 API"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.database import get_db
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"
location: str = ""
description: str = ""
class OLTEdit(BaseModel):
username: str
password: str = None
slot_command: str = "display onu slot"
location: str = ""
description: str = ""
@router.get("/devices")
def get_devices(db: Session = Depends(get_db)):
return db.query(OLTDevice).all()
@router.post("/devices")
def create_device(device: OLTCreate, db: Session = Depends(get_db)):
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)):
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not db_device:
raise HTTPException(status_code=404, detail="设备不存在")
db_device.username = device.username
if device.password:
db_device.password = device.password
db_device.slot_command = device.slot_command
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)):
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="设备不存在")
# 检查是否有关联的 ONU 设备
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)):
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',
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
return FileResponse(
path="/home/v6ole/pyproject/H3ConuMS2/backend/templates/OLT设备导入模板.xlsx",
filename="OLT设备导入模板.xlsx"
)
@router.get("/duplicate-macs")
def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
"""查询重复 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)):
"""删除重复 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)):
"""通过 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)):
"""查询新发现的设备列表(待补全信息)"""
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)):
"""补全新设备信息,完成后从 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)):
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
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)):
"""多线程对所有 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)):
"""对所有 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]
+59
View File
@@ -0,0 +1,59 @@
"""业务下发 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from app.core.database import get_db
from app.services.provision_service import ProvisionService
router = APIRouter(prefix="/api/provision", tags=["业务下发"])
class ProvisionRequest(BaseModel):
device_id: int
class BatchProvisionRequest(BaseModel):
device_ids: List[int]
class ProvisionResponse(BaseModel):
success: bool
message: str
device_id: Optional[int] = None
mac_address: Optional[str] = None
olt_ip: Optional[str] = None
port: Optional[str] = None
@router.post("/service", response_model=ProvisionResponse)
def provision_single_device(
request: ProvisionRequest,
db: Session = Depends(get_db)
):
"""下发业务到单个设备"""
service = ProvisionService(db)
result = service.provision_device(request.device_id)
if result.get("success"):
return ProvisionResponse(
success=True,
message=f"业务下发成功,MAC: {result['mac_address']}, 端口: {result['port']}",
device_id=result["device_id"],
mac_address=result["mac_address"],
olt_ip=result.get("olt_ip"),
port=result.get("port")
)
else:
raise HTTPException(status_code=400, detail=result.get("error", "业务下发失败"))
@router.post("/batch", response_model=dict)
def provision_batch_devices(
request: BatchProvisionRequest,
db: Session = Depends(get_db)
):
"""批量下发业务"""
service = ProvisionService(db)
result = service.batch_provision(request.device_ids)
return result
+118
View File
@@ -0,0 +1,118 @@
"""统计 API"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func, case
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory
from datetime import datetime, timedelta
router = APIRouter(prefix="/api/stats", tags=["统计"])
@router.get("/dashboard")
def get_dashboard(db: Session = Depends(get_db)):
"""仪表板统计:总体、城区、城郊、乡镇在线率"""
# 每台设备最新状态子查询
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_status_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
DeviceStatusHistory.status
)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
rows = (
db.query(
ONUDevice.region,
ONUDevice.school_name,
func.count().label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.group_by(ONUDevice.region, ONUDevice.school_name)
.order_by(ONUDevice.region, ONUDevice.school_name)
.all()
)
overall = {"total": 0, "online": 0, "offline": 0}
urban_schools, suburban_schools = [], []
rural_towns = {}
for row in rows:
region = row.region or ""
total = int(row.total or 0)
online = int(row.online or 0)
offline = int(row.offline or 0)
overall["total"] += total
overall["online"] += online
overall["offline"] += offline
school_stat = {"name": row.school_name or "未知", "total": total, "online": online, "offline": offline}
if region == "城区":
urban_schools.append(school_stat)
elif region == "城郊":
suburban_schools.append(school_stat)
else:
if region not in rural_towns:
rural_towns[region] = {"region": region, "total": 0, "online": 0, "offline": 0, "schools": []}
rural_towns[region]["total"] += total
rural_towns[region]["online"] += online
rural_towns[region]["offline"] += offline
rural_towns[region]["schools"].append(school_stat)
def sort_by_rate(items):
return sorted(items, key=lambda x: x["online"] / x["total"] if x["total"] > 0 else 0)
def agg(items):
return {"total": sum(s["total"] for s in items), "online": sum(s["online"] for s in items), "offline": sum(s["offline"] for s in items)}
rural_list = sort_by_rate(list(rural_towns.values()))
return {
"overall": overall,
"urban": {**agg(urban_schools), "schools": sort_by_rate(urban_schools)},
"suburban": {**agg(suburban_schools), "schools": sort_by_rate(suburban_schools)},
"rural": {**agg(list(rural_towns.values())), "towns": rural_list},
}
@router.get("/summary")
def get_summary(db: Session = Depends(get_db)):
"""获取统计摘要"""
total = db.query(ONUDevice).count()
latest_status = db.query(
DeviceStatusHistory.status,
func.count(DeviceStatusHistory.id)
).group_by(DeviceStatusHistory.status).all()
status_dict = dict(latest_status)
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
@router.get("/trend")
def get_trend(days: int = 7, db: Session = Depends(get_db)):
"""获取状态趋势数据"""
start_date = datetime.utcnow() - timedelta(days=days)
history = db.query(
func.date(DeviceStatusHistory.checked_at).label('date'),
func.sum(func.case((DeviceStatusHistory.status == 'online', 1), else_=0)).label('online'),
func.sum(func.case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label('offline')
).filter(DeviceStatusHistory.checked_at >= start_date).group_by(
func.date(DeviceStatusHistory.checked_at)
).all()
return [{"date": str(h.date), "online": h.online, "offline": h.offline} for h in history]
View File
+12
View File
@@ -0,0 +1,12 @@
"""Casdoor 认证配置"""
from casdoor import CasdoorSDK
from app.core.config import settings
casdoor_sdk = CasdoorSDK(
endpoint=settings.CASDOOR_ENDPOINT,
client_id=settings.CASDOOR_CLIENT_ID,
client_secret=settings.CASDOOR_CLIENT_SECRET,
certificate=settings.casdoor_cert_content,
org_name=settings.CASDOOR_ORG_NAME,
application_name=settings.CASDOOR_APP_NAME,
)
+23
View File
@@ -0,0 +1,23 @@
"""Celery 配置"""
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"h3c_onu_ms",
broker=settings.REDIS_URL,
backend=settings.REDIS_URL
)
celery_app.conf.update(
task_serializer='json',
result_serializer='json',
accept_content=['json'],
timezone='UTC',
enable_utc=True,
beat_schedule={
'check-devices-every-30-minutes': {
'task': 'app.tasks.check_tasks.check_all_devices',
'schedule': settings.CHECK_INTERVAL,
},
},
)
+50
View File
@@ -0,0 +1,50 @@
"""应用配置"""
import os
from pathlib import Path
from pydantic_settings import BaseSettings
# 项目根目录(config.py 位于 backend/app/core/parent.parent.parent 即 backend/
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
APP_NAME: str = "H3C-ONU-MS"
DEBUG: bool = False
SECRET_KEY: str
DATABASE_URL: str
REDIS_URL: str
CASDOOR_ENDPOINT: str
CASDOOR_CLIENT_ID: str
CASDOOR_CLIENT_SECRET: str
CASDOOR_ORG_NAME: str
CASDOOR_APP_NAME: str
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
SSH_TIMEOUT: int = 30
CHECK_INTERVAL: int = 1800
MANUAL_COOLDOWN: int = 300
class Config:
env_file = str(PROJECT_ROOT / ".env")
@property
def casdoor_cert_content(self) -> str:
"""读取证书文件内容或直接返回证书字符串"""
cert = self.CASDOOR_CERTIFICATE
if not cert:
return ""
cert_path = Path(cert)
if cert_path.is_absolute():
path = cert_path
else:
# 相对路径基于项目根目录解析
path = PROJECT_ROOT / cert
if path.is_file():
return path.read_text()
return cert # 直接是 PEM 内容
settings = Settings()
+17
View File
@@ -0,0 +1,17 @@
"""数据库配置"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+22
View File
@@ -0,0 +1,22 @@
"""JWT 安全配置"""
from datetime import datetime, timedelta
from jose import JWTError, jwt
from app.core.config import settings
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
+28
View File
@@ -0,0 +1,28 @@
"""FastAPI 主应用"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(devices.router)
app.include_router(check.router)
app.include_router(import_data.router)
app.include_router(stats.router)
app.include_router(olt.router)
app.include_router(provision.router)
@app.get("/health")
def health_check():
return {"status": "ok"}
@@ -0,0 +1,27 @@
"""权限检查中间件"""
from fastapi import HTTPException, Depends
from app.core.security import verify_token
ROLE_PERMISSIONS = {
'admin': ['*'],
'area_admin': ['device.view', 'device.check'],
'school_admin': ['device.view'],
'user': ['device.view']
}
def check_permission(required_permission: str):
def permission_checker(token: str):
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="未授权")
role = payload.get('role', 'user')
permissions = ROLE_PERMISSIONS.get(role, [])
if '*' in permissions or required_permission in permissions:
return payload
raise HTTPException(status_code=403, detail="权限不足")
return permission_checker
View File
+82
View File
@@ -0,0 +1,82 @@
"""设备数据模型"""
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class OLTDevice(Base):
__tablename__ = "olt_devices"
id = Column(BigInteger, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False)
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
slot_command = Column(String(50), nullable=False)
location = Column(String(200))
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class ONUDevice(Base):
__tablename__ = "onu_devices"
id = Column(BigInteger, primary_key=True, index=True)
mac_address = Column(String(17), nullable=False, index=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"))
slot_number = Column(Integer)
port_number = Column(Integer)
port_id = Column(String(20)) # 完整端口标识,如 "1/0/2:4"
# OLT 返回的额外信息
loid = Column(String(50)) # LOID
model = Column(String(100)) # 设备型号
distance_m = Column(Integer) # 距离(米)
region = Column(String(100), index=True)
school_name = Column(String(200), index=True)
building = Column(String(100))
place_type = Column(String(50)) # 场所类型
room_number = Column(String(50))
notes = Column(Text) # 备注
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
# 关联状态历史
status_history = relationship("DeviceStatusHistory", back_populates="device", lazy="selectin")
class DeviceStatusHistory(Base):
__tablename__ = "device_status_history"
id = Column(BigInteger, primary_key=True, index=True)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"))
status = Column(String(20), nullable=False) # online, offline
distance_m = Column(Integer) # 距离(米)
checked_at = Column(TIMESTAMP, nullable=False)
response_data = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
device = relationship("ONUDevice", back_populates="status_history")
class DuplicateMac(Base):
"""重复 MAC 地址记录(同一 MAC 出现在多个端口)"""
__tablename__ = "duplicate_macs"
id = Column(BigInteger, primary_key=True, index=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
mac_address = Column(String(17), nullable=False, index=True)
# 所有出现的端口列表,JSON 格式: [{"port_id": "1/0/1:1", "status": "online"}, ...]
ports = Column(JSON, nullable=False)
first_seen_at = Column(TIMESTAMP, server_default=func.now())
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class NewDevice(Base):
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
__tablename__ = "new_devices"
id = Column(BigInteger, primary_key=True, index=True)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
discovered_at = Column(TIMESTAMP, server_default=func.now())
+21
View File
@@ -0,0 +1,21 @@
"""权限数据模型"""
from sqlalchemy import Column, BigInteger, String, Text, ForeignKey, Table
from app.core.database import Base
role_permissions = Table(
'role_permissions',
Base.metadata,
Column('role', String(50), primary_key=True),
Column('permission_id', BigInteger, ForeignKey('permissions.id'), primary_key=True)
)
class Permission(Base):
__tablename__ = "permissions"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
code = Column(String(50), unique=True, nullable=False)
module = Column(String(50))
description = Column(Text)
+19
View File
@@ -0,0 +1,19 @@
"""用户数据模型"""
from sqlalchemy import Column, BigInteger, String, Boolean, TIMESTAMP
from sqlalchemy.sql import func
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(BigInteger, primary_key=True, index=True)
casdoor_id = Column(String(100), unique=True, nullable=False)
username = Column(String(100), nullable=False)
email = Column(String(255))
role = Column(String(50), default="user")
assigned_area = Column(String(100))
assigned_school = Column(String(200))
is_active = Column(Boolean, default=True)
last_login = Column(TIMESTAMP)
created_at = Column(TIMESTAMP, server_default=func.now())
View File
+20
View File
@@ -0,0 +1,20 @@
"""认证相关 Schema"""
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfo(BaseModel):
id: int
username: str
email: Optional[str]
role: str
assigned_area: Optional[str]
assigned_school: Optional[str]
class Config:
from_attributes = True
+36
View File
@@ -0,0 +1,36 @@
"""设备相关 Schema"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ONUDeviceBase(BaseModel):
mac_address: str
olt_id: Optional[int] = None
region: Optional[str] = None
school_name: Optional[str] = None
building: Optional[str] = None
place_type: Optional[str] = None # 场所类型
room_number: Optional[str] = None
notes: Optional[str] = None # 备注
class ONUDeviceResponse(ONUDeviceBase):
id: int
status: Optional[str] = None
distance_m: Optional[int] = None
slot_number: Optional[int] = None
port_number: Optional[int] = None
port_id: Optional[str] = None # 完整端口标识,如 "1/0/2:4"
model: Optional[str] = None
olt_location: Optional[str] = None # OLT 安装位置
created_at: datetime
class Config:
from_attributes = True
class DeviceListResponse(BaseModel):
total: int
items: list[ONUDeviceResponse]
View File
+263
View File
@@ -0,0 +1,263 @@
"""设备状态检查服务"""
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice
from datetime import datetime
import re
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
if not distance_str:
return None
m = re.search(r'\d+', distance_str)
return int(m.group()) if m else None
class CheckService:
"""设备状态检查服务"""
def __init__(self, db: Session):
self.db = db
def update_status_only(self, olt_id: int) -> Dict:
"""扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。
不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。
"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, _ = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
# 全局 MAC → id 索引,只取需要的字段
existing = {
row.mac_address.lower(): row.id
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
}
for mac, onu_info in onu_info_dict.items():
onu_id = existing.get(mac)
if onu_id is None:
continue # 不在库中,跳过
status = onu_info.status
distance_m = parse_distance(onu_info.distance_str)
if status == 'online':
online_count += 1
else:
offline_count += 1
self.db.add(DeviceStatusHistory(
onu_device_id=onu_id,
status=status,
distance_m=distance_m,
checked_at=checked_at,
response_data=None,
))
self.db.commit()
return {
"online": online_count,
"offline": offline_count,
}
finally:
ssh.close()
def check_single_device(self, device_id: int) -> Dict:
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
命令格式: display onu slot {slot} | include {mac}
"""
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise Exception("设备不存在")
if not device.olt_id:
raise Exception("该设备未关联 OLT,无法查询")
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
if not olt:
raise Exception("关联的 OLT 不存在")
mac = device.mac_address.lower()
base_cmd = olt.slot_command # e.g. "display onu slot"
if device.slot_number is not None:
cmd = f"{base_cmd} {device.slot_number} | include {mac}"
else:
cmd = f"{base_cmd} | include {mac}"
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(cmd)
# 从输出中找到包含该 MAC 的行并解析
info = None
for line in output.splitlines():
if mac in line.lower():
info = ssh._parse_device_line(line, device.slot_number)
if info:
break
if info is None:
# 未找到该 MAC,视为离线
status = 'offline'
distance_m = None
else:
status = info.status
distance_m = parse_distance(info.distance_str)
self.db.add(DeviceStatusHistory(
onu_device_id=device.id,
status=status,
distance_m=distance_m,
checked_at=datetime.utcnow(),
response_data=None,
))
self.db.commit()
return {"status": status, "distance_m": distance_m}
finally:
ssh.close()
def scan_and_discover(self, olt_id: int) -> Dict:
"""扫描 OLT,更新已有设备状态,并将新发现的在线设备入库。
不标记 unknown,不修改已有设备的 olt_id/端口以外的字段。
"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
new_count = 0
existing = {
row.mac_address.lower(): row.id
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
}
for mac, onu_info in onu_info_dict.items():
onu_id = existing.get(mac)
if onu_id is None:
# 新设备:只入库在线的
if onu_info.status != 'online':
continue
onu = ONUDevice(
mac_address=mac,
olt_id=olt_id,
slot_number=onu_info.slot_number,
port_number=onu_info.port_number,
port_id=onu_info.port_id,
distance_m=parse_distance(onu_info.distance_str),
loid=onu_info.loid,
model=onu_info.model,
)
self.db.add(onu)
self.db.flush()
self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id))
onu_id = onu.id
new_count += 1
status = onu_info.status
distance_m = parse_distance(onu_info.distance_str)
if status == 'online':
online_count += 1
else:
offline_count += 1
self.db.add(DeviceStatusHistory(
onu_device_id=onu_id,
status=status,
distance_m=distance_m,
checked_at=checked_at,
response_data=None,
))
self._save_duplicate_macs(olt_id, duplicate_dict)
self.db.commit()
return {
"online": online_count,
"offline": offline_count,
"new_discovered": new_count,
}
finally:
ssh.close()
async def scan_olt(self, olt_id: int) -> Dict:
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
# 对比全局 MAC
existing_macs = {
onu.mac_address.lower()
for onu in self.db.query(ONUDevice.mac_address).all()
}
devices = []
for mac, info in onu_info_dict.items():
devices.append({
"mac_address": mac,
"status": info.status,
"distance_m": info.distance_str,
"slot_number": info.slot_number,
"port_number": info.port_number,
"port_id": info.port_id,
"loid": info.loid,
"model": info.model,
"is_new": mac not in existing_macs,
})
duplicates = []
for mac, records in duplicate_dict.items():
duplicates.append({
"mac_address": mac,
"ports": [{"port_id": r.port_id, "status": r.status} for r in records],
})
return {
"olt_id": olt_id,
"olt_ip": olt.ip_address,
"total": len(devices),
"new": sum(1 for d in devices if d["is_new"]),
"devices": devices,
"duplicates": duplicates,
}
finally:
ssh.close()
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
for mac, records in duplicate_dict.items():
ports = [{"port_id": r.port_id, "status": r.status} for r in records]
existing = self.db.query(DuplicateMac).filter(
DuplicateMac.olt_id == olt_id,
DuplicateMac.mac_address == mac
).first()
if existing:
existing.ports = ports
existing.last_seen_at = datetime.utcnow()
else:
self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports))
+117
View File
@@ -0,0 +1,117 @@
"""Excel 导入服务"""
import pandas as pd
from typing import List, Dict
from sqlalchemy.orm import Session
from app.models.device import ONUDevice
import math
# 定义字段映射:Excel列名 -> 数据库字段名
FIELD_MAPPING = {
'mac_address': 'mac_address',
'region': 'region',
'school_name': 'school_name',
'building': 'building',
'place_type': 'place_type',
'room_number': 'room_number',
'notes': 'notes',
}
def clean_value(value) -> str:
"""清理单元格值,处理NaN和None"""
if value is None:
return ''
if isinstance(value, float) and math.isnan(value):
return ''
return str(value).strip()
class ImportService:
def __init__(self, db: Session):
self.db = db
def parse_excel(self, file_path: str) -> List[Dict]:
"""解析 Excel 文件"""
df = pd.read_excel(file_path)
# 标准化列名(去除空格,转小写)
df.columns = [col.strip().lower() for col in df.columns]
# 转换为记录列表
records = []
for _, row in df.iterrows():
record = {}
for col_name, db_field in FIELD_MAPPING.items():
if col_name in row:
record[db_field] = clean_value(row[col_name])
records.append(record)
return records
def validate_data(self, records: List[Dict]) -> Dict:
"""验证数据"""
valid = []
invalid = []
for idx, record in enumerate(records):
mac = record.get('mac_address', '').strip()
if not mac:
invalid.append({'record': record, 'error': f'{idx+2}行: MAC地址缺失'})
continue
# 标准化MAC地址:统一使用横杠分隔小写格式
# 支持格式:AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF, aa:bb:cc:dd:ee:ff
mac_clean = mac.upper().replace(':', '-')
# 验证基本格式:12个十六进制字符(可能有分隔符)
hex_chars = mac_clean.replace('-', '')
if len(hex_chars) != 12 or not all(c in '0123456789ABCDEF' for c in hex_chars):
invalid.append({'record': record, 'error': f'{idx+2}行: MAC地址格式错误 "{mac}"'})
continue
# 转换为标准格式 34dc-99c8-56e0(小写4位分组)
mac_formatted = '-'.join([hex_chars[i:i+4].lower() for i in range(0, 12, 4)])
record['mac_address'] = mac_formatted
valid.append(record)
return {'valid': valid, 'invalid': invalid}
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
"""批量导入设备,存在则更新,不存在则新增"""
success_count = 0
skip_count = 0
invalid_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:
# 更新现有记录
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
else:
# 新增记录
device = ONUDevice(
mac_address=mac,
olt_id=olt_id,
region=record.get('region', ''),
school_name=record.get('school_name', ''),
building=record.get('building') or None,
place_type=record.get('place_type') or None,
room_number=record.get('room_number') or None,
notes=record.get('notes') or None
)
self.db.add(device)
success_count += 1
self.db.commit()
return {'success': success_count}
+114
View File
@@ -0,0 +1,114 @@
"""业务下发服务"""
from typing import Dict
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
from app.models.device import ONUDevice, OLTDevice
import time
class ProvisionService:
"""业务下发服务"""
def __init__(self, db: Session):
self.db = db
def provision_device(self, device_id: int) -> Dict[str, any]:
"""
业务下发流程(参照下发业务流程.md):
1. system-view
2. interface Onu{port_id}
3. uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
4. port link-type trunk
5. undo port trunk permit vlan 1
6. port trunk permit vlan 2000 3000 to 3010 4094
7. save force
"""
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
return {"success": False, "error": f"设备不存在: {device_id}"}
if not device.olt_id:
return {"success": False, "error": "设备未关联 OLT,请先进行扫描"}
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
if not olt:
return {"success": False, "error": "关联的 OLT 设备不存在"}
if not device.port_id:
return {"success": False, "error": "设备端口信息不完整,请先对 OLT 执行扫描"}
port_name = f"Onu{device.port_id}"
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
ssh.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if ssh.shell.recv_ready():
buf += ssh.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 失败")
# 进入端口
out = send_and_wait(f"interface {port_name}", "]")
if "]" not in out:
raise Exception(f"进入端口 {port_name} 失败")
# 配置 VLAN
send_and_wait("uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010", "]")
send_and_wait("port link-type trunk", "]")
send_and_wait("undo port trunk permit vlan 1", "]")
send_and_wait("port trunk permit vlan 2000 3000 to 3010 4094", "]")
# 保存配置(等待 "successfully" 出现)
save_out = send_and_wait("save force", "successfully", timeout=30)
if "successfully" not in save_out:
raise Exception("save force 未确认成功,请检查 OLT 日志")
return {
"success": True,
"device_id": device_id,
"mac_address": device.mac_address,
"olt_ip": olt.ip_address,
"port": port_name,
}
except Exception as e:
return {
"success": False,
"device_id": device_id,
"mac_address": device.mac_address,
"error": str(e),
}
finally:
ssh.close()
def batch_provision(self, device_ids: list) -> Dict[str, any]:
results = []
success_count = 0
fail_count = 0
for device_id in device_ids:
result = self.provision_device(device_id)
results.append(result)
if result.get("success"):
success_count += 1
else:
fail_count += 1
return {
"total": len(device_ids),
"success": success_count,
"failed": fail_count,
"results": results,
}
+282
View File
@@ -0,0 +1,282 @@
"""SSH 连接服务"""
import paramiko
import re
import time
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from app.core.config import settings
@dataclass
class ONUInfo:
"""ONU 设备完整信息"""
mac_address: str
status: str # online/offline
distance_m: Optional[int] = None # 距离(米)
distance_str: Optional[str] = None # 距离原始字符串,如 "<1000"
slot_number: Optional[int] = None # 插槽号
port_number: Optional[int] = None # 端口号
port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1"
loid: Optional[str] = None # LOID
model: Optional[str] = None # 设备型号
class SSHService:
"""SSH 连接和命令执行服务"""
def __init__(self, host: str, username: str, password: str, port: int = 22):
self.host = host
self.username = username
self.password = password
self.port = port
self.client: Optional[paramiko.SSHClient] = None
self.shell = None
def connect(self) -> bool:
"""建立 SSH 连接,等待初始 banner 输出完毕"""
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host,
port=self.port,
username=self.username,
password=self.password,
timeout=30
)
self.shell = self.client.invoke_shell(width=200, height=50)
# 等待登录 banner 输出完毕,直到出现命令提示符 ">"
deadline = time.time() + 10
buf = ""
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if ">" in buf:
break
else:
time.sleep(0.2)
return True
except Exception as e:
raise Exception(f"SSH 连接失败: {str(e)}")
def execute_command(self, command: str) -> str:
"""执行命令并处理 More 分页,等待命令提示符出现后返回"""
if not self.shell:
raise Exception("SSH 未连接")
self.shell.send(command + "\n")
output = ""
# 等待命令回显出现,再开始收集输出
time.sleep(0.5)
deadline = time.time() + 60
while time.time() < deadline:
if self.shell.recv_ready():
chunk = self.shell.recv(4096).decode('utf-8', errors='ignore')
output += chunk
if "---- More ----" in chunk:
self.shell.send(" ")
time.sleep(0.3)
elif ">" in chunk:
# 命令提示符出现,说明输出完毕
break
else:
time.sleep(0.2)
return output
def clear_onu_port(self, port_id: str) -> bool:
"""清除指定端口的 ONU 配置(恢复默认)
流程: system-view -> interface Onu{port_id} -> default -> Y
"""
if not self.shell:
raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> 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 失败")
# 进入端口
out = send_and_wait(f"interface Onu{port_id}", "]")
if "]" not in out:
raise Exception(f"进入端口 Onu{port_id} 失败")
# 执行 default,等待确认提示
self.shell.send("default\n")
buf = ""
deadline = time.time() + 10
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if "[Y/N]" in buf or "[y/n]" in buf:
break
else:
time.sleep(0.2)
if "[Y/N]" not in buf and "[y/n]" not in buf:
raise Exception("未收到确认提示")
# 确认
self.shell.send("Y\n")
time.sleep(1)
# 排空缓冲区
if self.shell.recv_ready():
self.shell.recv(4096)
# 退出到用户视图
send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5)
return True
def detect_loopback(self) -> dict:
"""执行环路检测,返回 {has_loop: bool, interfaces: [str]}"""
output = self.execute_command("display loopback-detection")
has_loop = "Loop is detected on following interfaces" in output
interfaces = []
if has_loop:
for line in output.splitlines():
m = re.match(r'\s+(Onu\S+)\s+', line)
if m:
interfaces.append(m.group(1))
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
def parse_onu_status(self, output: str) -> Dict[str, str]:
"""解析 ONU 状态输出"""
devices = {}
lines = output.split('\n')
for line in lines:
# 匹配包含 MAC 地址的行
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
# 提取状态字段
if 'Up' in line:
devices[mac] = 'online'
elif 'Offline' in line:
devices[mac] = 'offline'
return devices
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
"""增强解析:提取完整 ONU 信息
返回: (unique_devices, duplicate_devices)
- unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口)
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
"""
all_records: Dict[str, List[ONUInfo]] = {}
lines = output.split('\n')
current_slot = None
for line in lines:
# 检测新的插槽区域: Olt1/0/1
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
if slot_match:
current_slot = int(slot_match.group(3))
continue
# 跳过表头行和空行
if 'MAC' in line and 'LOID' in line:
continue
if not line.strip():
continue
# 解析设备行
device = self._parse_device_line(line, current_slot)
if device:
if device.mac_address not in all_records:
all_records[device.mac_address] = []
all_records[device.mac_address].append(device)
unique_devices: Dict[str, ONUInfo] = {}
duplicate_devices: Dict[str, List[ONUInfo]] = {}
for mac, records in all_records.items():
if len(records) == 1:
unique_devices[mac] = records[0]
else:
duplicate_devices[mac] = records
# unique 中保留在线的,若都离线则保留最后一条
online = [r for r in records if r.status == 'online']
unique_devices[mac] = online[0] if online else records[-1]
return unique_devices, duplicate_devices
def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]:
"""解析单行设备信息"""
# 匹配 MAC 地址
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if not mac_match:
return None
mac = mac_match.group(1).lower()
# 提取状态
status = 'offline'
if 'Up' in line:
status = 'online'
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
slot_num, port_num, port_id = None, None, None
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
if port_match:
slot_num = int(port_match.group(3)) # 第三段数字为槽位
port_num = int(port_match.group(4)) # 冒号后为端口号
port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}"
# 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A"
distance_str = None
dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line)
if dist_match:
val = dist_match.group(1)
if val != 'N/A':
distance_str = val
# 提取 LOID - MAC 后第一个非空字段
loid = None
loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE)
if loid_match:
loid_val = loid_match.group(1)
if loid_val != 'N/A' and loid_val.isdigit():
loid = loid_val
# 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A"
model = None
model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line)
if model_match:
potential_model = model_match.group(1)
if potential_model != 'N/A':
model = potential_model
return ONUInfo(
mac_address=mac,
status=status,
distance_m=None,
distance_str=distance_str,
slot_number=slot_num or slot,
port_number=port_num,
port_id=port_id,
loid=loid,
model=model
)
def close(self):
"""关闭 SSH 连接"""
if self.client:
self.client.close()
+4
View File
@@ -0,0 +1,4 @@
"""Celery 任务模块"""
from app.tasks.check_tasks import check_all_devices # noqa: F401
__all__ = ['check_all_devices']
+72
View File
@@ -0,0 +1,72 @@
"""状态检查任务"""
import traceback
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
from app.services.check_service import CheckService
@celery_app.task(bind=True)
def check_all_devices(self):
"""检查所有设备状态"""
db = SessionLocal()
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
try:
service = CheckService(db)
from app.models.device import OLTDevice
olts = db.query(OLTDevice).all()
total = len(olts)
self.update_state(state='PROGRESS', meta={'current': 0, 'total': total, 'status': f'准备检查 {total} 个OLT...'})
results = []
errors = []
total_online = 0
total_offline = 0
for idx, olt in enumerate(olts):
self.update_state(state='PROGRESS', meta={
'current': idx, 'total': total,
'status': f'检查 OLT: {olt.location or olt.ip_address}...'
})
try:
result = service.update_status_only(olt.id)
total_online += result.get('online', 0)
total_offline += result.get('offline', 0)
results.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'online': result.get('online', 0),
'offline': result.get('offline', 0),
'success': True
})
except Exception as e:
errors.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'error': str(e)
})
results.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'success': False,
'error': str(e)
})
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
return {
'success': True,
'total_olts': total,
'total_online': total_online,
'total_offline': total_offline,
'results': results,
'errors': errors
}
except Exception as e:
return {
'success': False,
'error': str(e),
'traceback': traceback.format_exc()
}
finally:
db.close()
+5
View File
@@ -0,0 +1,5 @@
"""Celery Worker 入口模块"""
from app.core.celery_app import celery_app
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
__all__ = ['celery_app']
+20
View File
@@ -0,0 +1,20 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
alembic==1.13.1
psycopg2-binary==2.9.9
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
celery==5.3.6
redis==5.0.1
paramiko==3.4.0
pandas==2.1.4
openpyxl==3.1.2
cryptography==42.0.0
casdoor==1.18.0
aiohttp>=3.9.0
PyJWT>=2.8.0
requests>=2.31.0
+11
View File
@@ -0,0 +1,11 @@
"""数据库初始化脚本"""
from app.core.database import engine, Base
from app.models import device, user, permission
def init_db():
"""创建所有表"""
Base.metadata.create_all(bind=engine)
print("数据库表创建完成")
if __name__ == "__main__":
init_db()
Binary file not shown.
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
"""测试 ONU 状态解析"""
import re
def parse_onu_status(output: str):
"""解析 ONU 状态"""
devices = {}
lines = output.split('\n')
for line in lines:
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
if 'Up' in line:
devices[mac] = 'online'
elif 'Offline' in line:
devices[mac] = 'offline'
return devices
# 测试数据
test_output = """
1484-7790-5200 12 <1000 Onu1/0/1:1 WA6520H-EGPON/A 106/ Up N/A
1484-7790-4e20 13 <1000 Onu1/0/1:2 WA6520H-EGPON/A 106/ Up N/A
1484-7790-3900 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
"""
result = parse_onu_status(test_output)
print("解析结果:")
for mac, status in result.items():
print(f" {mac}: {status}")
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN CERTIFICATE-----
MIIE2TCCAsGgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCYxDjAMBgNVBAoTBWFk
bWluMRQwEgYDVQQDDAtjZXJ0X3drMWVlZjAeFw0yNTEyMTAxMDAzMzVaFw00NTEy
MTAxMDAzMzVaMCYxDjAMBgNVBAoTBWFkbWluMRQwEgYDVQQDDAtjZXJ0X3drMWVl
ZjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK78ajnNnwnutdZ48l92
hmqa2c8lP1IcpyB7CYVTKurQxSz5iQorYOVhR1UzluSpU8yiPPeFyTRD0pH+DzqG
otc+Alvxnka5DfP1z/P0asogkALJXouRR+YtUY6i1oo5tKlDbIGJNO2aQcOOak9b
YoqSRd9y/pq/bPjte7oww29hGQc03LgbNXmIb9n5NGznWCte1c88NUrTz9Dlpn2H
r3ncOmHpqpzg5NtXughnHsF4YCF+pPIgWlC0C6MtKk0fuJysCY5wpuA620pGL4zO
6QFv920MiWsdVWxcNo0aNQkXKGjEcy3LraXux2k/sH+E0e3GRXGhWYnkrK7i8kg+
V+Nm1YFgyGbY7V3yZ0mXPxb+iMlIvz885ViGsRnKqlR0pN1v8NbmzXPu9EkAO3wi
T/L4fxnETW1hd/ph+AdQ0jEpeyAMRjcl3kMjauOBqfU/THl7L6aUMXB3d05+JK0Y
uTc1nrZ0Qjh/5EG5XyvRSuKNVxpCB1XcAlAaBTuE5art6jQkCJINvFoOjoHZB65K
HJVvfMtMuVr7dLSbYOPHH6YJB2fUijNKoXnclAbxldX3fEALsC/SW7zMFF+3FThg
Seq3OEpNdbkRlQp5sORHeTkWtMO9A60GHvTqDamIqppC4fk71zIRpmEPlfGsOno5
gNhH8+NKH5Cvmfj4s7/S98D/AgMBAAGjEDAOMAwGA1UdEwEB/wQCMAAwDQYJKoZI
hvcNAQELBQADggIBAJLW6zjDKZdkfq00r88lM1IxaxRWmUEPQjINOa0GZH/DyAtd
fgYf35AN+NDEiIiDnJQTR3N8jbX1JySDClEOzv3wLXRiKhWcX8z4HOoieNmMu9lZ
6pf1lEaMX8WNxB475nfOueEEyHegbsbfpmYM/aVAiSxaKb87if7uDnxJeGqK2Ba0
c3LJnQ+H87gcAb84G78KXn/XwjRtwaf+Gy06EzycEnckEeN0vni0pRdw8coSudcX
726Kb+kZ4961G/XxQx4fKX+E4lXkx1CdxPBAws/uOjfciLnuVdT4yp6URXJKRw+/
R+flPtfvHXNDXB8BHj9ul4UJ4oSHGppx0BR80CmWWNDVdaoO+o0WieFOXG2U2PB+
p2pleJzckTDuOh5wIe2GFj9WKZTIn4S7ZA6hS5j5ea4bOX5m6cxXVaS5FPHHfHcN
FyUOEE4f61tv9omsFyl23YD/gmQzotFY+5YPS8DTb1soSzydKQODCq7SobyK8QFo
3FnOw1cnKuWDZbqkZS6TYHFc3doNaH77bBxgSgIBPlJewd98eZZCv+m8/mCOXCbo
+7HOgmOHJ1cOEtweCtgrCc2VNvV0w6RSwcZ+WmUAZMajvyEqikVVQzR2LtOOhOyr
REkkNRAX3PX69GDQLM9QSgGLv0f6nA5uTX9SFj71Iik6YAuUsvIK6uPpn/2B
-----END CERTIFICATE-----