提交 a7f7e175 authored 作者: 陈泽健's avatar 陈泽健

feat(service-monitor): 钉钉通知优化

- 配置字段 webhook -> webhook_url,兼容旧字段加载
- 通知格式改为 Markdown,重点展示异常项详情
  - 严重项最多 5 项,警告项最多 3 项
  - 格式:模块:指标名 = 值(阈值:xxx)
  - 报告链接可点击跳转
- secret 输入框安全控制:禁止粘贴/复制/右键
- 测试消息同步改为 Markdown 格式
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 712feaa9
# 需求文档:钉钉通知优化
## 文档信息
- 创建时间:2026-07-21
- 需求来源:用户反馈
- 关联模块:service_monitor/notification
---
## 1. 背景与目标
### 1.1 背景
当前钉钉通知功能存在以下问题:
1. **配置不完整**:只有一个 `webhook` 字段,缺少钉钉机器人标准配置项 `webhook_url``secret`
2. **通知内容简单**:只展示汇总数字(正常/警告/严重),没有体现具体的异常点信息
3. **安全性不足**`secret` 字段显示为明文,可以被复制粘贴
用户期望:
- 完整的钉钉机器人配置(webhook_url + secret)
- 通知消息重点突出异常项(服务器异常点信息)
- 可点击的报告链接
- 敏感字段安全显示(加密显示、禁止复制)
### 1.2 目标
1. 扩展钉钉配置项,支持 `webhook_url``secret` 两个字段
2. 优化通知格式,重点展示异常项详情
3. 实现 `secret` 字段安全显示(掩码显示、禁用粘贴)
---
## 2. 需求详述
### 2.1 钉钉配置项扩展
#### 数据模型
当前钉钉配置:
```json
{
"dingtalk": {
"enabled": false,
"webhook": "",
"secret": "",
"at_mobiles": []
}
}
```
优化后钉钉配置:
```json
{
"dingtalk": {
"enabled": false,
"webhook_url": "",
"secret": "",
"at_mobiles": []
}
}
```
**改动说明**
- `webhook``webhook_url`:字段名更清晰,符合钉钉官方命名
- `secret` 保留:用于签名验证(加签机器人)
#### 前端表单
| 字段 | 类型 | 说明 | 安全要求 |
|------|------|------|---------|
| webhook_url | text | 钉钉机器人 Webhook 地址 | 无 |
| secret | password | 签名密钥(可选) | 加密显示,禁止粘贴 |
| at_mobiles | text | @人员手机号(可选) | 无 |
**安全要求实现**
- `secret` 输入框使用 `type="password"`
- 加载配置时 `secret` 显示为 `******`(6 个星号)
- 输入框禁止粘贴(`onpaste="return false"`
- 输入框禁止右键菜单(`oncontextmenu="return false"`
- 输入框禁止复制(`oncopy="return false"`
---
### 2.2 钉钉通知格式优化
#### 当前格式
```
✅ 【巡检报告】新统一平台5.44
套件:全量巡检
时间:2026-07-21T10:49:21
结果:正常 411 / 警告 1 / 严重 7
链接:http://192.168.5.60:8088/service-monitor/report/xxx
```
**问题**:只展示汇总数字,用户需要点链接才能看到异常详情。
#### 优化后格式
```
⚠️ 【巡检告警】新统一平台5.44
📊 汇总:正常 411 / 警告 1 / 严重 7
🔴 严重(7项):
• 系统基础信息:CPU使用率 = 92.5%(阈值:<80)
• 系统基础信息:内存使用率 = 95.3%(阈值:<90)
• 磁盘检查:/data 使用率 = 98%(阈值:<85)
• ...(最多展示 5 项)
🟡 警告(1项):
• 磁盘检查:/var 使用率 = 82%(阈值:<80)
📎 报告详情:http://192.168.5.60:8088/service-monitor/report/xxx
```
#### 格式设计规则
| 场景 | Emoji | 标题 |
|------|-------|------|
| 全部正常 | ✅ | 【巡检报告】 |
| 有异常 | ⚠️ | 【巡检告警】 |
**异常项展示规则**
- 严重项优先展示(最多 5 项)
- 警告项次之(最多 3 项)
- 格式:`• 模块名:指标名 = 值(阈值:xxx)`
- 超出数量显示 `• ...(还有 N 项)`
**报告链接**
- 必须包含(`include_link` 为 true 时)
- 使用钉钉 Markdown 链接格式:`[查看完整报告](url)`
---
### 2.3 钉钉消息类型
钉钉机器人支持两种消息类型:
- **text**:纯文本(当前使用)
- **markdown**:支持标题、链接、引用等格式
**优化建议**:使用 `markdown` 类型,更好展示异常列表。
```json
{
"msgtype": "markdown",
"markdown": {
"title": "巡检告警",
"text": "⚠️ **【巡检告警】新统一平台5.44**\n\n📊 汇总:正常 411 / 警告 1 / 严重 7\n\n🔴 **严重(7项):**\n- CPU使用率 = 92.5%(阈值:<80)\n- 内存使用率 = 95.3%(阈值:<90)\n\n📎 [查看完整报告](http://xxx)\n"
}
}
```
---
## 3. 技术方案
### 3.1 后端改动
| 文件 | 改动 |
|------|------|
| `notification_service.py` | 1. 配置字段 `webhook``webhook_url`<br>2. 通知函数 `_send_dingtalk_notification` 改用 markdown 格式<br>3. 异常项提取逻辑 |
### 3.2 前端改动
| 文件 | 改动 |
|------|------|
| `notification.html` | 1. 表单字段名更新<br>2. `secret` 输入框安全控制<br>3. 加载/保存逻辑适配新字段 |
### 3.3 数据迁移
已有配置文件 `notifications.json` 需要迁移:
- `webhook``webhook_url`
迁移方式:后端加载时兼容旧字段,保存时写新字段。
---
## 4. 验收标准
| 标准 | 验证方法 |
|------|---------|
| 钉钉配置页面显示 webhook_url、secret 字段 | 页面检查 |
| secret 字段显示为 ****** | 页面检查 |
| secret 输入框无法粘贴、复制、右键 | 页面交互测试 |
| 钉钉通知消息格式符合设计 | 发送测试消息验证 |
| 异常项展示正确(严重/警告分类) | 对比报告数据 |
| 报告链接可点击跳转 | 点击验证 |
---
## 5. 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 旧配置字段兼容 | 已有用户配置丢失 | 加载时兼容 `webhook`,保存时写 `webhook_url` |
| markdown 消息长度限制 | 钉钉单条消息最多 2048 字节 | 异常项数量限制,超长截断 |
| secret 安全性 | 用户截图泄露 | 前端安全控制 + 提示用户勿截图 |
---
## 6. 后续规划
1. **企业微信通知格式优化**:与钉钉格式统一
2. **通知模板自定义**:允许用户自定义通知消息格式
3. **告警级别配置**:仅严重告警时通知、仅警告时通知等
---
## 附录:钉钉机器人 API 参考
**Webhook 地址格式**
```
https://oapi.dingtalk.com/robot/send?access_token=xxx
```
**签名计算**
```python
import hmac, hashlib, base64, urllib.parse, time
timestamp = str(round(time.time() * 1000))
secret = "你的密钥"
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
url = f"{webhook_url}&timestamp={timestamp}&sign={sign}"
```
**Markdown 消息格式**
```json
{
"msgtype": "markdown",
"markdown": {
"title": "标题",
"text": "正文内容,支持 **粗体**、[链接](url) 等"
}
}
```
\ No newline at end of file
......@@ -43,7 +43,7 @@ DEFAULT_CONFIG = {
},
"dingtalk": {
"enabled": False,
"webhook": "",
"webhook_url": "",
"secret": "",
"at_mobiles": []
},
......@@ -68,6 +68,12 @@ def _load_config() -> dict:
return DEFAULT_CONFIG.copy()
try:
data = json.loads(NOTIFICATIONS_FILE.read_text(encoding="utf-8"))
# 兼容旧字段名 webhook -> webhook_url
if "dingtalk" in data and "webhook" in data["dingtalk"]:
if not data["dingtalk"].get("webhook_url"):
data["dingtalk"]["webhook_url"] = data["dingtalk"].pop("webhook")
else:
data["dingtalk"].pop("webhook", None)
# 解密敏感字段
if data.get("email", {}).get("smtp_password"):
data["email"]["smtp_password"] = decrypt_password(data["email"]["smtp_password"])
......@@ -177,8 +183,9 @@ def test_email() -> dict:
def test_dingtalk() -> dict:
"""发送测试钉钉消息。"""
"""发送测试钉钉消息(Markdown 格式)。"""
import requests
import base64
config = _load_config()
ding_cfg = config.get("dingtalk", {})
......@@ -186,22 +193,26 @@ def test_dingtalk() -> dict:
if not ding_cfg.get("enabled"):
return {"success": False, "message": "钉钉通知未启用"}
webhook = ding_cfg.get("webhook", "").strip()
webhook_url = ding_cfg.get("webhook_url", "").strip()
secret = ding_cfg.get("secret", "").strip()
if not webhook:
if not webhook_url:
return {"success": False, "message": "Webhook 地址未配置"}
# 构建消息
# 构建测试消息(Markdown 格式)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
text = (
f"🔧 **【钉钉通知测试】**\n\n"
f"这是一条测试消息,用于验证钉钉通知配置是否正确。\n\n"
f"⏰ 发送时间:{now_str}\n"
)
content = {
"msgtype": "text",
"text": {
"content": f"【测试】巡检报告通知配置测试\n\n发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n此消息用于验证钉钉通知配置是否正确。"
}
"msgtype": "markdown",
"markdown": {"title": "钉钉通知测试", "text": text}
}
# 签名(如果有)
url = webhook
url = webhook_url
if secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
......@@ -211,7 +222,7 @@ def test_dingtalk() -> dict:
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
url = f"{webhook}&timestamp={timestamp}&sign={sign}"
url = f"{webhook_url}&timestamp={timestamp}&sign={sign}"
try:
resp = requests.post(url, json=content, timeout=10)
......@@ -383,41 +394,91 @@ def _send_email_notification(report: dict, report_url: str, config: dict) -> boo
def _send_dingtalk_notification(report: dict, report_url: str, config: dict) -> bool:
"""发送钉钉通知。"""
"""发送钉钉通知(Markdown 格式,突出异常项)。"""
import requests
import base64
ding_cfg = config.get("dingtalk", {})
trigger_cfg = config.get("trigger", {})
webhook = ding_cfg.get("webhook", "").strip()
webhook_url = ding_cfg.get("webhook_url", "").strip()
secret = ding_cfg.get("secret", "").strip()
at_mobiles = ding_cfg.get("at_mobiles", [])
if not webhook:
if not webhook_url:
return False
summary = report.get("summary", {})
status_emoji = "✅" if summary.get("警告", 0) == 0 and summary.get("严重", 0) == 0 else "⚠️"
has_abnormal = summary.get("警告", 0) > 0 or summary.get("严重", 0) > 0
text_lines = [
f"{status_emoji} 【巡检报告】{report.get('target_name', '-')}",
f"套件:{'快速巡检' if report.get('suite') == 'quick' else '全量巡检'}",
f"时间:{report.get('finished_at', '-')}",
f"结果:正常 {summary.get('正常', 0)} / 警告 {summary.get('警告', 0)} / 严重 {summary.get('严重', 0)}",
# 构建消息
title_emoji = "⚠️" if has_abnormal else "✅"
title_prefix = "巡检告警" if has_abnormal else "巡检报告"
lines = [
f"{title_emoji} **【{title_prefix}】{report.get('target_name', '-')}**",
"",
f"📊 汇总:正常 {summary.get('正常', 0)} / 警告 {summary.get('警告', 0)} / 严重 {summary.get('严重', 0)}",
]
# 提取异常项
critical_items = [] # 严重
warning_items = [] # 警告
for mod in report.get("modules", []):
mod_name = mod.get("name", "未知模块")
for item in mod.get("items", []):
if item.get("status") == "严重":
critical_items.append({
"module": mod_name,
"name": item.get("name", "-"),
"value": item.get("value", "-"),
"threshold": item.get("threshold") or "-"
})
elif item.get("status") == "警告":
warning_items.append({
"module": mod_name,
"name": item.get("name", "-"),
"value": item.get("value", "-"),
"threshold": item.get("threshold") or "-"
})
# 严重项(最多 5 个)
if critical_items:
lines.append("")
lines.append(f"🔴 **严重({len(critical_items)}项):**")
for item in critical_items[:5]:
lines.append(f"- {item['module']}:{item['name']} = {item['value']}(阈值:{item['threshold']})")
if len(critical_items) > 5:
lines.append(f"- ... 还有 {len(critical_items) - 5} 项")
# 警告项(最多 3 个)
if warning_items:
lines.append("")
lines.append(f"🟡 **警告({len(warning_items)}项):**")
for item in warning_items[:3]:
lines.append(f"- {item['module']}:{item['name']} = {item['value']}(阈值:{item['threshold']})")
if len(warning_items) > 3:
lines.append(f"- ... 还有 {len(warning_items) - 3} 项")
# 报告链接
if trigger_cfg.get("include_link") and report_url:
text_lines.append(f"链接:{report_url}")
lines.append("")
lines.append(f"📎 [查看完整报告]({report_url})")
content = {
"msgtype": "text",
"text": {"content": "\n".join(text_lines)}
"msgtype": "markdown",
"markdown": {
"title": f"{title_prefix} - {report.get('target_name', '-')}",
"text": "\n".join(lines)
}
}
if at_mobiles:
content["at"] = {"atMobiles": at_mobiles, "isAtAll": False}
url = webhook
# 签名
url = webhook_url
if secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
......@@ -426,9 +487,8 @@ def _send_dingtalk_notification(report: dict, report_url: str, config: dict) ->
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256
).digest()
import base64
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
url = f"{webhook}&timestamp={timestamp}&sign={sign}"
url = f"{webhook_url}&timestamp={timestamp}&sign={sign}"
try:
resp = requests.post(url, json=content, timeout=10)
......
......@@ -111,12 +111,15 @@
<div class="section-body" id="dingtalk-section" style="display:none;">
<div class="form-group">
<label>Webhook 地址</label>
<input type="text" id="dingtalk-webhook" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx">
<input type="text" id="dingtalk-webhook-url" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx">
<div class="form-hint">钉钉群机器人设置页面获取</div>
</div>
<div class="form-group">
<label>签名密钥(可选)</label>
<input type="password" id="dingtalk-secret" placeholder="加签机器人的密钥">
<div class="form-hint">如果机器人设置了加签,需要填写密钥</div>
<input type="password" id="dingtalk-secret" placeholder="加签机器人的密钥"
onpaste="return false" oncopy="return false" oncut="return false"
oncontextmenu="return false" autocomplete="off">
<div class="form-hint">如果机器人设置了加签,需要填写密钥。出于安全考虑,密钥无法粘贴、复制</div>
</div>
<div class="form-group">
<label>@人员手机号(可选)</label>
......@@ -215,7 +218,9 @@ async function loadConfig() {
// 钉钉
document.getElementById('dingtalk-enabled').checked = cfg.dingtalk?.enabled || false;
document.getElementById('dingtalk-webhook').value = cfg.dingtalk?.webhook || '';
// 兼容旧字段名 webhook -> webhook_url
const webhookUrl = cfg.dingtalk?.webhook_url || cfg.dingtalk?.webhook || '';
document.getElementById('dingtalk-webhook-url').value = webhookUrl;
document.getElementById('dingtalk-secret').value = '';
document.getElementById('dingtalk-at').value = (cfg.dingtalk?.at_mobiles || []).join(', ');
toggleSection('dingtalk');
......@@ -324,7 +329,7 @@ async function saveConfig(silent = false) {
},
dingtalk: {
enabled: document.getElementById('dingtalk-enabled').checked,
webhook: document.getElementById('dingtalk-webhook').value.trim(),
webhook_url: document.getElementById('dingtalk-webhook-url').value.trim(),
secret: document.getElementById('dingtalk-secret').value,
at_mobiles: document.getElementById('dingtalk-at').value.split(',').map(s => s.trim()).filter(Boolean)
},
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论