feat: 完善后端并新增 Vue3 WebUI
后端: - 修复登录校验、DB 并发(单例+WAL+纯Go sqlite)、会话密钥持久化等核心缺陷 - 新增 Timer CRUD/签到/手动触发、执行日志、仪表盘、驱动元数据等 API - 统一驱动接口: 带 context 的执行器、真实错误返回、敏感字段掩码、连通性测试 - 补全钉钉执行器; Timer 触发/提醒去重; 配置支持默认值与环境变量覆盖 前端: - Vite+Vue3+TS+Pinia+Element Plus, 由驱动元数据动态渲染配置表单 - 登录/仪表盘(倒计时环)/定时器/规则/账号/日志/设置页面, 支持暗色模式 - 主题色 #66ccff, 信号波纹 logo, go:embed 单二进制部署 构建: Dockerfile 增加前端构建阶段; 补充各驱动配置文档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+152
-104
@@ -1,188 +1,236 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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/meta"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// 获取用户的所有规则
|
||||
// HandleGetAllRules 获取用户的所有规则,支持按 timer_id 过滤
|
||||
func HandleGetAllRules(c *gin.Context) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "获取规则失败"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"})
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
var rules []model.Rule
|
||||
result := gormDB.Where("uid = ?", userInfo.ID).Find(&rules)
|
||||
if result.Error != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "获取规则失败"})
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"code": 200, "data": rules})
|
||||
query := gormDB.Where("uid = ?", userInfo.ID)
|
||||
if raw := c.Query("timer_id"); raw != "" {
|
||||
timerID, err := parseID(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "timer_id 格式错误")
|
||||
return
|
||||
}
|
||||
query = query.Where("timer_id = ?", timerID)
|
||||
}
|
||||
|
||||
rules := []model.Rule{}
|
||||
if err := query.Order("id DESC").Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "获取规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskRules(rules))
|
||||
}
|
||||
|
||||
// 创建新规则
|
||||
// HandleCreateRule 创建新规则
|
||||
func HandleCreateRule(c *gin.Context) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"})
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
var newRule model.Rule
|
||||
if err := c.BindJSON(&newRule); err != nil {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"})
|
||||
var req model.RuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if newRule.Type == "" {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "规则类型不能为空"})
|
||||
newRule := model.Rule{
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
TimerID: req.TimerID,
|
||||
AccountID: req.AccountID,
|
||||
Type: req.Type,
|
||||
ConfigJson: req.ConfigJson,
|
||||
Enabled: boolOr(req.Enabled, true),
|
||||
CreateAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := validateRule(&req, &newRule); err != nil {
|
||||
response.FromError(c, err, "创建规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
if newRule.Name == "" {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "规则名称不能为空"})
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查关联账号和 Timer 是否存在(可以没有关联账号)
|
||||
if exist, err := checkRuleConfigAccountAndTimerExist(newRule); err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
|
||||
return
|
||||
} else if !exist {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "关联的账户或Timer不存在"})
|
||||
if err := gormDB.Create(&newRule).Error; err != nil {
|
||||
response.ServerError(c, "创建规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 规则校验
|
||||
validatorRegistry := ruleConfigChecker.InitValidatorRegistry()
|
||||
if err := validatorRegistry.Validate(newRule.Type, newRule.ConfigJson); err != nil {
|
||||
c.JSON(400, gin.H{"code": 400, "data": fmt.Sprintf("规则配置验证失败: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
newRule.UID = userInfo.ID
|
||||
result := gormDB.Create(&newRule)
|
||||
if result.Error != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "创建规则失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"code": 200, "data": "规则创建成功"})
|
||||
response.OK(c, maskRule(newRule))
|
||||
}
|
||||
|
||||
// 编辑规则
|
||||
// HandleEditRule 编辑规则
|
||||
func HandleEditRule(c *gin.Context) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"})
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := strconv.Atoi(c.Param("ruleID"))
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "规则 ID 格式错误"})
|
||||
return
|
||||
}
|
||||
var rule model.Rule
|
||||
result := gormDB.Where("id = ? AND uid = ?", ruleID, userInfo.ID).First(&rule)
|
||||
if result.Error != nil {
|
||||
c.JSON(404, gin.H{"code": 404, "data": "规则不存在"})
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
var updatedRule model.Rule
|
||||
if err := c.BindJSON(&updatedRule); err != nil {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"})
|
||||
existing, err := findRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if updatedRule.Type == "" {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "规则类型不能为空"})
|
||||
var req model.RuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if updatedRule.Name == "" {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "规则名称不能为空"})
|
||||
updated := *existing
|
||||
updated.Name = req.Name
|
||||
updated.Type = req.Type
|
||||
updated.TimerID = req.TimerID
|
||||
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 := validateRule(&req, &updated); err != nil {
|
||||
response.FromError(c, err, "更新规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查关联账号和 Timer 是否存在(可以没有关联账号)
|
||||
if exist, err := checkRuleConfigAccountAndTimerExist(updatedRule); err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
|
||||
return
|
||||
} else if !exist {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "关联的账户或Timer不存在"})
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 规则校验
|
||||
validatorRegistry := ruleConfigChecker.InitValidatorRegistry()
|
||||
if err := validatorRegistry.Validate(updatedRule.Type, updatedRule.ConfigJson); err != nil {
|
||||
c.JSON(400, gin.H{"code": 400, "data": fmt.Sprintf("规则配置验证失败: %s", err.Error())})
|
||||
if err := gormDB.Save(&updated).Error; err != nil {
|
||||
response.ServerError(c, "更新规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
rule.Name = updatedRule.Name
|
||||
rule.Type = updatedRule.Type
|
||||
rule.ConfigJson = updatedRule.ConfigJson
|
||||
rule.AccountID = updatedRule.AccountID
|
||||
rule.TimerID = updatedRule.TimerID
|
||||
|
||||
result = gormDB.Save(&rule)
|
||||
if result.Error != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "更新规则失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"code": 200, "data": "规则更新成功"})
|
||||
response.OK(c, maskRule(updated))
|
||||
}
|
||||
|
||||
// 根据删除规则
|
||||
// HandleDeleteRule 删除规则
|
||||
func HandleDeleteRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"})
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := strconv.Atoi(c.Param("ruleID"))
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"code": 400, "data": "规则 ID 格式错误"})
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
err = DeleteRuleByID(uint(ruleID), userInfo.ID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "data": "删除规则失败"})
|
||||
if err := DeleteRuleByID(ruleID, userInfo.ID); err != nil {
|
||||
response.ServerError(c, "删除规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"code": 200, "data": "规则删除成功"})
|
||||
response.OK(c, "规则删除成功")
|
||||
}
|
||||
|
||||
// HandleTestRule 立即执行一次规则用于验证配置
|
||||
//
|
||||
// 使用较短的超时,避免在 WebUI 上等待数小时的指数退避。
|
||||
func HandleTestRule(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 := findRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := runner.ExecuteRuleWithContext(ctx, target, model.TriggerManual); err != nil {
|
||||
response.BadRequest(c, fmt.Sprintf("规则执行失败: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "规则执行成功")
|
||||
}
|
||||
|
||||
// validateRule 校验规则的通用字段、关联对象与类型专属配置
|
||||
func validateRule(req *model.RuleRequest, rule *model.Rule) error {
|
||||
if err := req.Validate(); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ruleConfigChecker.InitValidatorRegistry().Validate(req.Type, rule.ConfigJson); err != nil {
|
||||
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unmaskRuleConfig 把提交上来的掩码字段还原成旧值
|
||||
func unmaskRuleConfig(ruleType, newConfig, oldConfig string) string {
|
||||
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType)
|
||||
if !ok {
|
||||
return newConfig
|
||||
}
|
||||
return meta.Unmask(newConfig, oldConfig, ruleMeta.Fields)
|
||||
}
|
||||
|
||||
+80
-27
@@ -1,52 +1,105 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// 根据 ID 删除规则
|
||||
func parseID(raw string) (uint, error) {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func boolOr(v *bool, fallback bool) bool {
|
||||
if v == nil {
|
||||
return fallback
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// findRule 查找属于该用户的规则
|
||||
func findRule(ruleID, uid uint) (*model.Rule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var target model.Rule
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
result := gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{})
|
||||
return result.Error
|
||||
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error
|
||||
}
|
||||
|
||||
// 检查关联账号和 Timer 是否存在(可以没有关联账号)
|
||||
func checkRuleConfigAccountAndTimerExist(rule model.Rule) (exist bool, err error) {
|
||||
// checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户
|
||||
//
|
||||
// requiredAccountType 为空表示该规则类型不需要账号。
|
||||
func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if rule.TimerID == 0 {
|
||||
return false, nil
|
||||
return err
|
||||
}
|
||||
|
||||
var count int64
|
||||
if rule.AccountID != 0 {
|
||||
result := gormDB.Model(&model.Account{}).Where("id = ? AND uid = ?", rule.AccountID, rule.UID).Count(&count)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
if count == 0 {
|
||||
return false, nil
|
||||
}
|
||||
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 {
|
||||
result := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
if count == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if requiredAccountType == "" {
|
||||
// 该类型不需要账号,忽略前端可能传来的 account_id
|
||||
rule.AccountID = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
return true, 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)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func maskRules(rules []model.Rule) []model.Rule {
|
||||
masked := make([]model.Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
masked = append(masked, maskRule(r))
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user