3 Commits
Author SHA1 Message Date
mei 0d89b7fe8f feat(gateway): 新增消息网关及apikey功能
Quality check / Web UI (push) Successful in 9m16s
2026-08-30 21:09:26 +08:00
mei 360169ee64 fix: 修复删除资源时未验证所有者的问题
Quality check / Web UI (push) Successful in 9m11s
2026-08-30 17:47:53 +08:00
mei 59cf99ae22 feat(drivers): add alidns delete record
Quality check / Web UI (push) Successful in 9m14s
2026-08-30 16:03:32 +08:00
37 changed files with 1843 additions and 68 deletions
+14
View File
@@ -101,6 +101,20 @@ cd web/frontend && npm run dev
**第一个注册的用户自动成为管理员**。
### API Key
每个用户在注册时会自动生成 API Key,可在 WebUI 的“设置”页面查看。调用需要认证的 API 时,在请求头中任选其一:
```http
X-API-Key: gb_<your-api-key>
```
或:
```http
Authorization: Bearer gb_<your-api-key>
```
## 数据库
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
+12
View File
@@ -218,6 +218,18 @@ func HandleDeleteAccount(c *gin.Context) {
return
}
// 检查账号是否归属请求用户
ownerUID, err := getAccountOwnerUID(accountID)
if err != nil {
response.ServerError(c, "获取账号所属用户失败")
return
}
if ownerUID != userInfo.ID {
response.Forbidden(c, "无权限操作该账号")
return
}
// 删除相关规则
rules, err := getRulesByAccountID(accountID, userInfo.ID)
if err != nil {
+14
View File
@@ -59,3 +59,17 @@ func maskAccounts(accounts []model.Account) []model.Account {
}
return masked
}
// getAccountOwnerUID 获取账号所属用户的 UID
func getAccountOwnerUID(accountID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var account model.Account
if err := gormDB.Select("uid").Where("id = ?", accountID).First(&account).Error; err != nil {
return 0, err
}
return account.UID, nil
}
+168
View File
@@ -0,0 +1,168 @@
package gateway
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/handler/runner"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
type webhookRequest struct {
Message string `json:"message"`
Title string `json:"title"`
}
func token() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "gw_" + hex.EncodeToString(b), nil
}
func HandleList(c *gin.Context) {
u, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "未登录")
return
}
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "获取网关失败")
return
}
var items []model.MessageGateway
if err := dbConn.Where("uid = ?", u.ID).Order("id DESC").Find(&items).Error; err != nil {
response.ServerError(c, "获取网关失败")
return
}
response.OK(c, items)
}
func HandleCreate(c *gin.Context) {
u, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "未登录")
return
}
var req model.MessageGatewayRequest
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Name) == "" {
response.BadRequest(c, "网关名称和规则不能为空")
return
}
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "创建网关失败")
return
}
key, err := token()
if err != nil {
response.ServerError(c, "生成网关 Token 失败")
return
}
item := model.MessageGateway{UID: u.ID, Name: strings.TrimSpace(req.Name), Token: key, CreateAt: time.Now().Unix()}
if err := dbConn.Create(&item).Error; err != nil {
response.ServerError(c, "创建网关失败")
return
}
response.OK(c, item)
}
func HandleDelete(c *gin.Context) {
u, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "未登录")
return
}
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "删除网关失败")
return
}
if err := dbConn.Where("id = ? AND uid = ?", c.Param("gatewayID"), u.ID).Delete(&model.MessageGateway{}).Error; err != nil {
response.ServerError(c, "删除网关失败")
return
}
response.OK(c, "网关已删除")
}
func HandleWebhook(c *gin.Context) {
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "网关不可用")
return
}
var gw model.MessageGateway
if err := dbConn.Where("token = ?", c.Param("token")).First(&gw).Error; err != nil {
response.Fail(c, http.StatusNotFound, "网关不存在")
return
}
var req webhookRequest
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Message) == "" {
response.BadRequest(c, "message 不能为空")
return
}
var rules []model.Rule
if err := dbConn.Where("uid = ? AND gateway_id = ? AND enabled = ?", gw.UID, gw.ID, true).Find(&rules).Error; err != nil {
response.ServerError(c, "读取网关规则失败")
return
}
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
fails := make([]string, 0)
for i := range rules {
rule := rules[i]
cfg, err := overrideMessage(rule.ConfigJson, req.Title, req.Message)
if err != nil {
fails = append(fails, rule.Name+": 规则不支持消息网关")
continue
}
rule.ConfigJson = cfg
if err := runner.ExecuteRuleWithContext(ctx, &rule, "webhook"); err != nil {
fails = append(fails, rule.Name+": "+err.Error())
}
}
response.OK(c, gin.H{"total": len(rules), "failed": fails})
}
func overrideMessage(raw, title, message string) (string, error) {
var obj map[string]any
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
return raw, err
}
matched := false
if _, ok := obj["msg"]; ok {
obj["msg"] = message
matched = true
}
if _, ok := obj["message"]; ok {
obj["message"] = message
matched = true
}
if _, ok := obj["body"]; ok {
obj["body"] = message
matched = true
}
if !matched {
return raw, fmt.Errorf("message field not found")
}
if title != "" {
if _, ok := obj["title"]; ok {
obj["title"] = title
}
}
b, err := json.Marshal(obj)
return string(b), err
}
+3
View File
@@ -40,6 +40,9 @@ func NotFound(c *gin.Context, msg string) { Fail(c, http.StatusNotFound, msg) }
// ServerError 服务端错误
func ServerError(c *gin.Context, msg string) { Fail(c, http.StatusInternalServerError, msg) }
// Forbidden 无权限
func Forbidden(c *gin.Context, msg string) { Fail(c, http.StatusForbidden, msg) }
// FromError 按错误类型选择合适的状态码:
// 参数校验错误返回 400,其余返回 500
func FromError(c *gin.Context, err error, fallback string) {
+12
View File
@@ -67,6 +67,7 @@ func HandleCreateRule(c *gin.Context) {
UID: userInfo.ID,
Name: req.Name,
TimerID: req.TimerID,
GatewayID: req.GatewayID,
AccountID: req.AccountID,
Type: req.Type,
ConfigJson: req.ConfigJson,
@@ -123,6 +124,7 @@ func HandleEditRule(c *gin.Context) {
updated.Name = req.Name
updated.Type = req.Type
updated.TimerID = req.TimerID
updated.GatewayID = req.GatewayID
updated.AccountID = req.AccountID
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
@@ -162,6 +164,16 @@ func HandleDeleteRule(c *gin.Context) {
return
}
ownerUID, err := getRuleOwnerUID(ruleID)
if err != nil {
response.ServerError(c, "获取规则所属用户失败")
return
}
if ownerUID != userInfo.ID {
response.Forbidden(c, "无权限操作该规则")
return
}
if err := DeleteRuleByID(ruleID, userInfo.ID); err != nil {
response.ServerError(c, "删除规则失败")
return
+29 -6
View File
@@ -59,12 +59,20 @@ func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType s
}
var count int64
if err := gormDB.Model(&model.Timer{}).
Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return model.ErrValidation("关联的 Timer 不存在")
if rule.TimerID != 0 {
if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return model.ErrValidation("关联的 Timer 不存在")
}
} else {
if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", rule.GatewayID, rule.UID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return model.ErrValidation("关联的消息网关不存在")
}
}
if requiredAccountType == "" {
@@ -103,3 +111,18 @@ func maskRules(rules []model.Rule) []model.Rule {
}
return masked
}
// getRuleOwnerUID 获取规则所属用户的 UID
func getRuleOwnerUID(ruleID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var rule model.Rule
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&rule).Error; err != nil {
return 0, err
}
return rule.UID, nil
}
+14
View File
@@ -70,3 +70,17 @@ func signTimer(timer *model.Timer) error {
timer.Triggered = false
return nil
}
// getTimerOwnerUID 获取 Timer 的所属用户 ID
func getTimerOwnerUID(timerID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var timer model.Timer
if err := gormDB.Select("uid").Where("id = ?", timerID).First(&timer).Error; err != nil {
return 0, err
}
return timer.UID, nil
}
+38
View File
@@ -1,6 +1,8 @@
package user
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -48,11 +50,44 @@ func createUser(u *model.User) error {
return err
}
u.Password = hashedPassword
if u.APIKey == "" {
u.APIKey, err = generateAPIKey()
if err != nil {
return err
}
}
u.CreateAt = time.Now().Unix()
return gormDB.Create(u).Error
}
func generateAPIKey() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "gb_" + hex.EncodeToString(b), nil
}
func ensureAPIKey(user *model.User) error {
if user.APIKey != "" {
return nil
}
key, err := generateAPIKey()
if err != nil {
return err
}
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
if err := gormDB.Model(&model.User{}).Where("id = ? AND (api_key IS NULL OR api_key = '')", user.ID).Update("api_key", key).Error; err != nil {
return err
}
user.APIKey = key
return nil
}
// IsUsernameTaken 用户名是否已被占用
func IsUsernameTaken(username string) (bool, error) {
gormDB, err := db.GetGormDB()
@@ -134,6 +169,9 @@ func GetUserByID(id uint) (*model.User, error) {
}
return nil, err
}
if err := ensureAPIKey(&user); err != nil {
return nil, err
}
return &user, nil
}
+21
View File
@@ -210,6 +210,27 @@ func HandleUpdateNotifyConfig(c *gin.Context) {
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := c.GetHeader("X-API-Key")
if apiKey == "" {
auth := c.GetHeader("Authorization")
const prefix = "Bearer "
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
apiKey = auth[len(prefix):]
}
}
if apiKey != "" {
gormDB, err := db.GetGormDB()
if err == nil {
var user model.User
if gormDB.Where("api_key = ?", apiKey).First(&user).Error == nil {
c.Set(contextKey, &user)
c.Next()
return
}
}
response.AbortWith(c, http.StatusUnauthorized, "API Key 无效")
return
}
session := sessions.Default(c)
uid, ok := session.Get("uid").(uint)
if !ok {
+63
View File
@@ -0,0 +1,63 @@
package alidns
import (
"encoding/json"
"fmt"
"github.com/alibabacloud-go/alidns-20150109/v5/client"
"github.com/ssdomei232/goodBaby/internal/meta"
)
type AliDNSAccountConfigValidator struct{}
func (v *AliDNSAccountConfigValidator) GetType() string { return AccountType }
func (v *AliDNSAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
if cfg.AK == "" || cfg.SK == "" {
return fmt.Errorf("阿里云账号配置不完整")
}
return nil
}
func parseAccount(config string) (*AliDNSAccount, error) {
var cfg AliDNSAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
}
return &cfg, nil
}
func (v *AliDNSAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
if err != nil {
return err
}
aliDNSClient, err := getAliDNSClient(cfg.AK, cfg.SK)
if err != nil {
return err
}
_, err = aliDNSClient.DescribeDnsProductInstances(&client.DescribeDnsProductInstancesRequest{})
if err != nil {
return err
}
return nil
}
func (v *AliDNSAccountConfigValidator) Meta() meta.AccountMeta {
return meta.AccountMeta{
Type: AccountType,
Label: "阿里云",
Description: "阿里云国内站",
Fields: []meta.Field{
{Key: "ak", Label: "Access Key", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "xxxxxxxxx"},
{Key: "sk", Label: "Secret Key", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "xxxxxxxxx"},
},
}
}
+11 -5
View File
@@ -2,13 +2,16 @@ package alidns
import (
"context"
"fmt"
"github.com/alibabacloud-go/alidns-20150109/v5/client"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// deleteAliDNSRecord deletes a DNS record from Alibaba Cloud DNS based on the provided rule.
func deleteAliDNSRecord(ctx context.Context, rule *model.Rule) error {
aliDNSClient, err := getAliDNSClient(rule)
aliDNSClient, err := getAliDNSClientFromRule(rule)
if err != nil {
return err
}
@@ -17,11 +20,14 @@ func deleteAliDNSRecord(ctx context.Context, rule *model.Rule) error {
return err
}
_, err = aliDNSClient.DeleteDomainRecord(&client.DeleteDomainRecordRequest{
RecordId: &deleteRecordConfig.RecordID,
})
if err != nil {
if err := retry.Do(ctx, func() error {
_, err := aliDNSClient.DeleteDomainRecord(&client.DeleteDomainRecordRequest{
RecordId: &deleteRecordConfig.RecordID,
})
return err
}); err != nil {
return fmt.Errorf("删除阿里云DNS记录失败: %w", err)
}
return nil
}
+5
View File
@@ -1,5 +1,10 @@
package alidns
const (
AccountType = "alidns"
RuleTypeDeleteRecord = "alidns-delete-record"
)
type AliDNSAccount struct {
AK string `json:"ak"`
SK string `json:"sk"`
+40
View File
@@ -0,0 +1,40 @@
package alidns
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// 阿里云删除记录规则验证器
type AliDNSDeleteRecordRuleValidator struct{}
func (v *AliDNSDeleteRecordRuleValidator) GetType() string {
return RuleTypeDeleteRecord
}
func (v *AliDNSDeleteRecordRuleValidator) Validate(configJSON string) error {
var config DeleteRecordConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析阿里云删除记录规则配置失败: %v", err)
}
if config.RecordID == "" {
return fmt.Errorf("阿里云删除记录规则配置中 record_id 不能为空")
}
return nil
}
func (v *AliDNSDeleteRecordRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleTypeDeleteRecord,
Label: "删除阿里云DNS解析记录",
Description: "触发时删除阿里云DNS解析记录",
AccountType: AccountType,
Fields: []meta.Field{
{Key: "record_id", Label: "记录ID", Type: meta.FieldString, Required: true},
},
}
}
+21
View File
@@ -0,0 +1,21 @@
package alidns
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
// DeleteAliDNSRecordExecutor 删除阿里云DNS记录执行器
type DeleteAliDNSRecordExecutor struct{}
func (e *DeleteAliDNSRecordExecutor) GetType() string {
return RuleTypeDeleteRecord
}
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行删除阿里云DNS记录规则: %s (ID: %d)", rule.Name, rule.ID)
return deleteAliDNSRecord(ctx, rule)
}
+9 -3
View File
@@ -11,7 +11,8 @@ import (
"github.com/ssdomei232/goodBaby/model"
)
func getAliDNSClient(rule *model.Rule) (client *alidns.Client, err error) {
// getAliDNSClient initializes and returns an Alibaba Cloud DNS client based on the provided rule's account configuration. It retrieves the account configuration from the database, sets up the necessary credentials, and creates a new client instance for interacting with Alibaba Cloud DNS services.
func getAliDNSClientFromRule(rule *model.Rule) (client *alidns.Client, err error) {
var accountConfig AliDNSAccount
// get config
@@ -19,11 +20,15 @@ func getAliDNSClient(rule *model.Rule) (client *alidns.Client, err error) {
return nil, err
}
return getAliDNSClient(accountConfig.AK, accountConfig.SK)
}
func getAliDNSClient(ak string, sk string) (client *alidns.Client, err error) {
// init aliyun account config
credentialsConfig := new(credentials.Config).
SetType("access_key").
SetAccessKeyId(accountConfig.AK).
SetAccessKeySecret(accountConfig.SK)
SetAccessKeyId(ak).
SetAccessKeySecret(sk)
akCredential, err := credentials.NewCredential(credentialsConfig)
if err != nil {
return nil, err
@@ -38,6 +43,7 @@ func getAliDNSClient(rule *model.Rule) (client *alidns.Client, err error) {
return client, nil
}
// getDeleteRecordConfig retrieves the configuration for deleting DNS records from the provided rule. It unmarshals the rule's configuration JSON into a DeleteRecordConfig structure and returns it.
func getDeleteRecordConfig(rule *model.Rule) (*DeleteRecordConfig, error) {
var deleteRecordConfig DeleteRecordConfig
+1 -3
View File
@@ -12,9 +12,7 @@ import (
// RainyunAccountConfigValidator Rainyun账号配置验证器
type RainyunAccountConfigValidator struct{}
func (v *RainyunAccountConfigValidator) GetType() string {
return AccountType
}
func (v *RainyunAccountConfigValidator) GetType() string { return AccountType }
func (v *RainyunAccountConfigValidator) Validate(config string) error {
// 解析配置
+3 -2
View File
@@ -3,8 +3,9 @@ package rainyun
const (
// AccountType Rainyun 账号类型标识
AccountType = "rainyun"
// RuleType 发送 Rainyun 消息的规则类型标识
RuleType = "rainyun-workorder"
// RuleTypeWorkOrder 发送 Rainyun 工单消息的规则类型标识
RuleTypeWorkOrder = "rainyun-workorder"
RuleTypeRunAway = "rainyun-runaway"
)
type RainyunWorkOrderRule struct {
+4 -4
View File
@@ -11,7 +11,7 @@ import (
type RainyunWorkorderRuleValidator struct{}
func (v *RainyunWorkorderRuleValidator) GetType() string {
return RuleType
return RuleTypeWorkOrder
}
func (v *RainyunWorkorderRuleValidator) Validate(configJSON string) error {
@@ -33,7 +33,7 @@ func (v *RainyunWorkorderRuleValidator) Validate(configJSON string) error {
func (v *RainyunWorkorderRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleType,
Type: RuleTypeWorkOrder,
Label: "发送雨云工单",
Description: "触发时发送雨云工单",
AccountType: AccountType,
@@ -47,7 +47,7 @@ func (v *RainyunWorkorderRuleValidator) Meta() meta.RuleMeta {
type RainyunRunAwayRuleValidator struct{}
func (v *RainyunRunAwayRuleValidator) GetType() string {
return "rainyun-runaway"
return RuleTypeRunAway
}
func (v *RainyunRunAwayRuleValidator) Validate(configJSON string) error {
@@ -65,7 +65,7 @@ func (v *RainyunRunAwayRuleValidator) Validate(configJSON string) error {
func (v *RainyunRunAwayRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: "rainyun-runaway",
Type: RuleTypeRunAway,
Label: "重置雨云账号中所有云服务器(一键跑路)",
Description: "触发时会重装雨云账号下所有云服务器来实现跑路",
AccountType: AccountType,
+2 -2
View File
@@ -11,7 +11,7 @@ import (
type RainyunWorkorderExecutor struct{}
func (e *RainyunWorkorderExecutor) GetType() string {
return RuleType
return RuleTypeWorkOrder
}
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, rule *model.Rule) error {
@@ -24,7 +24,7 @@ func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, rule *model.Rule
type RainyunRunAwayExecutor struct{}
func (e *RainyunRunAwayExecutor) GetType() string {
return "rainyun-runaway"
return RuleTypeRunAway
}
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, rule *model.Rule) error {
+1
View File
@@ -138,5 +138,6 @@ func AutoMigrate(gormDB *gorm.DB) error {
&model.Rule{},
&model.Account{},
&model.ExecutionLog{},
&model.MessageGateway{},
)
}
-29
View File
@@ -6,13 +6,6 @@ import (
"sort"
"sync"
"github.com/ssdomei232/goodBaby/drivers/bilibili"
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
"github.com/ssdomei232/goodBaby/drivers/email"
"github.com/ssdomei232/goodBaby/drivers/fwalert"
"github.com/ssdomei232/goodBaby/drivers/github"
"github.com/ssdomei232/goodBaby/drivers/onebot"
"github.com/ssdomei232/goodBaby/drivers/rainyun"
"github.com/ssdomei232/goodBaby/model"
)
@@ -66,28 +59,6 @@ var (
globalExecutorRegistry *ExecutorRegistry
)
// InitExecutorRegistry 初始化执行器注册表并注册所有执行器
func InitExecutorRegistry() *ExecutorRegistry {
registryOnce.Do(func() {
registry := NewExecutorRegistry()
// 注册所有规则执行器
registry.Register(&bilibili.BilibiliDynamicExecutor{})
registry.Register(&bilibili.BilibiliPrivateMessageExecutor{})
registry.Register(&email.EmailExecutor{})
registry.Register(&github.GithubMakeRepoPublicExecutor{})
registry.Register(&onebot.OneBotExecutor{})
registry.Register(&dingtalk.DingTalkExecutor{})
registry.Register(&fwalert.FwalertExecutor{})
registry.Register(&rainyun.RainyunWorkorderExecutor{})
registry.Register(&rainyun.RainyunRunAwayExecutor{})
// 未来添加新规则类型时,在这里注册即可
globalExecutorRegistry = registry
})
return globalExecutorRegistry
}
// GetGlobalExecutorRegistry 获取全局执行器注册表
func GetGlobalExecutorRegistry() *ExecutorRegistry {
if globalExecutorRegistry == nil {
+35
View File
@@ -0,0 +1,35 @@
package runner
import (
"github.com/ssdomei232/goodBaby/drivers/alidns"
"github.com/ssdomei232/goodBaby/drivers/bilibili"
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
"github.com/ssdomei232/goodBaby/drivers/email"
"github.com/ssdomei232/goodBaby/drivers/fwalert"
"github.com/ssdomei232/goodBaby/drivers/github"
"github.com/ssdomei232/goodBaby/drivers/onebot"
"github.com/ssdomei232/goodBaby/drivers/rainyun"
)
// InitExecutorRegistry 初始化执行器注册表并注册所有执行器
func InitExecutorRegistry() *ExecutorRegistry {
registryOnce.Do(func() {
registry := NewExecutorRegistry()
// 注册所有规则执行器
registry.Register(&bilibili.BilibiliDynamicExecutor{})
registry.Register(&bilibili.BilibiliPrivateMessageExecutor{})
registry.Register(&email.EmailExecutor{})
registry.Register(&github.GithubMakeRepoPublicExecutor{})
registry.Register(&onebot.OneBotExecutor{})
registry.Register(&dingtalk.DingTalkExecutor{})
registry.Register(&fwalert.FwalertExecutor{})
registry.Register(&rainyun.RainyunWorkorderExecutor{})
registry.Register(&rainyun.RainyunRunAwayExecutor{})
registry.Register(&alidns.DeleteAliDNSRecordExecutor{})
// 未来添加新规则类型时,在这里注册即可
globalExecutorRegistry = registry
})
return globalExecutorRegistry
}
+2
View File
@@ -3,6 +3,7 @@ package accountConfigChecker
import (
"sync"
"github.com/ssdomei232/goodBaby/drivers/alidns"
"github.com/ssdomei232/goodBaby/drivers/bilibili"
"github.com/ssdomei232/goodBaby/drivers/email"
"github.com/ssdomei232/goodBaby/drivers/github"
@@ -26,6 +27,7 @@ func InitValidatorRegistry() *ValidatorRegistry {
r.Register(&github.GitHubAccountConfigValidator{})
r.Register(&onebot.OneBotAccountConfigValidator{})
r.Register(&rainyun.RainyunAccountConfigValidator{})
r.Register(&alidns.AliDNSAccountConfigValidator{})
registry = r
})
+2
View File
@@ -3,6 +3,7 @@ package ruleConfigChecker
import (
"sync"
"github.com/ssdomei232/goodBaby/drivers/alidns"
"github.com/ssdomei232/goodBaby/drivers/bilibili"
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
"github.com/ssdomei232/goodBaby/drivers/email"
@@ -32,6 +33,7 @@ func InitValidatorRegistry() *ValidatorRegistry {
r.Register(&fwalert.FwalertRuleValidator{})
r.Register(&rainyun.RainyunWorkorderRuleValidator{})
r.Register(&rainyun.RainyunRunAwayRuleValidator{})
r.Register(&alidns.AliDNSDeleteRecordRuleValidator{})
registry = r
})
+10 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/ssdomei232/goodBaby/api/account"
"github.com/ssdomei232/goodBaby/api/admin"
"github.com/ssdomei232/goodBaby/api/dashboard"
"github.com/ssdomei232/goodBaby/api/gateway"
apilog "github.com/ssdomei232/goodBaby/api/log"
apimeta "github.com/ssdomei232/goodBaby/api/meta"
"github.com/ssdomei232/goodBaby/api/rule"
@@ -65,6 +66,7 @@ func main() {
v1.GET("/site", apimeta.HandleGetSiteInfo)
v1.POST("/user/registry", user.HandleRegistry)
v1.POST("/user/login", user.HandleLogin)
v1.POST("/gateways/:token/webhook", gateway.HandleWebhook)
}
// 需要认证的路由组
@@ -114,6 +116,13 @@ func main() {
accounts.DELETE("/:accountID", account.HandleDeleteAccount)
}
gateways := authorized.Group("/gateways")
{
gateways.GET("/", gateway.HandleList)
gateways.POST("/", gateway.HandleCreate)
gateways.DELETE("/:gatewayID", gateway.HandleDelete)
}
logs := authorized.Group("/logs")
{
logs.GET("/", apilog.HandleGetLogs)
@@ -151,7 +160,7 @@ func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type")
c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
c.Header("Vary", "Origin")
}
+13
View File
@@ -0,0 +1,13 @@
package model
type MessageGateway struct {
ID uint `gorm:"primaryKey" json:"id"`
UID uint `gorm:"index" json:"uid"`
Name string `json:"name"`
Token string `gorm:"uniqueIndex;size:67" json:"token"`
CreateAt int64 `json:"create_at"`
}
type MessageGatewayRequest struct {
Name string `json:"name"`
}
+11 -6
View File
@@ -2,10 +2,11 @@ package model
// Rule 描述一个 Timer 到期后要执行的动作
type Rule struct {
ID uint `json:"id" gorm:"primaryKey"`
UID uint `gorm:"index" json:"uid"`
TimerID uint `gorm:"index" json:"timer_id"`
Name string `json:"name"`
ID uint `json:"id" gorm:"primaryKey"`
UID uint `gorm:"index" json:"uid"`
TimerID uint `gorm:"index" json:"timer_id"`
GatewayID uint `gorm:"index" json:"gateway_id"`
Name string `json:"name"`
// 关联账号,部分规则类型(如钉钉)不需要账号,此时为 0
AccountID uint `json:"account_id"`
// 规则类型,见 internal/ruleConfigChecker 注册表
@@ -20,6 +21,7 @@ type Rule struct {
type RuleRequest struct {
Name string `json:"name"`
TimerID uint `json:"timer_id"`
GatewayID uint `json:"gateway_id"`
AccountID uint `json:"account_id"`
Type string `json:"type"`
ConfigJson string `json:"config_json"`
@@ -37,8 +39,11 @@ func (r *RuleRequest) Validate() error {
if r.Type == "" {
return ErrValidation("规则类型不能为空")
}
if r.TimerID == 0 {
return ErrValidation("必须关联一个 Timer")
if r.TimerID == 0 && r.GatewayID == 0 {
return ErrValidation("必须关联一个定时器或消息网关")
}
if r.TimerID != 0 && r.GatewayID != 0 {
return ErrValidation("定时器和消息网关只能选择一个")
}
if r.ConfigJson == "" {
return ErrValidation("规则配置不能为空")
+1
View File
@@ -23,6 +23,7 @@ type User struct {
CreateAt int64 `json:"create_at"`
Username string `gorm:"uniqueIndex" json:"username"`
Password string `json:"-"`
APIKey string `gorm:"index;size:67" json:"api_key"`
// 管理员可以修改系统配置。第一个注册的用户自动成为管理员。
IsAdmin bool `gorm:"default:false" json:"is_admin"`
// 钉钉机器人配置(JSON 字符串),用于接收提醒
+1214
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -7,6 +7,7 @@ import type {
DashboardOverview,
ExecutionLog,
LogPage,
MessageGateway,
Providers,
Rule,
RuleRequest,
@@ -81,6 +82,12 @@ export const logApi = {
clear: () => api.delete<string>('/logs/'),
}
export const gatewayApi = {
list: () => api.get<MessageGateway[]>('/gateways/'),
create: (name: string) => api.post<MessageGateway>('/gateways/', { name }),
remove: (id: number) => api.delete<string>(`/gateways/${id}`),
}
export const adminApi = {
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
+6 -1
View File
@@ -10,7 +10,8 @@ export interface UserInfo {
create_at: number
username: string
is_admin: boolean
dingtalk_config: string | null
dingtalk_config: string | null
api_key: string
}
export interface Timer {
@@ -40,6 +41,7 @@ export interface Rule {
id: number
uid: number
timer_id: number
gateway_id: number
name: string
account_id: number
type: string
@@ -48,9 +50,12 @@ export interface Rule {
create_at: number
}
export interface MessageGateway { id: number; uid: number; name: string; token: string; create_at: number }
export interface RuleRequest {
name: string
timer_id: number
gateway_id?: number
account_id: number
type: string
config_json: string
+2
View File
@@ -7,6 +7,7 @@ import {
Timer as TimerIcon,
Operation,
User,
Connection,
Document,
Setting,
SwitchButton,
@@ -38,6 +39,7 @@ const menus = [
{ path: '/timers', title: '定时器', icon: TimerIcon },
{ path: '/rules', title: '规则', icon: Operation },
{ path: '/accounts', title: '账号', icon: User },
{ path: '/gateways', title: '消息网关', icon: Connection },
{ path: '/logs', title: '执行日志', icon: Document },
{ path: '/settings', title: '设置', icon: Setting },
]
+1
View File
@@ -40,6 +40,7 @@ const router = createRouter({
component: () => import('@/views/AccountsView.vue'),
meta: { title: '账号' },
},
{ path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } },
{
path: 'logs',
name: 'logs',
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument, Delete, Link, Plus } from '@element-plus/icons-vue'
import { gatewayApi, ruleApi } from '@/api'
import type { MessageGateway, Rule } from '@/api/types'
const gateways = ref<MessageGateway[]>([]), rules = ref<Rule[]>([]), name = ref(''), loading = ref(false), saving = ref(false)
const origin = window.location.origin
async function refresh() { loading.value = true; try { ;[gateways.value, rules.value] = await Promise.all([gatewayApi.list(), ruleApi.list()]) } catch { ElMessage.error('加载消息网关失败') } finally { loading.value = false } }
async function create() { if (!name.value.trim()) { ElMessage.warning('请输入网关名称'); return }; saving.value = true; try { await gatewayApi.create(name.value.trim()); name.value = ''; await refresh(); ElMessage.success('消息网关已创建') } catch (e) { ElMessage.error(e instanceof Error ? e.message : '创建失败') } finally { saving.value = false } }
async function remove(item: MessageGateway) { try { await ElMessageBox.confirm(`删除“${item.name}”?关联规则将无法继续通过此网关触发。`, '删除消息网关', { type: 'warning' }); await gatewayApi.remove(item.id); await refresh() } catch {} }
async function copy(url: string) { await navigator.clipboard?.writeText(url); ElMessage.success('Webhook 地址已复制') }
function endpoint(item: MessageGateway) { return `${origin}/api/v1/gateways/${item.token}/webhook` }
function ruleCount(item: MessageGateway) { return rules.value.filter(r => r.gateway_id === item.id).length }
onMounted(refresh)
</script>
<template><div class="gateway-page" v-loading="loading"><div class="page-header"><div><h2>消息网关</h2><div class="muted">创建入口,让外部系统通过 Webhook 触发通知规则</div></div><el-button type="primary" :icon="Plus">新建网关</el-button></div><el-card class="create-card"><div class="create-copy"><div class="create-icon"><el-icon :size="20"><Link /></el-icon></div><div><strong>新建 Webhook 入口</strong><div class="muted">创建后,在规则编辑页将通知规则关联到此网关</div></div></div><el-form class="create-form" @submit.prevent="create"><el-input v-model="name" size="large" placeholder="例如:生产环境告警" maxlength="64" clearable /><el-button type="primary" size="large" :loading="saving" @click="create">创建网关</el-button></el-form></el-card><div v-if="gateways.length" class="gateway-grid"><el-card v-for="item in gateways" :key="item.id" class="gateway-card"><div class="gateway-top"><div><div class="gateway-name">{{ item.name }}</div><div class="gateway-meta">{{ ruleCount(item) }} 条关联通知规则</div></div><el-button circle text type="danger" :icon="Delete" title="删除网关" @click="remove(item)" /></div><div class="endpoint-label">Webhook 地址</div><div class="endpoint"><code>{{ endpoint(item) }}</code><el-button text :icon="CopyDocument" title="复制地址" @click="copy(endpoint(item))" /></div><div class="payload-hint">POST JSON:<code>{ "message": "...", "title": "..." }</code></div></el-card></div><el-empty v-else-if="!loading" description="还没有消息网关" /></div></template>
<style scoped>.create-card{margin-bottom:20px}.create-copy{display:flex;align-items:center;gap:12px;margin-bottom:16px}.create-copy strong{font-size:15px}.create-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;color:var(--gb-primary-deep);background:var(--el-color-primary-light-9)}.create-form{display:flex;gap:10px;max-width:620px}.create-form .el-input{flex:1}.gateway-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}.gateway-card{min-width:0}.gateway-top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:22px}.gateway-name{font-size:17px;font-weight:700}.gateway-meta,.endpoint-label,.payload-hint{color:var(--el-text-color-secondary);font-size:12px}.endpoint-label{margin-bottom:6px}.endpoint{display:flex;align-items:center;gap:6px;min-width:0;padding:10px 12px;border-radius:8px;background:var(--gb-bg)}.endpoint code{flex:1;min-width:0;overflow-wrap:anywhere;color:var(--el-text-color-primary);font-size:12px}.payload-hint{margin-top:12px}.payload-hint code{color:var(--el-text-color-primary)}@media(max-width:600px){.create-form{flex-direction:column}.gateway-grid{grid-template-columns:1fr}}</style>
+22 -6
View File
@@ -2,9 +2,9 @@
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, VideoPlay } from '@element-plus/icons-vue'
import { accountApi, ruleApi, timerApi } from '@/api'
import { accountApi, gatewayApi, ruleApi, timerApi } from '@/api'
import { ApiError } from '@/api/client'
import type { Account, Rule, Timer } from '@/api/types'
import type { Account, MessageGateway, Rule, Timer } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
import ConfigForm from '@/components/ConfigForm.vue'
@@ -16,6 +16,7 @@ const metaStore = useMetaStore()
const rules = ref<Rule[]>([])
const timers = ref<Timer[]>([])
const gateways = ref<MessageGateway[]>([])
const accounts = ref<Account[]>([])
const loading = ref(false)
const testingId = ref<number | null>(null)
@@ -30,6 +31,7 @@ const form = ref<{
name: string
type: string
timer_id: number
gateway_id: number
account_id: number | undefined
enabled: boolean
config_json: string
@@ -37,6 +39,7 @@ const form = ref<{
name: '',
type: '',
timer_id: 0,
gateway_id: 0,
account_id: undefined,
enabled: true,
config_json: '',
@@ -69,14 +72,16 @@ function accountName(id: number): string {
async function refresh() {
loading.value = true
try {
const [ruleList, timerList, accountList] = await Promise.all([
const [ruleList, timerList, accountList, gatewayList] = await Promise.all([
ruleApi.list(),
timerApi.list(),
accountApi.list(),
gatewayApi.list(),
])
rules.value = ruleList
timers.value = timerList
accounts.value = accountList
gateways.value = gatewayList
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
} finally {
@@ -85,7 +90,7 @@ async function refresh() {
}
function openCreate() {
if (timers.value.length === 0) {
if (timers.value.length === 0 && gateways.value.length === 0) {
ElMessage.warning('请先在“定时器”页面创建一个定时器')
return
}
@@ -94,6 +99,7 @@ function openCreate() {
name: '',
type: metaStore.ruleMetas[0]?.type ?? '',
timer_id: timers.value[0]?.id ?? 0,
gateway_id: 0,
account_id: undefined,
enabled: true,
config_json: '',
@@ -107,6 +113,7 @@ function openEdit(rule: Rule) {
name: rule.name,
type: rule.type,
timer_id: rule.timer_id,
gateway_id: rule.gateway_id || 0,
account_id: rule.account_id || undefined,
enabled: rule.enabled,
config_json: rule.config_json,
@@ -133,6 +140,7 @@ async function save() {
name: form.value.name,
type: form.value.type,
timer_id: form.value.timer_id,
gateway_id: form.value.gateway_id,
account_id: form.value.account_id ?? 0,
enabled: form.value.enabled,
config_json: form.value.config_json,
@@ -162,6 +170,7 @@ async function toggleEnabled(rule: Rule) {
name: rule.name,
type: rule.type,
timer_id: rule.timer_id,
gateway_id: rule.gateway_id,
account_id: rule.account_id,
enabled: rule.enabled,
config_json: rule.config_json,
@@ -308,10 +317,14 @@ onMounted(async () => {
</el-select>
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
</el-form-item>
<el-form-item label="关联定时器" required>
<el-select v-model="form.timer_id" style="width: 100%">
<el-form-item label="关联定时器/消息网关" required>
<div class="source-hint muted">规则只能选择一种触发方式</div>
<el-select v-model="form.timer_id" class="source-select" clearable placeholder="定时器触发">
<el-option v-for="t in timers" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
<el-select v-model="form.gateway_id" clearable placeholder="消息网关(可选)" style="width: 100%; margin-top: 8px" @change="form.gateway_id && (form.timer_id = 0)">
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
</el-form-item>
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
<el-select
@@ -363,6 +376,9 @@ onMounted(async () => {
width: 200px;
}
.source-select { width: 100%; margin-top: 8px; }
.source-hint { margin-bottom: 2px; }
@media (max-width: 768px) {
.header-tools {
flex-wrap: wrap;
+14
View File
@@ -14,6 +14,13 @@ const userStore = useUserStore()
const isAdmin = computed(() => userStore.user?.is_admin === true)
async function copyApiKey() {
const key = userStore.user?.api_key
if (!key) return
await navigator.clipboard?.writeText(key)
ElMessage.success('API Key 已复制')
}
// ---- 系统配置(仅管理员) ----
const adminLoading = ref(false)
const adminSaving = ref(false)
@@ -166,6 +173,13 @@ onMounted(async () => {
<el-descriptions-item label="注册时间">
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
</el-descriptions-item>
<el-descriptions-item label="API Key">
<el-input :model-value="userStore.user?.api_key ?? ''" readonly style="max-width: 380px">
<template #append>
<el-button title="复制 API Key" @click="copyApiKey">复制</el-button>
</template>
</el-input>
</el-descriptions-item>
</el-descriptions>
</el-card>