Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b656cebee | ||
|
|
0d89b7fe8f | ||
|
|
360169ee64 |
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 不能为空")
|
||||
|
||||
@@ -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
@@ -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` 表,不需要手工处理。
|
||||
|
||||
@@ -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 记录」)也能挂到网关上,
|
||||
这类规则固定按自己的配置执行,投递的消息只起到触发器的作用。
|
||||
|
||||
规则编辑页的「测试」按钮会用一条示例消息走一次真实的投递覆盖,
|
||||
方便你在执行日志里确认最终发出的内容。
|
||||
@@ -1,7 +1,6 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
@@ -13,7 +12,7 @@ type AliDNSAccountConfigValidator struct{}
|
||||
func (v *AliDNSAccountConfigValidator) GetType() string { return AccountType }
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -23,21 +22,13 @@ func (v *AliDNSAccountConfigValidator) Validate(config string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*AliDNSAccount, error) {
|
||||
var cfg AliDNSAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliDNSClient, err := getAliDNSClient(cfg.AK, cfg.SK)
|
||||
aliDNSClient, err := newClient(cfg.AK, cfg.SK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,25 +4,20 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// deleteAliDNSRecord deletes a DNS record from Alibaba Cloud DNS based on the provided rule.
|
||||
func deleteAliDNSRecord(ctx context.Context, rule *model.Rule) error {
|
||||
aliDNSClient, err := getAliDNSClientFromRule(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
|
||||
}
|
||||
|
||||
if err := retry.Do(ctx, func() error {
|
||||
_, err := aliDNSClient.DeleteDomainRecord(&client.DeleteDomainRecordRequest{
|
||||
RecordId: &deleteRecordConfig.RecordID,
|
||||
_, err := client.DeleteDomainRecord(&alidns.DeleteDomainRecordRequest{
|
||||
RecordId: &config.RecordID,
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *AliDNSDeleteRecordRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) Validate(configJSON string) error {
|
||||
var config DeleteRecordConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析阿里云删除记录规则配置失败: %v", err)
|
||||
config, err := ParseDeleteRecordConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.RecordID == "" {
|
||||
|
||||
@@ -2,7 +2,6 @@ package alidns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *DeleteAliDNSRecordExecutor) GetType() string {
|
||||
return RuleTypeDeleteRecord
|
||||
}
|
||||
|
||||
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行删除阿里云DNS记录规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return deleteAliDNSRecord(ctx, rule)
|
||||
config, err := ParseDeleteRecordConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DeleteRecord(ctx, account, config)
|
||||
}
|
||||
|
||||
+27
-31
@@ -2,54 +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"
|
||||
)
|
||||
|
||||
// getAliDNSClient initializes and returns an Alibaba Cloud DNS client based on the provided rule's account configuration. It retrieves the account configuration from the database, sets up the necessary credentials, and creates a new client instance for interacting with Alibaba Cloud DNS services.
|
||||
func getAliDNSClientFromRule(rule *model.Rule) (client *alidns.Client, err error) {
|
||||
var accountConfig AliDNSAccount
|
||||
|
||||
// get config
|
||||
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("解析阿里云账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
return getAliDNSClient(accountConfig.AK, accountConfig.SK)
|
||||
var account AliDNSAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func getAliDNSClient(ak string, sk string) (client *alidns.Client, err error) {
|
||||
// init aliyun account config
|
||||
// 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(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
|
||||
}
|
||||
|
||||
// getDeleteRecordConfig retrieves the configuration for deleting DNS records from the provided rule. It unmarshals the rule's configuration JSON into a DeleteRecordConfig structure and returns it.
|
||||
func getDeleteRecordConfig(rule *model.Rule) (*DeleteRecordConfig, error) {
|
||||
var deleteRecordConfig DeleteRecordConfig
|
||||
|
||||
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"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: "要发送的动态正文",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -16,7 +15,7 @@ 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
|
||||
}
|
||||
@@ -28,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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ const (
|
||||
RuleTypeRunAway = "rainyun-runaway"
|
||||
)
|
||||
|
||||
// pageSize 拉取云服务器列表时每页的条数
|
||||
const pageSize = 20
|
||||
|
||||
// runAwayConfirmText 跑路规则要求用户输入的免责声明
|
||||
const runAwayConfirmText = "我已知晓"
|
||||
|
||||
type RainyunWorkOrderRule struct {
|
||||
Title string `json:"title"`
|
||||
Msg string `json:"msg"`
|
||||
|
||||
+114
-88
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *RainyunWorkorderRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
@@ -51,12 +50,12 @@ func (v *RainyunRunAwayRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
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("请输入正确内容")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,30 @@ package rainyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// RainyunExecutor 雨云执行器
|
||||
// RainyunWorkorderExecutor 雨云工单执行器
|
||||
type RainyunWorkorderExecutor struct{}
|
||||
|
||||
func (e *RainyunWorkorderExecutor) GetType() string {
|
||||
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 雨云跑路执行器
|
||||
@@ -27,7 +35,20 @@ func (e *RainyunRunAwayExecutor) GetType() string {
|
||||
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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,10 +9,13 @@ import (
|
||||
"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
|
||||
}
|
||||
@@ -35,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 获取所有支持的规则类型
|
||||
|
||||
+53
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package gateway 定义消息网关的扩展接口与注册表。
|
||||
//
|
||||
// 与 drivers 的分工:driver 负责把一条规则执行出去(发邮件、发消息……),
|
||||
// gateway 负责把外部系统投递进来的消息派发给绑定在网关上的规则。
|
||||
// 新增一种网关只要实现 Gateway 并在 reg.go 里注册。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// Message 是外部系统投递进来的一条消息
|
||||
type Message struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Result 是一次投递的汇总结果
|
||||
type Result struct {
|
||||
Total int `json:"total"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
|
||||
// Task 是一次投递所需的全部输入:网关与待触发的规则都由上层从数据库取出
|
||||
type Task struct {
|
||||
Gateway *model.MessageGateway
|
||||
Rules []model.GatewayRule
|
||||
Message Message
|
||||
}
|
||||
|
||||
// Gateway 一种消息网关
|
||||
type Gateway interface {
|
||||
// GetType 获取网关类型标识
|
||||
GetType() string
|
||||
// Meta 返回给 WebUI 展示用的元数据
|
||||
Meta() meta.GatewayMeta
|
||||
// Deliver 把投递进来的消息派发给 task 里的规则
|
||||
Deliver(ctx context.Context, task *Task) (*Result, error)
|
||||
}
|
||||
|
||||
// Registry 网关注册表
|
||||
type Registry struct {
|
||||
gateways map[string]Gateway
|
||||
}
|
||||
|
||||
// NewRegistry 创建新的网关注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
gateways: make(map[string]Gateway),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册消息网关
|
||||
func (r *Registry) Register(g Gateway) {
|
||||
r.gateways[g.GetType()] = g
|
||||
}
|
||||
|
||||
// Resolve 按类型取网关实现,类型为空时回退到默认类型
|
||||
func (r *Registry) Resolve(gatewayType string) (Gateway, bool) {
|
||||
if gatewayType == "" {
|
||||
gatewayType = model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
g, exists := r.gateways[gatewayType]
|
||||
return g, exists
|
||||
}
|
||||
|
||||
// Deliver 按网关类型派发一次投递
|
||||
func (r *Registry) Deliver(ctx context.Context, gatewayType string, task *Task) (*Result, error) {
|
||||
g, exists := r.Resolve(gatewayType)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("不支持的消息网关类型: %s", gatewayType)
|
||||
}
|
||||
|
||||
return g.Deliver(ctx, task)
|
||||
}
|
||||
|
||||
// GetSupportedTypes 获取所有支持的网关类型
|
||||
func (r *Registry) GetSupportedTypes() []string {
|
||||
types := make([]string, 0, len(r.gateways))
|
||||
for t := range r.gateways {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// Metas 返回所有网关类型的元数据,按类型名排序
|
||||
func (r *Registry) Metas() []meta.GatewayMeta {
|
||||
metas := make([]meta.GatewayMeta, 0, len(r.gateways))
|
||||
for _, g := range r.gateways {
|
||||
metas = append(metas, g.Meta())
|
||||
}
|
||||
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
|
||||
return metas
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package gateway
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
registry *Registry
|
||||
)
|
||||
|
||||
// InitGatewayRegistry 返回全局唯一的消息网关注册表
|
||||
func InitGatewayRegistry() *Registry {
|
||||
once.Do(func() {
|
||||
r := NewRegistry()
|
||||
|
||||
// 注册所有消息网关
|
||||
r.Register(&WebhookGateway{})
|
||||
// 未来添加新网关类型时,在这里注册即可
|
||||
|
||||
registry = r
|
||||
})
|
||||
return registry
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// NewToken 生成一个消息网关的访问 Token
|
||||
func NewToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gw_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/runner"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// messageFields 规则配置里可以被投递消息覆盖的字段名
|
||||
var messageFields = []string{"msg", "message", "body"}
|
||||
|
||||
// titleField 规则配置里可以被投递标题覆盖的字段名
|
||||
const titleField = "title"
|
||||
|
||||
// TestMessage 是「测试」网关规则时使用的示例投递内容,
|
||||
// 让用户在页面上就能看到消息被替换后的效果。
|
||||
var TestMessage = Message{
|
||||
Title: "goodBaby 测试标题",
|
||||
Content: "这是一条来自消息网关的测试消息",
|
||||
}
|
||||
|
||||
// WebhookGateway 默认网关:外部系统 POST 一条消息来触发规则
|
||||
type WebhookGateway struct{}
|
||||
|
||||
func (g *WebhookGateway) GetType() string {
|
||||
return model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
func (g *WebhookGateway) Meta() meta.GatewayMeta {
|
||||
return meta.GatewayMeta{
|
||||
Type: model.GatewayTypeWebhook,
|
||||
Label: "Webhook",
|
||||
Description: "为外部系统生成一个 Webhook 地址,POST 一条消息即可触发绑定在该网关上的规则。",
|
||||
Docs: "docs/gateway-config.md",
|
||||
PayloadHint: `{"title": "监控告警", "message": "服务已恢复"}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver 逐条执行绑定在网关上的规则
|
||||
//
|
||||
// 单条规则失败不影响其它规则,失败原因汇总在 Result 里返回。
|
||||
func (g *WebhookGateway) Deliver(ctx context.Context, task *Task) (*Result, error) {
|
||||
// 初始化为空切片:nil 切片会被序列化成 JSON null,前端读 failed.length 时会报错
|
||||
failed := []string{}
|
||||
|
||||
for i := range task.Rules {
|
||||
rule := task.Rules[i]
|
||||
|
||||
executed := rule
|
||||
configJSON, applied, err := ApplyMessage(rule.ConfigJson, task.Message)
|
||||
if err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if applied {
|
||||
// 用投递进来的消息执行,但不改动数据库里的规则配置
|
||||
executed.ConfigJson = configJSON
|
||||
}
|
||||
if err := runner.ExecuteGatewayRuleWithContext(ctx, &executed, model.TriggerGateway); err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{Total: len(task.Rules), Failed: failed}, nil
|
||||
}
|
||||
|
||||
// ApplyMessage 把投递进来的消息覆盖到规则配置的消息字段上。
|
||||
//
|
||||
// 返回覆盖后的配置;applied 为 false 表示配置里没有可覆盖的消息字段
|
||||
// (例如「公开 GitHub 仓库」「删除 DNS 记录」),此时按规则里保存的配置执行。
|
||||
func ApplyMessage(configJSON string, msg Message) (string, bool, error) {
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return configJSON, false, fmt.Errorf("解析规则配置失败: %v", err)
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, key := range messageFields {
|
||||
if _, ok := config[key]; ok {
|
||||
if msg.Content != "" {
|
||||
config[key] = msg.Content
|
||||
}
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Title != "" {
|
||||
if _, ok := config[titleField]; ok {
|
||||
config[titleField] = msg.Title
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
return configJSON, false, nil
|
||||
}
|
||||
|
||||
out, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return configJSON, false, fmt.Errorf("生成规则配置失败: %v", err)
|
||||
}
|
||||
return string(out), true, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// GatewayMessagePlaceholder 是「由投递内容提供」的字段占位值。
|
||||
//
|
||||
// 这些字段在「网关规则」页面不由用户填写,真实内容来自 webhook 请求;
|
||||
// 但规则配置里必须保留该字段,投递时才知道要覆盖哪一个。
|
||||
const GatewayMessagePlaceholder = "由消息网关投递内容替换"
|
||||
|
||||
// GatewayMessageFields 返回所有由投递内容提供的字段
|
||||
func GatewayMessageFields(fields []Field) []Field {
|
||||
matched := make([]Field, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f.GatewayMessage {
|
||||
matched = append(matched, f)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// FillGatewayMessages 给配置里「由投递内容提供」的字段补上占位值。
|
||||
//
|
||||
// 网关规则提交的配置里通常没有 title / msg 这类字段(前端不让填),
|
||||
// 校验前先补齐,规则才会既通过校验、又能在投递时被覆盖。
|
||||
func FillGatewayMessages(configJSON string, fields []Field) string {
|
||||
messageFields := GatewayMessageFields(fields)
|
||||
if len(messageFields) == 0 || configJSON == "" {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, f := range messageFields {
|
||||
if value, ok := raw[f.Key].(string); ok && value != "" {
|
||||
continue
|
||||
}
|
||||
raw[f.Key] = GatewayMessagePlaceholder
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
filled, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return string(filled)
|
||||
}
|
||||
+15
-2
@@ -26,8 +26,11 @@ type Field struct {
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Help string `json:"help,omitempty"`
|
||||
// Secret 为 true 的字段在读取接口中会被掩码
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
// GatewayMessage 为 true 的字段由消息网关投递的内容提供(msg / title 之类):
|
||||
// 在「网关规则」页面不需要用户填写,投递时会被请求里的 message / title 替换
|
||||
GatewayMessage bool `json:"gateway_message,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// AccountMeta 账号类型的元数据
|
||||
@@ -51,6 +54,16 @@ type RuleMeta struct {
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
// GatewayMeta 消息网关类型的元数据
|
||||
type GatewayMeta struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Docs string `json:"docs,omitempty"`
|
||||
// PayloadHint WebUI 上展示的请求体示例
|
||||
PayloadHint string `json:"payload_hint,omitempty"`
|
||||
}
|
||||
|
||||
// SecretKeys 返回需要掩码的字段名
|
||||
func SecretKeys(fields []Field) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
|
||||
@@ -31,6 +31,16 @@ func Do(ctx context.Context, op func() error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Permanent 标记一个重试没有意义的错误(例如凭据无效、配置错误)
|
||||
//
|
||||
// driver 里凡是重试也救不回来的失败都包一层,避免白白退避几个小时。
|
||||
func Permanent(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
// ExecutionTimeout 返回配置中的规则执行超时时间
|
||||
func ExecutionTimeout() time.Duration {
|
||||
config, err := configs.GetConfig()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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("规则配置不能为空")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 字符串),用于接收提醒
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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('、')}`
|
||||
: '没有规则使用该账号。'
|
||||
|
||||
@@ -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: '第三方凭据',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -21,6 +21,7 @@ const triggerLabels: Record<string, string> = {
|
||||
timer: '定时触发',
|
||||
manual: '手动测试',
|
||||
remind: '提醒',
|
||||
gateway: '网关投递',
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user