feat(gateway): 优化gateway相关功能
Quality check / Web UI (push) Successful in 9m19s

This commit is contained in:
mei
2026-09-12 15:32:27 +08:00
parent 0d89b7fe8f
commit 9b656cebee
84 changed files with 2769 additions and 825 deletions
+101
View File
@@ -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
}
+22
View File
@@ -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
}
+15
View File
@@ -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
}
+107
View File
@@ -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
}
+54
View File
@@ -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
View File
@@ -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))
+10
View File
@@ -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()