commit efaf7879811ef587f350d08029a1b26a94fd91b6 Author: openclaw Date: Sat Feb 14 05:09:23 2026 +0800 feat: ToNav-go v1.0.0 - 内部服务导航系统 功能: - 前台导航: 分类Tab切换、实时搜索、健康状态指示、响应式适配 - 后台管理: 服务/分类CRUD、系统设置、登录认证(bcrypt) - 健康检查: 定时检测(5min)、独立检查URL、三态指示(在线/离线/未检测) - 云端备份: WebDAV上传/下载/恢复/删除、定时自动备份、本地备份管理 技术栈: Go + Gin + GORM + SQLite diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7376368 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 编译产物 +tonav-go-v1 + +# 数据库 +tonav.db + +# 备份文件 +backups/ +tonav_backup_*.db + +# 日志 +tonav.log + +# PID 文件 +tonav-go.pid + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..d29885f --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# ToNav-go + +🧭 内部服务导航系统 — Go 版本 + +基于 Go + Gin + GORM + SQLite 构建的轻量级服务导航页,用于管理和展示内部服务链接。 + +## 功能特性 + +### 前台导航 +- 🔍 **实时搜索** — 输入即过滤,匹配服务名称、描述、标签 +- 📑 **分类 Tab 切换** — 一键过滤不同分类的服务 +- 🟢 **健康状态指示** — 绿色在线 / 红色离线 / 灰色未检测 +- 📱 **响应式设计** — 完美适配手机端 +- 🎨 **深色 Header + 白色卡片** — 现代化 UI 风格 + +### 后台管理 +- 📡 **服务管理** — 新增/编辑/删除服务(名称、URL、图标、分类、描述、标签、排序、健康检查配置) +- 📂 **分类管理** — 新增/编辑/删除分类,支持排序权重 +- ⚙️ **系统设置** — 站点标题、WebDAV 配置、自动备份开关 +- 🔐 **登录认证** — bcrypt 密码加密,首次登录强制修改密码 +- 📊 **Dashboard** — 服务总数、分类数、在线/离线统计 + +### 健康检查 +- ⏱️ **定时检测** — 每 5 分钟自动检测所有启用健康检查的服务 +- 🎯 **独立检查 URL** — 可为每个服务配置专用健康检查地址 +- 🔘 **三态指示** — 开启检测 + 成功 = 绿色 / 开启检测 + 失败 = 红色 / 未开启 = 灰色 + +### 云端备份(WebDAV) +- ☁️ **手动备份** — 一键备份数据库到 WebDAV 云端 +- 🔄 **恢复备份** — 从云端备份列表选择恢复,恢复前自动备份当前数据 +- 🗑️ **删除备份** — 清理云端旧备份(需 WebDAV 服务端开放 DELETE 权限) +- ⏰ **定时自动备份** — 可配置每天凌晨 3:00 自动执行 +- 📁 **本地备份管理** — 自动保留最近 5 份本地备份 + +## 技术栈 + +| 组件 | 技术 | +|------|------| +| 语言 | Go 1.24+ | +| Web 框架 | Gin | +| ORM | GORM | +| 数据库 | SQLite | +| 认证 | bcrypt + Cookie Session | +| 前端 | 原生 HTML/CSS/JS | +| 备份 | WebDAV 协议 | + +## 项目结构 + +``` +ToNav-go/ +├── main.go # 入口文件、路由注册 +├── go.mod # Go 模块定义 +├── go.sum # 依赖校验 +├── tonav-go-ctl.sh # 管理脚本 (start/stop/restart/status/build/log) +├── database/ +│ ├── db.go # 数据库初始化、自动迁移 +│ └── seed.go # 初始数据(默认管理员、分类、WebDAV 配置) +├── models/ +│ └── models.go # 数据模型(Category, Service, User, Setting) +├── handlers/ +│ ├── api.go # REST API(服务/分类 CRUD) +│ ├── auth.go # 登录/登出/修改密码 +│ ├── health.go # 健康检查定时任务 +│ ├── settings.go # 系统设置 & 备份/恢复/删除 +│ ├── views.go # 页面渲染(首页/Dashboard/管理页面) +│ └── utils.go # 工具函数(Session 获取) +├── utils/ +│ ├── config.go # 配置加载(环境变量) +│ └── webdav.go # WebDAV 客户端(上传/下载/列表/删除/备份) +├── templates/ +│ ├── index.html # 前台导航页 +│ └── admin/ +│ ├── login.html # 登录页 +│ ├── dashboard.html # 后台首页 +│ ├── services.html # 服务管理 +│ ├── categories.html # 分类管理 +│ └── change_password.html # 修改密码 +└── backups/ # 本地备份目录(自动创建) +``` + +## 快速开始 + +### 环境要求 +- Go 1.24+ +- GCC(CGO 编译 SQLite 需要) + +### 编译运行 + +```bash +# 克隆项目 +git clone https://gitea.king.nyc.mn/openclaw/ToNav-go.git +cd ToNav-go + +# 编译 +go build -o tonav-go-v1 + +# 运行 +./tonav-go-v1 +``` + +### 使用管理脚本 + +```bash +chmod +x tonav-go-ctl.sh + +./tonav-go-ctl.sh start # 启动(后台运行) +./tonav-go-ctl.sh stop # 停止 +./tonav-go-ctl.sh restart # 重启 +./tonav-go-ctl.sh status # 查看状态 +./tonav-go-ctl.sh build # 编译 +./tonav-go-ctl.sh log # 查看日志 +``` + +### 环境变量配置 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `TONAV_PORT` | `9520` | 监听端口 | +| `TONAV_DB` | `tonav.db` | 数据库文件路径 | +| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥 | + +### 默认账号 + +- **用户名**: `admin` +- **密码**: `admin123` +- 首次登录会强制修改密码 + +## API 接口 + +### 公开接口 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 前台导航页 | +| GET | `/admin/login` | 登录页 | +| POST | `/admin/login` | 登录 | + +### 管理接口(需登录) +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/admin/dashboard` | 后台首页 | +| GET | `/admin/api/services` | 获取所有服务 | +| POST | `/admin/api/services` | 新增服务 | +| PUT | `/admin/api/services/:id` | 更新服务 | +| DELETE | `/admin/api/services/:id` | 删除服务 | +| GET | `/admin/api/categories` | 获取所有分类 | +| POST | `/admin/api/categories` | 新增分类 | +| PUT | `/admin/api/categories/:id` | 更新分类 | +| DELETE | `/admin/api/categories/:id` | 删除分类 | +| GET | `/admin/api/settings` | 获取设置 | +| POST | `/admin/api/settings` | 保存设置 | +| POST | `/admin/api/backup/webdav` | 执行云端备份 | +| GET | `/admin/api/backup/list` | 列出云端备份 | +| DELETE | `/admin/api/backup/delete?name=xxx` | 删除云端备份 | +| POST | `/admin/api/backup/restore?name=xxx` | 恢复云端备份 | + +## 数据模型 + +### Service 服务 +| 字段 | 类型 | 说明 | +|------|------|------| +| name | string | 服务名称 | +| url | string | 服务地址 | +| icon | string | 图标(emoji) | +| description | string | 描述 | +| category_id | uint | 所属分类 ID | +| tags | string | 标签(逗号分隔) | +| status | string | 状态(online/offline/unknown) | +| is_enabled | bool | 是否启用 | +| sort_order | int | 排序权重(越大越靠前) | +| health_check_url | string | 健康检查 URL | +| health_check_enabled | bool | 是否启用健康检查 | +| click_count | int | 点击次数 | + +### Category 分类 +| 字段 | 类型 | 说明 | +|------|------|------| +| name | string | 分类名称 | +| sort_order | int | 排序权重(越大越靠前) | + +## License + +MIT diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..da07ed4 --- /dev/null +++ b/database/db.go @@ -0,0 +1,24 @@ +package database + +import ( + "log" + "tonav-go/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB(dbPath string) { + var err error + DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + log.Fatalf("Failed to connect database: %v", err) + } + + // 自动迁移 + err = DB.AutoMigrate(&models.Category{}, &models.Service{}, &models.User{}, &models.Setting{}) + if err != nil { + log.Fatalf("Failed to migrate database: %v", err) + } +} diff --git a/database/seed.go b/database/seed.go new file mode 100644 index 0000000..26079ba --- /dev/null +++ b/database/seed.go @@ -0,0 +1,44 @@ +package database + +import ( + "tonav-go/models" + "golang.org/x/crypto/bcrypt" +) + +func Seed() { + // 1. 初始化管理员 (admin / admin123) + var count int64 + DB.Model(&models.User{}).Count(&count) + if count == 0 { + hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) + DB.Create(&models.User{ + Username: "admin", + Password: string(hash), + MustChangePassword: true, + }) + } + + // 2. 初始化分类 + var catCount int64 + DB.Model(&models.Category{}).Count(&catCount) + if catCount == 0 { + categories := []models.Category{ + {Name: "内网服务", SortOrder: 100}, + {Name: "开发工具", SortOrder: 90}, + {Name: "测试环境", SortOrder: 80}, + } + DB.Create(&categories) + } + + // 3. 初始设置 + var setCount int64 + DB.Model(&models.Setting{}).Count(&setCount) + if setCount == 0 { + settings := []models.Setting{ + {Key: "webdav_url", Value: "https://chfs.ouaone.top/webdav/openclaw/upload/tonav-go/"}, + {Key: "webdav_user", Value: "openclaw"}, + {Key: "webdav_password", Value: "Khh13579"}, + } + DB.Create(&settings) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..db0562d --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module tonav-go + +go 1.24.4 + +require ( + github.com/gin-contrib/sessions v1.0.4 + github.com/gin-gonic/gin v1.11.0 + golang.org/x/crypto v0.48.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.uber.org/mock v0.6.0 // indirect + golang.org/x/arch v0.24.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..22c17fe --- /dev/null +++ b/go.sum @@ -0,0 +1,107 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= +github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= +golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/handlers/api.go b/handlers/api.go new file mode 100644 index 0000000..265e9aa --- /dev/null +++ b/handlers/api.go @@ -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: "删除成功"}) +} diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..75aaf4c --- /dev/null +++ b/handlers/auth.go @@ -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") +} diff --git a/handlers/health.go b/handlers/health.go new file mode 100644 index 0000000..209cd56 --- /dev/null +++ b/handlers/health.go @@ -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")) +} diff --git a/handlers/settings.go b/handlers/settings.go new file mode 100644 index 0000000..955024c --- /dev/null +++ b/handlers/settings.go @@ -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) +} diff --git a/handlers/utils.go b/handlers/utils.go new file mode 100644 index 0000000..702ca5f --- /dev/null +++ b/handlers/utils.go @@ -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) +} diff --git a/handlers/views.go b/handlers/views.go new file mode 100644 index 0000000..d9e41b0 --- /dev/null +++ b/handlers/views.go @@ -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) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..03c73ba --- /dev/null +++ b/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + "net/http" + "tonav-go/database" + "tonav-go/handlers" + config "tonav-go/utils" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +func main() { + cfg := config.LoadConfig() + database.InitDB(cfg.DBPath) + database.Seed() + handlers.StartHealthCheck() + handlers.StartAutoBackup() + + r := gin.Default() + store := cookie.NewStore([]byte(cfg.SecretKey)) + r.Use(sessions.Sessions("tonav_session", store)) + r.Static("/static", "./static") + + // 显式逐个加载,彻底杜绝路径猜测问题 + r.LoadHTMLFiles( + "templates/index.html", + "templates/admin/login.html", + "templates/admin/dashboard.html", + "templates/admin/categories.html", + "templates/admin/services.html", + "templates/admin/change_password.html", + ) + + r.GET("/", handlers.IndexHandler) + + r.GET("/admin/login", func(c *gin.Context) { + c.HTML(http.StatusOK, "login.html", nil) + }) + r.POST("/admin/login", handlers.LoginHandler) + r.GET("/admin/logout", handlers.LogoutHandler) + + admin := r.Group("/admin") + admin.Use(handlers.AuthRequired()) + { + admin.GET("", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/admin/dashboard") + }) + admin.GET("/dashboard", handlers.DashboardHandler) + admin.GET("/services", handlers.ServicesPageHandler) + admin.GET("/categories", handlers.CategoriesPageHandler) + admin.GET("/change-password", handlers.ChangePasswordHandler) + admin.POST("/change-password", handlers.ChangePasswordHandler) + + admin.GET("/api/services", handlers.GetServices) + admin.POST("/api/services", handlers.SaveService) + admin.PUT("/api/services/:id", handlers.SaveService) + admin.DELETE("/api/services/:id", handlers.DeleteService) + admin.GET("/api/categories", handlers.GetCategories) + admin.POST("/api/categories", handlers.SaveCategory) + admin.PUT("/api/categories/:id", handlers.SaveCategory) + admin.DELETE("/api/categories/:id", handlers.DeleteCategory) + admin.GET("/api/settings", handlers.GetSettings) + admin.POST("/api/settings", handlers.SaveSettings) + admin.POST("/api/backup/webdav", handlers.RunCloudBackup) + admin.GET("/api/backup/list", handlers.ListCloudBackups) + admin.DELETE("/api/backup/delete", handlers.DeleteCloudBackup) + admin.POST("/api/backup/restore", handlers.RestoreCloudBackup) + } + + log.Printf("ToNav-go 启动在端口 %s...", cfg.Port) + r.Run(":" + cfg.Port) +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..84b381d --- /dev/null +++ b/models/models.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" +) + +type Category struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"unique;not null" json:"name"` + SortOrder int `gorm:"default:0" json:"sort_order"` + Services []Service `gorm:"foreignKey:CategoryID" json:"services"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Service struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + URL string `gorm:"not null" json:"url"` + Description string `json:"description"` + Icon string `json:"icon"` + CategoryID uint `gorm:"index" json:"category_id"` + Status string `gorm:"default:'online'" json:"status"` + Tags string `json:"tags"` + IsEnabled bool `gorm:"default:true" json:"is_enabled"` + SortOrder int `gorm:"default:0" json:"sort_order"` + ClickCount int `gorm:"default:0" json:"click_count"` + HealthCheckURL string `json:"health_check_url"` + HealthCheckEnabled bool `gorm:"default:false" json:"health_check_enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type User struct { + ID uint `gorm:"primaryKey"` + Username string `gorm:"unique;not null"` + Password string `gorm:"not null"` + MustChangePassword bool `gorm:"default:true"` + CreatedAt time.Time +} + +type Setting struct { + Key string `gorm:"primaryKey"` + Value string +} diff --git a/templates/admin/categories.html b/templates/admin/categories.html new file mode 100644 index 0000000..4e5917b --- /dev/null +++ b/templates/admin/categories.html @@ -0,0 +1,176 @@ + + + + + + 分类管理 - ToNav + + + +
+
+

📂 分类管理

+
+ + 返回 +
+
+
+
加载中...
+
+
+ + + + + + + diff --git a/templates/admin/change_password.html b/templates/admin/change_password.html new file mode 100644 index 0000000..cfb8ab3 --- /dev/null +++ b/templates/admin/change_password.html @@ -0,0 +1,45 @@ + + + + + + 修改密码 - ToNav + + + +
+

🔐 修改密码

+
⚠️ 首次登录请先修改初始密码以启用管理功能。
+ + {{ if .error }} +
{{ .error }}
+ {{ end }} + +
+ + + + + + + +
+
+ + diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..528d693 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,260 @@ + + + + + + ToNav 管理后台 + + + +
+
+

🧭 ToNav 管理后台

+ +
+ +
+
+
{{ .service_count }}
+
总服务数
+
+
+
{{ .category_count }}
+
分类数
+
+
+
{{ .online_count }}
+
在线服务
+
+
+
{{ .offline_count }}
+
离线服务
+
+
+ +
+ +
📡
+
服务管理
+
+ +
📂
+
分类管理
+
+
+
☁️
+
立即云备份
+
+
+
⚙️
+
系统设置
+
+
+
+ + + + + + + diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..26e18fb --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,40 @@ + + + + + + ToNav 管理登录 + + + +
+

🧭 ToNav

+ {{ if .error }} +
{{ .error }}
+ {{ end }} +
+ + + + + +
+
+ + diff --git a/templates/admin/services.html b/templates/admin/services.html new file mode 100644 index 0000000..9399ce3 --- /dev/null +++ b/templates/admin/services.html @@ -0,0 +1,289 @@ + + + + + + 服务管理 - ToNav + + + +
+
+

📡 服务管理

+
+ + 返回 +
+
+
+ + + + + + + + + + + + +
图标名称分类状态排序操作
+ +
+
+ + + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7dd772c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,297 @@ + + + + + + {{ .site_title }} - 导航服务 + + + +
+
+

🧭 {{ .site_title }}

+
个人导航站
+
加载中...
+
+ + + +
+ +
+
加载中...
+
+ + +
+ + + + diff --git a/tonav-go-ctl.sh b/tonav-go-ctl.sh new file mode 100755 index 0000000..42e237a --- /dev/null +++ b/tonav-go-ctl.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Configuration +APP_NAME="tonav-go" +APP_DIR="/root/.openclaw/workspace/ToNav-go" +BINARY_NAME="tonav-go-v1" +PID_FILE="$APP_DIR/tonav-go.pid" +LOG_FILE="$APP_DIR/tonav.log" + +start() { + if [ -f $PID_FILE ]; then + PID=$(cat $PID_FILE) + if ps -p $PID > /dev/null; then + echo "$APP_NAME is already running (PID: $PID)" + return + fi + fi + + echo "Starting $APP_NAME..." + cd $APP_DIR + nohup ./$BINARY_NAME >> $LOG_FILE 2>&1 & + echo $! > $PID_FILE + echo "$APP_NAME started with PID: $(cat $PID_FILE)" +} + +stop() { + if [ -f $PID_FILE ]; then + PID=$(cat $PID_FILE) + echo "Stopping $APP_NAME (PID: $PID)..." + kill $PID + rm $PID_FILE + echo "$APP_NAME stopped." + else + echo "$APP_NAME is not running." + fi +} + +status() { + if [ -f $PID_FILE ]; then + PID=$(cat $PID_FILE) + if ps -p $PID > /dev/null; then + echo "$APP_NAME is running (PID: $PID)" + else + echo "$APP_NAME is not running (stale PID file)" + fi + else + echo "$APP_NAME is not running." + fi +} + +build() { + echo "Building $APP_NAME..." + cd $APP_DIR + go build -o $BINARY_NAME + echo "Build complete." +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + restart) + stop + sleep 2 + start + ;; + status) + status + ;; + build) + build + ;; + log) + tail -f $LOG_FILE + ;; + *) + echo "Usage: $0 {start|stop|restart|status|build|log}" + exit 1 +esac diff --git a/utils/config.go b/utils/config.go new file mode 100644 index 0000000..6a8262a --- /dev/null +++ b/utils/config.go @@ -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 +} diff --git a/utils/webdav.go b/utils/webdav.go new file mode 100644 index 0000000..59d3650 --- /dev/null +++ b/utils/webdav.go @@ -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)) + } + } +}