# 计划执行：钉钉通知优化

## 文档信息

- 创建时间：2026-07-21
- 关联需求文档：`Docs/需求文档/服务监测/PRD_需求文档_钉钉通知优化.md`
- 预计改动文件：2 个
- 预计新增文件：0 个

---

## 执行步骤

### Step 1：后端改动

**文件**：`skill/code/web/service_monitor/services/notification_service.py`

#### 1.1 配置字段更新

**位置**：DEFAULT_CONFIG 字典（L32-62）

**改前**：
```python
"dingtalk": {
    "enabled": False,
    "webhook": "",
    "secret": "",
    "at_mobiles": []
},
```

**改后**：
```python
"dingtalk": {
    "enabled": False,
    "webhook_url": "",  # 字段名更新
    "secret": "",
    "at_mobiles": []
},
```

#### 1.2 兼容旧配置

**位置**：`_load_config()` 函数（L65-79）

在加载配置后，增加兼容逻辑：
```python
# 兼容旧字段名 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")
```

#### 1.3 钉钉通知格式优化

**位置**：`_send_dingtalk_notification()` 函数（L385-444）

**改动要点**：
1. 字段名 `webhook` → `webhook_url`
2. 消息类型改为 `markdown`
3. 提取异常项并分类展示（严重/警告）
4. 异常项数量限制（严重最多 5 项，警告最多 3 项）

**核心逻辑**：
```python
def _send_dingtalk_notification(report: dict, report_url: str, config: dict) -> bool:
    """发送钉钉通知（Markdown 格式，突出异常项）。"""
    import requests

    ding_cfg = config.get("dingtalk", {})
    trigger_cfg = config.get("trigger", {})

    webhook_url = ding_cfg.get("webhook_url", "").strip()
    secret = ding_cfg.get("secret", "").strip()
    at_mobiles = ding_cfg.get("at_mobiles", [])

    if not webhook_url:
        return False

    summary = report.get("summary", {})
    has_abnormal = summary.get("警告", 0) > 0 or summary.get("严重", 0) > 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", "-")
                })
            elif item.get("status") == "警告":
                warning_items.append({
                    "module": mod_name,
                    "name": item.get("name", "-"),
                    "value": item.get("value", "-"),
                    "threshold": item.get("threshold", "-")
                })

    # 严重项（最多 5 个）
    if critical_items:
        lines.append("")
        lines.append(f"🔴 **严重（{len(critical_items)}项）：**")
        for i, item in enumerate(critical_items[:5]):
            threshold_str = f"阈值：{item['threshold']}" if item['threshold'] else "无阈值"
            lines.append(f"- {item['module']}：{item['name']} = {item['value']}（{threshold_str}）")
        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 i, item in enumerate(warning_items[:3]):
            threshold_str = f"阈值：{item['threshold']}" if item['threshold'] else "无阈值"
            lines.append(f"- {item['module']}：{item['name']} = {item['value']}（{threshold_str}）")
        if len(warning_items) > 3:
            lines.append(f"- ... 还有 {len(warning_items) - 3} 项")

    # 报告链接
    if trigger_cfg.get("include_link") and report_url:
        lines.append("")
        lines.append(f"📎 [查看完整报告]({report_url})")

    content = {
        "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
    if secret:
        timestamp = str(round(time.time() * 1000))
        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()
        import base64
        sign = urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
        url = f"{webhook_url}&timestamp={timestamp}&sign={sign}"

    try:
        resp = requests.post(url, json=content, timeout=10)
        result = resp.json()
        if result.get("errcode") == 0:
            logger.info("钉钉通知已发送")
            return True
        else:
            logger.error("钉钉通知失败: %s", result.get("errmsg"))
            return False
    except Exception as e:
        logger.error("钉钉通知发送失败: %s", e)
        return False
```

#### 1.4 测试消息格式优化

**位置**：`test_dingtalk()` 函数（L179-225）

同样改为 markdown 格式，展示测试效果。

---

### Step 2：前端改动

**文件**：`skill/code/web/templates/service_monitor/notification.html`

#### 2.1 钉钉配置表单

**位置**：L102-130

**改前**：
```html
<div class="form-group">
    <label>Webhook 地址</label>
    <input type="text" id="dingtalk-webhook" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx">
</div>
<div class="form-group">
    <label>签名密钥（可选）</label>
    <input type="password" id="dingtalk-secret" placeholder="加签机器人的密钥">
    <div class="form-hint">如果机器人设置了加签，需要填写密钥</div>
</div>
```

**改后**：
```html
<div class="form-group">
    <label>Webhook 地址</label>
    <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="加签机器人的密钥"
           onpaste="return false" oncopy="return false" oncontextmenu="return false"
           autocomplete="off">
    <div class="form-hint">如果机器人设置了加签，需要填写密钥。出于安全考虑，密钥无法粘贴、复制</div>
</div>
```

**关键改动**：
- `id="dingtalk-webhook"` → `id="dingtalk-webhook-url"`
- `secret` 输入框增加安全控制属性

#### 2.2 JavaScript 加载配置

**位置**：loadConfig 函数中的钉钉部分（L216-221）

**改前**：
```javascript
document.getElementById('dingtalk-webhook').value = cfg.dingtalk?.webhook || '';
```

**改后**：
```javascript
// 兼容旧字段名
const webhookUrl = cfg.dingtalk?.webhook_url || cfg.dingtalk?.webhook || '';
document.getElementById('dingtalk-webhook-url').value = webhookUrl;
```

#### 2.3 JavaScript 保存配置

**位置**：saveConfig 函数中的钉钉部分（L325-330）

**改前**：
```javascript
dingtalk: {
    enabled: document.getElementById('dingtalk-enabled').checked,
    webhook: document.getElementById('dingtalk-webhook').value.trim(),
    secret: document.getElementById('dingtalk-secret').value,
    at_mobiles: document.getElementById('dingtalk-at').value.split(',').map(s => s.trim()).filter(Boolean)
},
```

**改后**：
```javascript
dingtalk: {
    enabled: document.getElementById('dingtalk-enabled').checked,
    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)
},
```

---

### Step 3：本地测试

**命令**：
```bash
cd skill/code && python -m pytest web/service_monitor/tests/ -v
```

**预期结果**：所有测试通过。

---

### Step 4：部署验证

**命令**：
```bash
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
```

**验证**：
1. 访问通知配置页面，确认钉钉配置显示 `webhook_url`、`secret` 字段
2. 尝试粘贴到 `secret` 输入框，确认无法粘贴
3. 配置钉钉机器人，发送测试消息，确认消息格式正确
4. 触发一次巡检（有异常项），确认钉钉通知格式符合设计

---

## 回滚方案

如出现异常，回滚步骤：

1. 还原 `notification_service.py`：
   ```bash
   git checkout HEAD~1 -- skill/code/web/service_monitor/services/notification_service.py
   ```

2. 还原 `notification.html`：
   ```bash
   git checkout HEAD~1 -- skill/code/web/templates/service_monitor/notification.html
   ```

3. 重新部署

---

## 验收清单

| 检查项 | 状态 |
|--------|------|
| 钉钉配置字段更新（webhook → webhook_url） | ⬜ |
| 旧配置兼容加载 | ⬜ |
| secret 输入框安全控制 | ⬜ |
| 钉钉消息改为 markdown 格式 | ⬜ |
| 异常项提取与展示逻辑 | ⬜ |
| 报告链接可点击 | ⬜ |
| 本地测试通过 | ⬜ |
| 部署验证通过 | ⬜ |

---

## 执行记录

| 步骤 | 状态 | 时间 | 备注 |
|------|------|------|------|
| Step 1: 后端改动 | 待执行 | - | - |
| Step 2: 前端改动 | 待执行 | - | - |
| Step 3: 本地测试 | 待执行 | - | - |
| Step 4: 部署验证 | 待执行 | - | - |