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
+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()
}
}