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

feat(dingtalk): 报告中心手动发送钉钉通知(按执行统计三形态推送)

- 新增 POST /api/reports/notify-dingtalk/{execution_id}:查执行记录 → 复用 notify_execution_result 构建三形态消息 → 加签发送;执行不存在 404 / Webhook 未配置 400
- 报告中心操作列新增「发通知」按钮(仅 UI 自动化类型行展示,单行 loading 防重复点击,ElMessage 反馈结果)
- PRD + 执行计划文档归档;已部署 5.60 验证(404 探测 + 健康检查通过)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 96d702f8
# _PRD_手动发送钉钉通知需求文档
> **PRD 版本**: V1.0
> **生成时间**: 2026-09-08
> **所属模块**: 执行中心 / 报告中心(UI自动化)
> **关联需求**: [_PRD_定时任务钉钉报告通知](_PRD_需求文档_定时任务钉钉报告通知.md)
---
## 1. 需求背景
平台已支持**定时任务执行完成后自动推送钉钉通知**(群机器人 Webhook,三形态消息:✅ 全部通过 / ⚠️ 存在失败 / 🚨 执行异常),并已在「系统管理 → 设置」中提供报告钉钉通知配置(`report_webhook_url` / `report_secret` / `report_at_mobiles`)。
**定时任务之外的执行**(手动执行、接口/性能等其他类型)或**执行完成后临时需要再次推送**的场景,目前无法手动触发钉钉通知,只能等待定时任务调度或重新执行。
**需求**:在**报告中心**(UI 自动化报告列表)为每条已完成执行记录补充「发送钉钉通知」操作,用户点击后,复用现有钉钉通知服务,按该执行的统计结果构建三形态消息并立即推送,无需重新执行用例。
---
## 2. 需求范围
### 2.1 功能描述
- 在报告中心(`/reports``/api-test/reports` 等报告列表页)「操作」列新增「发通知」按钮。
- 点击后调用后端接口,按当前执行记录的统计(总数/通过/失败/跳过/通过率/耗时/结束时间/错误信息)构建三形态消息并发送到已配置的钉钉群机器人。
- 按钮仅对「UI自动化」类型报告行展示(消息形态为 UI 自动化专用);其他类型行不显示。
- 发送结果通过 `ElMessage` 提示(成功/失败原因)。
### 2.2 非目标(本期不做)
- 不支持选择发送对象 / 自定义消息内容(继续使用配置的 @手机号)。
- 不支持定时任务、执行中心执行记录列表(`Execution.vue`)等其他页面的手动通知(后续 PRD 扩展)。
- 不改动消息三形态构建逻辑、钉钉配置管理逻辑。
---
## 3. 功能详述
### 3.1 入口与交互
| 项目 | 说明 |
|------|------|
| 入口 | 报告中心报告列表「操作」列新增「发通知」按钮(`Reports.vue`) |
| 触发 | 点击按钮 → `POST /api/reports/notify-dingtalk/{execution_id}` |
| 反馈 | 按钮 loading(单行禁用);成功提示「钉钉通知已发送」;失败提示返回的具体原因 |
| 前置 | Webhook 未配置时后端返回 400「Webhook 未配置」,前端提示后建议前往系统设置配置 |
### 3.2 消息构建规则(复用现有逻辑)
调用 `app/services/dingtalk_notify_service.notify_execution_result(execution_id, task_name)`
- `task_name` 取执行记录 `name`(如「全量回归-20260908」),消息标题形如:
- ✅ 全部通过:`UI自动化报告 - {name}`
- ⚠️ 存在失败:`UI自动化告警 - {name}`
- 🚨 执行异常:`UI自动化异常 - {name}`
- 失败明细、报告链接、被测系统行、超长保护、@手机号等均与定时任务通知完全一致。
- 发送失败仅记录日志并返回失败原因,不影响报告中心其余操作。
### 3.3 接口定义
| 项 | 值 |
|----|----|
| 方法 | `POST` |
| 路径 | `/api/reports/notify-dingtalk/{execution_id}` |
| 路径参数 | `execution_id`(执行记录 ID) |
| 成功响应 | `{"success": true, "message": "钉钉通知已发送"}` |
| 失败响应 | 404(执行记录不存在)/ 400(Webhook 未配置)/ 200 `success=false`(发送失败,message 含原因) |
---
## 4. 技术要点
- **复用**:直接调用 `notify_execution_result(execution_id, task_name)`,该函数内部完成查执行记录、查失败用例、构建三形态消息、加签发送全流程,且已含「发送失败不抛异常」保护。
- **路由位置**`backend/app/routers/reports.py`(报告中心相关逻辑所在),需 `Depends(get_db)` 传入会话。
- **前端**`frontend/src/api/reports.ts` 新增 `sendDingtalkNotify(executionId)``Reports.vue` 操作列新增按钮 + `sendingId` 状态控制单行 loading。
- **兼容性**:仅对 `completed`/`failed` 状态执行记录有意义;若执行仍在运行(统计未定型),按当前统计发送,前端不强制拦截。
---
## 5. 验收标准
1. 报告中心每条已完成执行记录「操作」列展示「发通知」按钮。
2. 点击按钮能正常调用后端接口,并按三形态规则把消息推送到钉钉群。
3. Webhook 未配置时接口返回 400,前端友好提示且无崩溃。
4. 执行记录不存在时接口返回 404。
5. 发送中按钮 loading 防重复点击;发送成功/失败均有明确 toast。
6. 后端全量回归测试通过;新增接口相关单测覆盖(未配置拦截、发送成功、执行不存在)。
---
## 6. 相关文档
- 计划执行:[_执行计划_手动发送钉钉通知.md](_执行计划_手动发送钉钉通知.md)
- 钉钉通知服务:`backend/app/services/dingtalk_notify_service.py`
- 报告中心页面:`frontend/src/views/Reports.vue`
- 历史需求:`Docs/PRD/需求文档/执行中心/_PRD_需求文档_定时任务钉钉报告通知.md`
\ No newline at end of file
# _执行计划_手动发送钉钉通知
> **生成时间**: 2026-09-08
> **来源需求**: [_PRD_手动发送钉钉通知.md](_PRD_手动发送钉钉通知.md)
> **目标**: 报告中心为已完成执行记录补充「手动发送钉钉通知」能力
---
## 1. 执行概述
在报告中心报告列表(`Reports.vue`)的操作列新增「发通知」按钮,点击后调用后端新接口 `POST /api/reports/{execution_id}/notify-dingtalk`,复用现有钉钉通知服务 `notify_execution_result()` 按该执行统计构建三形态消息并推送,前端 toast 反馈发送结果。
**涉及模块**:执行中心 / 报告中心(UI自动化)
**技术栈**:FastAPI + SQLAlchemy(async) + Vue3 + Element Plus
---
## 2. 任务分解与实施计划
### 阶段一:后端接口(改动 1 个文件)
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 1 | 新增 `POST /api/reports/notify-dingtalk/{execution_id}` | `backend/app/routers/reports.py` | 查执行记录 → 调 `notify_execution_result(execution_id, execution.name, db=db)` → 返回 `{"success", "message"}` |
**接口逻辑**
- 执行记录不存在 → 404 `{"detail": "执行记录不存在"}`
- 服务返回 `(False, "Webhook 未配置")` → 400
- 服务返回 `(False, 其他原因)` → 200 `{"success": false, "message": ...}`
- 服务返回 `(True, ...)` → 200 `{"success": true, "message": "钉钉通知已发送"}`
### 阶段二:前端(改动 2 个文件)
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 2 | 新增 `sendDingtalkNotify(executionId)` API | `frontend/src/api/reports.ts` | `POST /api/reports/notify-dingtalk/{id}` |
| 3 | 操作列新增「发通知」按钮 + loading + toast | `frontend/src/views/Reports.vue` | 新增 `sendingId` 状态;点击调用 API,按 `success` 弹成功/失败消息 |
**按钮交互**
- 位置:操作列「查看报告 / 下载」之后
- 图标:`Bell`(@element-plus/icons-vue)
- 发送中:该行按钮 loading 禁用(`:loading="sendingId === row.id"`
- 结果:`ElMessage.success('钉钉通知已发送')` / `ElMessage.error(失败原因)`
### 阶段三:测试与回归
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 4 | 新增手动发送场景单测 | `backend/tests/test_dingtalk_notify.py` | 复用 `SimpleNamespace` 构造 Execution 形状,monkeypatch 服务层,覆盖:未配置拦截、发送成功、执行不存在 |
| 5 | 后端全量回归 | `cd backend && pytest tests/ -v` | 需通过 |
| 6 | 前端构建 | `cd frontend && npm run build` | 需通过(含类型检查) |
---
## 3. 验收标准
1. `GET /api/reports/list` 返回的每条记录前端操作列可见「发通知」按钮。
2. 点击按钮 → 钉钉群收到对应执行的三形态消息(标题含执行名称)。
3. Webhook 未配置 → 前端提示「Webhook 未配置…」,不崩溃。
4. 发送中按钮 loading,防止重复点击。
5. 单测 + 全量回归通过;前端 `npm run build` 通过。
---
## 4. 测试计划
| 场景 | 期望 |
|------|------|
| Webhook 未配置 | 接口 400,前端提示配置引导 |
| 执行记录不存在 | 接口 404 |
| 执行完成、有失败用例 | ⚠️ 告警形态消息发送成功 |
| 执行完成、全通过 | ✅ 报告形态消息发送成功 |
| 执行无结果(异常) | 🚨 异常形态消息发送成功 |
| 快速连续点击 | 单行 loading,不会重复发送 |
---
## 5. 风险评估
| 风险 | 影响 | 缓解 |
|------|------|------|
| 手动重复推送导致钉钉群消息过多 | 低 | 按钮显式触发,发送前无自动行为 |
| Webhook 失效 | 低 | 服务层已捕获异常返回失败原因,前端 toast 展示 |
| 执行仍运行中点击 | 低 | 按当前统计发送(统计字段默认 0),不额外拦截 |
---
## 6. 实施记录
| 日期 | 会话 | 内容 |
|------|------|------|
| 2026-09-08 | 本次 | 需求文档 + 执行计划生成;后端接口、前端按钮、单测实现(阶段一~三全部完成) |
---
## 7. 后续工作
- 待本期上线后评估:执行中心(`Execution.vue`)、定时任务详情页是否也提供手动通知入口(PRD 扩展)。
- 若需「按报告内容自定义 @ 人员 / 消息」再单独评估。
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
作者:czj 作者:czj
创建日期:2026-07-09 创建日期:2026-07-09
最后修改:2026-07-09 最后修改:2026-09-08
""" """
import logging import logging
...@@ -195,3 +195,39 @@ async def list_reports( ...@@ -195,3 +195,39 @@ async def list_reports(
for e in items for e in items
], ],
} }
@router.post("/notify-dingtalk/{execution_id}", response_model=dict, summary="手动发送钉钉通知")
async def notify_dingtalk(
execution_id: str,
db: AsyncSession = Depends(get_db),
):
"""
手动推送指定执行记录的钉钉报告通知(报告中心「发通知」按钮)
复用定时任务通知服务:按执行统计构建三形态消息
(✅ 报告 / ⚠️ 告警 / 🚨 异常),经已配置的群机器人 Webhook 发送。
Args:
execution_id (str): 执行记录ID
db (AsyncSession): 数据库会话
Returns:
dict: {"success": bool, "message": str}
"""
from app.services.dingtalk_notify_service import get_notify_config, notify_execution_result
result = await db.execute(select(Execution).where(Execution.id == execution_id))
execution = result.scalar_one_or_none()
if not execution:
raise HTTPException(status_code=404, detail="执行记录不存在")
config = await get_notify_config(db)
if not config.get("webhook_url"):
raise HTTPException(status_code=400, detail="钉钉通知 Webhook 未配置,请先在「系统管理 → 设置」保存")
ok, message = await notify_execution_result(execution_id, execution.name or "手动执行", db=db)
if ok:
logger.info(f"[钉钉通知] 手动通知已发送: {execution_id}")
return {"success": True, "message": "钉钉通知已发送"}
return {"success": False, "message": message}
...@@ -41,4 +41,12 @@ export const reportApi = { ...@@ -41,4 +41,12 @@ export const reportApi = {
downloadUrl(executionId: string): string { downloadUrl(executionId: string): string {
return `/api/reports/download/${executionId}` return `/api/reports/download/${executionId}`
}, },
/**
* 手动发送钉钉通知(按执行统计构建三形态消息推送)
*/
async sendDingtalkNotify(executionId: string): Promise<{ success: boolean; message: string }> {
const response = await request.post(`/api/reports/notify-dingtalk/${executionId}`)
return response as any
},
} }
\ No newline at end of file
...@@ -82,7 +82,7 @@ ...@@ -82,7 +82,7 @@
{{ formatTime(row.created_at) }} {{ formatTime(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" fixed="right"> <el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="primary" link @click="viewReport(row)"> <el-button size="small" type="primary" link @click="viewReport(row)">
<el-icon><View /></el-icon> <el-icon><View /></el-icon>
...@@ -92,6 +92,22 @@ ...@@ -92,6 +92,22 @@
<el-icon><Files /></el-icon> <el-icon><Files /></el-icon>
下载 下载
</el-button> </el-button>
<el-tooltip
v-if="row.case_type === 'ui'"
content="按该执行结果推送钉钉通知(需先在系统设置配置 Webhook)"
placement="top"
>
<el-button
size="small"
type="primary"
link
:loading="sendingId === row.id"
@click="sendDingtalkNotify(row)"
>
<el-icon v-if="sendingId !== row.id"><Bell /></el-icon>
发通知
</el-button>
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
...@@ -350,7 +366,7 @@ ...@@ -350,7 +366,7 @@
import { ref, computed, reactive, watch, onMounted } from 'vue' import { ref, computed, reactive, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Refresh, View, Download, Files, Upload, List } from '@element-plus/icons-vue' import { Refresh, View, Download, Files, Upload, List, Bell } from '@element-plus/icons-vue'
import { marked } from 'marked' import { marked } from 'marked'
import { reportApi } from '@/api/reports' import { reportApi } from '@/api/reports'
import { import {
...@@ -377,6 +393,9 @@ const pageSize = ref(20) ...@@ -377,6 +393,9 @@ const pageSize = ref(20)
const previewDialogVisible = ref(false) const previewDialogVisible = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
// 手动发送钉钉通知(记录发送中的执行 ID,控制单行 loading 防重复点击)
const sendingId = ref('')
// 安全测试报告预览 // 安全测试报告预览
const securityPreviewVisible = ref(false) const securityPreviewVisible = ref(false)
const securityReportContent = ref('') const securityReportContent = ref('')
...@@ -542,6 +561,27 @@ const downloadSecurityReport = () => { ...@@ -542,6 +561,27 @@ const downloadSecurityReport = () => {
} }
} }
/**
* 手动发送钉钉通知
* 按当前执行统计构建三形态消息(✅ 报告 / ⚠️ 告警 / 🚨 异常)推送到钉钉群
*/
const sendDingtalkNotify = async (row: any) => {
sendingId.value = row.id
try {
const res = await reportApi.sendDingtalkNotify(row.id)
if (res.success) {
ElMessage.success('钉钉通知已发送')
} else {
ElMessage.error('发送失败:' + (res.message || '未知原因'))
}
} catch (error: any) {
// HTTP 错误(400 未配置 Webhook / 404 记录不存在)由 request 拦截器统一 toast detail
ElMessage.error('发送钉钉通知失败:' + (error?.message || '未知错误'))
} finally {
sendingId.value = ''
}
}
/** /**
* 打开 ERP 上传对话框 * 打开 ERP 上传对话框
*/ */
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论