Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d89b7fe8f |
@@ -101,6 +101,20 @@ cd web/frontend && npm run dev
|
|||||||
|
|
||||||
**第一个注册的用户自动成为管理员**。
|
**第一个注册的用户自动成为管理员**。
|
||||||
|
|
||||||
|
### API Key
|
||||||
|
|
||||||
|
每个用户在注册时会自动生成 API Key,可在 WebUI 的“设置”页面查看。调用需要认证的 API 时,在请求头中任选其一:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: gb_<your-api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer gb_<your-api-key>
|
||||||
|
```
|
||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
|
||||||
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
|
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/response"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/user"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/db"
|
||||||
|
"github.com/ssdomei232/goodBaby/handler/runner"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type webhookRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func token() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "gw_" + hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleList(c *gin.Context) {
|
||||||
|
u, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbConn, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取网关失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var items []model.MessageGateway
|
||||||
|
if err := dbConn.Where("uid = ?", u.ID).Order("id DESC").Find(&items).Error; err != nil {
|
||||||
|
response.ServerError(c, "获取网关失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleCreate(c *gin.Context) {
|
||||||
|
u, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.MessageGatewayRequest
|
||||||
|
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Name) == "" {
|
||||||
|
response.BadRequest(c, "网关名称和规则不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbConn, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "创建网关失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := token()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "生成网关 Token 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := model.MessageGateway{UID: u.ID, Name: strings.TrimSpace(req.Name), Token: key, CreateAt: time.Now().Unix()}
|
||||||
|
if err := dbConn.Create(&item).Error; err != nil {
|
||||||
|
response.ServerError(c, "创建网关失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleDelete(c *gin.Context) {
|
||||||
|
u, err := user.GetUserInfoByGinCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbConn, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "删除网关失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := dbConn.Where("id = ? AND uid = ?", c.Param("gatewayID"), u.ID).Delete(&model.MessageGateway{}).Error; err != nil {
|
||||||
|
response.ServerError(c, "删除网关失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, "网关已删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleWebhook(c *gin.Context) {
|
||||||
|
dbConn, err := db.GetGormDB()
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "网关不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var gw model.MessageGateway
|
||||||
|
if err := dbConn.Where("token = ?", c.Param("token")).First(&gw).Error; err != nil {
|
||||||
|
response.Fail(c, http.StatusNotFound, "网关不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req webhookRequest
|
||||||
|
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Message) == "" {
|
||||||
|
response.BadRequest(c, "message 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rules []model.Rule
|
||||||
|
if err := dbConn.Where("uid = ? AND gateway_id = ? AND enabled = ?", gw.UID, gw.ID, true).Find(&rules).Error; err != nil {
|
||||||
|
response.ServerError(c, "读取网关规则失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
fails := make([]string, 0)
|
||||||
|
for i := range rules {
|
||||||
|
rule := rules[i]
|
||||||
|
cfg, err := overrideMessage(rule.ConfigJson, req.Title, req.Message)
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, rule.Name+": 规则不支持消息网关")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule.ConfigJson = cfg
|
||||||
|
if err := runner.ExecuteRuleWithContext(ctx, &rule, "webhook"); err != nil {
|
||||||
|
fails = append(fails, rule.Name+": "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"total": len(rules), "failed": fails})
|
||||||
|
}
|
||||||
|
|
||||||
|
func overrideMessage(raw, title, message string) (string, error) {
|
||||||
|
var obj map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
|
||||||
|
return raw, err
|
||||||
|
}
|
||||||
|
matched := false
|
||||||
|
if _, ok := obj["msg"]; ok {
|
||||||
|
obj["msg"] = message
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
if _, ok := obj["message"]; ok {
|
||||||
|
obj["message"] = message
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
if _, ok := obj["body"]; ok {
|
||||||
|
obj["body"] = message
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return raw, fmt.Errorf("message field not found")
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
if _, ok := obj["title"]; ok {
|
||||||
|
obj["title"] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(obj)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ func HandleCreateRule(c *gin.Context) {
|
|||||||
UID: userInfo.ID,
|
UID: userInfo.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
TimerID: req.TimerID,
|
TimerID: req.TimerID,
|
||||||
|
GatewayID: req.GatewayID,
|
||||||
AccountID: req.AccountID,
|
AccountID: req.AccountID,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
ConfigJson: req.ConfigJson,
|
ConfigJson: req.ConfigJson,
|
||||||
@@ -123,6 +124,7 @@ func HandleEditRule(c *gin.Context) {
|
|||||||
updated.Name = req.Name
|
updated.Name = req.Name
|
||||||
updated.Type = req.Type
|
updated.Type = req.Type
|
||||||
updated.TimerID = req.TimerID
|
updated.TimerID = req.TimerID
|
||||||
|
updated.GatewayID = req.GatewayID
|
||||||
updated.AccountID = req.AccountID
|
updated.AccountID = req.AccountID
|
||||||
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
|
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
|
||||||
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
|
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
|
||||||
|
|||||||
+10
-2
@@ -59,13 +59,21 @@ func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType s
|
|||||||
}
|
}
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
if err := gormDB.Model(&model.Timer{}).
|
if rule.TimerID != 0 {
|
||||||
Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
|
if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return model.ErrValidation("关联的 Timer 不存在")
|
return model.ErrValidation("关联的 Timer 不存在")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", rule.GatewayID, rule.UID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return model.ErrValidation("关联的消息网关不存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if requiredAccountType == "" {
|
if requiredAccountType == "" {
|
||||||
// 该类型不需要账号,忽略前端可能传来的 account_id
|
// 该类型不需要账号,忽略前端可能传来的 account_id
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -48,11 +50,44 @@ func createUser(u *model.User) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.Password = hashedPassword
|
u.Password = hashedPassword
|
||||||
|
if u.APIKey == "" {
|
||||||
|
u.APIKey, err = generateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
u.CreateAt = time.Now().Unix()
|
u.CreateAt = time.Now().Unix()
|
||||||
|
|
||||||
return gormDB.Create(u).Error
|
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 用户名是否已被占用
|
// IsUsernameTaken 用户名是否已被占用
|
||||||
func IsUsernameTaken(username string) (bool, error) {
|
func IsUsernameTaken(username string) (bool, error) {
|
||||||
gormDB, err := db.GetGormDB()
|
gormDB, err := db.GetGormDB()
|
||||||
@@ -134,6 +169,9 @@ func GetUserByID(id uint) (*model.User, error) {
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := ensureAPIKey(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,27 @@ func HandleUpdateNotifyConfig(c *gin.Context) {
|
|||||||
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
|
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
|
||||||
func AuthMiddleware() gin.HandlerFunc {
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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)
|
session := sessions.Default(c)
|
||||||
uid, ok := session.Get("uid").(uint)
|
uid, ok := session.Get("uid").(uint)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -138,5 +138,6 @@ func AutoMigrate(gormDB *gorm.DB) error {
|
|||||||
&model.Rule{},
|
&model.Rule{},
|
||||||
&model.Account{},
|
&model.Account{},
|
||||||
&model.ExecutionLog{},
|
&model.ExecutionLog{},
|
||||||
|
&model.MessageGateway{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/ssdomei232/goodBaby/api/account"
|
"github.com/ssdomei232/goodBaby/api/account"
|
||||||
"github.com/ssdomei232/goodBaby/api/admin"
|
"github.com/ssdomei232/goodBaby/api/admin"
|
||||||
"github.com/ssdomei232/goodBaby/api/dashboard"
|
"github.com/ssdomei232/goodBaby/api/dashboard"
|
||||||
|
"github.com/ssdomei232/goodBaby/api/gateway"
|
||||||
apilog "github.com/ssdomei232/goodBaby/api/log"
|
apilog "github.com/ssdomei232/goodBaby/api/log"
|
||||||
apimeta "github.com/ssdomei232/goodBaby/api/meta"
|
apimeta "github.com/ssdomei232/goodBaby/api/meta"
|
||||||
"github.com/ssdomei232/goodBaby/api/rule"
|
"github.com/ssdomei232/goodBaby/api/rule"
|
||||||
@@ -65,6 +66,7 @@ func main() {
|
|||||||
v1.GET("/site", apimeta.HandleGetSiteInfo)
|
v1.GET("/site", apimeta.HandleGetSiteInfo)
|
||||||
v1.POST("/user/registry", user.HandleRegistry)
|
v1.POST("/user/registry", user.HandleRegistry)
|
||||||
v1.POST("/user/login", user.HandleLogin)
|
v1.POST("/user/login", user.HandleLogin)
|
||||||
|
v1.POST("/gateways/:token/webhook", gateway.HandleWebhook)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要认证的路由组
|
// 需要认证的路由组
|
||||||
@@ -114,6 +116,13 @@ func main() {
|
|||||||
accounts.DELETE("/:accountID", account.HandleDeleteAccount)
|
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 := authorized.Group("/logs")
|
||||||
{
|
{
|
||||||
logs.GET("/", apilog.HandleGetLogs)
|
logs.GET("/", apilog.HandleGetLogs)
|
||||||
@@ -151,7 +160,7 @@ func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
|
|||||||
c.Header("Access-Control-Allow-Origin", origin)
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
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")
|
c.Header("Vary", "Origin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type MessageGateway struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UID uint `gorm:"index" json:"uid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `gorm:"uniqueIndex;size:67" json:"token"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageGatewayRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
+7
-2
@@ -5,6 +5,7 @@ type Rule struct {
|
|||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
UID uint `gorm:"index" json:"uid"`
|
UID uint `gorm:"index" json:"uid"`
|
||||||
TimerID uint `gorm:"index" json:"timer_id"`
|
TimerID uint `gorm:"index" json:"timer_id"`
|
||||||
|
GatewayID uint `gorm:"index" json:"gateway_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// 关联账号,部分规则类型(如钉钉)不需要账号,此时为 0
|
// 关联账号,部分规则类型(如钉钉)不需要账号,此时为 0
|
||||||
AccountID uint `json:"account_id"`
|
AccountID uint `json:"account_id"`
|
||||||
@@ -20,6 +21,7 @@ type Rule struct {
|
|||||||
type RuleRequest struct {
|
type RuleRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TimerID uint `json:"timer_id"`
|
TimerID uint `json:"timer_id"`
|
||||||
|
GatewayID uint `json:"gateway_id"`
|
||||||
AccountID uint `json:"account_id"`
|
AccountID uint `json:"account_id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ConfigJson string `json:"config_json"`
|
ConfigJson string `json:"config_json"`
|
||||||
@@ -37,8 +39,11 @@ func (r *RuleRequest) Validate() error {
|
|||||||
if r.Type == "" {
|
if r.Type == "" {
|
||||||
return ErrValidation("规则类型不能为空")
|
return ErrValidation("规则类型不能为空")
|
||||||
}
|
}
|
||||||
if r.TimerID == 0 {
|
if r.TimerID == 0 && r.GatewayID == 0 {
|
||||||
return ErrValidation("必须关联一个 Timer")
|
return ErrValidation("必须关联一个定时器或消息网关")
|
||||||
|
}
|
||||||
|
if r.TimerID != 0 && r.GatewayID != 0 {
|
||||||
|
return ErrValidation("定时器和消息网关只能选择一个")
|
||||||
}
|
}
|
||||||
if r.ConfigJson == "" {
|
if r.ConfigJson == "" {
|
||||||
return ErrValidation("规则配置不能为空")
|
return ErrValidation("规则配置不能为空")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type User struct {
|
|||||||
CreateAt int64 `json:"create_at"`
|
CreateAt int64 `json:"create_at"`
|
||||||
Username string `gorm:"uniqueIndex" json:"username"`
|
Username string `gorm:"uniqueIndex" json:"username"`
|
||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
|
APIKey string `gorm:"index;size:67" json:"api_key"`
|
||||||
// 管理员可以修改系统配置。第一个注册的用户自动成为管理员。
|
// 管理员可以修改系统配置。第一个注册的用户自动成为管理员。
|
||||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||||
// 钉钉机器人配置(JSON 字符串),用于接收提醒
|
// 钉钉机器人配置(JSON 字符串),用于接收提醒
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
DashboardOverview,
|
DashboardOverview,
|
||||||
ExecutionLog,
|
ExecutionLog,
|
||||||
LogPage,
|
LogPage,
|
||||||
|
MessageGateway,
|
||||||
Providers,
|
Providers,
|
||||||
Rule,
|
Rule,
|
||||||
RuleRequest,
|
RuleRequest,
|
||||||
@@ -81,6 +82,12 @@ export const logApi = {
|
|||||||
clear: () => api.delete<string>('/logs/'),
|
clear: () => api.delete<string>('/logs/'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const gatewayApi = {
|
||||||
|
list: () => api.get<MessageGateway[]>('/gateways/'),
|
||||||
|
create: (name: string) => api.post<MessageGateway>('/gateways/', { name }),
|
||||||
|
remove: (id: number) => api.delete<string>(`/gateways/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
|
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
|
||||||
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
|
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface UserInfo {
|
|||||||
username: string
|
username: string
|
||||||
is_admin: boolean
|
is_admin: boolean
|
||||||
dingtalk_config: string | null
|
dingtalk_config: string | null
|
||||||
|
api_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Timer {
|
export interface Timer {
|
||||||
@@ -40,6 +41,7 @@ export interface Rule {
|
|||||||
id: number
|
id: number
|
||||||
uid: number
|
uid: number
|
||||||
timer_id: number
|
timer_id: number
|
||||||
|
gateway_id: number
|
||||||
name: string
|
name: string
|
||||||
account_id: number
|
account_id: number
|
||||||
type: string
|
type: string
|
||||||
@@ -48,9 +50,12 @@ export interface Rule {
|
|||||||
create_at: number
|
create_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageGateway { id: number; uid: number; name: string; token: string; create_at: number }
|
||||||
|
|
||||||
export interface RuleRequest {
|
export interface RuleRequest {
|
||||||
name: string
|
name: string
|
||||||
timer_id: number
|
timer_id: number
|
||||||
|
gateway_id?: number
|
||||||
account_id: number
|
account_id: number
|
||||||
type: string
|
type: string
|
||||||
config_json: string
|
config_json: string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Timer as TimerIcon,
|
Timer as TimerIcon,
|
||||||
Operation,
|
Operation,
|
||||||
User,
|
User,
|
||||||
|
Connection,
|
||||||
Document,
|
Document,
|
||||||
Setting,
|
Setting,
|
||||||
SwitchButton,
|
SwitchButton,
|
||||||
@@ -38,6 +39,7 @@ const menus = [
|
|||||||
{ path: '/timers', title: '定时器', icon: TimerIcon },
|
{ path: '/timers', title: '定时器', icon: TimerIcon },
|
||||||
{ path: '/rules', title: '规则', icon: Operation },
|
{ path: '/rules', title: '规则', icon: Operation },
|
||||||
{ path: '/accounts', title: '账号', icon: User },
|
{ path: '/accounts', title: '账号', icon: User },
|
||||||
|
{ path: '/gateways', title: '消息网关', icon: Connection },
|
||||||
{ path: '/logs', title: '执行日志', icon: Document },
|
{ path: '/logs', title: '执行日志', icon: Document },
|
||||||
{ path: '/settings', title: '设置', icon: Setting },
|
{ path: '/settings', title: '设置', icon: Setting },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/AccountsView.vue'),
|
component: () => import('@/views/AccountsView.vue'),
|
||||||
meta: { title: '账号' },
|
meta: { title: '账号' },
|
||||||
},
|
},
|
||||||
|
{ path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } },
|
||||||
{
|
{
|
||||||
path: 'logs',
|
path: 'logs',
|
||||||
name: 'logs',
|
name: 'logs',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { CopyDocument, Delete, Link, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { gatewayApi, ruleApi } from '@/api'
|
||||||
|
import type { MessageGateway, Rule } from '@/api/types'
|
||||||
|
const gateways = ref<MessageGateway[]>([]), rules = ref<Rule[]>([]), name = ref(''), loading = ref(false), saving = ref(false)
|
||||||
|
const origin = window.location.origin
|
||||||
|
async function refresh() { loading.value = true; try { ;[gateways.value, rules.value] = await Promise.all([gatewayApi.list(), ruleApi.list()]) } catch { ElMessage.error('加载消息网关失败') } finally { loading.value = false } }
|
||||||
|
async function create() { if (!name.value.trim()) { ElMessage.warning('请输入网关名称'); return }; saving.value = true; try { await gatewayApi.create(name.value.trim()); name.value = ''; await refresh(); ElMessage.success('消息网关已创建') } catch (e) { ElMessage.error(e instanceof Error ? e.message : '创建失败') } finally { saving.value = false } }
|
||||||
|
async function remove(item: MessageGateway) { try { await ElMessageBox.confirm(`删除“${item.name}”?关联规则将无法继续通过此网关触发。`, '删除消息网关', { type: 'warning' }); await gatewayApi.remove(item.id); await refresh() } catch {} }
|
||||||
|
async function copy(url: string) { await navigator.clipboard?.writeText(url); ElMessage.success('Webhook 地址已复制') }
|
||||||
|
function endpoint(item: MessageGateway) { return `${origin}/api/v1/gateways/${item.token}/webhook` }
|
||||||
|
function ruleCount(item: MessageGateway) { return rules.value.filter(r => r.gateway_id === item.id).length }
|
||||||
|
onMounted(refresh)
|
||||||
|
</script>
|
||||||
|
<template><div class="gateway-page" v-loading="loading"><div class="page-header"><div><h2>消息网关</h2><div class="muted">创建入口,让外部系统通过 Webhook 触发通知规则</div></div><el-button type="primary" :icon="Plus">新建网关</el-button></div><el-card class="create-card"><div class="create-copy"><div class="create-icon"><el-icon :size="20"><Link /></el-icon></div><div><strong>新建 Webhook 入口</strong><div class="muted">创建后,在规则编辑页将通知规则关联到此网关</div></div></div><el-form class="create-form" @submit.prevent="create"><el-input v-model="name" size="large" placeholder="例如:生产环境告警" maxlength="64" clearable /><el-button type="primary" size="large" :loading="saving" @click="create">创建网关</el-button></el-form></el-card><div v-if="gateways.length" class="gateway-grid"><el-card v-for="item in gateways" :key="item.id" class="gateway-card"><div class="gateway-top"><div><div class="gateway-name">{{ item.name }}</div><div class="gateway-meta">{{ ruleCount(item) }} 条关联通知规则</div></div><el-button circle text type="danger" :icon="Delete" title="删除网关" @click="remove(item)" /></div><div class="endpoint-label">Webhook 地址</div><div class="endpoint"><code>{{ endpoint(item) }}</code><el-button text :icon="CopyDocument" title="复制地址" @click="copy(endpoint(item))" /></div><div class="payload-hint">POST JSON:<code>{ "message": "...", "title": "..." }</code></div></el-card></div><el-empty v-else-if="!loading" description="还没有消息网关" /></div></template>
|
||||||
|
<style scoped>.create-card{margin-bottom:20px}.create-copy{display:flex;align-items:center;gap:12px;margin-bottom:16px}.create-copy strong{font-size:15px}.create-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;color:var(--gb-primary-deep);background:var(--el-color-primary-light-9)}.create-form{display:flex;gap:10px;max-width:620px}.create-form .el-input{flex:1}.gateway-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}.gateway-card{min-width:0}.gateway-top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:22px}.gateway-name{font-size:17px;font-weight:700}.gateway-meta,.endpoint-label,.payload-hint{color:var(--el-text-color-secondary);font-size:12px}.endpoint-label{margin-bottom:6px}.endpoint{display:flex;align-items:center;gap:6px;min-width:0;padding:10px 12px;border-radius:8px;background:var(--gb-bg)}.endpoint code{flex:1;min-width:0;overflow-wrap:anywhere;color:var(--el-text-color-primary);font-size:12px}.payload-hint{margin-top:12px}.payload-hint code{color:var(--el-text-color-primary)}@media(max-width:600px){.create-form{flex-direction:column}.gateway-grid{grid-template-columns:1fr}}</style>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, VideoPlay } from '@element-plus/icons-vue'
|
import { Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||||
import { accountApi, ruleApi, timerApi } from '@/api'
|
import { accountApi, gatewayApi, ruleApi, timerApi } from '@/api'
|
||||||
import { ApiError } from '@/api/client'
|
import { ApiError } from '@/api/client'
|
||||||
import type { Account, Rule, Timer } from '@/api/types'
|
import type { Account, MessageGateway, Rule, Timer } from '@/api/types'
|
||||||
import { useMetaStore } from '@/stores/meta'
|
import { useMetaStore } from '@/stores/meta'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import ConfigForm from '@/components/ConfigForm.vue'
|
import ConfigForm from '@/components/ConfigForm.vue'
|
||||||
@@ -16,6 +16,7 @@ const metaStore = useMetaStore()
|
|||||||
|
|
||||||
const rules = ref<Rule[]>([])
|
const rules = ref<Rule[]>([])
|
||||||
const timers = ref<Timer[]>([])
|
const timers = ref<Timer[]>([])
|
||||||
|
const gateways = ref<MessageGateway[]>([])
|
||||||
const accounts = ref<Account[]>([])
|
const accounts = ref<Account[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const testingId = ref<number | null>(null)
|
const testingId = ref<number | null>(null)
|
||||||
@@ -30,6 +31,7 @@ const form = ref<{
|
|||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
timer_id: number
|
timer_id: number
|
||||||
|
gateway_id: number
|
||||||
account_id: number | undefined
|
account_id: number | undefined
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
config_json: string
|
config_json: string
|
||||||
@@ -37,6 +39,7 @@ const form = ref<{
|
|||||||
name: '',
|
name: '',
|
||||||
type: '',
|
type: '',
|
||||||
timer_id: 0,
|
timer_id: 0,
|
||||||
|
gateway_id: 0,
|
||||||
account_id: undefined,
|
account_id: undefined,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config_json: '',
|
config_json: '',
|
||||||
@@ -69,14 +72,16 @@ function accountName(id: number): string {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [ruleList, timerList, accountList] = await Promise.all([
|
const [ruleList, timerList, accountList, gatewayList] = await Promise.all([
|
||||||
ruleApi.list(),
|
ruleApi.list(),
|
||||||
timerApi.list(),
|
timerApi.list(),
|
||||||
accountApi.list(),
|
accountApi.list(),
|
||||||
|
gatewayApi.list(),
|
||||||
])
|
])
|
||||||
rules.value = ruleList
|
rules.value = ruleList
|
||||||
timers.value = timerList
|
timers.value = timerList
|
||||||
accounts.value = accountList
|
accounts.value = accountList
|
||||||
|
gateways.value = gatewayList
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,7 +90,7 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
if (timers.value.length === 0) {
|
if (timers.value.length === 0 && gateways.value.length === 0) {
|
||||||
ElMessage.warning('请先在“定时器”页面创建一个定时器')
|
ElMessage.warning('请先在“定时器”页面创建一个定时器')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,6 +99,7 @@ function openCreate() {
|
|||||||
name: '',
|
name: '',
|
||||||
type: metaStore.ruleMetas[0]?.type ?? '',
|
type: metaStore.ruleMetas[0]?.type ?? '',
|
||||||
timer_id: timers.value[0]?.id ?? 0,
|
timer_id: timers.value[0]?.id ?? 0,
|
||||||
|
gateway_id: 0,
|
||||||
account_id: undefined,
|
account_id: undefined,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config_json: '',
|
config_json: '',
|
||||||
@@ -107,6 +113,7 @@ function openEdit(rule: Rule) {
|
|||||||
name: rule.name,
|
name: rule.name,
|
||||||
type: rule.type,
|
type: rule.type,
|
||||||
timer_id: rule.timer_id,
|
timer_id: rule.timer_id,
|
||||||
|
gateway_id: rule.gateway_id || 0,
|
||||||
account_id: rule.account_id || undefined,
|
account_id: rule.account_id || undefined,
|
||||||
enabled: rule.enabled,
|
enabled: rule.enabled,
|
||||||
config_json: rule.config_json,
|
config_json: rule.config_json,
|
||||||
@@ -133,6 +140,7 @@ async function save() {
|
|||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
type: form.value.type,
|
type: form.value.type,
|
||||||
timer_id: form.value.timer_id,
|
timer_id: form.value.timer_id,
|
||||||
|
gateway_id: form.value.gateway_id,
|
||||||
account_id: form.value.account_id ?? 0,
|
account_id: form.value.account_id ?? 0,
|
||||||
enabled: form.value.enabled,
|
enabled: form.value.enabled,
|
||||||
config_json: form.value.config_json,
|
config_json: form.value.config_json,
|
||||||
@@ -162,6 +170,7 @@ async function toggleEnabled(rule: Rule) {
|
|||||||
name: rule.name,
|
name: rule.name,
|
||||||
type: rule.type,
|
type: rule.type,
|
||||||
timer_id: rule.timer_id,
|
timer_id: rule.timer_id,
|
||||||
|
gateway_id: rule.gateway_id,
|
||||||
account_id: rule.account_id,
|
account_id: rule.account_id,
|
||||||
enabled: rule.enabled,
|
enabled: rule.enabled,
|
||||||
config_json: rule.config_json,
|
config_json: rule.config_json,
|
||||||
@@ -308,10 +317,14 @@ onMounted(async () => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
|
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="关联定时器" required>
|
<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-option v-for="t in timers" :key="t.id" :label="t.name" :value="t.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select v-model="form.gateway_id" clearable placeholder="消息网关(可选)" style="width: 100%; margin-top: 8px" @change="form.gateway_id && (form.timer_id = 0)">
|
||||||
|
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
|
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
|
||||||
<el-select
|
<el-select
|
||||||
@@ -363,6 +376,9 @@ onMounted(async () => {
|
|||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-select { width: 100%; margin-top: 8px; }
|
||||||
|
.source-hint { margin-bottom: 2px; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-tools {
|
.header-tools {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
const isAdmin = computed(() => userStore.user?.is_admin === true)
|
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 adminLoading = ref(false)
|
||||||
const adminSaving = ref(false)
|
const adminSaving = ref(false)
|
||||||
@@ -166,6 +173,13 @@ onMounted(async () => {
|
|||||||
<el-descriptions-item label="注册时间">
|
<el-descriptions-item label="注册时间">
|
||||||
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
|
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
|
||||||
</el-descriptions-item>
|
</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-descriptions>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user