Files
iot-device-management-service/app/routers/wechat_notify_api.py
16337 63a8d5a8f2 告警-工单解耦:企微交互+Agent全面切换到工单驱动
Part A: 数据层
- 新增 WechatCardState 模型(order_id ↔ alarm_id 映射 + response_code)
- 新建 models_iot.py(IoT 工单只读 ORM:ops_order + security_ext + clean_ext)
- config.py 新增 IOT_DATABASE_URL 配置

Part B: 企微解耦(alarm_id → order_id)
- wechat_service: response_code 存储迁移到 wechat_card_state,集中 helper
- 卡片发送/更新方法改用 order_id,按钮 key: confirm_{order_id}
- wechat_callback: 按钮解析改 order_id,反查 alarm_id(可空)
- wechat_notify_api: send-card/sync-status 以 orderId 为主键
- yudao_aiot_alarm: 卡片操作改用 order_id,删重复 helper

Part C: Agent 工具全面改为工单驱动
- 新建 order_query.py(查 IoT ops_order,支持安保+保洁工单)
- 新建 order_action.py(操作工单状态 + 提交处理结果)
- 更新 prompts.py 为工单助手
- 更新工具注册(__init__.py)

Part D: 日报改为工单驱动
- daily_report_service 从查 alarm_event 改为查 IoT ops_order + 扩展表
- 支持安保+保洁工单统计
2026-03-31 10:49:42 +08:00

285 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
企微通知 API — 供 IoT 平台调用
IoT 平台通过这些接口驱动企微消息:
- /send-card: 工单派单后发送企微群聊+私发卡片
- /sync-status: 工单状态变更后同步confirmed更新卡片、终态更新告警+卡片)
"""
import json
from fastapi import APIRouter, Request
from pydantic import BaseModel
from typing import List, Optional
from app.utils.logger import logger
router = APIRouter(prefix="/api/wechat/notify", tags=["企微通知-IoT回调"])
class SendCardRequest(BaseModel):
"""IoT 派单后发送企微卡片"""
alarmId: Optional[str] = ""
orderId: str
userIds: List[str]
title: str
areaName: Optional[str] = ""
cameraName: Optional[str] = ""
eventTime: Optional[str] = ""
level: Optional[int] = 2
snapshotUrl: Optional[str] = ""
class SyncStatusRequest(BaseModel):
"""IoT 工单状态变更后同步"""
alarmId: Optional[str] = ""
orderId: str
status: str # dispatched / confirmed / completed / false_alarm / auto_resolved
operator: Optional[str] = ""
remark: Optional[str] = ""
async def _parse_body(request: Request) -> dict:
"""兼容解析请求体JSON / form / query params"""
content_type = request.headers.get("content-type", "")
logger.info(f"IoT请求调试: method={request.method}, content-type={content_type}, url={request.url}")
# 1. 读取原始 body
raw_body = await request.body()
logger.info(f"IoT请求调试: raw_body_len={len(raw_body)}, raw_body={raw_body[:500]}")
# 2. 尝试 JSON 解析
if raw_body:
try:
return json.loads(raw_body)
except Exception as e:
logger.warning(f"JSON解析失败: {e}")
# 3. 尝试 form 解析(需要重新构造 body因为上面已经读过了
if raw_body and "form" in content_type:
try:
from urllib.parse import parse_qs
params = parse_qs(raw_body.decode("utf-8"))
return {k: v[0] if len(v) == 1 else v for k, v in params.items()}
except Exception as e:
logger.warning(f"Form解析失败: {e}")
# 4. 降级到 query params
qp = dict(request.query_params)
if qp:
logger.info(f"使用query params: {qp}")
return qp
# 5. 尝试从 headers 中找数据(某些客户端可能这么做)
logger.warning(f"所有解析方式均无数据headers={dict(request.headers)}")
return {}
@router.post("/send-card")
async def send_card(request: Request):
"""IoT 派单后调用,发送企微群聊+私发卡片"""
try:
data = await _parse_body(request)
logger.info(f"IoT send-card 收到数据: {data}")
req = SendCardRequest(**data)
from app.services.wechat_service import get_wechat_service
from app.config import settings
wechat = get_wechat_service()
if not wechat.enabled:
return {"code": -1, "msg": "企微未启用"}
# 群聊通知
group_chat_id = settings.wechat.group_chat_id
if group_chat_id:
# 截图:从告警表获取 object key 生成预签名 URL
from app.services.notify_dispatch import _get_presigned_url
snapshot_url = req.snapshotUrl or ""
# 如果是完整 COS URL从告警表取 object key 重新生成预签名
if not snapshot_url or "403" in snapshot_url:
snapshot_url = ""
presigned_url = ""
if snapshot_url:
presigned_url = _get_presigned_url(snapshot_url)
# 从告警表补全摄像头名称和截图IoT 可能没传或传了无效数据)
camera_name = req.cameraName or ""
alarm_type_code = ""
alarm_snapshot_key = ""
if req.alarmId:
from app.models import get_session, AlarmEvent
from app.services.camera_name_service import get_camera_name_service
db = get_session()
try:
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == req.alarmId).first()
if alarm:
alarm_type_code = alarm.alarm_type or ""
alarm_snapshot_key = alarm.snapshot_url or ""
if not camera_name or camera_name == "未知":
if alarm.device_id:
camera_service = get_camera_name_service()
camera_info = await camera_service.get_camera_info(alarm.device_id)
camera_name = camera_service.format_display_name(alarm.device_id, camera_info)
finally:
db.close()
# 截图预签名:优先用告警表的 object key能正确签名
if not presigned_url or not snapshot_url:
if alarm_snapshot_key:
presigned_url = _get_presigned_url(alarm_snapshot_key)
logger.info(f"群聊截图诊断: order={req.orderId}, alarm={req.alarmId}, "
f"iot_snapshot={req.snapshotUrl!r}, "
f"db_snapshot={alarm_snapshot_key!r}, "
f"presigned_url={presigned_url[:80] if presigned_url else '(空)'}...")
# alarm_type: 用 alarm_type_code 避免"告警告警"
actual_alarm_type = alarm_type_code or req.title
await wechat.send_group_alarm_combo(
chat_id=group_chat_id,
alarm_id=req.alarmId or req.orderId,
alarm_type=actual_alarm_type,
area_name=req.areaName or "",
camera_name=camera_name,
description=req.title,
event_time=req.eventTime or "",
alarm_level=req.level or 2,
snapshot_url=presigned_url,
mention_user_ids=req.userIds,
)
# 私发卡片(以 order_id 为主键)
if not alarm_type_code and req.alarmId:
from app.models import get_session, AlarmEvent
db = get_session()
try:
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == req.alarmId).first()
alarm_type_code = alarm.alarm_type if alarm else ""
finally:
db.close()
sent = await wechat.send_alarm_card(
user_ids=req.userIds,
order_id=req.orderId,
alarm_type=alarm_type_code or req.title,
area_name=req.areaName or "",
camera_name=camera_name if 'camera_name' in dir() else (req.cameraName or ""),
description=f"工单编号:{req.orderId}",
event_time=req.eventTime or "",
alarm_level=req.level or 2,
alarm_id=req.alarmId or "",
)
if sent:
logger.info(f"IoT回调发卡片成功: order={req.orderId}, alarm={req.alarmId}, users={req.userIds}")
return {"code": 0, "msg": "success"}
else:
return {"code": -1, "msg": "发送失败"}
except Exception as e:
logger.error(f"IoT回调发卡片异常: {e}", exc_info=True)
return {"code": -1, "msg": str(e)}
@router.post("/sync-status")
async def sync_status(request: Request):
"""IoT 工单状态变更后调用"""
try:
data = await _parse_body(request)
logger.info(f"IoT sync-status 收到数据: {data}")
req = SyncStatusRequest(**data)
from app.services.alarm_event_service import get_alarm_event_service
from app.services.wechat_service import get_wechat_service
service = get_alarm_event_service()
wechat = get_wechat_service()
# 用 order_id 查 response_code 和 alarm_id
order_id = req.orderId
alarm_id = req.alarmId or wechat.get_alarm_id_for_order(order_id)
if req.status == "dispatched":
# 派单:不更新告警,不发企微(企微由 send-card 接口发)
logger.info(f"dispatched 已记录: order={order_id}, alarm={alarm_id}")
return {"code": 0, "msg": "success"}
elif req.status == "confirmed":
# 确认接单:更新卡片到第二步(不更新告警)
if wechat.enabled:
response_code = wechat.get_response_code(order_id)
if response_code:
await wechat.update_alarm_card_step2(
response_code=response_code,
user_ids=[req.operator] if req.operator else [],
order_id=order_id,
operator_name=req.operator,
)
logger.info(f"卡片已更新到步骤2: order={order_id}")
return {"code": 0, "msg": "success"}
elif req.status in ("completed", "false_alarm", "auto_resolved"):
# 终态:更新告警状态(如果有关联告警)+ 更新卡片
terminal_map = {
"completed": {"alarm_status": "CLOSED", "handle_status": "DONE", "card_action": "complete"},
"false_alarm": {"alarm_status": "FALSE", "handle_status": "IGNORED", "card_action": "false"},
"auto_resolved": {"alarm_status": "CLOSED", "handle_status": "DONE", "card_action": "auto_resolve"},
}
mapping = terminal_map[req.status]
# 仅在有关联告警时更新告警状态
if alarm_id:
service.handle_alarm(
alarm_id=alarm_id,
alarm_status=mapping["alarm_status"],
handle_status=mapping["handle_status"],
handler=req.operator or "",
remark=req.remark or f"IoT工单: {req.status}",
)
logger.info(f"告警终态已同步: alarm={alarm_id}, status={req.status}")
if wechat.enabled:
response_code = wechat.get_response_code(order_id)
if response_code:
await wechat.update_alarm_card_terminal(
response_code=response_code,
user_ids=[req.operator] if req.operator else [],
order_id=order_id,
action=mapping["card_action"],
operator_name=req.operator,
)
return {"code": 0, "msg": "success"}
else:
logger.warning(f"未知状态,忽略: {req.status}")
return {"code": 0, "msg": "ignored"}
except Exception as e:
logger.error(f"IoT回调同步状态异常: {e}", exc_info=True)
return {"code": -1, "msg": str(e)}
@router.post("/daily-report")
async def trigger_daily_report(preview: bool = False):
"""手动触发每日告警日报
- preview=false默认生成并发送到企微群聊
- preview=true仅生成内容预览不发送
"""
try:
from app.services.daily_report_service import generate_daily_report, _send_daily_report
if preview:
content = await generate_daily_report()
return {"code": 0, "data": {"content": content}, "msg": "预览生成成功(未发送)"}
await _send_daily_report()
return {"code": 0, "msg": "日报已发送"}
except Exception as e:
logger.error(f"手动触发日报异常: {e}", exc_info=True)
return {"code": -1, "msg": str(e)}