# NotifyHub **Repository Path**: qmdq/NotifyHub ## Basic Information - **Project Name**: NotifyHub - **Description**: 统一通知服务(Rust + Vue3):业务系统 HMAC 签名一行接入,异步可靠投递邮件(纯文本/HTML 富文本),渠道可插拔,自带管理后台。 - **Primary Language**: Rust - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-08-13 - **Last Updated**: 2026-08-17 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # NotifyHub > 统一通知服务(Unified Notification Hub)—— 独立后端 + 管理后台,业务系统接入后统一发送各类通知。 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Rust](https://img.shields.io/badge/Rust-1.76%2B-orange.svg)](https://www.rust-lang.org/) [![Vue](https://img.shields.io/badge/Vue-3-42b883.svg)](https://vuejs.org/) [![Element Plus](https://img.shields.io/badge/Element_Plus-2-409eff.svg)](https://element-plus.org/) ## 📖 项目介绍 **NotifyHub** 是一个独立部署的**统一通知中台**,把「发通知」从业务系统里抽出来集中做。各个业务系统作为「应用」接入,拿到一对 AppID/AppSecret,用 **HMAC-SHA256 签名**调一次 `POST /notify/send` 就能发信——秒回 `messageId`,真正的投递在后台 worker 异步完成,失败自动指数退避重试、超限进死信,**关停服务也不丢在途消息**。 它解决三件事:① 业务方接入要简单(一套签名头 + 模板编码,几分钟跑通);② 投递要可靠(队列 + 重试 + 幂等防刷);③ 渠道要能扩展(`Channel` trait 抽象,新增短信/钉钉只是加一个实现)。当前内置**邮箱渠道**,支持纯文本与 **HTML 富文本**模板(内置可视化编辑器,变量值自动转义防注入)。 配套一个 **Vue 3 + Element Plus 管理后台**:登录与多用户管理、应用 / 渠道(可建多个并指定「主要」)/ 模板的 CRUD、发送记录筛选、看板统计,以及一个**接入中心**——在浏览器里填凭证即可实时签名发信,并自动生成 curl / Python / Node 调用代码,复制即跑。 技术栈:后端 Rust(Axum + sqlx + SQLite + Redis + lettre),前端 Vue 3 + Vite。**开箱即跑**:SQLite 自动建表,只需一个 Redis;`provider=log` 时连 SMTP 都不用配,邮件打到日志即可验收全链路。 ## ✨ 功能特性 - **可插拔渠道**:`Channel` trait 抽象,邮箱开箱即用,扩展新渠道只加一个实现 - **安全接入**:AppID/AppSecret(AES-256 加密存储)+ HMAC-SHA256 签名 + 时间戳/nonce 防重放 + IP 白名单 + 每分钟/每日限流 - **异步可靠投递**:Redis 队列 + worker,失败指数退避重试(2s/8s,≤3 次)+ 死信,优雅关闭不丢消息 - **模板引擎**:`${变量}` 渲染、变量声明校验、缺参报错;支持**纯文本 / HTML 富文本**(内置可视化编辑器,HTML 变量值自动转义防注入) - **管理后台**:Vue 3 + Element Plus,看板 / 应用 / 渠道(可建多个并选「主要」)/ 模板 / 记录 / 用户管理 / **接入中心**(在线签名发信 + 自动生成 curl/Python/Node 代码) - **多用户与登录**:JWT 登录、用户 CRUD、改密码、角色/状态 - **零外部 DB 依赖(开箱即跑)**:SQLite 自动建表,仅需一个 Redis ## 🧱 技术栈 | 层 | 选型 | |----|------| | 后端 | Rust + Axum + Tokio | | 数据层 | sqlx + SQLite | | 缓存/队列/限流/幂等 | Redis | | 邮件 | lettre(`smtp` 真实发信 / `log` 日志验收) | | 鉴权 | bcrypt + JWT(jsonwebtoken) + HMAC-SHA256 + AES-256-GCM | | 前端 | Vue 3 + Vite + Element Plus + Vue Router | | 可观测 | tracing 结构化日志 + /health 探活 | ## 📸 界面预览 | 概览看板 | 应用接入 | |:--:|:--:| | ![概览看板](image/dashboard.png) | ![应用接入](image/apps.png) | | **渠道配置**(可建多个邮箱并选「主要」) | **消息模板**(HTML 富文本编辑器,可视化+源码+插入变量) | | ![渠道配置](image/channels.png) | ![消息模板](image/template-editor.png) | ## 架构 ``` 业务系统 → POST /api/v1/notify/send → 鉴权中间件(X-App-Id/Timestamp/Nonce/Sign + nonce防重放 + IP白名单 + 限流) → 模板渲染(变量校验, 纯文本/HTML) → 写记录(pending) → 投入 Redis 队列 → 秒回 messageId worker(BRPOP) → 渠道发送 → success / 失败指数退避重试(≤3) → 死信 ``` - 存储:**SQLite**(`notifyhub.db`,自动建表) - 队列/缓存/限流/幂等:**Redis** - 邮件:**lettre**(`provider=smtp` 真实发信 / `provider=log` 打印日志,本地验收用) ## 目录结构 ``` emailService/ ├── src/ │ ├── main.rs # 装配 + 路由 + 优雅关闭 + 默认数据 seed │ ├── config.rs # config.yaml + 环境变量覆盖 │ ├── auth.rs # JWT 签发/校验 │ ├── crypto.rs # HMAC-SHA256 / AES-256-GCM / 随机ID │ ├── db.rs / redis_pool.rs │ ├── model.rs # 数据模型 │ ├── middleware/ # auth(业务鉴权) + admin_auth(JWT) │ ├── channel/ # Channel trait + EmailChannel + Registry │ ├── service/ # TemplateService(缓存) + SendService(幂等/入队) │ ├── handler/ # notify + auth + admin_(app/channel/template/records/user) + health │ ├── worker/ # consumer(BRPOP+重试) + reaper(延迟zset回收) │ └── template_engine.rs # ${var} 渲染 + 缺失告警 + HTML转义 ├── frontend/ # Vue3 + Element Plus 管理后台 │ └── src/{views,layout,api,router,components} ├── migrations/ # SQLite 建表 SQL ├── docs/API.md # HTTP 接口文档 ├── config.yaml · docker-compose.yml · Cargo.toml ``` ## 运行 前置:本机或 Docker 起一个 Redis。 ```bash # 1) 起 Redis(任选其一) docker compose up -d redis # 或本机已装 redis-server,直接 redis-server & # 2) 编译运行 cargo run --release # 服务监听 0.0.0.0:8080,自动建库建表 ``` 健康检查: ```bash curl http://127.0.0.1:8080/health # {"status":"ok","db":"ok","redis":"ok","queue_depth":0} ``` ## 配置 编辑 `config.yaml`(或用 `NOTIFYHUB_*` 环境变量覆盖,见 `.env.example`)。 生产前务必修改: - `jwt_secret`:JWT 签名密钥,`openssl rand -base64 32` 生成 - `aes_key`:加密 AppSecret 的 AES-256 密钥,`openssl rand -base64 32` 生成 - 默认管理员 `admin/admin123`:登录后尽快在「修改密码」改掉 ## 前端管理后台(Vue 3 + Element Plus) 前端位于 `frontend/`,对接后端管理 API(JWT 登录)。两种使用方式: **方式 A · 生产同源托管(推荐,开箱即用)** ```bash cd frontend && npm install && npm run build # 产出 frontend/dist cd .. && cargo run # 后端自动托管 dist,访问根路径即可 ``` 浏览器打开 `http://127.0.0.1:8080/` → 跳转到**登录页**,默认账号 `admin` / `admin123`(首次启动自动创建,可在 `config.yaml` 用 `bootstrap_admin` 自定义;登录后颁发 JWT,24h 有效)。 **方式 B · 开发热重载** ```bash # 终端1:后端 cargo run # 终端2:前端 dev server(5173,自动代理 /api 到 8080) cd frontend && npm install && npm run dev ``` 浏览器打开 `http://127.0.0.1:5173/`。 功能:概览看板(从发送记录聚合统计/趋势/渠道占比)、应用接入 CRUD(创建后明文 AppSecret 仅显示一次、可重置)、渠道配置(邮箱 SMTP/log 保存+热加载+测试发信)、消息模板 CRUD(变量自动提取)、发送记录筛选分页、发送测试。 > 看板统计基于近 500 条记录客户端聚合(后端 MVP 未提供统计接口),数据量大时建议后续在后端实现 `/admin/stats`。 ## 快速验收(管理员侧) > 最快的方式:浏览器打开管理后台,用 `admin/admin123` 登录后图形化操作。 > 以下是命令行示例(先登录拿 JWT,再带 `Authorization: Bearer`)。完整接口见 [`docs/API.md`](docs/API.md)。 ```bash BASE="http://127.0.0.1:8080/api/v1" # 0) 登录拿 token(默认 admin / admin123) TOK=$(curl -s -X POST "$BASE/auth/login" -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin123"}' | grep -oE '"token":"[^"]+"' | cut -d'"' -f4) AUTH="Authorization: Bearer $TOK" # 1) 创建应用(返回明文 appSecret,仅此一次;授权 email 渠道) curl -s -X POST "$BASE/admin/app" -H "$AUTH" -H "Content-Type: application/json" \ -d '{"name":"用户中心","channels":["email"],"dailyQuota":10000,"rateLimit":60}' # 记录返回的 app.appId 与 appSecret # 2) 创建邮箱渠道(provider=log 免 SMTP 即可本地验收;真实发信用 smtp) curl -s -X POST "$BASE/admin/channel" -H "$AUTH" -H "Content-Type: application/json" \ -d '{"channelType":"email","name":"默认邮箱","provider":"log","isPrimary":true, "config":{"host":"smtp.exmail.qq.com","port":465,"username":"noreply@acme.com","password":"x", "fromName":"Acme平台","fromAddr":"noreply@acme.com","encryption":"ssl","timeout":10}}' # 3) 创建模板 curl -s -X POST "$BASE/admin/template" -H "$AUTH" -H "Content-Type: application/json" \ -d '{"code":"TPL_EMAIL_CODE","name":"邮箱验证码","channelType":"email","type":"verify_code", "title":"【Acme】您的验证码","content":"您的验证码为 ${code},${expire} 分钟内有效。","variables":["code","expire"]}' ``` ## 业务侧发送(带 HMAC 签名) 请求头:`X-App-Id`、`X-Timestamp`(秒)、`X-Nonce`、`X-Sign`。 `X-Sign = HMAC-SHA256(appSecret, X-Timestamp + X-Nonce + 原始请求体)` 的 hex。 ```bash APP_ID="app_xxxxx" # 上一步拿到的 appId APP_SECRET="xxxxxxxx" # 上一步拿到的明文 appSecret TS=$(date +%s) NONCE=$(uuidgen | tr -d '-') BODY='{"channel":"email","to":"user@example.com","templateCode":"TPL_EMAIL_CODE","params":{"code":"836425","expire":"5"}}' # 用 openssl 计算 HMAC-SHA256 PAYLOAD="${TS}${NONCE}${BODY}" SIGN=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$APP_SECRET" -hex | awk '{print $NF}') curl -s -X POST http://127.0.0.1:8080/api/v1/notify/send \ -H "Content-Type: application/json" \ -H "X-App-Id: $APP_ID" \ -H "X-Timestamp: $TS" \ -H "X-Nonce: $NONCE" \ -H "X-Sign: $SIGN" \ --data "$BODY" # {"code":0,"msg":"success","data":{"messageId":"msg_xxxx"}} # 查状态 curl -s http://127.0.0.1:8080/api/v1/notify/status/msg_xxxx \ -H "X-App-Id: $APP_ID" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE2" \ -H "X-Sign: <同法重算,body 为空>" ``` `provider=log` 时邮件内容会打到日志(`[EMAIL-LOG] ...`),便于无 SMTP 环境验收。 ## 鉴权验证要点 - 改 body 任意一个字符 → `401 签名校验失败` - 5 分钟外的 Timestamp → `401` - 同 nonce 复用 → `401 nonce 已使用` - 超过 `rateLimit`(每分钟) 或 `dailyQuota`(每日) → `429` - 同 app+收件人+模板 **5 秒内**重复 → 返回上次同一 `messageId`(幂等) ## 重试与死信 发送失败:`retry_count++`,未达上限按 **2s/8s** 延迟重入队(延迟队列 `notify:delayed` zset 由 reaper 回收);累计 **3 次**仍失败置 `failed` 并进入 `notify:dead`。 ## API 一览 > 完整请求/响应与错误码见 [`docs/API.md`](docs/API.md)。 | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| | POST | `/api/v1/notify/send` | App 签名 | 发送单条 | | POST | `/api/v1/notify/send/batch` | App 签名 | 批量(≤100) | | GET | `/api/v1/notify/status/:messageId` | App 签名 | 查状态 | | POST | `/api/v1/auth/login` | 公开 | 登录拿 JWT | | GET | `/api/v1/auth/me` · POST `/auth/change-password` | JWT | 当前用户 / 改密码 | | CRUD | `/api/v1/admin/app[/:id]` | JWT | 应用接入 | | POST | `/api/v1/admin/app/:id/reset-secret` | JWT | 重置 Secret | | GET/POST/PUT/DELETE | `/api/v1/admin/channel[/:id]` | JWT | 渠道(可多个) | | POST | `/api/v1/admin/channel/:id/set-primary` | JWT | 设为主要渠道 | | POST | `/api/v1/admin/channel/:id/test` | JWT | 测试发信 | | CRUD | `/api/v1/admin/template[/:id]` | JWT | 模板(纯文本/HTML) | | GET | `/api/v1/admin/records` | JWT | 发送记录(筛选/分页) | | GET/POST/PUT/DELETE | `/api/v1/admin/user[/:id]` | JWT | 用户管理 | | GET | `/health` | - | 健康检查 | ## 后续扩展 新增渠道(如短信/钉钉):实现 `Channel` trait → 在 `channel::instantiate` 增加 `match` 分支 → 表里加配置。核心发送/重试/记录逻辑无需改动。 ## License [MIT](LICENSE) © Strange