readme
This commit is contained in:
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user