readme
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import networks, ips, scan, enhanced_scan, async_scan, snmp, alerts, auth, audit
|
||||
from app.api.v1 import networks, ips, scan, enhanced_scan, async_scan, snmp, alerts, auth, audit, reports
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -12,3 +12,4 @@ api_router.include_router(snmp.router)
|
||||
api_router.include_router(alerts.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(audit.router)
|
||||
api_router.include_router(reports.router)
|
||||
|
||||
Binary file not shown.
+128
-127
@@ -1,164 +1,165 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.security import get_current_user, require_permission
|
||||
from app.models.auth import User
|
||||
from app.models.audit import AuditAction, AuditResource
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/audit",
|
||||
tags=["审计日志"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||
|
||||
|
||||
@router.get("/logs", summary="查询审计日志")
|
||||
@router.get("/logs", summary="获取审计日志列表")
|
||||
def get_audit_logs(
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
action: Optional[AuditAction] = None,
|
||||
resource: Optional[AuditResource] = None,
|
||||
success: Optional[bool] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
# 当前用户必须是 admin 或 super_admin 才能看审计
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: User = Depends(require_permission("audit:view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 角色检查:super_admin / admin 可以看所有人的,operator/viewer 只能看自己的
|
||||
if current_user.role not in ("super_admin", "admin"):
|
||||
user_id = current_user.id
|
||||
|
||||
"""
|
||||
获取审计日志列表(需要管理员权限)
|
||||
|
||||
- **user_id**: 按用户ID过滤
|
||||
- **action**: 按操作类型过滤 (create/read/update/delete/login/logout/scan/export)
|
||||
- **resource**: 按资源类型过滤
|
||||
- **success**: 按操作结果过滤
|
||||
- **start_time**: 开始时间
|
||||
- **end_time**: 结束时间
|
||||
"""
|
||||
total, items = AuditService.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
resource=resource,
|
||||
success=success,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
serialized = []
|
||||
for log in items:
|
||||
serialized.append({
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"username": log.username,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"resource_name": log.resource_name,
|
||||
"method": log.method,
|
||||
"path": log.path,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"status": log.status,
|
||||
"detail": log.detail,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
|
||||
# 格式化返回
|
||||
result_items = []
|
||||
for item in items:
|
||||
changed = item.changed_fields.split(',') if item.changed_fields else []
|
||||
result_items.append({
|
||||
"id": item.id,
|
||||
"user_id": item.user_id,
|
||||
"username": item.username,
|
||||
"real_name": item.real_name,
|
||||
"user_ip": item.user_ip,
|
||||
"action": item.action.value,
|
||||
"resource": item.resource.value,
|
||||
"resource_id": item.resource_id,
|
||||
"description": item.description,
|
||||
"changed_fields": changed,
|
||||
"request_method": item.request_method,
|
||||
"request_path": item.request_path,
|
||||
"success": bool(item.success),
|
||||
"error_message": item.error_message,
|
||||
"created_at": item.created_at
|
||||
})
|
||||
|
||||
return {"total": total, "items": serialized}
|
||||
|
||||
return {"total": total, "items": result_items}
|
||||
|
||||
|
||||
@router.get("/logs/export", summary="导出审计日志为 CSV")
|
||||
def export_audit_logs(
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
@router.get("/logs/my", summary="获取当前用户的操作日志")
|
||||
def get_my_logs(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""导出 CSV 流。admin/super_admin 看全部;operator/viewer 仅能导出自己的。"""
|
||||
if current_user.role not in ("super_admin", "admin"):
|
||||
user_id = current_user.id
|
||||
|
||||
# 导出上限 50000 行,避免一次拉太多 OOM
|
||||
"""获取当前登录用户的操作日志"""
|
||||
total, items = AuditService.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=0,
|
||||
limit=50000,
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
# 写入 UTF-8 BOM 让 Excel 直接打开不乱码
|
||||
output.write("\ufeff")
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"ID", "时间", "用户", "操作", "资源类型", "资源ID", "资源名称",
|
||||
"HTTP方法", "路径", "客户端IP", "状态", "详情"
|
||||
])
|
||||
for log in items:
|
||||
writer.writerow([
|
||||
log.id,
|
||||
log.created_at.isoformat() if log.created_at else "",
|
||||
log.username or "",
|
||||
log.action or "",
|
||||
log.resource_type or "",
|
||||
log.resource_id or "",
|
||||
log.resource_name or "",
|
||||
log.method or "",
|
||||
log.path or "",
|
||||
log.ip_address or "",
|
||||
log.status or "",
|
||||
(log.detail or "").replace("\n", " ")[:500],
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
filename = f"audit_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
user_id=current_user.id,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
result_items = []
|
||||
for item in items:
|
||||
result_items.append({
|
||||
"id": item.id,
|
||||
"action": item.action.value,
|
||||
"resource": item.resource.value,
|
||||
"resource_id": item.resource_id,
|
||||
"description": item.description,
|
||||
"success": bool(item.success),
|
||||
"created_at": item.created_at
|
||||
})
|
||||
|
||||
return {"total": total, "items": result_items}
|
||||
|
||||
|
||||
@router.get("/stats", summary="审计日志统计")
|
||||
def get_audit_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@router.get("/logs/resource/{resource_type}/{resource_id}", summary="获取资源操作历史")
|
||||
def get_resource_logs(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(require_permission("audit:view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if current_user.role not in ("super_admin", "admin"):
|
||||
# operator/viewer 只能看自己的简单计数
|
||||
from app.models.audit import AuditLog
|
||||
from sqlalchemy import func
|
||||
my_count = db.query(func.count(AuditLog.id)).filter(AuditLog.user_id == current_user.id).scalar()
|
||||
return {"recent_24h": 0, "by_action_24h": {}, "my_total": int(my_count or 0)}
|
||||
|
||||
return AuditService.get_stats(db)
|
||||
"""获取指定资源的操作历史"""
|
||||
try:
|
||||
resource_enum = AuditResource(resource_type)
|
||||
except ValueError:
|
||||
return {"total": 0, "items": [], "error": "无效的资源类型"}
|
||||
|
||||
items = AuditService.get_resource_logs(db, resource_enum, resource_id, limit=limit)
|
||||
|
||||
result_items = []
|
||||
for item in items:
|
||||
changed = item.changed_fields.split(',') if item.changed_fields else []
|
||||
result_items.append({
|
||||
"id": item.id,
|
||||
"username": item.username,
|
||||
"real_name": item.real_name,
|
||||
"action": item.action.value,
|
||||
"description": item.description,
|
||||
"changed_fields": changed,
|
||||
"success": bool(item.success),
|
||||
"created_at": item.created_at
|
||||
})
|
||||
|
||||
return {"total": len(items), "items": result_items}
|
||||
|
||||
|
||||
@router.get("/actions", summary="获取所有审计操作类型(用于前端过滤下拉框)")
|
||||
def get_action_types():
|
||||
"""硬编码返回常用 action 列表,避免每次都查 DB"""
|
||||
from app.services.audit_service import AuditAction
|
||||
actions = []
|
||||
for name in dir(AuditAction):
|
||||
if name.startswith("_") or name.isupper() and not name.startswith("__"):
|
||||
continue
|
||||
val = getattr(AuditAction, name, None)
|
||||
if isinstance(val, str):
|
||||
actions.append({"code": val, "name": name})
|
||||
return actions
|
||||
@router.get("/statistics", summary="获取审计统计信息")
|
||||
def get_audit_statistics(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: User = Depends(require_permission("audit:view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取审计统计信息"""
|
||||
stats = AuditService.get_statistics(db, days=days)
|
||||
return stats
|
||||
|
||||
|
||||
@router.delete("/clean", summary="清理旧日志")
|
||||
def clean_old_logs(
|
||||
days: int = Query(90, ge=7, le=3650, description="保留天数"),
|
||||
current_user: User = Depends(require_permission("system:admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
清理指定天数之前的审计日志(需要超级管理员权限)
|
||||
|
||||
- **days**: 保留日志的天数,默认90天
|
||||
"""
|
||||
deleted_count = AuditService.clean_old_logs(db, days=days)
|
||||
return {
|
||||
"message": f"清理完成,已删除 {deleted_count} 条旧日志",
|
||||
"deleted_count": deleted_count,
|
||||
"kept_days": days
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_permission
|
||||
from app.models.network import IPStatus
|
||||
from app.services.report_service import ReportExportService
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["报表导出"])
|
||||
|
||||
|
||||
@router.get("/ip-addresses/csv", summary="导出IP地址CSV")
|
||||
def export_ip_addresses_csv(
|
||||
network_id: Optional[int] = None,
|
||||
status: Optional[IPStatus] = None,
|
||||
online_only: bool = Query(False, description="仅导出在线IP"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_permission("report:export"))
|
||||
):
|
||||
"""导出IP地址表CSV"""
|
||||
csv_content, filename = ReportExportService.export_ip_addresses_csv(
|
||||
db,
|
||||
network_id=network_id,
|
||||
status=status,
|
||||
include_online_only=online_only
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/networks/csv", summary="导出网段汇总CSV")
|
||||
def export_networks_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_permission("report:export"))
|
||||
):
|
||||
"""导出网段汇总CSV"""
|
||||
csv_content, filename = ReportExportService.export_networks_csv(db)
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/alerts/csv", summary="导出告警CSV")
|
||||
def export_alerts_csv(
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_permission("report:export"))
|
||||
):
|
||||
"""导出告警CSV"""
|
||||
csv_content, filename = ReportExportService.export_alerts_csv(db, status=status)
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snmp-devices/csv", summary="导出SNMP设备CSV")
|
||||
def export_snmp_devices_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_permission("report:export"))
|
||||
):
|
||||
"""导出SNMP设备CSV"""
|
||||
csv_content, filename = ReportExportService.export_snmp_devices_csv(db)
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/arp-table/csv", summary="导出ARP表CSV")
|
||||
def export_arp_table_csv(
|
||||
device_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_permission("report:export"))
|
||||
):
|
||||
"""导出ARP表CSV"""
|
||||
csv_content, filename = ReportExportService.export_arp_table_csv(db, device_id=device_id)
|
||||
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", summary="获取IPAM系统汇总报表")
|
||||
def get_ipam_summary_report(
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_permission("report:view"))
|
||||
):
|
||||
"""获取IPAM系统汇总统计报表"""
|
||||
summary = ReportExportService.get_ipam_summary(db)
|
||||
return summary
|
||||
+6
-6
@@ -19,17 +19,17 @@ async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时创建数据库表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 初始化默认管理员账户 + 默认权限
|
||||
|
||||
# 初始化默认管理员账户
|
||||
from app.services.user_service import UserService
|
||||
db = SessionLocal()
|
||||
try:
|
||||
UserService.init_default_admin(db)
|
||||
UserService.init_default_permissions(db)
|
||||
print("✅ 系统初始化完成,默认管理员账户: admin / admin123")
|
||||
print("✅ IPAM System initialized successfully!")
|
||||
print(" Default admin: admin / admin123")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
yield
|
||||
# 关闭时清理
|
||||
|
||||
@@ -37,7 +37,7 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="企业级IP地址管理系统",
|
||||
description="Enterprise IP Address Management System",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
|
||||
+58
-28
@@ -1,37 +1,67 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
"""操作类型枚举"""
|
||||
CREATE = "create"
|
||||
READ = "read"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
LOGIN = "login"
|
||||
LOGOUT = "logout"
|
||||
SCAN = "scan"
|
||||
IMPORT = "import"
|
||||
EXPORT = "export"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class AuditResource(str, enum.Enum):
|
||||
"""资源类型枚举"""
|
||||
USER = "user"
|
||||
NETWORK = "network"
|
||||
IP_ADDRESS = "ip_address"
|
||||
SNMP_CREDENTIAL = "snmp_credential"
|
||||
SNMP_DEVICE = "snmp_device"
|
||||
ALERT = "alert"
|
||||
WHITELIST_MAC = "whitelist_mac"
|
||||
SCAN_TASK = "scan_task"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""审计日志:记录用户的关键操作"""
|
||||
"""审计日志表"""
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 操作人(nullable:登录失败、token 失效等场景可能没有 user_id)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
username = Column(String(50), index=True, comment="冗余存储用户名,便于 user 被删除后仍能查看")
|
||||
|
||||
# 操作内容
|
||||
action = Column(String(50), nullable=False, index=True, comment="操作类型,如:network.create")
|
||||
resource_type = Column(String(50), nullable=True, index=True, comment="资源类型:network / ip / snmp / user / scan")
|
||||
resource_id = Column(String(50), nullable=True, comment="资源 ID(字符串以便兼容多种类型)")
|
||||
resource_name = Column(String(200), nullable=True, comment="资源名称/标识,便于阅读")
|
||||
|
||||
|
||||
# 操作人信息
|
||||
user_id = Column(Integer, index=True, nullable=True)
|
||||
username = Column(String(50), index=True)
|
||||
real_name = Column(String(50))
|
||||
user_ip = Column(String(50))
|
||||
user_agent = Column(String(500))
|
||||
|
||||
# 操作信息
|
||||
action = Column(Enum(AuditAction), nullable=False, index=True)
|
||||
resource = Column(Enum(AuditResource), nullable=False, index=True)
|
||||
resource_id = Column(String(100), nullable=True) # 操作的资源ID
|
||||
|
||||
# 详细信息
|
||||
description = Column(String(500)) # 操作描述
|
||||
old_value = Column(Text) # 修改前的值 (JSON)
|
||||
new_value = Column(Text) # 修改后的值 (JSON)
|
||||
changed_fields = Column(String(500)) # 变更的字段列表
|
||||
|
||||
# 请求信息
|
||||
method = Column(String(10), comment="HTTP 方法")
|
||||
path = Column(String(500), comment="请求路径")
|
||||
ip_address = Column(String(50), comment="客户端 IP")
|
||||
user_agent = Column(String(500), comment="User-Agent")
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="success", index=True, comment="success / failed")
|
||||
detail = Column(Text, comment="变更详情 / 错误信息 / JSON diff")
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
|
||||
|
||||
# 复合索引:按时间+用户查、按时间+资源查更高效
|
||||
Index("idx_audit_logs_user_created", AuditLog.user_id, AuditLog.created_at.desc())
|
||||
Index("idx_audit_logs_resource", AuditLog.resource_type, AuditLog.resource_id, AuditLog.created_at.desc())
|
||||
request_method = Column(String(10))
|
||||
request_path = Column(String(200))
|
||||
|
||||
# 结果
|
||||
success = Column(Integer, default=1) # 1=成功,0=失败
|
||||
error_message = Column(Text)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
@@ -1,155 +1,245 @@
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.audit import AuditLog, AuditAction, AuditResource
|
||||
from app.models.auth import User
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""审计日志服务"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def record(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
user: Optional[User] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
resource_name: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
status: str = "success",
|
||||
detail: Optional[Any] = None,
|
||||
) -> AuditLog:
|
||||
def log(db: Session,
|
||||
user: User,
|
||||
action: AuditAction,
|
||||
resource: AuditResource,
|
||||
resource_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
old_value: Optional[dict] = None,
|
||||
new_value: Optional[dict] = None,
|
||||
changed_fields: Optional[List[str]] = None,
|
||||
user_ip: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
request_method: Optional[str] = None,
|
||||
request_path: Optional[str] = None,
|
||||
success: bool = True,
|
||||
error_message: Optional[str] = None) -> AuditLog:
|
||||
"""
|
||||
写入一条审计日志。任意字段缺失都安全降级(不抛异常),
|
||||
避免审计日志写入失败影响主业务流程。
|
||||
记录审计日志
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user: 操作用户
|
||||
action: 操作类型
|
||||
resource: 资源类型
|
||||
resource_id: 资源ID
|
||||
description: 操作描述
|
||||
old_value: 旧值
|
||||
new_value: 新值
|
||||
changed_fields: 变更的字段列表
|
||||
user_ip: 用户IP
|
||||
user_agent: 用户代理
|
||||
request_method: 请求方法
|
||||
request_path: 请求路径
|
||||
success: 操作是否成功
|
||||
error_message: 错误信息
|
||||
|
||||
Returns:
|
||||
AuditLog 实例
|
||||
"""
|
||||
try:
|
||||
log = AuditLog(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id) if resource_id is not None else None,
|
||||
resource_name=resource_name,
|
||||
method=method,
|
||||
path=path,
|
||||
ip_address=ip_address,
|
||||
user_agent=(user_agent or "")[:500],
|
||||
status=status,
|
||||
detail=_serialize_detail(detail),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return log
|
||||
except Exception as e:
|
||||
# 写审计日志失败不能影响主业务,仅回滚
|
||||
db.rollback()
|
||||
# 用 print 而非 logger,避免循环依赖(logger 可能未初始化)
|
||||
print(f"[audit] failed to write log action={action}: {e}")
|
||||
return None
|
||||
|
||||
audit_log = AuditLog(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
real_name=user.real_name if user else None,
|
||||
action=action,
|
||||
resource=resource,
|
||||
resource_id=str(resource_id) if resource_id else None,
|
||||
description=description,
|
||||
old_value=json.dumps(old_value, ensure_ascii=False, default=str) if old_value else None,
|
||||
new_value=json.dumps(new_value, ensure_ascii=False, default=str) if new_value else None,
|
||||
changed_fields=','.join(changed_fields) if changed_fields else None,
|
||||
user_ip=user_ip,
|
||||
user_agent=user_agent,
|
||||
request_method=request_method,
|
||||
request_path=request_path,
|
||||
success=1 if success else 0,
|
||||
error_message=error_message
|
||||
)
|
||||
|
||||
db.add(audit_log)
|
||||
db.commit()
|
||||
db.refresh(audit_log)
|
||||
return audit_log
|
||||
|
||||
@staticmethod
|
||||
def get_logs(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[int, List[AuditLog]]:
|
||||
def get_logs(db: Session,
|
||||
user_id: Optional[int] = None,
|
||||
action: Optional[AuditAction] = None,
|
||||
resource: Optional[AuditResource] = None,
|
||||
success: Optional[bool] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100) -> tuple[int, List[AuditLog]]:
|
||||
"""
|
||||
查询审计日志
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 按用户ID过滤
|
||||
action: 按操作类型过滤
|
||||
resource: 按资源类型过滤
|
||||
success: 按操作结果过滤
|
||||
start_time: 开始时间
|
||||
end_time: 结束时间
|
||||
skip: 跳过记录数
|
||||
limit: 返回记录数
|
||||
|
||||
Returns:
|
||||
(总数, 记录列表)
|
||||
"""
|
||||
query = db.query(AuditLog)
|
||||
|
||||
if user_id is not None:
|
||||
|
||||
if user_id:
|
||||
query = query.filter(AuditLog.user_id == user_id)
|
||||
if username:
|
||||
query = query.filter(AuditLog.username.like(f"%{username}%"))
|
||||
if action:
|
||||
query = query.filter(AuditLog.action.like(f"%{action}%"))
|
||||
if resource_type:
|
||||
query = query.filter(AuditLog.resource_type == resource_type)
|
||||
if status:
|
||||
query = query.filter(AuditLog.status == status)
|
||||
query = query.filter(AuditLog.action == action)
|
||||
if resource:
|
||||
query = query.filter(AuditLog.resource == resource)
|
||||
if success is not None:
|
||||
query = query.filter(AuditLog.success == (1 if success else 0))
|
||||
if start_time:
|
||||
query = query.filter(AuditLog.created_at >= start_time)
|
||||
if end_time:
|
||||
query = query.filter(AuditLog.created_at <= end_time)
|
||||
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(desc(AuditLog.created_at)).offset(skip).limit(limit).all()
|
||||
items = query.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_stats(db: Session) -> Dict[str, Any]:
|
||||
"""首页/汇总用:最近 24h 操作数 + 按 action 分组"""
|
||||
from datetime import timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(hours=24)
|
||||
recent = db.query(AuditLog).filter(AuditLog.created_at >= cutoff).count()
|
||||
by_action = db.query(AuditLog.action, db.query(AuditLog).filter(AuditLog.created_at >= cutoff).subquery()) # 占位避免循环
|
||||
# 简化:用 group by
|
||||
from sqlalchemy import func
|
||||
rows = db.query(AuditLog.action, func.count(AuditLog.id)).filter(
|
||||
AuditLog.created_at >= cutoff
|
||||
).group_by(AuditLog.action).all()
|
||||
def get_user_actions(db: Session, user_id: int, limit: int = 50) -> List[AuditLog]:
|
||||
"""获取指定用户的操作日志"""
|
||||
return db.query(AuditLog)\
|
||||
.filter(AuditLog.user_id == user_id)\
|
||||
.order_by(AuditLog.created_at.desc())\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
@staticmethod
|
||||
def get_resource_logs(db: Session, resource: AuditResource, resource_id: str, limit: int = 50) -> List[AuditLog]:
|
||||
"""获取指定资源的操作历史"""
|
||||
return db.query(AuditLog)\
|
||||
.filter(AuditLog.resource == resource, AuditLog.resource_id == str(resource_id))\
|
||||
.order_by(AuditLog.created_at.desc())\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
@staticmethod
|
||||
def get_statistics(db: Session, days: int = 30) -> dict:
|
||||
"""
|
||||
获取审计统计信息
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
统计信息字典
|
||||
"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# 总操作数
|
||||
total_ops = db.query(AuditLog).filter(AuditLog.created_at >= start_date).count()
|
||||
|
||||
# 成功/失败统计
|
||||
success_ops = db.query(AuditLog).filter(
|
||||
AuditLog.created_at >= start_date,
|
||||
AuditLog.success == 1
|
||||
).count()
|
||||
failed_ops = total_ops - success_ops
|
||||
|
||||
# 按操作类型统计
|
||||
action_stats = {}
|
||||
for action in AuditAction:
|
||||
count = db.query(AuditLog).filter(
|
||||
AuditLog.created_at >= start_date,
|
||||
AuditLog.action == action
|
||||
).count()
|
||||
if count > 0:
|
||||
action_stats[action.value] = count
|
||||
|
||||
# 按资源类型统计
|
||||
resource_stats = {}
|
||||
for resource in AuditResource:
|
||||
count = db.query(AuditLog).filter(
|
||||
AuditLog.created_at >= start_date,
|
||||
AuditLog.resource == resource
|
||||
).count()
|
||||
if count > 0:
|
||||
resource_stats[resource.value] = count
|
||||
|
||||
# 活跃用户数
|
||||
active_users = db.query(AuditLog.user_id).filter(
|
||||
AuditLog.created_at >= start_date
|
||||
).distinct().count()
|
||||
|
||||
return {
|
||||
"recent_24h": recent,
|
||||
"by_action_24h": {action: count for action, count in rows},
|
||||
"period_days": days,
|
||||
"total_operations": total_ops,
|
||||
"successful_operations": success_ops,
|
||||
"failed_operations": failed_ops,
|
||||
"success_rate": round(success_ops / total_ops * 100, 2) if total_ops > 0 else 0,
|
||||
"by_action": action_stats,
|
||||
"by_resource": resource_stats,
|
||||
"active_users_count": active_users
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def clean_old_logs(db: Session, days: int = 90) -> int:
|
||||
"""
|
||||
清理指定天数之前的日志
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
days: 保留天数
|
||||
|
||||
Returns:
|
||||
删除的记录数
|
||||
"""
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff_date).delete()
|
||||
db.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
def _serialize_detail(detail: Any) -> Optional[str]:
|
||||
"""把 dict/list 等结构化数据序列化成字符串,便于存储和展示"""
|
||||
if detail is None:
|
||||
return None
|
||||
if isinstance(detail, str):
|
||||
return detail[:4000]
|
||||
try:
|
||||
return json.dumps(detail, ensure_ascii=False, default=str)[:4000]
|
||||
except (TypeError, ValueError):
|
||||
return str(detail)[:4000]
|
||||
|
||||
|
||||
# 常用 action 字符串集中管理(避免散落各处的魔法字符串)
|
||||
class AuditAction:
|
||||
# 网络
|
||||
NETWORK_CREATE = "network.create"
|
||||
NETWORK_UPDATE = "network.update"
|
||||
NETWORK_DELETE = "network.delete"
|
||||
# IP
|
||||
IP_UPDATE = "ip.update"
|
||||
IP_RESERVE = "ip.reserve"
|
||||
IP_UNRESERVE = "ip.unreserve"
|
||||
# 扫描
|
||||
SCAN_TRIGGER = "scan.trigger"
|
||||
# SNMP
|
||||
SNMP_CRED_CREATE = "snmp.credential.create"
|
||||
SNMP_CRED_UPDATE = "snmp.credential.update"
|
||||
SNMP_CRED_DELETE = "snmp.credential.delete"
|
||||
SNMP_DEVICE_CREATE = "snmp.device.create"
|
||||
SNMP_DEVICE_UPDATE = "snmp.device.update"
|
||||
SNMP_DEVICE_DELETE = "snmp.device.delete"
|
||||
# 告警
|
||||
ALERT_ACK = "alert.acknowledge"
|
||||
ALERT_RESOLVE = "alert.resolve"
|
||||
# 用户管理
|
||||
USER_LOGIN = "user.login"
|
||||
USER_LOGIN_FAILED = "user.login.failed"
|
||||
USER_LOGOUT = "user.logout"
|
||||
USER_CREATE = "user.create"
|
||||
USER_UPDATE_STATUS = "user.update_status"
|
||||
USER_UNLOCK = "user.unlock"
|
||||
USER_RESET_PASSWORD = "user.reset_password"
|
||||
USER_DELETE = "user.delete"
|
||||
USER_CHANGE_PASSWORD = "user.change_password"
|
||||
class AuditLogger:
|
||||
"""审计日志装饰器/上下文管理器"""
|
||||
|
||||
def __init__(self, db: Session, user: User, resource: AuditResource, resource_id: Optional[str] = None):
|
||||
self.db = db
|
||||
self.user = user
|
||||
self.resource = resource
|
||||
self.resource_id = resource_id
|
||||
self.old_value = None
|
||||
|
||||
def set_old_value(self, value: dict):
|
||||
"""设置旧值(用于更新操作)"""
|
||||
self.old_value = value
|
||||
|
||||
def log(self, action: AuditAction, description: str, new_value: Optional[dict] = None, changed_fields: Optional[List[str]] = None):
|
||||
"""记录日志"""
|
||||
AuditService.log(
|
||||
db=self.db,
|
||||
user=self.user,
|
||||
action=action,
|
||||
resource=self.resource,
|
||||
resource_id=self.resource_id,
|
||||
description=description,
|
||||
old_value=self.old_value,
|
||||
new_value=new_value,
|
||||
changed_fields=changed_fields
|
||||
)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import smtplib
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailSettings(BaseSettings):
|
||||
"""邮件配置"""
|
||||
SMTP_HOST: str = "localhost"
|
||||
SMTP_PORT: int = 25
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_USE_TLS: bool = False
|
||||
SMTP_USE_SSL: bool = False
|
||||
SMTP_FROM: str = "ipam@localhost"
|
||||
|
||||
class Config:
|
||||
env_prefix = "EMAIL_"
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""邮件服务"""
|
||||
|
||||
def __init__(self, settings: EmailSettings):
|
||||
self.settings = settings
|
||||
|
||||
def send_email(self, to: str, subject: str, html_content: str) -> bool:
|
||||
"""
|
||||
发送HTML邮件
|
||||
|
||||
Args:
|
||||
to: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_content: HTML内容
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = self.settings.SMTP_FROM
|
||||
msg['To'] = to
|
||||
msg['Subject'] = subject
|
||||
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
msg.attach(html_part)
|
||||
|
||||
# 连接SMTP服务器
|
||||
if self.settings.SMTP_USE_SSL:
|
||||
server = smtplib.SMTP_SSL(
|
||||
host=self.settings.SMTP_HOST,
|
||||
port=self.settings.SMTP_PORT,
|
||||
timeout=10
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
host=self.settings.SMTP_HOST,
|
||||
port=self.settings.SMTP_PORT,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# 如果需要TLS加密
|
||||
if self.settings.SMTP_USE_TLS and not self.settings.SMTP_USE_SSL:
|
||||
server.starttls()
|
||||
|
||||
# 如果需要认证
|
||||
if self.settings.SMTP_USER and self.settings.SMTP_PASSWORD:
|
||||
server.login(self.settings.SMTP_USER, self.settings.SMTP_PASSWORD)
|
||||
|
||||
# 发送邮件
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
logger.info(f"邮件发送成功: {to} - {subject}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"邮件发送失败: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
def send_alert_notification(self, to: str, alert_data: dict) -> bool:
|
||||
"""发送告警通知邮件"""
|
||||
severity_colors = {
|
||||
'critical': '#dc2626',
|
||||
'error': '#ef4444',
|
||||
'warning': '#f59e0b',
|
||||
'info': '#3b82f6'
|
||||
}
|
||||
|
||||
severity_texts = {
|
||||
'critical': '严重',
|
||||
'error': '错误',
|
||||
'warning': '警告',
|
||||
'info': '信息'
|
||||
}
|
||||
|
||||
alert_type_texts = {
|
||||
'ip_conflict': 'IP 地址冲突',
|
||||
'unauthorized_access': '未授权设备接入',
|
||||
'subnet_full': '网段容量不足',
|
||||
'device_offline': '设备离线',
|
||||
'new_device_detected': '新设备发现',
|
||||
'mac_changed': 'MAC 地址变更'
|
||||
}
|
||||
|
||||
severity = alert_data.get('severity', 'warning')
|
||||
color = severity_colors.get(severity, '#6b7280')
|
||||
severity_text = severity_texts.get(severity, severity)
|
||||
alert_type_text = alert_type_texts.get(alert_data.get('alert_type'), alert_data.get('alert_type'))
|
||||
|
||||
html_content = f"""
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.header {{ background-color: {color}; color: white; padding: 20px; border-radius: 5px; }}
|
||||
.content {{ padding: 20px; background-color: #f9fafb; border-radius: 5px; margin-top: 10px; }}
|
||||
.alert-item {{ margin-bottom: 15px; }}
|
||||
.label {{ font-weight: bold; color: #6b7280; display: inline-block; width: 100px; }}
|
||||
.footer {{ margin-top: 20px; padding-top: 20px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h2>⚠️ IPAM 告警通知</h2>
|
||||
<p>告警级别: <strong>{severity_text}</strong></p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="alert-item">
|
||||
<span class="label">告警类型:</span> {alert_type_text}
|
||||
</div>
|
||||
<div class="alert-item">
|
||||
<span class="label">告警标题:</span> {alert_data.get('title', '-')}
|
||||
</div>
|
||||
<div class="alert-item">
|
||||
<span class="label">详细信息:</span> {alert_data.get('message', '-')}
|
||||
</div>
|
||||
<div class="alert-item">
|
||||
<span class="label">相关IP:</span> {alert_data.get('ip_address_str', '-')}
|
||||
</div>
|
||||
<div class="alert-item">
|
||||
<span class="label">相关MAC:</span> {alert_data.get('mac_address', '-')}
|
||||
</div>
|
||||
<div class="alert-item">
|
||||
<span class="label">告警时间:</span> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由 IPAM 地址管理系统自动发送,请勿直接回复。</p>
|
||||
<p>如需处理该告警,请登录 IPAM 管理控制台: <a href="#">点击访问</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
subject = f"【{severity_text}】IPAM 告警 - {alert_data.get('title', '新告警')}"
|
||||
|
||||
return self.send_email(to, subject, html_content)
|
||||
|
||||
def send_scan_report(self, to: str, report_data: dict) -> bool:
|
||||
"""发送扫描报告邮件"""
|
||||
html_content = f"""
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.header {{ background-color: #3b82f6; color: white; padding: 20px; border-radius: 5px; }}
|
||||
.content {{ padding: 20px; background-color: #f9fafb; border-radius: 5px; margin-top: 10px; }}
|
||||
.stat-item {{ display: inline-block; width: 22%; text-align: center; padding: 15px; background: white; border-radius: 5px; margin: 1%; }}
|
||||
.stat-number {{ font-size: 28px; font-weight: bold; color: #3b82f6; }}
|
||||
.stat-label {{ color: #6b7280; font-size: 14px; }}
|
||||
.success {{ color: #10b981; }}
|
||||
.warning {{ color: #f59e0b; }}
|
||||
.footer {{ margin-top: 20px; padding-top: 20px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h2>📊 IPAM 网段扫描报告</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h3>扫描结果概览</h3>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{report_data.get('total_ips', 0)}</div>
|
||||
<div class="stat-label">扫描IP总数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number success">{report_data.get('online_ips', 0)}</div>
|
||||
<div class="stat-label">在线设备</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{report_data.get('with_mac', 0)}</div>
|
||||
<div class="stat-label">发现MAC地址</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number warning">{report_data.get('new_devices', 0)}</div>
|
||||
<div class="stat-label">新发现设备</div>
|
||||
</div>
|
||||
|
||||
<div style="clear: both; margin-top: 20px;">
|
||||
<p><strong>扫描网段:</strong> {report_data.get('network_cidr', '-')}</p>
|
||||
<p><strong>扫描时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由 IPAM 地址管理系统自动发送,请勿直接回复。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
subject = f"IPAM 扫描报告 - {report_data.get('network_cidr', '')}"
|
||||
|
||||
return self.send_email(to, subject, html_content)
|
||||
|
||||
|
||||
# 全局邮件服务实例
|
||||
_email_service = None
|
||||
|
||||
def get_email_service() -> EmailService:
|
||||
"""获取邮件服务实例"""
|
||||
global _email_service
|
||||
if _email_service is None:
|
||||
settings = EmailSettings()
|
||||
_email_service = EmailService(settings)
|
||||
return _email_service
|
||||
|
||||
|
||||
def init_email_service(settings: EmailSettings):
|
||||
"""初始化邮件服务"""
|
||||
global _email_service
|
||||
_email_service = EmailService(settings)
|
||||
@@ -0,0 +1,277 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from io import BytesIO, StringIO
|
||||
import csv
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.network import IPAddress, Network, IPStatus
|
||||
from app.models.snmp import NetworkDevice, ARPEntry
|
||||
from app.models.alert import Alert
|
||||
|
||||
|
||||
class ReportExportService:
|
||||
"""报表导出服务"""
|
||||
|
||||
# ========== CSV 导出 ==========
|
||||
|
||||
@staticmethod
|
||||
def export_ip_addresses_csv(db: Session,
|
||||
network_id: Optional[int] = None,
|
||||
status: Optional[IPStatus] = None,
|
||||
include_online_only: bool = False) -> tuple[str, str]:
|
||||
"""
|
||||
导出IP地址表为CSV
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
network_id: 网段ID(可选,不指定则导出所有)
|
||||
status: IP状态过滤
|
||||
include_online_only: 仅导出在线IP
|
||||
|
||||
Returns:
|
||||
(CSV内容字符串, 文件名)
|
||||
"""
|
||||
query = db.query(IPAddress)
|
||||
|
||||
if network_id:
|
||||
query = query.filter(IPAddress.network_id == network_id)
|
||||
if status:
|
||||
query = query.filter(IPAddress.status == status)
|
||||
if include_online_only:
|
||||
query = query.filter(IPAddress.status == IPStatus.ONLINE)
|
||||
|
||||
ips = query.order_by(IPAddress.network_id, IPAddress.id).all()
|
||||
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# 写入表头
|
||||
writer.writerow([
|
||||
'ID', '网段ID', 'IP地址', 'MAC地址', '主机名', '状态',
|
||||
'使用者', '业务类型', '备注', '厂商',
|
||||
'最后发现时间', '创建时间'
|
||||
])
|
||||
|
||||
# 写入数据
|
||||
for ip in ips:
|
||||
writer.writerow([
|
||||
ip.id,
|
||||
ip.network_id,
|
||||
ip.ip_address,
|
||||
ip.mac_address or '',
|
||||
ip.hostname or '',
|
||||
ip.status.value,
|
||||
ip.owner or '',
|
||||
ip.business_type or '',
|
||||
ip.notes or '',
|
||||
ip.vendor or '',
|
||||
ip.last_seen.strftime('%Y-%m-%d %H:%M:%S') if ip.last_seen else '',
|
||||
ip.created_at.strftime('%Y-%m-%d %H:%M:%S') if ip.created_at else ''
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
filename = f"ip_addresses_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
return csv_content, filename
|
||||
|
||||
@staticmethod
|
||||
def export_networks_csv(db: Session) -> tuple[str, str]:
|
||||
"""导出网段汇总CSV"""
|
||||
networks = db.query(Network).order_by(Network.id).all()
|
||||
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow([
|
||||
'ID', '网段CIDR', '名称', '描述', '分组',
|
||||
'网关', 'VLAN', '总IP数', '已用IP数', '保留IP数',
|
||||
'创建时间'
|
||||
])
|
||||
|
||||
for net in networks:
|
||||
writer.writerow([
|
||||
net.id,
|
||||
net.cidr,
|
||||
net.name or '',
|
||||
net.description or '',
|
||||
net.group_name or '',
|
||||
net.gateway or '',
|
||||
net.vlan_id or '',
|
||||
net.total_ips,
|
||||
net.used_ips,
|
||||
net.reserved_ips,
|
||||
net.created_at.strftime('%Y-%m-%d %H:%M:%S') if net.created_at else ''
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
filename = f"networks_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
return csv_content, filename
|
||||
|
||||
@staticmethod
|
||||
def export_alerts_csv(db: Session, status: Optional[str] = None) -> tuple[str, str]:
|
||||
"""导出告警CSV"""
|
||||
query = db.query(Alert)
|
||||
if status:
|
||||
query = query.filter(Alert.status == status)
|
||||
|
||||
alerts = query.order_by(Alert.created_at.desc()).all()
|
||||
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow([
|
||||
'ID', '告警类型', '严重程度', '状态', '标题',
|
||||
'详细信息', '相关IP', '相关MAC', '创建时间'
|
||||
])
|
||||
|
||||
for alert in alerts:
|
||||
writer.writerow([
|
||||
alert.id,
|
||||
alert.alert_type.value,
|
||||
alert.severity.value,
|
||||
alert.status.value,
|
||||
alert.title,
|
||||
alert.message or '',
|
||||
alert.ip_address_str or '',
|
||||
alert.mac_address or '',
|
||||
alert.created_at.strftime('%Y-%m-%d %H:%M:%S') if alert.created_at else ''
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
filename = f"alerts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
return csv_content, filename
|
||||
|
||||
@staticmethod
|
||||
def export_snmp_devices_csv(db: Session) -> tuple[str, str]:
|
||||
"""导出SNMP设备CSV"""
|
||||
devices = db.query(NetworkDevice).order_by(NetworkDevice.id).all()
|
||||
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow([
|
||||
'ID', '设备名称', 'IP地址', '端口', '设备类型',
|
||||
'厂商', '型号', '位置', '最后轮询时间', '创建时间'
|
||||
])
|
||||
|
||||
for dev in devices:
|
||||
writer.writerow([
|
||||
dev.id,
|
||||
dev.name,
|
||||
dev.ip_address,
|
||||
dev.port,
|
||||
dev.device_type.value,
|
||||
dev.vendor or '',
|
||||
dev.model or '',
|
||||
dev.location or '',
|
||||
dev.last_polled_at.strftime('%Y-%m-%d %H:%M:%S') if dev.last_polled_at else '',
|
||||
dev.created_at.strftime('%Y-%m-%d %H:%M:%S') if dev.created_at else ''
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
filename = f"snmp_devices_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
return csv_content, filename
|
||||
|
||||
@staticmethod
|
||||
def export_arp_table_csv(db: Session, device_id: Optional[int] = None) -> tuple[str, str]:
|
||||
"""导出ARP表CSV"""
|
||||
query = db.query(ARPEntry)
|
||||
if device_id:
|
||||
query = query.filter(ARPEntry.device_id == device_id)
|
||||
|
||||
entries = query.order_by(ARPEntry.device_id, ARPEntry.id).all()
|
||||
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow([
|
||||
'ID', '设备ID', 'IP地址', 'MAC地址', '接口', '最后发现时间'
|
||||
])
|
||||
|
||||
for entry in entries:
|
||||
writer.writerow([
|
||||
entry.id,
|
||||
entry.device_id,
|
||||
entry.ip_address,
|
||||
entry.mac_address,
|
||||
entry.interface or '',
|
||||
entry.last_seen.strftime('%Y-%m-%d %H:%M:%S') if entry.last_seen else ''
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
filename = f"arp_table_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
return csv_content, filename
|
||||
|
||||
# ========== 汇总统计报表 ==========
|
||||
|
||||
@staticmethod
|
||||
def get_ipam_summary(db: Session) -> Dict[str, Any]:
|
||||
"""获取IPAM系统汇总报表"""
|
||||
# 网段统计
|
||||
total_networks = db.query(Network).count()
|
||||
total_ips = db.query(IPAddress).count()
|
||||
online_ips = db.query(IPAddress).filter(IPAddress.status == IPStatus.ONLINE).count()
|
||||
reserved_ips = db.query(IPAddress).filter(IPAddress.status == IPStatus.RESERVED).count()
|
||||
|
||||
# 带MAC/主机名的IP统计
|
||||
with_mac = db.query(IPAddress).filter(IPAddress.mac_address.isnot(None)).count()
|
||||
with_hostname = db.query(IPAddress).filter(IPAddress.hostname.isnot(None)).count()
|
||||
|
||||
# 网段使用率分布
|
||||
networks = db.query(Network).all()
|
||||
usage_distribution = {
|
||||
'under_50': 0, # 50%以下
|
||||
'50_70': 0, # 50%-70%
|
||||
'70_90': 0, # 70%-90%
|
||||
'over_90': 0 # 90%以上
|
||||
}
|
||||
|
||||
for net in networks:
|
||||
usage_rate = (net.used_ips / net.total_ips * 100) if net.total_ips > 0 else 0
|
||||
if usage_rate < 50:
|
||||
usage_distribution['under_50'] += 1
|
||||
elif usage_rate < 70:
|
||||
usage_distribution['50_70'] += 1
|
||||
elif usage_rate < 90:
|
||||
usage_distribution['70_90'] += 1
|
||||
else:
|
||||
usage_distribution['over_90'] += 1
|
||||
|
||||
# 告警统计
|
||||
from app.models.alert import Alert, AlertStatus
|
||||
active_alerts = db.query(Alert).filter(Alert.status == AlertStatus.ACTIVE).count()
|
||||
resolved_alerts = db.query(Alert).filter(Alert.status == AlertStatus.RESOLVED).count()
|
||||
|
||||
# SNMP设备统计
|
||||
snmp_devices = db.query(NetworkDevice).count()
|
||||
active_devices = db.query(NetworkDevice).filter(NetworkDevice.last_polled_at.isnot(None)).count()
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"total_networks": total_networks,
|
||||
"total_ip_addresses": total_ips,
|
||||
"online_ips": online_ips,
|
||||
"reserved_ips": reserved_ips,
|
||||
"available_ips": total_ips - online_ips - reserved_ips,
|
||||
"online_rate": round(online_ips / total_ips * 100, 2) if total_ips > 0 else 0
|
||||
},
|
||||
"discovery": {
|
||||
"with_mac_address": with_mac,
|
||||
"with_hostname": with_hostname,
|
||||
"mac_discovery_rate": round(with_mac / total_ips * 100, 2) if total_ips > 0 else 0
|
||||
},
|
||||
"network_usage_distribution": usage_distribution,
|
||||
"alerts": {
|
||||
"active": active_alerts,
|
||||
"resolved": resolved_alerts
|
||||
},
|
||||
"snmp": {
|
||||
"total_devices": snmp_devices,
|
||||
"active_devices": active_devices
|
||||
},
|
||||
"generated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user