feat: add system settings, audit log, device detail, dark mode, i18n, email notifications
System Management: - System Settings page with 8 configurable parameters (admin only) - Audit Log page with filterable table (user, action, resource, date range) - Audit logging wired into auth, devices, users, alarms, reports API handlers - SystemSetting model + migration (002) Device Detail: - Dedicated /devices/:id page with 4 tabs (realtime, historical trends, alarm history, device info) - ECharts historical charts with granularity/time range selectors - Device name clickable in Devices and Monitoring tables → navigates to detail Email & Scheduling: - Email service with SMTP support (STARTTLS/SSL/plain) - Alarm email notification with professional HTML template - Report scheduler using APScheduler for cron-based auto-generation - Scheduled report task seeded (daily at 8am) UI Enhancements: - Dark mode toggle (persisted to localStorage, Ant Design darkAlgorithm) - Data comparison view in Analysis page (dual date range, side-by-side metrics) - i18n framework (i18next) with zh/en translations for menu and common UI - Language switcher in header (中文/English) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
backend/alembic/versions/002_add_system_settings.py
Normal file
41
backend/alembic/versions/002_add_system_settings.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Add system_settings table
|
||||
|
||||
Revision ID: 002_system_settings
|
||||
Revises: 001_initial
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "002_system_settings"
|
||||
down_revision = "001_initial"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"system_settings",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("key", sa.String(100), unique=True, nullable=False, index=True),
|
||||
sa.Column("value", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("description", sa.String(255)),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Seed default settings
|
||||
op.execute("""
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'),
|
||||
('data_retention_days', '365', '数据保留天数'),
|
||||
('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'),
|
||||
('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'),
|
||||
('notification_email_enabled', 'false', '是否启用邮件通知'),
|
||||
('notification_email_smtp', '', 'SMTP服务器地址'),
|
||||
('report_auto_schedule_enabled', 'false', '是否启用自动报表'),
|
||||
('timezone', 'Asia/Shanghai', '系统时区')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("system_settings")
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -14,3 +14,5 @@ api_router.include_router(carbon.router)
|
||||
api_router.include_router(dashboard.router)
|
||||
api_router.include_router(collectors.router)
|
||||
api_router.include_router(websocket.router)
|
||||
api_router.include_router(audit.router)
|
||||
api_router.include_router(settings.router)
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.alarm import AlarmRule, AlarmEvent
|
||||
from app.models.user import User
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/alarms", tags=["告警管理"])
|
||||
|
||||
@@ -107,6 +108,7 @@ async def acknowledge_event(event_id: int, db: AsyncSession = Depends(get_db), u
|
||||
event.status = "acknowledged"
|
||||
event.acknowledged_by = user.id
|
||||
event.acknowledged_at = datetime.now(timezone.utc)
|
||||
await log_audit(db, user.id, "acknowledge", "alarm", detail=f"确认告警 #{event_id}")
|
||||
return {"message": "已确认"}
|
||||
|
||||
|
||||
@@ -119,6 +121,7 @@ async def resolve_event(event_id: int, note: str = "", db: AsyncSession = Depend
|
||||
event.status = "resolved"
|
||||
event.resolved_at = datetime.now(timezone.utc)
|
||||
event.resolve_note = note
|
||||
await log_audit(db, user.id, "resolve", "alarm", detail=f"解决告警 #{event_id}")
|
||||
return {"message": "已解决"}
|
||||
|
||||
|
||||
|
||||
76
backend/app/api/v1/audit.py
Normal file
76
backend/app/api/v1/audit.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.user import User, AuditLog
|
||||
|
||||
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def list_audit_logs(
|
||||
user_id: int | None = None,
|
||||
action: str | None = None,
|
||||
resource: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
"""Return paginated audit logs with optional filters."""
|
||||
query = select(
|
||||
AuditLog.id,
|
||||
AuditLog.user_id,
|
||||
User.username,
|
||||
AuditLog.action,
|
||||
AuditLog.resource,
|
||||
AuditLog.detail,
|
||||
AuditLog.ip_address,
|
||||
AuditLog.created_at,
|
||||
).outerjoin(User, AuditLog.user_id == User.id)
|
||||
|
||||
if user_id is not None:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if resource:
|
||||
query = query.where(AuditLog.resource == resource)
|
||||
if start_time:
|
||||
try:
|
||||
st = datetime.fromisoformat(start_time)
|
||||
query = query.where(AuditLog.created_at >= st)
|
||||
except ValueError:
|
||||
pass
|
||||
if end_time:
|
||||
try:
|
||||
et = datetime.fromisoformat(end_time)
|
||||
query = query.where(AuditLog.created_at <= et)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Count
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
# Paginate
|
||||
query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
|
||||
items = []
|
||||
for row in result.all():
|
||||
items.append({
|
||||
"id": row.id,
|
||||
"user_id": row.user_id,
|
||||
"username": row.username or "-",
|
||||
"action": row.action,
|
||||
"resource": row.resource,
|
||||
"detail": row.detail,
|
||||
"ip_address": row.ip_address,
|
||||
"created_at": str(row.created_at) if row.created_at else None,
|
||||
})
|
||||
|
||||
return {"total": total, "items": items}
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -8,6 +8,7 @@ from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, hash_password
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
@@ -27,7 +28,7 @@ class RegisterRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
async def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == form.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(form.password, user.hashed_password):
|
||||
@@ -36,6 +37,8 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
client_ip = request.client.host if request.client else None
|
||||
await log_audit(db, user.id, "login", "auth", detail=f"用户 {user.username} 登录", ip_address=client_ip)
|
||||
return Token(
|
||||
access_token=token,
|
||||
user={"id": user.id, "username": user.username, "full_name": user.full_name, "role": user.role}
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.device import Device, DeviceType, DeviceGroup
|
||||
from app.models.user import User
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/devices", tags=["设备管理"])
|
||||
|
||||
@@ -98,6 +99,7 @@ async def create_device(data: DeviceCreate, db: AsyncSession = Depends(get_db),
|
||||
device = Device(**data.model_dump())
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "create", "device", detail=f"创建设备 {data.name} ({data.code})")
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
@@ -107,8 +109,10 @@ async def update_device(device_id: int, data: DeviceUpdate, db: AsyncSession = D
|
||||
device = result.scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
for k, v in updates.items():
|
||||
setattr(device, k, v)
|
||||
await log_audit(db, user.id, "update", "device", detail=f"更新设备 {device.name}: {', '.join(updates.keys())}")
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.deps import get_current_user
|
||||
from app.models.report import ReportTemplate, ReportTask
|
||||
from app.models.user import User
|
||||
from app.services.report_generator import ReportGenerator, REPORTS_DIR
|
||||
from app.services.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -184,6 +185,7 @@ async def run_task(
|
||||
task.file_path = filepath
|
||||
task.last_run = datetime.now()
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "export", "report", detail=f"运行报表任务 #{task_id}")
|
||||
logger.info(f"Report task {task_id} completed: {filepath}")
|
||||
return {"message": "报表生成完成", "task_id": task.id, "mode": "sync", "status": "completed"}
|
||||
except Exception as e:
|
||||
@@ -286,6 +288,7 @@ async def generate_quick_report(
|
||||
raise HTTPException(status_code=500, detail=f"报表生成失败: {str(e)}")
|
||||
|
||||
filename = Path(filepath).name
|
||||
await log_audit(db, user.id, "export", "report", detail=f"生成报表: {req.report_type} ({req.export_format})")
|
||||
return {
|
||||
"message": "报表生成成功",
|
||||
"filename": filename,
|
||||
|
||||
84
backend/app/api/v1/settings.py
Normal file
84
backend/app/api/v1/settings.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.user import User
|
||||
from app.models.setting import SystemSetting
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
# Default settings — used when keys are missing from DB
|
||||
DEFAULTS: dict[str, str] = {
|
||||
"platform_name": "天普零碳园区智慧能源管理平台",
|
||||
"data_retention_days": "365",
|
||||
"alarm_auto_resolve_minutes": "30",
|
||||
"simulator_interval_seconds": "15",
|
||||
"notification_email_enabled": "false",
|
||||
"notification_email_smtp": "",
|
||||
"report_auto_schedule_enabled": "false",
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
platform_name: str | None = None
|
||||
data_retention_days: int | None = None
|
||||
alarm_auto_resolve_minutes: int | None = None
|
||||
simulator_interval_seconds: int | None = None
|
||||
notification_email_enabled: bool | None = None
|
||||
notification_email_smtp: str | None = None
|
||||
report_auto_schedule_enabled: bool | None = None
|
||||
timezone: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all platform settings as a flat dict."""
|
||||
result = await db.execute(select(SystemSetting))
|
||||
db_settings = {s.key: s.value for s in result.scalars().all()}
|
||||
# Merge defaults with DB values
|
||||
merged = {**DEFAULTS, **db_settings}
|
||||
# Cast types for frontend
|
||||
return {
|
||||
"platform_name": merged["platform_name"],
|
||||
"data_retention_days": int(merged["data_retention_days"]),
|
||||
"alarm_auto_resolve_minutes": int(merged["alarm_auto_resolve_minutes"]),
|
||||
"simulator_interval_seconds": int(merged["simulator_interval_seconds"]),
|
||||
"notification_email_enabled": merged["notification_email_enabled"] == "true",
|
||||
"notification_email_smtp": merged["notification_email_smtp"],
|
||||
"report_auto_schedule_enabled": merged["report_auto_schedule_enabled"] == "true",
|
||||
"timezone": merged["timezone"],
|
||||
}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(
|
||||
data: SettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin")),
|
||||
):
|
||||
"""Update platform settings (admin only)."""
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
changed_keys = []
|
||||
for key, value in updates.items():
|
||||
str_value = str(value).lower() if isinstance(value, bool) else str(value)
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = str_value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=str_value))
|
||||
changed_keys.append(key)
|
||||
|
||||
await log_audit(
|
||||
db, user.id, "update", "system",
|
||||
detail=f"更新系统设置: {', '.join(changed_keys)}",
|
||||
)
|
||||
|
||||
return {"message": "设置已更新"}
|
||||
@@ -6,6 +6,7 @@ from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User, Role
|
||||
from app.services.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["用户管理"])
|
||||
|
||||
@@ -58,6 +59,7 @@ async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), user
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
await log_audit(db, user.id, "create", "user", detail=f"创建用户 {data.username}")
|
||||
return {"id": new_user.id, "username": new_user.username}
|
||||
|
||||
|
||||
@@ -67,8 +69,10 @@ async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
updates = data.model_dump(exclude_unset=True)
|
||||
for k, v in updates.items():
|
||||
setattr(target, k, v)
|
||||
await log_audit(db, admin.id, "update", "user", detail=f"更新用户 {target.username}: {', '.join(updates.keys())}")
|
||||
return {"message": "已更新"}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,17 @@ class Settings(BaseSettings):
|
||||
CELERY_ENABLED: bool = False # Set True when Celery worker is running
|
||||
USE_SIMULATOR: bool = True # True=simulator mode, False=real IoT collectors
|
||||
|
||||
# SMTP Email settings
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = "noreply@tianpu-ems.com"
|
||||
SMTP_ENABLED: bool = False
|
||||
|
||||
# Platform URL for links in emails
|
||||
PLATFORM_URL: str = "http://localhost:3000"
|
||||
|
||||
@property
|
||||
def DATABASE_URL_SYNC(self) -> str:
|
||||
"""Derive synchronous URL from async DATABASE_URL for Alembic."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.api.router import api_router
|
||||
from app.api.v1.websocket import start_broadcast_task, stop_broadcast_task
|
||||
from app.core.config import get_settings
|
||||
from app.services.simulator import DataSimulator
|
||||
from app.services.report_scheduler import start_scheduler, stop_scheduler
|
||||
from app.collectors.manager import CollectorManager
|
||||
|
||||
settings = get_settings()
|
||||
@@ -28,7 +29,9 @@ async def lifespan(app: FastAPI):
|
||||
collector_manager = CollectorManager()
|
||||
await collector_manager.start()
|
||||
start_broadcast_task()
|
||||
await start_scheduler()
|
||||
yield
|
||||
await stop_scheduler()
|
||||
stop_broadcast_task()
|
||||
if settings.USE_SIMULATOR:
|
||||
await simulator.stop()
|
||||
|
||||
@@ -4,6 +4,7 @@ from app.models.energy import EnergyData, EnergyDailySummary
|
||||
from app.models.alarm import AlarmRule, AlarmEvent
|
||||
from app.models.carbon import CarbonEmission, EmissionFactor
|
||||
from app.models.report import ReportTemplate, ReportTask
|
||||
from app.models.setting import SystemSetting
|
||||
|
||||
__all__ = [
|
||||
"User", "Role", "AuditLog",
|
||||
@@ -12,4 +13,5 @@ __all__ = [
|
||||
"AlarmRule", "AlarmEvent",
|
||||
"CarbonEmission", "EmissionFactor",
|
||||
"ReportTemplate", "ReportTask",
|
||||
"SystemSetting",
|
||||
]
|
||||
|
||||
13
backend/app/models/setting.py
Normal file
13
backend/app/models/setting.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(100), unique=True, nullable=False, index=True)
|
||||
value = Column(Text, nullable=False, default="")
|
||||
description = Column(String(255))
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -1,13 +1,111 @@
|
||||
"""告警检测服务 - 根据告警规则检查最新数据,生成/自动恢复告警事件"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.alarm import AlarmRule, AlarmEvent
|
||||
from app.models.energy import EnergyData
|
||||
from app.models.device import Device
|
||||
|
||||
logger = logging.getLogger("alarm_checker")
|
||||
|
||||
# Alarm email template path
|
||||
_ALARM_TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "alarm_email.html"
|
||||
|
||||
# Severity display config
|
||||
_SEVERITY_CONFIG = {
|
||||
"critical": {
|
||||
"label": "紧急告警",
|
||||
"badge_color": "#d32f2f",
|
||||
"bg_color": "#ffebee",
|
||||
"text_color": "#c62828",
|
||||
},
|
||||
"major": {
|
||||
"label": "重要告警",
|
||||
"badge_color": "#e65100",
|
||||
"bg_color": "#fff3e0",
|
||||
"text_color": "#e65100",
|
||||
},
|
||||
"warning": {
|
||||
"label": "一般告警",
|
||||
"badge_color": "#f9a825",
|
||||
"bg_color": "#fffde7",
|
||||
"text_color": "#f57f17",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _send_alarm_email(
|
||||
rule: AlarmRule, event: AlarmEvent, device_id: int, session: AsyncSession
|
||||
):
|
||||
"""Send alarm notification email if configured."""
|
||||
from app.services.email_service import send_email
|
||||
from app.core.config import get_settings
|
||||
|
||||
# Check if email is in notify_channels
|
||||
channels = rule.notify_channels or []
|
||||
if "email" not in channels:
|
||||
return
|
||||
|
||||
# Get email targets from notify_targets
|
||||
targets = rule.notify_targets or {}
|
||||
emails = targets.get("emails", []) if isinstance(targets, dict) else []
|
||||
# If notify_targets is a list of strings (emails directly)
|
||||
if isinstance(targets, list):
|
||||
emails = [t for t in targets if isinstance(t, str) and "@" in t]
|
||||
|
||||
if not emails:
|
||||
logger.debug(f"No email recipients for alarm rule '{rule.name}', skipping.")
|
||||
return
|
||||
|
||||
# Fetch device info
|
||||
dev_result = await session.execute(select(Device).where(Device.id == device_id))
|
||||
device = dev_result.scalar_one_or_none()
|
||||
device_name = device.name if device else f"设备#{device_id}"
|
||||
device_code = device.code if device else "N/A"
|
||||
|
||||
settings = get_settings()
|
||||
severity_cfg = _SEVERITY_CONFIG.get(rule.severity, _SEVERITY_CONFIG["warning"])
|
||||
|
||||
# Build threshold string
|
||||
if rule.condition == "range_out":
|
||||
threshold_str = f"[{rule.threshold_low}, {rule.threshold_high}]"
|
||||
else:
|
||||
threshold_str = str(rule.threshold)
|
||||
|
||||
# Format triggered time in Beijing timezone
|
||||
triggered_time = event.triggered_at or datetime.now(timezone.utc)
|
||||
triggered_beijing = triggered_time + timedelta(hours=8)
|
||||
triggered_str = triggered_beijing.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Load and render template
|
||||
try:
|
||||
template_html = _ALARM_TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
logger.error("Alarm email template not found, skipping email.")
|
||||
return
|
||||
|
||||
body_html = template_html.format(
|
||||
severity_label=severity_cfg["label"],
|
||||
severity_badge_color=severity_cfg["badge_color"],
|
||||
severity_bg_color=severity_cfg["bg_color"],
|
||||
severity_text_color=severity_cfg["text_color"],
|
||||
title=event.title,
|
||||
device_name=device_name,
|
||||
device_code=device_code,
|
||||
data_type=rule.data_type,
|
||||
current_value=str(event.value),
|
||||
threshold_str=threshold_str,
|
||||
triggered_at=triggered_str,
|
||||
description=event.description or "",
|
||||
platform_url=settings.PLATFORM_URL,
|
||||
)
|
||||
|
||||
subject = f"[{severity_cfg['label']}] {event.title} - 天普EMS告警通知"
|
||||
asyncio.create_task(send_email(to=emails, subject=subject, body_html=body_html))
|
||||
|
||||
# Rate limit: don't create duplicate events for the same rule+device within this window
|
||||
RATE_LIMIT_MINUTES = 5
|
||||
|
||||
@@ -140,6 +238,9 @@ async def check_alarms(session: AsyncSession):
|
||||
f"value={dp.value} threshold={threshold_str}"
|
||||
)
|
||||
|
||||
# Send email notification (non-blocking)
|
||||
await _send_alarm_email(rule, event, device_id, session)
|
||||
|
||||
elif not triggered and active_event:
|
||||
# Auto-resolve
|
||||
active_event.status = "resolved"
|
||||
|
||||
32
backend/app/services/audit.py
Normal file
32
backend/app/services/audit.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.user import AuditLog
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
user_id: int | None,
|
||||
action: str,
|
||||
resource: str,
|
||||
detail: str = "",
|
||||
ip_address: str | None = None,
|
||||
):
|
||||
"""Write an audit log entry.
|
||||
|
||||
Args:
|
||||
db: async database session (must be flushed/committed by caller)
|
||||
user_id: ID of the acting user (None for system actions)
|
||||
action: one of login, create, update, delete, export, view,
|
||||
acknowledge, resolve
|
||||
resource: one of user, device, alarm, report, system, auth
|
||||
detail: human-readable description
|
||||
ip_address: client IP if available
|
||||
"""
|
||||
entry = AuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource=resource,
|
||||
detail=detail,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.add(entry)
|
||||
# Don't flush here — let the caller's transaction handle it
|
||||
105
backend/app/services/email_service.py
Normal file
105
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""邮件发送服务 - SMTP email sending for alarm notifications and report delivery."""
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger("email_service")
|
||||
|
||||
|
||||
async def send_email(
|
||||
to: list[str],
|
||||
subject: str,
|
||||
body_html: str,
|
||||
attachments: Optional[list[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email via SMTP.
|
||||
|
||||
Args:
|
||||
to: List of recipient email addresses.
|
||||
subject: Email subject line.
|
||||
body_html: HTML body content.
|
||||
attachments: Optional list of file paths to attach.
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.SMTP_ENABLED:
|
||||
logger.warning("SMTP is not enabled (SMTP_ENABLED=False). Skipping email send.")
|
||||
return False
|
||||
|
||||
if not settings.SMTP_HOST:
|
||||
logger.warning("SMTP_HOST is not configured. Skipping email send.")
|
||||
return False
|
||||
|
||||
if not to:
|
||||
logger.warning("No recipients specified. Skipping email send.")
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart("mixed")
|
||||
msg["From"] = settings.SMTP_FROM
|
||||
msg["To"] = ", ".join(to)
|
||||
msg["Subject"] = subject
|
||||
|
||||
# HTML body
|
||||
html_part = MIMEText(body_html, "html", "utf-8")
|
||||
msg.attach(html_part)
|
||||
|
||||
# Attachments
|
||||
if attachments:
|
||||
for filepath in attachments:
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
logger.warning(f"Attachment not found, skipping: {filepath}")
|
||||
continue
|
||||
|
||||
with open(path, "rb") as f:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(f.read())
|
||||
encoders.encode_base64(part)
|
||||
part.add_header(
|
||||
"Content-Disposition",
|
||||
f'attachment; filename="{path.name}"',
|
||||
)
|
||||
msg.attach(part)
|
||||
|
||||
# Send via SMTP
|
||||
context = ssl.create_default_context()
|
||||
|
||||
if settings.SMTP_PORT == 465:
|
||||
# SSL connection
|
||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
|
||||
else:
|
||||
# STARTTLS connection (port 587 or 25)
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
server.ehlo()
|
||||
if settings.SMTP_PORT == 587:
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.SMTP_FROM, to, msg.as_string())
|
||||
|
||||
logger.info(f"Email sent successfully to {to}, subject: {subject}")
|
||||
return True
|
||||
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP error sending email to {to}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending email to {to}: {e}")
|
||||
return False
|
||||
192
backend/app/services/report_scheduler.py
Normal file
192
backend/app/services/report_scheduler.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""报表定时调度服务 - Schedule report tasks via APScheduler and send results by email."""
|
||||
import logging
|
||||
from datetime import date, timedelta, datetime, timezone
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import async_session
|
||||
from app.models.report import ReportTask, ReportTemplate
|
||||
from app.services.report_generator import ReportGenerator
|
||||
from app.services.email_service import send_email
|
||||
|
||||
logger = logging.getLogger("report_scheduler")
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
def _parse_cron(cron_expr: str) -> dict:
|
||||
"""Parse a 5-field cron expression into APScheduler CronTrigger kwargs."""
|
||||
parts = cron_expr.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(f"Invalid cron expression (need 5 fields): {cron_expr}")
|
||||
return {
|
||||
"minute": parts[0],
|
||||
"hour": parts[1],
|
||||
"day": parts[2],
|
||||
"month": parts[3],
|
||||
"day_of_week": parts[4],
|
||||
}
|
||||
|
||||
|
||||
async def _run_report_task(task_id: int):
|
||||
"""Execute a single report task: generate the report and email it to recipients."""
|
||||
logger.info(f"Running scheduled report task id={task_id}")
|
||||
|
||||
async with async_session() as session:
|
||||
# Load task
|
||||
task_result = await session.execute(
|
||||
select(ReportTask).where(ReportTask.id == task_id)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
logger.warning(f"Report task id={task_id} not found, skipping.")
|
||||
return
|
||||
if not task.is_active:
|
||||
logger.info(f"Report task id={task_id} is inactive, skipping.")
|
||||
return
|
||||
|
||||
# Update status
|
||||
task.status = "running"
|
||||
task.last_run = datetime.now(timezone.utc)
|
||||
await session.flush()
|
||||
|
||||
# Load template to determine report type
|
||||
tmpl_result = await session.execute(
|
||||
select(ReportTemplate).where(ReportTemplate.id == task.template_id)
|
||||
)
|
||||
template = tmpl_result.scalar_one_or_none()
|
||||
if not template:
|
||||
logger.error(f"Template id={task.template_id} not found for task id={task_id}")
|
||||
task.status = "failed"
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
try:
|
||||
generator = ReportGenerator(session)
|
||||
today = date.today()
|
||||
export_format = task.export_format or "xlsx"
|
||||
|
||||
# Choose generation method based on template report_type
|
||||
if template.report_type == "daily":
|
||||
yesterday = today - timedelta(days=1)
|
||||
filepath = await generator.generate_energy_daily_report(
|
||||
start_date=yesterday, end_date=yesterday, export_format=export_format
|
||||
)
|
||||
elif template.report_type == "monthly":
|
||||
# Generate for previous month
|
||||
first_of_month = today.replace(day=1)
|
||||
last_month_end = first_of_month - timedelta(days=1)
|
||||
last_month_start = last_month_end.replace(day=1)
|
||||
filepath = await generator.generate_monthly_summary(
|
||||
month=last_month_start.month,
|
||||
year=last_month_start.year,
|
||||
export_format=export_format,
|
||||
)
|
||||
elif template.report_type == "custom" and "device" in template.name.lower():
|
||||
filepath = await generator.generate_device_status_report(
|
||||
export_format=export_format
|
||||
)
|
||||
else:
|
||||
# Default: daily report for yesterday
|
||||
yesterday = today - timedelta(days=1)
|
||||
filepath = await generator.generate_energy_daily_report(
|
||||
start_date=yesterday, end_date=yesterday, export_format=export_format
|
||||
)
|
||||
|
||||
task.file_path = filepath
|
||||
task.status = "completed"
|
||||
logger.info(f"Report task id={task_id} completed: {filepath}")
|
||||
|
||||
# Send email with attachment if recipients configured
|
||||
recipients = task.recipients or []
|
||||
if isinstance(recipients, list) and recipients:
|
||||
report_name = task.name or template.name
|
||||
subject = f"{report_name} - 天普EMS自动报表"
|
||||
body_html = f"""
|
||||
<div style="font-family: 'Microsoft YaHei', sans-serif; padding: 20px;">
|
||||
<h2 style="color: #1a73e8;">天普零碳园区智慧能源管理平台</h2>
|
||||
<p>您好,</p>
|
||||
<p>系统已自动生成 <strong>{report_name}</strong>,请查收附件。</p>
|
||||
<p style="color: #666; font-size: 13px;">
|
||||
生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>
|
||||
报表类型: {template.report_type}<br>
|
||||
格式: {export_format.upper()}
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e8e8e8; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #999;">此为系统自动发送,请勿回复。</p>
|
||||
</div>
|
||||
"""
|
||||
await send_email(
|
||||
to=recipients,
|
||||
subject=subject,
|
||||
body_html=body_html,
|
||||
attachments=[filepath],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Report task id={task_id} failed: {e}", exc_info=True)
|
||||
task.status = "failed"
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _load_and_schedule_tasks():
|
||||
"""Load all active report tasks with schedules and register them with APScheduler."""
|
||||
global _scheduler
|
||||
if not _scheduler:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(ReportTask).where(
|
||||
ReportTask.is_active == True,
|
||||
ReportTask.schedule != None,
|
||||
ReportTask.schedule != "",
|
||||
)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
for task in tasks:
|
||||
try:
|
||||
cron_kwargs = _parse_cron(task.schedule)
|
||||
_scheduler.add_job(
|
||||
_run_report_task,
|
||||
CronTrigger(**cron_kwargs),
|
||||
args=[task.id],
|
||||
id=f"report_task_{task.id}",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
logger.info(
|
||||
f"Scheduled report task id={task.id} name='{task.name}' "
|
||||
f"cron='{task.schedule}'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule report task id={task.id}: {e}")
|
||||
|
||||
logger.info(f"Report scheduler loaded {len(tasks)} task(s).")
|
||||
|
||||
|
||||
async def start_scheduler():
|
||||
"""Start the APScheduler-based report scheduler."""
|
||||
global _scheduler
|
||||
if _scheduler and _scheduler.running:
|
||||
logger.warning("Report scheduler is already running.")
|
||||
return
|
||||
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
_scheduler.start()
|
||||
logger.info("Report scheduler started.")
|
||||
|
||||
await _load_and_schedule_tasks()
|
||||
|
||||
|
||||
async def stop_scheduler():
|
||||
"""Stop the report scheduler gracefully."""
|
||||
global _scheduler
|
||||
if _scheduler and _scheduler.running:
|
||||
_scheduler.shutdown(wait=False)
|
||||
logger.info("Report scheduler stopped.")
|
||||
_scheduler = None
|
||||
98
backend/app/templates/alarm_email.html
Normal file
98
backend/app/templates/alarm_email.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f4f5f7; font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7; padding:20px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #1a73e8, #0d47a1); padding:24px 32px; text-align:center;">
|
||||
<div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">TIANPU EMS</div>
|
||||
<div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">天普零碳园区智慧能源管理平台</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alert Banner -->
|
||||
<tr>
|
||||
<td style="padding:0;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="background-color:{severity_bg_color}; padding:16px 32px; text-align:center;">
|
||||
<span style="display:inline-block; background-color:{severity_badge_color}; color:#ffffff; font-size:13px; font-weight:bold; padding:4px 16px; border-radius:12px; letter-spacing:1px;">{severity_label}</span>
|
||||
<div style="color:{severity_text_color}; font-size:16px; font-weight:bold; margin-top:8px;">{title}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alarm Details -->
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e8e8e8; border-radius:6px; overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; width:120px; border-bottom:1px solid #e8e8e8;">设备名称</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">设备编号</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{device_code}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">监控指标</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{data_type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">当前值</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:{severity_badge_color}; font-weight:bold; border-bottom:1px solid #e8e8e8;">{current_value}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8e8;">告警阈值</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333; border-bottom:1px solid #e8e8e8;">{threshold_str}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#fafafa; padding:10px 16px; font-size:13px; color:#666;">触发时间</td>
|
||||
<td style="padding:10px 16px; font-size:14px; color:#333;">{triggered_at}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Description -->
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<div style="background-color:#fff8e1; border-left:4px solid #ffa000; padding:12px 16px; border-radius:0 4px 4px 0; font-size:13px; color:#795548;">
|
||||
{description}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Action Button -->
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px; text-align:center;">
|
||||
<a href="{platform_url}/alarms" style="display:inline-block; background-color:#1a73e8; color:#ffffff; text-decoration:none; padding:12px 32px; border-radius:6px; font-size:14px; font-weight:bold;">查看告警详情</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#f8f9fa; padding:16px 32px; border-top:1px solid #e8e8e8;">
|
||||
<div style="font-size:12px; color:#999; text-align:center; line-height:1.6;">
|
||||
此为系统自动发送,请勿回复。<br>
|
||||
天普零碳园区智慧能源管理平台 © 2026
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user