Files
aiaa-notification-server/docs/superpowers/specs/2025-06-27-notification-service-design.md
T
ryan 39f3774940 feat: 配置列表分页与钉钉机器人分钟级排队限流
统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 00:34:26 +08:00

453 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Notification Service — 设计规格
## 概述
纯 Go 实现的通知服务。上游业务系统通过 webhook 推送消息,服务匹配规则、渲染模板、路由到多渠道发送。
**核心三层关系:**
```
source 1──N rule
template 1──N rule
rule N──M channel (via rule_channel)
```
## 技术栈
| 层 | 选型 |
|----|------|
| 语言 | Go 1.22+ |
| HTTP 框架 | gin-gonic/gin |
| 数据库驱动 | go-sql-driver/mysql |
| SQL 工具 | jmoiron/sqlx |
| 模板引擎 | text/template (标准库) |
| 缓存 | go-redis/redis |
| 配置 | viper |
| 日志 | slog (标准库) |
| 迁移 | golang-migrate |
**外部依赖:** MySQL 8.0、Redis 7
**部署形态:** Docker 容器化 (docker-compose),最终镜像 ~12MB
---
## 架构
```
notification-service (Go)
├── cmd/server/main.go ← 入口
├── internal/
│ ├── handler/ ← HTTP Handler (gin)
│ │ ├── notify.go ← POST /api/v1/notify (上游调用)
│ │ ├── source.go ← CRUD /api/v1/sources
│ │ ├── template.go ← CRUD /api/v1/templates
│ │ ├── channel.go ← CRUD /api/v1/channels
│ │ └── rule.go ← CRUD /api/v1/rules + 开关控制
│ │
│ ├── engine/ ← 核心引擎
│ │ ├── matcher.go ← source + event → rule 匹配
│ │ ├── renderer.go ← Go template 渲染
│ │ └── router.go ← rule → channels 路由
│ │
│ ├── adapter/ ← 渠道适配器
│ │ ├── adapter.go ← ChannelSender 接口
│ │ ├── dingtalk.go ← 钉钉 webhook
│ │ ├── wecom.go ← 企业微信机器人
│ │ ├── email.go ← SMTP 邮件
│ │ └── bark.go ← Bark HTTP
│ │
│ ├── model/ ← 数据模型
│ ├── store/ ← MySQL 访问层
│ └── cache/ ← Redis 缓存层
├── config/config.yaml
├── migrations/
├── Dockerfile
├── docker-compose.yml
└── go.mod
```
---
## 数据模型
### source(来源系统)
| 列 | 类型 | 说明 |
|----|------|------|
| id | INT PK | |
| name | VARCHAR(64) UNIQUE | trading-system |
| api_key | VARCHAR(128) UNIQUE | 通知 API 鉴权 token,创建 source 时自动生成 |
| parse_mode | VARCHAR(16) DEFAULT 'json' | json / regex / texttext=直接透传 body 到模板) |
| parse_pattern | VARCHAR(512) | regex 模式(含命名分组),parse_mode=regex 时必填 |
| status | TINYINT | 1=启用 0=禁用 |
### template(模板)
| 列 | 类型 | 说明 |
|----|------|------|
| id | INT PK | |
| name | VARCHAR(64) UNIQUE | trade_open |
| content | TEXT | Go template 语法,markdown/纯文本 |
### channel(渠道配置)
| 列 | 类型 | 说明 |
|----|------|------|
| id | INT PK | |
| name | VARCHAR(32) UNIQUE | dingtalk-prod |
| type | VARCHAR(32) | dingtalk / wecom / email / bark |
| config | JSON | 渠道特定配置 (webhook URL 等) |
| status | TINYINT | 1=启用 0=禁用 |
### rule(规则 — 核心绑定)
| 列 | 类型 | 说明 |
|----|------|------|
| id | INT PK | |
| source_id | INT FK→source | |
| event | VARCHAR(64) | trade.open / error / order.created |
| template_id | INT FK→template | |
| conditions | JSON | 过滤条件数组,null=不过滤。例: `[{"field":"symbol","op":"exists"}]` |
| enabled | TINYINT | 1=启用 0=禁用 |
| UNIQUE | (source_id, event) | 每个 source 下 event 唯一 |
**条件操作符:** `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `exists` / `not_exists` / `contains`
多个条件 AND 关系,全部满足才转发。
### rule_channel(规则-渠道关联 — 独立开关)
| 列 | 类型 | 说明 |
|----|------|------|
| id | INT PK | |
| rule_id | INT FK→rule | ON DELETE CASCADE |
| channel_id | INT FK→channel | |
| enabled | TINYINT | 每条规则下每个渠道可独立开关 |
| UNIQUE | (rule_id, channel_id) | |
### message_log(消息记录 — 排查用)
| 列 | 类型 | 说明 |
|----|------|------|
| id | BIGINT PK | |
| rule_id | INT | |
| channel_id | INT | |
| source | VARCHAR(64) | 冗余,方便查询 |
| event | VARCHAR(64) | 冗余,方便查询 |
| payload | JSON | 原始请求 data |
| content | TEXT | 渲染后内容 |
| status | ENUM | pending/success/failed/retrying |
| retry_count | INT | |
| response | TEXT | 渠道返回 |
| error_msg | TEXT | |
| created_at | DATETIME | |
---
## API 设计
### 认证
- **通知接口**`Authorization: Bearer <source.api_key>` — 通过 api_key 识别来源系统
- 管理接口:`Authorization: Bearer <admin_key>` — 全局管理 Key
- **Content-Type 不限** — source.parse_mode 决定如何解析 body
- `json`: body 按 JSON 解析,`event` 取自 JSON 字段
- `regex`: body 按正则解析,命名分组提取字段,`event` 需在 body 中显式提供
- `text`: body 原样透传到模板(模板引用 `.Body`
### 通知接口
**JSON 模式(parse_mode=json):**
```
POST /api/v1/notify
Authorization: Bearer <source_api_key>
Content-Type: application/json
{
"event": "trade.open",
"data": {
"symbol": "BTC",
"price": 65000
}
}
```
**Regex 模式(parse_mode=regexparse_pattern 定义提取规则):**
```
POST /api/v1/notify
Authorization: Bearer <source_api_key>
Content-Type: text/plain
BTC 开仓 价格:65000 方向:多
```
**Text 模式(parse_mode=textbody 直接给模板):**
```
POST /api/v1/notify
Authorization: Bearer <source_api_key>
Content-Type: text/plain
🚀 BTC 突破 68000,请注意风险
```
**响应:**
```
Response 200 (匹配且通过条件):
{
"matched": true,
"channels": ["dingtalk", "email"],
"accepted": true
}
Response 200 (匹配但条件不满足):
{ "matched": true, "filtered": true, "reason": "condition not met" }
Response 200 (无匹配规则):
{ "matched": false }
Response 401:
{ "error": "unauthorized" }
Response 429:
{ "error": "rate_limit_exceeded", "message": "...", "retry_after": 1 }
```
### 管理 API
```
来源:
POST /api/v1/sources
GET /api/v1/sources?page=1&page_size=20
GET /api/v1/sources/:id
PUT /api/v1/sources/:id
DELETE /api/v1/sources/:id
模板:
POST /api/v1/templates
GET /api/v1/templates?page=1&page_size=20
GET /api/v1/templates/:id
PUT /api/v1/templates/:id
DELETE /api/v1/templates/:id
渠道:
POST /api/v1/channels
GET /api/v1/channels?page=1&page_size=20
GET /api/v1/channels/:id
PUT /api/v1/channels/:id
DELETE /api/v1/channels/:id
规则:
POST /api/v1/rules
GET /api/v1/rules?page=1&page_size=20
GET /api/v1/rules/:id
PUT /api/v1/rules/:id
DELETE /api/v1/rules/:id
PATCH /api/v1/rules/:id/enable
PATCH /api/v1/rules/:id/disable
PATCH /api/v1/rules/:id/channels/:channel_id/enable
PATCH /api/v1/rules/:id/channels/:channel_id/disable
消息记录:
GET /api/v1/message-logs?source=&event=&status=&page=1&page_size=20
```
### 关键请求示例
**创建渠道:**
```json
POST /api/v1/channels
{
"name": "dingtalk-prod",
"type": "dingtalk",
"config": {
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
"secret": "SEC..."
}
}
```
**创建规则(带条件过滤):**
```json
POST /api/v1/rules
{
"source_name": "trading-system",
"event": "trade.open",
"template_name": "trade_open",
"channels": ["dingtalk", "email"],
"conditions": [
{"field": "symbol", "op": "exists"},
{"field": "price", "op": "gt", "value": "0"}
]
}
```
**创建 sourceRegex 解析模式):**
```json
POST /api/v1/sources
{
"name": "legacy-monitor",
"parse_mode": "regex",
"parse_pattern": "(?P<event>\\w+)\\s+(?P<message>.+)"
}
```
---
## 核心执行流程
```
POST /api/v1/notify
1. 鉴权 ── api_key 反查 source → source_id + parse_mode + parse_pattern
key 无效 → 401
2. 消息解析 ── 根据 source.parse_mode:
json: body → JSON decode → event + data
regex: body → regex 匹配 → 命名分组 → data map
text: body → .Body 字段
3. 规则匹配 ── 缓存: notify:rule:{source_id}:{event}
miss → SELECT * FROM rule WHERE source_id=? AND event=? AND enabled=1
无匹配 → 200 {matched: false}
4. 条件过滤 ── 检查 rule.conditions (如果非空)
对每条 condition 求值 (eq/ne/gt/lt/exists/contains...)
任一不满足 → 200 {matched: true, filtered: true}
5. 模板渲染 ── Go text/template 渲染 template.content + 解析后的 data
失败 → 422 {error: "template render failed"}
6. 查找渠道 ── 缓存: notify:channels:{rule_id}
miss → SELECT * FROM rule_channel WHERE rule_id=? AND enabled=1
无可用渠道 → 200 {matched: true, sent: 0}
7. 并发发送 ── 每个渠道 goroutine 异步发送
记录 message_log
失败 → 重试队列 (3次, 指数退避 1s/5s/30s)
8. 立即返回 200 {matched: true, channels: [...], accepted: true}
```
---
## 渠道适配器
```go
type ChannelSender interface {
Type() string
Send(title, content string, config json.RawMessage) error
}
```
| 渠道 | 协议 | 备注 |
|------|------|------|
| 钉钉 | POST webhook | 签名计算 + markdown 消息体 |
| 企业微信 | POST webhook | markdown/json 消息体 |
| 邮件 | SMTP | net/smtp 标准库,支持 TLS |
| Bark | POST HTTP | title + body POST 到设备 URL |
新增渠道:实现 ChannelSender 接口 + 工厂注册一行。
---
## 缓存策略
| Key | 内容 | TTL | 失效 |
|-----|------|-----|------|
| `notify:rule:{source_id}:{event}` | rule_id + template_id + template_content | 5min | 规则/模板 CUD 时主动删除 |
| `notify:channels:{rule_id}` | [channel_id, type, config] | 5min | 规则-渠道变更时主动删除 |
模式:Cache-Aside,管理 API 做 CUD 时主动失效,不依赖 TTL 被动过期。
---
## 限流 & 重试
**限流:**
- Per-source 限流,默认 100 req/s
- Redis 滑动窗口: `ratelimit:{source_id}:{window}` 1秒窗口
- 超限返回 429 + Retry-After
**重试:**
- 异步发送失败 → 内存重试队列
- 最多 3 次,退避 1s → 5s → 30s
- 3 次全失败 → message_log.status = 'failed'
- 后续可扩展为 Redis 死信队列 + 手动重发 API
---
## 配置
```yaml
server:
port: 8080
admin_key: "admin-sk-xxx"
database:
host: mysql
port: 3306
user: notify
password: ${DB_PASSWORD}
database: notification
redis:
host: redis
port: 6379
smtp:
host: smtp.example.com
port: 587
user: notify@example.com
password: ${SMTP_PASSWORD}
rate_limit:
default: 100
```
---
## 部署
**docker-compose.yml:**
```yaml
services:
mysql:
image: mysql:8.0
redis:
image: redis:7-alpine
api:
build: .
ports: ["8080:8080"]
depends_on: [mysql, redis]
```
**Dockerfile:** 多阶段构建 (golang:1.22-alpine → alpine:3.20),最终镜像 ~12MB。
---
## v1 范围
- [x] Source/Template/Channel/Rule CRUD 管理 API
- [x] POST /api/v1/notify 核心通知接口
- [x] 消息解析:JSON / Regex / Text 三种模式
- [x] 规则条件过滤(eq/ne/gt/lt/exists/not_exists/contains
- [x] API Key 鉴权(source 用 source key,管理用 admin key
- [x] 4 个渠道:钉钉、企业微信、邮件、Bark
- [x] Go template 渲染
- [x] 规则级 + 规则-渠道级开关控制
- [x] 异步发送 + 3 次指数退避重试
- [x] Redis 缓存规则和渠道配置
- [x] Per-source 限流
- [x] message_log 消息记录
- [x] Docker 部署