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:
mei
2026-07-26 22:19:41 +08:00
co-authored by Claude Fable 5
parent 89e8b7fd66
commit 79774070fc
110 changed files with 8936 additions and 849 deletions
+171 -54
View File
@@ -1,116 +1,232 @@
package account
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/rule"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/model"
)
// 添加账号
// HandleAddAccount 添加账号
func HandleAddAccount(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 newAccount model.Account
if err := c.BindJSON(&newAccount); err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入参数错误"})
var req model.AccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
newAccount.UID = userInfo.ID
result := gormDB.Create(&newAccount)
if result.Error != nil {
c.JSON(500, gin.H{"code": 500, "data": "添加账号失败"})
if err := req.Validate(); err != nil {
response.FromError(c, err, "添加账号失败")
return
}
c.JSON(200, gin.H{"code": 200, "data": "添加账号成功"})
// 之前这里完全没有走校验器,任何 JSON 都能存进去,直到规则触发才暴露问题
registry := accountConfigChecker.InitValidatorRegistry()
if err := registry.Validate(req.Type, req.Config); err != nil {
response.BadRequest(c, err.Error())
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
newAccount := model.Account{
UID: userInfo.ID,
Name: req.Name,
Type: req.Type,
Config: req.Config,
CreateAt: time.Now().Unix(),
}
if err := gormDB.Create(&newAccount).Error; err != nil {
response.ServerError(c, "添加账号失败")
return
}
response.OK(c, maskAccount(newAccount))
}
// 获取用户的所有账号
// HandleGetAllAccounts 获取用户的所有账号
func HandleGetAllAccounts(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 accounts []model.Account
result := gormDB.Where("uid = ?", userInfo.ID).Find(&accounts)
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": accounts})
query := gormDB.Where("uid = ?", userInfo.ID)
if accountType := c.Query("type"); accountType != "" {
query = query.Where("type = ?", accountType)
}
accounts := []model.Account{}
if err := query.Order("id DESC").Find(&accounts).Error; err != nil {
response.ServerError(c, "获取账号失败")
return
}
response.OK(c, maskAccounts(accounts))
}
// 检查删除账号请求
// HandleEditAccount 编辑账号
func HandleEditAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := parseID(c.Param("accountID"))
if err != nil {
response.BadRequest(c, "账号ID格式错误")
return
}
existing, err := findAccount(accountID, userInfo.ID)
if err != nil {
response.NotFound(c, "账号不存在")
return
}
var req model.AccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "更新账号失败")
return
}
if req.Type != existing.Type {
response.BadRequest(c, "不支持修改账号类型,请新建账号")
return
}
registry := accountConfigChecker.InitValidatorRegistry()
// 前端提交的密码等字段可能是掩码占位符,用旧配置补回
config := meta.Unmask(req.Config, existing.Config, registry.FieldsOf(req.Type))
if err := registry.Validate(req.Type, config); err != nil {
response.BadRequest(c, err.Error())
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
existing.Name = req.Name
existing.Config = config
if err := gormDB.Save(existing).Error; err != nil {
response.ServerError(c, "更新账号失败")
return
}
response.OK(c, maskAccount(*existing))
}
// HandleTestAccount 测试账号凭据是否可用
func HandleTestAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := parseID(c.Param("accountID"))
if err != nil {
response.BadRequest(c, "账号ID格式错误")
return
}
existing, err := findAccount(accountID, userInfo.ID)
if err != nil {
response.NotFound(c, "账号不存在")
return
}
registry := accountConfigChecker.InitValidatorRegistry()
if err := registry.Test(existing.Type, existing.Config); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, "账号可用")
}
// HandleCheckDeleteAccount 检查删除账号请求
//
// 删除账号会同时删除相关规则,先请求该接口获取受影响的规则
func HandleCheckDeleteAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"})
return
}
accountID, err := strconv.Atoi(c.Param("accountID"))
if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "账号ID格式错误"})
response.Unauthorized(c, "获取用户信息失败")
return
}
rules, err := getRulesByAccountID(uint(accountID), userInfo.ID)
accountID, err := parseID(c.Param("accountID"))
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取相关规则失败"})
response.BadRequest(c, "账号ID格式错误")
return
}
c.JSON(200, gin.H{"code": 200, "data": rules})
rules, err := getRulesByAccountID(accountID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取相关规则失败")
return
}
response.OK(c, rules)
}
// 删除账号
// HandleDeleteAccount 删除账号及其关联规则
func HandleDeleteAccount(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
c.JSON(401, gin.H{"code": 401, "data": "获取用户信息失败"})
response.Unauthorized(c, "获取用户信息失败")
return
}
accountID, err := strconv.Atoi(c.Param("accountID"))
accountID, err := parseID(c.Param("accountID"))
if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "账号ID格式错误"})
response.BadRequest(c, "账号ID格式错误")
return
}
// 删除相关规则
rules, err := getRulesByAccountID(uint(accountID), userInfo.ID)
rules, err := getRulesByAccountID(accountID, userInfo.ID)
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取相关规则失败"})
response.ServerError(c, "获取相关规则失败")
return
}
for _, oneRule := range rules {
err = rule.DeleteRuleByID(oneRule.ID, userInfo.ID)
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "删除相关规则失败"})
if err := rule.DeleteRuleByID(oneRule.ID, userInfo.ID); err != nil {
response.ServerError(c, "删除相关规则失败")
return
}
}
@@ -118,13 +234,14 @@ func HandleDeleteAccount(c *gin.Context) {
// 删除账号
gormDB, err := db.GetGormDB()
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "服务器内部错误"})
response.ServerError(c, "服务器内部错误")
return
}
result := gormDB.Where("id = ? AND uid = ?", accountID, userInfo.ID).Delete(&model.Account{})
if result.Error != nil {
c.JSON(500, gin.H{"code": 500, "data": "删除账号失败"})
if err := gormDB.Where("id = ? AND uid = ?", accountID, userInfo.ID).
Delete(&model.Account{}).Error; err != nil {
response.ServerError(c, "删除账号失败")
return
}
c.JSON(200, gin.H{"code": 200, "data": "删除账号成功"})
response.OK(c, "删除账号成功")
}
+45 -5
View File
@@ -1,21 +1,61 @@
package account
import (
"strconv"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/meta"
"github.com/ssdomei232/goodBaby/model"
)
// 获取账号相关的规则列表
func parseID(raw string) (uint, error) {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
// findAccount 查找属于该用户的账号
func findAccount(accountID, uid uint) (*model.Account, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var account model.Account
if err := gormDB.Where("id = ? AND uid = ?", accountID, uid).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// getRulesByAccountID 获取账号相关的规则列表
func getRulesByAccountID(accountID uint, uid uint) ([]*model.Rule, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var rules []*model.Rule
result := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules)
if result.Error != nil {
return nil, result.Error
rules := []*model.Rule{}
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)
account.Config = meta.Mask(account.Config, fields)
return account
}
func maskAccounts(accounts []model.Account) []model.Account {
masked := make([]model.Account, 0, len(accounts))
for _, a := range accounts {
masked = append(masked, maskAccount(a))
}
return masked
}
+80
View File
@@ -0,0 +1,80 @@
// Package dashboard 汇总首页需要的统计数据
package dashboard
import (
"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/model"
)
// HandleGetOverview 返回首页概览:各类对象数量、最紧急的 Timer、最近的执行日志
func HandleGetOverview(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
}
timers := []model.Timer{}
if err := gormDB.Where("uid = ?", userInfo.ID).Find(&timers).Error; err != nil {
response.ServerError(c, "获取统计数据失败")
return
}
var ruleCount, accountCount int64
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
recentLogs := []model.ExecutionLog{}
gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Limit(10).Find(&recentLogs)
now := time.Now().Unix()
var (
enabledCount int
triggeredCount int
urgent *model.Timer
)
for i := range timers {
timer := timers[i]
if !timer.Enabled {
continue
}
enabledCount++
if timer.Triggered {
triggeredCount++
continue
}
// 剩余时间最短的启用中 Timer
if urgent == nil || timer.NextSignTime() < urgent.NextSignTime() {
urgent = &timers[i]
}
}
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,
}
if urgent != nil {
overview["urgent_timer"] = urgent
overview["urgent_seconds_left"] = urgent.NextSignTime() - now
}
response.OK(c, overview)
}
+102
View File
@@ -0,0 +1,102 @@
// Package log 提供规则执行日志的查询接口
package log
import (
"strconv"
"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/model"
)
const (
defaultPageSize = 20
maxPageSize = 200
)
// HandleGetLogs 分页查询当前用户的执行日志
func HandleGetLogs(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
}
page := intQuery(c, "page", 1, 1, 1<<20)
pageSize := intQuery(c, "page_size", defaultPageSize, 1, maxPageSize)
query := gormDB.Model(&model.ExecutionLog{}).Where("uid = ?", userInfo.ID)
if ruleID := c.Query("rule_id"); ruleID != "" {
query = query.Where("rule_id = ?", ruleID)
}
if success := c.Query("success"); success == "true" || success == "false" {
query = query.Where("success = ?", success == "true")
}
var total int64
if err := query.Count(&total).Error; err != nil {
response.ServerError(c, "获取执行日志失败")
return
}
logs := []model.ExecutionLog{}
if err := query.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&logs).Error; err != nil {
response.ServerError(c, "获取执行日志失败")
return
}
response.OK(c, gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"items": logs,
})
}
// HandleClearLogs 清空当前用户的执行日志
func HandleClearLogs(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
}
if err := gormDB.Where("uid = ?", userInfo.ID).Delete(&model.ExecutionLog{}).Error; err != nil {
response.ServerError(c, "清空执行日志失败")
return
}
response.OK(c, "已清空执行日志")
}
func intQuery(c *gin.Context, key string, fallback, min, max int) int {
raw := c.Query(key)
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value < min {
return fallback
}
if value > max {
return max
}
return value
}
+44
View File
@@ -0,0 +1,44 @@
// Package meta 向 WebUI 暴露驱动能力与站点信息
package meta
import (
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/api/user"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
)
// HandleGetProviders 返回所有账号类型与规则类型的元数据
//
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
func HandleGetProviders(c *gin.Context) {
response.OK(c, gin.H{
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
})
}
// HandleGetSiteInfo 返回无需登录即可获取的站点信息
func HandleGetSiteInfo(c *gin.Context) {
config, err := configs.GetConfig()
if err != nil {
response.ServerError(c, "获取配置失败")
return
}
userCount, err := user.CountUsers()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
response.OK(c, gin.H{
"name": "goodBaby",
// 系统内还没有用户时始终允许注册,方便全新部署创建第一个账号
"enable_registry": config.EnableRegistry || userCount == 0,
"need_initial_user": userCount == 0,
"check_interval_minutes": config.CheckIntervalMinutes,
})
}
+52
View File
@@ -0,0 +1,52 @@
// Package response 统一 API 的返回格式
//
// {"code": 200, "data": ...}
//
// 出错时 data 为可以直接展示给用户的错误信息。
package response
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/model"
)
// OK 返回成功响应
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": data})
}
// Fail 返回错误响应
func Fail(c *gin.Context, code int, msg string) {
c.JSON(code, gin.H{"code": code, "data": msg})
}
// AbortWith 返回错误响应并终止后续 handler
func AbortWith(c *gin.Context, code int, msg string) {
c.AbortWithStatusJSON(code, gin.H{"code": code, "data": msg})
}
// BadRequest 参数错误
func BadRequest(c *gin.Context, msg string) { Fail(c, http.StatusBadRequest, msg) }
// Unauthorized 未登录
func Unauthorized(c *gin.Context, msg string) { Fail(c, http.StatusUnauthorized, msg) }
// NotFound 资源不存在
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) }
// FromError 按错误类型选择合适的状态码:
// 参数校验错误返回 400,其余返回 500
func FromError(c *gin.Context, err error, fallback string) {
var validationErr model.ValidationError
if errors.As(err, &validationErr) {
BadRequest(c, validationErr.Msg)
return
}
ServerError(c, fallback)
}
+152 -104
View File
@@ -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
View File
@@ -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
}
+289
View File
@@ -0,0 +1,289 @@
package timer
import (
"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/model"
"gorm.io/gorm"
)
// HandleGetAllTimers 获取当前用户的所有 Timer
func HandleGetAllTimers(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
}
timers := []model.Timer{}
if err := gormDB.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&timers).Error; err != nil {
response.ServerError(c, "获取 Timer 失败")
return
}
response.OK(c, timers)
}
// HandleGetTimer 获取单个 Timer 及其关联规则数量
func HandleGetTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
ruleCount, err := countRules(timerID, userInfo.ID)
if err != nil {
response.ServerError(c, "获取关联规则失败")
return
}
response.OK(c, gin.H{"timer": timer, "rule_count": ruleCount})
}
// HandleCreateTimer 创建 Timer
func HandleCreateTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.TimerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "创建 Timer 失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
now := time.Now().Unix()
timer := model.Timer{
UID: userInfo.ID,
Name: req.Name,
Description: req.Description,
Enabled: boolOr(req.Enabled, true),
SignDerationSeconds: req.SignDerationSeconds,
RemindTimeSeconds: req.RemindTimeSeconds,
// 创建即视为完成一次签到,从当前时间开始计时
LastSign: now,
CreateAt: now,
}
if err := gormDB.Create(&timer).Error; err != nil {
response.ServerError(c, "创建 Timer 失败")
return
}
response.OK(c, timer)
}
// HandleEditTimer 编辑 Timer
func HandleEditTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
var req model.TimerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入参数错误")
return
}
if err := req.Validate(); err != nil {
response.FromError(c, err, "更新 Timer 失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
timer.Name = req.Name
timer.Description = req.Description
timer.Enabled = boolOr(req.Enabled, timer.Enabled)
timer.SignDerationSeconds = req.SignDerationSeconds
timer.RemindTimeSeconds = req.RemindTimeSeconds
if err := gormDB.Save(timer).Error; err != nil {
response.ServerError(c, "更新 Timer 失败")
return
}
response.OK(c, timer)
}
// HandleSignTimer 签到:重置计时并解除已触发状态
func HandleSignTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
timer, err := findTimer(timerID, userInfo.ID)
if err != nil {
response.NotFound(c, "Timer 不存在")
return
}
if err := signTimer(timer); err != nil {
response.ServerError(c, "签到失败")
return
}
response.OK(c, timer)
}
// HandleSignAll 一键签到当前用户的所有启用中的 Timer
func HandleSignAll(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
}
now := time.Now().Unix()
result := gormDB.Model(&model.Timer{}).
Where("uid = ? AND enabled = ?", userInfo.ID, true).
Updates(map[string]any{
"last_sign": now,
"last_remind": 0,
"triggered": false,
})
if result.Error != nil {
response.ServerError(c, "签到失败")
return
}
response.OK(c, gin.H{"signed": result.RowsAffected, "last_sign": now})
}
// HandleDeleteTimer 删除 Timer
//
// Timer 被删除后,挂在它下面的规则就没有触发来源了,因此一并删除。
func HandleDeleteTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
if _, err := findTimer(timerID, userInfo.ID); err != nil {
response.NotFound(c, "Timer 不存在")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
err = gormDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Rule{}).Error; err != nil {
return err
}
return tx.Where("id = ? AND uid = ?", timerID, userInfo.ID).Delete(&model.Timer{}).Error
})
if err != nil {
response.ServerError(c, "删除 Timer 失败")
return
}
response.OK(c, "Timer 删除成功")
}
// HandleCheckDeleteTimer 删除前查询会被一并删除的规则
func HandleCheckDeleteTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
rules := []model.Rule{}
if err := gormDB.Where("timer_id = ? AND uid = ?", timerID, userInfo.ID).Find(&rules).Error; err != nil {
response.ServerError(c, "获取相关规则失败")
return
}
response.OK(c, rules)
}
+72
View File
@@ -0,0 +1,72 @@
package timer
import (
"strconv"
"time"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
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
}
// findTimer 查找属于该用户的 Timer
func findTimer(timerID, uid uint) (*model.Timer, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var timer model.Timer
if err := gormDB.Where("id = ? AND uid = ?", timerID, uid).First(&timer).Error; err != nil {
return nil, err
}
return &timer, nil
}
func countRules(timerID, uid uint) (int64, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var count int64
err = gormDB.Model(&model.Rule{}).Where("timer_id = ? AND uid = ?", timerID, uid).Count(&count).Error
return count, err
}
// signTimer 完成一次签到:重置计时、清除提醒与触发标记
func signTimer(timer *model.Timer) error {
gormDB, err := db.GetGormDB()
if err != nil {
return err
}
now := time.Now().Unix()
if err := gormDB.Model(&model.Timer{}).Where("id = ?", timer.ID).
Updates(map[string]any{
"last_sign": now,
"last_remind": 0,
"triggered": false,
}).Error; err != nil {
return err
}
timer.LastSign = now
timer.LastRemind = 0
timer.Triggered = false
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package timer
import (
"context"
"sync"
"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"
)
// HandleTriggerTimer 手动触发一个 Timer,用于调试。
//
// 会真实执行该 Timer 下所有启用的规则,但不会改变 Timer 的签到/触发状态;
// 使用较短的测试超时,避免在页面上等待数小时的重试。
func HandleTriggerTimer(c *gin.Context) {
userInfo, err := user.GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
timerID, err := parseID(c.Param("timerID"))
if err != nil {
response.BadRequest(c, "Timer ID 格式错误")
return
}
if _, err := findTimer(timerID, userInfo.ID); err != nil {
response.NotFound(c, "Timer 不存在")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
var rules []model.Rule
if err := gormDB.Where("timer_id = ? AND uid = ? AND enabled = ?", timerID, userInfo.ID, true).
Find(&rules).Error; err != nil {
response.ServerError(c, "获取关联规则失败")
return
}
if len(rules) == 0 {
response.BadRequest(c, "该定时器下没有启用的规则")
return
}
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
defer cancel()
// 并发执行所有规则,收集失败信息
var (
wg sync.WaitGroup
mu sync.Mutex
fails []string
)
for i := range rules {
rule := rules[i]
wg.Add(1)
go func() {
defer wg.Done()
if err := runner.ExecuteRuleWithContext(ctx, &rule, model.TriggerManual); err != nil {
mu.Lock()
fails = append(fails, rule.Name+": "+err.Error())
mu.Unlock()
}
}()
}
wg.Wait()
response.OK(c, gin.H{
"total": len(rules),
"failed": fails,
})
}
+128 -24
View File
@@ -1,6 +1,9 @@
package user
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/gin-contrib/sessions"
@@ -8,15 +11,25 @@ import (
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// ErrUserNotFound 用户不存在
var ErrUserNotFound = errors.New("用户不存在")
// ErrWrongPassword 密码错误
var ErrWrongPassword = errors.New("用户名或密码错误")
// contextKey AuthMiddleware 把当前用户放进 gin.Context 时使用的 key
const contextKey = "currentUser"
// 加密密码
func encryptPassword(password string) (string, error) {
hashedID, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedID), nil
return string(hashed), nil
}
// 验证密码
@@ -35,30 +48,62 @@ func createUser(u *model.User) error {
return err
}
u.Password = hashedPassword
u.CreateAt = time.Now().Unix()
result := gormDB.Create(u)
return result.Error
return gormDB.Create(u).Error
}
// 校验密码
func verifyUser(u *model.User) error {
// IsUsernameTaken 用户名是否已被占用
func IsUsernameTaken(username string) (bool, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return err
return false, err
}
var hashedPassword string
result := gormDB.Where("username = ?", u.Username).First(&model.User{}).Select("password").Scan(&hashedPassword)
if result.Error != nil {
return result.Error
var count int64
if err := gormDB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return false, err
}
return verifyPassword(hashedPassword, u.Password)
return count > 0, nil
}
// 通过用户名获取用户信息
// CountUsers 返回已注册用户数,用于首个用户的引导注册
func CountUsers() (int64, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var count int64
err = gormDB.Model(&model.User{}).Count(&count).Error
return count, err
}
// verifyUser 校验用户名与密码,成功时返回用户记录
//
// 原实现把 First 与 Select().Scan() 串在同一条语句上,实际拿不到密码哈希,
// 且用 sql.ErrNoRows 去比较 gorm 的错误永远不成立。
func verifyUser(username, password string) (*model.User, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
var user model.User
if err := gormDB.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
if err := verifyPassword(user.Password, password); err != nil {
return nil, ErrWrongPassword
}
return &user, nil
}
// GetUserInfo 通过用户名获取用户信息
func GetUserInfo(username string) (*model.User, error) {
gormDB, err := db.GetGormDB()
if err != nil {
@@ -66,20 +111,79 @@ func GetUserInfo(username string) (*model.User, error) {
}
var user model.User
result := gormDB.Where("username = ?", username).First(&user)
if result.Error != nil {
return nil, result.Error
if err := gormDB.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return &user, nil
}
// 通过 gin.Context 获取用户信息
func GetUserInfoByGinCtx(c *gin.Context) (userInfo *model.User, err error) {
session := sessions.Default(c)
username := session.Get("username")
userInfo, err = GetUserInfo(username.(string))
// GetUserByID 通过 ID 获取用户信息
func GetUserByID(id uint) (*model.User, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return nil, err
}
return userInfo, nil
var user model.User
if err := gormDB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return &user, nil
}
// GetUserInfoByGinCtx 获取当前登录用户
//
// AuthMiddleware 已经查过一次库并写入了 context,这里优先读缓存。
func GetUserInfoByGinCtx(c *gin.Context) (*model.User, error) {
if cached, ok := c.Get(contextKey); ok {
if user, ok := cached.(*model.User); ok {
return user, nil
}
}
session := sessions.Default(c)
uid, ok := session.Get("uid").(uint)
if !ok {
return nil, ErrUserNotFound
}
user, err := GetUserByID(uid)
if err != nil {
return nil, err
}
c.Set(contextKey, user)
return user, nil
}
// setSession 登录成功后写入会话
func setSession(c *gin.Context, user *model.User) error {
session := sessions.Default(c)
session.Set("uid", user.ID)
session.Set("username", user.Username)
return session.Save()
}
// validateNotifyConfig 校验钉钉提醒配置
func validateNotifyConfig(raw *string) error {
if raw == nil || *raw == "" {
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)
}
if config.AccessToken == "" {
return fmt.Errorf("钉钉配置中 access_token 不能为空")
}
return nil
}
+170 -58
View File
@@ -1,118 +1,230 @@
package user
import (
"database/sql"
"errors"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ssdomei232/goodBaby/api/response"
"github.com/ssdomei232/goodBaby/configs"
"github.com/ssdomei232/goodBaby/handler/db"
"github.com/ssdomei232/goodBaby/model"
)
// 处理注册请求,配置文件中可设置禁用注册
// HandleRegistry 处理注册请求,配置文件中可设置禁用注册
//
// 系统内还没有任何用户时始终允许注册,否则全新部署将无法创建第一个账号。
func HandleRegistry(c *gin.Context) {
var userRegistryRequest model.UserRegistryReuest
var err error
config, err := configs.GetConfig()
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取配置文件失败"})
response.ServerError(c, "获取配置文件失败")
return
}
if !config.EnableRegistry {
c.JSON(403, gin.H{"code": 403, "data": "注册功能已关闭"})
return
}
err = c.BindJSON(&userRegistryRequest)
userCount, err := CountUsers()
if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入错误"})
response.ServerError(c, "服务器内部错误")
return
}
if !config.EnableRegistry && userCount > 0 {
response.Fail(c, http.StatusForbidden, "注册功能已关闭")
return
}
var registryRequest model.UserRegistryReuest
if err := c.ShouldBindJSON(&registryRequest); err != nil {
response.BadRequest(c, "输入错误")
return
}
user := model.User{
Username: userRegistryRequest.Username,
Password: userRegistryRequest.Password,
Username: registryRequest.Username,
Password: registryRequest.Password,
}
if err = user.IsValid(); err != nil {
c.JSON(400, gin.H{"code": 400, "data": err.Error()})
if err := user.IsValid(); err != nil {
response.BadRequest(c, err.Error())
return
}
if isExist := user.IsExist(); isExist {
c.JSON(400, gin.H{"code": 400, "data": "用户名已存在"})
taken, err := IsUsernameTaken(user.Username)
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if taken {
response.BadRequest(c, "用户名已存在")
return
}
if err = createUser(&user); err != nil {
c.JSON(500, gin.H{"code": 500, "data": "注册失败"})
if err := createUser(&user); err != nil {
response.ServerError(c, "注册失败")
return
}
session := sessions.Default(c)
session.Set("username", user.Username)
session.Save()
if err := setSession(c, &user); err != nil {
response.ServerError(c, "写入会话失败")
return
}
c.JSON(200, gin.H{"code": 200, "data": "注册成功"})
response.OK(c, "注册成功")
}
// 处理登录请求
// HandleLogin 处理登录请求
func HandleLogin(c *gin.Context) {
var userLoginRequest model.UserRegistryReuest
var err error
err = c.BindJSON(&userLoginRequest)
if err != nil {
c.JSON(400, gin.H{"code": 400, "data": "输入错误"})
var loginRequest model.UserRegistryReuest
if err := c.ShouldBindJSON(&loginRequest); err != nil {
response.BadRequest(c, "输入错误")
return
}
user := model.User{
Username: userLoginRequest.Username,
Password: userLoginRequest.Password,
}
if err = user.IsValid(); err != nil {
c.JSON(400, gin.H{"code": 400, "data": err.Error()})
}
if err = verifyUser(&user); err == sql.ErrNoRows {
c.JSON(400, gin.H{"code": 400, "data": "用户不存在"})
return
} else if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "登录失败"})
if loginRequest.Username == "" || loginRequest.Password == "" {
response.BadRequest(c, "用户名或密码不能为空")
return
}
session := sessions.Default(c)
session.Set("username", user.Username)
session.Save()
user, err := verifyUser(loginRequest.Username, loginRequest.Password)
switch {
case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrWrongPassword):
// 不区分“用户不存在”和“密码错误”,避免泄露用户名是否存在
response.Unauthorized(c, "用户名或密码错误")
return
case err != nil:
response.ServerError(c, "登录失败")
return
}
c.JSON(200, gin.H{"code": 200, "data": "登录成功"})
if err := setSession(c, user); err != nil {
response.ServerError(c, "写入会话失败")
return
}
response.OK(c, "登录成功")
}
// 处理获取用户信息请求
// HandleLogout 退出登录
func HandleLogout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Options(sessions.Options{Path: "/", MaxAge: -1})
if err := session.Save(); err != nil {
response.ServerError(c, "退出登录失败")
return
}
response.OK(c, "已退出登录")
}
// HandleGetUserInfo 获取当前登录用户信息
func HandleGetUserInfo(c *gin.Context) {
userInfo, err := GetUserInfoByGinCtx(c)
if err != nil {
c.JSON(500, gin.H{"code": 500, "data": "获取用户信息失败"})
response.Unauthorized(c, "获取用户信息失败")
return
}
c.JSON(200, gin.H{"code": 200, "data": userInfo})
response.OK(c, userInfo)
}
// 认证中间件
// HandleChangePassword 修改密码
func HandleChangePassword(c *gin.Context) {
userInfo, err := GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入错误")
return
}
if _, err := verifyUser(userInfo.Username, req.OldPassword); err != nil {
response.BadRequest(c, "原密码错误")
return
}
candidate := model.User{Username: userInfo.Username, Password: req.NewPassword}
if err := candidate.IsValid(); err != nil {
response.BadRequest(c, err.Error())
return
}
hashed, err := encryptPassword(req.NewPassword)
if err != nil {
response.ServerError(c, "修改密码失败")
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Model(&model.User{}).Where("id = ?", userInfo.ID).
Update("password", hashed).Error; err != nil {
response.ServerError(c, "修改密码失败")
return
}
response.OK(c, "密码修改成功")
}
// HandleUpdateNotifyConfig 更新提醒渠道(钉钉机器人)配置
func HandleUpdateNotifyConfig(c *gin.Context) {
userInfo, err := GetUserInfoByGinCtx(c)
if err != nil {
response.Unauthorized(c, "获取用户信息失败")
return
}
var req model.NotifyConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "输入错误")
return
}
if err := validateNotifyConfig(req.DingTalkConfig); err != nil {
response.BadRequest(c, err.Error())
return
}
gormDB, err := db.GetGormDB()
if err != nil {
response.ServerError(c, "服务器内部错误")
return
}
if err := gormDB.Model(&model.User{}).Where("id = ?", userInfo.ID).
Update("ding_talk_config", req.DingTalkConfig).Error; err != nil {
response.ServerError(c, "保存提醒配置失败")
return
}
response.OK(c, "提醒配置已保存")
}
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
username := session.Get("username")
if username == nil {
c.JSON(401, gin.H{"code": 401, "data": "未登录"})
c.Abort()
uid, ok := session.Get("uid").(uint)
if !ok {
response.AbortWith(c, http.StatusUnauthorized, "未登录")
return
}
user, err := GetUserByID(uid)
if err != nil {
// 用户已被删除,清理会话
session.Clear()
_ = session.Save()
response.AbortWith(c, http.StatusUnauthorized, "未登录")
return
}
c.Set(contextKey, user)
c.Next()
}
}