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

46
utils/config.go Normal file
View File

@@ -0,0 +1,46 @@
package config
import (
"fmt"
"os"
)
type Config struct {
Port string
DBPath string
SecretKey string
LogPath string
WebDAVURL string
WebDAVUser string
WebDAVPassword string
}
func LoadConfig() *Config {
return &Config{
Port: getEnv("TONAV_PORT", "9520"),
DBPath: getEnv("TONAV_DB", "tonav.db"),
SecretKey: getEnv("TONAV_SECRET", "tonav-secret-key-7306783874"),
LogPath: "tonav.log",
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
// ReplaceDB 用备份文件替换当前数据库
func ReplaceDB(srcPath, dstPath string) error {
input, err := os.ReadFile(srcPath)
if err != nil {
return fmt.Errorf("读取备份文件失败: %v", err)
}
if err := os.WriteFile(dstPath, input, 0644); err != nil {
return fmt.Errorf("替换数据库失败: %v", err)
}
// 清理临时文件
os.Remove(srcPath)
return nil
}

259
utils/webdav.go Normal file
View File

@@ -0,0 +1,259 @@
package config
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
type WebDAVClient struct {
URL string
Username string
Password string
}
// BackupInfo 备份文件信息
type BackupInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime string `json:"mod_time"`
}
func NewWebDAVClient(url, user, pass string) *WebDAVClient {
if !strings.HasSuffix(url, "/") {
url += "/"
}
return &WebDAVClient{URL: url, Username: user, Password: pass}
}
func (w *WebDAVClient) Upload(localPath, remoteName string) error {
file, err := os.Open(localPath)
if err != nil {
return err
}
defer file.Close()
req, err := http.NewRequest("PUT", w.URL+remoteName, file)
if err != nil {
return err
}
req.SetBasicAuth(w.Username, w.Password)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("upload failed: %s", resp.Status)
}
return nil
}
// Download 从 WebDAV 下载文件
func (w *WebDAVClient) Download(remoteName, localPath string) error {
req, err := http.NewRequest("GET", w.URL+remoteName, nil)
if err != nil {
return err
}
req.SetBasicAuth(w.Username, w.Password)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(localPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// Delete 删除 WebDAV 上的文件
func (w *WebDAVClient) Delete(remoteName string) error {
req, err := http.NewRequest("DELETE", w.URL+remoteName, nil)
if err != nil {
return err
}
req.SetBasicAuth(w.Username, w.Password)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("delete failed: %s", resp.Status)
}
return nil
}
// WebDAV PROPFIND XML 结构
type multiStatus struct {
Responses []davResponse `xml:"response"`
}
type davResponse struct {
Href string `xml:"href"`
PropStat []propStat `xml:"propstat"`
}
type propStat struct {
Prop davProp `xml:"prop"`
Status string `xml:"status"`
}
type davProp struct {
ContentLength int64 `xml:"getcontentlength"`
LastModified string `xml:"getlastmodified"`
}
// List 列出云端备份,返回详细信息
func (w *WebDAVClient) List() ([]BackupInfo, error) {
req, err := http.NewRequest("PROPFIND", w.URL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Depth", "1")
req.SetBasicAuth(w.Username, w.Password)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 尝试 XML 解析获取详细信息
var ms multiStatus
re := regexp.MustCompile(`tonav_backup_[0-9_]+\.db`)
if xml.Unmarshal(body, &ms) == nil && len(ms.Responses) > 0 {
var list []BackupInfo
seen := make(map[string]bool)
for _, r := range ms.Responses {
matches := re.FindAllString(r.Href, -1)
for _, name := range matches {
if seen[name] {
continue
}
seen[name] = true
info := BackupInfo{Name: name}
if len(r.PropStat) > 0 {
info.Size = r.PropStat[0].Prop.ContentLength
info.ModTime = r.PropStat[0].Prop.LastModified
}
// 从文件名解析时间
if info.ModTime == "" {
info.ModTime = parseTimeFromName(name)
}
list = append(list, info)
}
}
// 按名称倒序(最新的在前)
sort.Slice(list, func(i, j int) bool {
return list[i].Name > list[j].Name
})
return list, nil
}
// 回退:正则匹配
matches := re.FindAllString(string(body), -1)
unique := make(map[string]bool)
var list []BackupInfo
for _, m := range matches {
if !unique[m] {
unique[m] = true
list = append(list, BackupInfo{
Name: m,
ModTime: parseTimeFromName(m),
})
}
}
sort.Slice(list, func(i, j int) bool {
return list[i].Name > list[j].Name
})
return list, nil
}
// parseTimeFromName 从备份文件名解析时间
func parseTimeFromName(name string) string {
re := regexp.MustCompile(`(\d{8})_(\d{6})`)
m := re.FindStringSubmatch(name)
if len(m) == 3 {
t, err := time.Parse("20060102_150405", m[1]+"_"+m[2])
if err == nil {
return t.Format("2006-01-02 15:04:05")
}
}
return ""
}
// CreateBackup 创建本地备份,存放到 backups/ 目录
func CreateBackup(dbPath string) (string, error) {
// 确保 backups 目录存在
backupDir := "backups"
if err := os.MkdirAll(backupDir, 0755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %v", err)
}
timestamp := time.Now().Format("20060102_150405")
backupName := fmt.Sprintf("tonav_backup_%s.db", timestamp)
backupPath := filepath.Join(backupDir, backupName)
input, err := os.ReadFile(dbPath)
if err != nil {
return "", fmt.Errorf("读取数据库失败: %v", err)
}
err = os.WriteFile(backupPath, input, 0644)
if err != nil {
return "", fmt.Errorf("写入备份文件失败: %v", err)
}
return backupPath, nil
}
// CleanOldBackups 清理本地旧备份,保留最近 keep 份
func CleanOldBackups(keep int) {
backupDir := "backups"
entries, err := os.ReadDir(backupDir)
if err != nil {
return
}
re := regexp.MustCompile(`^tonav_backup_\d{8}_\d{6}\.db$`)
var backups []string
for _, e := range entries {
if !e.IsDir() && re.MatchString(e.Name()) {
backups = append(backups, e.Name())
}
}
// 按名称排序(时间戳命名,字母序即时间序)
sort.Strings(backups)
// 删除多余的旧备份
if len(backups) > keep {
for _, name := range backups[:len(backups)-keep] {
os.Remove(filepath.Join(backupDir, name))
}
}
}