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:
+128
-24
@@ -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
@@ -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(®istryRequest); 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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user