Files
aiaa-notification-server/README.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

591 lines
13 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.
# AIAA Notification Service
纯 Go 实现的多渠道通知服务。上游系统通过 Webhook 推送消息,服务按来源解析、匹配规则、条件过滤、渲染模板后,异步分发到钉钉 / 企业微信 / 邮件 / Bark。
## 核心概念
```
source 1──N rule
template 1──N rule
rule N──M channel (via rule_channel,可独立开关)
```
| 实体 | 说明 |
|------|------|
| **Source** | 来源系统,持有独立 `api_key`,定义 body 解析方式(json / regex / text |
| **Template** | Go `text/template` 模板 |
| **Channel** | 发送渠道配置(钉钉 / 企微 / 邮件 / Bark |
| **Rule** | 绑定 `source + event → template + channels`,可选条件过滤 |
| **MessageLog** | 发送记录,便于排查 |
**处理流程:**
```
POST /api/v1/notify
→ 鉴权(source api_key+ 限流
→ 按 parse_mode 解析 body
→ 匹配 enabled 规则
→ 条件过滤(全部 AND
→ 渲染模板
→ 异步发送到启用渠道(失败重试 3 次:1s / 5s / 30s
→ 立即返回 accepted
```
## 技术栈
- Go 1.22+、Gin、sqlx、go-redis、Viper、slog
- MySQL 8.0、Redis 7
- Docker 多阶段构建
## 快速开始
### 依赖
- Go 1.22+
- MySQL 8.0
- Redis 7
- [golang-migrate](https://github.com/golang-migrate/migrate)(本地迁移时)
### Docker Compose(推荐)
```bash
docker-compose up -d
```
会启动 MySQL、Redis 与 API(默认 `http://localhost:8080`)。
### 本地开发
```bash
# 1. 启动依赖
docker-compose up -d mysql redis
# 2. 迁移数据库
make migrate-up
# 3. 修改配置(务必更换 admin_key)
# config/config.yaml
# 4. 运行
make run
# 或
make build && ./bin/server
```
### 常用命令
| 命令 | 说明 |
|------|------|
| `make run` | 本地启动 |
| `make build` | 编译到 `bin/server` |
| `make test` | 运行单元测试 |
| `make migrate-up` / `migrate-down` | 数据库迁移 |
| `make docker-up` / `docker-down` | Compose 启停 |
### 配置
主配置文件:`config/config.yaml`。支持 `${ENV_VAR}` / `${ENV_VAR:-default}` 插值,也可用 `NOTIFY_` 前缀环境变量覆盖(`.``_`,例如 `NOTIFY_DATABASE_HOST`)。
| 配置项 | 说明 | 默认 |
|--------|------|------|
| `server.port` | HTTP 端口 | `8080` |
| `server.admin_key` | 管理 API Bearer Token | 须修改 |
| `database.*` | MySQL 连接 | `notify` / `notification` |
| `redis.*` | Redis(缓存 + 限流) | `127.0.0.1:6379` |
| `smtp.*` | 邮件发送(email 渠道) | — |
| `rate_limit.default` | 每 source 每秒请求上限 | `100` |
| `rate_limit.dingtalk_per_min` | 同一钉钉机器人(access_token)每分钟发送上限;超限排队到下一分钟 | `18`(官方 20,留余量) |
健康检查:`GET /health``{"status":"ok"}`
---
## 认证
| 接口类型 | Header | 说明 |
|----------|--------|------|
| 通知接口 | `Authorization: Bearer <source.api_key>` | 创建 Source 时自动生成 |
| 管理接口 | `Authorization: Bearer <admin_key>` | 来自 `config.yaml``server.admin_key` |
未授权返回 `401``{"error":"unauthorized"}``{"error":"missing api key"}` / `{"error":"invalid api key"}`
通知接口超限返回 `429`
```json
{
"error": "rate_limit_exceeded",
"message": "...",
"retry_after": 1
}
```
---
## 接口文档
Base URL`http://localhost:8080`
通用错误体:`{"error": "<message>"}`
---
### 1. 通知接口
#### `POST /api/v1/notify`
上游业务推送入口。Body 解析方式由 Source 的 `parse_mode` 决定。
**鉴权:** Source API Key
**限流:** 按 source,默认 100 req/s(需 Redis
##### JSON 模式(`parse_mode=json`
```http
POST /api/v1/notify
Authorization: Bearer <source_api_key>
Content-Type: application/json
{
"event": "trade.open",
"data": {
"symbol": "BTC",
"price": 65000
}
}
```
- 必须包含 `event`string
- `data` 为模板变量;若省略 `data`,则除 `event` 外的顶层字段作为 data
##### Regex 模式(`parse_mode=regex`
```http
POST /api/v1/notify
Authorization: Bearer <source_api_key>
Content-Type: text/plain
trade.open BTC 开仓 价格:65000
```
- 使用 Source 的 `parse_pattern`(需含命名分组)
- 命名分组 `event` 作为事件名,其余命名分组进入模板 data
示例 pattern`(?P<event>\w+)\s+(?P<symbol>\w+)\s+(?P<message>.+)`
##### Text 模式(`parse_mode=text`
```http
POST /api/v1/notify
Authorization: Bearer <source_api_key>
Content-Type: text/plain
BTC 突破 68000,请注意风险
```
- body 原样透传,模板变量为 `.Body`
- **固定 event 为 `default`**,规则需按 `event=default` 配置
##### 成功响应
匹配并接受发送:
```json
{
"matched": true,
"channels": ["dingtalk:1", "email:2"],
"accepted": true
}
```
匹配但条件未通过:
```json
{
"matched": true,
"filtered": true,
"reason": "condition not met"
}
```
无匹配规则:
```json
{
"matched": false
}
```
##### 错误码
| HTTP | 场景 |
|------|------|
| 400 | 读取 body 失败 |
| 401 | 鉴权失败 |
| 422 | 解析失败 / 模板渲染失败 / 规则 conditions 非法 |
| 429 | 限流 |
| 500 | 模板缺失等内部错误 |
发送为异步:接口返回 `accepted` 不代表渠道侧已投递成功,请查 Message Log。
---
### 2. Source(来源)
前缀:`/api/v1/sources`
**鉴权:** Admin Key
#### `POST /api/v1/sources` — 创建
```json
{
"name": "trading-system",
"parse_mode": "json",
"parse_pattern": "",
"status": 1
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | 是 | 唯一名称 |
| `parse_mode` | string | 否 | `json`(默认)/ `regex` / `text` |
| `parse_pattern` | string | regex 时建议填 | 正则(命名分组) |
| `status` | int | 否 | `1` 启用(默认)/ `0` 禁用 |
**201** 返回完整 Source(含自动生成的 `api_key`)。名称冲突 → **409**
Regex 示例:
```json
{
"name": "legacy-monitor",
"parse_mode": "regex",
"parse_pattern": "(?P<event>\\w+)\\s+(?P<message>.+)"
}
```
#### `GET /api/v1/sources` — 列表
Query`page`(默认 1)、`page_size`(默认 20
**200**
```json
{ "data": [ /* Source[] */ ], "total": 10, "page": 1 }
```
#### `GET /api/v1/sources/:id` — 详情
**200** → Source;不存在 → **404**
#### `PUT /api/v1/sources/:id` — 更新
Body 同创建。成功:`{"ok": true}`
#### `DELETE /api/v1/sources/:id` — 删除
成功:`{"ok": true}`
---
### 3. Template(模板)
前缀:`/api/v1/templates`
**鉴权:** Admin Key
模板语法为 Go `text/template`,变量来自解析后的 data。
示例:
```
### {{.symbol}} 开仓
价格: {{.price}}
```
Text 模式示例:`{{.Body}}`
#### `POST /api/v1/templates` — 创建
```json
{
"name": "trade_open",
"content": "### {{.symbol}} 开仓\n价格: {{.price}}"
}
```
| 字段 | 类型 | 必填 |
|------|------|------|
| `name` | string | 是 |
| `content` | string | 是 |
**201** → Template;冲突 → **409**
#### `GET /api/v1/templates` — 列表
Query`page``page_size`(默认同 sources)。**200** `{ "data": Template[], "total", "page" }`
#### `GET /api/v1/templates/:id`
#### `PUT /api/v1/templates/:id` / `DELETE /api/v1/templates/:id`
更新/删除成功返回 `{"ok": true}`
---
### 4. Channel(渠道)
前缀:`/api/v1/channels`
**鉴权:** Admin Key
#### `POST /api/v1/channels` — 创建
```json
{
"name": "dingtalk-prod",
"type": "dingtalk",
"config": { ... },
"status": 1
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | 是 | 唯一名称(规则里用此名引用) |
| `type` | string | 是 | `dingtalk` / `wecom` / `email` / `bark` |
| `config` | object | 是 | 见下方各渠道配置 |
| `status` | int | 否 | 默认 `1` |
**201** → Channel;冲突 → **409**
##### 渠道 config
**钉钉 `dingtalk`**
```json
{
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
"secret": "SEC..."
}
```
`secret` 可选(加签机器人时填写)。消息类型:markdown。
**企业微信 `wecom`**
```json
{
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
}
```
消息类型:markdown。
**邮件 `email`**
```json
{
"to": ["ops@example.com", "dev@example.com"]
}
```
SMTP 使用全局 `config.yaml``smtp` 段;支持 587 STARTTLS / 465 TLS。
**Bark `bark`**
```json
{
"url": "https://api.day.app/<device_key>"
}
```
#### `GET /api/v1/channels` — 列表
Query`page``page_size`。**200** `{ "data": Channel[], "total", "page" }`
#### `GET /api/v1/channels/:id`
#### `PUT /api/v1/channels/:id` / `DELETE /api/v1/channels/:id`
---
### 5. Rule(规则)
前缀:`/api/v1/rules`
**鉴权:** Admin Key
同一 Source 下 `event` 唯一。
#### `POST /api/v1/rules` — 创建
```json
{
"source_name": "trading-system",
"event": "trade.open",
"template_name": "trade_open",
"channels": ["dingtalk-prod", "email-ops"],
"conditions": [
{"field": "symbol", "op": "exists"},
{"field": "price", "op": "gt", "value": "0"}
],
"enabled": 1
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `source_name` | string | 是 | Source 名称 |
| `event` | string | 是 | 事件名;text 模式请用 `default` |
| `template_name` | string | 是 | Template 名称 |
| `channels` | string[] | 否 | Channel 名称列表 |
| `conditions` | object[] | 否 | 过滤条件,全部 AND;省略则不过滤 |
| `enabled` | int | 否 | 默认 `1` |
**条件操作符:**
| op | 说明 |
|----|------|
| `eq` / `ne` | 等于 / 不等于(字符串比较) |
| `gt` / `gte` / `lt` / `lte` | 数值比较 |
| `exists` / `not_exists` | 字段是否存在 |
| `contains` | 字符串包含 |
条件结构:`{"field":"<字段>","op":"<操作符>","value":"<可选>"}`
**201** → Rule;冲突 → **409**
#### `GET /api/v1/rules` — 列表
Query`page``page_size`。**200** `{ "data": Rule[], "total", "page" }`
#### `GET /api/v1/rules/:id`
#### `PUT /api/v1/rules/:id` — 更新
Body 同创建。成功:`{"ok": true}`
#### `DELETE /api/v1/rules/:id`
成功:`{"ok": true}`(关联 `rule_channel` 级联删除)
#### 开关
| 方法 | 路径 | 说明 |
|------|------|------|
| `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` | 禁用该规则下某渠道 |
均返回 `{"ok": true}``:channel_id` 为渠道数字 ID。
---
### 6. Message Log(消息记录)
#### `GET /api/v1/message-logs`
**鉴权:** Admin Key
Query 参数:
| 参数 | 说明 |
|------|------|
| `source` | 按来源名过滤 |
| `event` | 按事件过滤 |
| `status` | `pending` / `success` / `failed` / `retrying` |
| `page` | 页码(从 1 |
| `page_size` | 每页条数 |
**200**
```json
{
"data": [ /* MessageLog[] */ ],
"total": 100,
"page": 1
}
```
MessageLog 主要字段:`rule_id``channel_id``source``event``payload``content``status``retry_count``response``error_msg``created_at`
---
## 端到端示例
```bash
ADMIN="Authorization: Bearer admin-sk-change-me"
# 1. 创建来源
curl -s -X POST http://localhost:8080/api/v1/sources \
-H "$ADMIN" -H "Content-Type: application/json" \
-d '{"name":"trading-system","parse_mode":"json"}'
# 记下返回的 api_key
# 2. 创建模板
curl -s -X POST http://localhost:8080/api/v1/templates \
-H "$ADMIN" -H "Content-Type: application/json" \
-d '{"name":"trade_open","content":"### {{.symbol}} 开仓\n价格: {{.price}}"}'
# 3. 创建渠道
curl -s -X POST http://localhost:8080/api/v1/channels \
-H "$ADMIN" -H "Content-Type: application/json" \
-d '{
"name":"dingtalk-prod",
"type":"dingtalk",
"config":{"webhook_url":"https://oapi.dingtalk.com/robot/send?access_token=xxx","secret":"SEC..."}
}'
# 4. 创建规则
curl -s -X POST http://localhost:8080/api/v1/rules \
-H "$ADMIN" -H "Content-Type: application/json" \
-d '{
"source_name":"trading-system",
"event":"trade.open",
"template_name":"trade_open",
"channels":["dingtalk-prod"],
"conditions":[{"field":"symbol","op":"exists"}]
}'
# 5. 发送通知
curl -s -X POST http://localhost:8080/api/v1/notify \
-H "Authorization: Bearer <source_api_key>" \
-H "Content-Type: application/json" \
-d '{"event":"trade.open","data":{"symbol":"BTC","price":65000}}'
```
---
## 项目结构
```
cmd/server/ 入口
config/ 配置文件
migrations/ MySQL 迁移
internal/
handler/ HTTP(鉴权、限流、CRUD、notify
engine/ 匹配 / 渲染 / 路由
adapter/ 渠道适配器(ChannelSender
parser/ json / regex / text
condition/ 条件求值
store/ MySQL
cache/ Redis
model/ 数据模型
retry/ 重试
config/ 配置加载
```
## 扩展渠道
实现 `adapter.ChannelSender` 接口并在工厂中注册即可:
```go
type ChannelSender interface {
Type() string
Send(title, content string, config json.RawMessage) error
}
```
## License
内部项目,按团队约定使用。