4 Commits
Author SHA1 Message Date
mei 9b656cebee feat(gateway): 优化gateway相关功能
Quality check / Web UI (push) Successful in 9m19s
2026-09-12 15:32:27 +08:00
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
94 changed files with 4424 additions and 705 deletions
+31 -2
View File
@@ -21,6 +21,8 @@
* 发送钉钉机器人消息
* 公开 GitHub 仓库
* **账号 (Account)**:集中管理第三方凭据,支持连通性测试,敏感字段(密码/Cookie/Token)不会回显
* **消息网关 (Gateway)**:为外部系统生成 Webhook 地址,投递进来的消息会触发绑定在该网关上的「网关规则」
* **网关规则**:与定时器规则分开存储、分开管理,规则里的消息字段(msg / message / body)会被投递内容替换
* **执行日志**:每次规则执行与提醒都有记录,规则支持手动测试
* **配置测试**:账号可一键测试连通性;规则可手动触发验证
@@ -101,6 +103,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)。
@@ -120,12 +136,25 @@ cd web/frontend && npm run dev
* [钉钉机器人](docs/dingtalk-config.md)
* [GitHub](docs/github-config.md)
* [饭碗警告](docs/fwalert-config.md)
* [消息网关](docs/gateway-config.md)
## 开发:新增一种规则类型
driver 只负责把动作做出去:规则本体与关联账号由 `handler/runner` 组装成 `model.RuleTask` 后传进来,
driver 内部不访问数据库、不写日志,需要账号凭据时从 `task.Account` 里解析。
1. 在 `drivers/<name>/` 下实现:
* `tool.go`:解析规则 / 账号配置的 `ParseXxx` 函数
* 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据)
* 执行器(实现 `runner.RuleExecutor`)
* 执行器(实现 `runner.RuleExecutor`,方法签名为 `Execute(ctx, *model.RuleTask)`)
* 对外动作(底层函数)保持原子:一次调用只做一件事,用 `retry.Do(ctx, ...)` 包裹重试
* 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试)
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/interface.go` 中注册
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/reg.go` 中注册
3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单
## 开发:新增一种消息网关
1. 在 `internal/gateway/` 下实现 `Gateway` 接口(`GetType()` / `Meta()` / `Deliver()`),
`Deliver` 拿到的 `Task` 里已经包含网关与待触发的规则,实现里不访问数据库
2. 在 `internal/gateway/reg.go` 中注册
3. 前端无需改动 —— 新建网关时的类型选项与说明来自 `Meta()`
+36 -2
View File
@@ -181,7 +181,7 @@ func HandleTestAccount(c *gin.Context) {
// HandleCheckDeleteAccount 检查删除账号请求
//
// 删除账号会同时删除相关规则,先请求该接口获取受影响的规则
// 删除账号会同时删除相关规则(含消息网关规则),先请求该接口获取受影响的规则
func HandleCheckDeleteAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
@@ -201,7 +201,16 @@ func HandleCheckDeleteAccount(c *gin.Context) {
return
}
response.OK(c, rules)
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取相关网关规则失败")
return
}
response.OK(c, gin.H{
"rules": rules,
"gateway_rules": gatewayRules,
})
}
// HandleDeleteAccount 删除账号及其关联规则
@@ -218,6 +227,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 {
@@ -231,6 +252,19 @@ func HandleDeleteAccount(c *gin.Context) {
}
}
// 删除相关的消息网关规则
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取相关网关规则失败")
return
}
for _, oneRule := range gatewayRules {
if err := rule.DeleteGatewayRuleByID(oneRule.ID, userInfo.ID); err != nil {
response.ServerError(c, "删除相关网关规则失败")
return
}
}
// 删除账号
gormDB, err := db.GetGormDB()
if err != nil {
+28
View File
@@ -45,6 +45,20 @@ func getRulesByAccountID(accountID uint, uid uint) ([]*model.Rule, error) {
return rules, nil
}
// getGatewayRulesByAccountID 获取账号相关的消息网关规则列表
func getGatewayRulesByAccountID(accountID uint, uid uint) ([]*model.GatewayRule, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
rules := []*model.GatewayRule{}
if err := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules).Error; err != nil {
return nil, err
}
return rules, nil
}
// maskAccount 掩码账号配置中的敏感字段,避免 Cookie/密码原样返回给前端
func maskAccount(account model.Account) model.Account {
fields := accountConfigChecker.InitValidatorRegistry().FieldsOf(account.Type)
@@ -59,3 +73,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
}
+12 -8
View File
@@ -31,8 +31,10 @@ func HandleGetOverview(c *gin.Context) {
return
}
var ruleCount, accountCount int64
var ruleCount, gatewayCount, gatewayRuleCount, accountCount int64
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
gormDB.Model(&model.MessageGateway{}).Where("uid = ?", userInfo.ID).Count(&gatewayCount)
gormDB.Model(&model.GatewayRule{}).Where("uid = ?", userInfo.ID).Count(&gatewayRuleCount)
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
recentLogs := []model.ExecutionLog{}
@@ -62,13 +64,15 @@ func HandleGetOverview(c *gin.Context) {
}
overview := gin.H{
"timer_count": len(timers),
"enabled_timers": enabledCount,
"triggered_count": triggeredCount,
"rule_count": ruleCount,
"account_count": accountCount,
"server_time": now,
"recent_logs": recentLogs,
"timer_count": len(timers),
"enabled_timers": enabledCount,
"triggered_count": triggeredCount,
"rule_count": ruleCount,
"gateway_count": gatewayCount,
"gateway_rule_count": gatewayRuleCount,
"account_count": accountCount,
"server_time": now,
"recent_logs": recentLogs,
}
if urgent != nil {
+187
View File
@@ -0,0 +1,187 @@
// Package gateway 提供消息网关的 HTTP 接口。
//
// 数据库读写都发生在这一层,真正的投递逻辑在 internal/gateway 里,
// 这里只负责鉴权、取数、组装投递任务。
package gateway
import (
"context"
"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"
gatewaycore "github.com/ssdomei232/goodBaby/internal/gateway"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
"gorm.io/gorm"
)
// webhookRequest 外部系统投递消息的请求体
type webhookRequest struct {
Message string `json:"message"`
Title string `json:"title"`
}
// HandleList 获取当前用户的所有消息网关
func HandleList(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "未登录")
return
}
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "获取网关失败")
return
}
items := []model.MessageGateway{}
if err := dbConn.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&items).Error; err != nil {
response.ServerError(c, "获取网关失败")
return
}
response.OK(c, items)
}
// HandleCreate 创建消息网关
func HandleCreate(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "未登录")
return
}
var req model.MessageGatewayRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
req.Name = strings.TrimSpace(req.Name)
if err := req.Validate(); err != nil {
response.FromError(c, err, "创建网关失败")
return
}
// 不填类型时用默认网关,填了就必须是已注册的类型
if req.Type == "" {
req.Type = model.GatewayTypeWebhook
}
if _, ok := gatewaycore.InitGatewayRegistry().Resolve(req.Type); !ok {
response.BadRequest(c, "不支持的消息网关类型: "+req.Type)
return
}
token, err := gatewaycore.NewToken()
if err != nil {
response.ServerError(c, "生成网关 Token 失败")
return
}
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "创建网关失败")
return
}
item := model.MessageGateway{
UID: userInfo.ID,
Name: req.Name,
Type: req.Type,
Token: token,
CreateAt: time.Now().Unix(),
}
if err := dbConn.Create(&item).Error; err != nil {
response.ServerError(c, "创建网关失败")
return
}
response.OK(c, item)
}
// HandleDelete 删除消息网关,绑定在它下面的规则一并删除
func HandleDelete(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "未登录")
return
}
gatewayID, err := parseID(c.Param("gatewayID"))
if err != nil {
response.BadRequest(c, "网关 ID 格式错误")
return
}
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "删除网关失败")
return
}
err = dbConn.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("gateway_id = ? AND uid = ?", gatewayID, userInfo.ID).
Delete(&model.GatewayRule{}).Error; err != nil {
return err
}
return tx.Where("id = ? AND uid = ?", gatewayID, userInfo.ID).
Delete(&model.MessageGateway{}).Error
})
if err != nil {
response.ServerError(c, "删除网关失败")
return
}
response.OK(c, "网关已删除")
}
// HandleWebhook 接收外部系统投递的消息,触发绑定在该网关上的规则
func HandleWebhook(c *gin.Context) {
var req webhookRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
response.BadRequest(c, "message 不能为空")
return
}
dbConn, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "网关不可用")
return
}
var target model.MessageGateway
if err := dbConn.Where("token = ?", c.Param("token")).First(&target).Error; err != nil {
response.Fail(c, http.StatusNotFound, "网关不存在")
return
}
rules := []model.GatewayRule{}
if err := dbConn.Where("uid = ? AND gateway_id = ? AND enabled = ?", target.UID, target.ID, true).
Find(&rules).Error; err != nil {
response.ServerError(c, "读取网关规则失败")
return
}
// 投递需要在 HTTP 请求内返回结果,因此用较短的超时
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
result, err := gatewaycore.InitGatewayRegistry().Deliver(ctx, target.Type, &gatewaycore.Task{
Gateway: &target,
Rules: rules,
Message: gatewaycore.Message{
Title: strings.TrimSpace(req.Title),
Content: strings.TrimSpace(req.Message),
},
})
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, result)
}
+12
View File
@@ -0,0 +1,12 @@
package gateway
import "strconv"
// parseID 解析 URL 里的数字 ID
func parseID(raw string) (uint, error) {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
+3
View File
@@ -37,6 +37,9 @@ func HandleGetLogs(c *gin.Context) {
if ruleID := c.Query("rule_id"); ruleID != "" {
query = query.Where("rule_id = ?", ruleID)
}
if gatewayRuleID := c.Query("gateway_rule_id"); gatewayRuleID != "" {
query = query.Where("gateway_rule_id = ?", gatewayRuleID)
}
if success := c.Query("success"); success == "true" || success == "false" {
query = query.Where("success = ?", success == "true")
}
+3 -1
View File
@@ -7,16 +7,18 @@ import (
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/gateway"
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
)
// HandleGetProviders 返回所有账号类型与规则类型的元数据
// HandleGetProviders 返回所有账号类型、规则类型与消息网关类型的元数据
//
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
func HandleGetProviders(c *gin.Context) {
response.OK(c, gin.H{
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
"gateways": gateway.InitGatewayRegistry().Metas(),
})
}
+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) {
+246
View File
@@ -0,0 +1,246 @@
package rule
import (
"context"
"fmt"
"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/gateway"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// HandleGetAllGatewayRules 获取用户的消息网关规则,支持按 gateway_id 过滤
//
// 消息网关规则和定时器规则是两张表,互不影响,因此这里单独一组接口。
func HandleGetAllGatewayRules(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
query := gormDB.Where("uid = ?", userInfo.ID)
if raw := c.Query("gateway_id"); raw != "" {
gatewayID, err := parseID(raw)
if err != nil {
response.BadRequest(c, "gateway_id 格式错误")
return
}
query = query.Where("gateway_id = ?", gatewayID)
}
rules := []model.GatewayRule{}
if err := query.Order("id DESC").Find(&rules).Error; err != nil {
response.ServerError(c, "获取网关规则失败")
return
}
response.OK(c, maskGatewayRules(rules))
}
// HandleCreateGatewayRule 创建消息网关规则
func HandleCreateGatewayRule(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.GatewayRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
newRule := model.GatewayRule{
UID: userInfo.ID,
Name: req.Name,
GatewayID: req.GatewayID,
AccountID: req.AccountID,
Type: req.Type,
ConfigJson: req.ConfigJson,
Enabled: boolOr(req.Enabled, true),
CreateAt: time.Now().Unix(),
}
if err := validateGatewayRule(&req, &newRule); err != nil {
response.FromError(c, err, "创建网关规则失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Create(&newRule).Error; err != nil {
response.ServerError(c, "创建网关规则失败")
return
}
response.OK(c, maskGatewayRule(newRule))
}
// HandleEditGatewayRule 编辑消息网关规则
func HandleEditGatewayRule(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
ruleID, err := parseID(c.Param("ruleID"))
if err != nil {
response.BadRequest(c, "规则 ID 格式错误")
return
}
existing, err := findGatewayRule(ruleID, userInfo.ID)
if err != nil {
response.NotFound(c, "网关规则不存在")
return
}
var req model.GatewayRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
updated := *existing
updated.Name = req.Name
updated.Type = req.Type
updated.GatewayID = req.GatewayID
updated.AccountID = req.AccountID
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
updated.ConfigJson = unmaskRuleConfig(req.Type, req.ConfigJson, existing.ConfigJson)
req.ConfigJson = updated.ConfigJson
if err := validateGatewayRule(&req, &updated); err != nil {
response.FromError(c, err, "更新网关规则失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Save(&updated).Error; err != nil {
response.ServerError(c, "更新网关规则失败")
return
}
response.OK(c, maskGatewayRule(updated))
}
// HandleDeleteGatewayRule 删除消息网关规则
func HandleDeleteGatewayRule(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
ruleID, err := parseID(c.Param("ruleID"))
if err != nil {
response.BadRequest(c, "规则 ID 格式错误")
return
}
if _, err := findGatewayRule(ruleID, userInfo.ID); err != nil {
response.NotFound(c, "网关规则不存在")
return
}
if err := DeleteGatewayRuleByID(ruleID, userInfo.ID); err != nil {
response.ServerError(c, "删除网关规则失败")
return
}
response.OK(c, "网关规则删除成功")
}
// HandleTestGatewayRule 立即执行一次网关规则,用于验证配置
//
// 与投递不同:这里用规则里保存的配置,不覆盖外部消息。
func HandleTestGatewayRule(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
ruleID, err := parseID(c.Param("ruleID"))
if err != nil {
response.BadRequest(c, "规则 ID 格式错误")
return
}
target, err := findGatewayRule(ruleID, userInfo.ID)
if err != nil {
response.NotFound(c, "网关规则不存在")
return
}
// 用一条示例消息走一次真实的投递覆盖,让用户看到规则最终会发出什么内容
if configJSON, applied, err := gateway.ApplyMessage(target.ConfigJson, gateway.TestMessage); err == nil && applied {
target.ConfigJson = configJSON
}
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
if err := runner.ExecuteGatewayRuleWithContext(ctx, target, model.TriggerManual); err != nil {
response.BadRequest(c, fmt.Sprintf("规则执行失败: %s", err.Error()))
return
}
response.OK(c, "规则执行成功")
}
// validateGatewayRule 校验网关规则的通用字段、关联对象与类型专属配置
func validateGatewayRule(req *model.GatewayRuleRequest, rule *model.GatewayRule) error {
if err := req.Validate(); err != nil {
return err
}
if err := checkGatewayExists(rule.GatewayID, rule.UID); err != nil {
return err
}
ruleMeta, err := ruleMetaOf(req.Type)
if err != nil {
return err
}
// 标题/内容由投递请求提供,页面不填,这里补占位值后再校验
rule.ConfigJson = meta.FillGatewayMessages(rule.ConfigJson, ruleMeta.Fields)
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
return err
}
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
if err != nil {
return err
}
rule.AccountID = accountID
return nil
}
+24 -9
View File
@@ -162,6 +162,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
@@ -209,19 +219,24 @@ func validateRule(req *model.RuleRequest, rule *model.Rule) error {
return err
}
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(req.Type)
if !ok {
return model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", req.Type))
}
// 检查关联的 Timer 与账号是否存在且属于当前用户
if err := checkRuleConfigAccountAndTimerExist(rule, ruleMeta.AccountType); err != nil {
// 检查关联的 Timer 是否存在、账号是否可用
if err := checkTimerExists(rule.TimerID, rule.UID); err != nil {
return err
}
if err := ruleConfigChecker.InitValidatorRegistry().Validate(req.Type, rule.ConfigJson); err != nil {
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
ruleMeta, err := ruleMetaOf(req.Type)
if err != nil {
return err
}
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
return err
}
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
if err != nil {
return err
}
rule.AccountID = accountID
return nil
}
+144 -31
View File
@@ -25,7 +25,7 @@ func boolOr(v *bool, fallback bool) bool {
return *v
}
// findRule 查找属于该用户的规则
// findRule 查找属于该用户的定时器规则
func findRule(ruleID, uid uint) (*model.Rule, error) {
gormDB, err := db.GetGormDB()
if err != nil {
@@ -39,7 +39,21 @@ func findRule(ruleID, uid uint) (*model.Rule, error) {
return &target, nil
}
// DeleteRuleByID 根据 ID 删除规则
// findGatewayRule 查找属于该用户的消息网关规则
func findGatewayRule(ruleID, uid uint) (*model.GatewayRule, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var target model.GatewayRule
if err := gormDB.Where("id = ? AND uid = ?", ruleID, uid).First(&target).Error; err != nil {
return nil, err
}
return &target, nil
}
// DeleteRuleByID 根据 ID 删除定时器规则
func DeleteRuleByID(id uint, uid uint) error {
gormDB, err := db.GetGormDB()
if err != nil {
@@ -49,50 +63,99 @@ func DeleteRuleByID(id uint, uid uint) error {
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error
}
// checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户
//
// requiredAccountType 为空表示该规则类型不需要账号。
func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error {
// DeleteGatewayRuleByID 根据 ID 删除消息网关规则
func DeleteGatewayRuleByID(id uint, uid uint) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.GatewayRule{}).Error
}
// checkTimerExists 检查关联的 Timer 是否存在且归属当前用户
func checkTimerExists(timerID, uid uint) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
var count int64
if err := gormDB.Model(&model.Timer{}).
Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", timerID, uid).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return model.ErrValidation("关联的 Timer 不存在")
}
if requiredAccountType == "" {
// 该类型不需要账号,忽略前端可能传来的 account_id
rule.AccountID = 0
return nil
}
if rule.AccountID == 0 {
return model.ErrValidation("该规则类型必须关联一个账号")
}
var account model.Account
if err := gormDB.Where("id = ? AND uid = ?", rule.AccountID, rule.UID).First(&account).Error; err != nil {
return model.ErrValidation("关联的账号不存在")
}
if account.Type != requiredAccountType {
return model.ErrValidation(fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredAccountType, account.Type))
}
return nil
}
// maskRule 掩码规则配置中的敏感字段
func maskRule(target model.Rule) model.Rule {
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(target.Type); ok {
target.ConfigJson = meta.Mask(target.ConfigJson, ruleMeta.Fields)
// checkGatewayExists 检查关联的消息网关是否存在且归属当前用户
func checkGatewayExists(gatewayID, uid uint) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
var count int64
if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", gatewayID, uid).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return model.ErrValidation("关联的消息网关不存在")
}
return nil
}
// checkAccount 校验规则关联的账号存在、归属当前用户且类型匹配。
//
// requiredType 为空表示该规则类型不需要账号,此时返回 0,调用方据此清掉 account_id。
func checkAccount(accountID, uid uint, requiredType string) (uint, error) {
if requiredType == "" {
return 0, nil
}
if accountID == 0 {
return 0, model.ErrValidation("该规则类型必须关联一个账号")
}
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var account model.Account
if err := gormDB.Where("id = ? AND uid = ?", accountID, uid).First(&account).Error; err != nil {
return 0, model.ErrValidation("关联的账号不存在")
}
if account.Type != requiredType {
return 0, model.ErrValidation(
fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredType, account.Type))
}
return accountID, nil
}
// ruleMetaOf 取规则类型的元数据,不支持的类型返回校验错误
func ruleMetaOf(ruleType string) (meta.RuleMeta, error) {
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType)
if !ok {
return meta.RuleMeta{}, model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", ruleType))
}
return ruleMeta, nil
}
// validateRuleConfig 校验类型专属的规则配置
func validateRuleConfig(ruleType, configJSON string) error {
if err := ruleConfigChecker.InitValidatorRegistry().Validate(ruleType, configJSON); err != nil {
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
}
return nil
}
// maskRule 掩码定时器规则配置中的敏感字段
func maskRule(target model.Rule) model.Rule {
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
return target
}
@@ -103,3 +166,53 @@ func maskRules(rules []model.Rule) []model.Rule {
}
return masked
}
// maskGatewayRule 掩码消息网关规则配置中的敏感字段
func maskGatewayRule(target model.GatewayRule) model.GatewayRule {
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
return target
}
func maskGatewayRules(rules []model.GatewayRule) []model.GatewayRule {
masked := make([]model.GatewayRule, 0, len(rules))
for _, r := range rules {
masked = append(masked, maskGatewayRule(r))
}
return masked
}
// maskConfig 掩码规则配置中的敏感字段
func maskConfig(ruleType, configJSON string) string {
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType); ok {
return meta.Mask(configJSON, ruleMeta.Fields)
}
return configJSON
}
// getRuleOwnerUID 获取定时器规则所属用户的 UID
func getRuleOwnerUID(ruleID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var target model.Rule
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
return 0, err
}
return target.UID, nil
}
// getGatewayRuleOwnerUID 获取消息网关规则所属用户的 UID
func getGatewayRuleOwnerUID(ruleID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var target model.GatewayRule
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
return 0, err
}
return target.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
}
+43 -6
View File
@@ -1,13 +1,15 @@
package user
import (
"encoding/json"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
"golang.org/x/crypto/bcrypt"
@@ -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
}
@@ -176,11 +214,10 @@ func validateNotifyConfig(raw *string) error {
return nil
}
var config struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal([]byte(*raw), &config); err != nil {
return fmt.Errorf("钉钉配置 JSON 格式错误: %v", err)
// 复用钉钉驱动里的解析逻辑,避免两边对配置格式的理解不一致
config, err := dingtalk.ParseAccountConfig(*raw)
if err != nil {
return err
}
if config.AccessToken == "" {
return fmt.Errorf("钉钉配置中 access_token 不能为空")
+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 {
+4 -1
View File
@@ -82,8 +82,11 @@ volumes:
两种数据库之间**没有**自动数据迁移。切换驱动后是一个全新的空库,需要重新注册账号并配置规则。
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `accounts` / `execution_logs`)。
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `gateway_rules` / `message_gateways` / `accounts` / `execution_logs`)。
## 建表
首次启动会自动执行 `AutoMigrate` 建表并补齐新增字段,不需要手动执行 SQL。
升级到 v2.2.0 时,如果 `rules` 表里还有靠 `gateway_id` 标识的历史消息网关规则,
启动阶段会自动把它们搬到 `gateway_rules` 表,不需要手工处理。
+55
View File
@@ -0,0 +1,55 @@
# 消息网关配置
消息网关让外部系统来触发 goodBaby 的动作:为网关生成一个 Webhook 地址,
外部系统往这个地址 POST 一条消息,绑定在该网关上的「网关规则」就会被执行。
## 创建网关
在 WebUI 的「消息网关」页面点击「新建网关」:
| 字段 | 说明 |
| --- | --- |
| 网关类型 | 目前只有 `webhook`,新类型由 `internal/gateway` 注册后自动出现在这里 |
| 网关名称 | 便于识别的名字,例如「生产环境告警」 |
创建后会得到一个地址:
```text
https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook
```
Token 由服务端随机生成,无法在页面上修改;删除网关会连同它的网关规则一起删除。
## 投递消息
```bash
curl -X POST https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook \
-H 'Content-Type: application/json' \
-d '{"title": "数据库告警", "message": "主库连接数超过 90%"}'
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `message` | 是 | 消息正文,会覆盖网关规则配置里的消息字段 |
| `title` | 否 | 消息标题,规则配置里存在 `title` 字段时会被替换 |
响应里会给出本次触发的规则总数与失败明细:
```json
{ "code": 200, "data": { "total": 2, "failed": ["发送邮件: 连接 SMTP 服务器失败"] } }
```
## 网关规则
网关规则在「网关规则」页面维护,和定时器规则是两张表、两个页面,互不影响。
只需配置「发给谁」这类固定信息:名称 / 类型 / 关联账号 / 类型专属配置 / 启用。
**消息标题与内容不用在规则里预先写**:邮件标题、邮件正文、QQ 消息内容、钉钉标题与内容、
B 站动态与私信内容、饭碗警告内容这些字段在「网关规则」页面上不会让用户填写
(页面上只显示一行提示),投递时统一由请求里的 `title` / `message` 填充。
没有消息字段的规则(例如「公开 GitHub 仓库」「删除阿里云 DNS 记录」)也能挂到网关上,
这类规则固定按自己的配置执行,投递的消息只起到触发器的作用。
规则编辑页的「测试」按钮会用一条示例消息走一次真实的投递覆盖,
方便你在执行日志里确认最终发出的内容。
+54
View File
@@ -0,0 +1,54 @@
package alidns
import (
"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 := ParseAccountConfig(config)
if err != nil {
return err
}
if cfg.AK == "" || cfg.SK == "" {
return fmt.Errorf("阿里云账号配置不完整")
}
return nil
}
func (v *AliDNSAccountConfigValidator) Test(config string) error {
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
aliDNSClient, err := newClient(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"},
},
}
}
+13 -12
View File
@@ -2,26 +2,27 @@ package alidns
import (
"context"
"fmt"
"github.com/alibabacloud-go/alidns-20150109/v5/client"
"github.com/ssdomei232/goodBaby/model"
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
"github.com/ssdomei232/goodBaby/internal/retry"
)
func deleteAliDNSRecord(ctx context.Context, rule *model.Rule) error {
aliDNSClient, err := getAliDNSClient(rule)
if err != nil {
return err
}
deleteRecordConfig, err := getDeleteRecordConfig(rule)
// DeleteRecord 用给定账号删除一条阿里云 DNS 解析记录
func DeleteRecord(ctx context.Context, account *AliDNSAccount, config *DeleteRecordConfig) error {
client, err := newClient(account.AK, account.SK)
if err != nil {
return err
}
_, err = aliDNSClient.DeleteDomainRecord(&client.DeleteDomainRecordRequest{
RecordId: &deleteRecordConfig.RecordID,
})
if err != nil {
if err := retry.Do(ctx, func() error {
_, err := client.DeleteDomainRecord(&alidns.DeleteDomainRecordRequest{
RecordId: &config.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"`
+39
View File
@@ -0,0 +1,39 @@
package alidns
import (
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// 阿里云删除记录规则验证器
type AliDNSDeleteRecordRuleValidator struct{}
func (v *AliDNSDeleteRecordRuleValidator) GetType() string {
return RuleTypeDeleteRecord
}
func (v *AliDNSDeleteRecordRuleValidator) Validate(configJSON string) error {
config, err := ParseDeleteRecordConfig(configJSON)
if err != nil {
return 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},
},
}
}
+28
View File
@@ -0,0 +1,28 @@
package alidns
import (
"context"
"github.com/ssdomei232/goodBaby/model"
)
// DeleteAliDNSRecordExecutor 删除阿里云DNS记录执行器
type DeleteAliDNSRecordExecutor struct{}
func (e *DeleteAliDNSRecordExecutor) GetType() string {
return RuleTypeDeleteRecord
}
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
config, err := ParseDeleteRecordConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return DeleteRecord(ctx, account, config)
}
+31 -29
View File
@@ -2,48 +2,50 @@ package alidns
import (
"encoding/json"
"fmt"
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
"github.com/alibabacloud-go/tea/tea"
"github.com/aliyun/credentials-go/credentials"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
func getAliDNSClient(rule *model.Rule) (client *alidns.Client, err error) {
var accountConfig AliDNSAccount
// get config
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
return nil, err
// ParseAccountConfig 解析阿里云账号配置
func ParseAccountConfig(config string) (*AliDNSAccount, error) {
if config == "" {
return nil, fmt.Errorf("解析阿里云账号配置失败: 规则没有关联账号")
}
// init aliyun account config
var account AliDNSAccount
if err := json.Unmarshal([]byte(config), &account); err != nil {
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
}
return &account, nil
}
// ParseDeleteRecordConfig 解析删除解析记录规则配置
func ParseDeleteRecordConfig(configJSON string) (*DeleteRecordConfig, error) {
var config DeleteRecordConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析阿里云删除记录规则配置失败: %v", err)
}
return &config, nil
}
// newClient 用 AccessKey 创建阿里云 DNS 客户端
func newClient(ak string, sk string) (*alidns.Client, error) {
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
}
config := &openapi.Config{}
config.Credential = akCredential
config.Endpoint = tea.String("alidns.aliyuncs.com")
// create aliyun account client
client, _ = alidns.NewClient(config)
return client, nil
}
func getDeleteRecordConfig(rule *model.Rule) (*DeleteRecordConfig, error) {
var deleteRecordConfig DeleteRecordConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &deleteRecordConfig); err != nil {
return nil, err
return nil, fmt.Errorf("创建阿里云凭据失败: %w", err)
}
return &deleteRecordConfig, nil
return alidns.NewClient(&openapi.Config{
Credential: akCredential,
Endpoint: tea.String("alidns.aliyuncs.com"),
})
}
+2 -11
View File
@@ -1,7 +1,6 @@
package bilibili
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
@@ -15,7 +14,7 @@ func (v *BilibiliAccountConfigValidator) GetType() string {
}
func (v *BilibiliAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -29,7 +28,7 @@ func (v *BilibiliAccountConfigValidator) Validate(config string) error {
// Test 用 cookie 拉一次账号信息,验证 cookie 是否还有效
func (v *BilibiliAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -64,11 +63,3 @@ func (v *BilibiliAccountConfigValidator) Meta() meta.AccountMeta {
},
}
}
func parseAccount(config string) (*BiliAccount, error) {
var cfg BiliAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
}
return &cfg, nil
}
+6 -16
View File
@@ -6,32 +6,22 @@ import (
"github.com/CuteReimu/bilibili/v2"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// SendBiliDynamicMsg 在 Bilibili 动态发送消息
// SendDynamic 用给定账号发送一条 B 站动态
//
// 暂时没有处理 429 和 403 的区别对待
func SendBiliDynamicMsg(ctx context.Context, rule *model.Rule) error {
biliClient, err := getBiliClient(rule)
if err != nil {
return fmt.Errorf("获取B站客户端失败: %w", err)
}
biliDynamicConfig, err := getBiliDynamicConfig(rule)
if err != nil {
return fmt.Errorf("获取B站动态配置失败: %w", err)
}
dynamicParams := bilibili.CreateDynamicParam{
func SendDynamic(ctx context.Context, account *BiliAccount, config *BiliDynamicConfig) error {
client := newClient(account.RawCookies)
params := bilibili.CreateDynamicParam{
DynamicId: 0,
Type: 4,
Rid: 0,
Content: biliDynamicConfig.Msg,
Content: config.Msg,
}
if err := retry.Do(ctx, func() error {
_, err := biliClient.CreateDynamic(dynamicParams)
_, err := client.CreateDynamic(params)
return err
}); err != nil {
return fmt.Errorf("发送B站动态失败: %w", err)
+38 -44
View File
@@ -2,54 +2,34 @@ package bilibili
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/CuteReimu/bilibili/v2"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// 私信消息类型:1 为文字
// privateMsgTypeText 私信消息类型:1 为文字
const privateMsgTypeText = 1
// SendBiliPrivateMessage 向配置中的每个 UID 发送一条 B 站私信
func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
client, err := getBiliClient(rule)
if err != nil {
return fmt.Errorf("获取B站客户端失败: %w", err)
}
// SendPrivateMessage 用给定账号向配置中的每个 UID 发送一条 B 站私信
//
// 每个接收者单独重试、互不阻塞,最后汇总失败信息交给上层记录。
func SendPrivateMessage(ctx context.Context, account *BiliAccount, config *BiliPrivateMessageConfig) error {
client := newClient(account.RawCookies)
config, err := getPrivateMessageConfig(rule)
// 发私信需要带上自己的 UID,从账号信息里取
selfUID, err := fetchSelfUID(ctx, client)
if err != nil {
return err
}
// 发私信需要带上自己的 UID,从账号信息里取
account, err := client.GetAccountInformation()
if err != nil {
return fmt.Errorf("获取B站账号信息失败(cookie 可能已失效): %w", err)
}
if account == nil || account.Mid == 0 {
return fmt.Errorf("B站 cookie 无效或已过期")
}
var fails []string
for _, uid := range config.ReceiverUids {
err := retry.Do(ctx, func() error {
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
SenderUid: account.Mid,
ReceiverId: int(uid),
ReceiverType: 1,
MsgType: privateMsgTypeText,
Timestamp: int(time.Now().Unix()),
Content: buildTextContent(config.Msg),
})
return err
})
if err != nil {
if err := retry.Do(ctx, func() error {
return sendPrivateMessage(client, selfUID, uid, config.Msg)
}); err != nil {
fails = append(fails, fmt.Sprintf("UID %d: %v", uid, err))
}
}
@@ -61,20 +41,34 @@ func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
return nil
}
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
func buildTextContent(msg string) string {
payload, err := json.Marshal(map[string]string{"content": msg})
if err != nil {
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
return `{"content":""}`
// fetchSelfUID 取当前账号的 UID,私信接口需要它
func fetchSelfUID(ctx context.Context, client *bilibili.Client) (int, error) {
var selfUID int
if err := retry.Do(ctx, func() error {
info, err := client.GetAccountInformation()
if err != nil {
return err
}
if info == nil || info.Mid == 0 {
return retry.Permanent(fmt.Errorf("B站 cookie 无效或已过期"))
}
selfUID = info.Mid
return nil
}); err != nil {
return 0, fmt.Errorf("获取B站账号信息失败: %w", err)
}
return string(payload)
return selfUID, nil
}
func getPrivateMessageConfig(rule *model.Rule) (*BiliPrivateMessageConfig, error) {
var config BiliPrivateMessageConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &config); err != nil {
return nil, fmt.Errorf("解析B站私信规则配置失败: %w", err)
}
return &config, nil
// sendPrivateMessage 发送一条私信
func sendPrivateMessage(client *bilibili.Client, senderUID int, receiverUID int64, msg string) error {
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
SenderUid: senderUID,
ReceiverId: int(receiverUID),
ReceiverType: 1,
MsgType: privateMsgTypeText,
Timestamp: int(time.Now().Unix()),
Content: buildTextContent(msg),
})
return err
}
+13 -13
View File
@@ -1,7 +1,6 @@
package bilibili
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
@@ -15,9 +14,9 @@ func (v *BilibiliDynamicRuleValidator) GetType() string {
}
func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
var config BiliDynamicConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析B站动态规则配置失败: %v", err)
config, err := ParseDynamicConfig(configJSON)
if err != nil {
return err
}
if config.Msg == "" {
@@ -39,9 +38,9 @@ func (v *BilibiliPrivateMessageRuleValidator) GetType() string {
}
func (v *BilibiliPrivateMessageRuleValidator) Validate(configJSON string) error {
var config BiliPrivateMessageConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析B站私信规则配置失败: %v", err)
config, err := ParsePrivateMessageConfig(configJSON)
if err != nil {
return err
}
if config.Msg == "" {
@@ -77,7 +76,7 @@ func (v *BilibiliPrivateMessageRuleValidator) Meta() meta.RuleMeta {
Placeholder: "2",
Help: "对方空间地址 space.bilibili.com/ 后面的那串数字",
},
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true},
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
},
}
}
@@ -91,11 +90,12 @@ func (v *BilibiliDynamicRuleValidator) Meta() meta.RuleMeta {
AccountType: AccountType,
Fields: []meta.Field{
{
Key: "msg",
Label: "动态内容",
Type: meta.FieldTextarea,
Required: true,
Placeholder: "要发送的动态正文",
Key: "msg",
Label: "动态内容",
Type: meta.FieldTextarea,
Required: true,
GatewayMessage: true,
Placeholder: "要发送的动态正文",
},
},
}
+22 -7
View File
@@ -2,7 +2,6 @@ package bilibili
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
@@ -14,10 +13,18 @@ func (e *BilibiliDynamicExecutor) GetType() string {
return RuleTypeDynamic
}
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行B站动态规则: %s (ID: %d)", rule.Name, rule.ID)
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
return SendBiliDynamicMsg(ctx, rule)
config, err := ParseDynamicConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return SendDynamic(ctx, account, config)
}
// BilibiliPrivateMessageExecutor B站私信执行器
@@ -27,8 +34,16 @@ func (e *BilibiliPrivateMessageExecutor) GetType() string {
return RuleTypePrivateMessage
}
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行B站私信规则: %s (ID: %d)", rule.Name, rule.ID)
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
return SendBiliPrivateMessage(ctx, rule)
config, err := ParsePrivateMessageConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return SendPrivateMessage(ctx, account, config)
}
+40 -19
View File
@@ -2,34 +2,55 @@ package bilibili
import (
"encoding/json"
"fmt"
"github.com/CuteReimu/bilibili/v2"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
// ParseAccountConfig 解析 B 站账号配置
func ParseAccountConfig(config string) (*BiliAccount, error) {
if config == "" {
return nil, fmt.Errorf("解析B站账号配置失败: 规则没有关联账号")
}
var account BiliAccount
if err := json.Unmarshal([]byte(config), &account); err != nil {
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
}
return &account, nil
}
// ParseDynamicConfig 解析 B 站动态规则配置
func ParseDynamicConfig(configJSON string) (*BiliDynamicConfig, error) {
var config BiliDynamicConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析B站动态规则配置失败: %v", err)
}
return &config, nil
}
// ParsePrivateMessageConfig 解析 B 站私信规则配置
func ParsePrivateMessageConfig(configJSON string) (*BiliPrivateMessageConfig, error) {
var config BiliPrivateMessageConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析B站私信规则配置失败: %v", err)
}
return &config, nil
}
// newClient 按账号配置创建 B 站客户端
func newClient(rawCookies string) *bilibili.Client {
client := bilibili.New()
client.SetRawCookies(rawCookies)
return client
}
// 获取 Bilibili Client
func getBiliClient(rule *model.Rule) (*bilibili.Client, error) {
var accountConfig BiliAccount
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
return nil, err
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
func buildTextContent(msg string) string {
payload, err := json.Marshal(map[string]string{"content": msg})
if err != nil {
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
return `{"content":""}`
}
return newClient(accountConfig.RawCookies), nil
}
// 获取 Bilibili Dynamic Config
func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) {
var biliDynamicConfig BiliDynamicConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig); err != nil {
return nil, err
}
return &biliDynamicConfig, nil
return string(payload)
}
+30 -11
View File
@@ -2,32 +2,51 @@
package dingtalk
import (
"context"
"fmt"
"github.com/CatchZeng/dingtalk/pkg/dingtalk"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// ErrNoUserConfig 表示用户没有配置钉钉提醒
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
// SendDingTalkMsg 使用用户配置的钉钉机器人发送提醒
//
// 用户没有配置钉钉机器人时返回 ErrNoUserConfig
func SendDingTalkMsg(user *model.User, title string, msg string) error {
config := getDingTalkConfigFromUser(user)
if config == nil {
if user.DingTalkConfig == nil {
return ErrNoUserConfig
}
return send(config, title, msg)
}
// ErrNoUserConfig 表示用户没有配置钉钉提醒
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
config, err := ParseAccountConfig(*user.DingTalkConfig)
if err != nil {
return err
}
func send(config *DingTalkConfig, title, msg string) error {
client := dingtalk.NewClient(config.AccessToken, config.Secret)
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
if _, _, err := client.Send(message); err != nil {
if err := send(config, title, msg); err != nil {
return fmt.Errorf("发送钉钉消息失败: %w", err)
}
return nil
}
// SendMessage 用规则里配置的机器人发送消息
func SendMessage(ctx context.Context, config *DingTalkRuleConfig) error {
if err := retry.Do(ctx, func() error {
return send(&config.DingTalkConfig, config.Title, config.Msg)
}); err != nil {
return fmt.Errorf("发送钉钉消息失败: %w", err)
}
return nil
}
// send 调用一次机器人接口
func send(config *DingTalkConfig, title, msg string) error {
client := dingtalk.NewClient(config.AccessToken, config.Secret)
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
_, _, err := client.Send(message)
return err
}
+2 -12
View File
@@ -1,7 +1,6 @@
package dingtalk
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
@@ -42,17 +41,8 @@ func (v *DingTalkRuleValidator) Meta() meta.RuleMeta {
Fields: []meta.Field{
{Key: "access_token", Label: "Access Token", Type: meta.FieldPassword, Required: true, Secret: true, Help: "机器人 Webhook 中 access_token 参数的值"},
{Key: "secret", Label: "加签 Secret", Type: meta.FieldPassword, Secret: true, Help: "机器人安全设置选择“加签”时填写"},
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true},
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "支持 Markdown"},
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true, GatewayMessage: true},
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true, Help: "支持 Markdown"},
},
}
}
// ParseRuleConfig 解析钉钉规则配置
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
var config DingTalkRuleConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
}
return &config, nil
}
+4 -9
View File
@@ -2,9 +2,7 @@ package dingtalk
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
@@ -15,15 +13,12 @@ func (e *DingTalkExecutor) GetType() string {
return RuleType
}
func (e *DingTalkExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行钉钉规则: %s (ID: %d)", rule.Name, rule.ID)
config, err := ParseRuleConfig(rule.ConfigJson)
func (e *DingTalkExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
// 钉钉机器人凭据直接写在规则配置里,不需要关联账号
config, err := ParseRuleConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return retry.Do(ctx, func() error {
return send(&config.DingTalkConfig, config.Title, config.Msg)
})
return SendMessage(ctx, config)
}
+18 -10
View File
@@ -2,19 +2,27 @@ package dingtalk
import (
"encoding/json"
"github.com/ssdomei232/goodBaby/model"
"fmt"
)
func getDingTalkConfigFromUser(user *model.User) *DingTalkConfig {
if user.DingTalkConfig == nil {
return nil
// ParseAccountConfig 解析钉钉机器人凭据配置
func ParseAccountConfig(config string) (*DingTalkConfig, error) {
if config == "" {
return nil, fmt.Errorf("解析钉钉机器人配置失败: 配置为空")
}
var config DingTalkConfig
err := json.Unmarshal([]byte(*user.DingTalkConfig), &config)
if err != nil {
return nil
var account DingTalkConfig
if err := json.Unmarshal([]byte(config), &account); err != nil {
return nil, fmt.Errorf("解析钉钉机器人配置失败: %v", err)
}
return &config
return &account, nil
}
// ParseRuleConfig 解析钉钉规则配置
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
var config DingTalkRuleConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
}
return &config, nil
}
+2 -11
View File
@@ -2,7 +2,6 @@ package email
import (
"context"
"encoding/json"
"fmt"
"net/mail"
@@ -18,7 +17,7 @@ func (v *EmailAccountConfigValidator) GetType() string {
}
func (v *EmailAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccountConfig(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -56,7 +55,7 @@ func (v *EmailAccountConfigValidator) Validate(config string) error {
// Test 连接 SMTP 服务器并完成认证;填写了测试收件地址时会真的发一封测试邮件
func (v *EmailAccountConfigValidator) Test(config string) error {
cfg, err := parseAccountConfig(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -103,11 +102,3 @@ func (v *EmailAccountConfigValidator) Meta() meta.AccountMeta {
},
}
}
func parseAccountConfig(config string) (*EmailAccountConfig, error) {
var cfg EmailAccountConfig
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
}
return &cfg, nil
}
+12 -17
View File
@@ -3,38 +3,31 @@ package email
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"github.com/ssdomei232/goodBaby/model"
"github.com/ssdomei232/goodBaby/internal/retry"
)
// SendMail 向规则配置里的所有地址发送邮件。
// SendMail 用给定 SMTP 账号向规则配置里的所有地址发送邮件。
//
// 每个地址单独重试,互不阻塞;全部失败/部分失败都会汇总成错误返回,
// 每个地址单独重试、互不阻塞;全部失败/部分失败都会汇总成错误返回,
// 由 runner 记录到执行日志中。
func SendMail(ctx context.Context, rule *model.Rule) error {
emailRule, err := GetEmailRuleFromRule(rule)
if err != nil {
return fmt.Errorf("获取邮件规则配置失败: %w", err)
}
accountConfig, err := GetEmailAccountFromRule(rule)
if err != nil {
return fmt.Errorf("获取邮件账户配置失败: %w", err)
}
func SendMail(ctx context.Context, account *EmailAccountConfig, config *EmailRule) error {
var (
wg sync.WaitGroup
mu sync.Mutex
fails []string
)
for _, destination := range emailRule.Destinations {
for _, destination := range config.Destinations {
wg.Add(1)
go func(address string) {
defer wg.Done()
if err := sendMailMsgWithRetry(ctx, accountConfig, emailRule, address); err != nil {
if err := retry.Do(ctx, func() error {
return sendMail(ctx, account, config, address)
}); err != nil {
mu.Lock()
fails = append(fails, fmt.Sprintf("%s: %v", address, err))
mu.Unlock()
@@ -44,8 +37,10 @@ func SendMail(ctx context.Context, rule *model.Rule) error {
wg.Wait()
if len(fails) > 0 {
// 并发发送的顺序不确定,排序后错误信息才是稳定的
sort.Strings(fails)
return fmt.Errorf("%d/%d 封邮件发送失败: %s",
len(fails), len(emailRule.Destinations), strings.Join(fails, "; "))
len(fails), len(config.Destinations), strings.Join(fails, "; "))
}
return nil
}
+5 -6
View File
@@ -1,7 +1,6 @@
package email
import (
"encoding/json"
"fmt"
"net/mail"
@@ -16,9 +15,9 @@ func (v *EmailRuleValidator) GetType() string {
}
func (v *EmailRuleValidator) Validate(configJSON string) error {
var config EmailRule
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析邮件规则配置失败: %v", err)
config, err := ParseRuleConfig(configJSON)
if err != nil {
return err
}
if config.Title == "" {
@@ -51,8 +50,8 @@ func (v *EmailRuleValidator) Meta() meta.RuleMeta {
Docs: "docs/email-config.md",
AccountType: AccountType,
Fields: []meta.Field{
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true},
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true},
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true, GatewayMessage: true},
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
{Key: "destinations", Label: "收件人", Type: meta.FieldStringList, Required: true, Placeholder: "someone@example.com"},
},
}
+11 -4
View File
@@ -2,7 +2,6 @@ package email
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
@@ -14,8 +13,16 @@ func (e *EmailExecutor) GetType() string {
return RuleType
}
func (e *EmailExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行邮件规则: %s (ID: %d)", rule.Name, rule.ID)
func (e *EmailExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
return SendMail(ctx, rule)
config, err := ParseRuleConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return SendMail(ctx, account, config)
}
+20 -23
View File
@@ -5,28 +5,29 @@ import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
"github.com/wneessen/go-mail"
)
// GetEmailAccountFromRule 从 Rule 中获取 EmailAccount 配置
func GetEmailAccountFromRule(rule *model.Rule) (*EmailAccountConfig, error) {
var emailAccountConfig EmailAccountConfig
if err := db.LoadAccountConfig(rule.AccountID, &emailAccountConfig); err != nil {
return nil, err
// ParseAccountConfig 解析邮箱账号配置
func ParseAccountConfig(config string) (*EmailAccountConfig, error) {
if config == "" {
return nil, fmt.Errorf("解析邮箱账号配置失败: 规则没有关联账号")
}
return &emailAccountConfig, nil
var account EmailAccountConfig
if err := json.Unmarshal([]byte(config), &account); err != nil {
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
}
return &account, nil
}
// GetEmailRuleFromRule 从 Rule 中获取 EmailRule 配置
func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) {
var emailRule EmailRule
if err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule); err != nil {
return nil, err
// ParseRuleConfig 解析邮件规则配置
func ParseRuleConfig(configJSON string) (*EmailRule, error) {
var config EmailRule
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析邮件规则配置失败: %v", err)
}
return &emailRule, nil
return &config, nil
}
// newSMTPClient 按账号配置里的加密方式创建 SMTP 客户端
@@ -54,6 +55,7 @@ func newSMTPClient(cfg *EmailAccountConfig) (*mail.Client, error) {
return client, nil
}
// buildMessage 组装一封邮件
func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) {
message := mail.NewMsg()
if err := message.From(cfg.FromOrDefault()); err != nil {
@@ -67,19 +69,14 @@ func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.M
return message, nil
}
func sendMailMsgWithRetry(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
return retry.Do(ctx, func() error {
return sendMailMsg(ctx, cfg, rule, address)
})
}
func sendMailMsg(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
// sendMail 给一个地址发送一封邮件
func sendMail(ctx context.Context, cfg *EmailAccountConfig, config *EmailRule, address string) error {
client, err := newSMTPClient(cfg)
if err != nil {
return err
}
message, err := buildMessage(cfg, address, rule.Title, rule.Msg)
message, err := buildMessage(cfg, address, config.Title, config.Msg)
if err != nil {
return err
}
+17 -10
View File
@@ -1,26 +1,33 @@
package fwalert
import (
"context"
"fmt"
"github.com/go-resty/resty/v2"
"github.com/ssdomei232/goodBaby/internal/retry"
)
// send 发送消息
// Send 发送一条饭碗警告
func Send(ctx context.Context, config *FwAlertRuleConfig) error {
if err := retry.Do(ctx, func() error {
return send(config)
}); err != nil {
return fmt.Errorf("发送饭碗警告失败: %w", err)
}
return nil
}
// send 调用一次 Webhook
func send(config *FwAlertRuleConfig) error {
restyClient := resty.New()
req := restyClient.R()
req.SetBody(FwAlertRequest{
Message: config.Msg,
})
resp, err := req.Execute(resty.MethodPost, config.WebhookURL)
resp, err := resty.New().R().
SetBody(FwAlertRequest{Message: config.Msg}).
Execute(resty.MethodPost, config.WebhookURL)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return fmt.Errorf("发送饭碗警告失败: %s", resp.Status())
return fmt.Errorf("Webhook 返回状态码 %s", resp.Status())
}
return nil
}
+2 -1
View File
@@ -1,6 +1,7 @@
package fwalert
const RuleTypeFwalert = "fwalert"
// RuleType 饭碗警告规则类型标识
const RuleType = "fwalert"
type FwAlertRuleConfig struct {
WebhookURL string `json:"webhook_url"`
@@ -1,23 +1,23 @@
package fwalert
import (
"encoding/json"
"fmt"
"net/url"
"github.com/ssdomei232/goodBaby/internal/meta"
)
// FwalertRuleValidator 饭碗警告规则验证器
type FwalertRuleValidator struct{}
func (v *FwalertRuleValidator) GetType() string {
return RuleTypeFwalert
return RuleType
}
func (v *FwalertRuleValidator) Validate(configJSON string) error {
var config FwAlertRuleConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
config, err := ParseRuleConfig(configJSON)
if err != nil {
return err
}
parsed, err := url.Parse(config.WebhookURL)
@@ -40,23 +40,14 @@ func (v *FwalertRuleValidator) Validate(configJSON string) error {
func (v *FwalertRuleValidator) Meta() meta.RuleMeta {
return meta.RuleMeta{
Type: RuleTypeFwalert,
Type: RuleType,
Label: "发送饭碗警告",
Description: "触发时发送饭碗警告消息。",
Docs: "docs/fwalert-config.md",
AccountType: "",
Fields: []meta.Field{
{Key: "webhook_url", Label: "Webhook URL", Type: meta.FieldString, Required: true, Help: "饭碗警告的触发地址", Placeholder: "https://fwalert.com/xxx-xxx-xxx-xxx-xxx"},
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "最大长度 1000 个字符"},
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true, Help: "最大长度 1000 个字符"},
},
}
}
// ParseRuleConfig 解析饭碗警告规则配置
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
var config FwAlertRuleConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
}
return &config, nil
}
+5 -10
View File
@@ -2,9 +2,7 @@ package fwalert
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
@@ -12,18 +10,15 @@ import (
type FwalertExecutor struct{}
func (e *FwalertExecutor) GetType() string {
return RuleTypeFwalert
return RuleType
}
func (e *FwalertExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行饭碗警告规则: %s (ID: %d)", rule.Name, rule.ID)
config, err := ParseRuleConfig(rule.ConfigJson)
func (e *FwalertExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
// 饭碗警告的凭据就是 Webhook 地址,直接写在规则配置里
config, err := ParseRuleConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return retry.Do(ctx, func() error {
return send(config)
})
return Send(ctx, config)
}
+15
View File
@@ -0,0 +1,15 @@
package fwalert
import (
"encoding/json"
"fmt"
)
// ParseRuleConfig 解析饭碗警告规则配置
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
var config FwAlertRuleConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
}
return &config, nil
}
+2 -11
View File
@@ -2,7 +2,6 @@ package github
import (
"context"
"encoding/json"
"fmt"
"github.com/google/go-github/v84/github"
@@ -18,7 +17,7 @@ func (v *GitHubAccountConfigValidator) GetType() string {
}
func (v *GitHubAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -35,7 +34,7 @@ func (v *GitHubAccountConfigValidator) Validate(config string) error {
// Test 用 token 拉一次当前用户信息,验证 token 是否有效
func (v *GitHubAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -65,11 +64,3 @@ func (v *GitHubAccountConfigValidator) Meta() meta.AccountMeta {
},
}
}
func parseAccount(config string) (*GithubAccount, error) {
var cfg GithubAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
}
return &cfg, nil
}
+9 -14
View File
@@ -6,29 +6,24 @@ import (
"strings"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// MakeRepositoryPublic 将规则中配置的仓库设置为 public
func MakeRepositoryPublic(ctx context.Context, rule *model.Rule) error {
reposConfig, account, err := GetGithubReposAndAccountFromRule(rule)
if err != nil {
return err
}
// MakeRepositoryPublic 把配置里的仓库逐个设置为 public
//
// 每个仓库单独重试、互不阻塞,最后汇总失败信息交给上层记录。
func MakeRepositoryPublic(ctx context.Context, account *GithubAccount, config *GithubReposConfig) error {
var fails []string
for _, repo := range reposConfig.Repos {
err := retry.Do(ctx, func() error {
for _, repo := range config.Repos {
if err := retry.Do(ctx, func() error {
return SetRepositoryPublic(ctx, account.Token, account.Owner, repo)
})
if err != nil {
fails = append(fails, fmt.Sprintf("%s: %v", repo, err))
}); err != nil {
fails = append(fails, err.Error())
}
}
if len(fails) > 0 {
return fmt.Errorf("%d/%d 个仓库设置为 public 失败: %s",
len(fails), len(reposConfig.Repos), strings.Join(fails, "; "))
len(fails), len(config.Repos), strings.Join(fails, "; "))
}
return nil
}
+3 -4
View File
@@ -1,7 +1,6 @@
package github
import (
"encoding/json"
"fmt"
"strings"
@@ -15,9 +14,9 @@ func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string {
}
func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error {
var config GithubReposConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析 GitHub 规则配置失败: %v", err)
config, err := ParseReposConfig(configJSON)
if err != nil {
return err
}
if len(config.Repos) == 0 {
+11 -4
View File
@@ -2,7 +2,6 @@ package github
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
@@ -14,8 +13,16 @@ func (e *GithubMakeRepoPublicExecutor) GetType() string {
return RuleTypeMakeRepoPublic
}
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行GitHub仓库公开规则: %s (ID: %d)", rule.Name, rule.ID)
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
return MakeRepositoryPublic(ctx, rule)
config, err := ParseReposConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return MakeRepositoryPublic(ctx, account, config)
}
+22 -18
View File
@@ -6,33 +6,37 @@ import (
"fmt"
"github.com/google/go-github/v84/github"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
// GetGithubReposAndAccountFromRule 从 rule 中获取 GithubReposConfig 和 GithubAccount
func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount, error) {
var githubReposConfig GithubReposConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig); err != nil {
return nil, nil, fmt.Errorf("解析 GitHub 规则配置失败: %w", err)
// ParseAccountConfig 解析 GitHub 账号配置
func ParseAccountConfig(config string) (*GithubAccount, error) {
if config == "" {
return nil, fmt.Errorf("解析GitHub账号配置失败: 规则没有关联账号")
}
var githubAccount GithubAccount
if err := db.LoadAccountConfig(rule.AccountID, &githubAccount); err != nil {
return nil, nil, err
var account GithubAccount
if err := json.Unmarshal([]byte(config), &account); err != nil {
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
}
return &githubReposConfig, &githubAccount, nil
return &account, nil
}
// SetRepositoryPublic 将仓库设置为 public
// ParseReposConfig 解析仓库规则配置
func ParseReposConfig(configJSON string) (*GithubReposConfig, error) {
var config GithubReposConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析GitHub规则配置失败: %v", err)
}
return &config, nil
}
// SetRepositoryPublic 把一个仓库设置为 public
func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
client := github.NewClient(nil).WithAuthToken(token)
opts := &github.Repository{
if _, _, err := client.Repositories.Edit(ctx, owner, repo, &github.Repository{
Visibility: github.Ptr("public"),
}); err != nil {
return fmt.Errorf("设置仓库 %s 为 public 失败: %w", repo, err)
}
_, _, err := client.Repositories.Edit(ctx, owner, repo, opts)
return err
return nil
}
+2 -11
View File
@@ -1,7 +1,6 @@
package onebot
import (
"encoding/json"
"fmt"
"net/url"
"strings"
@@ -17,7 +16,7 @@ func (v *OneBotAccountConfigValidator) GetType() string {
}
func (v *OneBotAccountConfigValidator) Validate(config string) error {
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -39,7 +38,7 @@ func (v *OneBotAccountConfigValidator) Validate(config string) error {
// Test 调用 get_login_info 验证地址与 Token
func (v *OneBotAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -62,11 +61,3 @@ func (v *OneBotAccountConfigValidator) Meta() meta.AccountMeta {
},
}
}
func parseAccount(config string) (*OneBotAccount, error) {
var cfg OneBotAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
}
return &cfg, nil
}
+12 -23
View File
@@ -6,40 +6,29 @@ import (
"strings"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
)
// SendOneBotMsg 向规则中配置的群和好友发送消息
func SendOneBotMsg(ctx context.Context, rule *model.Rule) error {
oneBotConfig, err := getOneBotConfigFromRule(rule)
if err != nil {
return err
}
oneBotAccount, err := getOneBotAccountFromRule(rule)
if err != nil {
return fmt.Errorf("获取OneBot账户失败: %w", err)
}
// SendMessage 向规则中配置的群和好友发送消息
//
// 每个目标单独重试、互不阻塞,最后汇总失败信息交给上层记录。
func SendMessage(ctx context.Context, account *OneBotAccount, config *OneBotConfig) error {
var fails []string
total := 0
for _, groupID := range oneBotConfig.SendGroups {
for _, groupID := range config.SendGroups {
total++
err := retry.Do(ctx, func() error {
return sendGroupMsg(oneBotAccount, groupID, oneBotConfig.Msg)
})
if err != nil {
if err := retry.Do(ctx, func() error {
return sendGroupMsg(account, groupID, config.Msg)
}); err != nil {
fails = append(fails, fmt.Sprintf("群 %d: %v", groupID, err))
}
}
for _, userID := range oneBotConfig.SendUsers {
for _, userID := range config.SendUsers {
total++
err := retry.Do(ctx, func() error {
return sendPrivateMsg(oneBotAccount, userID, oneBotConfig.Msg)
})
if err != nil {
if err := retry.Do(ctx, func() error {
return sendPrivateMsg(account, userID, config.Msg)
}); err != nil {
fails = append(fails, fmt.Sprintf("好友 %d: %v", userID, err))
}
}
+4 -5
View File
@@ -1,7 +1,6 @@
package onebot
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
@@ -15,9 +14,9 @@ func (v *OneBotRuleValidator) GetType() string {
}
func (v *OneBotRuleValidator) Validate(configJSON string) error {
var config OneBotConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析OneBot规则配置失败: %v", err)
config, err := ParseRuleConfig(configJSON)
if err != nil {
return err
}
if config.Msg == "" {
@@ -44,7 +43,7 @@ func (v *OneBotRuleValidator) Meta() meta.RuleMeta {
Description: "触发时通过 OneBot 向指定的群或好友发送消息。",
AccountType: AccountType,
Fields: []meta.Field{
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true},
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
{Key: "send_groups", Label: "群号", Type: meta.FieldNumberList, Placeholder: "123456789"},
{Key: "send_users", Label: "好友 QQ 号", Type: meta.FieldNumberList, Placeholder: "123456789"},
},
+11 -4
View File
@@ -2,7 +2,6 @@ package onebot
import (
"context"
"log"
"github.com/ssdomei232/goodBaby/model"
)
@@ -14,8 +13,16 @@ func (e *OneBotExecutor) GetType() string {
return RuleType
}
func (e *OneBotExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行OneBot规则: %s (ID: %d)", rule.Name, rule.ID)
func (e *OneBotExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
return SendOneBotMsg(ctx, rule)
config, err := ParseRuleConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return SendMessage(ctx, account, config)
}
+29 -25
View File
@@ -4,37 +4,37 @@ import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
napcat "github.com/ssdomei232/napcat-http-go-sdk"
)
// ParseAccountConfig 解析 OneBot 账号配置
func ParseAccountConfig(config string) (*OneBotAccount, error) {
if config == "" {
return nil, fmt.Errorf("解析OneBot账号配置失败: 规则没有关联账号")
}
var account OneBotAccount
if err := json.Unmarshal([]byte(config), &account); err != nil {
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
}
return &account, nil
}
// ParseRuleConfig 解析 OneBot 规则配置
func ParseRuleConfig(configJSON string) (*OneBotConfig, error) {
var config OneBotConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析OneBot规则配置失败: %v", err)
}
return &config, nil
}
// newClient 创建 OneBot HTTP 客户端
func newClient(account *OneBotAccount) *napcat.Client {
return napcat.NewClient(account.Token, account.URL)
}
// 从 Rule 中获取 OneBotConfig
func getOneBotConfigFromRule(rule *model.Rule) (*OneBotConfig, error) {
var oneBotConfig OneBotConfig
if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil {
return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err)
}
return &oneBotConfig, nil
}
// 从 Rule 中获取 OneBotAccount
func getOneBotAccountFromRule(rule *model.Rule) (*OneBotAccount, error) {
var oneBotAccount OneBotAccount
if err := db.LoadAccountConfig(rule.AccountID, &oneBotAccount); err != nil {
return nil, err
}
return &oneBotAccount, nil
}
// 发送一条群消息
//
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
// sendGroupMsg 发送一条群消息
func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
return call(account, "/send_group_msg", map[string]any{
"group_id": groupID,
@@ -42,7 +42,7 @@ func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
})
}
// 发送一条私聊消息
// sendPrivateMsg 发送一条私聊消息
func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
return call(account, "/send_private_msg", map[string]any{
"user_id": userID,
@@ -50,6 +50,10 @@ func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
})
}
// call 调用一次 OneBot HTTP API。
//
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
func call(account *OneBotAccount, endpoint string, payload map[string]any) error {
var resp apiResponse
if err := newClient(account).DoRequest("POST", endpoint, payload, &resp); err != nil {
+3 -14
View File
@@ -1,7 +1,6 @@
package rainyun
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
@@ -12,13 +11,11 @@ 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 {
// 解析配置
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
@@ -30,16 +27,8 @@ func (v *RainyunAccountConfigValidator) Validate(config string) error {
return nil
}
func parseAccount(config string) (*RainyunAccount, error) {
var cfg RainyunAccount
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
}
return &cfg, nil
}
func (v *RainyunAccountConfigValidator) Test(config string) error {
cfg, err := parseAccount(config)
cfg, err := ParseAccountConfig(config)
if err != nil {
return err
}
+9 -2
View File
@@ -3,10 +3,17 @@ package rainyun
const (
// AccountType Rainyun 账号类型标识
AccountType = "rainyun"
// RuleType 发送 Rainyun 消息的规则类型标识
RuleType = "rainyun-workorder"
// RuleTypeWorkOrder 发送 Rainyun 工单消息的规则类型标识
RuleTypeWorkOrder = "rainyun-workorder"
RuleTypeRunAway = "rainyun-runaway"
)
// pageSize 拉取云服务器列表时每页的条数
const pageSize = 20
// runAwayConfirmText 跑路规则要求用户输入的免责声明
const runAwayConfirmText = "我已知晓"
type RainyunWorkOrderRule struct {
Title string `json:"title"`
Msg string `json:"msg"`
+114 -88
View File
@@ -6,121 +6,147 @@ import (
"strings"
"github.com/ssdomei232/goodBaby/internal/retry"
"github.com/ssdomei232/goodBaby/model"
rain "github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/common"
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/workorder"
)
func SendRainyunWorkorderMsg(ctx context.Context, rule *model.Rule) error {
rainyunConfig, err := getRainyunConfigFromRule(rule)
if err != nil {
return err
// SendWorkOrder 用给定账号发送一条雨云工单
func SendWorkOrder(ctx context.Context, account *RainyunAccount, config *RainyunWorkOrderRule) error {
if err := retry.Do(ctx, func() error {
return createWorkOrder(account, config)
}); err != nil {
return fmt.Errorf("发送雨云工单失败: %w", err)
}
rainyunAccount, err := getRainyunAccountFromRule(rule)
if err != nil {
return err
}
err = retry.Do(ctx, func() error {
return sendRainyunWorkorderMsg(rainyunConfig, rainyunAccount)
})
if err != nil {
return err
}
return nil
}
func sendRainyunWorkorderMsg(rainyunRuleConfig *RainyunWorkOrderRule, rainyunAccount *RainyunAccount) error {
client := rain.NewClient(rainyunAccount.APIKey)
workorderClient := workorder.Client{Client: client}
// createWorkOrder 调用一次工单接口
func createWorkOrder(account *RainyunAccount, config *RainyunWorkOrderRule) error {
workorderClient := workorder.Client{Client: rain.NewClient(account.APIKey)}
_, err := workorderClient.CreateWorkOrder(&workorder.CreateWorkerorderRequest{
Content: rainyunRuleConfig.Msg,
Content: config.Msg,
IsAuthed: false,
IsUrgent: 1,
Title: rainyunRuleConfig.Title,
Title: config.Title,
Type: "reward",
})
if err != nil {
return err
}
return nil
return err
}
func RainyunRunAway(ctx context.Context, rule *model.Rule) error {
rainyunAccount, err := getRainyunAccountFromRule(rule)
// RunAway 重装账号下所有云服务器(一键跑路)
func RunAway(ctx context.Context, account *RainyunAccount) error {
client := rain.NewClient(account.APIKey)
rcsFails, rcsTotal, err := reinstallAllRcs(ctx, client)
if err != nil {
return err
}
client := rain.NewClient(rainyunAccount.APIKey)
var fails []string
total := 0
// 重装所有RCS
rcsClient := rcs.Client{Client: client}
options := rain.EncodingStandardQueryParameters(1, 20)
rcsList, err := rcsClient.GetRcsList(options)
rgsFails, rgsTotal, err := reinstallAllRgs(ctx, client)
if err != nil {
return err
}
if rcsList.Data.TotalRecords == 0 {
return nil
} else if rcsList.Data.TotalRecords > 20 {
rcsTotal := rcsList.Data.TotalRecords
options := rain.EncodingStandardQueryParameters(1, rcsTotal)
rcsList, err = rcsClient.GetRcsList(options)
if err != nil {
return err
}
}
for _, rcs := range rcsList.Data.Records {
total++
err := retry.Do(context.Background(), func() error {
return reinstallOneRCS(rcs.ID, &rcsClient)
})
if err != nil {
fails = append(fails, fmt.Sprintf("RCS %d: %v", rcs.ID, err))
}
}
// 重装所有RGS
rgsClient := rgs.Client{Client: client}
rgsOptions := rain.EncodingStandardQueryParameters(1, 20)
rgsList, err := rgsClient.GetRgsList(rgsOptions)
if err != nil {
return err
}
if rgsList.Data.TotalRecords == 0 {
return nil
} else if rgsList.Data.TotalRecords > 20 {
rgsTotal := rgsList.Data.TotalRecords
rgsOptions := rain.EncodingStandardQueryParameters(1, rgsTotal)
rgsList, err = rgsClient.GetRgsList(rgsOptions)
if err != nil {
return err
}
}
for _, rgs := range rgsList.Data.Records {
total++
err := retry.Do(context.Background(), func() error {
return reinstallOneRGS(rgs.ID, &rgsClient)
})
if err != nil {
fails = append(fails, fmt.Sprintf("RGS %d: %v", rgs.ID, err))
}
}
fails := append(rcsFails, rgsFails...)
total := rcsTotal + rgsTotal
if len(fails) > 0 {
return fmt.Errorf("%d/%d 台云服务器重装失败: %s", len(fails), total, strings.Join(fails, "; "))
}
return nil
}
// reinstallAllRcs 重装全部 RCS,返回失败信息与云服务器总数
func reinstallAllRcs(ctx context.Context, client *rain.Client) ([]string, int, error) {
rcsClient := rcs.Client{Client: client}
ids, err := listRcsIDs(&rcsClient)
if err != nil {
return nil, 0, err
}
var fails []string
for _, id := range ids {
if err := retry.Do(ctx, func() error {
return reinstallOneRCS(id, &rcsClient)
}); err != nil {
fails = append(fails, fmt.Sprintf("RCS %d: %v", id, err))
}
}
return fails, len(ids), nil
}
// reinstallAllRgs 重装全部 RGS,返回失败信息与云服务器总数
func reinstallAllRgs(ctx context.Context, client *rain.Client) ([]string, int, error) {
rgsClient := rgs.Client{Client: client}
ids, err := listRgsIDs(&rgsClient)
if err != nil {
return nil, 0, err
}
var fails []string
for _, id := range ids {
if err := retry.Do(ctx, func() error {
return reinstallOneRGS(id, &rgsClient)
}); err != nil {
fails = append(fails, fmt.Sprintf("RGS %d: %v", id, err))
}
}
return fails, len(ids), nil
}
// listRcsIDs 取账号下全部 RCS 的 ID,超过一页时重新按总数拉取
func listRcsIDs(client *rcs.Client) ([]int, error) {
first, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, pageSize))
if err != nil {
return nil, err
}
ids := make([]int, 0, len(first.Data.Records))
for _, server := range first.Data.Records {
ids = append(ids, server.ID)
}
if first.Data.TotalRecords <= pageSize {
return ids, nil
}
all, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
if err != nil {
return nil, err
}
ids = ids[:0]
for _, server := range all.Data.Records {
ids = append(ids, server.ID)
}
return ids, nil
}
// listRgsIDs 取账号下全部 RGS 的 ID,超过一页时重新按总数拉取
func listRgsIDs(client *rgs.Client) ([]int, error) {
first, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, pageSize))
if err != nil {
return nil, err
}
ids := make([]int, 0, len(first.Data.Records))
for _, server := range first.Data.Records {
ids = append(ids, server.ID)
}
if first.Data.TotalRecords <= pageSize {
return ids, nil
}
all, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
if err != nil {
return nil, err
}
ids = ids[:0]
for _, server := range all.Data.Records {
ids = append(ids, server.ID)
}
return ids, nil
}
+11 -12
View File
@@ -1,7 +1,6 @@
package rainyun
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/internal/meta"
@@ -11,13 +10,13 @@ import (
type RainyunWorkorderRuleValidator struct{}
func (v *RainyunWorkorderRuleValidator) GetType() string {
return RuleType
return RuleTypeWorkOrder
}
func (v *RainyunWorkorderRuleValidator) Validate(configJSON string) error {
var config RainyunWorkOrderRule
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析雨云规则配置失败: %v", err)
config, err := ParseWorkOrderConfig(configJSON)
if err != nil {
return err
}
if config.Title == "" {
@@ -33,7 +32,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,16 +46,16 @@ 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 {
var config RainyunRunAwayRule
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return fmt.Errorf("解析雨云规则配置失败: %v", err)
config, err := ParseRunAwayConfig(configJSON)
if err != nil {
return err
}
if config.IK != "我已知晓" {
if config.IK != runAwayConfirmText {
return fmt.Errorf("请输入正确内容")
}
@@ -65,7 +64,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,
+31 -10
View File
@@ -2,32 +2,53 @@ package rainyun
import (
"context"
"log"
"fmt"
"github.com/ssdomei232/goodBaby/model"
)
// RainyunExecutor 雨云执行器
// RainyunWorkorderExecutor 雨云工单执行器
type RainyunWorkorderExecutor struct{}
func (e *RainyunWorkorderExecutor) GetType() string {
return RuleType
return RuleTypeWorkOrder
}
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行雨云工单规则: %s", rule.Name)
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
return SendRainyunWorkorderMsg(ctx, rule)
config, err := ParseWorkOrderConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
return SendWorkOrder(ctx, account, config)
}
// RainyunRunAwayExecutor 雨云跑路执行器
type RainyunRunAwayExecutor struct{}
func (e *RainyunRunAwayExecutor) GetType() string {
return "rainyun-runaway"
return RuleTypeRunAway
}
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, rule *model.Rule) error {
log.Printf("执行雨云跑路规则: %s", rule.Name)
return RainyunRunAway(ctx, rule)
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
account, err := ParseAccountConfig(task.AccountConfig())
if err != nil {
return err
}
// 跑路规则只要求用户填一句免责声明,执行时重新校验一次
config, err := ParseRunAwayConfig(task.Rule.ConfigJson)
if err != nil {
return err
}
if config.IK != runAwayConfirmText {
return fmt.Errorf("请输入正确内容")
}
return RunAway(ctx, account)
}
+35 -30
View File
@@ -4,31 +4,43 @@ import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/public"
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
)
// 从 Rule 中获取 RainyunConfig
func getRainyunConfigFromRule(rule *model.Rule) (*RainyunWorkOrderRule, error) {
var rainyunConfig RainyunWorkOrderRule
if err := json.Unmarshal([]byte(rule.ConfigJson), &rainyunConfig); err != nil {
return nil, fmt.Errorf("解析Rainyun规则配置失败: %w", err)
// ParseAccountConfig 解析雨云账号配置
func ParseAccountConfig(config string) (*RainyunAccount, error) {
if config == "" {
return nil, fmt.Errorf("解析雨云账号配置失败: 规则没有关联账号")
}
return &rainyunConfig, nil
var account RainyunAccount
if err := json.Unmarshal([]byte(config), &account); err != nil {
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
}
return &account, nil
}
// 从 Rule 中获取 RainyunAccount
func getRainyunAccountFromRule(rule *model.Rule) (*RainyunAccount, error) {
var rainyunAccount RainyunAccount
if err := db.LoadAccountConfig(rule.AccountID, &rainyunAccount); err != nil {
return nil, err
// ParseWorkOrderConfig 解析雨云工单规则配置
func ParseWorkOrderConfig(configJSON string) (*RainyunWorkOrderRule, error) {
var config RainyunWorkOrderRule
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析雨云工单规则配置失败: %v", err)
}
return &rainyunAccount, nil
return &config, nil
}
// ParseRunAwayConfig 解析雨云跑路规则配置
func ParseRunAwayConfig(configJSON string) (*RainyunRunAwayRule, error) {
var config RainyunRunAwayRule
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, fmt.Errorf("解析雨云跑路规则配置失败: %v", err)
}
return &config, nil
}
// reinstallOneRCS 重装一台 RCS
func reinstallOneRCS(rcsID int, rcsClient *rcs.Client) error {
rcsDetail, err := rcsClient.GetRcsDetails(rcsID)
if err != nil {
@@ -42,20 +54,17 @@ func reinstallOneRCS(rcsID int, rcsClient *rcs.Client) error {
for _, os := range rcsOSList.Data {
if os.Region == rcsDetail.Data.Data.Node.Region && os.OsType == "linux" {
rcsReinstallRequest := &rcs.ReinstallRcsRequest{
_, err := rcsClient.ReinstallRcs(rcsID, &rcs.ReinstallRcsRequest{
OsID: os.ID,
ResetOsd: true,
}
_, err := rcsClient.ReinstallRcs(rcsID, rcsReinstallRequest)
if err != nil {
return err
}
break
})
return err
}
}
return nil
return fmt.Errorf("没有找到可用的 linux 镜像")
}
// reinstallOneRGS 重装一台 RGS
func reinstallOneRGS(rgsID int, rgsClient *rgs.Client) error {
rgsDetail, err := rgsClient.GetRgsDetails(rgsID)
if err != nil {
@@ -69,16 +78,12 @@ func reinstallOneRGS(rgsID int, rgsClient *rgs.Client) error {
for _, os := range rgsOSList.Data {
if os.Region == rgsDetail.Data.Data.Node.Region && os.OsType == "linux" {
rgsReinstallRequest := &rcs.ReinstallRcsRequest{
_, err := rgsClient.Reinstallgs(rgsID, &rcs.ReinstallRcsRequest{
OsID: os.ID,
ResetOsd: true,
}
_, err := rgsClient.Reinstallgs(rgsID, rgsReinstallRequest)
if err != nil {
return err
}
break
})
return err
}
}
return nil
return fmt.Errorf("没有找到可用的 linux 镜像")
}
+8 -13
View File
@@ -1,33 +1,28 @@
package db
import (
"encoding/json"
"fmt"
"github.com/ssdomei232/goodBaby/model"
)
// LoadAccountConfig 读取账号并把 Config 字段反序列化到 out。
// GetAccount 读取一个账号,返回数据库里的原始记录。
//
// 各个 driver 之前都各自实现了一遍这段逻辑,其中 github driver 甚至
// 直接把驱动内的结构体当成 gorm model 查询,这里统一收敛。
func LoadAccountConfig(accountID uint, out any) error {
// 规则执行需要的账号凭据由上层(handler/runner)在这里取好再传给 driver,
// driver 自己不碰数据库。accountID 为 0 表示该规则不需要账号,返回 (nil, nil)。
func GetAccount(accountID uint) (*model.Account, error) {
if accountID == 0 {
return fmt.Errorf("该规则没有关联账号")
return nil, nil
}
gormDB, err := GetGormDB()
if err != nil {
return err
return nil, err
}
var account model.Account
if err := gormDB.First(&account, accountID).Error; err != nil {
return fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
return nil, fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
}
if err := json.Unmarshal([]byte(account.Config), out); err != nil {
return fmt.Errorf("解析账号(ID: %d)配置失败: %w", accountID, err)
}
return nil
return &account, nil
}
+5
View File
@@ -43,6 +43,9 @@ func MustInit() *gorm.DB {
if err := ensureAdmin(gormDB); err != nil {
log.Fatalf("初始化管理员失败: %v", err)
}
if err := migrateGatewayRules(gormDB); err != nil {
log.Fatalf("迁移消息网关规则失败: %v", err)
}
return gormDB
}
@@ -136,7 +139,9 @@ func AutoMigrate(gormDB *gorm.DB) error {
&model.User{},
&model.Timer{},
&model.Rule{},
&model.GatewayRule{},
&model.Account{},
&model.ExecutionLog{},
&model.MessageGateway{},
)
}
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"log"
"github.com/ssdomei232/goodBaby/model"
"gorm.io/gorm"
)
// legacyRule 是迁移前的 rules 表结构,只用来读出已经废弃的 gateway_id
type legacyRule struct {
ID uint
UID uint
GatewayID uint
Name string
AccountID uint
Type string
ConfigJson string
Enabled bool
CreateAt int64
}
// migrateGatewayRules 把历史数据里混在 rules 表中的消息网关规则搬到 gateway_rules 表。
//
// v2.1 之前消息网关规则和定时器规则共用 rules 表,靠 gateway_id 区分;
// 现在两者分表存储,这里做一次性搬迁,避免老用户升级后规则凭空消失。
func migrateGatewayRules(gormDB *gorm.DB) error {
if !gormDB.Migrator().HasColumn("rules", "gateway_id") {
return nil
}
var legacy []legacyRule
if err := gormDB.Table("rules").Where("gateway_id <> 0").Find(&legacy).Error; err != nil {
return err
}
if len(legacy) == 0 {
return nil
}
return gormDB.Transaction(func(tx *gorm.DB) error {
for _, rule := range legacy {
moved := model.GatewayRule{
UID: rule.UID,
GatewayID: rule.GatewayID,
Name: rule.Name,
AccountID: rule.AccountID,
Type: rule.Type,
ConfigJson: rule.ConfigJson,
Enabled: rule.Enabled,
CreateAt: rule.CreateAt,
}
if err := tx.Create(&moved).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM rules WHERE id = ?", rule.ID).Error; err != nil {
return err
}
}
log.Printf("已迁移 %d 条消息网关规则到 gateway_rules 表", len(legacy))
return nil
})
}
+9 -35
View File
@@ -6,20 +6,16 @@ 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"
)
// RuleExecutor 规则执行器接口
// RuleExecutor 规则执行器接口。
//
// 执行一条规则需要的一切(规则本体、账号凭据)都由上层组装成 model.RuleTask,
// driver 只负责把动作做出去:不查数据库、不写日志,因此每个实现都能单独测试。
type RuleExecutor interface {
// Execute 执行规则,ctx 决定了重试的最长时间
Execute(ctx context.Context, rule *model.Rule) error
Execute(ctx context.Context, task *model.RuleTask) error
// GetType 获取执行器支持的规则类型
GetType() string
}
@@ -42,13 +38,13 @@ func (er *ExecutorRegistry) Register(executor RuleExecutor) {
}
// Execute 根据规则类型执行规则
func (er *ExecutorRegistry) Execute(ctx context.Context, rule *model.Rule) error {
executor, exists := er.executors[rule.Type]
func (er *ExecutorRegistry) Execute(ctx context.Context, task *model.RuleTask) error {
executor, exists := er.executors[task.Rule.Type]
if !exists {
return fmt.Errorf("不支持的规则类型: %s", rule.Type)
return fmt.Errorf("不支持的规则类型: %s", task.Rule.Type)
}
return executor.Execute(ctx, rule)
return executor.Execute(ctx, task)
}
// GetSupportedTypes 获取所有支持的规则类型
@@ -66,28 +62,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
}
+53 -12
View File
@@ -53,7 +53,18 @@ func Runner(timer *model.Timer) {
wg.Wait()
}
// ExecuteRule 执行单个规则并记录执行日志
// NewTask 组装执行一条规则所需的输入:规则本体 + 从数据库取出的关联账号。
//
// 数据库访问只发生在这一层,driver 拿到的 task 已经是自包含的。
func NewTask(rule *model.Rule) (*model.RuleTask, error) {
account, err := db.GetAccount(rule.AccountID)
if err != nil {
return nil, err
}
return &model.RuleTask{Rule: rule, Account: account}, nil
}
// ExecuteRule 执行单条规则并记录执行日志
func ExecuteRule(rule *model.Rule, trigger string) error {
ctx, cancel := retry.ExecutionContext()
defer cancel()
@@ -62,24 +73,54 @@ func ExecuteRule(rule *model.Rule, trigger string) error {
// ExecuteRuleWithContext 使用给定 context 执行规则,手动测试时可传入较短的超时
func ExecuteRuleWithContext(ctx context.Context, rule *model.Rule, trigger string) error {
err := GetGlobalExecutorRegistry().Execute(ctx, rule)
message := "执行成功"
if err != nil {
message = err.Error()
log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err)
}
logstore.Record(&model.ExecutionLog{
task, err := NewTask(rule)
return execTask(ctx, task, err, &model.ExecutionLog{
UID: rule.UID,
RuleID: rule.ID,
RuleName: rule.Name,
RuleType: rule.Type,
TimerID: rule.TimerID,
Trigger: trigger,
Success: err == nil,
Message: message,
})
}
// ExecuteGatewayRuleWithContext 执行一条消息网关规则,日志里带上网关信息。
//
// 与定时器规则走同一条执行链路,区别只有触发来源与日志字段。
func ExecuteGatewayRuleWithContext(ctx context.Context, rule *model.GatewayRule, trigger string) error {
task, err := NewTask(rule.AsRule())
return execTask(ctx, task, err, &model.ExecutionLog{
UID: rule.UID,
RuleName: rule.Name,
RuleType: rule.Type,
GatewayID: rule.GatewayID,
GatewayRuleID: rule.ID,
Trigger: trigger,
})
}
// execTask 执行规则并写入执行日志。
//
// taskErr 是组装输入时的错误(例如账号已被删除):这时不会走到 driver,
// 但同样要留下一条失败日志,方便用户在 WebUI 上看到原因。
func execTask(ctx context.Context, task *model.RuleTask, taskErr error, entry *model.ExecutionLog) error {
if taskErr != nil {
return record(entry, taskErr)
}
log.Printf("执行规则 [%s] %s (触发来源: %s)", entry.RuleType, entry.RuleName, entry.Trigger)
return record(entry, GetGlobalExecutorRegistry().Execute(ctx, task))
}
// record 写入执行日志并返回原始错误
func record(entry *model.ExecutionLog, err error) error {
entry.Success = err == nil
entry.Message = "执行成功"
if err != nil {
entry.Message = err.Error()
log.Printf("执行规则失败 [%s] %s: %v", entry.RuleType, entry.RuleName, err)
}
logstore.Record(entry)
return err
}
+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
})
+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()
+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
})
+19 -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)
}
// 需要认证的路由组
@@ -104,6 +106,15 @@ func main() {
rules.DELETE("/:ruleID", rule.HandleDeleteRule)
}
gatewayRules := authorized.Group("/gateway-rules")
{
gatewayRules.GET("/", rule.HandleGetAllGatewayRules)
gatewayRules.POST("/", rule.HandleCreateGatewayRule)
gatewayRules.PUT("/:ruleID", rule.HandleEditGatewayRule)
gatewayRules.POST("/:ruleID/test", rule.HandleTestGatewayRule)
gatewayRules.DELETE("/:ruleID", rule.HandleDeleteGatewayRule)
}
accounts := authorized.Group("/accounts")
{
accounts.GET("/", account.HandleGetAllAccounts)
@@ -114,6 +125,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 +169,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")
}
+63
View File
@@ -0,0 +1,63 @@
package model
// GatewayRule 是挂在消息网关上的通知规则。
//
// 与 Rule 的区别只有触发来源:Rule 由定时器到期触发,
// GatewayRule 由外部系统投递到网关的消息触发,两者互不干扰。
type GatewayRule struct {
ID uint `gorm:"primaryKey" json:"id"`
UID uint `gorm:"index" json:"uid"`
GatewayID uint `gorm:"index" json:"gateway_id"`
Name string `json:"name"`
AccountID uint `json:"account_id"`
Type string `json:"type"`
ConfigJson string `json:"config_json"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreateAt int64 `json:"create_at"`
}
// AsRule 把网关规则换成统一的执行结构。
//
// 执行层只认识 Rule:投递消息前把外部消息覆盖到 ConfigJson 上,
// 再交给同一个 runner 执行即可。
func (r *GatewayRule) AsRule() *Rule {
return &Rule{
UID: r.UID,
Name: r.Name,
AccountID: r.AccountID,
Type: r.Type,
ConfigJson: r.ConfigJson,
Enabled: r.Enabled,
CreateAt: r.CreateAt,
}
}
// GatewayRuleRequest 创建/编辑消息网关规则的请求体
type GatewayRuleRequest struct {
Name string `json:"name"`
GatewayID uint `json:"gateway_id"`
AccountID uint `json:"account_id"`
Type string `json:"type"`
ConfigJson string `json:"config_json"`
Enabled *bool `json:"enabled"`
}
// Validate 校验网关规则请求中与类型无关的通用部分
func (r *GatewayRuleRequest) Validate() error {
if r.Name == "" {
return ErrValidation("规则名称不能为空")
}
if len(r.Name) > 64 {
return ErrValidation("规则名称过长")
}
if r.Type == "" {
return ErrValidation("规则类型不能为空")
}
if r.GatewayID == 0 {
return ErrValidation("规则必须关联一个消息网关")
}
if r.ConfigJson == "" {
return ErrValidation("规则配置不能为空")
}
return nil
}
+11 -7
View File
@@ -2,9 +2,10 @@ package model
// 执行日志的触发来源
const (
TriggerTimer = "timer" // Timer 到期自动触发
TriggerManual = "manual" // 用户在 WebUI 手动测试
TriggerRemind = "remind" // 提醒通知
TriggerTimer = "timer" // Timer 到期自动触发
TriggerManual = "manual" // 用户在 WebUI 手动测试
TriggerRemind = "remind" // 提醒通知
TriggerGateway = "gateway" // 外部系统投递到消息网关触发
)
// ExecutionLog 记录每一次规则执行/提醒的结果,供 WebUI 展示
@@ -15,8 +16,11 @@ type ExecutionLog struct {
RuleName string `json:"rule_name"`
RuleType string `json:"rule_type"`
TimerID uint `json:"timer_id"`
Trigger string `json:"trigger"`
Success bool `json:"success"`
Message string `json:"message"`
CreateAt int64 `gorm:"index" json:"create_at"`
// GatewayID / GatewayRuleID 只有消息网关投递触发时才有值
GatewayID uint `json:"gateway_id"`
GatewayRuleID uint `json:"gateway_rule_id"`
Trigger string `json:"trigger"`
Success bool `json:"success"`
Message string `json:"message"`
CreateAt int64 `gorm:"index" json:"create_at"`
}
+30
View File
@@ -0,0 +1,30 @@
package model
// GatewayTypeWebhook 默认的网关类型:外部系统 POST 一条消息来触发规则
const GatewayTypeWebhook = "webhook"
type MessageGateway struct {
ID uint `gorm:"primaryKey" json:"id"`
UID uint `gorm:"index" json:"uid"`
Name string `json:"name"`
// Type 网关类型,见 internal/gateway 注册表
Type string `gorm:"default:webhook" json:"type"`
Token string `gorm:"uniqueIndex;size:67" json:"token"`
CreateAt int64 `json:"create_at"`
}
type MessageGatewayRequest struct {
Name string `json:"name"`
Type string `json:"type"`
}
// Validate 校验网关请求中与类型无关的通用部分
func (r *MessageGatewayRequest) Validate() error {
if r.Name == "" {
return ErrValidation("网关名称不能为空")
}
if len(r.Name) > 64 {
return ErrValidation("网关名称过长")
}
return nil
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (r *RuleRequest) Validate() error {
return ErrValidation("规则类型不能为空")
}
if r.TimerID == 0 {
return ErrValidation("必须关联一个 Timer")
return ErrValidation("规则必须关联一个定时器")
}
if r.ConfigJson == "" {
return ErrValidation("规则配置不能为空")
+20
View File
@@ -0,0 +1,20 @@
package model
// RuleTask 是一次规则执行所需的全部输入。
//
// 规则本体与关联账号都由上层(handler/runner、消息网关)从数据库取出后传进来,
// driver 只负责把动作做出去,所以底层实现里不会再出现任何数据库操作。
type RuleTask struct {
// Rule 规则本体,永远不会为空
Rule *Rule
// Account 规则关联的账号;该规则类型不需要账号时为 nil
Account *Account
}
// AccountConfig 返回关联账号的配置 JSON,没有关联账号时返回空字符串
func (t *RuleTask) AccountConfig() string {
if t == nil || t.Account == nil {
return ""
}
return t.Account.Config
}
+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
+20 -1
View File
@@ -6,10 +6,14 @@ import type {
AdminConfigResponse,
DashboardOverview,
ExecutionLog,
GatewayRule,
GatewayRuleRequest,
LogPage,
MessageGateway,
Providers,
Rule,
RuleRequest,
AccountDeleteImpact,
SiteInfo,
Timer,
TimerRequest,
@@ -65,13 +69,22 @@ export const ruleApi = {
remove: (id: number) => api.delete<string>(`/rules/${id}`),
}
export const gatewayRuleApi = {
list: (gatewayId?: number) =>
api.get<GatewayRule[]>('/gateway-rules/', gatewayId ? { gateway_id: gatewayId } : undefined),
create: (body: GatewayRuleRequest) => api.post<GatewayRule>('/gateway-rules/', body),
update: (id: number, body: GatewayRuleRequest) => api.put<GatewayRule>(`/gateway-rules/${id}`, body),
test: (id: number) => api.post<string>(`/gateway-rules/${id}/test`),
remove: (id: number) => api.delete<string>(`/gateway-rules/${id}`),
}
export const accountApi = {
list: (type?: string) =>
api.get<Account[]>('/accounts/', type ? { type } : undefined),
create: (body: AccountRequest) => api.post<Account>('/accounts/', body),
update: (id: number, body: AccountRequest) => api.put<Account>(`/accounts/${id}`, body),
test: (id: number) => api.post<string>(`/accounts/${id}/test`),
checkDelete: (id: number) => api.get<Rule[]>(`/accounts/${id}/check`),
checkDelete: (id: number) => api.get<AccountDeleteImpact>(`/accounts/${id}/check`),
remove: (id: number) => api.delete<string>(`/accounts/${id}`),
}
@@ -81,6 +94,12 @@ export const logApi = {
clear: () => api.delete<string>('/logs/'),
}
export const gatewayApi = {
list: () => api.get<MessageGateway[]>('/gateways/'),
create: (name: string, type: string) => api.post<MessageGateway>('/gateways/', { name, type }),
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),
+55 -2
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 {
@@ -48,6 +49,28 @@ export interface Rule {
create_at: number
}
/** 消息网关规则:由外部系统投递到网关的消息触发,与定时器规则分开存储 */
export interface GatewayRule {
id: number
uid: number
gateway_id: number
name: string
account_id: number
type: string
config_json: string
enabled: boolean
create_at: number
}
export interface MessageGateway {
id: number
uid: number
name: string
type: string
token: string
create_at: number
}
export interface RuleRequest {
name: string
timer_id: number
@@ -57,6 +80,15 @@ export interface RuleRequest {
enabled?: boolean
}
export interface GatewayRuleRequest {
name: string
gateway_id: number
account_id: number
type: string
config_json: string
enabled?: boolean
}
export interface Account {
id: number
uid: number
@@ -89,6 +121,8 @@ export interface MetaField {
placeholder?: string
help?: string
secret?: boolean
/** 由消息网关投递的请求内容提供,网关规则页面不需要用户填写 */
gateway_message?: boolean
default?: unknown
}
@@ -110,9 +144,18 @@ export interface RuleMeta {
fields: MetaField[]
}
export interface GatewayMeta {
type: string
label: string
description?: string
docs?: string
payload_hint?: string
}
export interface Providers {
accounts: AccountMeta[]
rules: RuleMeta[]
gateways: GatewayMeta[]
}
export interface ExecutionLog {
@@ -122,7 +165,9 @@ export interface ExecutionLog {
rule_name: string
rule_type: string
timer_id: number
trigger: 'timer' | 'manual' | 'remind'
gateway_id: number
gateway_rule_id: number
trigger: 'timer' | 'manual' | 'remind' | 'gateway'
success: boolean
message: string
create_at: number
@@ -140,6 +185,8 @@ export interface DashboardOverview {
enabled_timers: number
triggered_count: number
rule_count: number
gateway_count: number
gateway_rule_count: number
account_count: number
server_time: number
recent_logs: ExecutionLog[]
@@ -147,6 +194,12 @@ export interface DashboardOverview {
urgent_seconds_left?: number
}
/** 删除账号时会被一并删除的规则 */
export interface AccountDeleteImpact {
rules: Rule[]
gateway_rules: GatewayRule[]
}
export interface SiteInfo {
name: string
enable_registry: boolean
+4
View File
@@ -7,6 +7,8 @@ import {
Timer as TimerIcon,
Operation,
User,
Connection,
Promotion,
Document,
Setting,
SwitchButton,
@@ -38,6 +40,8 @@ const menus = [
{ path: '/timers', title: '定时器', icon: TimerIcon },
{ path: '/rules', title: '规则', icon: Operation },
{ path: '/accounts', title: '账号', icon: User },
{ path: '/gateways', title: '消息网关', icon: Connection },
{ path: '/gateway-rules', title: '网关规则', icon: Promotion },
{ path: '/logs', title: '执行日志', icon: Document },
{ path: '/settings', title: '设置', icon: Setting },
]
+7
View File
@@ -40,6 +40,13 @@ const router = createRouter({
component: () => import('@/views/AccountsView.vue'),
meta: { title: '账号' },
},
{ path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } },
{
path: 'gateway-rules',
name: 'gateway-rules',
component: () => import('@/views/GatewayRulesView.vue'),
meta: { title: '网关规则' },
},
{
path: 'logs',
name: 'logs',
+13 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { providerApi } from '@/api'
import type { AccountMeta, Providers, RuleMeta } from '@/api/types'
import type { AccountMeta, GatewayMeta, Providers, RuleMeta } from '@/api/types'
/** 驱动元数据:账号/规则类型的表单描述,登录后拉取一次全局共享 */
export const useMetaStore = defineStore('meta', () => {
@@ -16,6 +16,7 @@ export const useMetaStore = defineStore('meta', () => {
const accountMetas = computed<AccountMeta[]>(() => providers.value?.accounts ?? [])
const ruleMetas = computed<RuleMeta[]>(() => providers.value?.rules ?? [])
const gatewayMetas = computed<GatewayMeta[]>(() => providers.value?.gateways ?? [])
function accountMeta(type: string): AccountMeta | undefined {
return accountMetas.value.find((m) => m.type === type)
@@ -33,14 +34,25 @@ export const useMetaStore = defineStore('meta', () => {
return ruleMeta(type)?.label ?? type
}
function gatewayMeta(type: string): GatewayMeta | undefined {
return gatewayMetas.value.find((m) => m.type === type)
}
function gatewayLabel(type: string): string {
return gatewayMeta(type)?.label ?? type
}
return {
providers,
accountMetas,
ruleMetas,
gatewayMetas,
ensureLoaded,
accountMeta,
ruleMeta,
gatewayMeta,
accountLabel,
ruleLabel,
gatewayLabel,
}
})
+3 -2
View File
@@ -94,9 +94,10 @@ async function test(account: Account) {
async function remove(account: Account) {
try {
const affected = await accountApi.checkDelete(account.id)
const affectedRules = [...affected.rules, ...affected.gateway_rules]
const warning =
affected.length > 0
? `删除后,使用该账号的 ${affected.length} 条规则也会被一并删除:${affected
affectedRules.length > 0
? `删除后,使用该账号的 ${affectedRules.length} 条规则也会被一并删除:${affectedRules
.map((r) => r.name)
.join('、')}`
: '没有规则使用该账号。'
+11 -1
View File
@@ -8,6 +8,7 @@ import {
Timer as TimerIcon,
Operation,
User,
Connection,
Warning,
} from '@element-plus/icons-vue'
import { dashboardApi, timerApi } from '@/api'
@@ -73,13 +74,22 @@ const stats = computed(() => [
},
{
label: '规则',
sub: '触发时执行的动作',
sub: `定时器规则 ${overview.value?.rule_count ?? 0} 条`,
value: overview.value?.rule_count,
icon: Operation,
color: '#6366f1',
bg: 'rgb(99 102 241 / 0.12)',
to: '/rules',
},
{
label: '消息网关',
sub: `网关规则 ${overview.value?.gateway_rule_count ?? 0} 条`,
value: overview.value?.gateway_count,
icon: Connection,
color: '#0ea5e9',
bg: 'rgb(14 165 233 / 0.12)',
to: '/gateways',
},
{
label: '账号',
sub: '第三方凭据',
+436
View File
@@ -0,0 +1,436 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, VideoPlay } from '@element-plus/icons-vue'
import { accountApi, gatewayApi, gatewayRuleApi } from '@/api'
import { ApiError } from '@/api/client'
import type { Account, GatewayRule, MessageGateway } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
import ConfigForm from '@/components/ConfigForm.vue'
import { useIsMobile } from '@/composables/useBreakpoint'
const isMobile = useIsMobile()
const route = useRoute()
const metaStore = useMetaStore()
const rules = ref<GatewayRule[]>([])
const gateways = ref<MessageGateway[]>([])
const accounts = ref<Account[]>([])
const loading = ref(false)
const testingId = ref<number | null>(null)
const filterGatewayId = ref<number | undefined>(undefined)
// ---- 创建 / 编辑对话框 ----
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const form = ref<{
name: string
type: string
gateway_id: number
account_id: number | undefined
enabled: boolean
config_json: string
}>({
name: '',
type: '',
gateway_id: 0,
account_id: undefined,
enabled: true,
config_json: '',
})
const dialogTitle = computed(() => (editingId.value === null ? '创建网关规则' : '编辑网关规则'))
const currentMeta = computed(() => metaStore.ruleMeta(form.value.type))
/** 由投递请求提供的字段,页面上不让用户填 */
const gatewayMessageFields = computed(() =>
(currentMeta.value?.fields ?? []).filter((f) => f.gateway_message),
)
/** 需要用户填写的字段 */
const configFields = computed(() => (currentMeta.value?.fields ?? []).filter((f) => !f.gateway_message))
/** 当前规则类型可用的账号(按类型过滤) */
const availableAccounts = computed(() => {
const requiredType = currentMeta.value?.account_type
if (!requiredType) return []
return accounts.value.filter((a) => a.type === requiredType)
})
const filteredRules = computed(() => {
if (!filterGatewayId.value) return rules.value
return rules.value.filter((r) => r.gateway_id === filterGatewayId.value)
})
function gatewayName(id: number): string {
return gateways.value.find((g) => g.id === id)?.name ?? `#${id}`
}
function accountName(id: number): string {
if (!id) return '-'
return accounts.value.find((a) => a.id === id)?.name ?? `#${id}`
}
async function refresh() {
loading.value = true
try {
const [ruleList, gatewayList, accountList] = await Promise.all([
gatewayRuleApi.list(),
gatewayApi.list(),
accountApi.list(),
])
rules.value = ruleList
gateways.value = gatewayList
accounts.value = accountList
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
if (gateways.value.length === 0) {
ElMessage.warning('请先在“消息网关”页面创建一个网关')
return
}
editingId.value = null
form.value = {
name: '',
type: metaStore.ruleMetas[0]?.type ?? '',
gateway_id: filterGatewayId.value ?? gateways.value[0]?.id ?? 0,
account_id: undefined,
enabled: true,
config_json: '',
}
dialogVisible.value = true
}
function openEdit(rule: GatewayRule) {
editingId.value = rule.id
form.value = {
name: rule.name,
type: rule.type,
gateway_id: rule.gateway_id,
account_id: rule.account_id || undefined,
enabled: rule.enabled,
config_json: rule.config_json,
}
dialogVisible.value = true
}
function onTypeChange() {
form.value.config_json = ''
form.value.account_id = undefined
}
async function save() {
if (!form.value.name) {
ElMessage.warning('请填写规则名称')
return
}
if (!form.value.gateway_id) {
ElMessage.warning('请选择消息网关')
return
}
if (currentMeta.value?.account_type && !form.value.account_id) {
ElMessage.warning(`该规则类型需要关联一个「${metaStore.accountLabel(currentMeta.value.account_type)}」账号`)
return
}
const body = {
name: form.value.name,
type: form.value.type,
gateway_id: form.value.gateway_id,
account_id: form.value.account_id ?? 0,
enabled: form.value.enabled,
config_json: form.value.config_json,
}
saving.value = true
try {
if (editingId.value === null) {
await gatewayRuleApi.create(body)
ElMessage.success('网关规则已创建')
} else {
await gatewayRuleApi.update(editingId.value, body)
ElMessage.success('网关规则已更新')
}
dialogVisible.value = false
await refresh()
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function toggleEnabled(rule: GatewayRule) {
try {
await gatewayRuleApi.update(rule.id, {
name: rule.name,
type: rule.type,
gateway_id: rule.gateway_id,
account_id: rule.account_id,
enabled: rule.enabled,
config_json: rule.config_json,
})
ElMessage.success(rule.enabled ? '已启用' : '已停用')
} catch (error) {
rule.enabled = !rule.enabled
ElMessage.error(error instanceof ApiError ? error.message : '操作失败')
}
}
async function test(rule: GatewayRule) {
try {
await ElMessageBox.confirm(
'测试会按规则里保存的内容真实执行一次(发送消息 / 修改仓库等),确定继续吗?',
`测试网关规则「${rule.name}」`,
{ type: 'warning', confirmButtonText: '执行', cancelButtonText: '取消' },
)
} catch {
return
}
testingId.value = rule.id
try {
await gatewayRuleApi.test(rule.id)
ElMessage.success('执行成功,详情见执行日志')
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '执行失败')
} finally {
testingId.value = null
}
}
async function remove(rule: GatewayRule) {
try {
await ElMessageBox.confirm(`确定删除网关规则「${rule.name}」吗?`, '删除网关规则', {
type: 'warning',
confirmButtonText: '删除',
confirmButtonClass: 'el-button--danger',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await gatewayRuleApi.remove(rule.id)
ElMessage.success('已删除')
await refresh()
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '删除失败')
}
}
onMounted(async () => {
// 从「消息网关」页面跳转过来时可以带上要筛选的网关
const fromQuery = Number(route.query.gateway_id)
if (fromQuery) filterGatewayId.value = fromQuery
await metaStore.ensureLoaded()
await refresh()
})
</script>
<template>
<div v-loading="loading">
<div class="page-header">
<div>
<h2>网关规则</h2>
<div class="muted">外部系统往消息网关投递消息时要执行的动作,只需配置「发给谁」,内容来自请求</div>
</div>
<div class="header-tools">
<el-select
v-model="filterGatewayId"
placeholder="按消息网关筛选"
clearable
class="filter-select"
>
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
<el-button type="primary" :icon="Plus" @click="openCreate">创建网关规则</el-button>
</div>
</div>
<el-alert class="rule-tip" type="info" :closable="false" show-icon>
<template #title>消息内容由投递请求提供</template>
投递时把请求里的 <code>message</code> / <code>title</code> 填进规则的消息字段,因此规则里不需要预先写好内容。
</el-alert>
<el-empty v-if="!loading && filteredRules.length === 0" description="还没有网关规则">
<el-button type="primary" @click="openCreate">创建网关规则</el-button>
</el-empty>
<el-card v-else class="table-card gb-rise">
<el-table :data="filteredRules" :size="isMobile ? 'small' : 'default'">
<el-table-column prop="name" label="名称" min-width="110" />
<el-table-column label="类型" :width="isMobile ? 120 : 160">
<template #default="{ row }">
<el-tag>{{ metaStore.ruleLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!isMobile" label="消息网关" min-width="120">
<template #default="{ row }">
<el-tag size="small" type="info" effect="light">{{ gatewayName(row.gateway_id) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!isMobile" label="账号" min-width="120">
<template #default="{ row }">{{ accountName(row.account_id) }}</template>
</el-table-column>
<el-table-column v-if="!isMobile" label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.create_at) }}</template>
</el-table-column>
<el-table-column label="启用" :width="isMobile ? 60 : 80">
<template #default="{ row }">
<el-switch v-model="row.enabled" size="small" @change="toggleEnabled(row)" />
</template>
</el-table-column>
<el-table-column label="操作" :width="isMobile ? 200 : 240" fixed="right">
<template #default="{ row }">
<el-button
size="small"
:icon="VideoPlay"
:loading="testingId === row.id"
@click="test(row)"
>
测试
</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form :label-width="isMobile ? 'auto' : '110px'" :label-position="isMobile ? 'top' : 'right'">
<el-form-item label="规则名称" required>
<el-input v-model="form.name" placeholder="给这条规则起个名字" maxlength="64" />
</el-form-item>
<el-form-item label="消息网关" required>
<el-select v-model="form.gateway_id" style="width: 100%" placeholder="选择消息网关">
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
<div class="muted">外部系统向这个网关投递消息时触发本规则</div>
</el-form-item>
<el-form-item label="规则类型" required>
<el-select
v-model="form.type"
:disabled="editingId !== null"
style="width: 100%"
@change="onTypeChange"
>
<el-option
v-for="m in metaStore.ruleMetas"
:key="m.type"
:label="m.label"
:value="m.type"
/>
</el-select>
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
<div v-if="currentMeta && !gatewayMessageFields.length" class="muted">
该规则类型没有消息字段,投递只作为触发,按下方的配置执行
</div>
</el-form-item>
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
<el-select
v-model="form.account_id"
style="width: 100%"
:placeholder="availableAccounts.length ? '选择账号' : '没有可用账号,请先到“账号”页面添加'"
>
<el-option
v-for="a in availableAccounts"
:key="a.id"
:label="a.name"
:value="a.id"
/>
</el-select>
<div class="muted">
需要「{{ metaStore.accountLabel(currentMeta.account_type) }}」类型的账号
</div>
</el-form-item>
<el-form-item v-if="gatewayMessageFields.length" label="消息内容">
<div class="message-fields">
<el-tag
v-for="f in gatewayMessageFields"
:key="f.key"
size="small"
type="info"
effect="plain"
>
{{ f.label }}
</el-tag>
<div class="muted">
由投递请求的 <code>title</code> / <code>message</code> 自动填充,无需在此填写
</div>
</div>
</el-form-item>
<ConfigForm v-if="currentMeta" v-model="form.config_json" :fields="configFields" />
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.rule-tip {
margin-bottom: 16px;
}
.rule-tip code {
padding: 1px 5px;
border-radius: 4px;
background: var(--gb-bg);
color: var(--el-text-color-primary);
font-size: 12px;
}
.message-fields {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
line-height: 1.6;
}
.message-fields .muted {
width: 100%;
}
.header-tools {
display: flex;
gap: 12px;
}
.filter-select {
width: 200px;
}
@media (max-width: 768px) {
.header-tools {
flex-wrap: wrap;
gap: 10px;
}
.filter-select {
width: 100%;
}
}
</style>
+381
View File
@@ -0,0 +1,381 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Connection, CopyDocument, Delete, Link, Plus, Setting } from '@element-plus/icons-vue'
import { gatewayApi, gatewayRuleApi } from '@/api'
import type { GatewayRule, MessageGateway } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
const router = useRouter()
const metaStore = useMetaStore()
const gateways = ref<MessageGateway[]>([])
const rules = ref<GatewayRule[]>([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const name = ref('')
const type = ref('')
const origin = window.location.origin
const currentMeta = computed(() => metaStore.gatewayMeta(type.value))
const enabledRuleCount = computed(() => rules.value.filter((r) => r.enabled).length)
async function refresh() {
loading.value = true
try {
;[gateways.value, rules.value] = await Promise.all([gatewayApi.list(), gatewayRuleApi.list()])
} catch {
ElMessage.error('加载消息网关失败')
} finally {
loading.value = false
}
}
function openCreate() {
name.value = ''
type.value = metaStore.gatewayMetas[0]?.type ?? 'webhook'
dialogVisible.value = true
}
async function create() {
if (!name.value.trim()) {
ElMessage.warning('请输入网关名称')
return
}
saving.value = true
try {
await gatewayApi.create(name.value.trim(), type.value)
dialogVisible.value = false
await refresh()
ElMessage.success('消息网关已创建')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
} finally {
saving.value = false
}
}
async function remove(item: MessageGateway) {
const count = ruleCount(item)
const detail = count > 0 ? `关联的 ${count} 条网关规则也会被一并删除。` : ''
try {
await ElMessageBox.confirm(`删除“${item.name}”?${detail}`, '删除消息网关', { type: 'warning' })
} catch {
return
}
try {
await gatewayApi.remove(item.id)
await refresh()
ElMessage.success('消息网关已删除')
} catch {
ElMessage.error('删除失败')
}
}
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
}
function payloadHint(item: MessageGateway) {
return metaStore.gatewayMeta(item.type)?.payload_hint ?? '{ "message": "..." }'
}
function manageRules(item: MessageGateway) {
router.push({ name: 'gateway-rules', query: { gateway_id: String(item.id) } })
}
onMounted(async () => {
await metaStore.ensureLoaded()
await refresh()
})
</script>
<template>
<div v-loading="loading">
<div class="page-header">
<div>
<h2>消息网关</h2>
<div class="muted">为外部系统生成 Webhook 地址,投递消息即可触发绑定在网关上的规则</div>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建网关</el-button>
</div>
<div class="gw-stats gb-rise">
<div class="gw-stat">
<span class="gw-stat-label">网关</span>
<span class="gw-stat-value">{{ gateways.length }}</span>
</div>
<div class="gw-stat">
<span class="gw-stat-label">网关规则</span>
<span class="gw-stat-value">{{ rules.length }}</span>
</div>
<div class="gw-stat">
<span class="gw-stat-label">已启用规则</span>
<span class="gw-stat-value">{{ enabledRuleCount }}</span>
</div>
</div>
<el-empty v-if="!loading && gateways.length === 0" description="还没有消息网关">
<el-button type="primary" :icon="Plus" @click="openCreate">新建网关</el-button>
</el-empty>
<div v-else class="gateway-grid">
<el-card
v-for="(item, i) in gateways"
:key="item.id"
class="gateway-card gb-rise"
:style="{ animationDelay: `${i * 0.05}s` }"
>
<div class="gateway-head">
<div class="gateway-ident">
<div class="gateway-icon">
<el-icon :size="18"><Connection /></el-icon>
</div>
<div class="gateway-titles">
<div class="gateway-name">{{ item.name }}</div>
<div class="gateway-sub">
<el-tag size="small" type="info" effect="light">
{{ metaStore.gatewayLabel(item.type) }}
</el-tag>
<span>{{ ruleCount(item) }} 条网关规则</span>
<span>{{ formatDateTime(item.create_at) }}</span>
</div>
</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="endpoint-label payload-label">请求示例</div>
<pre class="payload"><code>{{ payloadHint(item) }}</code></pre>
<div class="gateway-actions">
<el-button size="small" :icon="Setting" @click="manageRules(item)">管理网关规则</el-button>
</div>
</el-card>
</div>
<!-- 新建网关 -->
<el-dialog v-model="dialogVisible" title="新建消息网关" width="520px">
<el-form label-width="90px">
<el-form-item label="网关类型" required>
<el-select v-model="type" style="width: 100%">
<el-option
v-for="m in metaStore.gatewayMetas"
:key="m.type"
:label="m.label"
:value="m.type"
/>
</el-select>
<div v-if="currentMeta?.description" class="muted field-hint">
{{ currentMeta.description }}
</div>
</el-form-item>
<el-form-item label="网关名称" required>
<el-input v-model="name" placeholder="例如:生产环境告警" maxlength="64" clearable />
</el-form-item>
</el-form>
<div class="dialog-hint">
<el-icon><Link /></el-icon>
<span>创建后到「网关规则」页面,把通知规则关联到这个网关</span>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="create">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.gw-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.gw-stat {
display: flex;
align-items: baseline;
gap: 10px;
padding: 14px 18px;
border-radius: var(--gb-radius-card);
background: var(--gb-card);
box-shadow: var(--gb-shadow-card);
}
.gw-stat-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.gw-stat-value {
margin-left: auto;
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--gb-primary-deep);
}
.gateway-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.gateway-card {
min-width: 0;
}
.gateway-card:hover {
transform: translateY(-3px);
box-shadow: var(--gb-shadow-hover);
}
.gateway-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.gateway-ident {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.gateway-icon {
width: 38px;
height: 38px;
flex: none;
display: grid;
place-items: center;
border-radius: 11px;
color: var(--gb-primary-deep);
background: var(--el-color-primary-light-9);
}
.gateway-titles {
min-width: 0;
}
.gateway-name {
font-size: 16px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gateway-sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.endpoint-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.endpoint {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin-top: 6px;
padding: 8px 10px;
border-radius: 10px;
background: var(--gb-bg);
}
.endpoint code {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
color: var(--el-text-color-primary);
font-size: 12px;
}
.payload-label {
margin-top: 14px;
}
.payload {
margin: 6px 0 0;
padding: 10px 12px;
border-radius: 10px;
background: var(--gb-bg);
overflow-x: auto;
}
.payload code {
color: var(--el-text-color-regular);
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.gateway-actions {
margin-top: 16px;
}
.field-hint {
line-height: 1.5;
margin-top: 4px;
}
.dialog-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
background: var(--el-color-primary-light-9);
color: var(--el-text-color-regular);
font-size: 12px;
}
@media (max-width: 600px) {
.gateway-grid {
grid-template-columns: 1fr;
}
}
</style>
+1
View File
@@ -21,6 +21,7 @@ const triggerLabels: Record<string, string> = {
timer: '定时触发',
manual: '手动测试',
remind: '提醒',
gateway: '网关投递',
}
async function refresh() {
+5 -1
View File
@@ -309,7 +309,8 @@ onMounted(async () => {
<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%">
<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-form-item>
@@ -363,6 +364,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>