chore: update core to v1.1.0 (hooks plugin system)

This commit is contained in:
Du Wenbo
2026-04-04 18:32:56 +08:00
6 changed files with 265 additions and 0 deletions

1
core/VERSION Normal file
View File

@@ -0,0 +1 @@
1.1.0

View File

@@ -0,0 +1,3 @@
from app.hooks.loader import get_hooks
__all__ = ["get_hooks"]

View File

@@ -0,0 +1,142 @@
"""客户钩子基类 — 定义所有可扩展的业务事件钩子
每个客户项目可以继承 CustomerHooks 并重写需要的方法。
核心代码在关键业务节点调用这些钩子,客户无需修改核心代码即可注入自定义逻辑。
使用方式:
1. 在客户项目中创建 customers/{customer}/hooks/__init__.py
2. 继承 CustomerHooks重写需要的方法
3. 导出 hooks = YourCustomerHooks() 实例
"""
import logging
from typing import Any, Optional
logger = logging.getLogger("hooks")
class CustomerHooks:
"""客户钩子基类 — 所有方法默认为空操作,客户按需重写。"""
# =====================================================================
# 告警相关钩子
# =====================================================================
async def on_alarm_created(self, alarm_event, device, rule, db) -> None:
"""告警创建后触发。可用于:自动创建工单、发送微信通知、触发联动控制等。
Args:
alarm_event: AlarmEvent 实例已持久化有id
device: Device 实例(触发告警的设备)
rule: AlarmRule 实例(匹配的告警规则)
db: AsyncSession 数据库会话
"""
pass
async def on_alarm_resolved(self, alarm_event, device, db) -> None:
"""告警恢复后触发。可用于:关闭关联工单、发送恢复通知等。
Args:
alarm_event: AlarmEvent 实例已更新为resolved状态
device: Device 实例
db: AsyncSession
"""
pass
# =====================================================================
# 能耗数据钩子
# =====================================================================
async def on_energy_data_received(self, device, data_point, db) -> None:
"""新能耗数据入库后触发。可用于:自定义数据校验、派生计算、异常检测等。
Args:
device: Device 实例
data_point: EnergyData 实例
db: AsyncSession
"""
pass
# =====================================================================
# 设备状态钩子
# =====================================================================
async def on_device_status_changed(self, device, old_status: str, new_status: str, db) -> None:
"""设备状态变更后触发。可用于:离线告警、自动重启、通知运维人员等。
Args:
device: Device 实例
old_status: 旧状态 (online/offline/alarm/maintenance)
new_status: 新状态
db: AsyncSession
"""
pass
# =====================================================================
# 定额钩子
# =====================================================================
async def on_quota_exceeded(self, quota, usage, db) -> None:
"""定额超限后触发。可用于:邮件通知楼宇负责人、限电策略、上报管理层等。
Args:
quota: EnergyQuota 实例
usage: QuotaUsage 实例(包含使用率)
db: AsyncSession
"""
pass
# =====================================================================
# 工单钩子
# =====================================================================
async def on_work_order_created(self, order, db) -> None:
"""维修工单创建后触发。可用于:通知维修人员、同步到外部系统等。"""
pass
async def on_work_order_completed(self, order, db) -> None:
"""维修工单完成后触发。可用于:生成维修报告、更新设备状态等。"""
pass
# =====================================================================
# 巡检钩子
# =====================================================================
async def on_inspection_completed(self, record, db) -> None:
"""巡检完成后触发。可用于:生成合规报告、发现问题自动创建工单等。"""
pass
# =====================================================================
# 报表钩子
# =====================================================================
async def on_report_generated(self, report_task, file_path: str, db) -> None:
"""报表生成后触发。可用于自动邮件发送、上传到FTP/OSS、自定义格式转换等。"""
pass
# =====================================================================
# 自定义KPI钩子
# =====================================================================
async def calculate_custom_kpis(self, period: str, db) -> dict:
"""Dashboard加载时调用返回客户自定义KPI。
Args:
period: 统计周期 ("today", "month", "year")
db: AsyncSession
Returns:
dict: 自定义KPI字典{"solar_efficiency": 0.85, "grid_independence": 0.72}
"""
return {}
# =====================================================================
# 充电桩钩子
# =====================================================================
async def on_charging_order_created(self, order, db) -> None:
"""充电订单创建时触发。可用于:自定义计费规则、用户验证等。"""
pass
async def on_charging_order_completed(self, order, db) -> None:
"""充电订单完成时触发。可用于:结算通知、积分奖励等。"""
pass

View File

@@ -0,0 +1,92 @@
"""客户钩子加载器 — 从 customers/{CUSTOMER}/hooks/ 动态加载客户自定义钩子
加载逻辑:
1. 读取 CUSTOMER 环境变量
2. 查找 customers/{CUSTOMER}/hooks/__init__.py
3. 导入并返回 hooks 实例
4. 找不到则返回默认空钩子(所有方法为空操作)
"""
import importlib
import importlib.util
import logging
import os
import sys
from functools import lru_cache
from typing import Optional
from app.hooks.base import CustomerHooks
from app.core.config import get_settings
logger = logging.getLogger("hooks.loader")
_hooks_instance: Optional[CustomerHooks] = None
def _find_hooks_dir() -> Optional[str]:
"""Find the customer hooks directory, searching multiple locations."""
settings = get_settings()
customer = settings.CUSTOMER
config_path = settings.customer_config_path
hooks_dir = os.path.join(config_path, "hooks")
if os.path.isdir(hooks_dir) and os.path.exists(os.path.join(hooks_dir, "__init__.py")):
return hooks_dir
return None
def _load_hooks_from_dir(hooks_dir: str) -> Optional[CustomerHooks]:
"""Load hooks module from a directory path."""
try:
init_path = os.path.join(hooks_dir, "__init__.py")
spec = importlib.util.spec_from_file_location("customer_hooks", init_path)
if spec and spec.loader:
# Add parent dir to sys.path so relative imports work
parent_dir = os.path.dirname(os.path.dirname(hooks_dir))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
hooks = getattr(module, "hooks", None)
if isinstance(hooks, CustomerHooks):
return hooks
else:
logger.warning(
f"Customer hooks module loaded but 'hooks' attribute is not a CustomerHooks instance"
)
except Exception as e:
logger.error(f"Failed to load customer hooks: {e}", exc_info=True)
return None
def get_hooks() -> CustomerHooks:
"""获取当前客户的钩子实例。线程安全,全局单例。
Returns:
CustomerHooks: 客户钩子实例,如果客户没有自定义钩子则返回默认空钩子
"""
global _hooks_instance
if _hooks_instance is not None:
return _hooks_instance
settings = get_settings()
hooks_dir = _find_hooks_dir()
if hooks_dir:
loaded = _load_hooks_from_dir(hooks_dir)
if loaded:
logger.info(f"Loaded customer hooks for '{settings.CUSTOMER}' from {hooks_dir}")
_hooks_instance = loaded
return _hooks_instance
logger.info(f"No custom hooks for '{settings.CUSTOMER}', using defaults")
_hooks_instance = CustomerHooks()
return _hooks_instance
def reload_hooks() -> CustomerHooks:
"""重新加载客户钩子(开发时热重载用)"""
global _hooks_instance
_hooks_instance = None
return get_hooks()

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alarm import AlarmRule, AlarmEvent from app.models.alarm import AlarmRule, AlarmEvent
from app.models.energy import EnergyData from app.models.energy import EnergyData
from app.models.device import Device from app.models.device import Device
from app.hooks import get_hooks
logger = logging.getLogger("alarm_checker") logger = logging.getLogger("alarm_checker")
@@ -233,6 +234,16 @@ async def check_alarms(session: AsyncSession):
triggered_at=now, triggered_at=now,
) )
session.add(event) session.add(event)
await session.flush() # Ensure event has id
# Customer hook: on_alarm_created
try:
_dev = await session.execute(select(Device).where(Device.id == device_id))
_device = _dev.scalar_one_or_none()
await get_hooks().on_alarm_created(event, _device, rule, session)
except Exception as _he:
logger.error(f"Hook on_alarm_created error: {_he}")
logger.info( logger.info(
f"Alarm triggered: {rule.name} | device={device_id} | " f"Alarm triggered: {rule.name} | device={device_id} | "
f"value={dp.value} threshold={threshold_str}" f"value={dp.value} threshold={threshold_str}"
@@ -246,6 +257,14 @@ async def check_alarms(session: AsyncSession):
active_event.status = "resolved" active_event.status = "resolved"
active_event.resolved_at = now active_event.resolved_at = now
active_event.resolve_note = "自动恢复" active_event.resolve_note = "自动恢复"
# Customer hook: on_alarm_resolved
try:
_dev2 = await session.execute(select(Device).where(Device.id == device_id))
_device2 = _dev2.scalar_one_or_none()
await get_hooks().on_alarm_resolved(active_event, _device2, session)
except Exception as _he2:
logger.error(f"Hook on_alarm_resolved error: {_he2}")
logger.info( logger.info(
f"Alarm auto-resolved: {rule.name} | device={device_id}" f"Alarm auto-resolved: {rule.name} | device={device_id}"
) )

View File

@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.quota import EnergyQuota, QuotaUsage from app.models.quota import EnergyQuota, QuotaUsage
from app.models.alarm import AlarmEvent from app.models.alarm import AlarmEvent
from app.models.energy import EnergyDailySummary from app.models.energy import EnergyDailySummary
from app.hooks import get_hooks
logger = logging.getLogger("quota_checker") logger = logging.getLogger("quota_checker")
@@ -34,6 +35,7 @@ async def check_quotas(session: AsyncSession):
) )
quotas = result.scalars().all() quotas = result.scalars().all()
hooks = get_hooks()
for quota in quotas: for quota in quotas:
period_start, period_end = _get_period_range(quota.period, now) period_start, period_end = _get_period_range(quota.period, now)
@@ -121,4 +123,10 @@ async def check_quotas(session: AsyncSession):
f"quota={quota.quota_value:.1f} rate={usage_rate_pct:.1f}%" f"quota={quota.quota_value:.1f} rate={usage_rate_pct:.1f}%"
) )
# Customer hook: on_quota_exceeded
try:
await hooks.on_quota_exceeded(quota, usage_record, session)
except Exception as _he:
logger.error(f"Hook on_quota_exceeded error: {_he}")
await session.flush() await session.flush()