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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user