feat: 两步卡片状态机 + 安保工单对接 + 先到先得结单
- 新增 work_order_client.py:SHA256签名的工单API客户端(创建/自动结单) - 企微卡片改为两步交互:确认接单→[已处理完成/标记误报]→终态 - 告警通知后自动创建工单,orderId存入alarm_event_ext - 边缘端resolve支持先到先得:终态不可被覆盖 - 边缘端resolve后异步触发工单自动结单+卡片终态更新 - 新增WorkOrderConfig配置项 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,16 @@ class AgentConfig:
|
|||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkOrderConfig:
|
||||||
|
"""安保工单开放接口配置"""
|
||||||
|
base_url: str = "" # 工单系统地址,如 http://aiot-platform.viewsh.com:48080
|
||||||
|
app_id: str = "" # 应用ID
|
||||||
|
app_secret: str = "" # 应用密钥
|
||||||
|
timeout: int = 10 # 请求超时(秒)
|
||||||
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RedisConfig:
|
class RedisConfig:
|
||||||
"""Redis 配置"""
|
"""Redis 配置"""
|
||||||
@@ -124,6 +134,7 @@ class Settings(BaseModel):
|
|||||||
vlm: VLMConfig = VLMConfig()
|
vlm: VLMConfig = VLMConfig()
|
||||||
wechat: WeChatConfig = WeChatConfig()
|
wechat: WeChatConfig = WeChatConfig()
|
||||||
agent: AgentConfig = AgentConfig()
|
agent: AgentConfig = AgentConfig()
|
||||||
|
work_order: WorkOrderConfig = WorkOrderConfig()
|
||||||
redis: RedisConfig = RedisConfig()
|
redis: RedisConfig = RedisConfig()
|
||||||
camera_name: CameraNameConfig = CameraNameConfig()
|
camera_name: CameraNameConfig = CameraNameConfig()
|
||||||
|
|
||||||
@@ -180,6 +191,13 @@ def load_settings() -> Settings:
|
|||||||
llm_timeout=int(os.getenv("AGENT_LLM_TIMEOUT", "15")),
|
llm_timeout=int(os.getenv("AGENT_LLM_TIMEOUT", "15")),
|
||||||
enabled=os.getenv("AGENT_ENABLED", "false").lower() == "true",
|
enabled=os.getenv("AGENT_ENABLED", "false").lower() == "true",
|
||||||
),
|
),
|
||||||
|
work_order=WorkOrderConfig(
|
||||||
|
base_url=os.getenv("WORK_ORDER_BASE_URL", ""),
|
||||||
|
app_id=os.getenv("WORK_ORDER_APP_ID", ""),
|
||||||
|
app_secret=os.getenv("WORK_ORDER_APP_SECRET", ""),
|
||||||
|
timeout=int(os.getenv("WORK_ORDER_TIMEOUT", "10")),
|
||||||
|
enabled=os.getenv("WORK_ORDER_ENABLED", "false").lower() == "true",
|
||||||
|
),
|
||||||
redis=RedisConfig(
|
redis=RedisConfig(
|
||||||
host=os.getenv("REDIS_HOST", "localhost"),
|
host=os.getenv("REDIS_HOST", "localhost"),
|
||||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ async def lifespan(app: FastAPI):
|
|||||||
agent = get_agent_dispatcher()
|
agent = get_agent_dispatcher()
|
||||||
agent.init(settings.agent)
|
agent.init(settings.agent)
|
||||||
|
|
||||||
|
# 初始化工单客户端
|
||||||
|
from app.services.work_order_client import get_work_order_client
|
||||||
|
wo_client = get_work_order_client()
|
||||||
|
wo_client.init(settings.work_order)
|
||||||
|
|
||||||
logger.info("AI 告警平台启动完成")
|
logger.info("AI 告警平台启动完成")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ async def edge_alarm_resolve(
|
|||||||
|
|
||||||
与 /admin-api/aiot/alarm/edge/resolve 功能相同,
|
与 /admin-api/aiot/alarm/edge/resolve 功能相同,
|
||||||
但不要求认证,供 Edge 设备直接调用。
|
但不要求认证,供 Edge 设备直接调用。
|
||||||
|
支持先到先得:已被人工处理的告警不覆盖状态。
|
||||||
"""
|
"""
|
||||||
|
# 先检查是否已到终态(先到先得)
|
||||||
|
was_terminal = service.is_alarm_terminal(resolve.alarm_id)
|
||||||
|
|
||||||
success = service.resolve_alarm(
|
success = service.resolve_alarm(
|
||||||
alarm_id=resolve.alarm_id,
|
alarm_id=resolve.alarm_id,
|
||||||
duration_ms=resolve.duration_ms,
|
duration_ms=resolve.duration_ms,
|
||||||
@@ -84,4 +88,54 @@ async def edge_alarm_resolve(
|
|||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
return YudaoResponse.error(404, "告警不存在")
|
return YudaoResponse.error(404, "告警不存在")
|
||||||
|
|
||||||
|
# 如果之前不是终态(边缘端先到),触发自动结单 + 卡片更新
|
||||||
|
if not was_terminal:
|
||||||
|
asyncio.create_task(_resolve_work_order_and_card(resolve.alarm_id, resolve.resolve_type))
|
||||||
|
|
||||||
return YudaoResponse.success(True)
|
return YudaoResponse.success(True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_work_order_and_card(alarm_id: str, resolve_type: str):
|
||||||
|
"""边缘端 resolve 后异步处理:更新卡片 + 自动结单"""
|
||||||
|
try:
|
||||||
|
from app.services.work_order_client import get_work_order_client
|
||||||
|
from app.services.wechat_service import get_wechat_service
|
||||||
|
from app.models import get_session, AlarmEventExt
|
||||||
|
|
||||||
|
# 1. 自动结单
|
||||||
|
wo_client = get_work_order_client()
|
||||||
|
if wo_client.enabled:
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
ext = db.query(AlarmEventExt).filter(
|
||||||
|
AlarmEventExt.alarm_id == alarm_id,
|
||||||
|
AlarmEventExt.ext_type == "WORK_ORDER",
|
||||||
|
).first()
|
||||||
|
order_id = ext.ext_data.get("order_id", "") if ext and ext.ext_data else ""
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if order_id:
|
||||||
|
remark_map = {
|
||||||
|
"person_returned": "人员回岗自动关闭",
|
||||||
|
"non_work_time": "非工作时间自动关闭",
|
||||||
|
"intrusion_cleared": "入侵消失自动关闭",
|
||||||
|
}
|
||||||
|
remark = remark_map.get(resolve_type, f"边缘端自动结单: {resolve_type}")
|
||||||
|
await wo_client.auto_complete_order(order_id, remark)
|
||||||
|
|
||||||
|
# 2. 更新企微卡片到终态(如果有 response_code)
|
||||||
|
wechat = get_wechat_service()
|
||||||
|
if wechat.enabled:
|
||||||
|
response_code = wechat.get_response_code(alarm_id)
|
||||||
|
if response_code:
|
||||||
|
await wechat.update_alarm_card_terminal(
|
||||||
|
response_code=response_code,
|
||||||
|
user_ids=[], # 空列表时企微更新所有已收到卡片的用户
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
action="auto_resolve",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"边缘端resolve后处理失败: alarm={alarm_id}, error={e}", exc_info=True)
|
||||||
|
|||||||
@@ -90,16 +90,22 @@ async def _process_agent_message(user_id: str, content: str):
|
|||||||
|
|
||||||
async def _process_card_button_click(msg: dict):
|
async def _process_card_button_click(msg: dict):
|
||||||
"""
|
"""
|
||||||
处理模板卡片按钮点击事件
|
处理模板卡片按钮点击事件(两步状态机)
|
||||||
|
|
||||||
企微回调 XML 解密后包含:
|
第一步按钮:
|
||||||
- FromUserName: 点击者 userid
|
- confirm_{alarm_id}: 确认接单 → 更新状态为处理中,创建工单,卡片更新到第二步
|
||||||
- EventKey: 按钮 key (handle_{alarm_id} / ignore_{alarm_id})
|
- ignore_{alarm_id}: 误报忽略 → 终态,自动结单
|
||||||
- TaskId: 卡片的 task_id (alarm_id)
|
|
||||||
- ResponseCode: 用于更新卡片状态(一次性)
|
第二步按钮:
|
||||||
|
- complete_{alarm_id}: 已处理完成 → 终态,自动结单
|
||||||
|
- false_{alarm_id}: 标记误报 → 终态,自动结单
|
||||||
|
|
||||||
|
终态按钮:
|
||||||
|
- done_{alarm_id}: 已完成,忽略
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from app.services.wechat_service import get_wechat_service
|
from app.services.wechat_service import get_wechat_service
|
||||||
|
from app.services.work_order_client import get_work_order_client
|
||||||
|
|
||||||
user_id = msg.get("FromUserName", "")
|
user_id = msg.get("FromUserName", "")
|
||||||
event_key = msg.get("EventKey", "")
|
event_key = msg.get("EventKey", "")
|
||||||
@@ -107,65 +113,155 @@ async def _process_card_button_click(msg: dict):
|
|||||||
response_code = msg.get("ResponseCode", "")
|
response_code = msg.get("ResponseCode", "")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"卡片按钮点击: user={user_id}, key={event_key}, "
|
f"卡片按钮点击: user={user_id}, key={event_key}, task={task_id}"
|
||||||
f"task={task_id}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 解析 action 和 alarm_id
|
# 解析 action 和 alarm_id
|
||||||
if event_key.startswith("handle_"):
|
if event_key.startswith("confirm_"):
|
||||||
action = "handle"
|
action = "confirm"
|
||||||
alarm_id = event_key[len("handle_"):]
|
alarm_id = event_key[len("confirm_"):]
|
||||||
elif event_key.startswith("ignore_"):
|
elif event_key.startswith("ignore_"):
|
||||||
action = "ignore"
|
action = "ignore"
|
||||||
alarm_id = event_key[len("ignore_"):]
|
alarm_id = event_key[len("ignore_"):]
|
||||||
|
elif event_key.startswith("complete_"):
|
||||||
|
action = "complete"
|
||||||
|
alarm_id = event_key[len("complete_"):]
|
||||||
|
elif event_key.startswith("false_"):
|
||||||
|
action = "false"
|
||||||
|
alarm_id = event_key[len("false_"):]
|
||||||
elif event_key.startswith("done_"):
|
elif event_key.startswith("done_"):
|
||||||
# 已处理状态的按钮,忽略
|
return # 终态按钮,忽略
|
||||||
return
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"未知的按钮 key: {event_key}")
|
logger.warning(f"未知的按钮 key: {event_key}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 更新告警状态
|
wechat = get_wechat_service()
|
||||||
action_map = {
|
wo_client = get_work_order_client()
|
||||||
"handle": {
|
service = get_alarm_event_service()
|
||||||
"alarm_status": "CONFIRMED",
|
|
||||||
"handle_status": "HANDLING",
|
|
||||||
"remark": "企微卡片-前往处理",
|
|
||||||
},
|
|
||||||
"ignore": {
|
|
||||||
"alarm_status": "FALSE",
|
|
||||||
"handle_status": "IGNORED",
|
|
||||||
"remark": "企微卡片-标记误报",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
action_cfg = action_map.get(action)
|
# ---- 第一步:确认接单 ----
|
||||||
if action_cfg:
|
if action == "confirm":
|
||||||
service = get_alarm_event_service()
|
# 更新告警状态为处理中
|
||||||
service.handle_alarm(
|
service.handle_alarm(
|
||||||
alarm_id=alarm_id,
|
alarm_id=alarm_id,
|
||||||
alarm_status=action_cfg["alarm_status"],
|
alarm_status="CONFIRMED",
|
||||||
handle_status=action_cfg["handle_status"],
|
handle_status="HANDLING",
|
||||||
handler=user_id,
|
handler=user_id,
|
||||||
remark=action_cfg["remark"],
|
remark="企微卡片-确认接单",
|
||||||
)
|
)
|
||||||
logger.info(f"告警状态已更新: alarm={alarm_id}, action={action}, by={user_id}")
|
logger.info(f"告警已确认接单: alarm={alarm_id}, by={user_id}")
|
||||||
|
|
||||||
# 更新卡片按钮状态(变灰 + 显示处理结果)
|
# 更新卡片到第二步(新交互按钮)
|
||||||
if response_code:
|
if response_code:
|
||||||
wechat = get_wechat_service()
|
await wechat.update_alarm_card_step2(
|
||||||
await wechat.update_alarm_card(
|
response_code=response_code,
|
||||||
response_code=response_code,
|
user_ids=[user_id],
|
||||||
user_ids=[user_id],
|
alarm_id=alarm_id,
|
||||||
|
operator_name=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 第一步:误报忽略 ----
|
||||||
|
elif action == "ignore":
|
||||||
|
service.handle_alarm(
|
||||||
alarm_id=alarm_id,
|
alarm_id=alarm_id,
|
||||||
action=action,
|
alarm_status="FALSE",
|
||||||
operator_name=user_id,
|
handle_status="IGNORED",
|
||||||
|
handler=user_id,
|
||||||
|
remark="企微卡片-误报忽略",
|
||||||
)
|
)
|
||||||
|
logger.info(f"告警已标记忽略: alarm={alarm_id}, by={user_id}")
|
||||||
|
|
||||||
|
# 终态卡片
|
||||||
|
if response_code:
|
||||||
|
await wechat.update_alarm_card_terminal(
|
||||||
|
response_code=response_code,
|
||||||
|
user_ids=[user_id],
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
action="ignore",
|
||||||
|
operator_name=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 自动结单
|
||||||
|
order_id = _get_order_id_for_alarm(alarm_id)
|
||||||
|
if order_id:
|
||||||
|
await wo_client.auto_complete_order(order_id, f"误报忽略 by {user_id}")
|
||||||
|
|
||||||
|
# ---- 第二步:已处理完成 ----
|
||||||
|
elif action == "complete":
|
||||||
|
service.handle_alarm(
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
alarm_status="CLOSED",
|
||||||
|
handle_status="DONE",
|
||||||
|
handler=user_id,
|
||||||
|
remark="企微卡片-已处理完成",
|
||||||
|
)
|
||||||
|
logger.info(f"告警已处理完成: alarm={alarm_id}, by={user_id}")
|
||||||
|
|
||||||
|
# 终态卡片
|
||||||
|
if response_code:
|
||||||
|
await wechat.update_alarm_card_terminal(
|
||||||
|
response_code=response_code,
|
||||||
|
user_ids=[user_id],
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
action="complete",
|
||||||
|
operator_name=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 自动结单
|
||||||
|
order_id = _get_order_id_for_alarm(alarm_id)
|
||||||
|
if order_id:
|
||||||
|
await wo_client.auto_complete_order(order_id, f"已处理 by {user_id}")
|
||||||
|
|
||||||
|
# ---- 第二步:标记误报 ----
|
||||||
|
elif action == "false":
|
||||||
|
service.handle_alarm(
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
alarm_status="FALSE",
|
||||||
|
handle_status="IGNORED",
|
||||||
|
handler=user_id,
|
||||||
|
remark="企微卡片-标记误报",
|
||||||
|
)
|
||||||
|
logger.info(f"告警已标记误报: alarm={alarm_id}, by={user_id}")
|
||||||
|
|
||||||
|
# 终态卡片
|
||||||
|
if response_code:
|
||||||
|
await wechat.update_alarm_card_terminal(
|
||||||
|
response_code=response_code,
|
||||||
|
user_ids=[user_id],
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
action="false",
|
||||||
|
operator_name=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 自动结单
|
||||||
|
order_id = _get_order_id_for_alarm(alarm_id)
|
||||||
|
if order_id:
|
||||||
|
await wo_client.auto_complete_order(order_id, f"标记误报 by {user_id}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理卡片按钮点击失败: {e}", exc_info=True)
|
logger.error(f"处理卡片按钮点击失败: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_order_id_for_alarm(alarm_id: str) -> str:
|
||||||
|
"""从 alarm_event_ext 中获取关联的工单ID"""
|
||||||
|
from app.models import get_session, AlarmEventExt
|
||||||
|
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
ext = db.query(AlarmEventExt).filter(
|
||||||
|
AlarmEventExt.alarm_id == alarm_id,
|
||||||
|
AlarmEventExt.ext_type == "WORK_ORDER",
|
||||||
|
).first()
|
||||||
|
if ext and ext.ext_data:
|
||||||
|
return ext.ext_data.get("order_id", "")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询工单ID失败: alarm={alarm_id}, error={e}")
|
||||||
|
return ""
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ==================== Agent测试接口(开发用) ====================
|
# ==================== Agent测试接口(开发用) ====================
|
||||||
|
|
||||||
@router.post("/agent/test")
|
@router.post("/agent/test")
|
||||||
|
|||||||
@@ -558,7 +558,11 @@ class AlarmEventService:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def resolve_alarm(self, alarm_id: str, duration_ms: int, last_frame_time: str, resolve_type: str) -> bool:
|
def resolve_alarm(self, alarm_id: str, duration_ms: int, last_frame_time: str, resolve_type: str) -> bool:
|
||||||
"""更新告警的持续时长和结束时间"""
|
"""
|
||||||
|
更新告警的持续时长和结束时间
|
||||||
|
|
||||||
|
先到先得:如果告警已被人工处理到终态(CLOSED/FALSE),仅更新时长,不覆盖状态。
|
||||||
|
"""
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
||||||
@@ -574,9 +578,12 @@ class AlarmEventService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
alarm.last_frame_time = beijing_now().replace(microsecond=0)
|
alarm.last_frame_time = beijing_now().replace(microsecond=0)
|
||||||
|
|
||||||
# 如果已被 VLM 标记为误报(IGNORED),只更新时长,不覆盖状态
|
# 先到先得:已被人工处理到终态的不覆盖
|
||||||
if alarm.handle_status == "IGNORED":
|
terminal_statuses = ("CLOSED", "FALSE")
|
||||||
logger.info(f"告警已为误报状态,仅更新时长: {alarm_id}")
|
terminal_handle = ("DONE", "IGNORED")
|
||||||
|
|
||||||
|
if alarm.alarm_status in terminal_statuses or alarm.handle_status in terminal_handle:
|
||||||
|
logger.info(f"告警已为终态({alarm.alarm_status}/{alarm.handle_status}),仅更新时长: {alarm_id}")
|
||||||
elif resolve_type == "person_returned":
|
elif resolve_type == "person_returned":
|
||||||
alarm.alarm_status = "CLOSED"
|
alarm.alarm_status = "CLOSED"
|
||||||
alarm.handle_status = "DONE"
|
alarm.handle_status = "DONE"
|
||||||
@@ -605,6 +612,19 @@ class AlarmEventService:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
def is_alarm_terminal(self, alarm_id: str) -> bool:
|
||||||
|
"""判断告警是否已到终态(先到先得判断用)"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
||||||
|
if not alarm:
|
||||||
|
return False
|
||||||
|
return alarm.alarm_status in ("CLOSED", "FALSE") or alarm.handle_status in ("DONE", "IGNORED")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
def count_alarms_by_edge_node(self, edge_node_id: str) -> int:
|
def count_alarms_by_edge_node(self, edge_node_id: str) -> int:
|
||||||
"""统计指定边缘节点的告警数量"""
|
"""统计指定边缘节点的告警数量"""
|
||||||
db = get_session()
|
db = get_session()
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ from typing import Dict, List
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
get_session,
|
get_session,
|
||||||
AlarmEvent, AlarmLlmAnalysis,
|
AlarmEvent, AlarmLlmAnalysis, AlarmEventExt,
|
||||||
CameraAreaBinding, AreaPersonBinding, NotifyArea,
|
CameraAreaBinding, AreaPersonBinding, NotifyArea,
|
||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services.vlm_service import get_vlm_service
|
from app.services.vlm_service import get_vlm_service
|
||||||
from app.services.wechat_service import get_wechat_service
|
from app.services.wechat_service import get_wechat_service, ALARM_TYPE_NAMES, ALARM_LEVEL_NAMES
|
||||||
|
from app.services.work_order_client import get_work_order_client
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
from app.utils.timezone import beijing_now
|
from app.utils.timezone import beijing_now
|
||||||
|
|
||||||
@@ -142,6 +143,21 @@ async def process_alarm_notification(alarm_data: Dict):
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"个人卡片发送失败: {alarm_id}")
|
logger.warning(f"个人卡片发送失败: {alarm_id}")
|
||||||
|
|
||||||
|
# ---- 4. 创建安保工单 ----
|
||||||
|
wo_client = get_work_order_client()
|
||||||
|
if wo_client.enabled:
|
||||||
|
type_name = ALARM_TYPE_NAMES.get(alarm_type, alarm_type)
|
||||||
|
level_name = ALARM_LEVEL_NAMES.get(alarm_level, "一般")
|
||||||
|
wo_title = f"【{level_name}】{type_name}告警 - {area_name}"
|
||||||
|
order_id = await wo_client.create_order(
|
||||||
|
title=wo_title,
|
||||||
|
area_id=area_name,
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
alarm_type=alarm_type,
|
||||||
|
)
|
||||||
|
if order_id:
|
||||||
|
_save_order_id(alarm_id, order_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"告警通知处理失败: {alarm_id}, error={e}", exc_info=True)
|
logger.error(f"告警通知处理失败: {alarm_id}, error={e}", exc_info=True)
|
||||||
|
|
||||||
@@ -255,3 +271,22 @@ def _get_notify_persons(camera_id: str, alarm_level: int) -> tuple:
|
|||||||
return ("未知区域", [])
|
return ("未知区域", [])
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _save_order_id(alarm_id: str, order_id: str):
|
||||||
|
"""将工单ID保存到 alarm_event_ext(ext_type=WORK_ORDER)"""
|
||||||
|
db = get_session()
|
||||||
|
try:
|
||||||
|
ext = AlarmEventExt(
|
||||||
|
alarm_id=alarm_id,
|
||||||
|
ext_type="WORK_ORDER",
|
||||||
|
ext_data={"order_id": order_id},
|
||||||
|
)
|
||||||
|
db.add(ext)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"工单ID已关联: alarm={alarm_id}, order={order_id}")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"保存工单ID失败: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -199,9 +199,9 @@ class WeChatService:
|
|||||||
},
|
},
|
||||||
"button_list": [
|
"button_list": [
|
||||||
{
|
{
|
||||||
"text": "前往处理",
|
"text": "确认接单",
|
||||||
"style": 1,
|
"style": 1,
|
||||||
"key": f"handle_{alarm_id}",
|
"key": f"confirm_{alarm_id}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "误报忽略",
|
"text": "误报忽略",
|
||||||
@@ -232,7 +232,72 @@ class WeChatService:
|
|||||||
logger.error(f"企微卡片发送异常: {e}")
|
logger.error(f"企微卡片发送异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def update_alarm_card(
|
async def update_alarm_card_step2(
|
||||||
|
self,
|
||||||
|
response_code: str,
|
||||||
|
user_ids: List[str],
|
||||||
|
alarm_id: str,
|
||||||
|
operator_name: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
更新卡片到第二步:确认接单后显示「已处理完成」「标记误报」按钮
|
||||||
|
|
||||||
|
利用 update_template_card 将卡片更新为新的交互卡片(非终态),
|
||||||
|
用户点击后会生成新的 response_code,实现链式更新。
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
access_token = await self._get_access_token()
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"userids": user_ids,
|
||||||
|
"agentid": self.agent_id_int,
|
||||||
|
"response_code": response_code,
|
||||||
|
"template_card": {
|
||||||
|
"card_type": "button_interaction",
|
||||||
|
"task_id": alarm_id,
|
||||||
|
"source": {
|
||||||
|
"desc": "AI安防告警 - 处理中",
|
||||||
|
"desc_color": 0,
|
||||||
|
},
|
||||||
|
"main_title": {
|
||||||
|
"title": f"已接单 - {operator_name}" if operator_name else "已接单",
|
||||||
|
},
|
||||||
|
"sub_title_text": "请完成处理后选择操作",
|
||||||
|
"button_list": [
|
||||||
|
{
|
||||||
|
"text": "已处理完成",
|
||||||
|
"style": 1,
|
||||||
|
"key": f"complete_{alarm_id}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "标记误报",
|
||||||
|
"style": 2,
|
||||||
|
"key": f"false_{alarm_id}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/update_template_card?access_token={access_token}"
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(url, json=body)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data.get("errcode") != 0:
|
||||||
|
logger.error(f"更新卡片到步骤2失败: {data}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"卡片已更新到步骤2: alarm={alarm_id}, operator={operator_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新卡片步骤2异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_alarm_card_terminal(
|
||||||
self,
|
self,
|
||||||
response_code: str,
|
response_code: str,
|
||||||
user_ids: List[str],
|
user_ids: List[str],
|
||||||
@@ -240,7 +305,7 @@ class WeChatService:
|
|||||||
action: str,
|
action: str,
|
||||||
operator_name: str = "",
|
operator_name: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""更新模板卡片状态(按钮变灰 + 显示处理结果)"""
|
"""更新卡片到终态(按钮变灰 + 显示最终结果)"""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -248,9 +313,10 @@ class WeChatService:
|
|||||||
access_token = await self._get_access_token()
|
access_token = await self._get_access_token()
|
||||||
|
|
||||||
action_text = {
|
action_text = {
|
||||||
"handle": f"处理中 - {operator_name}" if operator_name else "处理中",
|
|
||||||
"ignore": f"已忽略 - {operator_name}" if operator_name else "已忽略",
|
|
||||||
"complete": f"已处理 - {operator_name}" if operator_name else "已处理",
|
"complete": f"已处理 - {operator_name}" if operator_name else "已处理",
|
||||||
|
"false": f"已标记误报 - {operator_name}" if operator_name else "已标记误报",
|
||||||
|
"ignore": f"已忽略 - {operator_name}" if operator_name else "已忽略",
|
||||||
|
"auto_resolve": "系统自动结单",
|
||||||
}
|
}
|
||||||
replace_text = action_text.get(action, "已处理")
|
replace_text = action_text.get(action, "已处理")
|
||||||
|
|
||||||
@@ -264,7 +330,7 @@ class WeChatService:
|
|||||||
"main_title": {
|
"main_title": {
|
||||||
"title": replace_text,
|
"title": replace_text,
|
||||||
},
|
},
|
||||||
"sub_title_text": f"操作人:{operator_name}",
|
"sub_title_text": f"操作人:{operator_name}" if operator_name else "",
|
||||||
"button_list": [
|
"button_list": [
|
||||||
{
|
{
|
||||||
"text": replace_text,
|
"text": replace_text,
|
||||||
@@ -281,14 +347,14 @@ class WeChatService:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
if data.get("errcode") != 0:
|
if data.get("errcode") != 0:
|
||||||
logger.error(f"更新卡片失败: {data}")
|
logger.error(f"更新卡片终态失败: {data}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"卡片已更新: alarm={alarm_id}, action={action}")
|
logger.info(f"卡片已更新到终态: alarm={alarm_id}, action={action}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"更新卡片异常: {e}")
|
logger.error(f"更新卡片终态异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ==================== 个人消息:文本 ====================
|
# ==================== 个人消息:文本 ====================
|
||||||
|
|||||||
170
app/services/work_order_client.py
Normal file
170
app/services/work_order_client.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
安保工单开放接口客户端
|
||||||
|
|
||||||
|
对接外部工单系统,支持:
|
||||||
|
- 创建工单:POST /open-api/ops/security/order/create
|
||||||
|
- 自动结单:POST /open-api/ops/security/order/auto-complete
|
||||||
|
- SHA256 签名认证
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderClient:
|
||||||
|
"""安保工单 API 客户端(单例)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._enabled = False
|
||||||
|
self._base_url = ""
|
||||||
|
self._app_id = ""
|
||||||
|
self._app_secret = ""
|
||||||
|
self._timeout = 10
|
||||||
|
|
||||||
|
def init(self, config):
|
||||||
|
"""初始化工单配置"""
|
||||||
|
self._enabled = config.enabled and bool(config.base_url) and bool(config.app_secret)
|
||||||
|
self._base_url = config.base_url.rstrip("/")
|
||||||
|
self._app_id = config.app_id
|
||||||
|
self._app_secret = config.app_secret
|
||||||
|
self._timeout = getattr(config, "timeout", 10)
|
||||||
|
|
||||||
|
if self._enabled:
|
||||||
|
logger.info(f"工单客户端已启用: base_url={self._base_url}")
|
||||||
|
else:
|
||||||
|
logger.info("工单客户端未启用")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def _sign(self, body_json: str, nonce: str, timestamp: str) -> str:
|
||||||
|
"""
|
||||||
|
SHA256 签名
|
||||||
|
|
||||||
|
签名算法:SHA256(body_json + "appId=" + appId + "&nonce=" + nonce + "×tamp=" + timestamp + appSecret)
|
||||||
|
"""
|
||||||
|
raw = (
|
||||||
|
body_json
|
||||||
|
+ "appId=" + self._app_id
|
||||||
|
+ "&nonce=" + nonce
|
||||||
|
+ "×tamp=" + timestamp
|
||||||
|
+ self._app_secret
|
||||||
|
)
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _build_headers(self, body_json: str) -> dict:
|
||||||
|
"""构造请求头(含签名)"""
|
||||||
|
nonce = uuid.uuid4().hex[:16]
|
||||||
|
timestamp = str(int(time.time() * 1000))
|
||||||
|
sign = self._sign(body_json, nonce, timestamp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"appId": self._app_id,
|
||||||
|
"nonce": nonce,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"sign": sign,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_order(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
area_id: str,
|
||||||
|
alarm_id: str,
|
||||||
|
alarm_type: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
创建安保工单
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
orderId 字符串,失败返回 None
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
logger.debug("工单客户端未启用,跳过创建")
|
||||||
|
return None
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"title": title,
|
||||||
|
"areaId": area_id,
|
||||||
|
"alarmId": alarm_id,
|
||||||
|
"alarmType": alarm_type,
|
||||||
|
}
|
||||||
|
body_json = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = self._build_headers(body_json)
|
||||||
|
url = f"{self._base_url}/open-api/ops/security/order/create"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
resp = await client.post(url, content=body_json, headers=headers)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
logger.error(f"创建工单失败: {data}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
order_id = data.get("data", {}).get("orderId", "")
|
||||||
|
logger.info(f"工单已创建: orderId={order_id}, alarmId={alarm_id}")
|
||||||
|
return order_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建工单异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def auto_complete_order(
|
||||||
|
self,
|
||||||
|
order_id: str,
|
||||||
|
remark: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
自动结单
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"orderId": order_id,
|
||||||
|
"remark": remark,
|
||||||
|
}
|
||||||
|
body_json = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = self._build_headers(body_json)
|
||||||
|
url = f"{self._base_url}/open-api/ops/security/order/auto-complete"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
resp = await client.post(url, content=body_json, headers=headers)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
logger.error(f"自动结单失败: orderId={order_id}, resp={data}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"工单已自动结单: orderId={order_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"自动结单异常: orderId={order_id}, error={e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_work_order_client: Optional[WorkOrderClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_work_order_client() -> WorkOrderClient:
|
||||||
|
global _work_order_client
|
||||||
|
if _work_order_client is None:
|
||||||
|
_work_order_client = WorkOrderClient()
|
||||||
|
return _work_order_client
|
||||||
Reference in New Issue
Block a user