This commit is contained in:
Your Name
2026-07-20 13:40:05 +08:00
parent 9e34f6f0f9
commit bca0da7c7d
12 changed files with 1609 additions and 525 deletions
+2 -1
View File
@@ -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)
+128 -127
View File
@@ -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
}
+115
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+219 -129
View File
@@ -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
)
+239
View File
@@ -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)
+277
View File
@@ -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')
}