feat: ToNav-go v1.0.0 - 内部服务导航系统

功能:
- 前台导航: 分类Tab切换、实时搜索、健康状态指示、响应式适配
- 后台管理: 服务/分类CRUD、系统设置、登录认证(bcrypt)
- 健康检查: 定时检测(5min)、独立检查URL、三态指示(在线/离线/未检测)
- 云端备份: WebDAV上传/下载/恢复/删除、定时自动备份、本地备份管理

技术栈: Go + Gin + GORM + SQLite
This commit is contained in:
2026-02-14 05:09:23 +08:00
commit efaf787981
23 changed files with 2735 additions and 0 deletions

157
handlers/api.go Normal file
View File

@@ -0,0 +1,157 @@
package handlers
import (
"net/http"
"strconv"
"tonav-go/database"
"tonav-go/models"
"github.com/gin-gonic/gin"
)
// API 响应结构
type Response struct {
Success bool `json:"success"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// GetServices 获取所有服务
func GetServices(c *gin.Context) {
var services []models.Service
database.DB.Order("category_id asc, sort_order desc").Find(&services)
c.JSON(http.StatusOK, Response{Success: true, Data: services})
}
// SaveService 创建或更新服务
func SaveService(c *gin.Context) {
var service models.Service
if err := c.ShouldBindJSON(&service); err != nil {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "请求数据格式错误: " + err.Error()})
return
}
// 验证必填字段
if service.Name == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "服务名称不能为空"})
return
}
if service.URL == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "服务地址不能为空"})
return
}
// 如果 URL 路径中有 id使用路径参数
if idStr := c.Param("id"); idStr != "" {
id, err := strconv.ParseUint(idStr, 10, 32)
if err == nil {
service.ID = uint(id)
}
}
if service.ID > 0 {
// 更新时只更新指定字段,避免覆盖 created_at 等
result := database.DB.Model(&models.Service{}).Where("id = ?", service.ID).Updates(map[string]interface{}{
"name": service.Name,
"url": service.URL,
"description": service.Description,
"icon": service.Icon,
"category_id": service.CategoryID,
"tags": service.Tags,
"is_enabled": service.IsEnabled,
"sort_order": service.SortOrder,
"health_check_url": service.HealthCheckURL,
"health_check_enabled": service.HealthCheckEnabled,
})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "更新失败: " + result.Error.Error()})
return
}
} else {
if err := database.DB.Create(&service).Error; err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "创建失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, Response{Success: true, Message: "保存成功", Data: service})
}
// DeleteService 删除服务
func DeleteService(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少服务ID"})
return
}
if err := database.DB.Delete(&models.Service{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "删除失败"})
return
}
c.JSON(http.StatusOK, Response{Success: true, Message: "删除成功"})
}
// GetCategories 获取所有分类
func GetCategories(c *gin.Context) {
var categories []models.Category
database.DB.Order("sort_order desc").Find(&categories)
c.JSON(http.StatusOK, Response{Success: true, Data: categories})
}
// SaveCategory 保存分类
func SaveCategory(c *gin.Context) {
var category models.Category
if err := c.ShouldBindJSON(&category); err != nil {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "请求数据格式错误: " + err.Error()})
return
}
if category.Name == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "分类名称不能为空"})
return
}
// 如果 URL 路径中有 id
if idStr := c.Param("id"); idStr != "" {
id, err := strconv.ParseUint(idStr, 10, 32)
if err == nil {
category.ID = uint(id)
}
}
if category.ID > 0 {
result := database.DB.Model(&models.Category{}).Where("id = ?", category.ID).Updates(map[string]interface{}{
"name": category.Name,
"sort_order": category.SortOrder,
})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "更新失败: " + result.Error.Error()})
return
}
} else {
if err := database.DB.Create(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "创建失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, Response{Success: true, Message: "保存成功", Data: category})
}
// DeleteCategory 删除分类
func DeleteCategory(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少分类ID"})
return
}
// 检查是否有服务属于该分类
var count int64
database.DB.Model(&models.Service{}).Where("category_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "该分类下仍有服务,无法删除"})
return
}
database.DB.Delete(&models.Category{}, id)
c.JSON(http.StatusOK, Response{Success: true, Message: "删除成功"})
}

121
handlers/auth.go Normal file
View File

@@ -0,0 +1,121 @@
package handlers
import (
"log"
"net/http"
"tonav-go/database"
"tonav-go/models"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// LoginHandler 处理登录请求
func LoginHandler(c *gin.Context) {
var input struct {
Username string `form:"username" binding:"required"`
Password string `form:"password" binding:"required"`
}
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名和密码不能为空"})
return
}
var user models.User
if err := database.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名或密码错误"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名或密码错误"})
return
}
session := sessions.Default(c)
session.Set("user_id", int(user.ID))
session.Set("username", user.Username)
session.Set("must_change", user.MustChangePassword)
if err := session.Save(); err != nil {
log.Printf("Session save error: %v", err)
c.HTML(http.StatusOK, "login.html", gin.H{"error": "登录失败,请重试"})
return
}
if user.MustChangePassword {
c.Redirect(http.StatusFound, "/admin/change-password")
return
}
c.Redirect(http.StatusFound, "/admin/dashboard")
}
// LogoutHandler 处理退出登录
func LogoutHandler(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
c.Redirect(http.StatusFound, "/admin/login")
}
// ChangePasswordHandler 修改密码
func ChangePasswordHandler(c *gin.Context) {
if c.Request.Method == "GET" {
c.HTML(http.StatusOK, "change_password.html", nil)
return
}
var input struct {
OldPassword string `form:"old_password" binding:"required"`
NewPassword string `form:"new_password" binding:"required"`
ConfirmPassword string `form:"confirm_password" binding:"required"`
}
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "所有字段均为必填"})
return
}
if input.NewPassword != input.ConfirmPassword {
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "两次输入的新密码不一致"})
return
}
if len(input.NewPassword) < 6 {
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "新密码长度不能少于6位"})
return
}
session := sessions.Default(c)
userID, err := getSessionUserID(session)
if err != nil {
c.Redirect(http.StatusFound, "/admin/login")
return
}
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil {
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "用户不存在"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.OldPassword)); err != nil {
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "旧密码错误"})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "密码加密失败"})
return
}
user.Password = string(hashedPassword)
user.MustChangePassword = false
database.DB.Save(&user)
session.Set("must_change", false)
session.Save()
c.Redirect(http.StatusFound, "/admin/dashboard")
}

62
handlers/health.go Normal file
View File

@@ -0,0 +1,62 @@
package handlers
import (
"fmt"
"log"
"net/http"
"time"
"tonav-go/database"
"tonav-go/models"
)
// StartHealthCheck 启动异步健康检查Goroutine
func StartHealthCheck() {
go checkAllServices()
ticker := time.NewTicker(5 * time.Minute)
go func() {
for range ticker.C {
checkAllServices()
}
}()
}
func checkAllServices() {
var services []models.Service
database.DB.Find(&services)
client := http.Client{
Timeout: 10 * time.Second,
}
checked := 0
for _, s := range services {
// 未开启健康检查的,状态设为 unknown
if !s.HealthCheckEnabled {
if s.Status != "unknown" {
database.DB.Model(&s).Update("status", "unknown")
}
continue
}
// 开启了健康检查的,执行检测
checkURL := s.URL
if s.HealthCheckURL != "" {
checkURL = s.HealthCheckURL
}
resp, err := client.Get(checkURL)
status := "offline"
if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 400 {
status = "online"
}
if resp != nil {
resp.Body.Close()
}
database.DB.Model(&s).Update("status", status)
checked++
}
log.Printf("[%s] 健康检查完成,共 %d 个服务,实际检测 %d 个", time.Now().Format("2006-01-02 15:04:05"), len(services), checked)
fmt.Printf("[%s] 健康检查完成\n", time.Now().Format("2006-01-02 15:04:05"))
}

221
handlers/settings.go Normal file
View File

@@ -0,0 +1,221 @@
package handlers
import (
"fmt"
"log"
"net/http"
"path/filepath"
"time"
"tonav-go/database"
"tonav-go/models"
config "tonav-go/utils"
"github.com/gin-gonic/gin"
)
// GetSettings 获取设置
func GetSettings(c *gin.Context) {
var settings []models.Setting
database.DB.Find(&settings)
res := make(map[string]string)
for _, s := range settings {
res[s.Key] = s.Value
}
c.JSON(http.StatusOK, res)
}
// SaveSettings 保存设置
func SaveSettings(c *gin.Context) {
var input map[string]string
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
return
}
for k, v := range input {
database.DB.Save(&models.Setting{Key: k, Value: v})
}
c.JSON(http.StatusOK, Response{Success: true, Message: "设置已保存"})
}
// getWebDAVClient 从数据库配置构建 WebDAV 客户端
func getWebDAVClient() (*config.WebDAVClient, error) {
var url, user, pass models.Setting
database.DB.Where("key = ?", "webdav_url").First(&url)
database.DB.Where("key = ?", "webdav_user").First(&user)
database.DB.Where("key = ?", "webdav_password").First(&pass)
if url.Value == "" {
return nil, fmt.Errorf("未配置 WebDAV URL")
}
return config.NewWebDAVClient(url.Value, user.Value, pass.Value), nil
}
// RunCloudBackup 执行云端备份
func RunCloudBackup(c *gin.Context) {
client, err := getWebDAVClient()
if err != nil {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
return
}
// 获取数据库路径
cfg := config.LoadConfig()
backupPath, err := config.CreateBackup(cfg.DBPath)
if err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "本地备份失败: " + err.Error()})
return
}
// 上传时只用文件名,不带路径
remoteName := filepath.Base(backupPath)
err = client.Upload(backupPath, remoteName)
if err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "云端上传失败: " + err.Error()})
return
}
// 清理本地旧备份保留最近5份
config.CleanOldBackups(5)
c.JSON(http.StatusOK, Response{Success: true, Message: "备份成功: " + remoteName})
}
// ListCloudBackups 列出云端备份
func ListCloudBackups(c *gin.Context) {
client, err := getWebDAVClient()
if err != nil {
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}})
return
}
files, err := client.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// DeleteCloudBackup 删除云端备份
func DeleteCloudBackup(c *gin.Context) {
name := c.Query("name")
if name == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少备份文件名"})
return
}
client, err := getWebDAVClient()
if err != nil {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
return
}
if err := client.Delete(name); err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "删除失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, Response{Success: true, Message: "已删除: " + name})
}
// RestoreCloudBackup 从云端备份恢复
func RestoreCloudBackup(c *gin.Context) {
name := c.Query("name")
if name == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少备份文件名"})
return
}
client, err := getWebDAVClient()
if err != nil {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
return
}
cfg := config.LoadConfig()
// 恢复前先备份当前数据库
preBackup, err := config.CreateBackup(cfg.DBPath)
if err != nil {
log.Printf("恢复前备份失败: %v", err)
} else {
log.Printf("恢复前已备份当前数据库: %s", preBackup)
}
// 下载云端备份到临时文件
tmpPath := "backups/restore_tmp.db"
if err := client.Download(name, tmpPath); err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "下载备份失败: " + err.Error()})
return
}
// 关闭当前数据库连接
sqlDB, err := database.DB.DB()
if err == nil {
sqlDB.Close()
}
// 用下载的备份替换当前数据库
if err := config.ReplaceDB(tmpPath, cfg.DBPath); err != nil {
// 尝试重新连接原数据库
database.InitDB(cfg.DBPath)
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "替换数据库失败: " + err.Error()})
return
}
// 重新初始化数据库连接
database.InitDB(cfg.DBPath)
c.JSON(http.StatusOK, Response{Success: true, Message: fmt.Sprintf("已从 %s 恢复,恢复前备份: %s", name, filepath.Base(preBackup))})
}
// StartAutoBackup 启动定时自动备份
func StartAutoBackup() {
go func() {
for {
// 计算距离下一个凌晨3点的时间
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location())
if next.Before(now) {
next = next.Add(24 * time.Hour)
}
timer := time.NewTimer(next.Sub(now))
<-timer.C
// 检查是否启用了自动备份
var setting models.Setting
database.DB.Where("key = ?", "auto_backup").First(&setting)
if setting.Value != "true" {
continue
}
log.Println("开始执行自动备份...")
doAutoBackup()
}
}()
}
func doAutoBackup() {
client, err := getWebDAVClient()
if err != nil {
log.Printf("自动备份失败WebDAV未配置: %v", err)
return
}
cfg := config.LoadConfig()
backupPath, err := config.CreateBackup(cfg.DBPath)
if err != nil {
log.Printf("自动备份失败(本地备份): %v", err)
return
}
remoteName := filepath.Base(backupPath)
if err := client.Upload(backupPath, remoteName); err != nil {
log.Printf("自动备份失败(上传): %v", err)
return
}
config.CleanOldBackups(5)
log.Printf("自动备份成功: %s", remoteName)
}

11
handlers/utils.go Normal file
View File

@@ -0,0 +1,11 @@
package handlers
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// GetSession 统一会话获取
func GetSession(c *gin.Context) sessions.Session {
return sessions.Default(c)
}

119
handlers/views.go Normal file
View File

@@ -0,0 +1,119 @@
package handlers
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"tonav-go/database"
"tonav-go/models"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// AuthRequired 登录验证中间件
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
userID := session.Get("user_id")
if userID == nil {
c.Redirect(http.StatusFound, "/admin/login")
c.Abort()
return
}
mustChange := session.Get("must_change")
if mustChange == true && c.Request.URL.Path != "/admin/change-password" && c.Request.URL.Path != "/admin/logout" {
c.Redirect(http.StatusFound, "/admin/change-password")
c.Abort()
return
}
c.Next()
}
}
// DashboardHandler 渲染后台首页
func DashboardHandler(c *gin.Context) {
var serviceCount, categoryCount int64
var onlineCount, offlineCount int64
database.DB.Model(&models.Service{}).Count(&serviceCount)
database.DB.Model(&models.Category{}).Count(&categoryCount)
database.DB.Model(&models.Service{}).Where("status = ?", "online").Count(&onlineCount)
database.DB.Model(&models.Service{}).Where("status = ?", "offline").Count(&offlineCount)
c.HTML(http.StatusOK, "dashboard.html", gin.H{
"service_count": serviceCount,
"category_count": categoryCount,
"online_count": onlineCount,
"offline_count": offlineCount,
})
}
// IndexHandler 渲染前台首页
func IndexHandler(c *gin.Context) {
if database.DB == nil {
c.String(http.StatusInternalServerError, "DB NIL")
return
}
// 获取所有分类
var categories []models.Category
database.DB.Order("sort_order desc").Find(&categories)
// 获取所有启用的服务
var services []models.Service
database.DB.Where("is_enabled = ?", true).Order("category_id asc, sort_order desc").Find(&services)
// 获取站点标题设置
var titleSetting models.Setting
siteTitle := "ToNav"
if err := database.DB.Where("key = ?", "site_title").First(&titleSetting).Error; err == nil && titleSetting.Value != "" {
siteTitle = titleSetting.Value
}
// 序列化为 JSON 供前端 JS 使用
categoriesJSON, _ := json.Marshal(categories)
servicesJSON, _ := json.Marshal(services)
c.HTML(http.StatusOK, "index.html", gin.H{
"site_title": siteTitle,
"categories": categories,
"categories_json": template.JS(categoriesJSON),
"services_json": template.JS(servicesJSON),
})
}
// ServicesPageHandler 渲染服务管理页面
func ServicesPageHandler(c *gin.Context) {
var categories []models.Category
database.DB.Order("sort_order desc").Find(&categories)
c.HTML(http.StatusOK, "services.html", gin.H{
"categories": categories,
})
}
// CategoriesPageHandler 渲染分类管理页面
func CategoriesPageHandler(c *gin.Context) {
c.HTML(http.StatusOK, "categories.html", nil)
}
// getSessionUserID 安全获取 session 中的 user_id
func getSessionUserID(session sessions.Session) (uint, error) {
userID := session.Get("user_id")
if userID == nil {
return 0, fmt.Errorf("user not logged in")
}
switch v := userID.(type) {
case uint:
return v, nil
case int:
return uint(v), nil
case int64:
return uint(v), nil
case float64:
return uint(v), nil
default:
return 0, fmt.Errorf("unexpected user_id type: %T", userID)
}
}