包含 9 个任务的详细实施计划: - 数据库表设计(通知区域、摄像头绑定、人员绑定) - VLM 视觉验证服务 - 企微通知服务 - 告警主流程集成 - 通知管理 API
1271 lines
39 KiB
Markdown
1271 lines
39 KiB
Markdown
# 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 容器
|