This commit is contained in:
Your Name
2026-07-23 13:13:31 +08:00
parent 1a3c2d15c8
commit 5b93e4536f
16 changed files with 302 additions and 68 deletions
Binary file not shown.
+3 -2
View File
@@ -5,6 +5,7 @@ from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.services.alert_service import AlertService
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list, to_business_iso
router = APIRouter(
prefix="/alerts",
@@ -37,7 +38,7 @@ def get_alerts(
total = query.count()
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": items}
return {"total": total, "items": serialize_dt_list(items)}
@router.get("/{alert_id}", summary="获取告警详情")
@@ -47,7 +48,7 @@ def get_alert(alert_id: int, db: Session = Depends(get_db)):
alert = db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
raise HTTPException(status_code=404, detail="告警不存在")
return alert
return serialize_dt_fields(alert)
@router.post("/{alert_id}/acknowledge", summary="确认告警")
+2 -1
View File
@@ -8,6 +8,7 @@ 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
from app.schemas._tz_util import to_business_iso
router = APIRouter(prefix="/audit", tags=["审计日志"])
@@ -66,7 +67,7 @@ def get_audit_logs(
"request_path": item.request_path,
"success": bool(item.success),
"error_message": item.error_message,
"created_at": item.created_at
"created_at": to_business_iso(item.created_at)
})
return {"total": total, "items": result_items}
+17 -30
View File
@@ -7,6 +7,7 @@ from app.core.security import get_current_user
from app.models.auth import User
from app.services.snmp_service import SNMPService
from app.services.audit_service import AuditService, AuditAction
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list
router = APIRouter(
prefix="/snmp",
@@ -34,7 +35,7 @@ def get_snmp_credentials(
query = db.query(SNMPCredential)
total = query.count()
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": items}
return {"total": total, "items": serialize_dt_list(items)}
@router.get("/credentials/{credential_id}", summary="获取凭据详情")
@@ -43,7 +44,7 @@ def get_snmp_credential(credential_id: int, db: Session = Depends(get_db)):
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
if not credential:
raise HTTPException(status_code=404, detail="凭据不存在")
return credential
return serialize_dt_fields(credential)
@router.post("/credentials", summary="创建 SNMP 凭据")
@@ -100,7 +101,7 @@ def create_snmp_credential(
user_agent=u,
detail={"version": credential.version, "name": credential.name},
)
return credential
return serialize_dt_fields(credential)
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
@@ -172,7 +173,7 @@ def update_snmp_credential(
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
},
)
return credential
return serialize_dt_fields(credential)
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
@@ -243,29 +244,15 @@ def get_network_devices(
serialized = []
for d in items:
item = {
"id": d.id,
"name": d.name,
"description": d.description,
"ip_address": d.ip_address,
"port": d.port,
"device_type": d.device_type.value if hasattr(d.device_type, 'value') else d.device_type,
"vendor": d.vendor,
"model": d.model,
"firmware_version": d.firmware_version,
"serial_number": d.serial_number,
"location": d.location,
"snmp_credential_id": d.snmp_credential_id,
"credential_name": cred_map.get(d.snmp_credential_id),
"is_active": d.is_active,
"last_polled_at": d.last_polled_at.isoformat() if d.last_polled_at else None,
"last_successful_poll": d.last_successful_poll.isoformat() if d.last_successful_poll else None,
"arp_poll_interval": d.arp_poll_interval,
"mac_poll_interval": d.mac_poll_interval,
"interface_poll_interval": d.interface_poll_interval,
"created_at": d.created_at.isoformat() if d.created_at else None,
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
}
item = serialize_dt_fields(d)
# 补充手写的关联字段(datetime 已经由 serialize_dt_fields 序列化)
item["device_type"] = d.device_type.value if hasattr(d.device_type, 'value') else d.device_type
item["credential_name"] = cred_map.get(d.snmp_credential_id)
item["last_polled_at"] = item.get("last_polled_at") # 已是 ISO 字符串
item["last_successful_poll"] = item.get("last_successful_poll")
item["arp_poll_interval"] = d.arp_poll_interval
item["mac_poll_interval"] = d.mac_poll_interval
item["interface_poll_interval"] = d.interface_poll_interval
serialized.append(item)
return {"total": total, "items": serialized}
@@ -278,7 +265,7 @@ def get_network_device(device_id: int, db: Session = Depends(get_db)):
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
return device
return serialize_dt_fields(device)
@router.post("/devices", summary="创建网络设备")
@@ -329,7 +316,7 @@ def create_network_device(
user_agent=u,
detail={"ip_address": device.ip_address, "port": device.port, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type},
)
return device
return serialize_dt_fields(device)
@router.put("/devices/{device_id}", summary="更新网络设备")
@@ -400,7 +387,7 @@ def update_network_device(
},
},
)
return device
return serialize_dt_fields(device)
@router.delete("/devices/{device_id}", summary="删除网络设备")
View File
+109
View File
@@ -0,0 +1,109 @@
"""
统一时间格式化工具
背景:MySQL DATETIME 不带时区,backend 用 datetime.utcnow() 写入 UTC naive datetime。
原 schema 直接 .isoformat() 输出,前端拿到无时区字符串后当成 Asia/Shanghai 本地时间显示,
导致所有时间统一少 8 小时。
本工具把任意 datetimenaive 或 aware)一律按 UTC 输出,带 'Z' 后缀。
前端收到 'Z' 后缀字符串后,按 UTC 解析后再 +8 小时显示为 Asia/Shanghai 时间。
也兼容 MySQL 返回 naive 时被错当成 local time 的情况:调用方传入数字或字符串时
不会出错,自动识别。
"""
from datetime import datetime, timezone, timedelta
from typing import Optional, Union
# 业务时区:服务器期望最终用户看到的时区。
# 目前写死 Asia/Shanghai (UTC+8);如以后部署到其他时区,改这里即可。
BUSINESS_TZ = timezone(timedelta(hours=8))
BUSINESS_TZ_NAME = "Asia/Shanghai"
def to_utc_iso(dt: Optional[datetime]) -> Optional[str]:
"""
把 datetime 转成 UTC ISO8601 字符串(带 Z 后缀)。
- aware datetime 调 astimezone(UTC)
- naive datetime 假定为 UTC(与 datetime.utcnow() 写入策略一致)
- None 返回 None
"""
if dt is None:
return None
if dt.tzinfo is None:
# naive 一律视为 UTC(与 datetime.utcnow() 写入策略一致)
dt = dt.replace(tzinfo=timezone.utc)
# 用 '+00:00' 替换成 'Z',更标准的 ISO8601 形式
return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
def to_business_iso(dt: Optional[datetime]) -> Optional[str]:
"""
把 datetime 转成业务时区(Asia/Shanghai)的 ISO8601 字符串(带 +08:00 后缀)。
前端拿到后可直接当本地时间显示。
"""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(BUSINESS_TZ).isoformat()
def utc_str_to_business_str(s: Optional[str]) -> Optional[str]:
"""
'YYYY-MM-DDTHH:MM:SS[.ffffff][Z|+HH:MM]' 字符串按 UTC 解析,
转成业务时区 ISO8601 字符串。
用于后端已经输出 UTC 时,前端(或后端自己)做时区转换的辅助函数。
"""
if not s:
return None
try:
dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
return to_business_iso(dt)
except (ValueError, TypeError):
return s
def now_business_iso() -> str:
"""供后端临时插入用:返回当前业务时区时间"""
return datetime.now(BUSINESS_TZ).isoformat()
def serialize_dt_fields(model, fields: Optional[list] = None) -> dict:
"""
把 ORM model 转成 dictdatetime 字段自动用业务时区 ISO 字符串输出。
用于 audit/snmp/alerts 等内联 dict 序列化的 endpoint,确保返回的
时间字段不会因为裸 .isoformat() 输出而少 8 小时。
用法:
return serialize_dt_fields(alert, ['created_at', 'updated_at'])
return serialize_dt_fields(alert) # 自动检测所有 datetime 字段
"""
from sqlalchemy import inspect as sa_inspect
result = {}
# inspect Class(不是 instance),可以拿 mapper.columns
mapper = sa_inspect(type(model)) if not isinstance(model, type) else sa_inspect(model)
datetime_fields: list = []
if fields is None:
for column in mapper.columns:
col_type = str(column.type).upper()
if 'DATETIME' in col_type or 'TIMESTAMP' in col_type:
datetime_fields.append(column.key)
else:
datetime_fields = fields
for column in mapper.columns:
value = getattr(model, column.key, None)
if column.key in datetime_fields:
result[column.key] = to_business_iso(value)
else:
if hasattr(value, 'value'): # Enum
result[column.key] = value.value
else:
result[column.key] = value
return result
def serialize_dt_list(models, fields: Optional[list] = None) -> list:
"""批量版本:返回每个 model 的 dict 列表"""
return [serialize_dt_fields(m, fields) for m in models] # type: ignore
+28 -2
View File
@@ -1,8 +1,18 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, field_serializer
from typing import Optional, List, Dict, Any
from datetime import datetime
import ipaddress
from app.models.network import IPStatus, TaskStatus, TaskType
from app.schemas._tz_util import to_business_iso, to_utc_iso
# Pydantic v2 字段序列化器:把 datetime 转成业务时区 ISO 字符串
# 让前端拿到 'YYYY-MM-DDTHH:MM:SS+08:00',可直接当本地时间显示
_datetime_business_serializer = field_serializer(
'datetime',
when_used='always',
check_fields=None, # 重要:应用到所有 datetime 字段
)(lambda dt: to_business_iso(dt))
# ========== 网段相关 Schemas ==========
@@ -55,6 +65,11 @@ class Network(NetworkBase):
created_at: datetime
updated_at: Optional[datetime]
# Pydantic v2: 用 model_serializer 在序列化整个 model 时把所有 datetime 转成业务时区
@field_serializer('created_at', 'updated_at')
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
return to_business_iso(dt)
class Config:
from_attributes = True
@@ -120,6 +135,10 @@ class IPAddress(IPAddressBase):
created_at: datetime
updated_at: Optional[datetime]
@field_serializer('last_seen', 'first_seen', 'created_at', 'updated_at')
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
return to_business_iso(dt)
class Config:
from_attributes = True
@@ -155,6 +174,13 @@ class ScanTask(BaseModel):
error_message: Optional[str]
created_at: datetime
@field_serializer(
'started_at', 'completed_at', 'created_at',
check_fields=None,
)
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
return to_business_iso(dt)
class Config:
from_attributes = True
@@ -170,4 +196,4 @@ class ScanResult(BaseModel):
status: str
mac_address: Optional[str] = None
hostname: Optional[str] = None
response_time: Optional[float] = None
response_time: Optional[float] = None