feat: ToNav-go v1.0.0 - 内部服务导航系统
功能: - 前台导航: 分类Tab切换、实时搜索、健康状态指示、响应式适配 - 后台管理: 服务/分类CRUD、系统设置、登录认证(bcrypt) - 健康检查: 定时检测(5min)、独立检查URL、三态指示(在线/离线/未检测) - 云端备份: WebDAV上传/下载/恢复/删除、定时自动备份、本地备份管理 技术栈: Go + Gin + GORM + SQLite
This commit is contained in:
157
handlers/api.go
Normal file
157
handlers/api.go
Normal 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
121
handlers/auth.go
Normal 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
62
handlers/health.go
Normal 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
221
handlers/settings.go
Normal 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
11
handlers/utils.go
Normal 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
119
handlers/views.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user