feat: project scaffold, config loading, skeleton main
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go mod *)",
|
||||||
|
"Bash(go build *)",
|
||||||
|
"Bash(go run *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.superpowers/
|
||||||
|
bin/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.PHONY: build run test docker-build docker-up docker-down migrate-up migrate-down
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/server ./cmd/server
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run ./cmd/server
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./internal/... -v -count=1
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t notification-service .
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
migrate-up:
|
||||||
|
migrate -path migrations -database "mysql://notify:notify@tcp(127.0.0.1:3306)/notification" up
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
migrate -path migrations -database "mysql://notify:notify@tcp(127.0.0.1:3306)/notification" down
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"aiaa-notification-service/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
cfg, err := config.Load("config/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("config loaded", "port", cfg.Server.Port)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
admin_key: "admin-sk-change-me"
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 3306
|
||||||
|
user: "notify"
|
||||||
|
password: "${DB_PASSWORD:-notify}"
|
||||||
|
database: "notification"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 6379
|
||||||
|
password: ""
|
||||||
|
db: 0
|
||||||
|
|
||||||
|
smtp:
|
||||||
|
host: "smtp.example.com"
|
||||||
|
port: 587
|
||||||
|
user: "notify@example.com"
|
||||||
|
password: "${SMTP_PASSWORD:-}"
|
||||||
|
from: "Notification Service <notify@example.com>"
|
||||||
|
|
||||||
|
rate_limit:
|
||||||
|
default: 100
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
|||||||
|
# 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 / text(text=直接透传 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=regex,parse_pattern 定义提取规则):**
|
||||||
|
```
|
||||||
|
POST /api/v1/notify
|
||||||
|
Authorization: Bearer <source_api_key>
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
BTC 开仓 价格:65000 方向:多
|
||||||
|
```
|
||||||
|
|
||||||
|
**Text 模式(parse_mode=text,body 直接给模板):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
GET /api/v1/sources/:id
|
||||||
|
PUT /api/v1/sources/:id
|
||||||
|
DELETE /api/v1/sources/:id
|
||||||
|
|
||||||
|
模板:
|
||||||
|
POST /api/v1/templates
|
||||||
|
GET /api/v1/templates
|
||||||
|
GET /api/v1/templates/:id
|
||||||
|
PUT /api/v1/templates/:id
|
||||||
|
DELETE /api/v1/templates/:id
|
||||||
|
|
||||||
|
渠道:
|
||||||
|
POST /api/v1/channels
|
||||||
|
GET /api/v1/channels
|
||||||
|
GET /api/v1/channels/:id
|
||||||
|
PUT /api/v1/channels/:id
|
||||||
|
DELETE /api/v1/channels/:id
|
||||||
|
|
||||||
|
规则:
|
||||||
|
POST /api/v1/rules
|
||||||
|
GET /api/v1/rules
|
||||||
|
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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**创建 source(Regex 解析模式):**
|
||||||
|
```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 部署
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module aiaa-notification-service
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require github.com/spf13/viper v1.21.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `mapstructure:"server"`
|
||||||
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
|
SMTP SMTPConfig `mapstructure:"smtp"`
|
||||||
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
AdminKey string `mapstructure:"admin_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
User string `mapstructure:"user"`
|
||||||
|
Password string `mapstructure:"password"`
|
||||||
|
Database string `mapstructure:"database"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DatabaseConfig) DSN() string {
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||||
|
d.User, d.Password, d.Host, d.Port, d.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
Password string `mapstructure:"password"`
|
||||||
|
DB int `mapstructure:"db"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RedisConfig) Addr() string {
|
||||||
|
return fmt.Sprintf("%s:%d", r.Host, r.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
User string `mapstructure:"user"`
|
||||||
|
Password string `mapstructure:"password"`
|
||||||
|
From string `mapstructure:"from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
Default int `mapstructure:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
v := viper.New()
|
||||||
|
v.SetConfigFile(path)
|
||||||
|
v.SetEnvPrefix("NOTIFY")
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand ${ENV_VAR} placeholders in config values
|
||||||
|
for _, key := range v.AllKeys() {
|
||||||
|
val := v.GetString(key)
|
||||||
|
if strings.Contains(val, "${") {
|
||||||
|
expanded := expandEnv(val)
|
||||||
|
v.Set(key, expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandEnv(s string) string {
|
||||||
|
return os.Expand(s, func(key string) string {
|
||||||
|
// support ${VAR:-default}
|
||||||
|
if i := strings.Index(key, ":-"); i >= 0 {
|
||||||
|
name := key[:i]
|
||||||
|
def := key[i+2:]
|
||||||
|
if v, ok := os.LookupEnv(name); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return os.Getenv(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user