diff --git a/README.md b/README.md index 13a2be4..e75d93f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,20 @@ cd web/frontend && npm run dev **第一个注册的用户自动成为管理员**。 +### API Key + +每个用户在注册时会自动生成 API Key,可在 WebUI 的“设置”页面查看。调用需要认证的 API 时,在请求头中任选其一: + +```http +X-API-Key: gb_ +``` + +或: + +```http +Authorization: Bearer gb_ +``` + ## 数据库 默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。 diff --git a/api/gateway/gateway.go b/api/gateway/gateway.go new file mode 100644 index 0000000..c1a0ab7 --- /dev/null +++ b/api/gateway/gateway.go @@ -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 +} diff --git a/api/rule/rule.go b/api/rule/rule.go index e3d14a6..fcd164a 100644 --- a/api/rule/rule.go +++ b/api/rule/rule.go @@ -67,6 +67,7 @@ func HandleCreateRule(c *gin.Context) { UID: userInfo.ID, Name: req.Name, TimerID: req.TimerID, + GatewayID: req.GatewayID, AccountID: req.AccountID, Type: req.Type, ConfigJson: req.ConfigJson, @@ -123,6 +124,7 @@ func HandleEditRule(c *gin.Context) { updated.Name = req.Name updated.Type = req.Type updated.TimerID = req.TimerID + updated.GatewayID = req.GatewayID updated.AccountID = req.AccountID updated.Enabled = boolOr(req.Enabled, existing.Enabled) // 前端提交的敏感字段可能是掩码占位符,用旧配置补回 diff --git a/api/rule/tool.go b/api/rule/tool.go index e98b188..2bf2450 100644 --- a/api/rule/tool.go +++ b/api/rule/tool.go @@ -59,12 +59,20 @@ func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType s } var count int64 - if err := gormDB.Model(&model.Timer{}). - Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil { - return err - } - if count == 0 { - return model.ErrValidation("关联的 Timer 不存在") + if rule.TimerID != 0 { + if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return model.ErrValidation("关联的 Timer 不存在") + } + } else { + if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", rule.GatewayID, rule.UID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return model.ErrValidation("关联的消息网关不存在") + } } if requiredAccountType == "" { diff --git a/api/user/tool.go b/api/user/tool.go index e613682..b780452 100644 --- a/api/user/tool.go +++ b/api/user/tool.go @@ -1,6 +1,8 @@ package user import ( + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -48,11 +50,44 @@ func createUser(u *model.User) error { return err } u.Password = hashedPassword + if u.APIKey == "" { + u.APIKey, err = generateAPIKey() + if err != nil { + return err + } + } u.CreateAt = time.Now().Unix() return gormDB.Create(u).Error } +func generateAPIKey() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "gb_" + hex.EncodeToString(b), nil +} + +func ensureAPIKey(user *model.User) error { + if user.APIKey != "" { + return nil + } + key, err := generateAPIKey() + if err != nil { + return err + } + gormDB, err := db.GetGormDB() + if err != nil { + return err + } + if err := gormDB.Model(&model.User{}).Where("id = ? AND (api_key IS NULL OR api_key = '')", user.ID).Update("api_key", key).Error; err != nil { + return err + } + user.APIKey = key + return nil +} + // IsUsernameTaken 用户名是否已被占用 func IsUsernameTaken(username string) (bool, error) { gormDB, err := db.GetGormDB() @@ -134,6 +169,9 @@ func GetUserByID(id uint) (*model.User, error) { } return nil, err } + if err := ensureAPIKey(&user); err != nil { + return nil, err + } return &user, nil } diff --git a/api/user/user.go b/api/user/user.go index 3dec652..6f195d2 100644 --- a/api/user/user.go +++ b/api/user/user.go @@ -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 { diff --git a/handler/db/db.go b/handler/db/db.go index 7ba35ad..eec5cb6 100644 --- a/handler/db/db.go +++ b/handler/db/db.go @@ -138,5 +138,6 @@ func AutoMigrate(gormDB *gorm.DB) error { &model.Rule{}, &model.Account{}, &model.ExecutionLog{}, + &model.MessageGateway{}, ) } diff --git a/main.go b/main.go index 1a6d10b..80d3170 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/ssdomei232/goodBaby/api/account" "github.com/ssdomei232/goodBaby/api/admin" "github.com/ssdomei232/goodBaby/api/dashboard" + "github.com/ssdomei232/goodBaby/api/gateway" apilog "github.com/ssdomei232/goodBaby/api/log" apimeta "github.com/ssdomei232/goodBaby/api/meta" "github.com/ssdomei232/goodBaby/api/rule" @@ -65,6 +66,7 @@ func main() { v1.GET("/site", apimeta.HandleGetSiteInfo) v1.POST("/user/registry", user.HandleRegistry) v1.POST("/user/login", user.HandleLogin) + v1.POST("/gateways/:token/webhook", gateway.HandleWebhook) } // 需要认证的路由组 @@ -114,6 +116,13 @@ func main() { accounts.DELETE("/:accountID", account.HandleDeleteAccount) } + gateways := authorized.Group("/gateways") + { + gateways.GET("/", gateway.HandleList) + gateways.POST("/", gateway.HandleCreate) + gateways.DELETE("/:gatewayID", gateway.HandleDelete) + } + logs := authorized.Group("/logs") { logs.GET("/", apilog.HandleGetLogs) @@ -151,7 +160,7 @@ func corsMiddleware(allowedOrigins []string) gin.HandlerFunc { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type") + c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization") c.Header("Vary", "Origin") } diff --git a/model/message_gateway.go b/model/message_gateway.go new file mode 100644 index 0000000..57339ce --- /dev/null +++ b/model/message_gateway.go @@ -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"` +} diff --git a/model/rule.go b/model/rule.go index eb404f9..7c6d363 100644 --- a/model/rule.go +++ b/model/rule.go @@ -2,10 +2,11 @@ package model // Rule 描述一个 Timer 到期后要执行的动作 type Rule struct { - ID uint `json:"id" gorm:"primaryKey"` - UID uint `gorm:"index" json:"uid"` - TimerID uint `gorm:"index" json:"timer_id"` - Name string `json:"name"` + ID uint `json:"id" gorm:"primaryKey"` + UID uint `gorm:"index" json:"uid"` + TimerID uint `gorm:"index" json:"timer_id"` + GatewayID uint `gorm:"index" json:"gateway_id"` + Name string `json:"name"` // 关联账号,部分规则类型(如钉钉)不需要账号,此时为 0 AccountID uint `json:"account_id"` // 规则类型,见 internal/ruleConfigChecker 注册表 @@ -20,6 +21,7 @@ type Rule struct { type RuleRequest struct { Name string `json:"name"` TimerID uint `json:"timer_id"` + GatewayID uint `json:"gateway_id"` AccountID uint `json:"account_id"` Type string `json:"type"` ConfigJson string `json:"config_json"` @@ -37,8 +39,11 @@ func (r *RuleRequest) Validate() error { if r.Type == "" { return ErrValidation("规则类型不能为空") } - if r.TimerID == 0 { - return ErrValidation("必须关联一个 Timer") + if r.TimerID == 0 && r.GatewayID == 0 { + return ErrValidation("必须关联一个定时器或消息网关") + } + if r.TimerID != 0 && r.GatewayID != 0 { + return ErrValidation("定时器和消息网关只能选择一个") } if r.ConfigJson == "" { return ErrValidation("规则配置不能为空") diff --git a/model/user.go b/model/user.go index 050e01c..b2fe43d 100644 --- a/model/user.go +++ b/model/user.go @@ -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 字符串),用于接收提醒 diff --git a/web/frontend/src/api/index.ts b/web/frontend/src/api/index.ts index c711667..d271452 100644 --- a/web/frontend/src/api/index.ts +++ b/web/frontend/src/api/index.ts @@ -7,6 +7,7 @@ import type { DashboardOverview, ExecutionLog, LogPage, + MessageGateway, Providers, Rule, RuleRequest, @@ -81,6 +82,12 @@ export const logApi = { clear: () => api.delete('/logs/'), } +export const gatewayApi = { + list: () => api.get('/gateways/'), + create: (name: string) => api.post('/gateways/', { name }), + remove: (id: number) => api.delete(`/gateways/${id}`), +} + export const adminApi = { getConfig: () => api.get('/admin/config'), updateConfig: (body: AdminConfig) => api.put('/admin/config', body), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 01e085e..ee4e37a 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -10,7 +10,8 @@ export interface UserInfo { create_at: number username: string is_admin: boolean - dingtalk_config: string | null + dingtalk_config: string | null + api_key: string } export interface Timer { @@ -40,6 +41,7 @@ export interface Rule { id: number uid: number timer_id: number + gateway_id: number name: string account_id: number type: string @@ -48,9 +50,12 @@ export interface Rule { create_at: number } +export interface MessageGateway { id: number; uid: number; name: string; token: string; create_at: number } + export interface RuleRequest { name: string timer_id: number + gateway_id?: number account_id: number type: string config_json: string diff --git a/web/frontend/src/layouts/MainLayout.vue b/web/frontend/src/layouts/MainLayout.vue index 638a4cd..0bc4fc6 100644 --- a/web/frontend/src/layouts/MainLayout.vue +++ b/web/frontend/src/layouts/MainLayout.vue @@ -7,6 +7,7 @@ import { Timer as TimerIcon, Operation, User, + Connection, Document, Setting, SwitchButton, @@ -38,6 +39,7 @@ const menus = [ { path: '/timers', title: '定时器', icon: TimerIcon }, { path: '/rules', title: '规则', icon: Operation }, { path: '/accounts', title: '账号', icon: User }, + { path: '/gateways', title: '消息网关', icon: Connection }, { path: '/logs', title: '执行日志', icon: Document }, { path: '/settings', title: '设置', icon: Setting }, ] diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts index ddab536..936848d 100644 --- a/web/frontend/src/router/index.ts +++ b/web/frontend/src/router/index.ts @@ -40,6 +40,7 @@ const router = createRouter({ component: () => import('@/views/AccountsView.vue'), meta: { title: '账号' }, }, + { path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } }, { path: 'logs', name: 'logs', diff --git a/web/frontend/src/views/GatewaysView.vue b/web/frontend/src/views/GatewaysView.vue new file mode 100644 index 0000000..374e479 --- /dev/null +++ b/web/frontend/src/views/GatewaysView.vue @@ -0,0 +1,18 @@ + + + diff --git a/web/frontend/src/views/RulesView.vue b/web/frontend/src/views/RulesView.vue index fa1c5d8..39b80c8 100644 --- a/web/frontend/src/views/RulesView.vue +++ b/web/frontend/src/views/RulesView.vue @@ -2,9 +2,9 @@ import { computed, onMounted, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { Plus, VideoPlay } from '@element-plus/icons-vue' -import { accountApi, ruleApi, timerApi } from '@/api' +import { accountApi, gatewayApi, ruleApi, timerApi } from '@/api' import { ApiError } from '@/api/client' -import type { Account, Rule, Timer } from '@/api/types' +import type { Account, MessageGateway, Rule, Timer } from '@/api/types' import { useMetaStore } from '@/stores/meta' import { formatDateTime } from '@/utils/format' import ConfigForm from '@/components/ConfigForm.vue' @@ -16,6 +16,7 @@ const metaStore = useMetaStore() const rules = ref([]) const timers = ref([]) +const gateways = ref([]) const accounts = ref([]) const loading = ref(false) const testingId = ref(null) @@ -30,6 +31,7 @@ const form = ref<{ name: string type: string timer_id: number + gateway_id: number account_id: number | undefined enabled: boolean config_json: string @@ -37,6 +39,7 @@ const form = ref<{ name: '', type: '', timer_id: 0, + gateway_id: 0, account_id: undefined, enabled: true, config_json: '', @@ -69,14 +72,16 @@ function accountName(id: number): string { async function refresh() { loading.value = true try { - const [ruleList, timerList, accountList] = await Promise.all([ + const [ruleList, timerList, accountList, gatewayList] = await Promise.all([ ruleApi.list(), timerApi.list(), accountApi.list(), + gatewayApi.list(), ]) rules.value = ruleList timers.value = timerList accounts.value = accountList + gateways.value = gatewayList } catch (error) { ElMessage.error(error instanceof ApiError ? error.message : '加载失败') } finally { @@ -85,7 +90,7 @@ async function refresh() { } function openCreate() { - if (timers.value.length === 0) { + if (timers.value.length === 0 && gateways.value.length === 0) { ElMessage.warning('请先在“定时器”页面创建一个定时器') return } @@ -94,6 +99,7 @@ function openCreate() { name: '', type: metaStore.ruleMetas[0]?.type ?? '', timer_id: timers.value[0]?.id ?? 0, + gateway_id: 0, account_id: undefined, enabled: true, config_json: '', @@ -107,6 +113,7 @@ function openEdit(rule: Rule) { name: rule.name, type: rule.type, timer_id: rule.timer_id, + gateway_id: rule.gateway_id || 0, account_id: rule.account_id || undefined, enabled: rule.enabled, config_json: rule.config_json, @@ -133,6 +140,7 @@ async function save() { name: form.value.name, type: form.value.type, timer_id: form.value.timer_id, + gateway_id: form.value.gateway_id, account_id: form.value.account_id ?? 0, enabled: form.value.enabled, config_json: form.value.config_json, @@ -162,6 +170,7 @@ async function toggleEnabled(rule: Rule) { name: rule.name, type: rule.type, timer_id: rule.timer_id, + gateway_id: rule.gateway_id, account_id: rule.account_id, enabled: rule.enabled, config_json: rule.config_json, @@ -308,10 +317,14 @@ onMounted(async () => {
{{ currentMeta.description }}
- - + +
规则只能选择一种触发方式
+ + + +
{ width: 200px; } +.source-select { width: 100%; margin-top: 8px; } +.source-hint { margin-bottom: 2px; } + @media (max-width: 768px) { .header-tools { flex-wrap: wrap; diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue index a74b161..0c9af66 100644 --- a/web/frontend/src/views/SettingsView.vue +++ b/web/frontend/src/views/SettingsView.vue @@ -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 () => { {{ formatDateTime(userStore.user?.create_at ?? 0) }} + + + + +