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