This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Package gateway 定义消息网关的扩展接口与注册表。
|
||||
//
|
||||
// 与 drivers 的分工:driver 负责把一条规则执行出去(发邮件、发消息……),
|
||||
// gateway 负责把外部系统投递进来的消息派发给绑定在网关上的规则。
|
||||
// 新增一种网关只要实现 Gateway 并在 reg.go 里注册。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// Message 是外部系统投递进来的一条消息
|
||||
type Message struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Result 是一次投递的汇总结果
|
||||
type Result struct {
|
||||
Total int `json:"total"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
|
||||
// Task 是一次投递所需的全部输入:网关与待触发的规则都由上层从数据库取出
|
||||
type Task struct {
|
||||
Gateway *model.MessageGateway
|
||||
Rules []model.GatewayRule
|
||||
Message Message
|
||||
}
|
||||
|
||||
// Gateway 一种消息网关
|
||||
type Gateway interface {
|
||||
// GetType 获取网关类型标识
|
||||
GetType() string
|
||||
// Meta 返回给 WebUI 展示用的元数据
|
||||
Meta() meta.GatewayMeta
|
||||
// Deliver 把投递进来的消息派发给 task 里的规则
|
||||
Deliver(ctx context.Context, task *Task) (*Result, error)
|
||||
}
|
||||
|
||||
// Registry 网关注册表
|
||||
type Registry struct {
|
||||
gateways map[string]Gateway
|
||||
}
|
||||
|
||||
// NewRegistry 创建新的网关注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
gateways: make(map[string]Gateway),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册消息网关
|
||||
func (r *Registry) Register(g Gateway) {
|
||||
r.gateways[g.GetType()] = g
|
||||
}
|
||||
|
||||
// Resolve 按类型取网关实现,类型为空时回退到默认类型
|
||||
func (r *Registry) Resolve(gatewayType string) (Gateway, bool) {
|
||||
if gatewayType == "" {
|
||||
gatewayType = model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
g, exists := r.gateways[gatewayType]
|
||||
return g, exists
|
||||
}
|
||||
|
||||
// Deliver 按网关类型派发一次投递
|
||||
func (r *Registry) Deliver(ctx context.Context, gatewayType string, task *Task) (*Result, error) {
|
||||
g, exists := r.Resolve(gatewayType)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("不支持的消息网关类型: %s", gatewayType)
|
||||
}
|
||||
|
||||
return g.Deliver(ctx, task)
|
||||
}
|
||||
|
||||
// GetSupportedTypes 获取所有支持的网关类型
|
||||
func (r *Registry) GetSupportedTypes() []string {
|
||||
types := make([]string, 0, len(r.gateways))
|
||||
for t := range r.gateways {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// Metas 返回所有网关类型的元数据,按类型名排序
|
||||
func (r *Registry) Metas() []meta.GatewayMeta {
|
||||
metas := make([]meta.GatewayMeta, 0, len(r.gateways))
|
||||
for _, g := range r.gateways {
|
||||
metas = append(metas, g.Meta())
|
||||
}
|
||||
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
|
||||
return metas
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package gateway
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
registry *Registry
|
||||
)
|
||||
|
||||
// InitGatewayRegistry 返回全局唯一的消息网关注册表
|
||||
func InitGatewayRegistry() *Registry {
|
||||
once.Do(func() {
|
||||
r := NewRegistry()
|
||||
|
||||
// 注册所有消息网关
|
||||
r.Register(&WebhookGateway{})
|
||||
// 未来添加新网关类型时,在这里注册即可
|
||||
|
||||
registry = r
|
||||
})
|
||||
return registry
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// NewToken 生成一个消息网关的访问 Token
|
||||
func NewToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gw_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/runner"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// messageFields 规则配置里可以被投递消息覆盖的字段名
|
||||
var messageFields = []string{"msg", "message", "body"}
|
||||
|
||||
// titleField 规则配置里可以被投递标题覆盖的字段名
|
||||
const titleField = "title"
|
||||
|
||||
// TestMessage 是「测试」网关规则时使用的示例投递内容,
|
||||
// 让用户在页面上就能看到消息被替换后的效果。
|
||||
var TestMessage = Message{
|
||||
Title: "goodBaby 测试标题",
|
||||
Content: "这是一条来自消息网关的测试消息",
|
||||
}
|
||||
|
||||
// WebhookGateway 默认网关:外部系统 POST 一条消息来触发规则
|
||||
type WebhookGateway struct{}
|
||||
|
||||
func (g *WebhookGateway) GetType() string {
|
||||
return model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
func (g *WebhookGateway) Meta() meta.GatewayMeta {
|
||||
return meta.GatewayMeta{
|
||||
Type: model.GatewayTypeWebhook,
|
||||
Label: "Webhook",
|
||||
Description: "为外部系统生成一个 Webhook 地址,POST 一条消息即可触发绑定在该网关上的规则。",
|
||||
Docs: "docs/gateway-config.md",
|
||||
PayloadHint: `{"title": "监控告警", "message": "服务已恢复"}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver 逐条执行绑定在网关上的规则
|
||||
//
|
||||
// 单条规则失败不影响其它规则,失败原因汇总在 Result 里返回。
|
||||
func (g *WebhookGateway) Deliver(ctx context.Context, task *Task) (*Result, error) {
|
||||
// 初始化为空切片:nil 切片会被序列化成 JSON null,前端读 failed.length 时会报错
|
||||
failed := []string{}
|
||||
|
||||
for i := range task.Rules {
|
||||
rule := task.Rules[i]
|
||||
|
||||
executed := rule
|
||||
configJSON, applied, err := ApplyMessage(rule.ConfigJson, task.Message)
|
||||
if err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if applied {
|
||||
// 用投递进来的消息执行,但不改动数据库里的规则配置
|
||||
executed.ConfigJson = configJSON
|
||||
}
|
||||
if err := runner.ExecuteGatewayRuleWithContext(ctx, &executed, model.TriggerGateway); err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{Total: len(task.Rules), Failed: failed}, nil
|
||||
}
|
||||
|
||||
// ApplyMessage 把投递进来的消息覆盖到规则配置的消息字段上。
|
||||
//
|
||||
// 返回覆盖后的配置;applied 为 false 表示配置里没有可覆盖的消息字段
|
||||
// (例如「公开 GitHub 仓库」「删除 DNS 记录」),此时按规则里保存的配置执行。
|
||||
func ApplyMessage(configJSON string, msg Message) (string, bool, error) {
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return configJSON, false, fmt.Errorf("解析规则配置失败: %v", err)
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, key := range messageFields {
|
||||
if _, ok := config[key]; ok {
|
||||
if msg.Content != "" {
|
||||
config[key] = msg.Content
|
||||
}
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Title != "" {
|
||||
if _, ok := config[titleField]; ok {
|
||||
config[titleField] = msg.Title
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
return configJSON, false, nil
|
||||
}
|
||||
|
||||
out, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return configJSON, false, fmt.Errorf("生成规则配置失败: %v", err)
|
||||
}
|
||||
return string(out), true, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// GatewayMessagePlaceholder 是「由投递内容提供」的字段占位值。
|
||||
//
|
||||
// 这些字段在「网关规则」页面不由用户填写,真实内容来自 webhook 请求;
|
||||
// 但规则配置里必须保留该字段,投递时才知道要覆盖哪一个。
|
||||
const GatewayMessagePlaceholder = "由消息网关投递内容替换"
|
||||
|
||||
// GatewayMessageFields 返回所有由投递内容提供的字段
|
||||
func GatewayMessageFields(fields []Field) []Field {
|
||||
matched := make([]Field, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f.GatewayMessage {
|
||||
matched = append(matched, f)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// FillGatewayMessages 给配置里「由投递内容提供」的字段补上占位值。
|
||||
//
|
||||
// 网关规则提交的配置里通常没有 title / msg 这类字段(前端不让填),
|
||||
// 校验前先补齐,规则才会既通过校验、又能在投递时被覆盖。
|
||||
func FillGatewayMessages(configJSON string, fields []Field) string {
|
||||
messageFields := GatewayMessageFields(fields)
|
||||
if len(messageFields) == 0 || configJSON == "" {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, f := range messageFields {
|
||||
if value, ok := raw[f.Key].(string); ok && value != "" {
|
||||
continue
|
||||
}
|
||||
raw[f.Key] = GatewayMessagePlaceholder
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
filled, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return string(filled)
|
||||
}
|
||||
+15
-2
@@ -26,8 +26,11 @@ type Field struct {
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Help string `json:"help,omitempty"`
|
||||
// Secret 为 true 的字段在读取接口中会被掩码
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
// GatewayMessage 为 true 的字段由消息网关投递的内容提供(msg / title 之类):
|
||||
// 在「网关规则」页面不需要用户填写,投递时会被请求里的 message / title 替换
|
||||
GatewayMessage bool `json:"gateway_message,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// AccountMeta 账号类型的元数据
|
||||
@@ -51,6 +54,16 @@ type RuleMeta struct {
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
// GatewayMeta 消息网关类型的元数据
|
||||
type GatewayMeta struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Docs string `json:"docs,omitempty"`
|
||||
// PayloadHint WebUI 上展示的请求体示例
|
||||
PayloadHint string `json:"payload_hint,omitempty"`
|
||||
}
|
||||
|
||||
// SecretKeys 返回需要掩码的字段名
|
||||
func SecretKeys(fields []Field) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
|
||||
@@ -31,6 +31,16 @@ func Do(ctx context.Context, op func() error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Permanent 标记一个重试没有意义的错误(例如凭据无效、配置错误)
|
||||
//
|
||||
// driver 里凡是重试也救不回来的失败都包一层,避免白白退避几个小时。
|
||||
func Permanent(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
// ExecutionTimeout 返回配置中的规则执行超时时间
|
||||
func ExecutionTimeout() time.Duration {
|
||||
config, err := configs.GetConfig()
|
||||
|
||||
Reference in New Issue
Block a user