diff --git a/.release_log b/.release_log index cf6871d..1fc2b86 100644 --- a/.release_log +++ b/.release_log @@ -21,7 +21,8 @@ 21. 支持sqlite部署,支持不同版本mysql 22. [2025.01.01] 增加托管消息,现在可以将站点作为消息的接收,登录站点查看消息 23. [2025.04.28] 支持tidb数据库,支持数据库ssl连接 -23. [2025.08.10] UI组件使用shadcn-vue,基于tailwindcss -24. [2025.09.14] 支持系统信息展示更多的运行信息 -25. [2025.09.30] 支持页面的明暗主题切换设置,增加登录日志 -25. [2025.10.12] 增加cookies过期天数设置 +24. [2025.08.10] UI组件使用shadcn-vue,基于tailwindcss +25. [2025.09.14] 支持系统信息展示更多的运行信息 +26. [2025.09.30] 支持页面的明暗主题切换设置,增加登录日志 +27. [2025.10.12] 增加cookies过期天数设置 +28. [2025.12.06] 增加模板功能:集成预览到编辑器、优化ID生成规则、增强发送API日志记录 diff --git a/README.md b/README.md index 690243f..d042b5f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Message Nest 是一个灵活而强大的消息推送整合平台,旨在简化 如果你有很多消息推送方式,每次都需要调用各种接口去发送消息到各个渠道,或者不同的项目你都需要复制同样的发消息代码,这个项目可以帮你管理各种消息方式,并提供统一的发送api接入。你可以自由组合各种消息渠道,一个api推送到各种渠道,帮你省去接入的繁琐步骤。 +这个项目适合推送固定接受人(或者固定多个接收渠道)的场景,不适合批量群发场景。虽然也能支持群发,但是群发本身也有一定风险,不是维护的主要场景,群发的反馈不会处理。 + 演示站点(演示站点的服务器比较烂,见谅) [demo](https://message-nest-demo-site.qwapi.eu.org/) [![Hits](https://hits.sh/github.com/engigu/Message-Push-Nest.svg?view=today-total)](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 Chart](https://api.star-history.com/svg?repos=engigu/Message-Push-Nest&type=Date)](https://star-history.com/#engigu/Message-Push-Nest&Date) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ae91921..fbad0f4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -24,7 +24,7 @@ export default defineConfig({ { text: '首页', link: '/' }, { text: '指南', link: '/guide/introduction' }, { text: '部署', link: '/deployment/overview' }, - { text: 'API', link: '/api/usage' }, + { text: 'API', link: '/api/v1' }, { text: '演示站点', link: 'https://message-nest-demo-site.qwapi.eu.org/' } ], @@ -32,6 +32,17 @@ export default defineConfig({ '/guide/': [ { text: '介绍', link: '/guide/introduction' }, { 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' } ], '/deployment/': [ @@ -57,8 +68,8 @@ export default defineConfig({ { text: 'API文档', items: [ - { text: '使用说明', link: '/api/usage' }, - { text: '调用示例', link: '/api/examples' } + { text: 'V1 API(任务)', link: '/api/v1' }, + { text: 'V2 API(模板)', link: '/api/v2' } ] } ] diff --git a/docs/api/v1.md b/docs/api/v1.md new file mode 100644 index 0000000..438fa71 --- /dev/null +++ b/docs/api/v1.md @@ -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": "

系统通知

您好,系统检测到异常登录

", + "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": "

HTML内容

", + "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": "

订单通知

您的订单已发货

订单号:20241206001

", + "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 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 + "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) 了解如何使用模板功能 diff --git a/docs/api/v2.md b/docs/api/v2.md new file mode 100644 index 0000000..ac1ab3c --- /dev/null +++ b/docs/api/v2.md @@ -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: `

您好,{{username}}!

` +- 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) 了解更多调用示例 diff --git a/docs/guide/changelog.md b/docs/guide/changelog.md index 3f22cd9..419a19c 100644 --- a/docs/guide/changelog.md +++ b/docs/guide/changelog.md @@ -2,6 +2,19 @@ ## 2025 +### 2025.12.06 +- **新增消息模板功能** + - 支持创建可复用的消息模板 + - 支持占位符动态替换(`{{key}}` 语法) + - 支持 Text、HTML、Markdown 三种格式 + - 支持为模板配置多个发送实例 + - 支持 @提醒功能(钉钉、企业微信) +- **新增 V2 API** + - 基于模板的消息发送接口 + - 使用加密 Token 提升安全性 + - 支持占位符参数传递 + - 自动遍历模板的所有启用实例 + ### 2025.10.12 - 增加cookies过期天数设置 diff --git a/docs/guide/channels.md b/docs/guide/channels.md new file mode 100644 index 0000000..9abb9de --- /dev/null +++ b/docs/guide/channels.md @@ -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 发送消息 diff --git a/docs/guide/features.md b/docs/guide/features.md index 5d13d4b..f80122f 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -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** - 轻量级部署,无需额外数据库服务 diff --git a/docs/guide/scheduled-messages.md b/docs/guide/scheduled-messages.md new file mode 100644 index 0000000..ebb6aef --- /dev/null +++ b/docs/guide/scheduled-messages.md @@ -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) 了解如何配置推送渠道 diff --git a/docs/guide/self-hosted-messages-old.md b/docs/guide/self-hosted-messages-old.md new file mode 100644 index 0000000..e0eaf23 --- /dev/null +++ b/docs/guide/self-hosted-messages-old.md @@ -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": "

系统通知

您有一条新消息

", + "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 +
+

订单发货通知

+

您的订单 20241206001 已发货。

+ + + + + + + + + +
快递公司:顺丰速运
快递单号:SF1234567890
+

如有问题请联系客服。

+
+``` + +**展示效果:** 完整的 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) 了解如何发送消息 diff --git a/docs/guide/self-hosted-messages.md b/docs/guide/self-hosted-messages.md new file mode 100644 index 0000000..953ac88 --- /dev/null +++ b/docs/guide/self-hosted-messages.md @@ -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) 了解如何发送消息 diff --git a/docs/guide/settings.md b/docs/guide/settings.md new file mode 100644 index 0000000..df4629c --- /dev/null +++ b/docs/guide/settings.md @@ -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) 创建第一个模板 diff --git a/docs/guide/tasks.md b/docs/guide/tasks.md new file mode 100644 index 0000000..cc176a0 --- /dev/null +++ b/docs/guide/tasks.md @@ -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": "

订单通知

您的订单已发货

", + "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 调用方法 diff --git a/docs/guide/template.md b/docs/guide/template.md new file mode 100644 index 0000000..89abdc4 --- /dev/null +++ b/docs/guide/template.md @@ -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 +
+

您好,{{username}}!

+

您的账号 {{email}} 刚刚进行了 {{action}} 操作。

+

如果这不是您本人的操作,请立即联系我们。

+
+``` + +#### 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) 了解各语言的调用方式 diff --git a/docs/index.md b/docs/index.md index 6018457..97333a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,39 +48,3 @@ features: title: 数据统计 details: 支持数据统计展示,查看消息发送情况。 --- - - diff --git a/docs/package-lock.json b/docs/package-lock.json index 8f31c19..4d1ba01 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,7 +8,7 @@ "name": "message-nest-docs", "version": "1.0.0", "devDependencies": { - "vitepress": "^1.0.0" + "vitepress": "^1.6.4" } }, "node_modules/@algolia/abtesting": { diff --git a/docs/package.json b/docs/package.json index 474f3a3..84d9798 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,6 +8,6 @@ "docs:preview": "vitepress preview" }, "devDependencies": { - "vitepress": "^1.0.0" + "vitepress": "^1.6.4" } } diff --git a/middleware/ginlog.go b/middleware/ginlog.go index 65b868d..b849097 100644 --- a/middleware/ginlog.go +++ b/middleware/ginlog.go @@ -1,8 +1,10 @@ package middleware import ( + "bytes" "fmt" "math" + "strings" "net/http" "net/url" @@ -12,6 +14,23 @@ import ( "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 func LogMiddleware(notLogged ...string) gin.HandlerFunc { //hostname, err := os.Hostname() @@ -33,6 +52,24 @@ func LogMiddleware(notLogged ...string) gin.HandlerFunc { raw := c.Request.URL.RawQuery 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() stop := time.Since(start) @@ -70,6 +107,13 @@ func LogMiddleware(notLogged ...string) gin.HandlerFunc { entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String()) } else { 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 { entry.Error(msg) } else if statusCode >= http.StatusBadRequest { diff --git a/middleware/jwt.go b/middleware/jwt.go index f498fda..0e53e45 100644 --- a/middleware/jwt.go +++ b/middleware/jwt.go @@ -2,9 +2,10 @@ package middleware import ( "errors" - "github.com/golang-jwt/jwt/v5" "net/http" + "github.com/golang-jwt/jwt/v5" + "github.com/gin-gonic/gin" "message-nest/pkg/e" @@ -13,6 +14,7 @@ import ( var ExcludedRoutes = []string{ "/api/v1/message/send", + "/api/v2/message/send", "/api/v1/settings/getsetting", } diff --git a/migrate/migrate.go b/migrate/migrate.go index 2213b7d..0dc1e78 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -3,10 +3,11 @@ package migrate import ( "errors" "fmt" - "github.com/sirupsen/logrus" - "gorm.io/gorm" "message-nest/models" "message-nest/service/settings_service" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" ) // 初始化admin账户 @@ -67,6 +68,7 @@ func Setup() { &models.CronMessages{}, &models.HostedMessage{}, &models.LoginLog{}, + &models.Template{}, } for _, table := range tables { diff --git a/models/cron_messages.go b/models/cron_messages.go index e12ada8..6d727b2 100644 --- a/models/cron_messages.go +++ b/models/cron_messages.go @@ -22,7 +22,7 @@ type CronMessages struct { func GenerateMsgUniqueID() string { newUUID := util.GenerateUniqueID() - return fmt.Sprintf("C-%s", newUUID) + return fmt.Sprintf("CM%s", newUUID) } func AddSendCronMsg( diff --git a/models/message_template.go b/models/message_template.go new file mode 100644 index 0000000..fe0723f --- /dev/null +++ b/models/message_template.go @@ -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 +} diff --git a/models/send_ins.go b/models/send_ins.go index c8049d3..1325d39 100644 --- a/models/send_ins.go +++ b/models/send_ins.go @@ -1,9 +1,12 @@ package models +import "fmt" + type SendTasksIns struct { UUIDModel 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"` WayType string `json:"way_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 } + +// 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 +} diff --git a/models/send_tasks.go b/models/send_tasks.go index 458eabd..ee58c86 100644 --- a/models/send_tasks.go +++ b/models/send_tasks.go @@ -15,7 +15,7 @@ type SendTasks struct { func GenerateTaskUniqueID() string { newUUID := util.GenerateUniqueID() - return fmt.Sprintf("T-%s", newUUID) + return fmt.Sprintf("TK%s", newUUID) } // AddSendTaskWithID 添加实例的时候添加任务 diff --git a/models/send_tasks_logs.go b/models/send_tasks_logs.go index f185a67..01d65c6 100644 --- a/models/send_tasks_logs.go +++ b/models/send_tasks_logs.go @@ -9,6 +9,8 @@ import ( type SendTasksLogs struct { ID int `gorm:"primaryKey" json:"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 ;"` Status *int `json:"status" gorm:"type:int ;default:0;"` CallerIp string `json:"caller_ip" gorm:"type:varchar(256) ;default:'';"` @@ -29,10 +31,11 @@ func (log *SendTasksLogs) Add() error { type LogsResult struct { ID int `json:"id"` TaskID string `json:"task_id"` + Type string `json:"type"` + Name string `json:"name"` Log string `json:"log"` CreatedOn util.Time `json:"created_on"` ModifiedOn util.Time `json:"modified_on"` - TaskName string `json:"task_name"` Status int `json:"status"` 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) { var logs []LogsResult logt := GetSchema(SendTasksLogs{}) - taskt := GetSchema(SendTasks{}) - 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)) + // 简化查询,只查询日志表 + query := db.Table(logt) dayVal, ok := maps["day_created_on"] if ok { @@ -55,8 +55,10 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map } query = query.Where(maps) + + // 按名称搜索(搜索日志表的 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 != "" { 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.Scan(&logs) + + //v1 接口的历史日志数据兼容处理 + // 应用层处理:为历史数据(type=task 且 name 为空)补充任务名称 + fillTaskNamesForLogs(&logs) 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 获取所有日志总数 func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (int64, error) { var total int64 logt := GetSchema(SendTasksLogs{}) - taskt := GetSchema(SendTasks{}) - query := db. - Table(logt). - Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt)) + + // 简化查询,只查询日志表 + query := db.Table(logt) dayVal, ok := maps["day_created_on"] if ok { @@ -86,8 +141,10 @@ func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) ( } query = query.Where(maps) + + // 按名称搜索(搜索日志表的 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 != "" { query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId) @@ -282,17 +339,18 @@ func GetTrendStatisticData() (TrendStatisticData, error) { return statistic, nil } -// GetChannelStatisticData 获取渠道统计数据 +// GetChannelStatisticData 获取渠道统计数据(包含任务实例和模板实例) func GetChannelStatisticData() (ChannelStatisticData, error) { var statistic ChannelStatisticData var wayCateData []WayCateData inst := GetSchema(SendTasksIns{}) wayst := GetSchema(SendWays{}) - // 消息实例分类数目 + // 统计所有实例的渠道分布(包含任务实例和模板实例) + // 不区分 task_id 和 template_id,统计所有关联到渠道的实例 db. 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)). Group(fmt.Sprintf("%s.id", wayst)). Scan(&wayCateData) diff --git a/models/send_ways.go b/models/send_ways.go index 1041e4d..aeb64ff 100644 --- a/models/send_ways.go +++ b/models/send_ways.go @@ -17,7 +17,7 @@ type SendWays struct { func GenerateWayUniqueID() string { 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 { diff --git a/pkg/message/dtalk.go b/pkg/message/dtalk.go index fd0e0cf..d63d05b 100644 --- a/pkg/message/dtalk.go +++ b/pkg/message/dtalk.go @@ -62,6 +62,26 @@ func (t *Dtalk) SendMessageText(text string, at ...string) ([]byte, error) { "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) return resp, err } @@ -74,6 +94,26 @@ func (t *Dtalk) SendMessageMarkdown(title, text string, at ...string) ([]byte, e "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) return resp, err } diff --git a/pkg/message/qyweixin.go b/pkg/message/qyweixin.go index d2a4968..5e62152 100644 --- a/pkg/message/qyweixin.go +++ b/pkg/message/qyweixin.go @@ -53,10 +53,40 @@ func (t *QyWeiXin) Request(msg interface{}) ([]byte, error) { func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) { msg := map[string]interface{}{ "msgtype": "text", - "text": map[string]string{ + "text": map[string]interface{}{ "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) 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) { msg := map[string]interface{}{ "msgtype": "markdown", - "markdown": map[string]string{ - "title": title, + "markdown": map[string]interface{}{ "content": text, }, } + + // 企业微信Markdown消息不支持@功能,但可以在内容中手动添加 + // 如果需要@功能,建议使用text类型 + resp, err := t.Request(msg) return resp, err } diff --git a/routers/api/v1/message_template.go b/routers/api/v1/message_template.go new file mode 100644 index 0000000..3dd9cce --- /dev/null +++ b/routers/api/v1/message_template.go @@ -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) +} diff --git a/routers/api/v1/send_message.go b/routers/api/v1/send_message.go index 99736b5..d78e3a3 100644 --- a/routers/api/v1/send_message.go +++ b/routers/api/v1/send_message.go @@ -23,6 +23,11 @@ type SendMessageReq struct { URL string `json:"url" label:"消息详情url地址"` MarkDown string `json:"markdown" label:"markdown内容"` 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 外部调用发信接口 @@ -56,15 +61,19 @@ func DoSendMassage(c *gin.Context) { } msgService := send_message_service.SendMessageService{ - TaskID: taskID, - Title: req.Title, - Text: req.Text, - HTML: req.HTML, - URL: req.URL, - MarkDown: req.MarkDown, - CallerIp: c.ClientIP(), + SendMode: send_message_service.SendModeTask, // 明确标记为任务模式 + TaskID: taskID, + Title: req.Title, + Text: req.Text, + HTML: req.HTML, + URL: req.URL, + MarkDown: req.MarkDown, + CallerIp: c.ClientIP(), + AtMobiles: req.AtMobiles, + AtUserIds: req.AtUserIds, + AtAll: req.AtAll, DefaultLogger: logrus.WithFields(logrus.Fields{ - //"prefix": "[Message Instance]", + "prefix": "[Send Instance]", }), } task, err := msgService.SendPreCheck() diff --git a/routers/api/v2/send_message.go b/routers/api/v2/send_message.go new file mode 100644 index 0000000..a34e9bb --- /dev/null +++ b/routers/api/v2/send_message.go @@ -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 +} diff --git a/routers/router.go b/routers/router.go index 66d3790..08fe9fe 100644 --- a/routers/router.go +++ b/routers/router.go @@ -8,6 +8,7 @@ import ( "message-nest/pkg/setting" "message-nest/routers/api" "message-nest/routers/api/v1" + "message-nest/routers/api/v2" "net/http" ) @@ -98,6 +99,26 @@ func InitRouter(f embed.FS) *gin.Engine { // hostedMessage 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 diff --git a/service/message_template_service/message_template.go b/service/message_template_service/message_template.go new file mode 100644 index 0000000..0ddbd0b --- /dev/null +++ b/service/message_template_service/message_template.go @@ -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 +} diff --git a/service/send_message_service/send_custom.go b/service/send_message_service/send_custom.go deleted file mode 100644 index 228ba28..0000000 --- a/service/send_message_service/send_custom.go +++ /dev/null @@ -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 -} diff --git a/service/send_message_service/send_dtalk.go b/service/send_message_service/send_dtalk.go deleted file mode 100644 index 0a8ca39..0000000 --- a/service/send_message_service/send_dtalk.go +++ /dev/null @@ -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 -} diff --git a/service/send_message_service/send_email.go b/service/send_message_service/send_email.go deleted file mode 100644 index 829b7b5..0000000 --- a/service/send_message_service/send_email.go +++ /dev/null @@ -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 -} diff --git a/service/send_message_service/send_hosted_message.go b/service/send_message_service/send_hosted_message.go deleted file mode 100644 index 7c670ae..0000000 --- a/service/send_message_service/send_hosted_message.go +++ /dev/null @@ -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 -} diff --git a/service/send_message_service/send_message.go b/service/send_message_service/send_message.go index 025df9d..a250af6 100644 --- a/service/send_message_service/send_message.go +++ b/service/send_message_service/send_message.go @@ -5,6 +5,7 @@ import ( "fmt" "message-nest/models" "message-nest/pkg/constant" + "message-nest/service/send_message_service/unified" "message-nest/service/send_task_service" "message-nest/service/send_way_service" "strings" @@ -17,6 +18,12 @@ const ( SendFail = 0 ) +// 发送模式类型 +const ( + SendModeTask = "task" // 传统任务模式 + SendModeTemplate = "template" // 模板模式 +) + func errStrIsSuccess(errStr string) int { if errStr == "" { return SendSuccess @@ -25,13 +32,21 @@ func errStrIsSuccess(errStr string) int { } type SendMessageService struct { - TaskID string - Title string - Text string - HTML string - URL string - MarkDown string - CallerIp string + SendMode string // 发送模式:task(任务模式) 或 template(模板模式) + TaskID string // 任务ID(任务模式)或模板ID(模板模式,用于日志记录) + TemplateID string // 模板ID(仅模板模式使用) + Name string // 任务或模板名称(用于日志记录) + Title string + Text string + HTML string + URL string + MarkDown string + CallerIp string + + // @提及相关字段 + AtMobiles []string + AtUserIds []string + AtAll bool Status int LogOutput []string @@ -76,31 +91,88 @@ func (sm *SendMessageService) AsyncSend(task models.TaskIns) { } // SendPreCheck 发送前数据准备和预检查 +// 支持两种模式: +// 1. SendModeTask:传统任务模式,使用 TaskID 查询任务和实例 +// 2. SendModeTemplate:模板模式,使用 TemplateID 查询模板关联的实例 func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) { errStr := "" entry := logrus.WithFields(logrus.Fields{ "prefix": "[Message PreChecK]", }) - sendTaskService := send_task_service.SendTaskService{ - ID: sm.TaskID, - } - task, err := sendTaskService.GetTaskWithIns() - if err != nil { - errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID) + + var task models.TaskIns + + switch sm.SendMode { + case SendModeTemplate: + // 模板模式:使用模板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) 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 发送一个消息任务的所有实例 @@ -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", 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() if errStr != "" { @@ -151,61 +216,50 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) { continue } - // 邮箱类型的实例发送 - emailAuth, ok := msgObj.(send_way_service.WayDetailEmail) - if ok { - //continue - es := EmailService{} - errMsg := es.SendTaskEmail(emailAuth, ins.SendTasksIns, typeC, sm.Title, content) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) + // 使用新的Channel架构发送消息 + channelRegistry := unified.GetGlobalChannelRegistry() + channel, ok := channelRegistry.GetChannel(way.Type) + if !ok { + sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道类型 %s 的发信实例: %s\n", way.Type, ins.ID), SendFail) continue } - // 钉钉类型的实例发送 - dtalkAuth, ok := msgObj.(send_way_service.WayDetailDTalk) - if ok { - es := DtalkService{} - res, errMsg := es.SendDtalkMessage(dtalkAuth, ins.SendTasksIns, typeC, sm.Title, content) - sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) - continue + + // 根据发送模式构建消息内容 + var unifiedContent *unified.UnifiedMessageContent + if sm.SendMode == SendModeTemplate { + // 模板模式:根据实例的 ContentType 精确发送对应类型的内容 + unifiedContent = sm.BuildTemplateContent(ins.SendTasksIns) + if unifiedContent == nil { + 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) - if ok { - es := QyWeiXinService{} - res, errMsg := es.SendQyWeiXinMessage(qywxAuth, ins.SendTasksIns, typeC, sm.Title, content) - sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) + + // 使用 SendUnified 方法(自动格式转换和@功能支持) + res, errMsg := channel.SendUnified(msgObj, ins.SendTasksIns, unifiedContent) + if res != "" { + sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s\n", res), sm.Status) + } else { 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 记录发送日志 func (sm *SendMessageService) RecordSendLog() { + // 确定日志类型 + logType := "task" + if sm.SendMode == SendModeTemplate { + logType = "template" + } + log := models.SendTasksLogs{ Log: strings.Join(sm.LogOutput, "\n"), TaskID: sm.TaskID, + Type: logType, + Name: sm.Name, Status: &sm.Status, 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发送 func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) { data := map[string]string{} - data["text"] = sm.Text - data["html"] = sm.HTML - data["markdown"] = sm.MarkDown + data[unified.FormatTypeText] = sm.Text + data[unified.FormatTypeHTML] = sm.HTML + data[unified.FormatTypeMarkdown] = sm.MarkDown content, ok := data[strings.ToLower(ins.ContentType)] if !ok || len(content) == 0 { - content, ok := data["text"] + content, ok := data[unified.FormatTypeText] if !ok { logrus.Error("text节点数据为空!") - return "text", "" + return unified.FormatTypeText, "" } else { logrus.Error(fmt.Sprintf("没有找到%s对应的消息,使用text消息替代!", ins.ContentType)) - return "text", content + return unified.FormatTypeText, content } } else { return strings.ToLower(ins.ContentType), content diff --git a/service/send_message_service/send_qywexin.go b/service/send_message_service/send_qywexin.go deleted file mode 100644 index 029b5ce..0000000 --- a/service/send_message_service/send_qywexin.go +++ /dev/null @@ -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 -} diff --git a/service/send_message_service/send_wechat_account.go b/service/send_message_service/send_wechat_account.go deleted file mode 100644 index 971dada..0000000 --- a/service/send_message_service/send_wechat_account.go +++ /dev/null @@ -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 -} diff --git a/service/send_message_service/unified/channel_registry.go b/service/send_message_service/unified/channel_registry.go new file mode 100644 index 0000000..5579c87 --- /dev/null +++ b/service/send_message_service/unified/channel_registry.go @@ -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 +} diff --git a/service/send_message_service/unified/channels/base.go b/service/send_message_service/unified/channels/base.go new file mode 100644 index 0000000..aa9f3af --- /dev/null +++ b/service/send_message_service/unified/channels/base.go @@ -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 +} diff --git a/service/send_message_service/unified/channels/custom.go b/service/send_message_service/unified/channels/custom.go new file mode 100644 index 0000000..4bcc21d --- /dev/null +++ b/service/send_message_service/unified/channels/custom.go @@ -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)]*>.*?`).ReplaceAllString(t, "") + t = regexp.MustCompile(`(?i)]*>.*?`).ReplaceAllString(t, "") + t = regexp.MustCompile(`(?i)`).ReplaceAllString(t, "\n") + t = regexp.MustCompile(`(?i)

`).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) +} diff --git a/service/send_message_service/unified/channels/dtalk.go b/service/send_message_service/unified/channels/dtalk.go new file mode 100644 index 0000000..7eedd2a --- /dev/null +++ b/service/send_message_service/unified/channels/dtalk.go @@ -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 +} diff --git a/service/send_message_service/unified/channels/email.go b/service/send_message_service/unified/channels/email.go new file mode 100644 index 0000000..4926d8c --- /dev/null +++ b/service/send_message_service/unified/channels/email.go @@ -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 +} diff --git a/service/send_message_service/unified/channels/nest.go b/service/send_message_service/unified/channels/nest.go new file mode 100644 index 0000000..17fb182 --- /dev/null +++ b/service/send_message_service/unified/channels/nest.go @@ -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 +} diff --git a/service/send_message_service/unified/channels/qyweixin.go b/service/send_message_service/unified/channels/qyweixin.go new file mode 100644 index 0000000..50377ef --- /dev/null +++ b/service/send_message_service/unified/channels/qyweixin.go @@ -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 +} diff --git a/service/send_message_service/unified/channels/types.go b/service/send_message_service/unified/channels/types.go new file mode 100644 index 0000000..909b891 --- /dev/null +++ b/service/send_message_service/unified/channels/types.go @@ -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 } diff --git a/service/send_message_service/unified/channels/wechat.go b/service/send_message_service/unified/channels/wechat.go new file mode 100644 index 0000000..6743ee9 --- /dev/null +++ b/service/send_message_service/unified/channels/wechat.go @@ -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 +} diff --git a/web/package-lock.json b/web/package-lock.json index 176995c..1cb6db1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,13 +10,13 @@ "dependencies": { "@tailwindcss/vite": "^4.1.11", "@tanstack/vue-table": "^8.21.3", - "@vueuse/core": "^13.6.0", + "@vueuse/core": "^13.9.0", "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.536.0", "pinia": "^3.0.3", - "reka-ui": "^2.4.1", + "reka-ui": "^2.6.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", @@ -1605,14 +1605,14 @@ } }, "node_modules/@vueuse/core": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz", - "integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "13.6.0", - "@vueuse/shared": "13.6.0" + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -1622,9 +1622,9 @@ } }, "node_modules/@vueuse/core/node_modules/@vueuse/shared": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz", - "integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -1634,9 +1634,9 @@ } }, "node_modules/@vueuse/metadata": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz", - "integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -2632,9 +2632,9 @@ "license": "MIT" }, "node_modules/reka-ui": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.4.1.tgz", - "integrity": "sha512-NB7DrCsODN8MH02BWtgiExygfFcuuZ5/PTn6fMgjppmFHqePvNhmSn1LEuF35nel6PFbA4v+gdj0IoGN1yZ+vw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.1.tgz", + "integrity": "sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", diff --git a/web/package.json b/web/package.json index 0f7c7e3..491ebed 100644 --- a/web/package.json +++ b/web/package.json @@ -13,13 +13,13 @@ "dependencies": { "@tailwindcss/vite": "^4.1.11", "@tanstack/vue-table": "^8.21.3", - "@vueuse/core": "^13.6.0", + "@vueuse/core": "^13.9.0", "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^0.536.0", "pinia": "^3.0.3", - "reka-ui": "^2.4.1", + "reka-ui": "^2.6.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", @@ -37,4 +37,4 @@ "vite": "^7.0.4", "vue-tsc": "^2.2.12" } -} \ No newline at end of file +} diff --git a/web/src/components/Index.vue b/web/src/components/Index.vue index 4b896bf..8e6ffbc 100644 --- a/web/src/components/Index.vue +++ b/web/src/components/Index.vue @@ -252,6 +252,7 @@ const tabRoutes: TabRoute[] = [ { name: '发信日志', path: '/sendlogs' }, { name: '托管消息', path: '/hostedmessage' }, { name: '定时消息', path: '/cronmessages' }, + { name: '模板任务', path: '/templates' }, { name: '发信任务', path: '/sendtasks' }, { name: '发信渠道', path: '/sendways' }, { name: '设置偏好', path: '/settings' } @@ -326,9 +327,9 @@ const siteTitle = computed(() => { -