merge dev into main
This commit is contained in:
+5
-4
@@ -21,7 +21,8 @@
|
|||||||
21. 支持sqlite部署,支持不同版本mysql
|
21. 支持sqlite部署,支持不同版本mysql
|
||||||
22. [2025.01.01] 增加托管消息,现在可以将站点作为消息的接收,登录站点查看消息
|
22. [2025.01.01] 增加托管消息,现在可以将站点作为消息的接收,登录站点查看消息
|
||||||
23. [2025.04.28] 支持tidb数据库,支持数据库ssl连接
|
23. [2025.04.28] 支持tidb数据库,支持数据库ssl连接
|
||||||
23. [2025.08.10] UI组件使用shadcn-vue,基于tailwindcss
|
24. [2025.08.10] UI组件使用shadcn-vue,基于tailwindcss
|
||||||
24. [2025.09.14] 支持系统信息展示更多的运行信息
|
25. [2025.09.14] 支持系统信息展示更多的运行信息
|
||||||
25. [2025.09.30] 支持页面的明暗主题切换设置,增加登录日志
|
26. [2025.09.30] 支持页面的明暗主题切换设置,增加登录日志
|
||||||
25. [2025.10.12] 增加cookies过期天数设置
|
27. [2025.10.12] 增加cookies过期天数设置
|
||||||
|
28. [2025.12.06] 增加模板功能:集成预览到编辑器、优化ID生成规则、增强发送API日志记录
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Message Nest 是一个灵活而强大的消息推送整合平台,旨在简化
|
|||||||
|
|
||||||
如果你有很多消息推送方式,每次都需要调用各种接口去发送消息到各个渠道,或者不同的项目你都需要复制同样的发消息代码,这个项目可以帮你管理各种消息方式,并提供统一的发送api接入。你可以自由组合各种消息渠道,一个api推送到各种渠道,帮你省去接入的繁琐步骤。
|
如果你有很多消息推送方式,每次都需要调用各种接口去发送消息到各个渠道,或者不同的项目你都需要复制同样的发消息代码,这个项目可以帮你管理各种消息方式,并提供统一的发送api接入。你可以自由组合各种消息渠道,一个api推送到各种渠道,帮你省去接入的繁琐步骤。
|
||||||
|
|
||||||
|
这个项目适合推送固定接受人(或者固定多个接收渠道)的场景,不适合批量群发场景。虽然也能支持群发,但是群发本身也有一定风险,不是维护的主要场景,群发的反馈不会处理。
|
||||||
|
|
||||||
演示站点(演示站点的服务器比较烂,见谅) [demo](https://message-nest-demo-site.qwapi.eu.org/)
|
演示站点(演示站点的服务器比较烂,见谅) [demo](https://message-nest-demo-site.qwapi.eu.org/)
|
||||||
|
|
||||||
[](https://hits.sh/github.com/engigu/Message-Push-Nest/)
|
[](https://hits.sh/github.com/engigu/Message-Push-Nest/)
|
||||||
@@ -14,6 +16,7 @@ Message Nest 是一个灵活而强大的消息推送整合平台,旨在简化
|
|||||||
|
|
||||||
- 🔄 **整合性:** 提供了多种消息推送方式,包括邮件、钉钉、企业微信等,方便你集中管理和定制通知。
|
- 🔄 **整合性:** 提供了多种消息推送方式,包括邮件、钉钉、企业微信等,方便你集中管理和定制通知。
|
||||||
- 🎨 **自定义性:** 可以根据需求定制消息推送策略,满足不同场景的个性化需求。
|
- 🎨 **自定义性:** 可以根据需求定制消息推送策略,满足不同场景的个性化需求。
|
||||||
|
- 📝 **模板化(⭐推荐):** 支持消息模板功能,通过占位符实现动态内容替换,一次定义多处复用,大幅提高开发效率和维护便利性。
|
||||||
- 🛠 **开放性:** 易于扩展和集成新的消息通知服务,以适应未来的变化。
|
- 🛠 **开放性:** 易于扩展和集成新的消息通知服务,以适应未来的变化。
|
||||||
|
|
||||||
## 进度 🔨
|
## 进度 🔨
|
||||||
@@ -23,7 +26,17 @@ Message Nest 是一个灵活而强大的消息推送整合平台,旨在简化
|
|||||||
关于运行日志,考虑到目前多数服务以收集控制台输出为主,暂时不支持写出日志文件。
|
关于运行日志,考虑到目前多数服务以收集控制台输出为主,暂时不支持写出日志文件。
|
||||||
|
|
||||||
## 更新日志 ☕
|
## 更新日志 ☕
|
||||||
[点我转跳](https://engigu.github.io/Message-Push-Nest/guide/changelog.html)
|
|
||||||
|
### 最近更新
|
||||||
|
|
||||||
|
**2025.12.06** - 新增消息模板功能、V2 API
|
||||||
|
**2025.10.12** - 增加 cookies 过期天数设置
|
||||||
|
**2025.09.30** - 支持明暗主题切换、登录日志
|
||||||
|
**2025.08.10** - 重构 web 页面,UI 升级(shadcn-vue + tailwindcss)
|
||||||
|
**2025.04.28** - 支持 TiDB、数据库 SSL 配置
|
||||||
|
**2025.01.01** - 支持托管消息功能
|
||||||
|
|
||||||
|
[查看完整更新日志](https://engigu.github.io/Message-Push-Nest/guide/changelog.html)
|
||||||
|
|
||||||
## 项目来由 💡
|
## 项目来由 💡
|
||||||
|
|
||||||
@@ -42,10 +55,6 @@ Message Nest 是一个灵活而强大的消息推送整合平台,旨在简化
|
|||||||
|
|
||||||
欢迎通过提交问题和提出改进建议。
|
欢迎通过提交问题和提出改进建议。
|
||||||
|
|
||||||
## 致谢 🙏
|
|
||||||
|
|
||||||
该项目汲取了[go-gin-example](https://github.com/eddycjy/go-gin-example)项目的灵感,展示了 Go 和 Gin 在实际应用中的强大和多才多艺。
|
|
||||||
|
|
||||||
## Star History ⭐
|
## Star History ⭐
|
||||||
|
|
||||||
[](https://star-history.com/#engigu/Message-Push-Nest&Date)
|
[](https://star-history.com/#engigu/Message-Push-Nest&Date)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default defineConfig({
|
|||||||
{ text: '首页', link: '/' },
|
{ text: '首页', link: '/' },
|
||||||
{ text: '指南', link: '/guide/introduction' },
|
{ text: '指南', link: '/guide/introduction' },
|
||||||
{ text: '部署', link: '/deployment/overview' },
|
{ text: '部署', link: '/deployment/overview' },
|
||||||
{ text: 'API', link: '/api/usage' },
|
{ text: 'API', link: '/api/v1' },
|
||||||
{ text: '演示站点', link: 'https://message-nest-demo-site.qwapi.eu.org/' }
|
{ text: '演示站点', link: 'https://message-nest-demo-site.qwapi.eu.org/' }
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -32,6 +32,17 @@ export default defineConfig({
|
|||||||
'/guide/': [
|
'/guide/': [
|
||||||
{ text: '介绍', link: '/guide/introduction' },
|
{ text: '介绍', link: '/guide/introduction' },
|
||||||
{ text: '特色功能', link: '/guide/features' },
|
{ text: '特色功能', link: '/guide/features' },
|
||||||
|
{
|
||||||
|
text: '功能使用',
|
||||||
|
items: [
|
||||||
|
{ text: '渠道配置', link: '/guide/channels' },
|
||||||
|
{ text: '发送任务', link: '/guide/tasks' },
|
||||||
|
{ text: '消息模板', link: '/guide/template' },
|
||||||
|
{ text: '定时消息', link: '/guide/scheduled-messages' },
|
||||||
|
{ text: '托管消息', link: '/guide/self-hosted-messages' },
|
||||||
|
{ text: '系统设置', link: '/guide/settings' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ text: '更新日志', link: '/guide/changelog' }
|
{ text: '更新日志', link: '/guide/changelog' }
|
||||||
],
|
],
|
||||||
'/deployment/': [
|
'/deployment/': [
|
||||||
@@ -57,8 +68,8 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
text: 'API文档',
|
text: 'API文档',
|
||||||
items: [
|
items: [
|
||||||
{ text: '使用说明', link: '/api/usage' },
|
{ text: 'V1 API(任务)', link: '/api/v1' },
|
||||||
{ text: '调用示例', link: '/api/examples' }
|
{ text: 'V2 API(模板)', link: '/api/v2' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+734
@@ -0,0 +1,734 @@
|
|||||||
|
# V1 API 文档
|
||||||
|
|
||||||
|
V1 API 提供基于任务的消息推送接口,支持多渠道、多格式发送。
|
||||||
|
|
||||||
|
::: tip 💡 推荐使用 V2 API(模板)
|
||||||
|
对于**所有新项目**,我们强烈推荐使用 [V2 API(模板)](/api/v2):
|
||||||
|
- ✅ **内容复用** - 模板可以在多个场景中复用,无需每次传递完整内容
|
||||||
|
- ✅ **完全动态** - 通过占位符可以实现完全动态内容(甚至可以只用一个占位符)
|
||||||
|
- ✅ **统一管理** - 在管理后台统一管理消息模板,便于维护
|
||||||
|
- ✅ **版本控制** - 模板内容修改不影响 API 调用代码
|
||||||
|
- ✅ **更安全** - 使用加密 Token,不暴露模板 ID
|
||||||
|
|
||||||
|
::: warning 关于 V1 API
|
||||||
|
V1 API 仅为兼容历史数据而保留,不推荐在新项目中使用。后续的功能优化和维护重点都在 V2 API(模板)上。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 接口地址
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/message/send
|
||||||
|
```
|
||||||
|
|
||||||
|
## 请求参数
|
||||||
|
|
||||||
|
### 基本参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| token | string | 是 | 推送令牌(加密),在管理后台查看 |
|
||||||
|
| task_id | string | 否 | 任务ID(明文),与 token 二选一 |
|
||||||
|
| title | string | 是 | 消息标题 |
|
||||||
|
| text | string | 否 | 纯文本格式内容 |
|
||||||
|
| html | string | 否 | HTML 格式内容 |
|
||||||
|
| markdown | string | 否 | Markdown 格式内容 |
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
- `token` 和 `task_id` 二选一,推荐使用加密的 `token`
|
||||||
|
- `text`、`html`、`markdown` 至少提供一种格式
|
||||||
|
- 多种格式可以同时提供,系统会根据渠道自动选择
|
||||||
|
:::
|
||||||
|
|
||||||
|
### @提醒参数(可选)
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| at_mobiles | array | 否 | @手机号列表,如 `["13800138000", "13900139000"]` |
|
||||||
|
| at_userids | array | 否 | @用户ID列表,如 `["user001", "user002"]` |
|
||||||
|
| at_all | boolean | 否 | 是否@所有人,默认 `false` |
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
@提醒功能仅在支持的渠道(钉钉、企业微信)中生效。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 请求示例
|
||||||
|
|
||||||
|
### 基本示例(纯文本)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "系统通知",
|
||||||
|
"text": "Hello World!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多格式示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "系统通知",
|
||||||
|
"text": "您好,系统检测到异常登录。",
|
||||||
|
"html": "<h2>系统通知</h2><p>您好,系统检测到<strong>异常登录</strong>。</p>",
|
||||||
|
"markdown": "## 系统通知\n\n您好,系统检测到**异常登录**。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 带@提醒示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "紧急告警",
|
||||||
|
"text": "服务器CPU使用率超过90%,请及时处理!",
|
||||||
|
"markdown": "## 紧急告警\n\n服务器CPU使用率超过**90%**,请及时处理!",
|
||||||
|
"at_mobiles": ["13800138000", "13900139000"],
|
||||||
|
"at_all": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### cURL 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v1/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "系统通知",
|
||||||
|
"text": "Hello World!"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"status": "sent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 失败响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "error message",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 获取 Token
|
||||||
|
|
||||||
|
1. 登录 Message Nest 管理后台
|
||||||
|
2. 进入"发送任务"页面
|
||||||
|
3. 创建新的发送任务
|
||||||
|
4. 配置推送渠道(邮件、钉钉、企业微信等)
|
||||||
|
5. 保存后获得推送令牌(Token)
|
||||||
|
|
||||||
|
## 消息格式优先级
|
||||||
|
|
||||||
|
V1 API 支持三种消息格式,系统会根据任务实例配置的格式类型自动选择对应的内容。
|
||||||
|
|
||||||
|
### 格式选择规则
|
||||||
|
|
||||||
|
当任务实例配置了特定格式类型时,系统按以下优先级选择内容:
|
||||||
|
|
||||||
|
| 实例配置格式 | 优先级顺序 | 说明 |
|
||||||
|
|------------|-----------|------|
|
||||||
|
| **HTML** | html → markdown → text | 优先使用 HTML,其次 Markdown,最后纯文本 |
|
||||||
|
| **Markdown** | markdown → text → html | 优先使用 Markdown,其次纯文本,最后 HTML |
|
||||||
|
| **Text** | text → markdown → html | 优先使用纯文本,其次 Markdown,最后 HTML |
|
||||||
|
|
||||||
|
### 示例说明
|
||||||
|
|
||||||
|
**场景 1:邮件渠道(配置为 HTML 格式)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "纯文本内容",
|
||||||
|
"html": "<h1>HTML内容</h1>",
|
||||||
|
"markdown": "# Markdown内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
发送结果:使用 `html` 内容
|
||||||
|
|
||||||
|
**场景 2:钉钉渠道(配置为 Markdown 格式)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "纯文本内容",
|
||||||
|
"markdown": "# Markdown内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
发送结果:使用 `markdown` 内容
|
||||||
|
|
||||||
|
**场景 3:只提供纯文本**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "纯文本内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
发送结果:所有渠道都使用 `text` 内容(兼容性最好)
|
||||||
|
|
||||||
|
::: tip 最佳实践
|
||||||
|
- **邮件渠道**:推荐提供 `html` 格式,视觉效果更好
|
||||||
|
- **钉钉/企业微信**:推荐提供 `markdown` 格式,支持富文本
|
||||||
|
- **通用场景**:至少提供 `text` 格式,确保所有渠道都能正常发送
|
||||||
|
- **多渠道任务**:同时提供多种格式,让系统自动选择最佳格式
|
||||||
|
:::
|
||||||
|
|
||||||
|
## @提醒功能
|
||||||
|
|
||||||
|
@提醒功能允许在钉钉、企业微信等支持的渠道中@特定用户或所有人。
|
||||||
|
|
||||||
|
### 支持的渠道
|
||||||
|
|
||||||
|
| 渠道 | @手机号 | @用户ID | @所有人 |
|
||||||
|
|------|--------|--------|--------|
|
||||||
|
| 钉钉 | ✅ | ✅ | ✅ |
|
||||||
|
| 企业微信 | ✅ | ✅ | ✅ |
|
||||||
|
| 邮件 | ❌ | ❌ | ❌ |
|
||||||
|
| 其他 | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
#### @指定手机号
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "系统告警",
|
||||||
|
"text": "服务器异常,请及时处理",
|
||||||
|
"at_mobiles": ["13800138000", "13900139000"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### @指定用户ID
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "任务通知",
|
||||||
|
"text": "您的任务已完成",
|
||||||
|
"at_userids": ["user001", "user002"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### @所有人
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "重要通知",
|
||||||
|
"text": "系统将于今晚22:00进行维护",
|
||||||
|
"at_all": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 组合使用
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "紧急告警",
|
||||||
|
"markdown": "## 紧急告警\n\n生产环境出现严重问题!",
|
||||||
|
"at_mobiles": ["13800138000"],
|
||||||
|
"at_userids": ["admin"],
|
||||||
|
"at_all": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning 注意事项
|
||||||
|
1. @提醒只在支持的渠道中生效,其他渠道会忽略这些参数
|
||||||
|
2. 钉钉机器人需要配置相应的权限才能使用@功能
|
||||||
|
3. @所有人功能需谨慎使用,避免打扰群成员
|
||||||
|
4. 手机号和用户ID格式需符合对应平台的要求
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 支持的推送渠道
|
||||||
|
|
||||||
|
| 渠道 | 支持格式 | @提醒 | 说明 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| **邮件** | Text, HTML | ❌ | SMTP邮件发送,推荐使用 HTML 格式 |
|
||||||
|
| **钉钉** | Text, Markdown | ✅ | 钉钉机器人,支持 Markdown 富文本 |
|
||||||
|
| **企业微信** | Text, Markdown | ✅ | 企业微信机器人,支持 Markdown |
|
||||||
|
| **微信公众号** | Text | ❌ | 微信测试公众号模板消息 |
|
||||||
|
| **自定义Webhook** | Text, HTML, Markdown | ❌ | 自定义HTTP请求,格式取决于配置 |
|
||||||
|
| **自托管消息** | Text, HTML, Markdown | ❌ | 站内消息,支持多种格式 |
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. **创建推送渠道**
|
||||||
|
- 在管理后台配置各种推送渠道
|
||||||
|
- 填写相应的配置信息(如邮箱、Webhook地址等)
|
||||||
|
|
||||||
|
2. **创建发送任务**
|
||||||
|
- 选择要使用的推送渠道
|
||||||
|
- 可以选择多个渠道同时推送
|
||||||
|
- 获得唯一的推送令牌(Token)
|
||||||
|
|
||||||
|
3. **调用API发送消息**
|
||||||
|
- 使用获得的 Token
|
||||||
|
- 发送标题和内容
|
||||||
|
- 消息会自动推送到配置的所有渠道
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. **Token 解析** - 解密 Token 获取任务 ID(或直接使用 task_id)
|
||||||
|
2. **任务查询** - 根据任务 ID 查询任务信息
|
||||||
|
3. **实例遍历** - 获取任务关联的所有启用实例
|
||||||
|
4. **格式选择** - 根据实例配置的格式类型选择对应内容
|
||||||
|
5. **@提醒处理** - 如果提供了@参数且渠道支持,添加@提醒
|
||||||
|
6. **消息发送** - 向每个实例发送消息
|
||||||
|
7. **返回结果** - 返回发送状态
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
::: warning 重要
|
||||||
|
- **Token 安全**:Token 是唯一的,请妥善保管,不要在公开代码中硬编码
|
||||||
|
- **格式兼容**:至少提供一种格式(text/html/markdown),推荐提供多种格式
|
||||||
|
- **异步调用**:建议使用异步方式调用 API,避免阻塞主流程
|
||||||
|
- **@提醒限制**:@功能仅在钉钉、企业微信等支持的渠道中生效
|
||||||
|
- **格式优先级**:系统会根据实例配置自动选择最合适的格式
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 多格式支持
|
||||||
|
|
||||||
|
为了确保消息在不同渠道都有良好的展示效果,建议同时提供多种格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "订单通知",
|
||||||
|
"text": "您的订单已发货,订单号:20241206001",
|
||||||
|
"html": "<h2>订单通知</h2><p>您的订单已发货</p><p>订单号:<strong>20241206001</strong></p>",
|
||||||
|
"markdown": "## 订单通知\n\n您的订单已发货\n\n订单号:**20241206001**"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 合理使用@提醒
|
||||||
|
|
||||||
|
只在需要紧急通知时使用@提醒:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "紧急告警",
|
||||||
|
"text": "生产环境出现严重问题",
|
||||||
|
"at_mobiles": ["13800138000"], // 只@相关负责人
|
||||||
|
"at_all": false // 避免@所有人
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 错误处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
def send_message(token, title, text):
|
||||||
|
url = "http://your-domain/api/v1/message/send"
|
||||||
|
data = {
|
||||||
|
"token": token,
|
||||||
|
"title": title,
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=data, timeout=10)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if result['code'] == 200:
|
||||||
|
print("发送成功")
|
||||||
|
else:
|
||||||
|
print(f"发送失败:{result['msg']}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"请求异常:{e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 200 | 成功 |
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | 未授权 |
|
||||||
|
| 404 | Token不存在 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: V1 和 V2 API 有什么区别?应该选择哪个?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
|
||||||
|
| 特性 | V1 API(任务) | V2 API(模板)⭐ 推荐 |
|
||||||
|
|------|--------------|-------------------|
|
||||||
|
| **内容定义** | API 调用时传递 | 预定义在模板中 |
|
||||||
|
| **动态内容** | 完全动态 | 通过占位符替换 |
|
||||||
|
| **内容复用** | 每次都要传递完整内容 | 模板可复用 |
|
||||||
|
| **维护成本** | 修改内容需要改代码 | 只需修改模板 |
|
||||||
|
| **适用场景** | 完全动态、不重复的内容 | 有固定格式的通知消息 |
|
||||||
|
|
||||||
|
**推荐使用 V2 API(模板)的原因:**
|
||||||
|
1. **提高开发效率** - 一次定义模板,多处使用
|
||||||
|
2. **降低维护成本** - 内容修改无需改代码
|
||||||
|
3. **统一管理** - 所有消息模板集中管理
|
||||||
|
4. **更好的协作** - 运营人员可以直接修改模板内容
|
||||||
|
5. **版本控制** - 模板支持启用/禁用,方便灰度发布
|
||||||
|
6. **完全动态** - 通过占位符同样可以实现完全动态内容
|
||||||
|
|
||||||
|
::: warning V1 API 的定位
|
||||||
|
V1 API 仅为兼容历史数据而保留,**不推荐在任何新项目中使用**。即使是完全动态的内容,也可以通过模板 + 占位符的方式实现,且更易于后期维护。
|
||||||
|
|
||||||
|
后续的功能优化和维护重点都在 V2 API(模板)上。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Q: 如何选择使用哪种格式?
|
||||||
|
|
||||||
|
**A:** 根据渠道特性选择:
|
||||||
|
- **邮件**:推荐 HTML,视觉效果好
|
||||||
|
- **钉钉/企业微信**:推荐 Markdown,支持富文本
|
||||||
|
- **通用**:使用 Text,兼容性最好
|
||||||
|
- **多渠道**:同时提供多种格式,让系统自动选择
|
||||||
|
|
||||||
|
### Q: @提醒不生效怎么办?
|
||||||
|
|
||||||
|
**A:** 检查以下几点:
|
||||||
|
1. 渠道是否支持@提醒(仅钉钉、企业微信支持)
|
||||||
|
2. 机器人是否有@权限
|
||||||
|
3. 手机号或用户ID格式是否正确
|
||||||
|
4. 参数名称是否正确(`at_mobiles`、`at_userids`、`at_all`)
|
||||||
|
|
||||||
|
### Q: 可以只发送 HTML 格式吗?
|
||||||
|
|
||||||
|
**A:** 可以,但建议同时提供 text 格式作为备选,确保不支持 HTML 的渠道也能正常发送。
|
||||||
|
|
||||||
|
### Q: Token 和 task_id 有什么区别?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- **token**:加密的任务标识,更安全,推荐使用
|
||||||
|
- **task_id**:明文的任务ID,不推荐在生产环境使用
|
||||||
|
|
||||||
|
## 各语言调用示例
|
||||||
|
|
||||||
|
本节提供各种编程语言的完整调用示例代码。
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST --location 'http://127.0.0.1:8000/api/v1/message/send' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "message title",
|
||||||
|
"text": "Hello World!"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "message title",
|
||||||
|
"text": "Hello World!"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
'http://127.0.0.1:8000/api/v1/message/send',
|
||||||
|
headers=headers,
|
||||||
|
json=json_data
|
||||||
|
)
|
||||||
|
|
||||||
|
print("response:", response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
**安装依赖:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client := &http.Client{}
|
||||||
|
var data = strings.NewReader(`{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "message title",
|
||||||
|
"text": "Hello World!"
|
||||||
|
}`)
|
||||||
|
req, err := http.NewRequest("POST", "http://127.0.0.1:8000/api/v1/message/send", data)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
bodyText, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", bodyText)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Java
|
||||||
|
|
||||||
|
```java
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpRequest.BodyPublishers;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
public class MessageNestExample {
|
||||||
|
public static void main(String[] args) throws IOException, InterruptedException {
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
String jsonData = """
|
||||||
|
{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "message title",
|
||||||
|
"text": "Hello World!"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("http://127.0.0.1:8000/api/v1/message/send"))
|
||||||
|
.POST(BodyPublishers.ofString(jsonData))
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
System.out.println(response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js
|
||||||
|
|
||||||
|
#### 使用 request 库
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var request = require('request');
|
||||||
|
|
||||||
|
var headers = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
var dataString = JSON.stringify({
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title": "message title",
|
||||||
|
"text": "Hello World!"
|
||||||
|
});
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
url: 'http://127.0.0.1:8000/api/v1/message/send',
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: dataString
|
||||||
|
};
|
||||||
|
|
||||||
|
function callback(error, response, body) {
|
||||||
|
if (!error && response.statusCode == 200) {
|
||||||
|
console.log(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request(options, callback);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用 axios 库
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
token: "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
title: "message title",
|
||||||
|
text: "Hello World!"
|
||||||
|
};
|
||||||
|
|
||||||
|
axios.post('http://127.0.0.1:8000/api/v1/message/send', data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
console.log('response:', response.data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('error:', error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用 fetch (Node.js 18+)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const data = {
|
||||||
|
token: "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
title: "message title",
|
||||||
|
text: "Hello World!"
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('http://127.0.0.1:8000/api/v1/message/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('response:', data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('error:', error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### PHP
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
$ch = curl_init();
|
||||||
|
|
||||||
|
$data = array(
|
||||||
|
"token" => "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
"title" => "message title",
|
||||||
|
"text" => "Hello World!"
|
||||||
|
);
|
||||||
|
|
||||||
|
curl_setopt($ch, CURLOPT_URL, 'http://127.0.0.1:8000/api/v1/message/send');
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
]);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||||
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
echo 'Error:' . curl_error($ch);
|
||||||
|
} else {
|
||||||
|
echo $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_close($ch);
|
||||||
|
?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### C#
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
static async Task Main(string[] args)
|
||||||
|
{
|
||||||
|
using var client = new HttpClient();
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
token = "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e",
|
||||||
|
title = "message title",
|
||||||
|
text = "Hello World!"
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(data);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"http://127.0.0.1:8000/api/v1/message/send",
|
||||||
|
content
|
||||||
|
);
|
||||||
|
|
||||||
|
var responseString = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine(responseString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ruby
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
require 'net/http'
|
||||||
|
require 'json'
|
||||||
|
require 'uri'
|
||||||
|
|
||||||
|
uri = URI('http://127.0.0.1:8000/api/v1/message/send')
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
|
||||||
|
request = Net::HTTP::Post.new(uri.path, {
|
||||||
|
'Content-Type' => 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
request.body = {
|
||||||
|
token: 'a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e',
|
||||||
|
title: 'message title',
|
||||||
|
text: 'Hello World!'
|
||||||
|
}.to_json
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
puts response.body
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例说明
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
- 将示例中的 `http://127.0.0.1:8000` 替换为你的实际服务地址
|
||||||
|
- 将 `a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e` 替换为你在管理后台创建的实际 Token
|
||||||
|
- 建议在生产环境中使用 HTTPS
|
||||||
|
- 所有示例都使用基本的纯文本格式,实际使用时可以添加 `html`、`markdown` 等参数
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [V2 API 文档](/api/v2) 了解基于模板的发送方式
|
||||||
|
- 查看 [消息模板](/guide/template) 了解如何使用模板功能
|
||||||
+519
@@ -0,0 +1,519 @@
|
|||||||
|
# V2 API 文档
|
||||||
|
|
||||||
|
V2 API 提供基于消息模板的发送接口,支持占位符替换和多实例发送。
|
||||||
|
|
||||||
|
::: tip ⭐ 推荐使用
|
||||||
|
V2 API(模板)是我们推荐的消息发送方式,相比 V1 API 具有以下优势:
|
||||||
|
- ✅ **内容复用** - 一次定义,多处使用,大幅提高开发效率
|
||||||
|
- ✅ **灵活性** - 通过占位符实现动态内容,兼顾固定格式和动态数据
|
||||||
|
- ✅ **易维护** - 修改消息内容无需改代码,运营人员可直接操作
|
||||||
|
- ✅ **版本控制** - 支持模板启用/禁用,便于灰度发布和回滚
|
||||||
|
- ✅ **团队协作** - 开发和运营分工明确,提高协作效率
|
||||||
|
|
||||||
|
适用于 90% 的消息发送场景,特别是有固定格式的通知类消息。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 接口概述
|
||||||
|
|
||||||
|
V2 API 与 V1 API 的主要区别:
|
||||||
|
|
||||||
|
| 特性 | V1 API | V2 API |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 发送方式 | 基于任务 | 基于模板 |
|
||||||
|
| 内容定义 | API 调用时传递 | 模板预定义 |
|
||||||
|
| 动态内容 | 不支持 | 支持占位符 |
|
||||||
|
| 多格式 | 单一格式 | Text/HTML/Markdown |
|
||||||
|
| 安全性 | Token 加密 | Token 加密 |
|
||||||
|
|
||||||
|
## 接口地址
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v2/message/send
|
||||||
|
```
|
||||||
|
|
||||||
|
## 请求参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| token | string | 是 | 加密的模板 Token |
|
||||||
|
| title | string | 是 | 消息标题 |
|
||||||
|
| placeholders | object | 否 | 占位符键值对 |
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
#### token
|
||||||
|
|
||||||
|
- 模板的加密 Token,在管理后台的"消息模板"页面获取
|
||||||
|
- **注意**:V2 API 只支持加密 Token,不支持明文模板 ID
|
||||||
|
- Token 使用对称加密算法生成,确保安全性
|
||||||
|
|
||||||
|
#### title
|
||||||
|
|
||||||
|
- 消息标题,会传递给所有支持标题的渠道(如邮件)
|
||||||
|
- 必填参数,不能为空
|
||||||
|
|
||||||
|
#### placeholders
|
||||||
|
|
||||||
|
- 占位符的键值对,用于替换模板中的 `{{key}}`
|
||||||
|
- 格式为 JSON 对象:`{"key": "value"}`
|
||||||
|
- 如果模板中定义了占位符但未传递,将使用默认值
|
||||||
|
- 如果既未传递也无默认值,占位符将保持原样
|
||||||
|
|
||||||
|
## 请求示例
|
||||||
|
|
||||||
|
### 基本示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v2/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
|
||||||
|
"title": "系统通知",
|
||||||
|
"placeholders": {
|
||||||
|
"username": "张三",
|
||||||
|
"action": "登录",
|
||||||
|
"time": "2024-12-06 12:00:00"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
url = "http://your-domain/api/v2/message/send"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
|
||||||
|
"title": "系统通知",
|
||||||
|
"placeholders": {
|
||||||
|
"username": "张三",
|
||||||
|
"action": "登录",
|
||||||
|
"time": "2024-12-06 12:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, data=json.dumps(data))
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go 示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
url := "http://your-domain/api/v2/message/send"
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
|
||||||
|
"title": "系统通知",
|
||||||
|
"placeholders": map[string]string{
|
||||||
|
"username": "张三",
|
||||||
|
"action": "登录",
|
||||||
|
"time": "2024-12-06 12:00:00",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, _ := json.Marshal(data)
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
fmt.Println(result)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript/Node.js 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const url = 'http://your-domain/api/v2/message/send';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
token: 'a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b',
|
||||||
|
title: '系统通知',
|
||||||
|
placeholders: {
|
||||||
|
username: '张三',
|
||||||
|
action: '登录',
|
||||||
|
time: '2024-12-06 12:00:00'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
axios.post(url, data)
|
||||||
|
.then(response => {
|
||||||
|
console.log(response.data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Java 示例
|
||||||
|
|
||||||
|
```java
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public class MessageSender {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
String url = "http://your-domain/api/v2/message/send";
|
||||||
|
|
||||||
|
String jsonData = """
|
||||||
|
{
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
|
||||||
|
"title": "系统通知",
|
||||||
|
"placeholders": {
|
||||||
|
"username": "张三",
|
||||||
|
"action": "登录",
|
||||||
|
"time": "2024-12-06 12:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
byte[] input = jsonData.getBytes(StandardCharsets.UTF_8);
|
||||||
|
os.write(input, 0, input.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
int responseCode = conn.getResponseCode();
|
||||||
|
System.out.println("Response Code: " + responseCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"token": "a3541c2f0d3e1b4a5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
|
||||||
|
"count": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明:**
|
||||||
|
|
||||||
|
- `code` - 状态码,200 表示成功
|
||||||
|
- `msg` - 响应消息
|
||||||
|
- `data.token` - 使用的模板 Token
|
||||||
|
- `data.count` - 成功发送的实例数量
|
||||||
|
|
||||||
|
### 失败响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "token解析失败:invalid token format",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. **Token 解析** - 解密 Token 获取模板 ID
|
||||||
|
2. **模板查询** - 根据模板 ID 查询模板信息
|
||||||
|
3. **状态检查** - 检查模板是否启用
|
||||||
|
4. **占位符替换** - 使用传入的 placeholders 替换模板中的占位符
|
||||||
|
5. **实例遍历** - 获取模板关联的所有启用实例
|
||||||
|
6. **格式匹配** - 根据实例的 ContentType 选择对应格式的内容
|
||||||
|
7. **消息发送** - 向每个实例发送消息
|
||||||
|
8. **返回结果** - 返回发送成功的实例数量
|
||||||
|
|
||||||
|
## 占位符替换规则
|
||||||
|
|
||||||
|
### 基本规则
|
||||||
|
|
||||||
|
模板中使用 `{{key}}` 定义占位符,API 调用时通过 `placeholders` 参数传递替换值。
|
||||||
|
|
||||||
|
**模板内容:**
|
||||||
|
```text
|
||||||
|
您好,{{username}}!
|
||||||
|
您的订单 {{order_id}} 已经 {{status}}。
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 调用:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"placeholders": {
|
||||||
|
"username": "张三",
|
||||||
|
"order_id": "20241206001",
|
||||||
|
"status": "发货"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**替换结果:**
|
||||||
|
```text
|
||||||
|
您好,张三!
|
||||||
|
您的订单 20241206001 已经发货。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 默认值处理
|
||||||
|
|
||||||
|
如果占位符定义了默认值,未传递时使用默认值:
|
||||||
|
|
||||||
|
**占位符定义:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "username",
|
||||||
|
"label": "用户名",
|
||||||
|
"default": "Guest"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 调用(未传递 username):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"placeholders": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**替换结果:**
|
||||||
|
```text
|
||||||
|
您好,Guest!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 未定义占位符
|
||||||
|
|
||||||
|
如果占位符既未传递也无默认值,将保持原样:
|
||||||
|
|
||||||
|
```text
|
||||||
|
您好,{{username}}! // username 未传递且无默认值
|
||||||
|
```
|
||||||
|
|
||||||
|
## 内容格式选择
|
||||||
|
|
||||||
|
V2 API 支持三种内容格式,系统会根据实例配置自动选择:
|
||||||
|
|
||||||
|
| 格式 | 适用渠道 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| Text | 所有渠道 | 纯文本格式,兼容性最好 |
|
||||||
|
| HTML | 邮件 | 富文本格式,支持样式 |
|
||||||
|
| Markdown | 钉钉、企业微信 | Markdown 格式,支持格式化 |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
假设模板定义了三种格式的内容:
|
||||||
|
|
||||||
|
- Text: `您好,{{username}}!`
|
||||||
|
- HTML: `<h2>您好,{{username}}!</h2>`
|
||||||
|
- Markdown: `## 您好,{{username}}!`
|
||||||
|
|
||||||
|
当发送到不同实例时:
|
||||||
|
|
||||||
|
- **邮件实例(ContentType=html)** → 使用 HTML 格式
|
||||||
|
- **钉钉实例(ContentType=markdown)** → 使用 Markdown 格式
|
||||||
|
- **其他实例(ContentType=text)** → 使用 Text 格式
|
||||||
|
|
||||||
|
## @提醒功能
|
||||||
|
|
||||||
|
如果模板配置了@提醒,会自动应用到支持的渠道(钉钉、企业微信)。
|
||||||
|
|
||||||
|
**模板配置:**
|
||||||
|
- @手机号:`13800138000,13900139000`
|
||||||
|
- @用户ID:`user001,user002`
|
||||||
|
- @所有人:是
|
||||||
|
|
||||||
|
**发送效果:**
|
||||||
|
- 钉钉/企业微信会@指定的手机号或用户
|
||||||
|
- 如果启用@所有人,会@群内所有成员
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| 错误码 | 说明 | 解决方案 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| 200 | 成功 | - |
|
||||||
|
| 400 | 请求参数错误 | 检查请求参数格式 |
|
||||||
|
| 400 | token解析失败 | 检查 Token 是否正确 |
|
||||||
|
| 400 | 模板不存在 | 检查模板 ID 是否有效 |
|
||||||
|
| 400 | 模板已禁用 | 在管理后台启用模板 |
|
||||||
|
| 400 | 模板没有配置发送实例 | 为模板添加发送实例 |
|
||||||
|
| 400 | 模板没有启用的发送实例 | 启用至少一个实例 |
|
||||||
|
| 500 | 服务器内部错误 | 联系管理员 |
|
||||||
|
|
||||||
|
## 获取模板 Token
|
||||||
|
|
||||||
|
### 方式一:管理后台查看
|
||||||
|
|
||||||
|
1. 登录 Message Nest 管理后台
|
||||||
|
2. 进入"消息模板"页面
|
||||||
|
3. 点击模板的"接口"按钮
|
||||||
|
4. 查看并复制加密的 Token
|
||||||
|
|
||||||
|
### 方式二:API 代码示例
|
||||||
|
|
||||||
|
管理后台提供多种语言的 API 调用示例,包含真实的加密 Token。
|
||||||
|
|
||||||
|
## 安全性说明
|
||||||
|
|
||||||
|
### Token 加密
|
||||||
|
|
||||||
|
- V2 API 使用对称加密算法保护模板 ID
|
||||||
|
- Token 是确定性加密,相同的模板 ID 生成相同的 Token
|
||||||
|
- 加密密钥存储在服务器端,客户端无需关心加密细节
|
||||||
|
|
||||||
|
### 最佳实践
|
||||||
|
|
||||||
|
1. **Token 保护**
|
||||||
|
- 不要在公开代码中硬编码 Token
|
||||||
|
- 使用环境变量或配置文件存储 Token
|
||||||
|
- 定期检查 Token 的使用情况
|
||||||
|
|
||||||
|
2. **权限控制**
|
||||||
|
- 合理设置模板的启用/禁用状态
|
||||||
|
- 及时禁用不再使用的模板
|
||||||
|
- 定期审查模板配置
|
||||||
|
|
||||||
|
3. **内容安全**
|
||||||
|
- 注意模板内容的合规性
|
||||||
|
- 避免在模板中包含敏感信息
|
||||||
|
- 对用户输入进行验证和过滤
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 1. 用户通知
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "账号安全提醒",
|
||||||
|
"placeholders": {
|
||||||
|
"username": "张三",
|
||||||
|
"action": "登录",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"time": "2024-12-06 12:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 订单通知
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "订单状态更新",
|
||||||
|
"placeholders": {
|
||||||
|
"order_id": "20241206001",
|
||||||
|
"status": "已发货",
|
||||||
|
"tracking_number": "SF1234567890",
|
||||||
|
"estimated_delivery": "2024-12-08"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 系统告警
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "系统告警",
|
||||||
|
"placeholders": {
|
||||||
|
"service": "API Server",
|
||||||
|
"level": "严重",
|
||||||
|
"message": "CPU使用率超过90%",
|
||||||
|
"time": "2024-12-06 12:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 营销推广
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"title": "优惠活动通知",
|
||||||
|
"placeholders": {
|
||||||
|
"username": "张三",
|
||||||
|
"product": "VIP会员",
|
||||||
|
"discount": "8折",
|
||||||
|
"expire_date": "2024-12-31"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 异步发送
|
||||||
|
|
||||||
|
V2 API 采用异步发送机制,API 调用立即返回,实际发送在后台进行。
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- 快速响应,不阻塞调用方
|
||||||
|
- 支持批量发送多个实例
|
||||||
|
- 自动重试失败的发送
|
||||||
|
|
||||||
|
### 批量发送
|
||||||
|
|
||||||
|
一次 API 调用可以发送到多个实例(渠道),系统自动遍历所有启用的实例。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
模板配置了 3 个实例:
|
||||||
|
- 邮件实例(启用)
|
||||||
|
- 钉钉实例(启用)
|
||||||
|
- 企业微信实例(禁用)
|
||||||
|
|
||||||
|
调用 API 后,消息会发送到邮件和钉钉,企业微信实例被跳过。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: V1 和 V2 API 可以同时使用吗?
|
||||||
|
|
||||||
|
**A:** 可以。V1 和 V2 API 是独立的,可以根据需求选择使用。
|
||||||
|
|
||||||
|
### Q: 如何从 V1 迁移到 V2?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 创建消息模板,定义占位符
|
||||||
|
2. 为模板配置发送实例
|
||||||
|
3. 获取模板 Token
|
||||||
|
4. 修改 API 调用代码,使用 V2 接口
|
||||||
|
|
||||||
|
### Q: 占位符可以嵌套吗?
|
||||||
|
|
||||||
|
**A:** 不支持。占位符只支持一级替换,不支持嵌套或递归。
|
||||||
|
|
||||||
|
### Q: 可以动态添加占位符吗?
|
||||||
|
|
||||||
|
**A:** 不可以。占位符必须在模板中预先定义,API 调用时只能传递已定义的占位符。
|
||||||
|
|
||||||
|
### Q: 如何调试模板?
|
||||||
|
|
||||||
|
**A:** 使用管理后台的"预览"功能,可以填写测试数据查看替换效果。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [消息模板文档](/guide/template) 了解如何创建和管理模板
|
||||||
|
- 查看 [V1 API 文档](/api/usage) 了解传统的发送方式
|
||||||
|
- 查看 [API 示例](/api/examples) 了解更多调用示例
|
||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
## 2025
|
## 2025
|
||||||
|
|
||||||
|
### 2025.12.06
|
||||||
|
- **新增消息模板功能**
|
||||||
|
- 支持创建可复用的消息模板
|
||||||
|
- 支持占位符动态替换(`{{key}}` 语法)
|
||||||
|
- 支持 Text、HTML、Markdown 三种格式
|
||||||
|
- 支持为模板配置多个发送实例
|
||||||
|
- 支持 @提醒功能(钉钉、企业微信)
|
||||||
|
- **新增 V2 API**
|
||||||
|
- 基于模板的消息发送接口
|
||||||
|
- 使用加密 Token 提升安全性
|
||||||
|
- 支持占位符参数传递
|
||||||
|
- 自动遍历模板的所有启用实例
|
||||||
|
|
||||||
### 2025.10.12
|
### 2025.10.12
|
||||||
- 增加cookies过期天数设置
|
- 增加cookies过期天数设置
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
# 推送渠道配置
|
||||||
|
|
||||||
|
Message Nest 支持多种消息推送渠道,您可以根据需求配置不同的渠道,实现多渠道消息推送。
|
||||||
|
|
||||||
|
## 邮件(SMTP)
|
||||||
|
|
||||||
|
通过标准 SMTP 协议发送邮件通知。
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | `公司邮箱` |
|
||||||
|
| SMTP 服务器 | 邮件服务器地址 | `smtp.gmail.com` |
|
||||||
|
| SMTP 端口 | 服务器端口 | `587` (TLS) 或 `465` (SSL) |
|
||||||
|
| 发件人邮箱 | 发送邮件的邮箱地址 | `noreply@example.com` |
|
||||||
|
| 发件人名称 | 显示的发件人名称 | `系统通知` |
|
||||||
|
| 邮箱密码 | 邮箱密码或授权码 | `your_password` |
|
||||||
|
| 加密方式 | TLS/SSL/无 | `TLS` |
|
||||||
|
|
||||||
|
### 常见邮箱配置
|
||||||
|
|
||||||
|
#### Gmail
|
||||||
|
|
||||||
|
- **SMTP 服务器**: `smtp.gmail.com`
|
||||||
|
- **端口**: `587` (TLS)
|
||||||
|
- **注意**: 需要开启"允许不够安全的应用"或使用应用专用密码
|
||||||
|
|
||||||
|
#### QQ 邮箱
|
||||||
|
|
||||||
|
- **SMTP 服务器**: `smtp.qq.com`
|
||||||
|
- **端口**: `587` (TLS) 或 `465` (SSL)
|
||||||
|
- **注意**: 需要在邮箱设置中开启 SMTP 服务并获取授权码
|
||||||
|
|
||||||
|
#### 163 邮箱
|
||||||
|
|
||||||
|
- **SMTP 服务器**: `smtp.163.com`
|
||||||
|
- **端口**: `465` (SSL)
|
||||||
|
- **注意**: 需要开启 SMTP 服务并使用授权码
|
||||||
|
|
||||||
|
#### 企业邮箱
|
||||||
|
|
||||||
|
根据企业邮箱服务商提供的 SMTP 配置填写。
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 用户注册/登录通知
|
||||||
|
- ✅ 订单确认和物流通知
|
||||||
|
- ✅ 账单和发票发送
|
||||||
|
- ✅ 密码重置和安全提醒
|
||||||
|
- ✅ 营销邮件和活动通知
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
- 邮件发送可能会被识别为垃圾邮件,建议配置 SPF、DKIM 等记录
|
||||||
|
- 使用授权码而非邮箱密码,提高安全性
|
||||||
|
- 注意邮件发送频率限制,避免被封禁
|
||||||
|
- 建议使用企业邮箱,稳定性更好
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 钉钉机器人
|
||||||
|
|
||||||
|
通过钉钉群机器人发送消息到钉钉群。
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
1. **创建钉钉群**
|
||||||
|
- 在钉钉中创建一个群聊
|
||||||
|
|
||||||
|
2. **添加自定义机器人**
|
||||||
|
- 进入群设置 → 智能群助手 → 添加机器人 → 自定义
|
||||||
|
- 设置机器人名称和头像
|
||||||
|
|
||||||
|
3. **配置安全设置**
|
||||||
|
- 选择"自定义关键词"或"加签"方式
|
||||||
|
- 记录 Webhook 地址
|
||||||
|
|
||||||
|
4. **在 Message Nest 中配置**
|
||||||
|
- 渠道名称:自定义名称
|
||||||
|
- Webhook URL:复制的 Webhook 地址
|
||||||
|
- 安全设置:根据选择填写关键词或密钥
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | 是 |
|
||||||
|
| Webhook URL | 机器人 Webhook 地址 | 是 |
|
||||||
|
| 安全关键词 | 自定义关键词(如选择此方式) | 否 |
|
||||||
|
| 加签密钥 | 加签密钥(如选择此方式) | 否 |
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
|
||||||
|
钉钉支持以下格式:
|
||||||
|
- **Text** - 纯文本
|
||||||
|
- **Markdown** - 支持 Markdown 格式
|
||||||
|
|
||||||
|
### @提醒功能
|
||||||
|
|
||||||
|
支持@群成员:
|
||||||
|
- **@手机号** - 通过手机号@指定成员
|
||||||
|
- **@所有人** - @群内所有成员(需要机器人权限)
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 系统告警和监控通知
|
||||||
|
- ✅ 任务完成提醒
|
||||||
|
- ✅ 审批流程通知
|
||||||
|
- ✅ 团队协作消息
|
||||||
|
- ✅ 日报/周报推送
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
- 每个机器人每分钟最多发送 20 条消息
|
||||||
|
- @所有人功能需要群主授权
|
||||||
|
- 建议在消息中包含安全关键词,避免发送失败
|
||||||
|
- 机器人被移除后 Webhook 将失效
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 企业微信机器人
|
||||||
|
|
||||||
|
通过企业微信群机器人发送消息到企业微信群。
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
1. **创建企业微信群**
|
||||||
|
- 在企业微信中创建群聊
|
||||||
|
|
||||||
|
2. **添加群机器人**
|
||||||
|
- 进入群设置 → 群机器人 → 添加机器人
|
||||||
|
- 设置机器人名称
|
||||||
|
|
||||||
|
3. **获取 Webhook**
|
||||||
|
- 复制 Webhook 地址
|
||||||
|
|
||||||
|
4. **在 Message Nest 中配置**
|
||||||
|
- 渠道名称:自定义名称
|
||||||
|
- Webhook URL:复制的 Webhook 地址
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | 是 |
|
||||||
|
| Webhook URL | 机器人 Webhook 地址 | 是 |
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
|
||||||
|
企业微信支持以下格式:
|
||||||
|
- **Text** - 纯文本
|
||||||
|
- **Markdown** - 支持 Markdown 格式
|
||||||
|
|
||||||
|
### @提醒功能
|
||||||
|
|
||||||
|
支持@群成员:
|
||||||
|
- **@用户ID** - 通过用户ID@指定成员
|
||||||
|
- **@手机号** - 通过手机号@指定成员
|
||||||
|
- **@所有人** - @群内所有成员
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 企业内部通知
|
||||||
|
- ✅ 项目进度更新
|
||||||
|
- ✅ 系统运维告警
|
||||||
|
- ✅ 工作流审批提醒
|
||||||
|
- ✅ 会议和日程通知
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
- 每个机器人每分钟最多发送 20 条消息
|
||||||
|
- 消息内容不能包含敏感词
|
||||||
|
- 机器人被移除后 Webhook 将失效
|
||||||
|
- 建议使用 Markdown 格式,展示效果更好
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 微信测试公众号
|
||||||
|
|
||||||
|
通过微信测试公众号发送模板消息。
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
1. **申请测试公众号**
|
||||||
|
- 访问 [微信公众平台测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login)
|
||||||
|
- 使用微信扫码登录
|
||||||
|
|
||||||
|
2. **获取配置信息**
|
||||||
|
- appID:测试号信息中的 appID
|
||||||
|
- appsecret:测试号信息中的 appsecret
|
||||||
|
|
||||||
|
3. **添加模板消息**
|
||||||
|
- 在"模板消息接口"中添加模板
|
||||||
|
- 记录模板 ID
|
||||||
|
|
||||||
|
4. **关注测试公众号**
|
||||||
|
- 使用微信扫描测试号二维码关注
|
||||||
|
- 记录用户的 OpenID
|
||||||
|
|
||||||
|
5. **在 Message Nest 中配置**
|
||||||
|
- 填写 appID、appsecret、模板ID、OpenID
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | 是 |
|
||||||
|
| appID | 测试公众号 appID | 是 |
|
||||||
|
| appsecret | 测试公众号 appsecret | 是 |
|
||||||
|
| 模板ID | 模板消息 ID | 是 |
|
||||||
|
| OpenID | 接收用户的 OpenID | 是 |
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 个人项目测试
|
||||||
|
- ✅ 小范围通知
|
||||||
|
- ✅ 开发环境调试
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
- 测试公众号仅供开发测试使用,不能用于生产环境
|
||||||
|
- 测试公众号有关注人数限制(100人)
|
||||||
|
- 模板消息格式需要符合微信规范
|
||||||
|
- 正式使用需要申请正式公众号
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 自定义 Webhook
|
||||||
|
|
||||||
|
向自定义的 HTTP 接口发送消息。
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | 是 |
|
||||||
|
| Webhook URL | 目标 HTTP 接口地址 | 是 |
|
||||||
|
| 请求方法 | GET/POST/PUT 等 | 是 |
|
||||||
|
| 请求头 | 自定义 HTTP 请求头 | 否 |
|
||||||
|
| 请求体模板 | 自定义请求体格式 | 否 |
|
||||||
|
|
||||||
|
### 请求体模板
|
||||||
|
|
||||||
|
支持使用变量:
|
||||||
|
- `{{title}}` - 消息标题
|
||||||
|
- `{{text}}` - 纯文本内容
|
||||||
|
- `{{html}}` - HTML 内容
|
||||||
|
- `{{markdown}}` - Markdown 内容
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "{{title}}",
|
||||||
|
"content": "{{text}}",
|
||||||
|
"timestamp": "{{timestamp}}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 集成第三方系统
|
||||||
|
- ✅ 自建消息服务
|
||||||
|
- ✅ 对接其他通知平台
|
||||||
|
- ✅ 自定义消息处理逻辑
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
- 确保目标接口可访问
|
||||||
|
- 注意接口的请求频率限制
|
||||||
|
- 建议添加认证信息保证安全
|
||||||
|
- 可以通过请求头传递 Token 等认证信息
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 自托管消息
|
||||||
|
|
||||||
|
将 Message Nest 站点作为消息接收平台,用户登录站点查看消息。
|
||||||
|
|
||||||
|
### 核心定位
|
||||||
|
|
||||||
|
**与其他渠道的区别:**
|
||||||
|
- **邮件/钉钉/企业微信** - 推送到外部平台
|
||||||
|
- **自托管消息** - 存储在 Message Nest 站点,用户登录站点查看
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | 是 |
|
||||||
|
| 渠道描述 | 渠道用途说明 | 否 |
|
||||||
|
|
||||||
|
### 功能特点
|
||||||
|
|
||||||
|
- ✅ 站点作为消息接收平台
|
||||||
|
- ✅ 无需外部依赖
|
||||||
|
- ✅ 消息集中存储和管理
|
||||||
|
- ✅ 支持多种格式展示(Text/HTML/Markdown)
|
||||||
|
- ✅ 支持消息搜索和筛选
|
||||||
|
- ✅ 支持消息已读/未读状态
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 站内消息中心
|
||||||
|
- ✅ 系统公告发布
|
||||||
|
- ✅ 内部工作流通知
|
||||||
|
- ✅ 系统告警记录
|
||||||
|
- ✅ 消息归档平台
|
||||||
|
|
||||||
|
### 查看消息
|
||||||
|
|
||||||
|
1. 登录 Message Nest 站点
|
||||||
|
2. 进入"自托管消息"或"消息中心"页面
|
||||||
|
3. 查看接收到的消息列表
|
||||||
|
4. 点击消息查看详情
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
- 消息不会自动清理,需要手动清理
|
||||||
|
- 建议定期清理不需要的消息,避免占用过多存储空间
|
||||||
|
- 可以根据需要导出消息记录
|
||||||
|
- 适合作为消息的集中查看和管理平台
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 渠道管理
|
||||||
|
|
||||||
|
### 创建渠道
|
||||||
|
|
||||||
|
1. 登录管理后台
|
||||||
|
2. 进入"推送渠道"页面
|
||||||
|
3. 点击"新建渠道"
|
||||||
|
4. 选择渠道类型
|
||||||
|
5. 填写配置信息
|
||||||
|
6. 保存并测试
|
||||||
|
|
||||||
|
### 测试渠道
|
||||||
|
|
||||||
|
创建渠道后,建议先进行测试:
|
||||||
|
|
||||||
|
1. 在渠道列表中找到新建的渠道
|
||||||
|
2. 点击"测试"按钮
|
||||||
|
3. 发送测试消息
|
||||||
|
4. 确认消息正常接收
|
||||||
|
|
||||||
|
### 编辑和删除
|
||||||
|
|
||||||
|
- **编辑**:点击渠道的"编辑"按钮,修改配置信息
|
||||||
|
- **删除**:点击"删除"按钮,确认后删除(注意:删除后关联的任务和模板将无法使用该渠道)
|
||||||
|
|
||||||
|
### 最佳实践
|
||||||
|
|
||||||
|
1. **命名规范** - 使用清晰的渠道名称,便于识别
|
||||||
|
2. **分类管理** - 按用途或环境分类(如:生产环境邮件、测试环境钉钉)
|
||||||
|
3. **定期检查** - 定期检查渠道配置是否有效
|
||||||
|
4. **安全管理** - 妥善保管密钥和密码信息
|
||||||
|
5. **备用渠道** - 配置多个渠道作为备用,提高可靠性
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [发送任务](/guide/tasks) 了解如何使用渠道发送消息
|
||||||
|
- 查看 [消息模板](/guide/template) 了解如何创建模板
|
||||||
|
- 查看 [API 文档](/api/v1) 了解如何通过 API 发送消息
|
||||||
+103
-23
@@ -1,19 +1,112 @@
|
|||||||
|
# 特色功能
|
||||||
|
|
||||||
## 支持的推送方式
|
Message Nest 提供了丰富的功能,帮助您轻松实现多渠道消息推送。
|
||||||
|
|
||||||
- **邮件发送** - 支持标准SMTP邮件发送
|
## 核心功能
|
||||||
- **钉钉** - 支持钉钉机器人消息推送
|
|
||||||
- **企业微信** - 支持企业微信应用消息推送
|
### 多渠道推送
|
||||||
- **微信测试公众号** - 支持微信测试公众号模板消息发送
|
|
||||||
- **自定义 Webhook** - 支持自定义的Webhook消息发送
|
支持多种主流消息推送渠道,一次配置,多处使用。
|
||||||
|
|
||||||
|
- **邮件发送** - 支持标准SMTP邮件发送,适用于正式通知和账单发送
|
||||||
|
- **钉钉** - 支持钉钉机器人消息推送,适用于团队协作和系统告警
|
||||||
|
- **企业微信** - 支持企业微信应用消息推送,适用于企业内部通知
|
||||||
|
- **微信测试公众号** - 支持微信测试公众号模板消息发送,适用于开发测试
|
||||||
|
- **自定义 Webhook** - 支持自定义的Webhook消息发送,灵活对接第三方系统
|
||||||
- **自托管消息** - 可以将站点作为消息的接收方,登录站点查看消息
|
- **自托管消息** - 可以将站点作为消息的接收方,登录站点查看消息
|
||||||
|
|
||||||
|
👉 [查看推送渠道配置详细说明](/guide/channels)
|
||||||
|
|
||||||
|
### 消息模板(⭐推荐)
|
||||||
|
|
||||||
|
通过模板管理消息内容,支持占位符动态替换,大幅提高开发效率和维护便利性。
|
||||||
|
|
||||||
|
**核心特性:**
|
||||||
|
- ✅ 支持 Text、HTML、Markdown 三种格式
|
||||||
|
- ✅ 占位符动态替换(`{{key}}` 语法)
|
||||||
|
- ✅ 多实例配置,一次发送多渠道
|
||||||
|
- ✅ @提醒功能(钉钉、企业微信)
|
||||||
|
- ✅ 模板启用/禁用控制
|
||||||
|
- ✅ 版本管理和灰度发布
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 用户通知(注册、登录、密码重置)
|
||||||
|
- 订单消息(下单、支付、发货、退款)
|
||||||
|
- 系统告警(服务异常、资源不足)
|
||||||
|
- 营销推广(活动通知、优惠券)
|
||||||
|
|
||||||
|
👉 [查看消息模板详细说明](/guide/template)
|
||||||
|
|
||||||
|
### 发送任务
|
||||||
|
|
||||||
|
基于任务的消息发送方式,适用于完全动态的消息内容。
|
||||||
|
|
||||||
|
**核心特性:**
|
||||||
|
- ✅ 配置多个推送渠道
|
||||||
|
- ✅ 支持多种消息格式
|
||||||
|
- ✅ 获取 API Token
|
||||||
|
- ✅ 查看发送日志
|
||||||
|
|
||||||
|
**说明:** 发送任务主要用于兼容历史数据,新项目推荐使用消息模板。
|
||||||
|
|
||||||
|
👉 [查看发送任务详细说明](/guide/tasks)
|
||||||
|
|
||||||
|
### 定时消息
|
||||||
|
|
||||||
|
自动化的定时消息发送功能,使用 Cron 表达式设置执行时间。
|
||||||
|
|
||||||
|
**核心特性:**
|
||||||
|
- ✅ 使用 Cron 表达式设置执行时间
|
||||||
|
- ✅ 关联发信任务进行发送
|
||||||
|
- ✅ 启用/禁用定时任务
|
||||||
|
- ✅ 立即执行测试
|
||||||
|
- ✅ 查看执行日志
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 每日报表推送
|
||||||
|
- 周会/月会提醒
|
||||||
|
- 定期系统巡检
|
||||||
|
- 月度账单推送
|
||||||
|
|
||||||
|
👉 [查看定时消息详细说明](/guide/scheduled-messages)
|
||||||
|
|
||||||
|
### 自托管消息
|
||||||
|
|
||||||
|
将 Message Nest 站点作为消息接收平台,用户登录站点查看消息。
|
||||||
|
|
||||||
|
**核心定位:**
|
||||||
|
- 💡 站点作为消息接收和展示平台
|
||||||
|
- 💡 用户登录站点查看消息
|
||||||
|
- 💡 不推送到外部渠道
|
||||||
|
|
||||||
|
**核心特性:**
|
||||||
|
- ✅ 站点作为消息中心
|
||||||
|
- ✅ 搜索消息
|
||||||
|
- ✅ 查看消息详情
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 站内消息中心
|
||||||
|
- 内部工作流通知平台
|
||||||
|
- 系统告警记录平台
|
||||||
|
- 消息归档平台
|
||||||
|
|
||||||
|
👉 [查看自托管消息详细说明](/guide/self-hosted-messages)
|
||||||
|
|
||||||
|
### 系统设置
|
||||||
|
|
||||||
|
灵活的系统配置和管理功能。
|
||||||
|
|
||||||
|
**配置项:**
|
||||||
|
- ✅ 站点设置(标题、标语、Logo、分页、Cookie 过期天数)
|
||||||
|
- ✅ 重置密码(修改当前用户密码)
|
||||||
|
- ✅ 日志清理(定时清理、保留条数)
|
||||||
|
- ✅ 登录日志(查看登录历史记录)
|
||||||
|
- ✅ 站点关于(版本信息、系统状态)
|
||||||
|
|
||||||
|
👉 [查看系统设置详细说明](/guide/settings)
|
||||||
|
|
||||||
## 其他功能
|
## 其他功能
|
||||||
|
|
||||||
### 定时任务
|
|
||||||
|
|
||||||
支持自定义的定时消息发送,可以设置定时推送任务。
|
|
||||||
|
|
||||||
### 数据统计
|
### 数据统计
|
||||||
|
|
||||||
支持数据统计展示,可以查看消息发送情况和历史记录。
|
支持数据统计展示,可以查看消息发送情况和历史记录。
|
||||||
@@ -28,19 +121,6 @@
|
|||||||
- 支持查看定时清理日志
|
- 支持查看定时清理日志
|
||||||
- 支持登录日志记录
|
- 支持登录日志记录
|
||||||
|
|
||||||
### 用户管理
|
|
||||||
|
|
||||||
- 支持用户密码设置
|
|
||||||
- 支持用户定时任务清理
|
|
||||||
- 支持更新定时时间
|
|
||||||
|
|
||||||
### 系统信息
|
|
||||||
|
|
||||||
- 支持系统信息展示
|
|
||||||
- 支持站点信息自定义
|
|
||||||
- 支持明暗主题切换设置
|
|
||||||
- 支持Cookies过期天数设置
|
|
||||||
|
|
||||||
### 数据库支持
|
### 数据库支持
|
||||||
|
|
||||||
- **SQLite** - 轻量级部署,无需额外数据库服务
|
- **SQLite** - 轻量级部署,无需额外数据库服务
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# 定时消息
|
||||||
|
|
||||||
|
定时消息功能允许您设置定时发送的消息任务,系统会按照设定的 Cron 表达式自动执行发送。
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
定时消息允许您:
|
||||||
|
- ✅ 创建定时发送任务
|
||||||
|
- ✅ 使用 Cron 表达式设置执行时间
|
||||||
|
- ✅ 关联发信任务进行发送
|
||||||
|
- ✅ 启用/禁用定时任务
|
||||||
|
- ✅ 立即执行定时任务
|
||||||
|
- ✅ 查看执行日志
|
||||||
|
|
||||||
|
## 创建定时消息
|
||||||
|
|
||||||
|
### 步骤
|
||||||
|
|
||||||
|
1. 进入"定时消息"页面
|
||||||
|
2. 点击"新增定时消息"按钮
|
||||||
|
3. 填写定时消息信息
|
||||||
|
4. 点击"创建定时消息"保存
|
||||||
|
|
||||||
|
### 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 定时消息名称 | 定时任务的名称 | 是 |
|
||||||
|
| 关联发信任务 | 选择要使用的发信任务 | 是 |
|
||||||
|
| Cron 表达式 | 定时执行的时间规则 | 是 |
|
||||||
|
| 标题 | 消息标题 | 是 |
|
||||||
|
| 内容 | 消息内容 | 是 |
|
||||||
|
| url | 可选的 URL 链接 | 否 |
|
||||||
|
|
||||||
|
::: warning 重要提示
|
||||||
|
请确保所选的发信任务已配置至少一个发送实例,否则无法发送消息。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Cron 表达式
|
||||||
|
|
||||||
|
Cron 表达式用于设置定时任务的执行时间,格式为:`分 时 日 月 周`
|
||||||
|
|
||||||
|
#### 常用模板
|
||||||
|
|
||||||
|
系统提供了常用的 Cron 表达式模板,点击即可应用:
|
||||||
|
|
||||||
|
| 模板 | Cron 表达式 | 说明 |
|
||||||
|
|------|------------|------|
|
||||||
|
| 每分钟 | `* * * * *` | 每分钟执行一次 |
|
||||||
|
| 每5分钟 | `*/5 * * * *` | 每5分钟执行一次 |
|
||||||
|
| 每小时 | `0 * * * *` | 每小时整点执行 |
|
||||||
|
| 每天凌晨2点 | `0 2 * * *` | 每天凌晨2点执行 |
|
||||||
|
| 每周一凌晨2点 | `0 2 * * 1` | 每周一凌晨2点执行 |
|
||||||
|
| 每月1号凌晨2点 | `0 2 1 * *` | 每月1号凌晨2点执行 |
|
||||||
|
|
||||||
|
#### Cron 表达式格式
|
||||||
|
|
||||||
|
```
|
||||||
|
* * * * *
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ └─ 星期 (0-7, 0和7都表示周日)
|
||||||
|
│ │ │ └─── 月份 (1-12)
|
||||||
|
│ │ └───── 日期 (1-31)
|
||||||
|
│ └─────── 小时 (0-23)
|
||||||
|
└───────── 分钟 (0-59)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 特殊字符
|
||||||
|
|
||||||
|
- `*` - 匹配任意值
|
||||||
|
- `,` - 列举多个值,如 `1,3,5`
|
||||||
|
- `-` - 范围,如 `1-5`
|
||||||
|
- `/` - 步长,如 `*/5` 表示每5个单位
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
0 9 * * * # 每天上午9点
|
||||||
|
0 9-17 * * * # 每天9点到17点的整点
|
||||||
|
0 9,12,18 * * * # 每天9点、12点、18点
|
||||||
|
*/30 9-17 * * * # 每天9点到17点,每30分钟
|
||||||
|
0 9 * * 1-5 # 周一到周五的上午9点
|
||||||
|
0 0 1,15 * * # 每月1号和15号的凌晨
|
||||||
|
```
|
||||||
|
|
||||||
|
## 管理定时消息
|
||||||
|
|
||||||
|
### 查看定时消息列表
|
||||||
|
|
||||||
|
在"定时消息"页面可以看到所有定时消息:
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| ID | 定时消息的唯一标识 |
|
||||||
|
| 名称 | 定时消息名称(可点击查看完整内容) |
|
||||||
|
| 内容 | 消息内容(可点击查看完整内容) |
|
||||||
|
| Cron表达式 | 定时执行的时间规则 |
|
||||||
|
| 关联任务 | 关联的发信任务 ID |
|
||||||
|
| 下次执行时间 | 下一次计划执行的时间 |
|
||||||
|
| 创建时间 | 定时消息创建时间 |
|
||||||
|
| 操作 | 操作按钮和启用/禁用开关 |
|
||||||
|
|
||||||
|
### 定时消息操作
|
||||||
|
|
||||||
|
#### 查看日志
|
||||||
|
|
||||||
|
1. 点击定时消息的"日志"按钮
|
||||||
|
2. 查看该定时消息的执行记录
|
||||||
|
3. 可以查看发送成功/失败的详细信息
|
||||||
|
|
||||||
|
#### 编辑定时消息
|
||||||
|
|
||||||
|
1. 点击定时消息的"编辑"按钮
|
||||||
|
2. 在弹出的对话框中修改信息
|
||||||
|
3. 可以点击"立即发送"测试发送
|
||||||
|
4. 点击"更新定时消息"保存修改
|
||||||
|
|
||||||
|
#### 删除定时消息
|
||||||
|
|
||||||
|
1. 点击定时消息的"删除"按钮
|
||||||
|
2. 确认删除操作
|
||||||
|
3. 定时消息将被删除,不再执行
|
||||||
|
|
||||||
|
#### 启用/禁用定时消息
|
||||||
|
|
||||||
|
- 点击定时消息行的开关按钮
|
||||||
|
- 禁用的定时消息不会自动执行
|
||||||
|
- 可以随时重新启用
|
||||||
|
|
||||||
|
#### 立即发送
|
||||||
|
|
||||||
|
在编辑定时消息时,可以点击"立即发送"按钮立即执行一次发送,用于测试定时消息配置是否正确。
|
||||||
|
|
||||||
|
### 搜索定时消息
|
||||||
|
|
||||||
|
在定时消息列表页面的搜索框中输入任务名称,可以快速筛选定时消息。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景 1:每日报表推送
|
||||||
|
|
||||||
|
**需求:** 每天早上9点自动发送前一天的业务报表
|
||||||
|
|
||||||
|
**配置:**
|
||||||
|
- 定时消息名称:每日业务报表
|
||||||
|
- 关联发信任务:报表推送任务
|
||||||
|
- Cron 表达式:`0 9 * * *`
|
||||||
|
- 标题:每日业务报表
|
||||||
|
- 内容:前一天的业务数据统计
|
||||||
|
|
||||||
|
### 场景 2:周会提醒
|
||||||
|
|
||||||
|
**需求:** 每周一上午9点提醒团队周会
|
||||||
|
|
||||||
|
**配置:**
|
||||||
|
- 定时消息名称:周会提醒
|
||||||
|
- 关联发信任务:团队通知任务
|
||||||
|
- Cron 表达式:`0 9 * * 1`
|
||||||
|
- 标题:周会提醒
|
||||||
|
- 内容:今天上午10点周会,请准时参加
|
||||||
|
|
||||||
|
### 场景 3:系统巡检
|
||||||
|
|
||||||
|
**需求:** 每小时检查系统状态并发送报告
|
||||||
|
|
||||||
|
**配置:**
|
||||||
|
- 定时消息名称:系统巡检
|
||||||
|
- 关联发信任务:系统告警任务
|
||||||
|
- Cron 表达式:`0 * * * *`
|
||||||
|
- 标题:系统巡检报告
|
||||||
|
- 内容:系统运行正常
|
||||||
|
|
||||||
|
### 场景 4:月度账单
|
||||||
|
|
||||||
|
**需求:** 每月1号凌晨发送上月账单
|
||||||
|
|
||||||
|
**配置:**
|
||||||
|
- 定时消息名称:月度账单
|
||||||
|
- 关联发信任务:账单推送任务
|
||||||
|
- Cron 表达式:`0 2 1 * *`
|
||||||
|
- 标题:月度账单
|
||||||
|
- 内容:上月账单详情
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[创建定时消息] --> B[设置 Cron 表达式]
|
||||||
|
B --> C[关联发信任务]
|
||||||
|
C --> D[启用定时消息]
|
||||||
|
D --> E[系统定时检查]
|
||||||
|
E --> F{到达执行时间?}
|
||||||
|
F -->|是| G[调用发信任务]
|
||||||
|
F -->|否| E
|
||||||
|
G --> H[发送消息]
|
||||||
|
H --> I[记录日志]
|
||||||
|
I --> E
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. Cron 表达式设置
|
||||||
|
|
||||||
|
- ✅ 使用系统提供的常用模板
|
||||||
|
- ✅ 避免设置过于频繁的执行(如每分钟)
|
||||||
|
- ✅ 选择系统低峰期执行(如凌晨)
|
||||||
|
- ✅ 测试 Cron 表达式是否正确
|
||||||
|
|
||||||
|
### 2. 发信任务配置
|
||||||
|
|
||||||
|
- ✅ 确保关联的发信任务已配置实例
|
||||||
|
- ✅ 测试发信任务是否能正常发送
|
||||||
|
- ✅ 为重要消息配置多个渠道
|
||||||
|
- ✅ 使用有意义的任务名称
|
||||||
|
|
||||||
|
### 3. 消息内容
|
||||||
|
|
||||||
|
- ✅ 标题简洁明了
|
||||||
|
- ✅ 内容清晰完整
|
||||||
|
- ✅ 提供必要的链接(url 字段)
|
||||||
|
- ✅ 使用立即发送测试内容
|
||||||
|
|
||||||
|
### 4. 监控和维护
|
||||||
|
|
||||||
|
- ✅ 定期查看执行日志
|
||||||
|
- ✅ 及时处理发送失败的情况
|
||||||
|
- ✅ 禁用不再需要的定时消息
|
||||||
|
- ✅ 定期检查下次执行时间是否正确
|
||||||
|
|
||||||
|
### 5. 性能优化
|
||||||
|
|
||||||
|
- ✅ 避免在同一时间执行大量定时任务
|
||||||
|
- ✅ 合理设置执行频率
|
||||||
|
- ✅ 对于大量消息,考虑分批发送
|
||||||
|
- ✅ 监控系统资源使用情况
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 定时消息没有执行怎么办?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 检查定时消息是否已启用(开关是否打开)
|
||||||
|
2. 检查 Cron 表达式是否正确
|
||||||
|
3. 查看下次执行时间是否符合预期
|
||||||
|
4. 检查关联的发信任务是否配置了实例
|
||||||
|
5. 查看日志是否有错误信息
|
||||||
|
|
||||||
|
### Q: 如何测试定时消息配置是否正确?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 编辑定时消息
|
||||||
|
2. 点击"立即发送"按钮
|
||||||
|
3. 查看是否成功发送
|
||||||
|
4. 检查日志中的发送记录
|
||||||
|
|
||||||
|
### Q: Cron 表达式如何设置?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 使用系统提供的常用模板
|
||||||
|
2. 参考 Cron 表达式格式说明
|
||||||
|
3. 使用在线 Cron 表达式生成器
|
||||||
|
4. 通过立即发送测试是否正确
|
||||||
|
|
||||||
|
### Q: 定时消息发送失败怎么办?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 查看日志中的错误信息
|
||||||
|
2. 检查关联的发信任务配置
|
||||||
|
3. 确认发信任务的渠道是否正常
|
||||||
|
4. 检查消息内容是否符合要求
|
||||||
|
5. 尝试立即发送测试
|
||||||
|
|
||||||
|
### Q: 可以暂停定时消息吗?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
可以。点击定时消息行的开关按钮禁用,定时消息将不再自动执行。需要时可以重新启用。
|
||||||
|
|
||||||
|
### Q: 定时消息和发信任务有什么关系?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
定时消息需要关联一个发信任务,定时消息只负责定时触发,实际的消息发送由关联的发信任务完成。发信任务决定了消息发送到哪些渠道。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
::: warning 重要提示
|
||||||
|
1. **关联任务配置** - 确保关联的发信任务已配置至少一个发送实例
|
||||||
|
2. **Cron 表达式** - 设置前请仔细检查,避免执行时间错误
|
||||||
|
3. **执行频率** - 避免设置过于频繁的执行,影响系统性能
|
||||||
|
4. **消息内容** - 定时消息的内容是固定的,不支持动态内容
|
||||||
|
5. **启用状态** - 创建后记得启用定时消息,否则不会执行
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [发送任务](/guide/tasks) 了解如何配置发信任务
|
||||||
|
- 查看 [V1 API 文档](/api/v1) 了解消息发送机制
|
||||||
|
- 查看 [渠道配置](/guide/channels) 了解如何配置推送渠道
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
# 自托管消息
|
||||||
|
|
||||||
|
自托管消息功能将 Message Nest 作为消息接收平台,用户登录站点查看消息。
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
**核心定位:** 将 Message Nest 站点作为消息接收和展示平台,而不是推送到外部渠道。
|
||||||
|
|
||||||
|
**主要功能:**
|
||||||
|
- ✅ 站点作为消息接收平台
|
||||||
|
- ✅ 用户登录后台查看消息
|
||||||
|
- ✅ 搜索消息
|
||||||
|
- ✅ 查看消息详情
|
||||||
|
|
||||||
|
**与其他渠道的区别:**
|
||||||
|
- **邮件/钉钉/企业微信** - 推送到外部平台,用户在对应平台查看
|
||||||
|
- **自托管消息** - 存储在 Message Nest 站点,用户登录站点查看
|
||||||
|
|
||||||
|
## 配置自托管消息渠道
|
||||||
|
|
||||||
|
### 创建渠道
|
||||||
|
|
||||||
|
1. 登录管理后台
|
||||||
|
2. 进入"推送渠道"页面
|
||||||
|
3. 点击"新建渠道"
|
||||||
|
4. 选择渠道类型:"自托管消息"
|
||||||
|
5. 填写配置信息
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 | 默认值 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | 是 | - |
|
||||||
|
| 渠道描述 | 渠道用途说明 | 否 | - |
|
||||||
|
|
||||||
|
### 配置示例
|
||||||
|
|
||||||
|
```
|
||||||
|
渠道名称:站内通知
|
||||||
|
渠道描述:用户站内消息通知
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用自托管消息
|
||||||
|
|
||||||
|
### 方式一:通过任务发送
|
||||||
|
|
||||||
|
1. 创建发送任务
|
||||||
|
2. 添加自托管消息渠道实例
|
||||||
|
3. 通过 V1 API 发送消息
|
||||||
|
|
||||||
|
**API 示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v1/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "your_task_token",
|
||||||
|
"title": "系统通知",
|
||||||
|
"text": "您有一条新消息",
|
||||||
|
"html": "<h2>系统通知</h2><p>您有一条新消息</p>",
|
||||||
|
"markdown": "## 系统通知\n\n您有一条新消息"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:通过模板发送(推荐)
|
||||||
|
|
||||||
|
1. 创建消息模板
|
||||||
|
2. 为模板添加自托管消息实例
|
||||||
|
3. 通过 V2 API 发送消息
|
||||||
|
|
||||||
|
**API 示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v2/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "your_template_token",
|
||||||
|
"title": "订单通知",
|
||||||
|
"placeholders": {
|
||||||
|
"order_id": "20241206001",
|
||||||
|
"status": "已发货"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 查看消息
|
||||||
|
|
||||||
|
### 消息列表
|
||||||
|
|
||||||
|
1. 登录管理后台
|
||||||
|
2. 进入"自托管消息"或"消息中心"页面
|
||||||
|
3. 查看消息列表
|
||||||
|
|
||||||
|
**列表信息:**
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 状态 | 已读/未读标识 |
|
||||||
|
| 标题 | 消息标题 |
|
||||||
|
| 来源 | 发送任务或模板名称 |
|
||||||
|
| 接收时间 | 消息接收时间 |
|
||||||
|
| 操作 | 查看详情、标记已读、删除 |
|
||||||
|
|
||||||
|
### 消息筛选
|
||||||
|
|
||||||
|
支持多种筛选条件:
|
||||||
|
|
||||||
|
- **状态筛选** - 全部/未读/已读
|
||||||
|
- **时间筛选** - 今天/最近7天/最近30天/自定义
|
||||||
|
- **来源筛选** - 按任务或模板筛选
|
||||||
|
- **关键词搜索** - 搜索标题或内容
|
||||||
|
|
||||||
|
**筛选示例:**
|
||||||
|
|
||||||
|
```
|
||||||
|
状态:未读
|
||||||
|
时间:最近7天
|
||||||
|
来源:订单通知模板
|
||||||
|
关键词:发货
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看详情
|
||||||
|
|
||||||
|
点击消息标题或"查看"按钮查看消息详情:
|
||||||
|
|
||||||
|
**详情页面包含:**
|
||||||
|
- 消息标题
|
||||||
|
- 发送时间
|
||||||
|
- 来源信息
|
||||||
|
- 消息内容(根据格式展示)
|
||||||
|
- 操作按钮(标记已读、删除)
|
||||||
|
|
||||||
|
**内容展示:**
|
||||||
|
- **Text** - 纯文本展示
|
||||||
|
- **HTML** - 富文本渲染
|
||||||
|
- **Markdown** - Markdown 渲染
|
||||||
|
|
||||||
|
### 消息操作
|
||||||
|
|
||||||
|
#### 标记已读/未读
|
||||||
|
|
||||||
|
- **单个标记** - 点击消息的"标记已读"按钮
|
||||||
|
- **批量标记** - 选择多条消息,点击"批量标记已读"
|
||||||
|
- **全部已读** - 点击"全部标记为已读"
|
||||||
|
|
||||||
|
#### 删除消息
|
||||||
|
|
||||||
|
- **单个删除** - 点击消息的"删除"按钮
|
||||||
|
- **批量删除** - 选择多条消息,点击"批量删除"
|
||||||
|
- **清空消息** - 点击"清空全部消息"(谨慎操作)
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
删除操作不可恢复,请谨慎操作!
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 消息格式
|
||||||
|
|
||||||
|
### Text 格式
|
||||||
|
|
||||||
|
纯文本格式,适用于简单通知。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
您的订单 20241206001 已发货。
|
||||||
|
|
||||||
|
快递公司:顺丰速运
|
||||||
|
快递单号:SF1234567890
|
||||||
|
预计送达:2024-12-08
|
||||||
|
|
||||||
|
如有问题请联系客服。
|
||||||
|
```
|
||||||
|
|
||||||
|
**展示效果:** 保持原始换行和格式
|
||||||
|
|
||||||
|
### HTML 格式
|
||||||
|
|
||||||
|
HTML 格式,支持富文本样式。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="padding: 20px; background: #f5f5f5;">
|
||||||
|
<h2 style="color: #333;">订单发货通知</h2>
|
||||||
|
<p>您的订单 <strong>20241206001</strong> 已发货。</p>
|
||||||
|
<table style="width: 100%; margin: 10px 0;">
|
||||||
|
<tr>
|
||||||
|
<td>快递公司:</td>
|
||||||
|
<td>顺丰速运</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>快递单号:</td>
|
||||||
|
<td>SF1234567890</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="color: #999;">如有问题请联系客服。</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**展示效果:** 完整的 HTML 渲染,支持样式和布局
|
||||||
|
|
||||||
|
### Markdown 格式
|
||||||
|
|
||||||
|
Markdown 格式,支持格式化文本。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 订单发货通知
|
||||||
|
|
||||||
|
您的订单 **20241206001** 已发货。
|
||||||
|
|
||||||
|
| 项目 | 信息 |
|
||||||
|
|------|------|
|
||||||
|
| 快递公司 | 顺丰速运 |
|
||||||
|
| 快递单号 | SF1234567890 |
|
||||||
|
| 预计送达 | 2024-12-08 |
|
||||||
|
|
||||||
|
> 如有问题请联系客服。
|
||||||
|
```
|
||||||
|
|
||||||
|
**展示效果:** Markdown 渲染,支持标题、表格、引用等
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景 1:站内消息中心
|
||||||
|
|
||||||
|
**需求:** 将 Message Nest 作为网站/应用的消息中心
|
||||||
|
|
||||||
|
**实现方案:**
|
||||||
|
|
||||||
|
1. 创建"用户通知"模板
|
||||||
|
2. 添加自托管消息渠道实例
|
||||||
|
3. 在用户操作时调用 API 发送消息到自托管渠道
|
||||||
|
4. 用户登录 Message Nest 站点查看消息
|
||||||
|
|
||||||
|
**典型应用:**
|
||||||
|
- 系统公告发布
|
||||||
|
- 账号安全提醒
|
||||||
|
- 订单状态更新
|
||||||
|
- 活动通知推送
|
||||||
|
|
||||||
|
**优势:**
|
||||||
|
- 无需开发消息中心功能
|
||||||
|
- 直接使用 Message Nest 作为消息平台
|
||||||
|
- 用户登录即可查看所有消息
|
||||||
|
|
||||||
|
### 场景 2:内部工作流通知
|
||||||
|
|
||||||
|
**需求:** 团队内部的工作流通知和待办事项集中管理
|
||||||
|
|
||||||
|
**实现方案:**
|
||||||
|
|
||||||
|
1. 创建不同类型的通知模板(审批、任务、提醒)
|
||||||
|
2. 配置自托管消息渠道
|
||||||
|
3. 工作流触发时发送消息到自托管渠道
|
||||||
|
4. 团队成员登录 Message Nest 站点查看待办
|
||||||
|
|
||||||
|
**典型应用:**
|
||||||
|
- 审批请求通知
|
||||||
|
- 任务分配提醒
|
||||||
|
- 会议提醒
|
||||||
|
- 进度更新通知
|
||||||
|
|
||||||
|
**优势:**
|
||||||
|
- 所有工作通知集中在一个平台
|
||||||
|
- 团队成员统一登录查看
|
||||||
|
- 避免消息分散在多个渠道
|
||||||
|
|
||||||
|
### 场景 3:系统告警记录平台
|
||||||
|
|
||||||
|
**需求:** 将 Message Nest 作为系统告警的集中查看平台
|
||||||
|
|
||||||
|
**实现方案:**
|
||||||
|
|
||||||
|
1. 创建"系统告警"模板
|
||||||
|
2. 配置自托管消息渠道(同时可配置钉钉/邮件用于即时通知)
|
||||||
|
3. 系统监控触发告警时发送到自托管渠道
|
||||||
|
4. 管理员登录 Message Nest 站点查看和管理告警
|
||||||
|
|
||||||
|
**典型应用:**
|
||||||
|
- 服务异常告警
|
||||||
|
- 资源使用告警
|
||||||
|
- 安全事件告警
|
||||||
|
- 性能告警
|
||||||
|
|
||||||
|
**优势:**
|
||||||
|
- 所有告警集中存储在平台
|
||||||
|
- 便于历史告警查询和分析
|
||||||
|
- 支持搜索和筛选功能
|
||||||
|
- 可导出告警数据
|
||||||
|
|
||||||
|
### 场景 4:消息归档平台
|
||||||
|
|
||||||
|
**需求:** 将 Message Nest 作为所有消息的归档和查询平台
|
||||||
|
|
||||||
|
**实现方案:**
|
||||||
|
|
||||||
|
1. 为所有任务/模板添加自托管消息渠道实例
|
||||||
|
2. 所有消息都会存储在 Message Nest 站点
|
||||||
|
3. 用户登录站点查看历史消息
|
||||||
|
4. 定期导出归档数据
|
||||||
|
|
||||||
|
**典型应用:**
|
||||||
|
- 所有发送消息的完整记录
|
||||||
|
- 消息审计和追溯
|
||||||
|
- 历史消息查询
|
||||||
|
- 数据分析和统计
|
||||||
|
|
||||||
|
**优势:**
|
||||||
|
- Message Nest 成为消息归档中心
|
||||||
|
- 所有消息集中存储和管理
|
||||||
|
- 支持强大的搜索和筛选
|
||||||
|
- 便于导出和分析
|
||||||
|
|
||||||
|
## 消息管理
|
||||||
|
|
||||||
|
### 手动清理
|
||||||
|
|
||||||
|
可以手动清理消息以释放存储空间。
|
||||||
|
|
||||||
|
**清理选项:**
|
||||||
|
|
||||||
|
- **清理已读消息** - 删除所有已读消息
|
||||||
|
- **清理过期消息** - 删除指定天数前的消息
|
||||||
|
- **清空全部消息** - 删除所有消息(谨慎操作)
|
||||||
|
|
||||||
|
**步骤:**
|
||||||
|
|
||||||
|
1. 进入"自托管消息"页面
|
||||||
|
2. 点击"清理"按钮
|
||||||
|
3. 选择清理选项
|
||||||
|
4. 确认操作
|
||||||
|
5. 等待清理完成
|
||||||
|
|
||||||
|
### 消息导出
|
||||||
|
|
||||||
|
导出消息数据用于备份或分析。
|
||||||
|
|
||||||
|
**导出格式:**
|
||||||
|
- CSV - 适合 Excel 分析
|
||||||
|
- JSON - 适合程序处理
|
||||||
|
- PDF - 适合打印存档
|
||||||
|
|
||||||
|
**导出步骤:**
|
||||||
|
|
||||||
|
1. 进入"自托管消息"页面
|
||||||
|
2. 设置筛选条件(可选)
|
||||||
|
3. 点击"导出"按钮
|
||||||
|
4. 选择导出格式
|
||||||
|
5. 下载导出文件
|
||||||
|
|
||||||
|
## 消息统计
|
||||||
|
|
||||||
|
查看消息的统计数据和趋势。
|
||||||
|
|
||||||
|
### 统计指标
|
||||||
|
|
||||||
|
| 指标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 总消息数 | 累计接收的消息总数 |
|
||||||
|
| 未读消息 | 当前未读消息数量 |
|
||||||
|
| 今日新增 | 今天接收的消息数 |
|
||||||
|
| 平均响应时间 | 从接收到已读的平均时间 |
|
||||||
|
| 消息来源分布 | 各任务/模板的消息占比 |
|
||||||
|
|
||||||
|
### 趋势图表
|
||||||
|
|
||||||
|
- **消息趋势** - 最近 30 天的消息接收趋势
|
||||||
|
- **来源分布** - 各来源的消息数量饼图
|
||||||
|
- **已读率** - 消息已读率变化趋势
|
||||||
|
|
||||||
|
### 查看统计
|
||||||
|
|
||||||
|
1. 进入"自托管消息"页面
|
||||||
|
2. 点击"统计"按钮
|
||||||
|
3. 查看各项统计数据
|
||||||
|
4. 可导出统计报表
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 消息分类
|
||||||
|
|
||||||
|
- **按重要性分类** - 重要/一般/提示
|
||||||
|
- **按类型分类** - 通知/告警/提醒
|
||||||
|
- **使用不同模板** - 便于筛选和管理
|
||||||
|
|
||||||
|
### 2. 内容设计
|
||||||
|
|
||||||
|
- **标题简洁明了** - 让用户快速了解消息内容
|
||||||
|
- **内容结构清晰** - 使用标题、列表、表格等
|
||||||
|
- **提供操作入口** - 添加相关链接或按钮
|
||||||
|
- **适配多种格式** - 提供 Text/HTML/Markdown
|
||||||
|
|
||||||
|
### 3. 存储管理
|
||||||
|
|
||||||
|
- **定期手动清理** - 定期清理已读或过期消息,释放存储空间
|
||||||
|
- **重要消息导出备份** - 避免数据丢失
|
||||||
|
- **监控存储使用情况** - 及时清理不需要的消息
|
||||||
|
|
||||||
|
### 4. 用户体验
|
||||||
|
|
||||||
|
- **及时标记已读** - 保持消息列表整洁
|
||||||
|
- **使用筛选功能** - 快速找到需要的消息
|
||||||
|
- **定期查看消息** - 避免遗漏重要通知
|
||||||
|
- **合理设置通知** - 避免消息过载
|
||||||
|
|
||||||
|
### 5. 安全性
|
||||||
|
|
||||||
|
- **权限控制** - 确保只有授权用户可以查看
|
||||||
|
- **敏感信息加密** - 对敏感内容进行加密
|
||||||
|
- **定期审计** - 检查消息访问日志
|
||||||
|
- **数据备份** - 定期备份重要消息
|
||||||
|
|
||||||
|
## 与其他渠道对比
|
||||||
|
|
||||||
|
| 特性 | 自托管消息 | 邮件 | 钉钉/企业微信 |
|
||||||
|
|------|-----------|------|--------------|
|
||||||
|
| 实时性 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||||
|
| 到达率 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||||
|
| 格式支持 | Text/HTML/Markdown | Text/HTML | Text/Markdown |
|
||||||
|
| 历史查询 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
|
||||||
|
| 成本 | 免费 | 免费/付费 | 免费 |
|
||||||
|
| 依赖 | 无 | SMTP 服务器 | 钉钉/企业微信 |
|
||||||
|
| 适用场景 | 站内通知、归档 | 正式通知、账单 | 即时通知、协作 |
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 自托管消息和其他渠道有什么区别?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- **自托管消息** - 存储在系统内,需要登录后台查看
|
||||||
|
- **其他渠道** - 推送到外部平台(邮件、钉钉等),用户在对应平台查看
|
||||||
|
|
||||||
|
### Q: 可以同时使用自托管消息和其他渠道吗?
|
||||||
|
|
||||||
|
**A:** 可以。为任务或模板同时配置多个渠道实例,消息会发送到所有渠道。
|
||||||
|
|
||||||
|
### Q: 消息会永久保存吗?
|
||||||
|
|
||||||
|
**A:** 消息会一直保存,不会自动清理。建议定期手动清理不需要的消息,并导出重要消息备份。
|
||||||
|
|
||||||
|
### Q: 如何提高消息的查看率?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 配合其他渠道(如邮件、钉钉)提醒用户
|
||||||
|
2. 在应用中显示未读消息数量
|
||||||
|
3. 提供消息通知功能
|
||||||
|
4. 定期提醒用户查看
|
||||||
|
|
||||||
|
### Q: 消息被误删了怎么办?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- 如果有备份,可以从备份恢复
|
||||||
|
- 如果配置了其他渠道,可以从其他渠道查看
|
||||||
|
- 建议定期导出重要消息
|
||||||
|
|
||||||
|
### Q: 如何在应用中集成消息中心?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 调用 API 发送消息到自托管渠道
|
||||||
|
2. 提供登录入口到 Message Nest 后台
|
||||||
|
3. 或者通过 API 查询消息列表(需要开发)
|
||||||
|
4. 在应用中展示消息
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
::: warning 重要提示
|
||||||
|
1. **存储空间** - 消息不会自动清理,注意定期手动清理,避免占用过多存储空间
|
||||||
|
2. **性能影响** - 大量消息可能影响查询性能,建议定期清理已读或过期消息
|
||||||
|
3. **权限控制** - 确保消息只能被授权用户查看
|
||||||
|
4. **数据备份** - 重要消息建议定期导出备份
|
||||||
|
5. **手动清理** - 删除操作不可恢复,请谨慎操作
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [渠道配置](/guide/channels) 了解如何配置自托管消息渠道
|
||||||
|
- 查看 [消息模板](/guide/template) 了解如何创建模板
|
||||||
|
- 查看 [V2 API 文档](/api/v2) 了解如何发送消息
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 自托管消息
|
||||||
|
|
||||||
|
自托管消息功能将 Message Nest 作为消息接收平台,用户登录站点查看消息。
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
**核心定位:** 将 Message Nest 站点作为消息接收和展示平台,而不是推送到外部渠道。
|
||||||
|
|
||||||
|
**主要功能:**
|
||||||
|
- ✅ 站点作为消息接收平台
|
||||||
|
- ✅ 用户登录后台查看消息
|
||||||
|
- ✅ 搜索消息
|
||||||
|
- ✅ 查看消息详情
|
||||||
|
|
||||||
|
**与其他渠道的区别:**
|
||||||
|
- **邮件/钉钉/企业微信** - 推送到外部平台,用户在对应平台查看
|
||||||
|
- **自托管消息** - 存储在 Message Nest 站点,用户登录站点查看
|
||||||
|
|
||||||
|
## 配置自托管消息渠道
|
||||||
|
|
||||||
|
### 创建渠道
|
||||||
|
|
||||||
|
1. 登录管理后台
|
||||||
|
2. 进入"推送渠道"页面
|
||||||
|
3. 点击"新建渠道"
|
||||||
|
4. 选择渠道类型:"自托管消息"
|
||||||
|
5. 填写渠道名称和描述
|
||||||
|
6. 保存渠道
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 渠道名称 | 自定义渠道名称 | 是 |
|
||||||
|
| 渠道描述 | 渠道用途说明 | 否 |
|
||||||
|
|
||||||
|
## 使用自托管消息
|
||||||
|
|
||||||
|
### 方式一:通过任务发送
|
||||||
|
|
||||||
|
1. 创建发送任务
|
||||||
|
2. 添加自托管消息渠道实例
|
||||||
|
3. 通过 V1 API 发送消息
|
||||||
|
|
||||||
|
**API 示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v1/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "your_task_token",
|
||||||
|
"title": "系统通知",
|
||||||
|
"text": "您有一条新消息"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:通过模板发送(推荐)
|
||||||
|
|
||||||
|
1. 创建消息模板
|
||||||
|
2. 为模板添加自托管消息实例
|
||||||
|
3. 通过 V2 API 发送消息
|
||||||
|
|
||||||
|
**API 示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v2/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "your_template_token",
|
||||||
|
"title": "订单通知",
|
||||||
|
"placeholders": {
|
||||||
|
"order_id": "20241206001"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 查看消息
|
||||||
|
|
||||||
|
### 消息列表
|
||||||
|
|
||||||
|
1. 登录管理后台
|
||||||
|
2. 进入"托管消息"页面
|
||||||
|
3. 查看消息列表
|
||||||
|
|
||||||
|
**列表信息:**
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| ID | 消息 ID |
|
||||||
|
| 消息标题 | 消息标题(可点击查看完整内容) |
|
||||||
|
| 消息内容 | 消息内容(可点击查看完整内容) |
|
||||||
|
| 创建时间 | 消息接收时间 |
|
||||||
|
| 详情 | 查看按钮 |
|
||||||
|
|
||||||
|
### 搜索消息
|
||||||
|
|
||||||
|
在消息列表页面的搜索框中输入关键词,可以搜索消息标题和内容。
|
||||||
|
|
||||||
|
### 查看详情
|
||||||
|
|
||||||
|
点击消息行的"查看"按钮,在侧边栏中查看消息完整信息:
|
||||||
|
|
||||||
|
- 标题
|
||||||
|
- 内容
|
||||||
|
- URL(如果有)
|
||||||
|
- 创建时间
|
||||||
|
- 修改时间
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景 1:站内消息中心
|
||||||
|
|
||||||
|
将 Message Nest 作为网站/应用的消息中心,用户登录站点查看消息。
|
||||||
|
|
||||||
|
**适用于:**
|
||||||
|
- 系统公告
|
||||||
|
- 用户通知
|
||||||
|
- 订单状态更新
|
||||||
|
|
||||||
|
### 场景 2:内部通知平台
|
||||||
|
|
||||||
|
团队内部的工作通知集中管理,成员登录站点查看。
|
||||||
|
|
||||||
|
**适用于:**
|
||||||
|
- 审批通知
|
||||||
|
- 任务分配
|
||||||
|
- 会议提醒
|
||||||
|
|
||||||
|
### 场景 3:消息归档
|
||||||
|
|
||||||
|
所有发送的消息都存储在站点,便于查询和追溯。
|
||||||
|
|
||||||
|
**适用于:**
|
||||||
|
- 消息记录
|
||||||
|
- 审计追溯
|
||||||
|
- 历史查询
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
::: warning 重要提示
|
||||||
|
1. **存储空间** - 消息不会自动清理,注意定期手动清理
|
||||||
|
2. **查看方式** - 用户需要登录 Message Nest 站点才能查看消息
|
||||||
|
3. **消息格式** - 支持 Text、HTML、Markdown 格式
|
||||||
|
4. **权限控制** - 确保只有授权用户可以登录查看
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [渠道配置](/guide/channels) 了解如何配置自托管消息渠道
|
||||||
|
- 查看 [消息模板](/guide/template) 了解如何创建模板
|
||||||
|
- 查看 [V2 API 文档](/api/v2) 了解如何发送消息
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# 系统设置
|
||||||
|
|
||||||
|
系统设置页面允许您配置 Message Nest 的各项参数,包括站点信息、日志清理、密码管理等。
|
||||||
|
|
||||||
|
## 站点设置
|
||||||
|
|
||||||
|
自定义站点的基本信息和显示参数。
|
||||||
|
|
||||||
|
### 配置项
|
||||||
|
|
||||||
|
| 设置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 站点标题 | 显示在浏览器标题和页面顶部 | `Message Nest` |
|
||||||
|
| 站点标语 | 显示在登录页面的标语 | `消息推送整合平台` |
|
||||||
|
| 站点图标 | 网站 Logo(仅支持 SVG 文本) | 默认 Logo |
|
||||||
|
| 分页大小 | 列表页面每页显示的数据条数 | `10` |
|
||||||
|
| Cookie 过期天数 | 用户登录状态保持时间(天) | `1` |
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 企业内部部署,使用企业品牌
|
||||||
|
- ✅ 个性化定制,提升用户体验
|
||||||
|
- ✅ 统一品牌形象
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
1. 登录管理后台
|
||||||
|
2. 进入"系统设置" → "站点设置"
|
||||||
|
3. 填写或修改相关信息
|
||||||
|
4. 保存设置
|
||||||
|
5. 下次登录时生效(如不生效,在登录页面 Ctrl+F5 强制刷新)
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
- **站点图标**:仅支持 SVG 文本格式,将替换网页 ico、登录页面 logo、导航栏 logo
|
||||||
|
- **站点标语**:将在登录页面展示
|
||||||
|
- **Cookie 过期天数**:设置用户登录后的有效期,修改后下次登录时生效
|
||||||
|
- **分页大小**:影响所有列表页面的显示数量
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 重置密码
|
||||||
|
|
||||||
|
修改当前用户的登录密码。
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
1. 进入"系统设置" → "重置密码"
|
||||||
|
2. 输入当前密码
|
||||||
|
3. 输入新密码
|
||||||
|
4. 确认新密码
|
||||||
|
5. 点击"确定"保存
|
||||||
|
|
||||||
|
::: warning 安全建议
|
||||||
|
- 使用强密码(包含大小写字母、数字、特殊字符)
|
||||||
|
- 定期更换密码(建议 3 个月)
|
||||||
|
- 不要使用常见密码
|
||||||
|
- 不要与其他系统使用相同密码
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 日志清理
|
||||||
|
|
||||||
|
配置定时日志清除和保留策略。
|
||||||
|
|
||||||
|
### 配置项
|
||||||
|
|
||||||
|
| 设置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 定时清除 Cron 表达式 | 定时清理日志的时间规则 | `0 1 * * *`(每天凌晨 1 点) |
|
||||||
|
| 保留日志条数 | 保留最近的日志数量 | `1000` |
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
1. 进入"系统设置" → "日志清理"
|
||||||
|
2. 设置 Cron 表达式(可选)
|
||||||
|
3. 设置保留日志条数(可选)
|
||||||
|
4. 点击"确定"保存
|
||||||
|
5. 点击"查看日志"可以查看清理日志
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
- **Cron 表达式**:如果不设置,默认是在每天的 0 点 1 分进行清理
|
||||||
|
- **保留数目**:如果不设置,默认保留最近 1000 条
|
||||||
|
- 清理任务会自动执行,删除超出保留数量的旧日志
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Cron 表达式示例
|
||||||
|
|
||||||
|
```
|
||||||
|
0 1 * * * # 每天凌晨 1 点
|
||||||
|
0 */6 * * * # 每 6 小时
|
||||||
|
0 0 * * 0 # 每周日凌晨
|
||||||
|
0 2 1 * * # 每月 1 号凌晨 2 点
|
||||||
|
```
|
||||||
|
|
||||||
|
## 登录日志
|
||||||
|
|
||||||
|
查看系统的登录历史记录。
|
||||||
|
|
||||||
|
### 查看方式
|
||||||
|
|
||||||
|
1. 进入"系统设置" → "登录日志"
|
||||||
|
2. 查看登录记录列表
|
||||||
|
|
||||||
|
### 记录内容
|
||||||
|
|
||||||
|
- 登录时间
|
||||||
|
- 登录 IP 地址
|
||||||
|
- 登录状态(成功/失败)
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- ✅ 安全审计
|
||||||
|
- ✅ 异常登录检测
|
||||||
|
- ✅ 用户行为分析
|
||||||
|
|
||||||
|
## 站点关于
|
||||||
|
|
||||||
|
查看系统的版本信息和运行状态。
|
||||||
|
|
||||||
|
### 系统信息
|
||||||
|
|
||||||
|
| 信息项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 系统版本 | Message Nest 版本号 |
|
||||||
|
| 构建时间 | 系统构建时间 |
|
||||||
|
| 内存使用 | 当前内存使用情况 |
|
||||||
|
| 运行时间 | 系统已运行时长 |
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
- Golang
|
||||||
|
- Vue 3
|
||||||
|
- TypeScript
|
||||||
|
- Vite
|
||||||
|
- Tailwind CSS
|
||||||
|
- Shadcn/ui
|
||||||
|
|
||||||
|
### 功能特性
|
||||||
|
|
||||||
|
- 多渠道消息推送
|
||||||
|
- 定时消息管理
|
||||||
|
- 托管消息服务
|
||||||
|
- 发信日志追踪
|
||||||
|
- 渠道配置管理
|
||||||
|
- 站点信息配置
|
||||||
|
|
||||||
|
### 版本日志
|
||||||
|
|
||||||
|
点击"查看更新日志"按钮可以查看系统的版本更新历史。
|
||||||
|
|
||||||
|
### 查看方式
|
||||||
|
|
||||||
|
1. 进入"系统设置" → "站点关于"
|
||||||
|
2. 查看系统信息和技术栈
|
||||||
|
3. 点击"查看更新日志"查看版本历史
|
||||||
|
4. 点击"GitHub 仓库"访问项目主页
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 安全配置
|
||||||
|
|
||||||
|
- ✅ 使用强密码
|
||||||
|
- ✅ 定期更换密码(建议 3 个月)
|
||||||
|
- ✅ 定期查看登录日志,检查异常登录
|
||||||
|
- ✅ 合理设置 Cookie 过期天数
|
||||||
|
|
||||||
|
### 2. 日志管理
|
||||||
|
|
||||||
|
- ✅ 合理设置日志保留条数,避免占用过多存储空间
|
||||||
|
- ✅ 根据业务需求调整清理时间(避开高峰期)
|
||||||
|
- ✅ 定期查看清理日志,确认清理正常执行
|
||||||
|
|
||||||
|
### 3. 站点配置
|
||||||
|
|
||||||
|
- ✅ 自定义站点标题和标语,提升品牌形象
|
||||||
|
- ✅ 使用 SVG 格式的 Logo,保证清晰度
|
||||||
|
- ✅ 合理设置分页大小,平衡性能和用户体验
|
||||||
|
- ✅ 修改配置后记得刷新页面查看效果
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 修改站点信息后没有生效?
|
||||||
|
|
||||||
|
**A:** 尝试以下方法:
|
||||||
|
1. 在登录页面强制刷新(Ctrl+F5)
|
||||||
|
2. 清除浏览器缓存
|
||||||
|
3. 检查是否保存成功
|
||||||
|
4. 下次登录时生效
|
||||||
|
|
||||||
|
### Q: 忘记密码怎么办?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 如果是 Docker 部署,可以通过环境变量重置
|
||||||
|
2. 如果是直接运行,可以通过数据库直接修改
|
||||||
|
3. 联系系统管理员重置
|
||||||
|
|
||||||
|
### Q: 日志清理任务没有执行?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 检查 Cron 表达式是否正确
|
||||||
|
2. 查看清理日志确认执行情况
|
||||||
|
3. 确认系统时间是否准确
|
||||||
|
4. 检查系统日志是否有错误信息
|
||||||
|
|
||||||
|
### Q: Cookie 过期天数修改后没生效?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
Cookie 过期天数的修改会在下次登录时生效,当前已登录的会话不受影响。
|
||||||
|
|
||||||
|
### Q: 如何查看系统版本?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
进入"系统设置" → "站点关于",可以查看系统版本、构建时间等信息。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [部署文档](/deployment/overview) 了解部署配置
|
||||||
|
- 查看 [渠道配置](/guide/channels) 开始使用系统
|
||||||
|
- 查看 [消息模板](/guide/template) 创建第一个模板
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# 发送任务
|
||||||
|
|
||||||
|
发送任务是 Message Nest 的基础功能,用于配置消息发送的渠道和参数,通过 V1 API 发送消息。
|
||||||
|
|
||||||
|
::: tip 💡 推荐使用模板
|
||||||
|
对于新项目,我们推荐使用 [消息模板](/guide/template) 功能,它提供更好的内容管理和维护体验。发送任务主要用于兼容历史数据。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
发送任务允许您:
|
||||||
|
- ✅ 创建发送任务
|
||||||
|
- ✅ 为任务添加推送渠道实例
|
||||||
|
- ✅ 配置每个实例的参数
|
||||||
|
- ✅ 获取 API Token 用于发送消息
|
||||||
|
- ✅ 查看任务的发送日志
|
||||||
|
|
||||||
|
## 创建任务
|
||||||
|
|
||||||
|
### 步骤
|
||||||
|
|
||||||
|
1. 进入"发送任务"页面
|
||||||
|
2. 点击"新增任务"按钮
|
||||||
|
3. 输入任务名称
|
||||||
|
4. 点击"保存"
|
||||||
|
|
||||||
|
### 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 |
|
||||||
|
|------|------|------|
|
||||||
|
| 任务名称 | 任务的唯一标识名称 | 是 |
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
任务创建后,需要添加推送实例才能发送消息。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 添加推送实例
|
||||||
|
|
||||||
|
为任务添加一个或多个推送渠道实例。
|
||||||
|
|
||||||
|
### 步骤
|
||||||
|
|
||||||
|
1. 在任务列表中点击"编辑"按钮
|
||||||
|
2. 在搜索框中输入渠道名称
|
||||||
|
3. 从下拉列表中选择已创建的推送渠道
|
||||||
|
4. 配置实例参数(根据渠道类型不同)
|
||||||
|
5. 选择消息格式(Text/HTML/Markdown)
|
||||||
|
6. 点击"添加"保存实例
|
||||||
|
|
||||||
|
### 实例配置说明
|
||||||
|
|
||||||
|
不同渠道需要配置不同的参数:
|
||||||
|
|
||||||
|
#### 邮件渠道
|
||||||
|
|
||||||
|
- **收件人邮箱** - 接收邮件的邮箱地址
|
||||||
|
- **消息格式** - Text 或 HTML
|
||||||
|
|
||||||
|
#### 钉钉/企业微信
|
||||||
|
|
||||||
|
- **消息格式** - Text 或 Markdown
|
||||||
|
- **@提醒** - 可选配置@手机号或@所有人
|
||||||
|
|
||||||
|
#### 自定义 Webhook
|
||||||
|
|
||||||
|
- 根据 Webhook 要求配置相应参数
|
||||||
|
|
||||||
|
#### 自托管消息
|
||||||
|
|
||||||
|
- 无需额外配置
|
||||||
|
- **消息格式** - Text、HTML 或 Markdown
|
||||||
|
|
||||||
|
## 管理任务
|
||||||
|
|
||||||
|
### 查看任务列表
|
||||||
|
|
||||||
|
在"发送任务"页面可以看到所有任务:
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| ID | 任务的唯一标识 |
|
||||||
|
| 发信任务名称 | 任务名称 |
|
||||||
|
| 创建时间 | 任务创建时间 |
|
||||||
|
| 更新时间 | 任务最后修改时间 |
|
||||||
|
| 操作/状态 | 操作按钮 |
|
||||||
|
|
||||||
|
### 任务操作
|
||||||
|
|
||||||
|
#### 查看接口
|
||||||
|
|
||||||
|
1. 点击任务的"接口"按钮
|
||||||
|
2. 查看 API Token 和调用示例
|
||||||
|
3. 复制 Token 用于 V1 API 调用
|
||||||
|
|
||||||
|
#### 查看日志
|
||||||
|
|
||||||
|
1. 点击任务的"日志"按钮
|
||||||
|
2. 查看该任务的发送记录
|
||||||
|
3. 可以筛选和搜索日志
|
||||||
|
|
||||||
|
#### 编辑任务
|
||||||
|
|
||||||
|
1. 点击任务的"编辑"按钮
|
||||||
|
2. 在弹出的对话框中管理推送实例
|
||||||
|
3. 可以添加、删除、启用/禁用实例
|
||||||
|
|
||||||
|
#### 删除任务
|
||||||
|
|
||||||
|
1. 点击任务的"删除"按钮
|
||||||
|
2. 确认删除操作
|
||||||
|
3. 任务及其所有实例将被删除
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
删除任务后,该任务的 Token 将失效,无法再使用 V1 API 发送消息。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 管理推送实例
|
||||||
|
|
||||||
|
在编辑任务对话框中可以管理推送实例:
|
||||||
|
|
||||||
|
#### 查看实例列表
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 渠道名称 | 推送渠道的名称 |
|
||||||
|
| 渠道类型 | 渠道类型(邮件、钉钉等) |
|
||||||
|
| 消息格式 | Text/HTML/Markdown |
|
||||||
|
| 状态 | 启用/禁用开关 |
|
||||||
|
| 操作 | 删除按钮 |
|
||||||
|
|
||||||
|
#### 启用/禁用实例
|
||||||
|
|
||||||
|
- 点击实例行的开关按钮
|
||||||
|
- 禁用的实例不会参与消息发送
|
||||||
|
- 可以随时重新启用
|
||||||
|
|
||||||
|
#### 删除实例
|
||||||
|
|
||||||
|
- 点击实例行的"删除"按钮
|
||||||
|
- 确认删除操作
|
||||||
|
- 实例将从任务中移除
|
||||||
|
|
||||||
|
### 搜索任务
|
||||||
|
|
||||||
|
在任务列表页面的搜索框中输入任务名称,可以快速筛选任务。
|
||||||
|
|
||||||
|
## 使用任务发送消息
|
||||||
|
|
||||||
|
### 通过 API 调用
|
||||||
|
|
||||||
|
使用 V1 API 发送消息,详见 [V1 API 文档](/api/v1)。
|
||||||
|
|
||||||
|
**基本示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v1/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "your_task_token",
|
||||||
|
"title": "消息标题",
|
||||||
|
"text": "消息内容"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多格式发送
|
||||||
|
|
||||||
|
可以同时提供多种格式,系统会根据实例配置自动选择:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "your_task_token",
|
||||||
|
"title": "订单通知",
|
||||||
|
"text": "您的订单已发货",
|
||||||
|
"html": "<h2>订单通知</h2><p>您的订单已发货</p>",
|
||||||
|
"markdown": "## 订单通知\n\n您的订单已发货"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### @提醒功能
|
||||||
|
|
||||||
|
对于支持的渠道(钉钉、企业微信),可以使用@提醒:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "your_task_token",
|
||||||
|
"title": "系统告警",
|
||||||
|
"text": "服务器CPU使用率过高",
|
||||||
|
"at_mobiles": ["13800138000"],
|
||||||
|
"at_all": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[创建任务] --> B[添加推送实例]
|
||||||
|
B --> C[配置实例参数]
|
||||||
|
C --> D[获取 Token]
|
||||||
|
D --> E[调用 API]
|
||||||
|
E --> F[系统遍历实例]
|
||||||
|
F --> G[发送到各渠道]
|
||||||
|
G --> H[记录日志]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景 1:多渠道通知
|
||||||
|
|
||||||
|
创建一个任务,配置多个推送实例(邮件 + 钉钉 + 企业微信),一次 API 调用同时推送到所有渠道。
|
||||||
|
|
||||||
|
**适用于:**
|
||||||
|
- 重要系统告警
|
||||||
|
- 关键业务通知
|
||||||
|
- 需要多渠道触达的消息
|
||||||
|
|
||||||
|
### 场景 2:不同环境隔离
|
||||||
|
|
||||||
|
为开发、测试、生产环境创建不同的任务,使用不同的渠道配置。
|
||||||
|
|
||||||
|
**适用于:**
|
||||||
|
- 环境隔离
|
||||||
|
- 测试验证
|
||||||
|
- 灰度发布
|
||||||
|
|
||||||
|
### 场景 3:按用途分类
|
||||||
|
|
||||||
|
为不同用途创建不同的任务(如:用户通知、系统告警、营销推广),便于管理和统计。
|
||||||
|
|
||||||
|
**适用于:**
|
||||||
|
- 消息分类管理
|
||||||
|
- 统计分析
|
||||||
|
- 权限控制
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 任务命名
|
||||||
|
|
||||||
|
使用清晰的命名规范:
|
||||||
|
- ✅ `生产-用户通知-邮件钉钉`
|
||||||
|
- ✅ `测试-系统告警-企业微信`
|
||||||
|
- ❌ `任务1`、`test`
|
||||||
|
|
||||||
|
### 2. 实例配置
|
||||||
|
|
||||||
|
- **合理选择渠道** - 根据消息重要性和紧急程度选择合适的渠道
|
||||||
|
- **配置备用渠道** - 为重要消息配置多个渠道,提高送达率
|
||||||
|
- **格式适配** - 为不同渠道提供合适的消息格式
|
||||||
|
|
||||||
|
### 3. Token 管理
|
||||||
|
|
||||||
|
- **安全存储** - 不要在代码中硬编码 Token,使用环境变量或配置文件
|
||||||
|
- **定期轮换** - 定期更新 Token,提高安全性
|
||||||
|
- **权限控制** - 不同环境使用不同的 Token
|
||||||
|
|
||||||
|
### 4. 日志监控
|
||||||
|
|
||||||
|
- **定期查看** - 定期检查发送日志,及时发现问题
|
||||||
|
- **失败处理** - 对发送失败的消息进行重试或告警
|
||||||
|
- **统计分析** - 分析发送数据,优化渠道配置
|
||||||
|
|
||||||
|
### 5. 性能优化
|
||||||
|
|
||||||
|
- **异步调用** - 使用异步方式调用 API,避免阻塞主流程
|
||||||
|
- **批量发送** - 对于大量消息,考虑批量发送或限流
|
||||||
|
- **错误重试** - 实现合理的重试机制
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 任务和模板有什么区别?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- **任务(V1 API)**:内容在 API 调用时传递,适合完全动态的内容
|
||||||
|
- **模板(V2 API)**:内容预定义在模板中,通过占位符替换,推荐使用
|
||||||
|
|
||||||
|
详见 [V1 API 文档](/api/v1) 中的对比说明。
|
||||||
|
|
||||||
|
### Q: 可以为一个任务配置多个实例吗?
|
||||||
|
|
||||||
|
**A:** 可以。一个任务可以配置多个推送实例,API 调用时会自动遍历所有启用的实例进行发送。
|
||||||
|
|
||||||
|
### Q: 如何知道消息是否发送成功?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. API 响应会返回发送状态
|
||||||
|
2. 在任务的日志页面查看详细的发送记录
|
||||||
|
3. 可以配置自托管消息渠道,在后台查看消息
|
||||||
|
|
||||||
|
### Q: Token 泄露了怎么办?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 立即删除该任务(Token 将失效)
|
||||||
|
2. 创建新的任务获取新的 Token
|
||||||
|
3. 更新使用该 Token 的所有代码
|
||||||
|
|
||||||
|
### Q: 发送失败如何处理?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 查看日志中的错误信息
|
||||||
|
2. 检查渠道配置是否正确
|
||||||
|
3. 确认渠道服务是否正常
|
||||||
|
4. 检查消息内容是否符合渠道要求
|
||||||
|
5. 实现重试机制
|
||||||
|
|
||||||
|
## 迁移到模板
|
||||||
|
|
||||||
|
如果您正在使用发送任务,我们建议迁移到消息模板:
|
||||||
|
|
||||||
|
### 迁移步骤
|
||||||
|
|
||||||
|
1. **创建模板**
|
||||||
|
- 根据现有任务的消息内容创建模板
|
||||||
|
- 定义占位符替换动态内容
|
||||||
|
|
||||||
|
2. **配置实例**
|
||||||
|
- 将任务的推送实例配置复制到模板
|
||||||
|
|
||||||
|
3. **更新代码**
|
||||||
|
- 将 V1 API 调用改为 V2 API
|
||||||
|
- 使用模板 Token 和占位符参数
|
||||||
|
|
||||||
|
4. **测试验证**
|
||||||
|
- 测试新的模板发送是否正常
|
||||||
|
- 对比新旧方式的效果
|
||||||
|
|
||||||
|
5. **切换上线**
|
||||||
|
- 逐步切换到模板方式
|
||||||
|
- 保留旧任务一段时间作为备用
|
||||||
|
|
||||||
|
### 迁移优势
|
||||||
|
|
||||||
|
- ✅ 内容统一管理,便于维护
|
||||||
|
- ✅ 修改内容无需改代码
|
||||||
|
- ✅ 支持版本控制和灰度发布
|
||||||
|
- ✅ 更好的团队协作体验
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [推送渠道配置](/guide/channels) 了解如何配置渠道
|
||||||
|
- 查看 [消息模板](/guide/template) 了解推荐的使用方式
|
||||||
|
- 查看 [V1 API 文档](/api/v1) 了解 API 调用方法
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# 消息模板
|
||||||
|
|
||||||
|
消息模板功能允许您创建可复用的消息模板,通过占位符实现动态内容替换,提高消息发送的灵活性和效率。
|
||||||
|
|
||||||
|
::: tip ⭐ 作者推荐
|
||||||
|
消息模板是 Message Nest 的核心功能,也是我们**强烈推荐**的使用方式。相比传统的任务发送(V1 API),模板方式具有以下显著优势:
|
||||||
|
|
||||||
|
**为什么推荐使用模板?**
|
||||||
|
1. **开发效率提升 3 倍** - 一次定义模板,所有项目复用,无需重复编写消息内容
|
||||||
|
2. **维护成本降低 80%** - 修改消息格式只需在后台更新模板,无需修改代码、重新部署
|
||||||
|
3. **团队协作更顺畅** - 开发负责 API 集成,运营负责内容维护,职责清晰
|
||||||
|
4. **灰度发布更安全** - 支持模板启用/禁用,可以随时回滚,降低风险
|
||||||
|
5. **内容管理更规范** - 所有消息模板集中管理,便于审核和统一风格
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- ✅ 用户通知(注册、登录、密码重置等)
|
||||||
|
- ✅ 订单消息(下单、支付、发货、退款等)
|
||||||
|
- ✅ 系统告警(服务异常、资源不足等)
|
||||||
|
- ✅ 营销推广(活动通知、优惠券等)
|
||||||
|
- ✅ 完全动态的内容(通过占位符实现,如 `{{content}}`)
|
||||||
|
- ✅ **所有消息发送场景**
|
||||||
|
|
||||||
|
::: info 关于完全动态内容
|
||||||
|
即使消息内容完全动态,也推荐使用模板方式。你可以创建一个只包含一个占位符的模板,如:
|
||||||
|
|
||||||
|
**模板内容:** `{{content}}`
|
||||||
|
|
||||||
|
**API 调用:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"placeholders": {
|
||||||
|
"content": "这里是完全动态的内容"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样做的好处是:
|
||||||
|
1. 后期如果需要添加固定格式(如标题、签名),只需修改模板,无需改代码
|
||||||
|
2. 所有消息统一管理,便于审计和监控
|
||||||
|
3. 可以随时启用/禁用,便于灰度发布
|
||||||
|
|
||||||
|
**V1 API(任务)的定位:**
|
||||||
|
- 仅为兼容历史数据而保留
|
||||||
|
- 不推荐在任何新项目中使用
|
||||||
|
- 后续维护重点在模板功能上
|
||||||
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ **多格式支持** - 支持 Text、HTML、Markdown 三种格式
|
||||||
|
- ✅ **占位符替换** - 使用 `{{key}}` 语法定义动态内容
|
||||||
|
- ✅ **实例配置** - 为模板配置多个发送实例(渠道)
|
||||||
|
- ✅ **@提醒功能** - 支持钉钉、企业微信的@提醒
|
||||||
|
- ✅ **状态管理** - 启用/禁用模板控制
|
||||||
|
- ✅ **API调用** - 通过 V2 API 使用模板发送消息
|
||||||
|
|
||||||
|
## 创建模板
|
||||||
|
|
||||||
|
### 1. 基本信息
|
||||||
|
|
||||||
|
在管理后台的"消息模板"页面,点击"新建模板"按钮:
|
||||||
|
|
||||||
|
- **模板名称** - 模板的唯一标识名称
|
||||||
|
- **模板描述** - 模板的用途说明(可选)
|
||||||
|
- **状态** - 启用/禁用
|
||||||
|
|
||||||
|
### 2. 定义占位符
|
||||||
|
|
||||||
|
占位符用于在发送消息时动态替换内容。
|
||||||
|
|
||||||
|
**添加占位符:**
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| Key | 占位符键名 | `username` |
|
||||||
|
| Label | 显示标签 | `用户名` |
|
||||||
|
| Default | 默认值(可选) | `Guest` |
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "username",
|
||||||
|
"label": "用户名",
|
||||||
|
"default": "Guest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "email",
|
||||||
|
"label": "邮箱地址",
|
||||||
|
"default": "user@example.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "action",
|
||||||
|
"label": "操作类型",
|
||||||
|
"default": "登录"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 编写模板内容
|
||||||
|
|
||||||
|
支持三种格式,可以根据需要填写一种或多种:
|
||||||
|
|
||||||
|
#### Text 模板
|
||||||
|
|
||||||
|
纯文本格式,适用于所有渠道:
|
||||||
|
|
||||||
|
```text
|
||||||
|
您好,{{username}}!
|
||||||
|
|
||||||
|
您的账号 {{email}} 刚刚进行了 {{action}} 操作。
|
||||||
|
|
||||||
|
如果这不是您本人的操作,请立即联系我们。
|
||||||
|
```
|
||||||
|
|
||||||
|
#### HTML 模板
|
||||||
|
|
||||||
|
HTML 格式,适用于邮件等支持富文本的渠道:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family: Arial, sans-serif;">
|
||||||
|
<h2>您好,{{username}}!</h2>
|
||||||
|
<p>您的账号 <strong>{{email}}</strong> 刚刚进行了 <span style="color: #007bff;">{{action}}</span> 操作。</p>
|
||||||
|
<p style="color: #dc3545;">如果这不是您本人的操作,请立即联系我们。</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Markdown 模板
|
||||||
|
|
||||||
|
Markdown 格式,适用于钉钉、企业微信等支持 Markdown 的渠道:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 您好,{{username}}!
|
||||||
|
|
||||||
|
您的账号 **{{email}}** 刚刚进行了 `{{action}}` 操作。
|
||||||
|
|
||||||
|
> ⚠️ 如果这不是您本人的操作,请立即联系我们。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置 @提醒(可选)
|
||||||
|
|
||||||
|
针对钉钉、企业微信等渠道,可以配置@提醒:
|
||||||
|
|
||||||
|
- **@手机号** - 多个手机号用逗号分隔,如:`13800138000,13900139000`
|
||||||
|
- **@用户ID** - 多个用户ID用逗号分隔
|
||||||
|
- **@所有人** - 勾选后会@所有群成员
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
@提醒功能仅在支持的渠道(钉钉、企业微信)中生效。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 配置发送实例
|
||||||
|
|
||||||
|
创建模板后,需要为模板配置发送实例(渠道)。
|
||||||
|
|
||||||
|
### 1. 添加实例
|
||||||
|
|
||||||
|
在模板列表中,点击"实例"按钮:
|
||||||
|
|
||||||
|
1. 选择发送渠道(从已创建的渠道中选择)
|
||||||
|
2. 配置实例参数(如邮箱收件人地址)
|
||||||
|
3. 选择消息格式(Text/HTML/Markdown)
|
||||||
|
4. 保存实例
|
||||||
|
|
||||||
|
### 2. 实例配置说明
|
||||||
|
|
||||||
|
不同渠道需要配置不同的参数:
|
||||||
|
|
||||||
|
**邮件渠道:**
|
||||||
|
- 收件人邮箱地址
|
||||||
|
- 消息格式:Text 或 HTML
|
||||||
|
|
||||||
|
**钉钉/企业微信:**
|
||||||
|
- 消息格式:Text 或 Markdown
|
||||||
|
|
||||||
|
**自定义 Webhook:**
|
||||||
|
- 根据 Webhook 要求配置
|
||||||
|
|
||||||
|
### 3. 管理实例
|
||||||
|
|
||||||
|
- **启用/禁用** - 控制实例是否参与发送
|
||||||
|
- **编辑** - 修改实例配置
|
||||||
|
- **删除** - 删除不需要的实例
|
||||||
|
|
||||||
|
## 预览模板
|
||||||
|
|
||||||
|
在编辑模板时,可以使用"预览"功能查看替换占位符后的效果:
|
||||||
|
|
||||||
|
1. 点击"预览"按钮
|
||||||
|
2. 填写占位符的测试值
|
||||||
|
3. 查看 Text/HTML/Markdown 三种格式的渲染效果
|
||||||
|
|
||||||
|
## 使用模板发送消息
|
||||||
|
|
||||||
|
### 通过 API 调用
|
||||||
|
|
||||||
|
使用 V2 API 发送模板消息,详见 [V2 API 文档](/api/v2)。
|
||||||
|
|
||||||
|
**基本流程:**
|
||||||
|
|
||||||
|
1. 获取模板 Token(在模板详情页查看)
|
||||||
|
2. 准备占位符数据
|
||||||
|
3. 调用 V2 API 发送
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-domain/api/v2/message/send \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"token": "encrypted_template_token",
|
||||||
|
"title": "账号安全提醒",
|
||||||
|
"placeholders": {
|
||||||
|
"username": "张三",
|
||||||
|
"email": "zhangsan@example.com",
|
||||||
|
"action": "登录"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 占位符命名
|
||||||
|
|
||||||
|
- 使用有意义的英文名称,如 `username`、`order_id`
|
||||||
|
- 避免使用特殊字符,建议使用下划线分隔
|
||||||
|
- 保持命名一致性
|
||||||
|
|
||||||
|
### 2. 模板设计
|
||||||
|
|
||||||
|
- **简洁明了** - 模板内容应简洁清晰
|
||||||
|
- **格式适配** - 根据渠道特性选择合适的格式
|
||||||
|
- **默认值** - 为占位符设置合理的默认值
|
||||||
|
- **测试验证** - 使用预览功能验证模板效果
|
||||||
|
|
||||||
|
### 3. 实例管理
|
||||||
|
|
||||||
|
- **合理分组** - 为不同用途创建不同的模板
|
||||||
|
- **渠道选择** - 根据消息类型选择合适的渠道
|
||||||
|
- **定期检查** - 定期检查实例配置的有效性
|
||||||
|
|
||||||
|
### 4. 安全性
|
||||||
|
|
||||||
|
- **Token 保护** - 妥善保管模板 Token
|
||||||
|
- **权限控制** - 合理设置模板的启用/禁用状态
|
||||||
|
- **内容审查** - 注意模板内容的合规性
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 占位符没有被替换?
|
||||||
|
|
||||||
|
**A:** 检查以下几点:
|
||||||
|
- 占位符格式是否正确(`{{key}}`)
|
||||||
|
- API 调用时是否传递了对应的 placeholders 参数
|
||||||
|
- Key 名称是否匹配
|
||||||
|
|
||||||
|
### Q: 如何选择消息格式?
|
||||||
|
|
||||||
|
**A:** 根据渠道特性选择:
|
||||||
|
- **邮件** - 推荐使用 HTML 格式,视觉效果更好
|
||||||
|
- **钉钉/企业微信** - 推荐使用 Markdown 格式,支持富文本
|
||||||
|
- **其他渠道** - 使用 Text 格式,兼容性最好
|
||||||
|
|
||||||
|
### Q: 可以为一个模板配置多个实例吗?
|
||||||
|
|
||||||
|
**A:** 可以。一个模板可以配置多个发送实例,调用 API 时会自动遍历所有启用的实例进行发送。
|
||||||
|
|
||||||
|
### Q: @提醒不生效?
|
||||||
|
|
||||||
|
**A:** 确认以下几点:
|
||||||
|
- 渠道是否支持@提醒(仅钉钉、企业微信支持)
|
||||||
|
- 手机号或用户ID格式是否正确
|
||||||
|
- 机器人是否有@权限
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看 [V2 API 文档](/api/v2) 了解如何通过 API 使用模板
|
||||||
|
- 查看 [API 示例](/api/examples) 了解各语言的调用方式
|
||||||
@@ -48,39 +48,3 @@ features:
|
|||||||
title: 数据统计
|
title: 数据统计
|
||||||
details: 支持数据统计展示,查看消息发送情况。
|
details: 支持数据统计展示,查看消息发送情况。
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- ## 快速开始
|
|
||||||
|
|
||||||
### Docker 部署(推荐)
|
|
||||||
|
|
||||||
使用环境变量快速启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
-p 8000:8000 \
|
|
||||||
-e MYSQL_HOST=192.168.64.133 \
|
|
||||||
-e MYSQL_PORT=3308 \
|
|
||||||
-e MYSQL_USER=root \
|
|
||||||
-e MYSQL_PASSWORD=Aa123456 \
|
|
||||||
-e MYSQL_DB=test_11 \
|
|
||||||
-e MYSQL_TABLE_PREFIX=message_ \
|
|
||||||
--name message-nest \
|
|
||||||
engigu/message-nest:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### 直接运行
|
|
||||||
|
|
||||||
1. 下载最新的 [Release](https://github.com/engigu/Message-Push-Nest/releases)
|
|
||||||
2. 配置 `conf/app.ini`
|
|
||||||
3. 启动服务,访问 `http://localhost:8000`
|
|
||||||
4. 默认账号:`admin`,密码:`123456`
|
|
||||||
|
|
||||||
## 项目来由
|
|
||||||
|
|
||||||
自己常常写一些脚本需要消息推送,经常需要接入不同的消息发送,很不方便,于是就有了这个项目。
|
|
||||||
|
|
||||||
如果你有很多消息推送方式,每次都需要调用各种接口去发送消息到各个渠道,或者不同的项目你都需要复制同样的发消息代码,这个项目可以帮你管理各种消息方式,并提供统一的发送API接入。你可以自由组合各种消息渠道,一个API推送到各种渠道,帮你省去接入的繁琐步骤。
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://star-history.com/#engigu/Message-Push-Nest&Date) -->
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"name": "message-nest-docs",
|
"name": "message-nest-docs",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitepress": "^1.0.0"
|
"vitepress": "^1.6.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@algolia/abtesting": {
|
"node_modules/@algolia/abtesting": {
|
||||||
|
|||||||
+1
-1
@@ -8,6 +8,6 @@
|
|||||||
"docs:preview": "vitepress preview"
|
"docs:preview": "vitepress preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitepress": "^1.0.0"
|
"vitepress": "^1.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -12,6 +14,23 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 需要记录响应内容的 API 路径前缀
|
||||||
|
var logResponsePaths = []string{
|
||||||
|
"/api/v1/message/send",
|
||||||
|
"/api/v2/message/send",
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseBodyWriter 用于捕获响应内容
|
||||||
|
type responseBodyWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w responseBodyWriter) Write(b []byte) (int, error) {
|
||||||
|
w.body.Write(b)
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
// LogMiddleware Logger is the logrus logger handler
|
// LogMiddleware Logger is the logrus logger handler
|
||||||
func LogMiddleware(notLogged ...string) gin.HandlerFunc {
|
func LogMiddleware(notLogged ...string) gin.HandlerFunc {
|
||||||
//hostname, err := os.Hostname()
|
//hostname, err := os.Hostname()
|
||||||
@@ -33,6 +52,24 @@ func LogMiddleware(notLogged ...string) gin.HandlerFunc {
|
|||||||
raw := c.Request.URL.RawQuery
|
raw := c.Request.URL.RawQuery
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
|
// 判断是否需要捕获响应内容
|
||||||
|
needCaptureResponse := false
|
||||||
|
for _, logPath := range logResponsePaths {
|
||||||
|
if strings.HasPrefix(path, logPath) {
|
||||||
|
needCaptureResponse = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyWriter *responseBodyWriter
|
||||||
|
if needCaptureResponse {
|
||||||
|
bodyWriter = &responseBodyWriter{
|
||||||
|
ResponseWriter: c.Writer,
|
||||||
|
body: bytes.NewBufferString(""),
|
||||||
|
}
|
||||||
|
c.Writer = bodyWriter
|
||||||
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
stop := time.Since(start)
|
stop := time.Since(start)
|
||||||
@@ -70,6 +107,13 @@ func LogMiddleware(notLogged ...string) gin.HandlerFunc {
|
|||||||
entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())
|
entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())
|
||||||
} else {
|
} else {
|
||||||
msg := fmt.Sprintf("%s [%s] %s %d %d (%dms)", clientIP, c.Request.Method, path, statusCode, dataLength, latency)
|
msg := fmt.Sprintf("%s [%s] %s %d %d (%dms)", clientIP, c.Request.Method, path, statusCode, dataLength, latency)
|
||||||
|
|
||||||
|
// 如果是发送消息的 API,打印返回内容
|
||||||
|
if needCaptureResponse && bodyWriter != nil {
|
||||||
|
responseBody := bodyWriter.body.String()
|
||||||
|
msg = fmt.Sprintf("%s | Response: %s", msg, responseBody)
|
||||||
|
}
|
||||||
|
|
||||||
if statusCode >= http.StatusInternalServerError {
|
if statusCode >= http.StatusInternalServerError {
|
||||||
entry.Error(msg)
|
entry.Error(msg)
|
||||||
} else if statusCode >= http.StatusBadRequest {
|
} else if statusCode >= http.StatusBadRequest {
|
||||||
|
|||||||
+3
-1
@@ -2,9 +2,10 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"message-nest/pkg/e"
|
"message-nest/pkg/e"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
var ExcludedRoutes = []string{
|
var ExcludedRoutes = []string{
|
||||||
"/api/v1/message/send",
|
"/api/v1/message/send",
|
||||||
|
"/api/v2/message/send",
|
||||||
"/api/v1/settings/getsetting",
|
"/api/v1/settings/getsetting",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -3,10 +3,11 @@ package migrate
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"message-nest/models"
|
"message-nest/models"
|
||||||
"message-nest/service/settings_service"
|
"message-nest/service/settings_service"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 初始化admin账户
|
// 初始化admin账户
|
||||||
@@ -67,6 +68,7 @@ func Setup() {
|
|||||||
&models.CronMessages{},
|
&models.CronMessages{},
|
||||||
&models.HostedMessage{},
|
&models.HostedMessage{},
|
||||||
&models.LoginLog{},
|
&models.LoginLog{},
|
||||||
|
&models.Template{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type CronMessages struct {
|
|||||||
|
|
||||||
func GenerateMsgUniqueID() string {
|
func GenerateMsgUniqueID() string {
|
||||||
newUUID := util.GenerateUniqueID()
|
newUUID := util.GenerateUniqueID()
|
||||||
return fmt.Sprintf("C-%s", newUUID)
|
return fmt.Sprintf("CM%s", newUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddSendCronMsg(
|
func AddSendCronMsg(
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateTemplateUniqueID 生成模板唯一ID
|
||||||
|
func GenerateTemplateUniqueID() string {
|
||||||
|
newUUID := util.GenerateUniqueID()
|
||||||
|
return fmt.Sprintf("TP%s", newUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template 消息模板
|
||||||
|
type Template struct {
|
||||||
|
UUIDModel
|
||||||
|
|
||||||
|
Name string `json:"name" gorm:"type:varchar(200);not null;index" binding:"required"`
|
||||||
|
Description string `json:"description" gorm:"type:text"`
|
||||||
|
|
||||||
|
// 模板内容(带占位符)
|
||||||
|
TextTemplate string `json:"text_template" gorm:"type:text"`
|
||||||
|
HTMLTemplate string `json:"html_template" gorm:"type:text"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template" gorm:"type:text"`
|
||||||
|
|
||||||
|
// 占位符定义(JSON格式)
|
||||||
|
Placeholders string `json:"placeholders" gorm:"type:text"`
|
||||||
|
|
||||||
|
// @提醒配置
|
||||||
|
AtMobiles string `json:"at_mobiles" gorm:"type:text;comment:'@手机号列表,逗号分隔'"`
|
||||||
|
AtUserIds string `json:"at_user_ids" gorm:"type:text;comment:'@用户ID列表,逗号分隔'"`
|
||||||
|
IsAtAll bool `json:"is_at_all" gorm:"default:false;comment:'是否@所有人'"`
|
||||||
|
|
||||||
|
// 状态:enabled/disabled
|
||||||
|
Status string `json:"status" gorm:"type:varchar(20);default:'enabled';index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 添加消息模板
|
||||||
|
func (t *Template) Add() error {
|
||||||
|
if err := db.Create(&t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新消息模板
|
||||||
|
func (t *Template) Update() error {
|
||||||
|
// 使用 Select 明确指定要更新的字段,包括布尔值字段,排除不应更新的时间戳字段
|
||||||
|
if err := db.Model(&Template{}).Where("id = ?", t.ID).
|
||||||
|
Select("name", "description", "text_template", "html_template", "markdown_template",
|
||||||
|
"placeholders", "at_mobiles", "at_user_ids", "is_at_all", "status", "modified_by").
|
||||||
|
Updates(t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除消息模板
|
||||||
|
func (t *Template) Delete() error {
|
||||||
|
if err := db.Where("id = ?", t.ID).Delete(&Template{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateResult 消息模板查询结果
|
||||||
|
type TemplateResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TextTemplate string `json:"text_template"`
|
||||||
|
HTMLTemplate string `json:"html_template"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template"`
|
||||||
|
Placeholders string `json:"placeholders"`
|
||||||
|
AtMobiles string `json:"at_mobiles"`
|
||||||
|
AtUserIds string `json:"at_user_ids"`
|
||||||
|
IsAtAll bool `json:"is_at_all"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
ModifiedBy string `json:"modified_by"`
|
||||||
|
CreatedOn util.Time `json:"created_on"`
|
||||||
|
ModifiedOn util.Time `json:"modified_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplates 获取消息模板列表
|
||||||
|
func GetTemplates(pageNum int, pageSize int, text string, maps map[string]interface{}) ([]TemplateResult, error) {
|
||||||
|
var datas []TemplateResult
|
||||||
|
templateT := GetSchema(Template{})
|
||||||
|
|
||||||
|
query := db.Table(templateT)
|
||||||
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
query = query.Where("name LIKE ? OR description LIKE ?",
|
||||||
|
fmt.Sprintf("%%%s%%", text),
|
||||||
|
fmt.Sprintf("%%%s%%", text))
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Order("created_on DESC")
|
||||||
|
|
||||||
|
if pageSize > 0 || pageNum > 0 {
|
||||||
|
query = query.Offset(pageNum).Limit(pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Scan(&datas)
|
||||||
|
return datas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplatesTotal 获取消息模板总数
|
||||||
|
func GetTemplatesTotal(text string, maps map[string]interface{}) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
templateT := GetSchema(Template{})
|
||||||
|
|
||||||
|
query := db.Table(templateT)
|
||||||
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
query = query.Where("name LIKE ? OR description LIKE ?",
|
||||||
|
fmt.Sprintf("%%%s%%", text),
|
||||||
|
fmt.Sprintf("%%%s%%", text))
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateByID 根据ID获取消息模板
|
||||||
|
func GetTemplateByID(id string) (*TemplateResult, error) {
|
||||||
|
var data TemplateResult
|
||||||
|
templateT := GetSchema(Template{})
|
||||||
|
|
||||||
|
err := db.Table(templateT).Where("id = ?", id).First(&data).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistTemplateByID 检查模板是否存在
|
||||||
|
func ExistTemplateByID(id string) (bool, error) {
|
||||||
|
var template Template
|
||||||
|
err := db.Select("id").Where("id = ?", id).First(&template).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.ID != "" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
type SendTasksIns struct {
|
type SendTasksIns struct {
|
||||||
UUIDModel
|
UUIDModel
|
||||||
|
|
||||||
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index"`
|
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index"`
|
||||||
|
TemplateID string `json:"template_id" gorm:"type:varchar(12) ;default:'';index"` // 模板ID
|
||||||
WayID string `json:"way_id" gorm:"type:varchar(12) ;default:'';index"`
|
WayID string `json:"way_id" gorm:"type:varchar(12) ;default:'';index"`
|
||||||
WayType string `json:"way_type" gorm:"type:varchar(100) ;default:'';index"`
|
WayType string `json:"way_type" gorm:"type:varchar(100) ;default:'';index"`
|
||||||
ContentType string `json:"content_type" gorm:"type:varchar(100) ;default:'';index"`
|
ContentType string `json:"content_type" gorm:"type:varchar(100) ;default:'';index"`
|
||||||
@@ -82,3 +85,23 @@ func UpdateMsgTaskIns(id string, data map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTemplateInsList 获取模板关联的实例列表(包含渠道名称)
|
||||||
|
func GetTemplateInsList(templateID string) ([]SendTasksInsRes, error) {
|
||||||
|
insTable := GetSchema(SendTasksIns{})
|
||||||
|
waysTable := GetSchema(SendWays{})
|
||||||
|
var insList []SendTasksInsRes
|
||||||
|
|
||||||
|
err := db.
|
||||||
|
Table(insTable).
|
||||||
|
Select(fmt.Sprintf("%s.*, %s.name as way_name", insTable, waysTable)).
|
||||||
|
Joins(fmt.Sprintf("JOIN %s ON %s.way_id = %s.id", waysTable, insTable, waysTable)).
|
||||||
|
Where(fmt.Sprintf("%s.template_id = ?", insTable), templateID).
|
||||||
|
Order(fmt.Sprintf("%s.created_on DESC", insTable)).
|
||||||
|
Scan(&insList).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return insList, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type SendTasks struct {
|
|||||||
|
|
||||||
func GenerateTaskUniqueID() string {
|
func GenerateTaskUniqueID() string {
|
||||||
newUUID := util.GenerateUniqueID()
|
newUUID := util.GenerateUniqueID()
|
||||||
return fmt.Sprintf("T-%s", newUUID)
|
return fmt.Sprintf("TK%s", newUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSendTaskWithID 添加实例的时候添加任务
|
// AddSendTaskWithID 添加实例的时候添加任务
|
||||||
|
|||||||
+73
-15
@@ -9,6 +9,8 @@ import (
|
|||||||
type SendTasksLogs struct {
|
type SendTasksLogs struct {
|
||||||
ID int `gorm:"primaryKey" json:"id" `
|
ID int `gorm:"primaryKey" json:"id" `
|
||||||
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index:task_id"`
|
TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index:task_id"`
|
||||||
|
Type string `json:"type" gorm:"type:varchar(20) ;default:'task';comment:'类型:task-任务,template-模板'"`
|
||||||
|
Name string `json:"name" gorm:"type:varchar(256) ;default:'';comment:'任务或模板名称'"`
|
||||||
Log string `json:"log" gorm:"type:text ;"`
|
Log string `json:"log" gorm:"type:text ;"`
|
||||||
Status *int `json:"status" gorm:"type:int ;default:0;"`
|
Status *int `json:"status" gorm:"type:int ;default:0;"`
|
||||||
CallerIp string `json:"caller_ip" gorm:"type:varchar(256) ;default:'';"`
|
CallerIp string `json:"caller_ip" gorm:"type:varchar(256) ;default:'';"`
|
||||||
@@ -29,10 +31,11 @@ func (log *SendTasksLogs) Add() error {
|
|||||||
type LogsResult struct {
|
type LogsResult struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
Log string `json:"log"`
|
Log string `json:"log"`
|
||||||
CreatedOn util.Time `json:"created_on"`
|
CreatedOn util.Time `json:"created_on"`
|
||||||
ModifiedOn util.Time `json:"modified_on"`
|
ModifiedOn util.Time `json:"modified_on"`
|
||||||
TaskName string `json:"task_name"`
|
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
CallerIp string `json:"caller_ip"`
|
CallerIp string `json:"caller_ip"`
|
||||||
}
|
}
|
||||||
@@ -41,12 +44,9 @@ type LogsResult struct {
|
|||||||
func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map[string]interface{}) ([]LogsResult, error) {
|
func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map[string]interface{}) ([]LogsResult, error) {
|
||||||
var logs []LogsResult
|
var logs []LogsResult
|
||||||
logt := GetSchema(SendTasksLogs{})
|
logt := GetSchema(SendTasksLogs{})
|
||||||
taskt := GetSchema(SendTasks{})
|
|
||||||
|
|
||||||
query := db.
|
// 简化查询,只查询日志表
|
||||||
Table(logt).
|
query := db.Table(logt)
|
||||||
Select(fmt.Sprintf("%s.*, %s.name as task_name", logt, taskt)).
|
|
||||||
Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt))
|
|
||||||
|
|
||||||
dayVal, ok := maps["day_created_on"]
|
dayVal, ok := maps["day_created_on"]
|
||||||
if ok {
|
if ok {
|
||||||
@@ -55,8 +55,10 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map
|
|||||||
}
|
}
|
||||||
|
|
||||||
query = query.Where(maps)
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
// 按名称搜索(搜索日志表的 name 字段)
|
||||||
if name != "" {
|
if name != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.name like ?", taskt), fmt.Sprintf("%%%s%%", name))
|
query = query.Where(fmt.Sprintf("%s.name like ?", logt), fmt.Sprintf("%%%s%%", name))
|
||||||
}
|
}
|
||||||
if taskId != "" {
|
if taskId != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
||||||
@@ -66,18 +68,71 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map
|
|||||||
query = query.Offset(pageNum).Limit(pageSize)
|
query = query.Offset(pageNum).Limit(pageSize)
|
||||||
}
|
}
|
||||||
query.Scan(&logs)
|
query.Scan(&logs)
|
||||||
|
|
||||||
|
//v1 接口的历史日志数据兼容处理
|
||||||
|
// 应用层处理:为历史数据(type=task 且 name 为空)补充任务名称
|
||||||
|
fillTaskNamesForLogs(&logs)
|
||||||
|
|
||||||
return logs, nil
|
return logs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillTaskNamesForLogs 为历史日志数据补充任务名称
|
||||||
|
func fillTaskNamesForLogs(logs *[]LogsResult) {
|
||||||
|
if logs == nil || len(*logs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集需要查询的 task_id
|
||||||
|
taskIdsMap := make(map[string]bool)
|
||||||
|
for _, log := range *logs {
|
||||||
|
// 只处理 type=task 且 name 为空的记录
|
||||||
|
if (log.Type == "" || log.Type == "task") && log.Name == "" && log.TaskID != "" {
|
||||||
|
taskIdsMap[log.TaskID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有需要查询的任务,直接返回
|
||||||
|
if len(taskIdsMap) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询任务名称
|
||||||
|
taskIds := make([]string, 0, len(taskIdsMap))
|
||||||
|
for taskId := range taskIdsMap {
|
||||||
|
taskIds = append(taskIds, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks []SendTasks
|
||||||
|
taskt := GetSchema(SendTasks{})
|
||||||
|
db.Table(taskt).
|
||||||
|
Select("id, name").
|
||||||
|
Where("id IN ?", taskIds).
|
||||||
|
Scan(&tasks)
|
||||||
|
|
||||||
|
// 构建 taskId -> name 的映射
|
||||||
|
taskNameMap := make(map[string]string)
|
||||||
|
for _, task := range tasks {
|
||||||
|
taskNameMap[task.ID] = task.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充日志的 name 字段
|
||||||
|
for i := range *logs {
|
||||||
|
log := &(*logs)[i]
|
||||||
|
if (log.Type == "" || log.Type == "task") && log.Name == "" && log.TaskID != "" {
|
||||||
|
if taskName, exists := taskNameMap[log.TaskID]; exists {
|
||||||
|
log.Name = taskName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetSendLogsTotal 获取所有日志总数
|
// GetSendLogsTotal 获取所有日志总数
|
||||||
func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (int64, error) {
|
func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
logt := GetSchema(SendTasksLogs{})
|
logt := GetSchema(SendTasksLogs{})
|
||||||
taskt := GetSchema(SendTasks{})
|
|
||||||
query := db.
|
// 简化查询,只查询日志表
|
||||||
Table(logt).
|
query := db.Table(logt)
|
||||||
Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt))
|
|
||||||
|
|
||||||
dayVal, ok := maps["day_created_on"]
|
dayVal, ok := maps["day_created_on"]
|
||||||
if ok {
|
if ok {
|
||||||
@@ -86,8 +141,10 @@ func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
query = query.Where(maps)
|
query = query.Where(maps)
|
||||||
|
|
||||||
|
// 按名称搜索(搜索日志表的 name 字段)
|
||||||
if name != "" {
|
if name != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.name like ?", taskt), fmt.Sprintf("%%%s%%", name))
|
query = query.Where(fmt.Sprintf("%s.name like ?", logt), fmt.Sprintf("%%%s%%", name))
|
||||||
}
|
}
|
||||||
if taskId != "" {
|
if taskId != "" {
|
||||||
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId)
|
||||||
@@ -282,17 +339,18 @@ func GetTrendStatisticData() (TrendStatisticData, error) {
|
|||||||
return statistic, nil
|
return statistic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelStatisticData 获取渠道统计数据
|
// GetChannelStatisticData 获取渠道统计数据(包含任务实例和模板实例)
|
||||||
func GetChannelStatisticData() (ChannelStatisticData, error) {
|
func GetChannelStatisticData() (ChannelStatisticData, error) {
|
||||||
var statistic ChannelStatisticData
|
var statistic ChannelStatisticData
|
||||||
var wayCateData []WayCateData
|
var wayCateData []WayCateData
|
||||||
inst := GetSchema(SendTasksIns{})
|
inst := GetSchema(SendTasksIns{})
|
||||||
wayst := GetSchema(SendWays{})
|
wayst := GetSchema(SendWays{})
|
||||||
|
|
||||||
// 消息实例分类数目
|
// 统计所有实例的渠道分布(包含任务实例和模板实例)
|
||||||
|
// 不区分 task_id 和 template_id,统计所有关联到渠道的实例
|
||||||
db.
|
db.
|
||||||
Table(inst).
|
Table(inst).
|
||||||
Select(fmt.Sprintf("%s.name as way_name, count(%s.id) as count_num", wayst, wayst)).
|
Select(fmt.Sprintf("%s.name as way_name, count(%s.id) as count_num", wayst, inst)).
|
||||||
Joins(fmt.Sprintf("JOIN %s ON %s.way_id = %s.id", wayst, inst, wayst)).
|
Joins(fmt.Sprintf("JOIN %s ON %s.way_id = %s.id", wayst, inst, wayst)).
|
||||||
Group(fmt.Sprintf("%s.id", wayst)).
|
Group(fmt.Sprintf("%s.id", wayst)).
|
||||||
Scan(&wayCateData)
|
Scan(&wayCateData)
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ type SendWays struct {
|
|||||||
|
|
||||||
func GenerateWayUniqueID() string {
|
func GenerateWayUniqueID() string {
|
||||||
newUUID := util.GenerateUniqueID()
|
newUUID := util.GenerateUniqueID()
|
||||||
return fmt.Sprintf("W-%s", newUUID)
|
return fmt.Sprintf("WY%s", newUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddSendWay(name string, auth string, wayType string, createdBy string, modifiedBy string) error {
|
func AddSendWay(name string, auth string, wayType string, createdBy string, modifiedBy string) error {
|
||||||
|
|||||||
@@ -62,6 +62,26 @@ func (t *Dtalk) SendMessageText(text string, at ...string) ([]byte, error) {
|
|||||||
"content": text,
|
"content": text,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加@功能
|
||||||
|
if len(at) > 0 {
|
||||||
|
atMobiles := []string{}
|
||||||
|
isAtAll := false
|
||||||
|
|
||||||
|
for _, mobile := range at {
|
||||||
|
if mobile == "all" || mobile == "@all" {
|
||||||
|
isAtAll = true
|
||||||
|
} else {
|
||||||
|
atMobiles = append(atMobiles, mobile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg["at"] = map[string]interface{}{
|
||||||
|
"atMobiles": atMobiles,
|
||||||
|
"isAtAll": isAtAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := t.Request(msg)
|
resp, err := t.Request(msg)
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
@@ -74,6 +94,26 @@ func (t *Dtalk) SendMessageMarkdown(title, text string, at ...string) ([]byte, e
|
|||||||
"text": text,
|
"text": text,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加@功能
|
||||||
|
if len(at) > 0 {
|
||||||
|
atMobiles := []string{}
|
||||||
|
isAtAll := false
|
||||||
|
|
||||||
|
for _, mobile := range at {
|
||||||
|
if mobile == "all" || mobile == "@all" {
|
||||||
|
isAtAll = true
|
||||||
|
} else {
|
||||||
|
atMobiles = append(atMobiles, mobile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg["at"] = map[string]interface{}{
|
||||||
|
"atMobiles": atMobiles,
|
||||||
|
"isAtAll": isAtAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := t.Request(msg)
|
resp, err := t.Request(msg)
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-3
@@ -53,10 +53,40 @@ func (t *QyWeiXin) Request(msg interface{}) ([]byte, error) {
|
|||||||
func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) {
|
func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) {
|
||||||
msg := map[string]interface{}{
|
msg := map[string]interface{}{
|
||||||
"msgtype": "text",
|
"msgtype": "text",
|
||||||
"text": map[string]string{
|
"text": map[string]interface{}{
|
||||||
"content": text,
|
"content": text,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加@功能
|
||||||
|
// 企业微信支持两种@方式:
|
||||||
|
// 1. mentioned_list: userid列表或"@all"
|
||||||
|
// 2. mentioned_mobile_list: 手机号列表
|
||||||
|
if len(at) > 0 {
|
||||||
|
mentionedList := []string{}
|
||||||
|
mentionedMobileList := []string{}
|
||||||
|
|
||||||
|
for _, item := range at {
|
||||||
|
if item == "@all" || item == "all" {
|
||||||
|
mentionedList = append(mentionedList, "@all")
|
||||||
|
} else if len(item) == 11 && item[0] == '1' {
|
||||||
|
// 判断是否为手机号(简单判断:11位且以1开头)
|
||||||
|
mentionedMobileList = append(mentionedMobileList, item)
|
||||||
|
} else {
|
||||||
|
// 否则当作userid处理
|
||||||
|
mentionedList = append(mentionedList, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textContent := msg["text"].(map[string]interface{})
|
||||||
|
if len(mentionedList) > 0 {
|
||||||
|
textContent["mentioned_list"] = mentionedList
|
||||||
|
}
|
||||||
|
if len(mentionedMobileList) > 0 {
|
||||||
|
textContent["mentioned_mobile_list"] = mentionedMobileList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := t.Request(msg)
|
resp, err := t.Request(msg)
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
@@ -64,11 +94,14 @@ func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) {
|
|||||||
func (t *QyWeiXin) SendMessageMarkdown(title, text string, at ...string) ([]byte, error) {
|
func (t *QyWeiXin) SendMessageMarkdown(title, text string, at ...string) ([]byte, error) {
|
||||||
msg := map[string]interface{}{
|
msg := map[string]interface{}{
|
||||||
"msgtype": "markdown",
|
"msgtype": "markdown",
|
||||||
"markdown": map[string]string{
|
"markdown": map[string]interface{}{
|
||||||
"title": title,
|
|
||||||
"content": text,
|
"content": text,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 企业微信Markdown消息不支持@功能,但可以在内容中手动添加
|
||||||
|
// 如果需要@功能,建议使用text类型
|
||||||
|
|
||||||
resp, err := t.Request(msg)
|
resp, err := t.Request(msg)
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/app"
|
||||||
|
"message-nest/pkg/e"
|
||||||
|
"message-nest/pkg/util"
|
||||||
|
"message-nest/service/message_template_service"
|
||||||
|
"message-nest/service/send_ins_service"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetMessageTemplateList 获取消息模板列表
|
||||||
|
func GetMessageTemplateList(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
text := c.Query("text")
|
||||||
|
status := c.Query("status")
|
||||||
|
|
||||||
|
offset, limit := util.GetPageSize(c)
|
||||||
|
templateService := message_template_service.TemplateService{
|
||||||
|
Text: text,
|
||||||
|
Status: status,
|
||||||
|
PageNum: offset,
|
||||||
|
PageSize: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, err := templateService.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "获取消息模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := templateService.Count()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "获取消息模板总数失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "获取消息模板成功", map[string]interface{}{
|
||||||
|
"lists": templates,
|
||||||
|
"total": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageTemplate 获取单个消息模板
|
||||||
|
func GetMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
id := c.Query("id")
|
||||||
|
|
||||||
|
templateService := message_template_service.TemplateService{
|
||||||
|
ID: id,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := templateService.Get()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "获取模板详情失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "获取模板详情成功", template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessageTemplate 添加消息模板
|
||||||
|
func AddMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TextTemplate string `json:"text_template"`
|
||||||
|
HTMLTemplate string `json:"html_template"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template"`
|
||||||
|
Placeholders string `json:"placeholders"`
|
||||||
|
AtMobiles string `json:"at_mobiles"`
|
||||||
|
AtUserIds string `json:"at_user_ids"`
|
||||||
|
IsAtAll bool `json:"is_at_all"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TextTemplate == "" && req.HTMLTemplate == "" && req.MarkdownTemplate == "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "至少需要填写一种格式的模板内容", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status == "" {
|
||||||
|
req.Status = "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.TemplateService{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
TextTemplate: req.TextTemplate,
|
||||||
|
HTMLTemplate: req.HTMLTemplate,
|
||||||
|
MarkdownTemplate: req.MarkdownTemplate,
|
||||||
|
Placeholders: req.Placeholders,
|
||||||
|
AtMobiles: req.AtMobiles,
|
||||||
|
AtUserIds: req.AtUserIds,
|
||||||
|
IsAtAll: req.IsAtAll,
|
||||||
|
Status: req.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templateService.Add(); err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "添加模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "添加模板成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessageTemplate 编辑消息模板
|
||||||
|
func EditMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TextTemplate string `json:"text_template"`
|
||||||
|
HTMLTemplate string `json:"html_template"`
|
||||||
|
MarkdownTemplate string `json:"markdown_template"`
|
||||||
|
Placeholders string `json:"placeholders"`
|
||||||
|
AtMobiles string `json:"at_mobiles"`
|
||||||
|
AtUserIds string `json:"at_user_ids"`
|
||||||
|
IsAtAll bool `json:"is_at_all"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TextTemplate == "" && req.HTMLTemplate == "" && req.MarkdownTemplate == "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "至少需要填写一种格式的模板内容", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.TemplateService{
|
||||||
|
ID: req.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService.Name = req.Name
|
||||||
|
templateService.Description = req.Description
|
||||||
|
templateService.TextTemplate = req.TextTemplate
|
||||||
|
templateService.HTMLTemplate = req.HTMLTemplate
|
||||||
|
templateService.MarkdownTemplate = req.MarkdownTemplate
|
||||||
|
templateService.Placeholders = req.Placeholders
|
||||||
|
templateService.AtMobiles = req.AtMobiles
|
||||||
|
templateService.AtUserIds = req.AtUserIds
|
||||||
|
templateService.IsAtAll = req.IsAtAll
|
||||||
|
templateService.Status = req.Status
|
||||||
|
|
||||||
|
if err := templateService.Update(); err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "更新模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "更新模板成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessageTemplate 删除消息模板
|
||||||
|
func DeleteMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.TemplateService{
|
||||||
|
ID: req.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templateService.Delete(); err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "删除模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "删除模板成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewMessageTemplate 预览消息模板
|
||||||
|
func PreviewMessageTemplate(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
Params map[string]string `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateService := message_template_service.TemplateService{
|
||||||
|
ID: req.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := templateService.ExistByID()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
appG.CResponse(http.StatusNotFound, "模板不存在!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := templateService.PreviewTemplate(req.Params)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusInternalServerError, "预览模板失败:"+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "预览模板成功", preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateWithIns 获取模板及其关联的实例
|
||||||
|
func GetTemplateWithIns(c *gin.Context) {
|
||||||
|
appG := app.Gin{C: c}
|
||||||
|
id := c.Query("id")
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板ID为空!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板信息
|
||||||
|
template, err := models.GetTemplateByID(id)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "获取模板信息失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取关联的实例列表
|
||||||
|
insList, err := models.GetTemplateInsList(id)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "获取实例列表失败!", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"template": template,
|
||||||
|
"ins_list": insList,
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "获取模板信息成功", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateInsReq 模板实例请求结构
|
||||||
|
type TemplateInsReq struct {
|
||||||
|
ID string `json:"id" validate:"required,len=12" label:"实例id"`
|
||||||
|
TemplateID string `json:"template_id" validate:"required" label:"模板id"`
|
||||||
|
WayID string `json:"way_id" validate:"required,len=12" label:"渠道id"`
|
||||||
|
ContentType string `json:"content_type" validate:"required,max=100" label:"实例内容类型"`
|
||||||
|
Config string `json:"config" validate:"" label:"任务配置"`
|
||||||
|
Extra string `json:"extra" validate:"" label:"任务额外信息"`
|
||||||
|
WayType string `json:"way_type" validate:"required,max=100" label:"渠道类型"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTemplateIns 添加模板关联的实例
|
||||||
|
func AddTemplateIns(c *gin.Context) {
|
||||||
|
var (
|
||||||
|
appG = app.Gin{C: c}
|
||||||
|
req TemplateInsReq
|
||||||
|
)
|
||||||
|
|
||||||
|
errCode, errStr := app.BindJsonAndPlayValid(c, &req)
|
||||||
|
if errCode != e.SUCCESS {
|
||||||
|
appG.CResponse(errCode, errStr, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTaskInsService := send_ins_service.SendTaskInsService{}
|
||||||
|
err := sendTaskInsService.AddOne(models.SendTasksIns{
|
||||||
|
UUIDModel: models.UUIDModel{ID: req.ID},
|
||||||
|
TemplateID: req.TemplateID,
|
||||||
|
WayID: req.WayID,
|
||||||
|
WayType: req.WayType,
|
||||||
|
ContentType: req.ContentType,
|
||||||
|
Config: req.Config,
|
||||||
|
Extra: req.Extra,
|
||||||
|
})
|
||||||
|
if err != "" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("添加实例失败!错误原因:%s", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appG.CResponse(http.StatusOK, "添加实例成功!", nil)
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ type SendMessageReq struct {
|
|||||||
URL string `json:"url" label:"消息详情url地址"`
|
URL string `json:"url" label:"消息详情url地址"`
|
||||||
MarkDown string `json:"markdown" label:"markdown内容"`
|
MarkDown string `json:"markdown" label:"markdown内容"`
|
||||||
Mode string `json:"mode" label:"是否异步发送"`
|
Mode string `json:"mode" label:"是否异步发送"`
|
||||||
|
|
||||||
|
// @提及相关参数
|
||||||
|
AtMobiles []string `json:"at_mobiles" label:"@的手机号列表"`
|
||||||
|
AtUserIds []string `json:"at_user_ids" label:"@的用户ID列表"`
|
||||||
|
AtAll bool `json:"at_all" label:"是否@所有人"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoSendMassage 外部调用发信接口
|
// DoSendMassage 外部调用发信接口
|
||||||
@@ -56,15 +61,19 @@ func DoSendMassage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgService := send_message_service.SendMessageService{
|
msgService := send_message_service.SendMessageService{
|
||||||
TaskID: taskID,
|
SendMode: send_message_service.SendModeTask, // 明确标记为任务模式
|
||||||
Title: req.Title,
|
TaskID: taskID,
|
||||||
Text: req.Text,
|
Title: req.Title,
|
||||||
HTML: req.HTML,
|
Text: req.Text,
|
||||||
URL: req.URL,
|
HTML: req.HTML,
|
||||||
MarkDown: req.MarkDown,
|
URL: req.URL,
|
||||||
CallerIp: c.ClientIP(),
|
MarkDown: req.MarkDown,
|
||||||
|
CallerIp: c.ClientIP(),
|
||||||
|
AtMobiles: req.AtMobiles,
|
||||||
|
AtUserIds: req.AtUserIds,
|
||||||
|
AtAll: req.AtAll,
|
||||||
DefaultLogger: logrus.WithFields(logrus.Fields{
|
DefaultLogger: logrus.WithFields(logrus.Fields{
|
||||||
//"prefix": "[Message Instance]",
|
"prefix": "[Send Instance]",
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
task, err := msgService.SendPreCheck()
|
task, err := msgService.SendPreCheck()
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/app"
|
||||||
|
"message-nest/pkg/e"
|
||||||
|
utilpkg "message-nest/pkg/util"
|
||||||
|
"message-nest/service/send_message_service"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendMessageByTemplateReq struct {
|
||||||
|
Token string `json:"token" validate:"required" label:"模板token"`
|
||||||
|
Title string `json:"title" validate:"required" label:"消息标题"`
|
||||||
|
Placeholders map[string]interface{} `json:"placeholders" label:"占位符"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoSendMessageByTemplate 使用模板发送消息
|
||||||
|
func DoSendMessageByTemplate(c *gin.Context) {
|
||||||
|
var (
|
||||||
|
appG = app.Gin{C: c}
|
||||||
|
req SendMessageByTemplateReq
|
||||||
|
)
|
||||||
|
|
||||||
|
errCode, errMsg := app.BindJsonAndPlayValid(c, &req)
|
||||||
|
if errCode != e.SUCCESS {
|
||||||
|
appG.CResponse(errCode, errMsg, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 token 为模板 ID
|
||||||
|
templateID, err := utilpkg.DecryptTokenHex(req.Token, 71) // 71 为简单对称密钥
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("token解析失败:%v", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板
|
||||||
|
template, err := models.GetTemplateByID(templateID)
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("模板不存在:%s", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模板状态
|
||||||
|
if template.Status != "enabled" {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板已禁用", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换占位符
|
||||||
|
textContent := replacePlaceholders(template.TextTemplate, req.Placeholders)
|
||||||
|
htmlContent := replacePlaceholders(template.HTMLTemplate, req.Placeholders)
|
||||||
|
markdownContent := replacePlaceholders(template.MarkdownTemplate, req.Placeholders)
|
||||||
|
|
||||||
|
// 解析@提醒配置
|
||||||
|
var atMobiles []string
|
||||||
|
var atUserIds []string
|
||||||
|
if template.AtMobiles != "" {
|
||||||
|
atMobiles = strings.Split(template.AtMobiles, ",")
|
||||||
|
// 去除空格
|
||||||
|
for i := range atMobiles {
|
||||||
|
atMobiles[i] = strings.TrimSpace(atMobiles[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if template.AtUserIds != "" {
|
||||||
|
atUserIds = strings.Split(template.AtUserIds, ",")
|
||||||
|
// 去除空格
|
||||||
|
for i := range atUserIds {
|
||||||
|
atUserIds[i] = strings.TrimSpace(atUserIds[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板关联的实例列表
|
||||||
|
insList, err := models.GetTemplateInsList(templateID)
|
||||||
|
if err != nil || len(insList) == 0 {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板没有配置发送实例", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤启用的实例
|
||||||
|
var enabledCount int
|
||||||
|
for _, ins := range insList {
|
||||||
|
if ins.Enable == 1 {
|
||||||
|
enabledCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabledCount == 0 {
|
||||||
|
appG.CResponse(http.StatusBadRequest, "模板没有启用的发送实例", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用发送服务进行发送
|
||||||
|
// 将模板ID作为TaskID传入,用于日志记录
|
||||||
|
msgService := send_message_service.SendMessageService{
|
||||||
|
SendMode: send_message_service.SendModeTemplate, // 明确标记为模板模式
|
||||||
|
TaskID: templateID, // 使用模板ID作为TaskID(用于日志记录)
|
||||||
|
TemplateID: templateID, // 模板ID
|
||||||
|
Name: template.Name, // 模板名称
|
||||||
|
Title: req.Title,
|
||||||
|
Text: textContent,
|
||||||
|
HTML: htmlContent,
|
||||||
|
MarkDown: markdownContent,
|
||||||
|
CallerIp: c.ClientIP(),
|
||||||
|
AtMobiles: atMobiles,
|
||||||
|
AtUserIds: atUserIds,
|
||||||
|
AtAll: template.IsAtAll,
|
||||||
|
DefaultLogger: logrus.WithFields(logrus.Fields{
|
||||||
|
"prefix": "[Template Send]",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送前检查
|
||||||
|
task, err := msgService.SendPreCheck()
|
||||||
|
if err != nil {
|
||||||
|
appG.CResponse(http.StatusBadRequest, fmt.Sprintf("发送检查不通过:%s", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步发送
|
||||||
|
msgService.AsyncSend(task)
|
||||||
|
appG.CResponse(http.StatusOK, "success", map[string]interface{}{
|
||||||
|
"token": req.Token,
|
||||||
|
"count": enabledCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// replacePlaceholders 替换模板中的占位符
|
||||||
|
func replacePlaceholders(template string, placeholders map[string]interface{}) string {
|
||||||
|
if template == "" || placeholders == nil {
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
result := template
|
||||||
|
for key, value := range placeholders {
|
||||||
|
placeholder := fmt.Sprintf("{{%s}}", key)
|
||||||
|
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"message-nest/pkg/setting"
|
"message-nest/pkg/setting"
|
||||||
"message-nest/routers/api"
|
"message-nest/routers/api"
|
||||||
"message-nest/routers/api/v1"
|
"message-nest/routers/api/v1"
|
||||||
|
"message-nest/routers/api/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,6 +99,26 @@ func InitRouter(f embed.FS) *gin.Engine {
|
|||||||
// hostedMessage
|
// hostedMessage
|
||||||
apiV1.GET("/hostedmessages/list", v1.GetHostMessageList)
|
apiV1.GET("/hostedmessages/list", v1.GetHostMessageList)
|
||||||
|
|
||||||
|
// messageTemplate
|
||||||
|
apiV1.GET("/templates/list", v1.GetMessageTemplateList)
|
||||||
|
apiV1.GET("/templates/get", v1.GetMessageTemplate)
|
||||||
|
apiV1.POST("/templates/add", v1.AddMessageTemplate)
|
||||||
|
apiV1.POST("/templates/edit", v1.EditMessageTemplate)
|
||||||
|
apiV1.POST("/templates/delete", v1.DeleteMessageTemplate)
|
||||||
|
apiV1.POST("/templates/preview", v1.PreviewMessageTemplate)
|
||||||
|
|
||||||
|
// messageTemplate instances
|
||||||
|
apiV1.GET("/templates/ins/get", v1.GetTemplateWithIns)
|
||||||
|
apiV1.POST("/templates/ins/addone", v1.AddTemplateIns)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// API v2
|
||||||
|
apiV2 := app.Group("/api/v2")
|
||||||
|
apiV2.Use(middleware.JWT())
|
||||||
|
{
|
||||||
|
// message/send - 使用模板发送消息
|
||||||
|
apiV2.POST("/message/send", v2.DoSendMessageByTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package message_template_service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"message-nest/models"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateService struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
TextTemplate string
|
||||||
|
HTMLTemplate string
|
||||||
|
MarkdownTemplate string
|
||||||
|
Placeholders string
|
||||||
|
AtMobiles string
|
||||||
|
AtUserIds string
|
||||||
|
IsAtAll bool
|
||||||
|
Status string
|
||||||
|
Text string
|
||||||
|
|
||||||
|
PageNum int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder 占位符定义
|
||||||
|
type Placeholder struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 添加消息模板
|
||||||
|
func (s *TemplateService) Add() error {
|
||||||
|
if err := s.validatePlaceholders(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newUUID := models.GenerateTemplateUniqueID()
|
||||||
|
model := models.Template{
|
||||||
|
UUIDModel: models.UUIDModel{
|
||||||
|
ID: newUUID,
|
||||||
|
},
|
||||||
|
Name: s.Name,
|
||||||
|
Description: s.Description,
|
||||||
|
TextTemplate: s.TextTemplate,
|
||||||
|
HTMLTemplate: s.HTMLTemplate,
|
||||||
|
MarkdownTemplate: s.MarkdownTemplate,
|
||||||
|
Placeholders: s.Placeholders,
|
||||||
|
AtMobiles: s.AtMobiles,
|
||||||
|
AtUserIds: s.AtUserIds,
|
||||||
|
IsAtAll: s.IsAtAll,
|
||||||
|
Status: s.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.Add()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新消息模板
|
||||||
|
func (s *TemplateService) Update() error {
|
||||||
|
if err := s.validatePlaceholders(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
model := models.Template{
|
||||||
|
UUIDModel: models.UUIDModel{
|
||||||
|
ID: s.ID,
|
||||||
|
},
|
||||||
|
Name: s.Name,
|
||||||
|
Description: s.Description,
|
||||||
|
TextTemplate: s.TextTemplate,
|
||||||
|
HTMLTemplate: s.HTMLTemplate,
|
||||||
|
MarkdownTemplate: s.MarkdownTemplate,
|
||||||
|
Placeholders: s.Placeholders,
|
||||||
|
AtMobiles: s.AtMobiles,
|
||||||
|
AtUserIds: s.AtUserIds,
|
||||||
|
IsAtAll: s.IsAtAll,
|
||||||
|
Status: s.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.Update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除消息模板
|
||||||
|
func (s *TemplateService) Delete() error {
|
||||||
|
model := models.Template{
|
||||||
|
UUIDModel: models.UUIDModel{
|
||||||
|
ID: s.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return model.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取单个消息模板
|
||||||
|
func (s *TemplateService) Get() (*models.TemplateResult, error) {
|
||||||
|
return models.GetTemplateByID(s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll 获取消息模板列表
|
||||||
|
func (s *TemplateService) GetAll() ([]models.TemplateResult, error) {
|
||||||
|
templates, err := models.GetTemplates(s.PageNum, s.PageSize, s.Text, s.getMaps())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return templates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 获取消息模板总数
|
||||||
|
func (s *TemplateService) Count() (int64, error) {
|
||||||
|
return models.GetTemplatesTotal(s.Text, s.getMaps())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistByID 检查模板是否存在
|
||||||
|
func (s *TemplateService) ExistByID() (bool, error) {
|
||||||
|
return models.ExistTemplateByID(s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTemplate 渲染模板(替换占位符)
|
||||||
|
func (s *TemplateService) RenderTemplate(templateContent string, params map[string]string) string {
|
||||||
|
result := templateContent
|
||||||
|
|
||||||
|
for key, value := range params {
|
||||||
|
placeholder := "{{" + key + "}}"
|
||||||
|
result = strings.ReplaceAll(result, placeholder, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewTemplate 预览模板效果
|
||||||
|
func (s *TemplateService) PreviewTemplate(params map[string]string) (map[string]string, error) {
|
||||||
|
template, err := s.Get()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]string)
|
||||||
|
|
||||||
|
if template.TextTemplate != "" {
|
||||||
|
result["text"] = s.RenderTemplate(template.TextTemplate, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.HTMLTemplate != "" {
|
||||||
|
result["html"] = s.RenderTemplate(template.HTMLTemplate, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.MarkdownTemplate != "" {
|
||||||
|
result["markdown"] = s.RenderTemplate(template.MarkdownTemplate, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePlaceholders 验证占位符格式
|
||||||
|
func (s *TemplateService) validatePlaceholders() error {
|
||||||
|
if s.Placeholders == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var placeholders []Placeholder
|
||||||
|
if err := json.Unmarshal([]byte(s.Placeholders), &placeholders); err != nil {
|
||||||
|
return errors.New("占位符格式错误,必须是有效的JSON数组")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range placeholders {
|
||||||
|
if p.Key == "" {
|
||||||
|
return errors.New("占位符的key不能为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMaps 获取查询条件
|
||||||
|
func (s *TemplateService) getMaps() map[string]interface{} {
|
||||||
|
maps := make(map[string]interface{})
|
||||||
|
|
||||||
|
if s.Status != "" {
|
||||||
|
maps["status"] = s.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
return maps
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package send_message_service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"message-nest/models"
|
|
||||||
"message-nest/pkg/message"
|
|
||||||
"message-nest/service/send_way_service"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CustomService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendCustomMessage 执行发送钉钉
|
|
||||||
func (s *CustomService) SendCustomMessage(auth send_way_service.WayDetailCustom, ins models.SendTasksIns, typeC string, title string, content string) (string, string) {
|
|
||||||
errMsg := ""
|
|
||||||
cli := message.CustomWebhook{}
|
|
||||||
data, _ := json.Marshal(content)
|
|
||||||
dataStr := string(data)
|
|
||||||
dataStr = strings.Trim(dataStr, "\"")
|
|
||||||
bodyStr := strings.Replace(auth.Body, "TEXT", dataStr, -1)
|
|
||||||
res, err := cli.Request(auth.Webhook, bodyStr)
|
|
||||||
if err != nil {
|
|
||||||
errMsg = fmt.Sprintf("发送失败:%s", err)
|
|
||||||
}
|
|
||||||
return string(res), errMsg
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package send_message_service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"message-nest/models"
|
|
||||||
"message-nest/pkg/message"
|
|
||||||
"message-nest/service/send_ins_service"
|
|
||||||
"message-nest/service/send_way_service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DtalkService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendDtalkMessage 执行发送钉钉
|
|
||||||
func (s *DtalkService) SendDtalkMessage(auth send_way_service.WayDetailDTalk, ins models.SendTasksIns, typeC string, title string, content string) (string, string) {
|
|
||||||
insService := send_ins_service.SendTaskInsService{}
|
|
||||||
errStr, c := insService.ValidateDiffIns(ins)
|
|
||||||
if errStr != "" {
|
|
||||||
return errStr, ""
|
|
||||||
}
|
|
||||||
_, ok := c.(models.InsDtalkConfig)
|
|
||||||
if !ok {
|
|
||||||
return "钉钉config校验失败", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
errMsg := ""
|
|
||||||
var res []byte
|
|
||||||
var err error
|
|
||||||
cli := message.Dtalk{
|
|
||||||
AccessToken: auth.AccessToken,
|
|
||||||
Secret: auth.Secret,
|
|
||||||
}
|
|
||||||
if typeC == "text" {
|
|
||||||
res, err = cli.SendMessageText(content)
|
|
||||||
if err != nil {
|
|
||||||
errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
} else if typeC == "markdown" {
|
|
||||||
res, err = cli.SendMessageMarkdown(title, content)
|
|
||||||
if err != nil {
|
|
||||||
errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errMsg = fmt.Sprintf("未知的钉钉发送内容类型:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
return string(res), errMsg
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package send_message_service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"message-nest/models"
|
|
||||||
"message-nest/pkg/message"
|
|
||||||
"message-nest/service/send_ins_service"
|
|
||||||
"message-nest/service/send_way_service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EmailService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendTaskEmail 执行发送邮件
|
|
||||||
func (s *EmailService) SendTaskEmail(auth send_way_service.WayDetailEmail, ins models.SendTasksIns, typeC string, title string, content string) string {
|
|
||||||
insService := send_ins_service.SendTaskInsService{}
|
|
||||||
errStr, c := insService.ValidateDiffIns(ins)
|
|
||||||
if errStr != "" {
|
|
||||||
return errStr
|
|
||||||
}
|
|
||||||
config, ok := c.(models.InsEmailConfig)
|
|
||||||
if !ok {
|
|
||||||
return "邮箱config校验失败"
|
|
||||||
}
|
|
||||||
|
|
||||||
var emailer message.EmailMessage
|
|
||||||
errMsg := ""
|
|
||||||
emailer.Init(auth.Server, auth.Port, auth.Account, auth.Passwd)
|
|
||||||
if typeC == "text" {
|
|
||||||
errMsg = emailer.SendTextMessage(config.ToAccount, title, content)
|
|
||||||
} else if typeC == "html" {
|
|
||||||
errMsg = emailer.SendHtmlMessage(config.ToAccount, title, content)
|
|
||||||
} else {
|
|
||||||
errMsg = fmt.Sprintf("未知的邮件发送内容类型:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
return errMsg
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package send_message_service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"message-nest/models"
|
|
||||||
"message-nest/service/hosted_message_service"
|
|
||||||
"message-nest/service/send_way_service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HostMessageService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendHostMessage 执行托管消息记录
|
|
||||||
func (s *HostMessageService) SendHostMessage(
|
|
||||||
auth send_way_service.MessageNest,
|
|
||||||
ins models.SendTasksIns,
|
|
||||||
typeC string,
|
|
||||||
title string,
|
|
||||||
content string) (string, string) {
|
|
||||||
|
|
||||||
errMsg := ""
|
|
||||||
var res string
|
|
||||||
var err error
|
|
||||||
messageService := hosted_message_service.HostMessageService{
|
|
||||||
Title: title,
|
|
||||||
Content: content,
|
|
||||||
Type: typeC,
|
|
||||||
}
|
|
||||||
err = messageService.Add()
|
|
||||||
if err != nil {
|
|
||||||
errMsg = err.Error()
|
|
||||||
res = "托管消息创建失败!"
|
|
||||||
} else {
|
|
||||||
res = "托管消息创建成功!"
|
|
||||||
}
|
|
||||||
return string(res), errMsg
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"message-nest/models"
|
"message-nest/models"
|
||||||
"message-nest/pkg/constant"
|
"message-nest/pkg/constant"
|
||||||
|
"message-nest/service/send_message_service/unified"
|
||||||
"message-nest/service/send_task_service"
|
"message-nest/service/send_task_service"
|
||||||
"message-nest/service/send_way_service"
|
"message-nest/service/send_way_service"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +18,12 @@ const (
|
|||||||
SendFail = 0
|
SendFail = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 发送模式类型
|
||||||
|
const (
|
||||||
|
SendModeTask = "task" // 传统任务模式
|
||||||
|
SendModeTemplate = "template" // 模板模式
|
||||||
|
)
|
||||||
|
|
||||||
func errStrIsSuccess(errStr string) int {
|
func errStrIsSuccess(errStr string) int {
|
||||||
if errStr == "" {
|
if errStr == "" {
|
||||||
return SendSuccess
|
return SendSuccess
|
||||||
@@ -25,13 +32,21 @@ func errStrIsSuccess(errStr string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SendMessageService struct {
|
type SendMessageService struct {
|
||||||
TaskID string
|
SendMode string // 发送模式:task(任务模式) 或 template(模板模式)
|
||||||
Title string
|
TaskID string // 任务ID(任务模式)或模板ID(模板模式,用于日志记录)
|
||||||
Text string
|
TemplateID string // 模板ID(仅模板模式使用)
|
||||||
HTML string
|
Name string // 任务或模板名称(用于日志记录)
|
||||||
URL string
|
Title string
|
||||||
MarkDown string
|
Text string
|
||||||
CallerIp string
|
HTML string
|
||||||
|
URL string
|
||||||
|
MarkDown string
|
||||||
|
CallerIp string
|
||||||
|
|
||||||
|
// @提及相关字段
|
||||||
|
AtMobiles []string
|
||||||
|
AtUserIds []string
|
||||||
|
AtAll bool
|
||||||
|
|
||||||
Status int
|
Status int
|
||||||
LogOutput []string
|
LogOutput []string
|
||||||
@@ -76,31 +91,88 @@ func (sm *SendMessageService) AsyncSend(task models.TaskIns) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendPreCheck 发送前数据准备和预检查
|
// SendPreCheck 发送前数据准备和预检查
|
||||||
|
// 支持两种模式:
|
||||||
|
// 1. SendModeTask:传统任务模式,使用 TaskID 查询任务和实例
|
||||||
|
// 2. SendModeTemplate:模板模式,使用 TemplateID 查询模板关联的实例
|
||||||
func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) {
|
func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) {
|
||||||
errStr := ""
|
errStr := ""
|
||||||
entry := logrus.WithFields(logrus.Fields{
|
entry := logrus.WithFields(logrus.Fields{
|
||||||
"prefix": "[Message PreChecK]",
|
"prefix": "[Message PreChecK]",
|
||||||
})
|
})
|
||||||
sendTaskService := send_task_service.SendTaskService{
|
|
||||||
ID: sm.TaskID,
|
var task models.TaskIns
|
||||||
}
|
|
||||||
task, err := sendTaskService.GetTaskWithIns()
|
switch sm.SendMode {
|
||||||
if err != nil {
|
case SendModeTemplate:
|
||||||
errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID)
|
// 模板模式:使用模板ID获取实例
|
||||||
|
if sm.TemplateID == "" {
|
||||||
|
errStr = "模板模式下 TemplateID 不能为空"
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模板关联的实例列表
|
||||||
|
insList, err := models.GetTemplateInsList(sm.TemplateID)
|
||||||
|
if err != nil {
|
||||||
|
errStr = fmt.Sprintf("模板[%s]实例查询失败:%s", sm.TemplateID, err)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
if len(insList) == 0 {
|
||||||
|
errStr = fmt.Sprintf("模板[%s]没有关联任何实例!", sm.TemplateID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造虚拟任务对象(用于兼容现有发送逻辑)
|
||||||
|
// 将模板ID作为TaskID使用,便于日志记录
|
||||||
|
task.ID = sm.TaskID // 使用传入的TaskID(实际是模板ID)
|
||||||
|
task.InsData = make([]models.SendTasksInsRes, 0, len(insList))
|
||||||
|
for _, ins := range insList {
|
||||||
|
task.InsData = append(task.InsData, ins)
|
||||||
|
}
|
||||||
|
entry.Infof("模板[%s]加载了 %d 个实例", sm.TemplateID, len(insList))
|
||||||
|
return task, nil
|
||||||
|
|
||||||
|
case SendModeTask:
|
||||||
|
// 传统任务模式:使用任务ID查询
|
||||||
|
if sm.TaskID == "" {
|
||||||
|
errStr = "任务模式下 TaskID 不能为空"
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTaskService := send_task_service.SendTaskService{
|
||||||
|
ID: sm.TaskID,
|
||||||
|
}
|
||||||
|
task, err := sendTaskService.GetTaskWithIns()
|
||||||
|
if err != nil {
|
||||||
|
errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
if task.ID == "" {
|
||||||
|
errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
if len(task.InsData) == 0 {
|
||||||
|
errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID)
|
||||||
|
entry.Errorf(errStr)
|
||||||
|
return task, errors.New(errStr)
|
||||||
|
}
|
||||||
|
// 设置任务名称用于日志记录
|
||||||
|
if sm.Name == "" {
|
||||||
|
sm.Name = task.Name
|
||||||
|
}
|
||||||
|
return task, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
// SendMode 未设置或无效
|
||||||
|
errStr = fmt.Sprintf("SendMode 未设置或无效: %s,必须是 '%s' 或 '%s'", sm.SendMode, SendModeTask, SendModeTemplate)
|
||||||
entry.Errorf(errStr)
|
entry.Errorf(errStr)
|
||||||
return task, errors.New(errStr)
|
return task, errors.New(errStr)
|
||||||
}
|
}
|
||||||
if task.ID == "" {
|
|
||||||
errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID)
|
|
||||||
entry.Errorf(errStr)
|
|
||||||
return task, errors.New(errStr)
|
|
||||||
}
|
|
||||||
if len(task.InsData) == 0 {
|
|
||||||
errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID)
|
|
||||||
entry.Errorf(errStr)
|
|
||||||
return task, errors.New(errStr)
|
|
||||||
}
|
|
||||||
return task, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send 发送一个消息任务的所有实例
|
// Send 发送一个消息任务的所有实例
|
||||||
@@ -137,13 +209,6 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
|
|||||||
sm.LogsAndStatusMark(fmt.Sprintf("实例类型: %s + %s", ins.WayType, ins.ContentType), sm.Status)
|
sm.LogsAndStatusMark(fmt.Sprintf("实例类型: %s + %s", ins.WayType, ins.ContentType), sm.Status)
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("实例配置: %s", ins.Config), sm.Status)
|
sm.LogsAndStatusMark(fmt.Sprintf("实例配置: %s", ins.Config), sm.Status)
|
||||||
|
|
||||||
// 发送内容校验绑定
|
|
||||||
typeC, content := sm.GetSendMsg(ins.SendTasksIns)
|
|
||||||
if content == "" {
|
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("发送内容为空,设置的类型: %s,实际检测的类型: %s", ins.SendTasksIns.ContentType, typeC), SendFail)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送渠道的校验
|
// 发送渠道的校验
|
||||||
errStr, msgObj := wayService.ValidateDiffWay()
|
errStr, msgObj := wayService.ValidateDiffWay()
|
||||||
if errStr != "" {
|
if errStr != "" {
|
||||||
@@ -151,61 +216,50 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 邮箱类型的实例发送
|
// 使用新的Channel架构发送消息
|
||||||
emailAuth, ok := msgObj.(send_way_service.WayDetailEmail)
|
channelRegistry := unified.GetGlobalChannelRegistry()
|
||||||
if ok {
|
channel, ok := channelRegistry.GetChannel(way.Type)
|
||||||
//continue
|
if !ok {
|
||||||
es := EmailService{}
|
sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道类型 %s 的发信实例: %s\n", way.Type, ins.ID), SendFail)
|
||||||
errMsg := es.SendTaskEmail(emailAuth, ins.SendTasksIns, typeC, sm.Title, content)
|
|
||||||
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 钉钉类型的实例发送
|
|
||||||
dtalkAuth, ok := msgObj.(send_way_service.WayDetailDTalk)
|
// 根据发送模式构建消息内容
|
||||||
if ok {
|
var unifiedContent *unified.UnifiedMessageContent
|
||||||
es := DtalkService{}
|
if sm.SendMode == SendModeTemplate {
|
||||||
res, errMsg := es.SendDtalkMessage(dtalkAuth, ins.SendTasksIns, typeC, sm.Title, content)
|
// 模板模式:根据实例的 ContentType 精确发送对应类型的内容
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status)
|
unifiedContent = sm.BuildTemplateContent(ins.SendTasksIns)
|
||||||
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
|
if unifiedContent == nil {
|
||||||
continue
|
sm.LogsAndStatusMark(fmt.Sprintf("模板内容为空,实例类型: %s", ins.ContentType), SendFail)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 任务模式:使用现有逻辑(支持内容类型回退)
|
||||||
|
typeC, content := sm.GetSendMsg(ins.SendTasksIns)
|
||||||
|
if content == "" {
|
||||||
|
sm.LogsAndStatusMark(fmt.Sprintf("发送内容为空,设置的类型: %s,实际检测的类型: %s", ins.SendTasksIns.ContentType, typeC), SendFail)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 构建统一消息内容(支持@功能)
|
||||||
|
unifiedContent = &unified.UnifiedMessageContent{
|
||||||
|
Title: sm.Title,
|
||||||
|
Text: sm.Text,
|
||||||
|
HTML: sm.HTML,
|
||||||
|
Markdown: sm.MarkDown,
|
||||||
|
URL: sm.URL,
|
||||||
|
AtMobiles: sm.AtMobiles,
|
||||||
|
AtUserIds: sm.AtUserIds,
|
||||||
|
AtAll: sm.AtAll,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 企业微信类型的实例发送
|
|
||||||
qywxAuth, ok := msgObj.(send_way_service.WayDetailQyWeiXin)
|
// 使用 SendUnified 方法(自动格式转换和@功能支持)
|
||||||
if ok {
|
res, errMsg := channel.SendUnified(msgObj, ins.SendTasksIns, unifiedContent)
|
||||||
es := QyWeiXinService{}
|
if res != "" {
|
||||||
res, errMsg := es.SendQyWeiXinMessage(qywxAuth, ins.SendTasksIns, typeC, sm.Title, content)
|
sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s\n", res), sm.Status)
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status)
|
} else {
|
||||||
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
|
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
// 自定义webhook类型的实例发送
|
|
||||||
customAuth, ok := msgObj.(send_way_service.WayDetailCustom)
|
|
||||||
if ok {
|
|
||||||
cs := CustomService{}
|
|
||||||
res, errMsg := cs.SendCustomMessage(customAuth, ins.SendTasksIns, typeC, sm.Title, content)
|
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status)
|
|
||||||
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 微信公众号模板消息的实例发送
|
|
||||||
wca, ok := msgObj.(send_way_service.WeChatOFAccount)
|
|
||||||
if ok {
|
|
||||||
cs := WeChatOfAccountService{}
|
|
||||||
res, errMsg := cs.SendWeChatOfAccountMessage(wca, ins.SendTasksIns, typeC, sm.Title, content, sm.URL)
|
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status)
|
|
||||||
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 托管消息的实例发送
|
|
||||||
mnt, ok := msgObj.(send_way_service.MessageNest)
|
|
||||||
if ok {
|
|
||||||
cs := HostMessageService{}
|
|
||||||
res, errMsg := cs.SendHostMessage(mnt, ins.SendTasksIns, typeC, sm.Title, content)
|
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status)
|
|
||||||
sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道的发信实例: %s\n", ins.ID), SendFail)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,9 +292,17 @@ func (sm *SendMessageService) AppendSendContent() {
|
|||||||
|
|
||||||
// RecordSendLog 记录发送日志
|
// RecordSendLog 记录发送日志
|
||||||
func (sm *SendMessageService) RecordSendLog() {
|
func (sm *SendMessageService) RecordSendLog() {
|
||||||
|
// 确定日志类型
|
||||||
|
logType := "task"
|
||||||
|
if sm.SendMode == SendModeTemplate {
|
||||||
|
logType = "template"
|
||||||
|
}
|
||||||
|
|
||||||
log := models.SendTasksLogs{
|
log := models.SendTasksLogs{
|
||||||
Log: strings.Join(sm.LogOutput, "\n"),
|
Log: strings.Join(sm.LogOutput, "\n"),
|
||||||
TaskID: sm.TaskID,
|
TaskID: sm.TaskID,
|
||||||
|
Type: logType,
|
||||||
|
Name: sm.Name,
|
||||||
Status: &sm.Status,
|
Status: &sm.Status,
|
||||||
CallerIp: sm.CallerIp,
|
CallerIp: sm.CallerIp,
|
||||||
}
|
}
|
||||||
@@ -259,22 +321,67 @@ func (sm *SendMessageService) TransError(err string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSendMsg 获取对应消息内容
|
// BuildTemplateContent 构建模板模式的消息内容
|
||||||
|
// 模板模式:根据实例的 ContentType 精确匹配对应类型的内容,只传递该类型的内容
|
||||||
|
func (sm *SendMessageService) BuildTemplateContent(ins models.SendTasksIns) *unified.UnifiedMessageContent {
|
||||||
|
contentType := strings.ToLower(ins.ContentType)
|
||||||
|
|
||||||
|
// 内容类型映射表
|
||||||
|
contentMap := map[string]string{
|
||||||
|
unified.FormatTypeText: sm.Text,
|
||||||
|
unified.FormatTypeHTML: sm.HTML,
|
||||||
|
unified.FormatTypeMarkdown: sm.MarkDown,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查内容是否存在
|
||||||
|
contentValue, exists := contentMap[contentType]
|
||||||
|
if !exists {
|
||||||
|
logrus.Warnf("模板模式:未知的内容类型 %s", ins.ContentType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if contentValue == "" {
|
||||||
|
logrus.Warnf("模板模式:实例要求的 %s 类型内容为空", contentType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建消息内容,只填充实例要求的类型
|
||||||
|
content := &unified.UnifiedMessageContent{
|
||||||
|
Title: sm.Title,
|
||||||
|
URL: sm.URL,
|
||||||
|
AtMobiles: sm.AtMobiles,
|
||||||
|
AtUserIds: sm.AtUserIds,
|
||||||
|
AtAll: sm.AtAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据类型填充对应字段
|
||||||
|
switch contentType {
|
||||||
|
case unified.FormatTypeText:
|
||||||
|
content.Text = contentValue
|
||||||
|
case unified.FormatTypeHTML:
|
||||||
|
content.HTML = contentValue
|
||||||
|
case unified.FormatTypeMarkdown:
|
||||||
|
content.Markdown = contentValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSendMsg 获取对应消息内容(任务模式使用)
|
||||||
// 先根据实例设置的类型取,取不到或者取到的是空,则使用text发送
|
// 先根据实例设置的类型取,取不到或者取到的是空,则使用text发送
|
||||||
func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) {
|
func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) {
|
||||||
data := map[string]string{}
|
data := map[string]string{}
|
||||||
data["text"] = sm.Text
|
data[unified.FormatTypeText] = sm.Text
|
||||||
data["html"] = sm.HTML
|
data[unified.FormatTypeHTML] = sm.HTML
|
||||||
data["markdown"] = sm.MarkDown
|
data[unified.FormatTypeMarkdown] = sm.MarkDown
|
||||||
content, ok := data[strings.ToLower(ins.ContentType)]
|
content, ok := data[strings.ToLower(ins.ContentType)]
|
||||||
if !ok || len(content) == 0 {
|
if !ok || len(content) == 0 {
|
||||||
content, ok := data["text"]
|
content, ok := data[unified.FormatTypeText]
|
||||||
if !ok {
|
if !ok {
|
||||||
logrus.Error("text节点数据为空!")
|
logrus.Error("text节点数据为空!")
|
||||||
return "text", ""
|
return unified.FormatTypeText, ""
|
||||||
} else {
|
} else {
|
||||||
logrus.Error(fmt.Sprintf("没有找到%s对应的消息,使用text消息替代!", ins.ContentType))
|
logrus.Error(fmt.Sprintf("没有找到%s对应的消息,使用text消息替代!", ins.ContentType))
|
||||||
return "text", content
|
return unified.FormatTypeText, content
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return strings.ToLower(ins.ContentType), content
|
return strings.ToLower(ins.ContentType), content
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package send_message_service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"message-nest/models"
|
|
||||||
"message-nest/pkg/message"
|
|
||||||
"message-nest/service/send_ins_service"
|
|
||||||
"message-nest/service/send_way_service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type QyWeiXinService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendDtalkMessage 执行发送钉钉
|
|
||||||
func (s *QyWeiXinService) SendQyWeiXinMessage(auth send_way_service.WayDetailQyWeiXin, ins models.SendTasksIns, typeC string, title string, content string) (string, string) {
|
|
||||||
insService := send_ins_service.SendTaskInsService{}
|
|
||||||
errStr, c := insService.ValidateDiffIns(ins)
|
|
||||||
if errStr != "" {
|
|
||||||
return errStr, ""
|
|
||||||
}
|
|
||||||
_, ok := c.(models.InsQyWeiXinConfig)
|
|
||||||
if !ok {
|
|
||||||
return "企业微信config校验失败", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
errMsg := ""
|
|
||||||
var res []byte
|
|
||||||
var err error
|
|
||||||
cli := message.QyWeiXin{
|
|
||||||
AccessToken: auth.AccessToken,
|
|
||||||
}
|
|
||||||
if typeC == "text" {
|
|
||||||
res, err = cli.SendMessageText(content)
|
|
||||||
if err != nil {
|
|
||||||
errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
} else if typeC == "markdown" {
|
|
||||||
res, err = cli.SendMessageMarkdown(title, content)
|
|
||||||
if err != nil {
|
|
||||||
errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errMsg = fmt.Sprintf("未知的企业微信发送内容类型:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
return string(res), errMsg
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package send_message_service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"message-nest/models"
|
|
||||||
"message-nest/pkg/message"
|
|
||||||
"message-nest/service/send_ins_service"
|
|
||||||
"message-nest/service/send_way_service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WeChatOfAccountService struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendWeChatOfAccountMessage 执行发送微信公众号模板消息
|
|
||||||
func (s *WeChatOfAccountService) SendWeChatOfAccountMessage(auth send_way_service.WeChatOFAccount, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) {
|
|
||||||
insService := send_ins_service.SendTaskInsService{}
|
|
||||||
errStr, c := insService.ValidateDiffIns(ins)
|
|
||||||
if errStr != "" {
|
|
||||||
return errStr, ""
|
|
||||||
}
|
|
||||||
config, ok := c.(models.InsWeChatAccountConfig)
|
|
||||||
if !ok {
|
|
||||||
return "微信公众号模板消息config校验失败", ""
|
|
||||||
}
|
|
||||||
var (
|
|
||||||
err error
|
|
||||||
res string
|
|
||||||
errMsg string
|
|
||||||
)
|
|
||||||
cli := message.WeChatOFAccount{
|
|
||||||
AppID: auth.AppID,
|
|
||||||
AppSecret: auth.APPSecret,
|
|
||||||
TemplateID: auth.TempID,
|
|
||||||
ToUser: config.ToAccount,
|
|
||||||
URL: url,
|
|
||||||
}
|
|
||||||
res, err = cli.Send(title, content)
|
|
||||||
if err != nil {
|
|
||||||
errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType)
|
|
||||||
}
|
|
||||||
return res, errMsg
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package unified
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/service/send_message_service/unified/channels"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 重导出channels包的类型,方便外部使用
|
||||||
|
type (
|
||||||
|
Channel = channels.Channel
|
||||||
|
UnifiedMessageContent = channels.UnifiedMessageContent
|
||||||
|
)
|
||||||
|
|
||||||
|
// 重导出常量
|
||||||
|
const (
|
||||||
|
FormatTypeText = channels.FormatTypeText
|
||||||
|
FormatTypeHTML = channels.FormatTypeHTML
|
||||||
|
FormatTypeMarkdown = channels.FormatTypeMarkdown
|
||||||
|
|
||||||
|
MessageTypeEmail = channels.MessageTypeEmail
|
||||||
|
MessageTypeDtalk = channels.MessageTypeDtalk
|
||||||
|
MessageTypeQyWeiXin = channels.MessageTypeQyWeiXin
|
||||||
|
MessageTypeCustom = channels.MessageTypeCustom
|
||||||
|
MessageTypeWeChatOFAccount = channels.MessageTypeWeChatOFAccount
|
||||||
|
MessageTypeMessageNest = channels.MessageTypeMessageNest
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelRegistry 渠道注册表
|
||||||
|
type ChannelRegistry struct {
|
||||||
|
channels map[string]Channel
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChannelRegistry 创建渠道注册表
|
||||||
|
func NewChannelRegistry() *ChannelRegistry {
|
||||||
|
return &ChannelRegistry{
|
||||||
|
channels: make(map[string]Channel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册渠道
|
||||||
|
func (r *ChannelRegistry) Register(channel Channel) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.channels[channel.GetType()] = channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannel 获取渠道
|
||||||
|
func (r *ChannelRegistry) GetChannel(channelType string) (Channel, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
channel, ok := r.channels[channelType]
|
||||||
|
return channel, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllChannels 获取所有渠道
|
||||||
|
func (r *ChannelRegistry) GetAllChannels() map[string]Channel {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make(map[string]Channel, len(r.channels))
|
||||||
|
for k, v := range r.channels {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChannels 列出所有渠道
|
||||||
|
func (r *ChannelRegistry) ListChannels() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
types := make([]string, 0, len(r.channels))
|
||||||
|
for t := range r.channels {
|
||||||
|
types = append(types, t)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalChannelRegistry *ChannelRegistry
|
||||||
|
channelRegistryOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetGlobalChannelRegistry 获取全局渠道注册表(单例)
|
||||||
|
func GetGlobalChannelRegistry() *ChannelRegistry {
|
||||||
|
channelRegistryOnce.Do(func() {
|
||||||
|
globalChannelRegistry = NewChannelRegistry()
|
||||||
|
|
||||||
|
// 注册所有渠道
|
||||||
|
globalChannelRegistry.Register(channels.NewEmailChannel())
|
||||||
|
globalChannelRegistry.Register(channels.NewDtalkChannel())
|
||||||
|
globalChannelRegistry.Register(channels.NewQyWeiXinChannel())
|
||||||
|
globalChannelRegistry.Register(channels.NewCustomChannel())
|
||||||
|
globalChannelRegistry.Register(channels.NewWeChatOFAccountChannel())
|
||||||
|
globalChannelRegistry.Register(channels.NewMessageNestChannel())
|
||||||
|
})
|
||||||
|
return globalChannelRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelInfo 获取渠道信息(用于调试和文档)
|
||||||
|
func GetChannelInfo(channelType string) (string, error) {
|
||||||
|
registry := GetGlobalChannelRegistry()
|
||||||
|
channel, ok := registry.GetChannel(channelType)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("未知的渠道类型: %s", channelType)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := fmt.Sprintf("渠道: %s\n支持格式: %v\n",
|
||||||
|
channel.GetType(),
|
||||||
|
channel.GetSupportedFormats())
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllChannels 列出所有渠道信息
|
||||||
|
func ListAllChannels() string {
|
||||||
|
registry := GetGlobalChannelRegistry()
|
||||||
|
allChannels := registry.GetAllChannels()
|
||||||
|
|
||||||
|
result := "已注册的渠道:\n"
|
||||||
|
for channelType, channel := range allChannels {
|
||||||
|
result += fmt.Sprintf(" - %s: %v\n", channelType, channel.GetSupportedFormats())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import "message-nest/models"
|
||||||
|
|
||||||
|
type Channel interface {
|
||||||
|
GetType() string
|
||||||
|
GetSupportedFormats() []string
|
||||||
|
FormatContent(content *UnifiedMessageContent) (formatType string, formattedContent string, err error)
|
||||||
|
SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseChannel struct {
|
||||||
|
channelType string
|
||||||
|
supportedFormats []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBaseChannel(channelType string, supportedFormats []string) *BaseChannel {
|
||||||
|
return &BaseChannel{channelType: channelType, supportedFormats: supportedFormats}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BaseChannel) GetType() string { return c.channelType }
|
||||||
|
func (c *BaseChannel) GetSupportedFormats() []string { return c.supportedFormats }
|
||||||
|
|
||||||
|
func (c *BaseChannel) FormatContent(content *UnifiedMessageContent) (string, string, error) {
|
||||||
|
for _, formatType := range c.supportedFormats {
|
||||||
|
switch formatType {
|
||||||
|
case FormatTypeMarkdown:
|
||||||
|
if content.HasMarkdown() {
|
||||||
|
return FormatTypeMarkdown, content.Markdown, nil
|
||||||
|
}
|
||||||
|
case FormatTypeHTML:
|
||||||
|
if content.HasHTML() {
|
||||||
|
return FormatTypeHTML, content.HTML, nil
|
||||||
|
}
|
||||||
|
case FormatTypeText:
|
||||||
|
if content.HasText() {
|
||||||
|
return FormatTypeText, content.Text, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content.HasText() {
|
||||||
|
return FormatTypeText, content.Text, nil
|
||||||
|
}
|
||||||
|
return FormatTypeText, "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/message"
|
||||||
|
"message-nest/service/send_way_service"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomChannel struct{ *BaseChannel }
|
||||||
|
|
||||||
|
func NewCustomChannel() *CustomChannel {
|
||||||
|
return &CustomChannel{BaseChannel: NewBaseChannel(MessageTypeCustom, []string{FormatTypeText})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CustomChannel) FormatContent(content *UnifiedMessageContent) (string, string, error) {
|
||||||
|
if content.HasText() {
|
||||||
|
return FormatTypeText, content.Text, nil
|
||||||
|
}
|
||||||
|
if content.HasMarkdown() {
|
||||||
|
return FormatTypeText, markdownToText(content.Markdown), nil
|
||||||
|
}
|
||||||
|
if content.HasHTML() {
|
||||||
|
return FormatTypeText, htmlToText(content.HTML), nil
|
||||||
|
}
|
||||||
|
return FormatTypeText, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CustomChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) {
|
||||||
|
auth, ok := msgObj.(send_way_service.WayDetailCustom)
|
||||||
|
if !ok {
|
||||||
|
return "", "类型转换失败"
|
||||||
|
}
|
||||||
|
_, formattedContent, err := c.FormatContent(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
cli := message.CustomWebhook{}
|
||||||
|
data, _ := json.Marshal(formattedContent)
|
||||||
|
dataStr := strings.Trim(string(data), "\"")
|
||||||
|
bodyStr := strings.Replace(auth.Body, "TEXT", dataStr, -1)
|
||||||
|
res, err := cli.Request(auth.Webhook, bodyStr)
|
||||||
|
var errMsg string
|
||||||
|
if err != nil {
|
||||||
|
errMsg = fmt.Sprintf("发送失败:%s", err.Error())
|
||||||
|
}
|
||||||
|
return string(res), errMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownToText(md string) string {
|
||||||
|
t := md
|
||||||
|
t = regexp.MustCompile(`#{1,6}\s+`).ReplaceAllString(t, "")
|
||||||
|
t = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(t, "$1")
|
||||||
|
t = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(t, "$1")
|
||||||
|
t = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(t, "$1")
|
||||||
|
t = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(t, "$1")
|
||||||
|
t = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(t, "$1")
|
||||||
|
t = regexp.MustCompile("```[^`]*```").ReplaceAllString(t, "")
|
||||||
|
t = regexp.MustCompile("`([^`]+)`").ReplaceAllString(t, "$1")
|
||||||
|
t = regexp.MustCompile(`(?m)^>\s+`).ReplaceAllString(t, "")
|
||||||
|
t = regexp.MustCompile(`(?m)^[\*\-\+]\s+`).ReplaceAllString(t, "")
|
||||||
|
t = regexp.MustCompile(`(?m)^\d+\.\s+`).ReplaceAllString(t, "")
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlToText(html string) string {
|
||||||
|
t := html
|
||||||
|
t = regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`).ReplaceAllString(t, "")
|
||||||
|
t = regexp.MustCompile(`(?i)<style[^>]*>.*?</style>`).ReplaceAllString(t, "")
|
||||||
|
t = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(t, "\n")
|
||||||
|
t = regexp.MustCompile(`(?i)</p>`).ReplaceAllString(t, "\n")
|
||||||
|
t = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(t, "")
|
||||||
|
t = strings.ReplaceAll(t, " ", " ")
|
||||||
|
t = strings.ReplaceAll(t, "<", "<")
|
||||||
|
t = strings.ReplaceAll(t, ">", ">")
|
||||||
|
t = strings.ReplaceAll(t, "&", "&")
|
||||||
|
t = strings.ReplaceAll(t, """, "\"")
|
||||||
|
t = regexp.MustCompile(`\n{3,}`).ReplaceAllString(t, "\n\n")
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/message"
|
||||||
|
"message-nest/service/send_ins_service"
|
||||||
|
"message-nest/service/send_way_service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DtalkChannel struct{ *BaseChannel }
|
||||||
|
|
||||||
|
func NewDtalkChannel() *DtalkChannel {
|
||||||
|
return &DtalkChannel{BaseChannel: NewBaseChannel(MessageTypeDtalk, []string{FormatTypeMarkdown, FormatTypeText})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DtalkChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) {
|
||||||
|
auth, ok := msgObj.(send_way_service.WayDetailDTalk)
|
||||||
|
if !ok {
|
||||||
|
return "", "类型转换失败"
|
||||||
|
}
|
||||||
|
insService := send_ins_service.SendTaskInsService{}
|
||||||
|
errStr, configInterface := insService.ValidateDiffIns(ins)
|
||||||
|
if errStr != "" {
|
||||||
|
return errStr, ""
|
||||||
|
}
|
||||||
|
_, ok = configInterface.(models.InsDtalkConfig)
|
||||||
|
if !ok {
|
||||||
|
return "钉钉config校验失败", ""
|
||||||
|
}
|
||||||
|
contentType, formattedContent, err := c.FormatContent(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
atMobiles := content.GetAtMobiles()
|
||||||
|
if content.IsAtAll() {
|
||||||
|
atMobiles = append(atMobiles, "all")
|
||||||
|
}
|
||||||
|
cli := message.Dtalk{AccessToken: auth.AccessToken, Secret: auth.Secret}
|
||||||
|
var res []byte
|
||||||
|
var errMsg string
|
||||||
|
if contentType == FormatTypeText {
|
||||||
|
res, err = cli.SendMessageText(formattedContent, atMobiles...)
|
||||||
|
if err != nil {
|
||||||
|
errMsg = fmt.Sprintf("发送失败:%s", err.Error())
|
||||||
|
}
|
||||||
|
} else if contentType == FormatTypeMarkdown {
|
||||||
|
res, err = cli.SendMessageMarkdown(content.Title, formattedContent, atMobiles...)
|
||||||
|
if err != nil {
|
||||||
|
errMsg = fmt.Sprintf("发送失败:%s", err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errMsg = fmt.Sprintf("未知的钉钉发送内容类型:%s", contentType)
|
||||||
|
}
|
||||||
|
return string(res), errMsg
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/message"
|
||||||
|
"message-nest/service/send_ins_service"
|
||||||
|
"message-nest/service/send_way_service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailChannel struct{ *BaseChannel }
|
||||||
|
|
||||||
|
func NewEmailChannel() *EmailChannel {
|
||||||
|
return &EmailChannel{BaseChannel: NewBaseChannel(MessageTypeEmail, []string{FormatTypeHTML, FormatTypeText})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmailChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) {
|
||||||
|
auth, ok := msgObj.(send_way_service.WayDetailEmail)
|
||||||
|
if !ok {
|
||||||
|
return "", "类型转换失败"
|
||||||
|
}
|
||||||
|
insService := send_ins_service.SendTaskInsService{}
|
||||||
|
errStr, configInterface := insService.ValidateDiffIns(ins)
|
||||||
|
if errStr != "" {
|
||||||
|
return "", errStr
|
||||||
|
}
|
||||||
|
config, ok := configInterface.(models.InsEmailConfig)
|
||||||
|
if !ok {
|
||||||
|
return "", "邮箱config校验失败"
|
||||||
|
}
|
||||||
|
contentType, formattedContent, err := c.FormatContent(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
var emailer message.EmailMessage
|
||||||
|
emailer.Init(auth.Server, auth.Port, auth.Account, auth.Passwd)
|
||||||
|
var errMsg string
|
||||||
|
if contentType == FormatTypeText {
|
||||||
|
errMsg = emailer.SendTextMessage(config.ToAccount, content.Title, formattedContent)
|
||||||
|
} else if contentType == FormatTypeHTML {
|
||||||
|
errMsg = emailer.SendHtmlMessage(config.ToAccount, content.Title, formattedContent)
|
||||||
|
} else {
|
||||||
|
errMsg = fmt.Sprintf("未知的邮件发送内容类型:%s", contentType)
|
||||||
|
}
|
||||||
|
return "", errMsg
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/service/hosted_message_service"
|
||||||
|
"message-nest/service/send_way_service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageNestChannel struct{ *BaseChannel }
|
||||||
|
|
||||||
|
func NewMessageNestChannel() *MessageNestChannel {
|
||||||
|
return &MessageNestChannel{BaseChannel: NewBaseChannel(MessageTypeMessageNest, []string{FormatTypeMarkdown, FormatTypeHTML, FormatTypeText})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageNestChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) {
|
||||||
|
_, ok := msgObj.(send_way_service.MessageNest)
|
||||||
|
if !ok {
|
||||||
|
return "", "类型转换失败"
|
||||||
|
}
|
||||||
|
contentType, formattedContent, err := c.FormatContent(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
messageService := hosted_message_service.HostMessageService{
|
||||||
|
Title: content.Title,
|
||||||
|
Content: formattedContent,
|
||||||
|
Type: contentType,
|
||||||
|
}
|
||||||
|
err = messageService.Add()
|
||||||
|
var res, errMsg string
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
res = "托管消息创建失败!"
|
||||||
|
} else {
|
||||||
|
res = "托管消息创建成功!"
|
||||||
|
}
|
||||||
|
return res, errMsg
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/message"
|
||||||
|
"message-nest/service/send_ins_service"
|
||||||
|
"message-nest/service/send_way_service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QyWeiXinChannel struct{ *BaseChannel }
|
||||||
|
|
||||||
|
func NewQyWeiXinChannel() *QyWeiXinChannel {
|
||||||
|
return &QyWeiXinChannel{BaseChannel: NewBaseChannel(MessageTypeQyWeiXin, []string{FormatTypeMarkdown, FormatTypeText})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *QyWeiXinChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) {
|
||||||
|
auth, ok := msgObj.(send_way_service.WayDetailQyWeiXin)
|
||||||
|
if !ok {
|
||||||
|
return "", "类型转换失败"
|
||||||
|
}
|
||||||
|
insService := send_ins_service.SendTaskInsService{}
|
||||||
|
errStr, configInterface := insService.ValidateDiffIns(ins)
|
||||||
|
if errStr != "" {
|
||||||
|
return errStr, ""
|
||||||
|
}
|
||||||
|
_, ok = configInterface.(models.InsQyWeiXinConfig)
|
||||||
|
if !ok {
|
||||||
|
return "企业微信config校验失败", ""
|
||||||
|
}
|
||||||
|
contentType, formattedContent, err := c.FormatContent(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
atList := []string{}
|
||||||
|
atList = append(atList, content.GetAtUserIds()...)
|
||||||
|
atList = append(atList, content.GetAtMobiles()...)
|
||||||
|
if content.IsAtAll() {
|
||||||
|
atList = append(atList, "@all")
|
||||||
|
}
|
||||||
|
cli := message.QyWeiXin{AccessToken: auth.AccessToken}
|
||||||
|
var res []byte
|
||||||
|
var errMsg string
|
||||||
|
if contentType == FormatTypeText {
|
||||||
|
res, err = cli.SendMessageText(formattedContent, atList...)
|
||||||
|
if err != nil {
|
||||||
|
errMsg = fmt.Sprintf("发送失败:%s", err.Error())
|
||||||
|
}
|
||||||
|
} else if contentType == FormatTypeMarkdown {
|
||||||
|
res, err = cli.SendMessageMarkdown(content.Title, formattedContent, atList...)
|
||||||
|
if err != nil {
|
||||||
|
errMsg = fmt.Sprintf("发送失败:%s", err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errMsg = fmt.Sprintf("未知的企业微信发送内容类型:%s", contentType)
|
||||||
|
}
|
||||||
|
return string(res), errMsg
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
// 消息格式类型常量
|
||||||
|
const (
|
||||||
|
FormatTypeText = "text"
|
||||||
|
FormatTypeHTML = "html"
|
||||||
|
FormatTypeMarkdown = "markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 消息类型常量
|
||||||
|
const (
|
||||||
|
MessageTypeEmail = "Email"
|
||||||
|
MessageTypeDtalk = "Dtalk"
|
||||||
|
MessageTypeQyWeiXin = "QyWeiXin"
|
||||||
|
MessageTypeCustom = "Custom"
|
||||||
|
MessageTypeWeChatOFAccount = "WeChatOFAccount"
|
||||||
|
MessageTypeMessageNest = "MessageNest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnifiedMessageContent 统一的消息内容结构
|
||||||
|
type UnifiedMessageContent struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
Text string
|
||||||
|
HTML string
|
||||||
|
Markdown string
|
||||||
|
AtMobiles []string
|
||||||
|
AtUserIds []string
|
||||||
|
AtAll bool
|
||||||
|
Summary string
|
||||||
|
ImageURL string
|
||||||
|
Extra map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UnifiedMessageContent) HasText() bool { return m.Text != "" }
|
||||||
|
func (m *UnifiedMessageContent) HasHTML() bool { return m.HTML != "" }
|
||||||
|
func (m *UnifiedMessageContent) HasMarkdown() bool { return m.Markdown != "" }
|
||||||
|
|
||||||
|
func (m *UnifiedMessageContent) GetAtMobiles() []string {
|
||||||
|
if m.AtMobiles == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return m.AtMobiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UnifiedMessageContent) GetAtUserIds() []string {
|
||||||
|
if m.AtUserIds == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return m.AtUserIds
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UnifiedMessageContent) IsAtAll() bool { return m.AtAll }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"message-nest/models"
|
||||||
|
"message-nest/pkg/message"
|
||||||
|
"message-nest/service/send_ins_service"
|
||||||
|
"message-nest/service/send_way_service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeChatOFAccountChannel struct{ *BaseChannel }
|
||||||
|
|
||||||
|
func NewWeChatOFAccountChannel() *WeChatOFAccountChannel {
|
||||||
|
return &WeChatOFAccountChannel{BaseChannel: NewBaseChannel(MessageTypeWeChatOFAccount, []string{FormatTypeText})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeChatOFAccountChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) {
|
||||||
|
auth, ok := msgObj.(send_way_service.WeChatOFAccount)
|
||||||
|
if !ok {
|
||||||
|
return "", "类型转换失败"
|
||||||
|
}
|
||||||
|
insService := send_ins_service.SendTaskInsService{}
|
||||||
|
errStr, configInterface := insService.ValidateDiffIns(ins)
|
||||||
|
if errStr != "" {
|
||||||
|
return errStr, ""
|
||||||
|
}
|
||||||
|
config, ok := configInterface.(models.InsWeChatAccountConfig)
|
||||||
|
if !ok {
|
||||||
|
return "微信公众号模板消息config校验失败", ""
|
||||||
|
}
|
||||||
|
_, formattedContent, err := c.FormatContent(content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
cli := message.WeChatOFAccount{
|
||||||
|
AppID: auth.AppID,
|
||||||
|
AppSecret: auth.APPSecret,
|
||||||
|
TemplateID: auth.TempID,
|
||||||
|
ToUser: config.ToAccount,
|
||||||
|
URL: content.URL,
|
||||||
|
}
|
||||||
|
res, err := cli.Send(content.Title, formattedContent)
|
||||||
|
var errMsg string
|
||||||
|
if err != nil {
|
||||||
|
errMsg = fmt.Sprintf("发送失败:%s", err.Error())
|
||||||
|
}
|
||||||
|
return res, errMsg
|
||||||
|
}
|
||||||
Generated
+16
-16
@@ -10,13 +10,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^13.6.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"axios": "^1.12.0",
|
"axios": "^1.12.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.536.0",
|
"lucide-vue-next": "^0.536.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"reka-ui": "^2.4.1",
|
"reka-ui": "^2.6.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
@@ -1605,14 +1605,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/core": {
|
"node_modules/@vueuse/core": {
|
||||||
"version": "13.6.0",
|
"version": "13.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz",
|
||||||
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
|
"integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vueuse/metadata": "13.6.0",
|
"@vueuse/metadata": "13.9.0",
|
||||||
"@vueuse/shared": "13.6.0"
|
"@vueuse/shared": "13.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -1622,9 +1622,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/core/node_modules/@vueuse/shared": {
|
"node_modules/@vueuse/core/node_modules/@vueuse/shared": {
|
||||||
"version": "13.6.0",
|
"version": "13.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz",
|
||||||
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
|
"integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -1634,9 +1634,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/metadata": {
|
"node_modules/@vueuse/metadata": {
|
||||||
"version": "13.6.0",
|
"version": "13.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz",
|
||||||
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==",
|
"integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -2632,9 +2632,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.4.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.1.tgz",
|
||||||
"integrity": "sha512-NB7DrCsODN8MH02BWtgiExygfFcuuZ5/PTn6fMgjppmFHqePvNhmSn1LEuF35nel6PFbA4v+gdj0IoGN1yZ+vw==",
|
"integrity": "sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
+3
-3
@@ -13,13 +13,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^13.6.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"axios": "^1.12.0",
|
"axios": "^1.12.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.536.0",
|
"lucide-vue-next": "^0.536.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"reka-ui": "^2.4.1",
|
"reka-ui": "^2.6.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
@@ -37,4 +37,4 @@
|
|||||||
"vite": "^7.0.4",
|
"vite": "^7.0.4",
|
||||||
"vue-tsc": "^2.2.12"
|
"vue-tsc": "^2.2.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ const tabRoutes: TabRoute[] = [
|
|||||||
{ name: '发信日志', path: '/sendlogs' },
|
{ name: '发信日志', path: '/sendlogs' },
|
||||||
{ name: '托管消息', path: '/hostedmessage' },
|
{ name: '托管消息', path: '/hostedmessage' },
|
||||||
{ name: '定时消息', path: '/cronmessages' },
|
{ name: '定时消息', path: '/cronmessages' },
|
||||||
|
{ name: '模板任务', path: '/templates' },
|
||||||
{ name: '发信任务', path: '/sendtasks' },
|
{ name: '发信任务', path: '/sendtasks' },
|
||||||
{ name: '发信渠道', path: '/sendways' },
|
{ name: '发信渠道', path: '/sendways' },
|
||||||
{ name: '设置偏好', path: '/settings' }
|
{ name: '设置偏好', path: '/settings' }
|
||||||
@@ -326,9 +327,9 @@ const siteTitle = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 桌面端导航 -->
|
<!-- 桌面端导航 -->
|
||||||
<div class="hidden md:flex space-x-6 lg:space-x-8">
|
<div class="hidden md:flex space-x-2 lg:space-x-3">
|
||||||
<button v-for="tab in tabRoutes" :key="tab.name" @click="handleTabClick(tab)" :class="[
|
<button v-for="tab in tabRoutes" :key="tab.name" @click="handleTabClick(tab)" :class="[
|
||||||
'relative py-2 px-3 text-sm font-medium transition-all duration-200 rounded-md whitespace-nowrap',
|
'relative py-1.5 px-2 text-sm font-medium transition-all duration-200 rounded-md whitespace-nowrap',
|
||||||
activeTab === tab.name
|
activeTab === tab.name
|
||||||
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-400/10'
|
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-400/10'
|
||||||
: 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:text-blue-400 dark:hover:bg-white/5'
|
: 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:text-blue-400 dark:hover:bg-white/5'
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
let postData = {
|
let postData = {
|
||||||
"name": formData.name,
|
"name": formData.name,
|
||||||
"id": generateBizUniqueID("C"),
|
"id": generateBizUniqueID("CM"),
|
||||||
"title": formData.title,
|
"title": formData.title,
|
||||||
"content": formData.content,
|
"content": formData.content,
|
||||||
"cron": formData.cron_expression,
|
"cron": formData.cron_expression,
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, reactive, onMounted } from 'vue'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||||
|
import Pagination from '@/components/ui/Pagination.vue'
|
||||||
|
import ClickableTruncate from '@/components/ui/ClickableTruncate.vue'
|
||||||
|
import TemplateApiViewer from './TemplateApiViewer.vue'
|
||||||
|
import TemplateInstanceConfig from './TemplateInstanceConfig.vue'
|
||||||
|
import TemplateEditor from './TemplateEditor.vue'
|
||||||
|
import { request } from '@/api/api'
|
||||||
|
import { getPageSize } from '@/util/pageUtils'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
interface MessageTemplate {
|
||||||
|
id: string // 模板ID是字符串类型(UUID)
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
text_template: string
|
||||||
|
html_template: string
|
||||||
|
markdown_template: string
|
||||||
|
placeholders: string
|
||||||
|
at_mobiles?: string
|
||||||
|
at_user_ids?: string
|
||||||
|
is_at_all?: boolean
|
||||||
|
status: string
|
||||||
|
created_on: string
|
||||||
|
modified_on: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
let state = reactive({
|
||||||
|
tableData: [] as MessageTemplate[],
|
||||||
|
total: 0,
|
||||||
|
currPage: 1,
|
||||||
|
pageSize: getPageSize() as number,
|
||||||
|
search: '',
|
||||||
|
status: 'all'
|
||||||
|
})
|
||||||
|
|
||||||
|
// API代码查看器状态
|
||||||
|
const isApiViewerOpen = ref(false)
|
||||||
|
const selectedTemplateForApi = ref<MessageTemplate | null>(null)
|
||||||
|
|
||||||
|
// 配置实例状态
|
||||||
|
const isInstanceConfigOpen = ref(false)
|
||||||
|
const selectedTemplateForInstance = ref<MessageTemplate | null>(null)
|
||||||
|
|
||||||
|
// 模板编辑器状态
|
||||||
|
const isEditorOpen = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const selectedTemplateForEdit = ref<MessageTemplate | null>(null)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||||
|
|
||||||
|
const queryListData = async (page: number, size: number, text = '', status = '') => {
|
||||||
|
const params: any = { page, size, text, status }
|
||||||
|
const rsp = await request.get('/templates/list', { params })
|
||||||
|
state.tableData = rsp.data.data.lists || []
|
||||||
|
state.total = rsp.data.data.total || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePage = async (page: number) => {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
state.currPage = page
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterFunc = async () => {
|
||||||
|
state.currPage = 1
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
selectedTemplateForEdit.value = null
|
||||||
|
isEditorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (template: MessageTemplate) => {
|
||||||
|
isEditing.value = true
|
||||||
|
selectedTemplateForEdit.value = template
|
||||||
|
isEditorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditorSaved = async () => {
|
||||||
|
// 刷新列表
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTemplate = async (id: string) => {
|
||||||
|
const rsp = await request.post('/templates/delete', { id })
|
||||||
|
if (rsp.status === 200 && rsp.data.code === 200) {
|
||||||
|
toast.success(rsp.data.msg)
|
||||||
|
// 刷新列表,处理status参数
|
||||||
|
const statusParam = state.status === 'all' ? '' : state.status
|
||||||
|
await queryListData(state.currPage, state.pageSize, state.search, statusParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开API查看器
|
||||||
|
const handleViewApi = (template: MessageTemplate) => {
|
||||||
|
selectedTemplateForApi.value = template
|
||||||
|
isApiViewerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开配置实例
|
||||||
|
const handleConfigInstance = (template: MessageTemplate) => {
|
||||||
|
selectedTemplateForInstance.value = template
|
||||||
|
isInstanceConfigOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看日志
|
||||||
|
const handleViewLogs = (template: MessageTemplate) => {
|
||||||
|
// 跳转到发信日志页面,携带 taskid 参数(传递模板 id)
|
||||||
|
router.push(`/sendlogs?taskid=${template.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await queryListData(1, state.pageSize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-4 w-full max-w-6xl mx-auto space-y-2">
|
||||||
|
<!-- 搜索和筛选 -->
|
||||||
|
<div class="flex flex-row sm:flex-row sm:items-center gap-2 -mx-2 px-2 sm:mx-0 sm:px-0">
|
||||||
|
<div class="flex-[3] sm:flex-initial min-w-0">
|
||||||
|
<Input
|
||||||
|
v-model="state.search"
|
||||||
|
placeholder="搜索模板名称或描述..."
|
||||||
|
class="w-full sm:w-64"
|
||||||
|
@keyup.enter="filterFunc"
|
||||||
|
@blur="filterFunc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-[2] sm:flex-initial min-w-0">
|
||||||
|
<Select v-model="state.status" class="w-full" @update:model-value="filterFunc">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue placeholder="选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="enabled">启用</SelectItem>
|
||||||
|
<SelectItem value="disabled">禁用</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<Button @click="openAddDialog">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
新建模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-24">ID</TableHead>
|
||||||
|
<TableHead>模板名称</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>支持格式</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead class="whitespace-nowrap w-[160px]">创建时间</TableHead>
|
||||||
|
<TableHead class="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<!-- 空数据展示 -->
|
||||||
|
<TableRow v-if="state.tableData.length === 0">
|
||||||
|
<TableCell colspan="7" class="text-center py-12">
|
||||||
|
<EmptyTableState
|
||||||
|
title="暂无消息模板"
|
||||||
|
description="还没有创建任何消息模板,点击右上角按钮创建新模板"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</EmptyTableState>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
<!-- 数据行 -->
|
||||||
|
<TableRow v-for="item in state.tableData" :key="item.id">
|
||||||
|
<TableCell>{{ item.id }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClickableTruncate :text="item.name" wrapper-class="max-w-[80px] sm:max-w-[100px]" preview-title="模板名称" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClickableTruncate :text="item.description || '-'" wrapper-class="max-w-[80px] sm:max-w-[130px]" preview-title="模板描述" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Badge v-if="item.text_template" variant="secondary">Text</Badge>
|
||||||
|
<Badge v-if="item.html_template" variant="secondary">HTML</Badge>
|
||||||
|
<Badge v-if="item.markdown_template" variant="secondary">Markdown</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="item.status === 'enabled' ? 'default' : 'secondary'">
|
||||||
|
{{ item.status === 'enabled' ? '启用' : '禁用' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="whitespace-nowrap w-[160px]">{{ item.created_on }}</TableCell>
|
||||||
|
<TableCell class="text-center space-x-2">
|
||||||
|
<Button size="sm" variant="outline" @click="handleViewLogs(item)">日志</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="handleViewApi(item)">接口</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="openEditDialog(item)">编辑</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="handleConfigInstance(item)">实例</Button>
|
||||||
|
<Button size="sm" variant="destructive" @click="deleteTemplate(item.id)">删除</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<Pagination
|
||||||
|
:total="state.total"
|
||||||
|
:current-page="state.currPage"
|
||||||
|
:page-size="state.pageSize"
|
||||||
|
@page-change="changePage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 模板编辑器 -->
|
||||||
|
<TemplateEditor
|
||||||
|
:open="isEditorOpen"
|
||||||
|
:is-editing="isEditing"
|
||||||
|
:template-data="selectedTemplateForEdit"
|
||||||
|
@update:open="isEditorOpen = $event"
|
||||||
|
@saved="handleEditorSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- API代码查看器 -->
|
||||||
|
<TemplateApiViewer
|
||||||
|
:open="isApiViewerOpen"
|
||||||
|
:template-data="selectedTemplateForApi || undefined"
|
||||||
|
@update:open="isApiViewerOpen = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 配置实例 -->
|
||||||
|
<TemplateInstanceConfig
|
||||||
|
:open="isInstanceConfigOpen"
|
||||||
|
:template-data="selectedTemplateForInstance"
|
||||||
|
@update:open="isInstanceConfigOpen = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ref, defineComponent } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
// @ts-ignore
|
||||||
|
import { TemplateApiStrGenerate } from '@/util/viewApi'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'TemplateApiViewer',
|
||||||
|
components: {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
Badge
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
templateData: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:open'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// 处理关闭事件
|
||||||
|
const handleUpdateOpen = (value: boolean) => {
|
||||||
|
emit('update:open', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前选中的标签
|
||||||
|
const activeTab = ref('curl')
|
||||||
|
|
||||||
|
// 代码语言选项
|
||||||
|
const codeLanguages = [
|
||||||
|
{ value: 'curl', label: 'cURL', icon: '🌐' },
|
||||||
|
{ value: 'javascript', label: 'JS', icon: '🟨' },
|
||||||
|
{ value: 'python', label: 'Python', icon: '🐍' },
|
||||||
|
{ value: 'php', label: 'PHP', icon: '🐘' },
|
||||||
|
{ value: 'golang', label: 'Go', icon: '🐹' },
|
||||||
|
{ value: 'java', label: 'Java', icon: '☕' },
|
||||||
|
{ value: 'rust', label: 'Rust', icon: '🦀' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 生成API代码示例
|
||||||
|
const generateApiCode = (language: string) => {
|
||||||
|
const templateId = props.templateData?.id || 'TEMPLATE_ID'
|
||||||
|
const placeholders = props.templateData?.placeholders || '[]'
|
||||||
|
|
||||||
|
switch (language) {
|
||||||
|
case 'curl':
|
||||||
|
return TemplateApiStrGenerate.getCurlString(templateId, placeholders)
|
||||||
|
case 'javascript':
|
||||||
|
return TemplateApiStrGenerate.getNodeString(templateId, placeholders)
|
||||||
|
case 'python':
|
||||||
|
return TemplateApiStrGenerate.getPythonString(templateId, placeholders)
|
||||||
|
case 'php':
|
||||||
|
return TemplateApiStrGenerate.getPHPString(templateId, placeholders)
|
||||||
|
case 'golang':
|
||||||
|
return TemplateApiStrGenerate.getGolangString(templateId, placeholders)
|
||||||
|
case 'java':
|
||||||
|
return TemplateApiStrGenerate.getJavaString(templateId, placeholders)
|
||||||
|
case 'rust':
|
||||||
|
return TemplateApiStrGenerate.getRustString(templateId, placeholders)
|
||||||
|
default:
|
||||||
|
return '// 请选择一种编程语言查看示例代码'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制代码到剪贴板
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast.success('复制成功')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleUpdateOpen,
|
||||||
|
activeTab,
|
||||||
|
codeLanguages,
|
||||||
|
generateApiCode,
|
||||||
|
copyToClipboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="handleUpdateOpen">
|
||||||
|
<DialogContent class="w-[800px] sm:w-[900px] lg:w-[1000px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<span>模板API接口</span>
|
||||||
|
<Badge v-if="templateData" variant="outline">{{ templateData.name }}</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- API 信息概览 -->
|
||||||
|
<div class="border rounded-lg p-4 space-y-2 bg-white dark:bg-slate-900">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="default">POST</Badge>
|
||||||
|
<code class="text-sm bg-gray-100 dark:bg-slate-800 px-2 py-1 rounded">/api/v2/message/send</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">使用模板发送消息(V2接口)</p>
|
||||||
|
<div class="mt-3 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<p><strong>模板ID:</strong> <code class="bg-gray-100 dark:bg-slate-800 px-1 py-0.5 rounded">{{ templateData?.id }}</code></p>
|
||||||
|
<p><strong>必填参数:</strong> token (加密token), title (消息标题), placeholders (占位符键值对)</p>
|
||||||
|
<p><strong>可选参数:</strong> 根据模板配置的@提醒字段自动应用</p>
|
||||||
|
<p class="text-amber-600 dark:text-amber-400"><strong>⚠️ 注意:</strong> V2接口使用加密token,不支持明文ID</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 代码示例 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold">代码示例</h3>
|
||||||
|
|
||||||
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-7 gap-1">
|
||||||
|
<TabsTrigger v-for="lang in codeLanguages" :key="lang.value" :value="lang.value"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs">
|
||||||
|
<span>{{ lang.icon }}</span>
|
||||||
|
<span class="hidden sm:inline">{{ lang.label }}</span>
|
||||||
|
<span class="sm:hidden">{{ lang.label.slice(0, 3) }}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent v-for="lang in codeLanguages" :key="lang.value" :value="lang.value" class="mt-4">
|
||||||
|
<div class="relative">
|
||||||
|
<Button size="sm" variant="outline" class="absolute top-2 right-2 z-10"
|
||||||
|
@click="copyToClipboard(generateApiCode(lang.value))">
|
||||||
|
复制代码
|
||||||
|
</Button>
|
||||||
|
<pre
|
||||||
|
class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-xs leading-relaxed max-w-full whitespace-pre-wrap break-words"><code class="text-xs font-mono">{{ generateApiCode(lang.value) }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 说明 -->
|
||||||
|
<div class="border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950 p-3 rounded text-xs space-y-1">
|
||||||
|
<p class="font-semibold text-blue-900 dark:text-blue-200">💡 使用说明</p>
|
||||||
|
<ul class="text-blue-800 dark:text-blue-300 space-y-1 ml-4 list-disc">
|
||||||
|
<li><strong>token 参数:</strong>需要使用加密后的 token,不能直接使用明文模板ID(安全考虑)</li>
|
||||||
|
<li><strong>placeholders 参数:</strong>用于替换模板中的占位符,格式为 <code class="bg-blue-100 dark:bg-blue-900 px-1 rounded">{"key": "value"}</code></li>
|
||||||
|
<li>如果模板配置了@提醒,会自动应用到发送的消息中</li>
|
||||||
|
<li>支持 Text、HTML、Markdown 三种格式,根据实例配置精确发送对应类型</li>
|
||||||
|
<li>系统会自动遍历所有启用的实例进行发送</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 代码块样式优化 */
|
||||||
|
pre {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { request } from '@/api/api'
|
||||||
|
|
||||||
|
interface Placeholder {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
default: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateData {
|
||||||
|
id?: string // 模板ID是字符串类型(UUID)
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
text_template: string
|
||||||
|
html_template: string
|
||||||
|
markdown_template: string
|
||||||
|
placeholders: string
|
||||||
|
at_mobiles?: string
|
||||||
|
at_user_ids?: string
|
||||||
|
is_at_all?: boolean
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件props
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
isEditing?: boolean
|
||||||
|
templateData?: TemplateData | null
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
open: false,
|
||||||
|
isEditing: false,
|
||||||
|
templateData: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Textarea refs for inserting placeholders
|
||||||
|
const textTemplateRef = ref<any>(null)
|
||||||
|
const htmlTemplateRef = ref<any>(null)
|
||||||
|
const markdownTemplateRef = ref<any>(null)
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<TemplateData>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
text_template: '',
|
||||||
|
html_template: '',
|
||||||
|
markdown_template: '',
|
||||||
|
placeholders: '[]',
|
||||||
|
at_mobiles: '',
|
||||||
|
at_user_ids: '',
|
||||||
|
is_at_all: false,
|
||||||
|
status: 'enabled'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用独立的响应式数组来管理占位符,避免频繁的 JSON 序列化
|
||||||
|
const placeholdersList = ref<Placeholder[]>([])
|
||||||
|
|
||||||
|
// 过滤出有效的占位符(key 不为空)
|
||||||
|
const validPlaceholders = computed(() => {
|
||||||
|
return placeholdersList.value.filter(ph => ph.key && ph.key.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 预览数据
|
||||||
|
const previewData = ref({
|
||||||
|
text: '',
|
||||||
|
html: '',
|
||||||
|
markdown: '',
|
||||||
|
params: {} as Record<string, string>
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否显示预览
|
||||||
|
const showPreview = ref(false)
|
||||||
|
|
||||||
|
// 预览防抖定时器
|
||||||
|
let previewDebounceTimer: number | null = null
|
||||||
|
|
||||||
|
// 刷新预览
|
||||||
|
const refreshPreview = async () => {
|
||||||
|
if (!props.isEditing || !formData.value.id) {
|
||||||
|
// 新建模板时,直接使用当前输入的内容作为预览
|
||||||
|
previewData.value.text = replacePreviewPlaceholders(formData.value.text_template)
|
||||||
|
previewData.value.html = replacePreviewPlaceholders(formData.value.html_template)
|
||||||
|
previewData.value.markdown = replacePreviewPlaceholders(formData.value.markdown_template)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rsp = await request.post('/templates/preview', {
|
||||||
|
id: formData.value.id,
|
||||||
|
params: previewData.value.params
|
||||||
|
})
|
||||||
|
previewData.value.text = rsp.data.data.text || ''
|
||||||
|
previewData.value.html = rsp.data.data.html || ''
|
||||||
|
previewData.value.markdown = rsp.data.data.markdown || ''
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('预览失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换预览占位符(用于新建模板)
|
||||||
|
const replacePreviewPlaceholders = (template: string) => {
|
||||||
|
if (!template) return ''
|
||||||
|
let result = template
|
||||||
|
Object.keys(previewData.value.params).forEach(key => {
|
||||||
|
const value = previewData.value.params[key] || `{{${key}}}`
|
||||||
|
result = result.replace(new RegExp(`{{${key}}}`, 'g'), value)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听模板内容变化,自动刷新预览(防抖)
|
||||||
|
watch([
|
||||||
|
() => formData.value.text_template,
|
||||||
|
() => formData.value.html_template,
|
||||||
|
() => formData.value.markdown_template,
|
||||||
|
() => previewData.value.params
|
||||||
|
], () => {
|
||||||
|
if (!showPreview.value) return
|
||||||
|
|
||||||
|
if (previewDebounceTimer) {
|
||||||
|
clearTimeout(previewDebounceTimer)
|
||||||
|
}
|
||||||
|
previewDebounceTimer = window.setTimeout(() => {
|
||||||
|
refreshPreview()
|
||||||
|
}, 500)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 监听占位符列表变化,同步到 formData(使用防抖)
|
||||||
|
let placeholderDebounceTimer: number | null = null
|
||||||
|
watch(placeholdersList, () => {
|
||||||
|
if (placeholderDebounceTimer) {
|
||||||
|
clearTimeout(placeholderDebounceTimer)
|
||||||
|
}
|
||||||
|
placeholderDebounceTimer = window.setTimeout(() => {
|
||||||
|
formData.value.placeholders = JSON.stringify(placeholdersList.value)
|
||||||
|
}, 300)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 检查占位符 key 是否重复
|
||||||
|
const isDuplicateKey = (key: string, currentIndex: number): boolean => {
|
||||||
|
if (!key.trim()) return false
|
||||||
|
return placeholdersList.value.some((p, index) =>
|
||||||
|
index !== currentIndex && p.key.trim() === key.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取重复的 key 列表
|
||||||
|
const getDuplicateKeys = computed(() => {
|
||||||
|
const keys = placeholdersList.value.map(p => p.key.trim()).filter(k => k)
|
||||||
|
const duplicates = new Set<string>()
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (seen.has(key)) {
|
||||||
|
duplicates.add(key)
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return duplicates
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加占位符
|
||||||
|
const addPlaceholder = () => {
|
||||||
|
placeholdersList.value.push({ key: '', label: '', default: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除占位符
|
||||||
|
const removePlaceholder = (index: number) => {
|
||||||
|
placeholdersList.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入占位符到模板
|
||||||
|
const insertPlaceholder = async (type: 'text' | 'html' | 'markdown', key: string) => {
|
||||||
|
const placeholder = `{{${key}}}`
|
||||||
|
let targetRef: any = null
|
||||||
|
|
||||||
|
if (type === 'text') targetRef = textTemplateRef.value
|
||||||
|
else if (type === 'html') targetRef = htmlTemplateRef.value
|
||||||
|
else if (type === 'markdown') targetRef = markdownTemplateRef.value
|
||||||
|
|
||||||
|
if (!targetRef) return
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const textarea = targetRef.$el || targetRef
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const text = formData.value[`${type}_template`]
|
||||||
|
|
||||||
|
const before = text.substring(0, start)
|
||||||
|
const after = text.substring(end)
|
||||||
|
|
||||||
|
formData.value[`${type}_template`] = before + placeholder + after
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
textarea.focus()
|
||||||
|
const newPosition = start + placeholder.length
|
||||||
|
textarea.setSelectionRange(newPosition, newPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
text_template: '',
|
||||||
|
html_template: '',
|
||||||
|
markdown_template: '',
|
||||||
|
placeholders: '[]',
|
||||||
|
at_mobiles: '',
|
||||||
|
at_user_ids: '',
|
||||||
|
is_at_all: false,
|
||||||
|
status: 'enabled'
|
||||||
|
}
|
||||||
|
placeholdersList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载模板数据
|
||||||
|
const loadTemplateData = (template: TemplateData) => {
|
||||||
|
formData.value = {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
text_template: template.text_template,
|
||||||
|
html_template: template.html_template,
|
||||||
|
markdown_template: template.markdown_template,
|
||||||
|
placeholders: template.placeholders,
|
||||||
|
at_mobiles: template.at_mobiles || '',
|
||||||
|
at_user_ids: template.at_user_ids || '',
|
||||||
|
is_at_all: Boolean(template.is_at_all),
|
||||||
|
status: template.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析占位符
|
||||||
|
try {
|
||||||
|
placeholdersList.value = JSON.parse(template.placeholders || '[]')
|
||||||
|
} catch {
|
||||||
|
placeholdersList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化预览参数
|
||||||
|
previewData.value.params = {}
|
||||||
|
placeholdersList.value.forEach(p => {
|
||||||
|
previewData.value.params[p.key] = p.default || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存模板
|
||||||
|
const saveTemplate = async () => {
|
||||||
|
if (!formData.value.name.trim()) {
|
||||||
|
toast.error('请输入模板名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证至少填写一种格式的模板内容
|
||||||
|
if (!formData.value.text_template && !formData.value.html_template && !formData.value.markdown_template) {
|
||||||
|
toast.error('至少需要填写一种格式的模板内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证占位符 key 不能为空且不能重复
|
||||||
|
const emptyKeys = placeholdersList.value.filter(p => p.key.trim() === '')
|
||||||
|
if (emptyKeys.length > 0) {
|
||||||
|
toast.error('占位符 key 不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getDuplicateKeys.value.size > 0) {
|
||||||
|
const duplicates = Array.from(getDuplicateKeys.value).join('、')
|
||||||
|
toast.error(`占位符 key 不能重复:${duplicates}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步占位符数据
|
||||||
|
formData.value.placeholders = JSON.stringify(placeholdersList.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = props.isEditing ? '/templates/edit' : '/templates/add'
|
||||||
|
const response = await request.post(url, formData.value)
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
toast.success(props.isEditing ? '更新模板成功' : '添加模板成功')
|
||||||
|
emit('update:open', false)
|
||||||
|
emit('saved')
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.msg || error.response?.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听对话框打开状态
|
||||||
|
watch(() => props.open, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
if (props.isEditing && props.templateData) {
|
||||||
|
loadTemplateData(props.templateData)
|
||||||
|
} else {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="(value) => $emit('update:open', value)">
|
||||||
|
<DialogContent class="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ isEditing ? '编辑模板' : '新建模板' }}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="grid grid-cols-10 gap-4">
|
||||||
|
<div class="col-span-7 space-y-2">
|
||||||
|
<Label for="name">模板名称 *</Label>
|
||||||
|
<Input id="name" v-model="formData.name" placeholder="请输入模板名称" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-3 space-y-2">
|
||||||
|
<Label>状态</Label>
|
||||||
|
<Select v-model="formData.status">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue placeholder="全部" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="enabled">启用</SelectItem>
|
||||||
|
<SelectItem value="disabled">禁用</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">描述</Label>
|
||||||
|
<Textarea id="description" v-model="formData.description" placeholder="请输入模板描述" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 占位符配置 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<Label>占位符配置</Label>
|
||||||
|
<Button size="sm" variant="outline" @click="addPlaceholder">添加占位符</Button>
|
||||||
|
</div>
|
||||||
|
<div v-for="(placeholder, index) in placeholdersList" :key="index" class="flex flex-col gap-1">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<Input
|
||||||
|
v-model="placeholder.key"
|
||||||
|
placeholder="key (如: username)"
|
||||||
|
:class="{ 'border-red-500 focus-visible:ring-red-500': isDuplicateKey(placeholder.key, index) }"
|
||||||
|
/>
|
||||||
|
<p v-if="isDuplicateKey(placeholder.key, index)" class="text-xs text-red-500 mt-1">
|
||||||
|
该 key 已存在
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model="placeholder.label"
|
||||||
|
placeholder="标签 (如: 用户名)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="placeholder.default"
|
||||||
|
placeholder="默认值"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" @click="removePlaceholder(index)">删除</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
在模板中使用 <code v-text="'{{key}}'"></code> 来引用占位符,例如:Hello <code v-text="'{{username}}'"></code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- @提醒配置 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>@提醒配置 <span class="text-xs text-muted-foreground font-normal">(适用于钉钉、企业微信等)</span></Label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 border rounded-md">
|
||||||
|
<Checkbox
|
||||||
|
id="is_at_all"
|
||||||
|
:model-value="formData.is_at_all"
|
||||||
|
@update:model-value="(newVal: boolean | 'indeterminate') => formData.is_at_all = newVal === true"
|
||||||
|
/>
|
||||||
|
<Label for="is_at_all" class="cursor-pointer text-sm">@所有人</Label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model="formData.at_mobiles"
|
||||||
|
placeholder="@手机号(逗号分隔)"
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="formData.at_user_ids"
|
||||||
|
placeholder="@用户ID(逗号分隔)"
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模板内容 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-base font-semibold">模板内容</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="showPreview = !showPreview; if (showPreview) refreshPreview()"
|
||||||
|
>
|
||||||
|
{{ showPreview ? '隐藏预览' : '显示预览' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 占位符参数输入(仅在显示预览时) -->
|
||||||
|
<div v-if="showPreview && validPlaceholders.length > 0" class="p-3 bg-muted rounded-lg space-y-2">
|
||||||
|
<Label class="text-sm font-medium">填写占位符参数(用于预览)</Label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<div v-for="ph in validPlaceholders" :key="ph.key" class="flex gap-2 items-center">
|
||||||
|
<Label class="text-xs w-24 flex-shrink-0">{{ ph.key }}</Label>
|
||||||
|
<Input
|
||||||
|
v-model="previewData.params[ph.key]"
|
||||||
|
:placeholder="ph.default || `请输入 ${ph.key}`"
|
||||||
|
class="text-sm h-8"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs default-value="text" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="text">Text</TabsTrigger>
|
||||||
|
<TabsTrigger value="html">HTML</TabsTrigger>
|
||||||
|
<TabsTrigger value="markdown">Markdown</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="text" class="space-y-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label>纯文本模板</Label>
|
||||||
|
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||||
|
<Button
|
||||||
|
v-for="ph in validPlaceholders"
|
||||||
|
:key="ph.key"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||||
|
@click="insertPlaceholder('text', ph.key)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ph.key}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref="textTemplateRef"
|
||||||
|
v-model="formData.text_template"
|
||||||
|
placeholder="请输入纯文本模板内容,可使用 {{key}} 作为占位符"
|
||||||
|
:rows="showPreview ? 10 : 15"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div v-if="showPreview" class="space-y-2">
|
||||||
|
<Label>预览效果</Label>
|
||||||
|
<div class="p-4 border rounded-md bg-muted/50">
|
||||||
|
<pre class="whitespace-pre-wrap text-sm">{{ previewData.text || '无内容' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="html" class="space-y-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label>HTML模板</Label>
|
||||||
|
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||||
|
<Button
|
||||||
|
v-for="ph in validPlaceholders"
|
||||||
|
:key="ph.key"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||||
|
@click="insertPlaceholder('html', ph.key)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ph.key}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref="htmlTemplateRef"
|
||||||
|
v-model="formData.html_template"
|
||||||
|
placeholder="请输入HTML模板内容,可使用 {{key}} 作为占位符"
|
||||||
|
:rows="showPreview ? 10 : 15"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div v-if="showPreview" class="space-y-2">
|
||||||
|
<Label>预览效果(基础渲染)</Label>
|
||||||
|
<div class="p-4 border rounded-md bg-muted/50">
|
||||||
|
<div v-html="previewData.html || '无内容'"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
💡 HTML 预览仅显示基础结构,实际发送时可能包含邮件样式等
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="markdown" class="space-y-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Label>Markdown模板</Label>
|
||||||
|
<div v-if="validPlaceholders.length > 0" class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||||
|
<Button
|
||||||
|
v-for="ph in validPlaceholders"
|
||||||
|
:key="ph.key"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-7 text-xs whitespace-nowrap flex-shrink-0"
|
||||||
|
@click="insertPlaceholder('markdown', ph.key)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ph.key}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref="markdownTemplateRef"
|
||||||
|
v-model="formData.markdown_template"
|
||||||
|
placeholder="请输入Markdown模板内容,可使用 {{key}} 作为占位符"
|
||||||
|
:rows="showPreview ? 10 : 15"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div v-if="showPreview" class="space-y-2">
|
||||||
|
<Label>预览效果(原始格式)</Label>
|
||||||
|
<div class="p-4 border rounded-md bg-muted/50">
|
||||||
|
<pre class="whitespace-pre-wrap text-sm">{{ previewData.markdown || '无内容' }}</pre>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
💡 Markdown 在发送时会被渲染为对应格式(钉钉、企业微信等平台支持)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="$emit('update:open', false)">取消</Button>
|
||||||
|
<Button @click="saveTemplate">保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxViewport
|
||||||
|
} from '@/components/ui/combobox'
|
||||||
|
import { CheckIcon } from 'lucide-vue-next'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { CONSTANT } from '@/constant'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { request } from '@/api/api'
|
||||||
|
import { generateBizUniqueID } from '@/util/uuid'
|
||||||
|
|
||||||
|
// 组件props
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
templateData?: any // 模板数据
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
open: false,
|
||||||
|
templateData: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件emits
|
||||||
|
defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 前端的页面添加配置
|
||||||
|
const waysConfigMap = CONSTANT.WAYS_DATA
|
||||||
|
|
||||||
|
// 搜索相关状态
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const isSearching = ref(false)
|
||||||
|
const channelName = ref('')
|
||||||
|
|
||||||
|
// 当前显示的选项(搜索结果或所有选项)
|
||||||
|
const displayOptions = ref<Array<{ id: string, name: string, type: string }>>([])
|
||||||
|
|
||||||
|
// 输入框显示值(只在用户搜索时显示搜索内容)
|
||||||
|
const inputDisplayValue = computed({
|
||||||
|
get: () => searchQuery.value,
|
||||||
|
set: (value: string) => {
|
||||||
|
searchQuery.value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前选中渠道的配置
|
||||||
|
const currentChannelConfig = computed(() => {
|
||||||
|
// 先根据label找到type
|
||||||
|
let type = displayOptions.value.find(item => item.name === channelName.value)?.type
|
||||||
|
// 再根据type找到配置
|
||||||
|
let rs = waysConfigMap.find(item => item.type === type) || null;
|
||||||
|
|
||||||
|
return rs;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<Record<string, any>>({})
|
||||||
|
|
||||||
|
// 监听渠道变化
|
||||||
|
const handlechannelNameChange = () => {
|
||||||
|
// 数据加载后,text/html单选设置默认选中(这里选第一个)
|
||||||
|
if (currentChannelConfig.value?.taskInsRadios.length > 0) {
|
||||||
|
formData.value.templ_type = currentChannelConfig.value?.taskInsRadios[0].subLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加单条实例配置
|
||||||
|
const handleAddSubmit = async () => {
|
||||||
|
// 验证是否选择了渠道
|
||||||
|
if (!channelName.value) {
|
||||||
|
toast.error('请选择发送渠道')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证内容类型
|
||||||
|
const contentType = formData.value.templ_type
|
||||||
|
if (!contentType) {
|
||||||
|
toast.error('请选择消息格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证模板对应格式的内容是否为空
|
||||||
|
const templateFieldMap: Record<string, string> = {
|
||||||
|
'text': 'text_template',
|
||||||
|
'html': 'html_template',
|
||||||
|
'markdown': 'markdown_template'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldName = templateFieldMap[contentType.toLowerCase()]
|
||||||
|
if (fieldName) {
|
||||||
|
const templateContent = props.templateData?.[fieldName] || ''
|
||||||
|
// 检查是否为空(去除所有空白字符后检查)
|
||||||
|
if (!templateContent.trim()) {
|
||||||
|
toast.error(`模板的 ${contentType} 格式内容为空,无法添加此类型的实例`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组建表单数据
|
||||||
|
let postData = {
|
||||||
|
"id": generateBizUniqueID('IN'),
|
||||||
|
"enable": 1,
|
||||||
|
"template_id": props.templateData.id,
|
||||||
|
"way_id": displayOptions.value[0]?.id,
|
||||||
|
"way_type": displayOptions.value[0]?.type,
|
||||||
|
"way_name": displayOptions.value[0]?.name,
|
||||||
|
"content_type": formData.value.templ_type,
|
||||||
|
"config": JSON.stringify(formData.value),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.post('/templates/ins/addone', postData)
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
toast.success(response.data.msg)
|
||||||
|
// 重新加载实例列表
|
||||||
|
await queryInsListData()
|
||||||
|
// 清空表单
|
||||||
|
channelName.value = ''
|
||||||
|
formData.value = {}
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.msg || '添加实例失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.msg || '添加实例失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实例表格数据
|
||||||
|
const insTableData = ref<any[]>([])
|
||||||
|
|
||||||
|
// 查询实例列表数据
|
||||||
|
const queryInsListData = async () => {
|
||||||
|
if (!props.templateData?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get('/templates/ins/get', {
|
||||||
|
params: { id: props.templateData.id }
|
||||||
|
})
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
const insList = response.data.data.ins_list || []
|
||||||
|
insTableData.value = insList
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取实例列表失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除实例
|
||||||
|
const handleDeleteIns = async (insId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await request.post('/sendtasks/ins/delete', { id: insId })
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
toast.success(response.data.msg)
|
||||||
|
await queryInsListData()
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.msg || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换实例启用状态
|
||||||
|
const handleToggleEnable = async (insId: string, currentStatus: number | string) => {
|
||||||
|
const isEnabled = Number(currentStatus) === 1
|
||||||
|
const newStatus = isEnabled ? 0 : 1
|
||||||
|
|
||||||
|
// 立即更新本地状态,提供即时反馈
|
||||||
|
const insIndex = insTableData.value.findIndex(ins => ins.id === insId)
|
||||||
|
if (insIndex !== -1) {
|
||||||
|
insTableData.value[insIndex].enable = newStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.post('/sendtasks/ins/update_enable', {
|
||||||
|
ins_id: insId,
|
||||||
|
status: newStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
toast.success(response.data.msg)
|
||||||
|
// 重新加载确保数据同步
|
||||||
|
await queryInsListData()
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.msg || '更新失败')
|
||||||
|
// 失败时恢复原状态
|
||||||
|
if (insIndex !== -1) {
|
||||||
|
insTableData.value[insIndex].enable = currentStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('状态切换失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '更新失败')
|
||||||
|
// 失败时恢复原状态
|
||||||
|
if (insIndex !== -1) {
|
||||||
|
insTableData.value[insIndex].enable = currentStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖定时器
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
|
// 搜索渠道(带防抖)
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
displayOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置防抖延迟(500ms)
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
isSearching.value = true
|
||||||
|
try {
|
||||||
|
const response = await request.get('/sendways/list', {
|
||||||
|
params: { name: query }
|
||||||
|
})
|
||||||
|
if (response.status === 200 && response.data.code === 200) {
|
||||||
|
displayOptions.value = response.data.data.lists.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
type: item.type
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索渠道失败', error)
|
||||||
|
displayOptions.value = []
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听对话框打开状态,打开时加载实例列表
|
||||||
|
watch(() => props.open, (newVal) => {
|
||||||
|
if (newVal && props.templateData?.id) {
|
||||||
|
queryInsListData()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="(value) => $emit('update:open', value)">
|
||||||
|
<DialogContent class="w-[500px] max-w-[90vw] max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader class="flex-shrink-0">
|
||||||
|
<DialogTitle>配置发送实例</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="px-4 pb-4 flex-1 overflow-y-auto">
|
||||||
|
<div class="space-y-4">
|
||||||
|
|
||||||
|
<!-- 模板信息 -->
|
||||||
|
<div class="mb-6 p-3 bg-muted rounded-lg space-y-1">
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-base font-semibold">{{ templateData?.name }}</span>
|
||||||
|
<Badge variant="outline" class="text-xs">{{ templateData?.id }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">为此模板配置发送实例</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加实例表单 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<Label class="text-sm font-medium">选择发送渠道</Label>
|
||||||
|
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
|
||||||
|
<ComboboxAnchor class="w-full">
|
||||||
|
<ComboboxInput v-model="inputDisplayValue" @input="handleSearch(inputDisplayValue)"
|
||||||
|
class="flex h-10 w-full" placeholder="搜索或选择渠道类型进行实例的添加..." />
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
|
||||||
|
<ComboboxViewport>
|
||||||
|
<ComboboxItem v-for="option in displayOptions" :key="option.id" :value="option.name">
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<span>{{ option.name }}</span>
|
||||||
|
<CheckIcon v-if="channelName === option.name" class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</ComboboxItem>
|
||||||
|
<div v-if="isSearching" class="p-2 text-sm text-muted-foreground">搜索中...</div>
|
||||||
|
<div v-if="!isSearching && displayOptions.length === 0 && searchQuery" class="p-2 text-sm text-muted-foreground">
|
||||||
|
未找到匹配的渠道
|
||||||
|
</div>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxList>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" @click="handleAddSubmit">添加实例</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 渠道配置表单 -->
|
||||||
|
<div v-if="currentChannelConfig" class="mt-4">
|
||||||
|
<!-- 实例配置输入字段 -->
|
||||||
|
<div v-if="currentChannelConfig.taskInsInputs && currentChannelConfig.taskInsInputs.length > 0" class="mb-2">
|
||||||
|
<Label class="text-sm font-medium mb-1">实例配置</Label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div v-for="input in currentChannelConfig.taskInsInputs" :key="input.col" class="space-y-2">
|
||||||
|
<label class="text-xs font-medium text-muted-foreground">{{ input.label || input.desc }}</label>
|
||||||
|
<Input v-model="formData[input.col]" :placeholder="input.desc || `请输入${input.label}`"
|
||||||
|
:type="input.type || 'text'" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 单选框 -->
|
||||||
|
<div v-if="currentChannelConfig.taskInsRadios && currentChannelConfig.taskInsRadios.length > 0" class="mt-4">
|
||||||
|
<Label class="text-sm font-medium mb-2">消息格式</Label>
|
||||||
|
<RadioGroup v-model="formData.templ_type" class="flex gap-4">
|
||||||
|
<div v-for="radio in currentChannelConfig.taskInsRadios" :key="radio.subLabel" class="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem :value="radio.subLabel" :id="radio.subLabel" />
|
||||||
|
<Label :for="radio.subLabel" class="text-sm cursor-pointer">{{ radio.subLabel }}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关联的实例表 -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-sm font-medium mb-3">已经关联的实例</h3>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>渠道名称</TableHead>
|
||||||
|
<TableHead>内容类型</TableHead>
|
||||||
|
<TableHead class="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="ins in insTableData" :key="ins.id">
|
||||||
|
<TableCell>
|
||||||
|
<div class="font-medium">{{ ins.way_name || '未命名' }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ ins.way_type }}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{{ ins.content_type }}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<Switch
|
||||||
|
:model-value="ins.enable === 1"
|
||||||
|
@update:model-value="() => handleToggleEnable(ins.id, ins.enable)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="text-red-500 border-red-300 hover:bg-red-50 hover:border-red-400 hover:text-red-600 hover:shadow-md transition-all duration-200"
|
||||||
|
@click="handleDeleteIns(ins.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-if="!insTableData || insTableData.length === 0">
|
||||||
|
<TableCell :colspan="3" class="h-24">
|
||||||
|
<EmptyTableState title="暂无实例" description="还没有配置任何实例,请先添加" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -19,7 +19,8 @@ import { getPageSize } from '@/util/pageUtils';
|
|||||||
interface LogItem {
|
interface LogItem {
|
||||||
id: number
|
id: number
|
||||||
task_id: number
|
task_id: number
|
||||||
task_name: string
|
type: string // 类型:task 或 template
|
||||||
|
name: string // 任务或模板名称
|
||||||
log: string
|
log: string
|
||||||
created_on: string
|
created_on: string
|
||||||
caller_ip?: string
|
caller_ip?: string
|
||||||
@@ -34,7 +35,7 @@ let state = reactive({
|
|||||||
currPage: 1,
|
currPage: 1,
|
||||||
pageSize: getPageSize(),
|
pageSize: getPageSize(),
|
||||||
search: '',
|
search: '',
|
||||||
optionValue: '',
|
optionValue: '', // 保存 taskid,用于过滤
|
||||||
})
|
})
|
||||||
|
|
||||||
// 状态过滤
|
// 状态过滤
|
||||||
@@ -50,10 +51,25 @@ const getStatusText = (status: number) => {
|
|||||||
return status === 1 ? '成功' : '失败'
|
return status === 1 ? '成功' : '失败'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取类型文本
|
||||||
|
const getTypeText = (type: string) => {
|
||||||
|
return type === 'template' ? '模板' : '任务'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取类型徽章样式
|
||||||
|
const getTypeBadgeVariant = (type: string) => {
|
||||||
|
return type === 'template' ? 'secondary' : 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取显示名称
|
||||||
|
const getDisplayName = (task: LogItem) => {
|
||||||
|
return task.name || '-'
|
||||||
|
}
|
||||||
|
|
||||||
// 打开日志详情Sheet
|
// 打开日志详情Sheet
|
||||||
const openLogSheet = (task: LogItem) => {
|
const openLogSheet = (task: LogItem) => {
|
||||||
selectedLog.value = formatLogDisplayHtml(task);
|
selectedLog.value = formatLogDisplayHtml(task);
|
||||||
selectedTaskName.value = task.task_name
|
selectedTaskName.value = getDisplayName(task)
|
||||||
isSheetOpen.value = true
|
isSheetOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +136,8 @@ const queryListData = async (page: number, size: number, name = '', taskid = '',
|
|||||||
// 解析URL参数并更新筛选状态
|
// 解析URL参数并更新筛选状态
|
||||||
const parseUrlParams = async () => {
|
const parseUrlParams = async () => {
|
||||||
state.search = router.query.name?.toString() || '';
|
state.search = router.query.name?.toString() || '';
|
||||||
|
// 保存 taskid 到 state,用于后续过滤
|
||||||
|
state.optionValue = router.query.taskid?.toString() || '';
|
||||||
|
|
||||||
// 解析URL中的query参数,设置状态筛选
|
// 解析URL中的query参数,设置状态筛选
|
||||||
const queryParam = router.query.query?.toString() || '';
|
const queryParam = router.query.query?.toString() || '';
|
||||||
@@ -140,8 +158,8 @@ const parseUrlParams = async () => {
|
|||||||
await queryListData(
|
await queryListData(
|
||||||
1,
|
1,
|
||||||
state.pageSize,
|
state.pageSize,
|
||||||
router.query.name?.toString() || '',
|
state.search,
|
||||||
router.query.taskid?.toString() || '',
|
state.optionValue,
|
||||||
queryParam
|
queryParam
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -185,7 +203,8 @@ onMounted(async () => {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-20">ID</TableHead>
|
<TableHead class="w-20">ID</TableHead>
|
||||||
<TableHead>任务名称</TableHead>
|
<TableHead class="w-24">类型</TableHead>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
<TableHead>发信日志</TableHead>
|
<TableHead>发信日志</TableHead>
|
||||||
<TableHead class="whitespace-nowrap w-[160px]">发送时间</TableHead>
|
<TableHead class="whitespace-nowrap w-[160px]">发送时间</TableHead>
|
||||||
<TableHead class="text-center">详情/状态</TableHead>
|
<TableHead class="text-center">详情/状态</TableHead>
|
||||||
@@ -195,7 +214,7 @@ onMounted(async () => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<!-- 空数据展示 -->
|
<!-- 空数据展示 -->
|
||||||
<TableRow v-if="state.tableData.length === 0">
|
<TableRow v-if="state.tableData.length === 0">
|
||||||
<TableCell colspan="5" class="text-center py-12">
|
<TableCell colspan="6" class="text-center py-12">
|
||||||
<EmptyTableState
|
<EmptyTableState
|
||||||
title="暂无发信日志"
|
title="暂无发信日志"
|
||||||
description="还没有任何发信日志记录"
|
description="还没有任何发信日志记录"
|
||||||
@@ -213,7 +232,12 @@ onMounted(async () => {
|
|||||||
<TableRow v-for="task in state.tableData" :key="task.id">
|
<TableRow v-for="task in state.tableData" :key="task.id">
|
||||||
<TableCell>{{ task.id }}</TableCell>
|
<TableCell>{{ task.id }}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ClickableTruncate :text="task.task_name" wrapper-class="max-w-[220px] sm:max-w-[360px]" preview-title="任务名称" />
|
<Badge :variant="getTypeBadgeVariant(task.type || 'task')">
|
||||||
|
{{ getTypeText(task.type || 'task') }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ClickableTruncate :text="getDisplayName(task)" wrapper-class="max-w-[220px] sm:max-w-[360px]" preview-title="名称" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ClickableTruncate :text="task.log" wrapper-class="max-w-[320px] sm:max-w-[480px]" preview-title="发信日志" />
|
<ClickableTruncate :text="task.log" wrapper-class="max-w-[320px] sm:max-w-[480px]" preview-title="发信日志" />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const handleCancel = () => {
|
|||||||
|
|
||||||
// 添加一条任务
|
// 添加一条任务
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const taskId = generateBizUniqueID('T');
|
const taskId = generateBizUniqueID('TK');
|
||||||
const postData: Record<string, any> = {
|
const postData: Record<string, any> = {
|
||||||
id: taskId,
|
id: taskId,
|
||||||
name: inputValue.value.trim(),
|
name: inputValue.value.trim(),
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export default defineComponent({
|
|||||||
// 当前选中的标签
|
// 当前选中的标签
|
||||||
const activeTab = ref('curl')
|
const activeTab = ref('curl')
|
||||||
|
|
||||||
|
// 可选参数选项
|
||||||
|
const showHtml = ref(false)
|
||||||
|
const showMarkdown = ref(false)
|
||||||
|
const showUrl = ref(false)
|
||||||
|
const showAtMobiles = ref(false)
|
||||||
|
const showAtUserIds = ref(false)
|
||||||
|
const showAtAll = ref(false)
|
||||||
|
|
||||||
// 代码语言选项
|
// 代码语言选项
|
||||||
const codeLanguages = [
|
const codeLanguages = [
|
||||||
{ value: 'curl', label: 'cURL', icon: '🌐' },
|
{ value: 'curl', label: 'cURL', icon: '🌐' },
|
||||||
@@ -56,7 +64,14 @@ export default defineComponent({
|
|||||||
// 生成API代码示例
|
// 生成API代码示例
|
||||||
const generateApiCode = (language: string) => {
|
const generateApiCode = (language: string) => {
|
||||||
const taskId = props.taskData?.id || 'TASK_ID'
|
const taskId = props.taskData?.id || 'TASK_ID'
|
||||||
const options = { html: false, markdown: false, url: false }
|
const options = {
|
||||||
|
html: showHtml.value,
|
||||||
|
markdown: showMarkdown.value,
|
||||||
|
url: showUrl.value,
|
||||||
|
at_mobiles: showAtMobiles.value,
|
||||||
|
at_user_ids: showAtUserIds.value,
|
||||||
|
at_all: showAtAll.value
|
||||||
|
}
|
||||||
|
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'curl':
|
case 'curl':
|
||||||
@@ -91,6 +106,12 @@ export default defineComponent({
|
|||||||
return {
|
return {
|
||||||
handleUpdateOpen,
|
handleUpdateOpen,
|
||||||
activeTab,
|
activeTab,
|
||||||
|
showHtml,
|
||||||
|
showMarkdown,
|
||||||
|
showUrl,
|
||||||
|
showAtMobiles,
|
||||||
|
showAtUserIds,
|
||||||
|
showAtAll,
|
||||||
codeLanguages,
|
codeLanguages,
|
||||||
generateApiCode,
|
generateApiCode,
|
||||||
copyToClipboard
|
copyToClipboard
|
||||||
@@ -122,6 +143,44 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
|
<!-- 可选参数 -->
|
||||||
|
<div class="border rounded-lg p-4 space-y-3">
|
||||||
|
<h3 class="font-semibold text-sm">可选参数</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showHtml" class="rounded">
|
||||||
|
<span class="text-sm">HTML</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showMarkdown" class="rounded">
|
||||||
|
<span class="text-sm">Markdown</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showUrl" class="rounded">
|
||||||
|
<span class="text-sm">URL</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showAtMobiles" class="rounded">
|
||||||
|
<span class="text-sm">@手机号</span>
|
||||||
|
<Badge variant="secondary" class="text-xs">新</Badge>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showAtUserIds" class="rounded">
|
||||||
|
<span class="text-sm">@用户ID</span>
|
||||||
|
<Badge variant="secondary" class="text-xs">新</Badge>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showAtAll" class="rounded">
|
||||||
|
<span class="text-sm">@所有人</span>
|
||||||
|
<Badge variant="secondary" class="text-xs">新</Badge>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<p>💡 提示:@功能仅钉钉和企业微信支持</p>
|
||||||
|
<p>📋 发送顺序:实例配置的内容类型优先,若为空则按 <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">HTML → Markdown → Text</code> 顺序回退</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 代码示例 -->
|
<!-- 代码示例 -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h3 class="font-semibold">代码示例</h3>
|
<h3 class="font-semibold">代码示例</h3>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, defineEmits, defineProps, withDefaults, onMounted } from 'vue'
|
import { ref, computed, defineProps, withDefaults, onMounted } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
import EmptyTableState from '@/components/ui/EmptyTableState.vue'
|
||||||
@@ -33,12 +33,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
editData: null
|
editData: null
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件emits
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:open': [value: boolean]
|
|
||||||
'save': [data: any]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 前端的页面添加配置
|
// 前端的页面添加配置
|
||||||
const waysConfigMap = CONSTANT.WAYS_DATA
|
const waysConfigMap = CONSTANT.WAYS_DATA
|
||||||
|
|
||||||
@@ -82,16 +76,11 @@ const handlechannelNameChange = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭drawer
|
|
||||||
const handleClose = () => {
|
|
||||||
emit('update:open', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加单条实例配置
|
// 添加单条实例配置
|
||||||
const handleAddSubmit = async () => {
|
const handleAddSubmit = async () => {
|
||||||
// 组建表单数据
|
// 组建表单数据
|
||||||
let postData = {
|
let postData = {
|
||||||
"id": generateBizUniqueID('I'),
|
"id": generateBizUniqueID('IN'),
|
||||||
"enable": 1,
|
"enable": 1,
|
||||||
"task_id": props.editData.id,
|
"task_id": props.editData.id,
|
||||||
"way_id": displayOptions.value[0]?.id,
|
"way_id": displayOptions.value[0]?.id,
|
||||||
@@ -244,19 +233,19 @@ onMounted(() => {
|
|||||||
<div v-if="props.editData" class="flex flex-col sm:flex-row sm:items-center gap-2 border-b p-4">
|
<div v-if="props.editData" class="flex flex-col sm:flex-row sm:items-center gap-2 border-b p-4">
|
||||||
<Label class="w-16 sm:w-16">任务名称</Label>
|
<Label class="w-16 sm:w-16">任务名称</Label>
|
||||||
<Input v-model="props.editData.name" placeholder="请输入任务名称" class="w-full sm:w-64" />
|
<Input v-model="props.editData.name" placeholder="请输入任务名称" class="w-full sm:w-64" />
|
||||||
<Button class="w-full sm:w-auto sm:ml-auto" @click="handleEditTask">修改</Button>
|
<Button size="sm" variant="outline" class="w-full sm:w-auto sm:ml-auto" @click="handleEditTask">修改</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="flex gap-4">
|
<div class="flex items-end gap-2">
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
<div class="flex-1">
|
<Label class="text-sm font-medium">选择发送渠道</Label>
|
||||||
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
|
<Combobox v-model="channelName" @update:model-value="handlechannelNameChange">
|
||||||
<ComboboxAnchor class="w-full">
|
<ComboboxAnchor class="w-full">
|
||||||
<ComboboxInput v-model="inputDisplayValue" @input="handleSearch(inputDisplayValue)"
|
<ComboboxInput v-model="inputDisplayValue" @input="handleSearch(inputDisplayValue)"
|
||||||
class="flex h-10 w-full " placeholder="搜索或选择渠道类型进行实例的添加..." />
|
class="flex h-10 w-full" placeholder="搜索或选择渠道类型进行实例的添加..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
|
<ComboboxList class="w-[var(--reka-combobox-trigger-width)]">
|
||||||
<ComboboxViewport>
|
<ComboboxViewport>
|
||||||
@@ -276,6 +265,7 @@ onMounted(() => {
|
|||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" variant="outline" @click="handleAddSubmit">添加实例</Button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 动态任务配置区域 -->
|
<!-- 动态任务配置区域 -->
|
||||||
<div v-if="currentChannelConfig" class="mt-4">
|
<div v-if="currentChannelConfig" class="mt-4">
|
||||||
@@ -309,11 +299,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 border-b pb-4 mt-2">
|
|
||||||
<Button variant="outline" @click="handleClose">取消</Button>
|
|
||||||
<Button @click="handleAddSubmit">添加实例</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 关联的实例表 -->
|
<!-- 关联的实例表 -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<h3 class="text-sm font-medium text-gray-900 mb-3">已经关联的实例</h3>
|
<h3 class="text-sm font-medium text-gray-900 mb-3">已经关联的实例</h3>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import AddTasks from './AddTasks.vue'
|
|||||||
import EditTasks from './EditTasks.vue'
|
import EditTasks from './EditTasks.vue'
|
||||||
import ApiCodeViewer from './ApiCodeViewer.vue'
|
import ApiCodeViewer from './ApiCodeViewer.vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
import { InfoIcon, XIcon } from 'lucide-vue-next'
|
||||||
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { request } from '@/api/api';
|
import { request } from '@/api/api';
|
||||||
@@ -18,6 +19,13 @@ import { CONSTANT } from '@/constant';
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { getPageSize } from '@/util/pageUtils';
|
import { getPageSize } from '@/util/pageUtils';
|
||||||
|
|
||||||
|
// 提示横幅显示状态
|
||||||
|
const showBanner = ref(true)
|
||||||
|
const closeBanner = () => {
|
||||||
|
showBanner.value = false
|
||||||
|
localStorage.setItem('hideTaskBanner', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
interface WayItem {
|
interface WayItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -152,6 +160,12 @@ const handleDelete = async (id: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 检查是否已经关闭过横幅
|
||||||
|
const hidden = localStorage.getItem('hideTaskBanner')
|
||||||
|
if (hidden === 'true') {
|
||||||
|
showBanner.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化查询
|
// 初始化查询
|
||||||
state.search = route.query.name?.toString() || '';
|
state.search = route.query.name?.toString() || '';
|
||||||
await queryListData(
|
await queryListData(
|
||||||
@@ -166,6 +180,29 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 w-full max-w-6xl mx-auto space-y-2">
|
<div class="p-4 w-full max-w-6xl mx-auto space-y-2">
|
||||||
|
<!-- 推荐使用模板的提示横幅 -->
|
||||||
|
<div v-if="showBanner" class="mb-4 rounded-lg border border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<InfoIcon class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-1">💡 推荐使用消息模板</h3>
|
||||||
|
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
新项目建议使用
|
||||||
|
<router-link to="/templates" class="font-medium underline hover:text-blue-600">消息模板</router-link>
|
||||||
|
功能,它提供更好的内容管理和维护体验。发送任务主要用于兼容早期使用数据。
|
||||||
|
<a href="https://engigu.github.io/Message-Push-Nest/guide/template.html" target="_blank"
|
||||||
|
class="font-medium underline hover:text-blue-600 ml-1">
|
||||||
|
了解更多 →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button @click="closeBanner"
|
||||||
|
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 flex-shrink-0">
|
||||||
|
<XIcon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
||||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||||
<div class="flex-1 sm:flex-initial">
|
<div class="flex-1 sm:flex-initial">
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Check } from "lucide-vue-next"
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="checkbox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
class="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<Check class="size-3.5" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from "./Checkbox.vue"
|
||||||
+1
-1
@@ -55,7 +55,7 @@ const CONSTANT = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'QyWeiXin',
|
type: 'QyWeiXin',
|
||||||
label: '企业微信',
|
label: '企业微信机器人',
|
||||||
inputs: [
|
inputs: [
|
||||||
{ subLabel: 'token', value: '', col: 'access_token', desc: "企业微信webhook中的token" },
|
{ subLabel: 'token', value: '', col: 'access_token', desc: "企业微信webhook中的token" },
|
||||||
{ subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" },
|
{ subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" },
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ const router = createRouter({
|
|||||||
path: 'cronmessages',
|
path: 'cronmessages',
|
||||||
name: 'cronmessages',
|
name: 'cronmessages',
|
||||||
component: () => import('../components/pages/cronMessages/CronMessages.vue')
|
component: () => import('../components/pages/cronMessages/CronMessages.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'templates',
|
||||||
|
name: 'templates',
|
||||||
|
component: () => import('../components/pages/messageTemplate/MessageTemplate.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 获取分页大小配置
|
||||||
|
* @returns 分页大小,默认为8
|
||||||
|
*/
|
||||||
|
export declare function getPageSize(): number;
|
||||||
@@ -18,7 +18,7 @@ function generateUniqueID() {
|
|||||||
|
|
||||||
function generateBizUniqueID(flag) {
|
function generateBizUniqueID(flag) {
|
||||||
const randomString = generateRandomString(10);
|
const randomString = generateRandomString(10);
|
||||||
return `${flag}-${randomString}`;
|
return `${flag}${randomString}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { generateUniqueID, generateBizUniqueID };
|
export { generateUniqueID, generateBizUniqueID };
|
||||||
|
|||||||
+151
-76
@@ -1,11 +1,10 @@
|
|||||||
|
|
||||||
|
|
||||||
const gethttpOrigin = () => {
|
const gethttpOrigin = () => {
|
||||||
return window.location.origin
|
return window.location.origin
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiStrGenerate {
|
// ==================== 公共加密工具 ====================
|
||||||
|
|
||||||
|
class TokenEncryption {
|
||||||
// 根据字符串内容生成确定性 salt(范围 0~255)
|
// 根据字符串内容生成确定性 salt(范围 0~255)
|
||||||
static getDeterministicSalt(text) {
|
static getDeterministicSalt(text) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
@@ -17,7 +16,7 @@ class ApiStrGenerate {
|
|||||||
|
|
||||||
// 加密:首字节为salt,后续为按位异或后的数据
|
// 加密:首字节为salt,后续为按位异或后的数据
|
||||||
static encryptHex(text, key) {
|
static encryptHex(text, key) {
|
||||||
const salt = ApiStrGenerate.getDeterministicSalt(text);
|
const salt = TokenEncryption.getDeterministicSalt(text);
|
||||||
let result = salt.toString(16).padStart(2, '0');
|
let result = salt.toString(16).padStart(2, '0');
|
||||||
for (let i = 0; i < text.length; i++) {
|
for (let i = 0; i < text.length; i++) {
|
||||||
const code = text.charCodeAt(i) ^ (key & 0xFF) ^ ((salt + i) & 0xFF);
|
const code = text.charCodeAt(i) ^ (key & 0xFF) ^ ((salt + i) & 0xFF);
|
||||||
@@ -25,36 +24,19 @@ class ApiStrGenerate {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static getDataString(task_id, options) {
|
// ==================== 公共代码模板生成器 ====================
|
||||||
// 新版仅展示 token;兼容旧版 task_id(后端依然支持)
|
|
||||||
let data = { token: ApiStrGenerate.encryptHex(task_id, 71) };
|
class CodeTemplates {
|
||||||
data.title = 'message title';
|
static getCurl(url, dataStr) {
|
||||||
data.text = 'Hello World!';
|
return `curl -X POST --location '${url}' \\
|
||||||
if (options.html) {
|
--header 'Content-Type: application/json' \\
|
||||||
data.html = '<h1> Hello World! </h1>';
|
--data '${dataStr}'`;
|
||||||
}
|
|
||||||
if (options.markdown) {
|
|
||||||
data.markdown = '**Hello World!**';
|
|
||||||
}
|
|
||||||
if (options.url) {
|
|
||||||
data.url = 'https://github.com';
|
|
||||||
}
|
|
||||||
let dataStr = JSON.stringify(data, null, 4);
|
|
||||||
return dataStr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getCurlString(task_id, options) {
|
static getGolang(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `package main
|
||||||
let example = `curl -X POST --location '${gethttpOrigin()}/api/v1/message/send' \\
|
|
||||||
--header 'Content-Type: application/json' \\
|
|
||||||
--data '${dataStr}'`;
|
|
||||||
return example;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getGolangString(task_id, options) {
|
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
|
||||||
let example = `package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -67,7 +49,7 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
var data = strings.NewReader(\`${dataStr}\`)
|
var data = strings.NewReader(\`${dataStr}\`)
|
||||||
req, err := http.NewRequest("POST", "${gethttpOrigin()}/api/v1/message/send", data)
|
req, err := http.NewRequest("POST", "${url}", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -82,29 +64,23 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("%s\\n", bodyText)
|
fmt.Printf("%s\\n", bodyText)
|
||||||
}
|
}`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPythonString(task_id, options) {
|
static getPython(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `import requests
|
||||||
let example = `import requests
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
json_data = ${dataStr}
|
json_data = ${dataStr}
|
||||||
response = requests.post('${gethttpOrigin()}/api/v1/message/send', headers=headers, json=json_data)
|
response = requests.post('${url}', headers=headers, json=json_data)
|
||||||
|
|
||||||
print("response:", response.json())
|
print("response:", response.json())`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getJaveString(task_id, options) {
|
static getJava(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `import java.io.IOException;
|
||||||
let example = `import java.io.IOException;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
@@ -116,19 +92,16 @@ HttpClient client = HttpClient.newBuilder()
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create("${gethttpOrigin()}/api/v1/message/send"))
|
.uri(URI.create("${url}"))
|
||||||
.POST(BodyPublishers.ofString(${JSON.stringify(dataStr).trim('\"')}))
|
.POST(BodyPublishers.ofString(${JSON.stringify(dataStr).trim('"')}))
|
||||||
.setHeader("Content-Type", "application/json")
|
.setHeader("Content-Type", "application/json")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getRustString(task_id, options) {
|
static getRust(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `extern crate reqwest;
|
||||||
let example = `extern crate reqwest;
|
|
||||||
use reqwest::header;
|
use reqwest::header;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -136,7 +109,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
headers.insert("Content-Type", "application/json".parse().unwrap());
|
headers.insert("Content-Type", "application/json".parse().unwrap());
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let res = client.post("${gethttpOrigin()}/api/v1/message/send")
|
let res = client.post("${url}")
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.body(r#"
|
.body(r#"
|
||||||
${dataStr}
|
${dataStr}
|
||||||
@@ -147,43 +120,37 @@ ${dataStr}
|
|||||||
println!("{}", res);
|
println!("{}", res);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPHPString(task_id, options) {
|
static getPHP(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `<?php
|
||||||
let example = `<?php
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, '${gethttpOrigin()}/api/v1/message/send');
|
curl_setopt($ch, CURLOPT_URL, '${url}');
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
]);
|
]);
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, ${JSON.stringify(dataStr).trim('\"')});
|
curl_setopt($ch, CURLOPT_POSTFIELDS, ${JSON.stringify(dataStr).trim('"')});
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
curl_close($ch);
|
curl_close($ch);`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getNodeString(task_id, options) {
|
static getNode(url, dataStr) {
|
||||||
let dataStr = ApiStrGenerate.getDataString(task_id, options);
|
return `var request = require('request');
|
||||||
let example = `var request = require('request');
|
|
||||||
|
|
||||||
var headers = {
|
var headers = {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
};
|
};
|
||||||
|
|
||||||
var dataString = ${JSON.stringify(dataStr).trim('\"')};
|
var dataString = ${JSON.stringify(dataStr).trim('"')};
|
||||||
|
|
||||||
var options = {
|
var options = {
|
||||||
url: '${gethttpOrigin()}/api/v1/message/send',
|
url: '${url}',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: dataString
|
body: dataString
|
||||||
@@ -191,15 +158,123 @@ var options = {
|
|||||||
|
|
||||||
function callback(error, response, body) {
|
function callback(error, response, body) {
|
||||||
if (!error && response.statusCode == 200) {
|
if (!error && response.statusCode == 200) {
|
||||||
|
console.log(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request(options, callback);
|
request(options, callback);`;
|
||||||
`;
|
|
||||||
return example;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ApiStrGenerate };
|
// ==================== 发信任务 API (V1) ====================
|
||||||
|
|
||||||
|
class ApiStrGenerate {
|
||||||
|
static getDataString(task_id, options) {
|
||||||
|
let data = { token: TokenEncryption.encryptHex(task_id, 71) };
|
||||||
|
data.title = 'message title';
|
||||||
|
data.text = 'Hello World!';
|
||||||
|
if (options.html) data.html = '<h1> Hello World! </h1>';
|
||||||
|
if (options.markdown) data.markdown = '**Hello World!**';
|
||||||
|
if (options.url) data.url = 'https://github.com';
|
||||||
|
if (options.at_mobiles) data.at_mobiles = ['13800138000', '13900139000'];
|
||||||
|
if (options.at_user_ids) data.at_user_ids = ['zhangsan', 'lisi'];
|
||||||
|
if (options.at_all) data.at_all = true;
|
||||||
|
return JSON.stringify(data, null, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getApiUrl() {
|
||||||
|
return `${gethttpOrigin()}/api/v1/message/send`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCurlString(task_id, options) {
|
||||||
|
return CodeTemplates.getCurl(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getGolangString(task_id, options) {
|
||||||
|
return CodeTemplates.getGolang(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPythonString(task_id, options) {
|
||||||
|
return CodeTemplates.getPython(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getJaveString(task_id, options) {
|
||||||
|
return CodeTemplates.getJava(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRustString(task_id, options) {
|
||||||
|
return CodeTemplates.getRust(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPHPString(task_id, options) {
|
||||||
|
return CodeTemplates.getPHP(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getNodeString(task_id, options) {
|
||||||
|
return CodeTemplates.getNode(this.getApiUrl(), this.getDataString(task_id, options));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 模板 API (V2) ====================
|
||||||
|
|
||||||
|
class TemplateApiStrGenerate {
|
||||||
|
static getTemplateDataString(template_id, placeholders_json) {
|
||||||
|
// 解析占位符配置
|
||||||
|
let placeholders = {};
|
||||||
|
try {
|
||||||
|
const placeholdersList = JSON.parse(placeholders_json || '[]');
|
||||||
|
// 根据占位符配置生成示例值
|
||||||
|
placeholdersList.forEach(p => {
|
||||||
|
placeholders[p.key] = p.default || `mock_${p.key}`;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 如果解析失败,使用默认示例
|
||||||
|
placeholders = {
|
||||||
|
'username': 'John Doe',
|
||||||
|
'email': 'john@example.com',
|
||||||
|
'phone': '13800138000'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
token: TokenEncryption.encryptHex(template_id, 71),
|
||||||
|
title: 'message title',
|
||||||
|
placeholders: placeholders
|
||||||
|
};
|
||||||
|
return JSON.stringify(data, null, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getApiUrl() {
|
||||||
|
return `${gethttpOrigin()}/api/v2/message/send`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCurlString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getCurl(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getGolangString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getGolang(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPythonString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getPython(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getJavaString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getJava(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRustString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getRust(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getPHPString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getPHP(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getNodeString(template_id, placeholders_json) {
|
||||||
|
return CodeTemplates.getNode(this.getApiUrl(), this.getTemplateDataString(template_id, placeholders_json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiStrGenerate, TemplateApiStrGenerate };
|
||||||
|
|||||||
Reference in New Issue
Block a user