Files
iot-device-management-service/docs/plans/2026-03-06-vlm-wechat-notification-v1.md
16337 09e0dbbc38 docs: 添加 VLM 验证 + 企微通知 V1 实施计划
包含 9 个任务的详细实施计划:
- 数据库表设计(通知区域、摄像头绑定、人员绑定)
- VLM 视觉验证服务
- 企微通知服务
- 告警主流程集成
- 通知管理 API
2026-03-06 11:14:57 +08:00

1271 lines
39 KiB
Markdown
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.

# V1: VLM 复核 + 企微通知 + 手动结单 实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 边缘端告警上报后vsp-service 异步调用 VLM 复核截图,确认告警后通过企微互动卡片通知责任人,安保人员可通过企微按钮手动结单。
**Architecture:** 在现有 `POST /api/ai/alert/edge/report` 告警接收流程末尾追加异步任务,调用 qwen3-vl-flash VLM 复核截图,结果写入已有的 `alarm_llm_analysis` 表。VLM 确认后,通过 camera→area→person 三层查表找到责任人,推送企微互动卡片。企微按钮回调更新已有的 `alarm_event.handle_status` 字段完成手动结单。全程不修改现有表结构,不破坏现有功能。
**Tech Stack:** Python 3.8+, FastAPI, AsyncOpenAI (dashscope), 企微 Webhook/API, SQLAlchemy, MySQL
---
## 现有架构关键约束
### 真实生产入口
边缘端使用 **无认证** 端点上报告警:
- **文件:** `app/routers/edge_compat.py:23`
- **路径:** `POST /api/ai/alert/edge/report`
- **流程:** `create_from_edge_report()` → 存 MySQL → WebSocket 推前端
认证端点 `app/routers/yudao_aiot_alarm.py:350` 额外调用 OPS 回调,但边缘端不走这条路。
### 不可修改的现有功能
1. 告警存入 `alarm_event` 表的逻辑不变
2. WebSocket 推送前端的逻辑不变
3. 边缘端 resolve 自动结单逻辑不变
4. 前端告警列表/处理/统计接口不变
5. OPS 平台回调(认证端点)不变
### 可复用的现有资源
| 资源 | 位置 | 复用方式 |
|------|------|---------|
| `alarm_llm_analysis` 表 | `app/models.py:339-366` | 存 VLM 复核结果summary, is_false_alarm |
| `alarm_event.handle_status` | `app/models.py:289-291` | 手动结单状态更新 (UNHANDLED→HANDLING→DONE) |
| `alarm_event.alarm_status` | `app/models.py:287-288` | 误报标记 (NEW→FALSE) |
| `alarm_event.handler` | `app/models.py:295` | 记录操作人 |
| `alarm_event.handle_remark` | `app/models.py:296` | 记录操作备注 |
| `AlarmEventService.handle_alarm()` | `app/services/alarm_event_service.py:394-426` | 更新告警处理状态 |
| `AlarmLlmAnalysis` 模型 | `app/models.py:339-366` | 直接写入 VLM 结果 |
| `AppConfig.ai_model` | `app/config.py:36-40` | 扩展添加 VLM 配置 |
---
## Task 1: 新增 3 张通知路由表的 SQLAlchemy 模型
**Files:**
- Modify: `app/models.py` (末尾追加 3 个 Model 类)
**Step 1: 在 models.py 末尾添加 3 个新模型**
在文件末尾(`AlarmLlmAnalysis` 类之后)追加:
```python
class NotifyArea(Base):
"""通知区域"""
__tablename__ = "notify_area"
id = Column(Integer, primary_key=True, autoincrement=True)
area_id = Column(String(36), unique=True, nullable=False, index=True)
area_name = Column(String(100), nullable=False)
description = Column(String(200), nullable=True)
enabled = Column(SmallInteger, default=1)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class CameraAreaBinding(Base):
"""摄像头-区域映射"""
__tablename__ = "camera_area_binding"
id = Column(Integer, primary_key=True, autoincrement=True)
camera_id = Column(String(64), unique=True, nullable=False, index=True)
area_id = Column(String(36), nullable=False, index=True)
created_at = Column(DateTime, default=datetime.now)
class AreaPersonBinding(Base):
"""区域-人员通知绑定"""
__tablename__ = "area_person_binding"
id = Column(Integer, primary_key=True, autoincrement=True)
area_id = Column(String(36), nullable=False, index=True)
person_name = Column(String(50), nullable=False)
wechat_uid = Column(String(100), nullable=False)
role = Column(String(20), default="SECURITY")
notify_level = Column(Integer, default=1)
enabled = Column(SmallInteger, default=1)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
```
**Step 2: 在 MySQL 中手动创建对应的表**
由于 vsp-service 使用 `create_all()` 自动建表,新模型在服务重启后会自动创建。但生产环境建议手动执行:
```sql
CREATE TABLE IF NOT EXISTS notify_area (
id INT AUTO_INCREMENT PRIMARY KEY,
area_id VARCHAR(36) NOT NULL UNIQUE,
area_name VARCHAR(100) NOT NULL,
description VARCHAR(200),
enabled SMALLINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_area_id (area_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS camera_area_binding (
id INT AUTO_INCREMENT PRIMARY KEY,
camera_id VARCHAR(64) NOT NULL UNIQUE,
area_id VARCHAR(36) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_area (area_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS area_person_binding (
id INT AUTO_INCREMENT PRIMARY KEY,
area_id VARCHAR(36) NOT NULL,
person_name VARCHAR(50) NOT NULL,
wechat_uid VARCHAR(100) NOT NULL,
role VARCHAR(20) DEFAULT 'SECURITY',
notify_level INT DEFAULT 1,
enabled SMALLINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_area (area_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
**Step 3: Commit**
```bash
git add app/models.py
git commit -m "feat: 新增通知区域、摄像头区域映射、区域人员绑定 3 张表模型"
```
---
## Task 2: 扩展配置项VLM + 企微)
**Files:**
- Modify: `app/config.py` (添加 VLM 和企微配置段)
- Modify: `.env.example` (添加新环境变量说明)
**Step 1: 在 config.py 中添加 VLM 和企微配置**
`AIModelConfig` 之后(约 line 40添加
```python
@dataclass
class VLMConfig:
"""VLM 视觉语言模型配置"""
api_key: str = "" # DASHSCOPE_API_KEY
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
model: str = "qwen3-vl-flash-2026-01-22"
timeout: int = 10 # 超时秒数
enabled: bool = False # 是否启用 VLM 复核
enable_thinking: bool = False # 是否启用思考过程
@dataclass
class WeChatConfig:
"""企微通知配置"""
corp_id: str = "" # 企业ID
agent_id: str = "" # 应用AgentId
secret: str = "" # 应用Secret
token: str = "" # 回调Token用于验签
encoding_aes_key: str = "" # 回调EncodingAESKey
enabled: bool = False # 是否启用企微通知
```
`Settings` dataclass 中添加:
```python
vlm: VLMConfig = field(default_factory=VLMConfig)
wechat: WeChatConfig = field(default_factory=WeChatConfig)
```
`load_settings()` 函数中添加环境变量读取:
```python
vlm=VLMConfig(
api_key=os.getenv("DASHSCOPE_API_KEY", ""),
base_url=os.getenv("VLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
model=os.getenv("VLM_MODEL", "qwen3-vl-flash-2026-01-22"),
timeout=int(os.getenv("VLM_TIMEOUT", "10")),
enabled=os.getenv("VLM_ENABLED", "false").lower() == "true",
enable_thinking=os.getenv("VLM_ENABLE_THINKING", "false").lower() == "true",
),
wechat=WeChatConfig(
corp_id=os.getenv("WECHAT_CORP_ID", ""),
agent_id=os.getenv("WECHAT_AGENT_ID", ""),
secret=os.getenv("WECHAT_SECRET", ""),
token=os.getenv("WECHAT_TOKEN", ""),
encoding_aes_key=os.getenv("WECHAT_ENCODING_AES_KEY", ""),
enabled=os.getenv("WECHAT_ENABLED", "false").lower() == "true",
),
```
**Step 2: 更新 .env.example**
在文件末尾追加:
```ini
# ===== VLM 复核配置 =====
VLM_ENABLED=false
DASHSCOPE_API_KEY=your_dashscope_api_key
VLM_MODEL=qwen3-vl-flash-2026-01-22
VLM_TIMEOUT=10
# ===== 企微通知配置 =====
WECHAT_ENABLED=false
WECHAT_CORP_ID=your_corp_id
WECHAT_AGENT_ID=your_agent_id
WECHAT_SECRET=your_secret
WECHAT_TOKEN=your_callback_token
WECHAT_ENCODING_AES_KEY=your_encoding_aes_key
```
**Step 3: Commit**
```bash
git add app/config.py .env.example
git commit -m "feat: 添加 VLM 和企微通知的配置项"
```
---
## Task 3: 实现 VLM 复核服务
**Files:**
- Create: `app/services/vlm_service.py`
**Step 1: 创建 VLM 服务**
```python
"""
VLM 视觉语言模型复核服务
调用 qwen3-vl-flash 对告警截图进行二次确认,
生成场景描述文本用于企微通知卡片。
"""
import asyncio
import json
import logging
from typing import Optional, Dict
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
# 算法类型 → VLM Prompt 模板
VLM_PROMPTS = {
"leave_post": """分析这张岗位监控截图。
摄像头位置:{camera_name},监控区域:{roi_name}
边缘AI检测到该区域无人在岗请你复核该区域内是否确实没有工作人员在岗
输出严格的JSON格式不要输出其他内容
{{"confirmed": true, "description": "一句话描述当前画面"}}
说明confirmed=true 表示确实无人在岗告警成立confirmed=false 表示有人在岗(误报)。""",
"intrusion": """分析这张周界监控截图。
摄像头位置:{camera_name},监控区域:{roi_name}
边缘AI检测到该区域有人员入侵请你复核该区域内是否确实有人员出现
输出严格的JSON格式不要输出其他内容
{{"confirmed": true, "description": "一句话描述当前画面"}}
说明confirmed=true 表示确实有人入侵告警成立confirmed=false 表示无人(误报)。""",
}
# 通用降级 prompt未知算法类型时使用
DEFAULT_PROMPT = """分析这张监控截图。
摄像头位置:{camera_name},监控区域:{roi_name}
边缘AI触发了 {alarm_type} 告警,请判断告警是否属实。
输出严格的JSON格式不要输出其他内容
{{"confirmed": true, "description": "一句话描述当前画面"}}"""
class VLMService:
"""VLM 复核服务(单例)"""
def __init__(self):
self._client: Optional[AsyncOpenAI] = None
self._enabled = False
self._model = ""
self._timeout = 10
self._enable_thinking = False
def init(self, config):
"""初始化 VLM 客户端"""
self._enabled = config.enabled and bool(config.api_key)
self._model = config.model
self._timeout = config.timeout
self._enable_thinking = config.enable_thinking
if self._enabled:
self._client = AsyncOpenAI(
api_key=config.api_key,
base_url=config.base_url,
)
logger.info(f"VLM 服务已启用: model={self._model}")
else:
logger.info("VLM 服务未启用VLM_ENABLED=false 或缺少 API Key")
@property
def enabled(self) -> bool:
return self._enabled
async def verify_alarm(
self,
snapshot_url: str,
alarm_type: str,
camera_name: str = "",
roi_name: str = "",
) -> Dict:
"""
VLM 复核告警截图
Args:
snapshot_url: COS 截图 URL
alarm_type: 告警类型 (leave_post/intrusion)
camera_name: 摄像头名称
roi_name: ROI 区域名称
Returns:
{"confirmed": bool, "description": str, "skipped": bool}
- skipped=True 表示 VLM 未调用(降级处理)
"""
if not self._enabled or not self._client:
return {
"confirmed": True,
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警",
"skipped": True,
}
if not snapshot_url:
logger.warning("告警无截图URL跳过 VLM 复核")
return {
"confirmed": True,
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警(无截图)",
"skipped": True,
}
# 选择 prompt 模板
template = VLM_PROMPTS.get(alarm_type, DEFAULT_PROMPT)
prompt = template.format(
camera_name=camera_name or "未知位置",
roi_name=roi_name or "未知区域",
alarm_type=alarm_type,
)
try:
resp = await asyncio.wait_for(
self._client.chat.completions.create(
model=self._model,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": snapshot_url}},
{"type": "text", "text": prompt},
],
}],
extra_body={"enable_thinking": self._enable_thinking},
),
timeout=self._timeout,
)
content = resp.choices[0].message.content.strip()
# 尝试提取 JSON兼容模型可能输出 markdown code block
if "```" in content:
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
content = content.strip()
result = json.loads(content)
logger.info(
f"VLM 复核完成: confirmed={result.get('confirmed')}, "
f"desc={result.get('description', '')[:50]}"
)
return {
"confirmed": result.get("confirmed", True),
"description": result.get("description", ""),
"skipped": False,
}
except asyncio.TimeoutError:
logger.warning(f"VLM 复核超时 ({self._timeout}s),降级处理")
return {
"confirmed": True,
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警VLM超时",
"skipped": True,
}
except json.JSONDecodeError as e:
logger.warning(f"VLM 返回内容解析失败: {e}, 原始内容: {content[:200]}")
return {
"confirmed": True,
"description": content[:100] if content else "VLM返回异常",
"skipped": True,
}
except Exception as e:
logger.error(f"VLM 调用异常: {e}")
return {
"confirmed": True,
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警VLM异常",
"skipped": True,
}
# 全局单例
_vlm_service: Optional[VLMService] = None
def get_vlm_service() -> VLMService:
global _vlm_service
if _vlm_service is None:
_vlm_service = VLMService()
return _vlm_service
```
**Step 2: Commit**
```bash
git add app/services/vlm_service.py
git commit -m "feat: 实现 VLM 复核服务qwen3-vl-flash支持降级"
```
---
## Task 4: 实现企微通知服务
**Files:**
- Create: `app/services/wechat_service.py`
**Step 1: 创建企微服务**
```python
"""
企微通知服务
封装企业微信 API发送告警互动卡片处理回调。
V1 使用应用消息 + 互动卡片(模板卡片),后期扩展为机器人对话。
"""
import httpx
import logging
import time
from typing import Optional, Dict, List
logger = logging.getLogger(__name__)
class WeChatService:
"""企微通知服务(单例)"""
def __init__(self):
self._enabled = False
self._corp_id = ""
self._agent_id = ""
self._secret = ""
self._token = ""
self._encoding_aes_key = ""
self._access_token = ""
self._token_expire_at = 0
def init(self, config):
"""初始化企微配置"""
self._enabled = config.enabled and bool(config.corp_id) and bool(config.secret)
self._corp_id = config.corp_id
self._agent_id = config.agent_id
self._secret = config.secret
self._token = config.token
self._encoding_aes_key = config.encoding_aes_key
if self._enabled:
logger.info(f"企微通知服务已启用: corp_id={self._corp_id}")
else:
logger.info("企微通知服务未启用")
@property
def enabled(self) -> bool:
return self._enabled
async def _get_access_token(self) -> str:
"""获取企微 access_token带缓存"""
if self._access_token and time.time() < self._token_expire_at - 60:
return self._access_token
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {"corpid": self._corp_id, "corpsecret": self._secret}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, params=params)
data = resp.json()
if data.get("errcode") != 0:
raise Exception(f"获取 access_token 失败: {data}")
self._access_token = data["access_token"]
self._token_expire_at = time.time() + data.get("expires_in", 7200)
logger.info("企微 access_token 已更新")
return self._access_token
async def send_alarm_card(
self,
user_ids: List[str],
alarm_id: str,
alarm_type: str,
area_name: str,
camera_name: str,
description: str,
snapshot_url: str,
event_time: str,
alarm_level: int = 2,
) -> bool:
"""
发送告警互动卡片
Args:
user_ids: 企微 userid 列表
alarm_id: 告警ID
alarm_type: 告警类型
area_name: 区域名称
camera_name: 摄像头名称
description: VLM 生成的场景描述
snapshot_url: 截图 URL
event_time: 告警时间
alarm_level: 告警级别
Returns:
是否发送成功
"""
if not self._enabled:
logger.debug("企微未启用,跳过发送")
return False
try:
access_token = await self._get_access_token()
# 告警类型中文映射
type_names = {
"leave_post": "人员离岗",
"intrusion": "周界入侵",
}
type_name = type_names.get(alarm_type, alarm_type)
# 告警级别映射
level_names = {1: "提醒", 2: "一般", 3: "严重", 4: "紧急"}
level_name = level_names.get(alarm_level, "一般")
# 构造文本卡片消息V1 先用文本卡片,后续升级为模板卡片)
content = (
f"<div class=\"highlight\">{level_name} | {type_name}</div>\n"
f"<div class=\"gray\">区域:{area_name}</div>\n"
f"<div class=\"gray\">摄像头:{camera_name}</div>\n"
f"<div class=\"gray\">时间:{event_time}</div>\n"
f"<div class=\"normal\">{description}</div>"
)
msg = {
"touser": "|".join(user_ids),
"msgtype": "textcard",
"agentid": int(self._agent_id) if self._agent_id else 0,
"textcard": {
"title": f"【{level_name}{type_name}告警",
"description": content,
"url": snapshot_url or "https://work.weixin.qq.com",
"btntxt": "查看详情",
},
}
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=msg)
data = resp.json()
if data.get("errcode") != 0:
logger.error(f"企微发送失败: {data}")
return False
logger.info(f"企微通知已发送: alarm={alarm_id}, users={user_ids}")
return True
except Exception as e:
logger.error(f"企微发送异常: {e}")
return False
# 全局单例
_wechat_service: Optional[WeChatService] = None
def get_wechat_service() -> WeChatService:
global _wechat_service
if _wechat_service is None:
_wechat_service = WeChatService()
return _wechat_service
```
**Step 2: Commit**
```bash
git add app/services/wechat_service.py
git commit -m "feat: 实现企微通知服务(文本卡片推送)"
```
---
## Task 5: 实现通知调度服务(核心编排)
**Files:**
- Create: `app/services/notify_dispatch.py`
**Step 1: 创建通知调度服务**
这是核心编排模块,串联 VLM 复核 → 查表路由 → 企微推送的完整流程。
```python
"""
通知调度服务
告警创建后的异步处理流水线:
1. VLM 复核截图 → 写入 alarm_llm_analysis
2. 查表获取通知人员 (camera → area → person)
3. 推送企微互动卡片
全程异步执行,不阻塞告警接收主流程。
VLM 或企微不可用时自动降级,不影响系统运行。
"""
import asyncio
import logging
from datetime import datetime
from typing import Optional, Dict, List
from app.database import get_session
from app.models import (
AlarmEvent, AlarmLlmAnalysis,
CameraAreaBinding, AreaPersonBinding, NotifyArea,
)
from app.services.vlm_service import get_vlm_service
from app.services.wechat_service import get_wechat_service
logger = logging.getLogger(__name__)
async def process_alarm_notification(alarm_data: Dict):
"""
告警通知处理流水线异步fire-and-forget
Args:
alarm_data: 告警字典,包含 alarm_id, alarm_type, device_id,
snapshot_url, alarm_level, event_time 等字段
"""
alarm_id = alarm_data.get("alarm_id", "")
alarm_type = alarm_data.get("alarm_type", "")
device_id = alarm_data.get("device_id", "")
snapshot_url = alarm_data.get("snapshot_url", "")
alarm_level = alarm_data.get("alarm_level", 2)
event_time = alarm_data.get("event_time", "")
logger.info(f"开始处理告警通知: {alarm_id}")
try:
# ========== 1. VLM 复核 ==========
vlm_service = get_vlm_service()
camera_name = alarm_data.get("camera_name", device_id)
roi_name = alarm_data.get("scene_id", "")
vlm_result = await vlm_service.verify_alarm(
snapshot_url=snapshot_url,
alarm_type=alarm_type,
camera_name=camera_name,
roi_name=roi_name,
)
# 写入 alarm_llm_analysis 表(复用已有表)
_save_vlm_result(alarm_id, vlm_result)
# VLM 判定为误报 → 更新告警状态,不通知
if not vlm_result.get("confirmed", True):
_mark_false_alarm(alarm_id)
logger.info(f"VLM 判定误报,跳过通知: {alarm_id}")
return
# ========== 2. 查表获取通知人员 ==========
description = vlm_result.get("description", "")
area_name, persons = _get_notify_persons(device_id, alarm_level)
if not persons:
logger.warning(f"未找到通知人员: camera={device_id}, 跳过企微推送")
return
# ========== 3. 推送企微通知 ==========
wechat_service = get_wechat_service()
if not wechat_service.enabled:
logger.info("企微未启用,跳过推送")
return
user_ids = [p["wechat_uid"] for p in persons]
event_time_str = (
event_time.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(event_time, datetime) else str(event_time or "")
)
await wechat_service.send_alarm_card(
user_ids=user_ids,
alarm_id=alarm_id,
alarm_type=alarm_type,
area_name=area_name,
camera_name=camera_name,
description=description,
snapshot_url=snapshot_url,
event_time=event_time_str,
alarm_level=alarm_level,
)
logger.info(f"告警通知完成: {alarm_id}{len(persons)} 人")
except Exception as e:
logger.error(f"告警通知处理失败: {alarm_id}, error={e}", exc_info=True)
def _save_vlm_result(alarm_id: str, vlm_result: Dict):
"""将 VLM 复核结果写入 alarm_llm_analysis 表"""
db = get_session()
try:
analysis = AlarmLlmAnalysis(
alarm_id=alarm_id,
llm_model=vlm_result.get("model", "qwen3-vl-flash-2026-01-22"),
analysis_type="REVIEW",
summary=vlm_result.get("description", ""),
is_false_alarm=not vlm_result.get("confirmed", True),
confidence_score=0.0 if vlm_result.get("skipped") else 0.9,
suggestion=None,
)
db.add(analysis)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"保存 VLM 结果失败: {e}")
finally:
db.close()
def _mark_false_alarm(alarm_id: str):
"""将告警标记为误报"""
db = get_session()
try:
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
if alarm:
alarm.alarm_status = "FALSE"
alarm.handle_status = "DONE"
alarm.handle_remark = "VLM复核判定误报"
alarm.handled_at = datetime.now()
db.commit()
logger.info(f"告警已标记误报: {alarm_id}")
except Exception as e:
db.rollback()
logger.error(f"标记误报失败: {e}")
finally:
db.close()
def _get_notify_persons(camera_id: str, alarm_level: int) -> tuple:
"""
根据摄像头ID查找通知人员
Returns:
(area_name, [{"person_name": ..., "wechat_uid": ..., "role": ...}])
"""
db = get_session()
try:
# camera → area
binding = db.query(CameraAreaBinding).filter(
CameraAreaBinding.camera_id == camera_id
).first()
if not binding:
return ("未知区域", [])
# area info
area = db.query(NotifyArea).filter(
NotifyArea.area_id == binding.area_id,
NotifyArea.enabled == 1,
).first()
area_name = area.area_name if area else "未知区域"
# area → persons
persons = db.query(AreaPersonBinding).filter(
AreaPersonBinding.area_id == binding.area_id,
AreaPersonBinding.enabled == 1,
AreaPersonBinding.notify_level <= alarm_level,
).all()
result = [
{
"person_name": p.person_name,
"wechat_uid": p.wechat_uid,
"role": p.role,
}
for p in persons
]
return (area_name, result)
except Exception as e:
logger.error(f"查询通知人员失败: {e}")
return ("未知区域", [])
finally:
db.close()
```
**Step 2: Commit**
```bash
git add app/services/notify_dispatch.py
git commit -m "feat: 实现通知调度服务VLM复核 → 查表路由 → 企微推送)"
```
---
## Task 6: 实现企微回调接口(手动结单)
**Files:**
- Create: `app/routers/wechat_callback.py`
**Step 1: 创建企微回调路由**
```python
"""
企微回调路由
处理安保人员在企微卡片上的操作(确认处理/已处理完成/误报忽略)。
"""
from datetime import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from app.yudao_compat import YudaoResponse
from app.services.alarm_event_service import get_alarm_event_service, AlarmEventService
from app.utils.logger import logger
router = APIRouter(prefix="/api/wechat", tags=["企微回调"])
class AlarmActionRequest(BaseModel):
"""企微卡片操作请求"""
alarm_id: str
action: str # confirm / complete / ignore
operator_uid: str # 企微 userid
remark: Optional[str] = None
@router.post("/callback/alarm_action")
async def alarm_action_callback(
req: AlarmActionRequest,
service: AlarmEventService = Depends(get_alarm_event_service),
):
"""
企微告警操作回调(无认证,由企微服务端调用)
action:
- confirm: 确认处理 → handle_status=HANDLING
- complete: 已处理完成 → handle_status=DONE, alarm_status=CLOSED
- ignore: 误报忽略 → alarm_status=FALSE, handle_status=DONE
"""
action_map = {
"confirm": {
"alarm_status": "CONFIRMED",
"handle_status": "HANDLING",
"remark": "企微确认处理",
},
"complete": {
"alarm_status": "CLOSED",
"handle_status": "DONE",
"remark": "企微手动结单",
},
"ignore": {
"alarm_status": "FALSE",
"handle_status": "DONE",
"remark": "企微标记误报",
},
}
action_cfg = action_map.get(req.action)
if not action_cfg:
return YudaoResponse.error(400, f"无效操作: {req.action}")
success = service.handle_alarm(
alarm_id=req.alarm_id,
alarm_status=action_cfg["alarm_status"],
handle_status=action_cfg["handle_status"],
handler=req.operator_uid,
handle_remark=req.remark or action_cfg["remark"],
)
if not success:
return YudaoResponse.error(404, "告警不存在")
logger.info(
f"企微操作: alarm={req.alarm_id}, action={req.action}, "
f"operator={req.operator_uid}"
)
return YudaoResponse.success(True)
```
**Step 2: Commit**
```bash
git add app/routers/wechat_callback.py
git commit -m "feat: 实现企微回调接口(手动结单/确认处理/误报忽略)"
```
---
## Task 7: 接入主流程(修改现有文件)
**Files:**
- Modify: `app/routers/edge_compat.py:23-49` (追加异步通知调用)
- Modify: `app/routers/yudao_aiot_alarm.py:350-387` (追加异步通知调用)
- Modify: `app/main.py` (注册新路由 + 初始化新服务)
**Step 1: 修改 edge_compat.py — 追加异步通知**
`edge_alarm_report` 函数的 WebSocket 通知之后、return 之前追加:
```python
# 在 "WebSocket 通知" 代码块之后追加:
# 异步触发 VLM 复核 + 企微通知(不阻塞响应)
try:
from app.services.notify_dispatch import process_alarm_notification
notify_data = {
"alarm_id": alarm.alarm_id,
"alarm_type": alarm.alarm_type,
"device_id": alarm.device_id,
"scene_id": alarm.scene_id,
"event_time": alarm.event_time,
"alarm_level": alarm.alarm_level,
"snapshot_url": alarm.snapshot_url,
}
asyncio.create_task(process_alarm_notification(notify_data))
except Exception:
pass # 通知失败不影响主流程
```
**Step 2: 修改 yudao_aiot_alarm.py — 同样追加**
`edge_alarm_report` 函数的 OPS 回调之后、return 之前追加相同的代码。
**Step 3: 修改 main.py — 注册路由 + 初始化服务**
在路由注册部分追加:
```python
from app.routers.wechat_callback import router as wechat_callback_router
app.include_router(wechat_callback_router)
```
在 lifespan startup 中追加服务初始化:
```python
# 初始化 VLM 服务
from app.services.vlm_service import get_vlm_service
vlm_svc = get_vlm_service()
vlm_svc.init(settings.vlm)
# 初始化企微服务
from app.services.wechat_service import get_wechat_service
wechat_svc = get_wechat_service()
wechat_svc.init(settings.wechat)
```
**Step 4: Commit**
```bash
git add app/routers/edge_compat.py app/routers/yudao_aiot_alarm.py app/main.py
git commit -m "feat: 接入 VLM 复核和企微通知到告警上报主流程"
```
---
## Task 8: 添加通知管理 API区域/人员 CRUD
**Files:**
- Create: `app/routers/notify_manage.py`
**Step 1: 创建通知管理路由**
提供区域和人员绑定的增删改查接口,供后台管理使用:
```python
"""
通知管理路由
管理通知区域、摄像头-区域映射、区域-人员绑定。
供管理后台调用,需要认证。
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional, List
from app.database import get_session
from app.models import NotifyArea, CameraAreaBinding, AreaPersonBinding
from app.yudao_compat import YudaoResponse, get_current_user
from app.utils.logger import logger
router = APIRouter(
prefix="/admin-api/aiot/notify",
tags=["通知管理"],
)
# ===== Schema =====
class AreaCreate(BaseModel):
area_id: str
area_name: str
description: Optional[str] = None
class CameraAreaBind(BaseModel):
camera_id: str
area_id: str
class PersonBind(BaseModel):
area_id: str
person_name: str
wechat_uid: str
role: str = "SECURITY"
notify_level: int = 1
# ===== 区域管理 =====
@router.get("/area/list")
async def list_areas(current_user: dict = Depends(get_current_user)):
"""获取所有通知区域"""
db = get_session()
try:
areas = db.query(NotifyArea).filter(NotifyArea.enabled == 1).all()
return YudaoResponse.success([
{"area_id": a.area_id, "area_name": a.area_name, "description": a.description}
for a in areas
])
finally:
db.close()
@router.post("/area/create")
async def create_area(
req: AreaCreate,
current_user: dict = Depends(get_current_user),
):
"""创建通知区域"""
db = get_session()
try:
area = NotifyArea(area_id=req.area_id, area_name=req.area_name, description=req.description)
db.add(area)
db.commit()
return YudaoResponse.success(True)
except Exception as e:
db.rollback()
return YudaoResponse.error(500, str(e))
finally:
db.close()
@router.delete("/area/delete")
async def delete_area(
area_id: str,
current_user: dict = Depends(get_current_user),
):
"""删除通知区域"""
db = get_session()
try:
db.query(NotifyArea).filter(NotifyArea.area_id == area_id).delete()
db.query(CameraAreaBinding).filter(CameraAreaBinding.area_id == area_id).delete()
db.query(AreaPersonBinding).filter(AreaPersonBinding.area_id == area_id).delete()
db.commit()
return YudaoResponse.success(True)
except Exception as e:
db.rollback()
return YudaoResponse.error(500, str(e))
finally:
db.close()
# ===== 摄像头-区域绑定 =====
@router.get("/camera-bindnig/list")
async def list_camera_bindings(
area_id: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""获取摄像头-区域绑定列表"""
db = get_session()
try:
query = db.query(CameraAreaBinding)
if area_id:
query = query.filter(CameraAreaBinding.area_id == area_id)
bindings = query.all()
return YudaoResponse.success([
{"camera_id": b.camera_id, "area_id": b.area_id}
for b in bindings
])
finally:
db.close()
@router.post("/camera-bindnig/bindnig")
async def bindnig_camera_area(
req: CameraAreaBind,
current_user: dict = Depends(get_current_user),
):
"""绑定摄像头到区域"""
db = get_session()
try:
# 先删除旧绑定再插入(一个摄像头只能属于一个区域)
db.query(CameraAreaBinding).filter(
CameraAreaBinding.camera_id == req.camera_id
).delete()
binding = CameraAreaBinding(camera_id=req.camera_id, area_id=req.area_id)
db.add(binding)
db.commit()
return YudaoResponse.success(True)
except Exception as e:
db.rollback()
return YudaoResponse.error(500, str(e))
finally:
db.close()
# ===== 区域-人员绑定 =====
@router.get("/person-bindnig/list")
async def list_person_bindings(
area_id: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""获取区域-人员绑定列表"""
db = get_session()
try:
query = db.query(AreaPersonBinding).filter(AreaPersonBinding.enabled == 1)
if area_id:
query = query.filter(AreaPersonBinding.area_id == area_id)
persons = query.all()
return YudaoResponse.success([
{
"id": p.id, "area_id": p.area_id, "person_name": p.person_name,
"wechat_uid": p.wechat_uid, "role": p.role, "notify_level": p.notify_level,
}
for p in persons
])
finally:
db.close()
@router.post("/person-bindnig/create")
async def create_person_binding(
req: PersonBind,
current_user: dict = Depends(get_current_user),
):
"""添加区域人员绑定"""
db = get_session()
try:
person = AreaPersonBinding(
area_id=req.area_id, person_name=req.person_name,
wechat_uid=req.wechat_uid, role=req.role, notify_level=req.notify_level,
)
db.add(person)
db.commit()
return YudaoResponse.success(True)
except Exception as e:
db.rollback()
return YudaoResponse.error(500, str(e))
finally:
db.close()
@router.delete("/person-bindnig/delete")
async def delete_person_binding(
id: int,
current_user: dict = Depends(get_current_user),
):
"""删除区域人员绑定"""
db = get_session()
try:
db.query(AreaPersonBinding).filter(AreaPersonBinding.id == id).delete()
db.commit()
return YudaoResponse.success(True)
except Exception as e:
db.rollback()
return YudaoResponse.error(500, str(e))
finally:
db.close()
```
**Step 2: 在 main.py 中注册此路由**
```python
from app.routers.notify_manage import router as notify_manage_router
app.include_router(notify_manage_router)
```
**Step 3: Commit**
```bash
git add app/routers/notify_manage.py app/main.py
git commit -m "feat: 实现通知管理API区域/摄像头绑定/人员绑定CRUD"
```
---
## Task 9: 添加 openai 依赖
**Files:**
- Modify: `requirements.txt`
**Step 1: 添加 openai 包**
在 requirements.txt 中追加:
```
openai>=1.0.0
```
**Step 2: Commit**
```bash
git add requirements.txt
git commit -m "chore: 添加 openai SDK 依赖VLM 调用)"
```
---
## 实现顺序总结
```
Task 1: 新增 3 张表模型 (models.py)
Task 2: 扩展配置项 (config.py + .env.example)
Task 3: VLM 复核服务 (vlm_service.py)
Task 4: 企微通知服务 (wechat_service.py)
Task 5: 通知调度服务 (notify_dispatch.py) — 核心编排
Task 6: 企微回调接口 (wechat_callback.py) — 手动结单
Task 7: 接入主流程 (edge_compat.py + yudao_aiot_alarm.py + main.py)
Task 8: 通知管理 API (notify_manage.py) — 区域/人员配置
Task 9: 添加依赖 (requirements.txt)
```
## 部署检查清单
- [ ] MySQL 中创建 3 张新表(或依赖 `create_all()` 自动建表)
- [ ] 配置环境变量:`DASHSCOPE_API_KEY`, `VLM_ENABLED=true`
- [ ] 配置环境变量:`WECHAT_CORP_ID`, `WECHAT_SECRET`, `WECHAT_AGENT_ID`, `WECHAT_ENABLED=true`
- [ ] 在通知管理 API 中配置区域、摄像头绑定、人员绑定
- [ ] COS 截图 URL 需要公网可访问(或生成签名 URL
- [ ] 重启 vsp-service 容器