feat: speedtest 网速测试服务(Go+Gin+SQLite,延迟/下载/上传 + 排行榜)
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
# 编译产物
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/speedtest
|
||||
|
||||
# 测试产物
|
||||
*.test
|
||||
*.out
|
||||
*.prof
|
||||
|
||||
# Go 工具缓存
|
||||
vendor/
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
|
||||
# IDE / 编辑器
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 项目运行时数据(本地调试路径)
|
||||
win/
|
||||
testdata/
|
||||
test*.toml
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -0,0 +1,109 @@
|
||||
# SpeedTest · 网速测试服务
|
||||
|
||||
基于 **Go + Gin + SQLite (GORM)** 的网页网速测试服务,支持 **延迟、下载、上传** 三项测试,
|
||||
测速结果自动入库并生成 **排行榜**(下载榜 / 上传榜 / 延迟榜)。
|
||||
|
||||
- 线上地址: https://speedtest.lmve.net
|
||||
- 代码仓库: https://git.lmve.net/dsh/speedtest
|
||||
- 技术栈: Go 1.25 / Gin / GORM / SQLite (CGO) / TOML 配置 / systemd / Caddy
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ⚡ **延迟测试**: 前端连续 10 次 ping,取中位数作为延迟、平均绝对偏差作为抖动
|
||||
- ⬇️ **下载测试**: 渐进式下载 1 / 2.5 / 5 / 10 / 25 MB 随机数据,实时仪表盘,取最优值
|
||||
- ⬆️ **上传测试**: 渐进式上传 1 / 2.5 / 5 / 10 MB 伪随机数据,实时仪表盘,取最优值
|
||||
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,IP 自动打码(保留前 3 段)
|
||||
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
speedtest/
|
||||
├── main.go # 入口:加载配置 → 初始化数据库 → 启动 Web
|
||||
├── install.sh # 一键安装/更新/卸载/管理脚本(systemd)
|
||||
├── config/
|
||||
│ ├── config.go # TOML 配置加载(缺失自动生成/补全)
|
||||
│ └── defaults.go # 默认路径与默认值
|
||||
├── internal/
|
||||
│ ├── db/
|
||||
│ │ ├── db.go # GORM 初始化 + 自动迁移
|
||||
│ │ └── models.go # SpeedTestResult 数据模型
|
||||
│ ├── store/
|
||||
│ │ ├── stores.go # 数据访问层聚合
|
||||
│ │ └── result_store.go # 测速结果增查(Top N / Count)
|
||||
│ └── web/
|
||||
│ ├── server.go # Gin 引擎 + 路由 + Unix socket/TCP 监听
|
||||
│ ├── handlers/
|
||||
│ │ └── speedtest.go # ping/download/upload/result/rankings
|
||||
│ └── templates/
|
||||
│ └── index.html # 前端单页(原生 JS,无外部依赖)
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 编译(SQLite 依赖 CGO)
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o speedtest .
|
||||
|
||||
# 本地运行(可用 SPEEDTEST_CONFIG 覆盖配置路径,见 test.toml 示例)
|
||||
SPEEDTEST_CONFIG=./test.toml ./speedtest
|
||||
```
|
||||
|
||||
## 部署(生产)
|
||||
|
||||
```bash
|
||||
sudo ./install.sh install
|
||||
```
|
||||
|
||||
脚本会自动完成:创建 `speedtest` 系统用户 → 创建目录 → 从 Gitea 拉取代码 →
|
||||
编译(CGO_ENABLED=1)→ 部署二进制与模板 → 生成 systemd 服务 → 启动并设置开机自启。
|
||||
|
||||
| 项目 | 路径 |
|
||||
| --- | --- |
|
||||
| 程序目录 | `/opt/speedtest` |
|
||||
| Unix socket | `/opt/speedtest/web.sock` |
|
||||
| 配置文件 | `/etc/speedtest/speedtest.toml` |
|
||||
| SQLite 数据库 | `/srv/speedtest/speedtest.db` |
|
||||
| 日志 | `journalctl -u speedtest -f` |
|
||||
|
||||
### Caddy 反代配置
|
||||
|
||||
```caddy
|
||||
speedtest.lmve.net {
|
||||
reverse_proxy unix//opt/speedtest/web.sock {
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
|
||||
程序首次启动自动生成 `/etc/speedtest/speedtest.toml`(缺失项自动补全):
|
||||
|
||||
```toml
|
||||
[database]
|
||||
driver = "sqlite"
|
||||
dsn = "/srv/speedtest/speedtest.db"
|
||||
|
||||
[web]
|
||||
addr = "/opt/speedtest/web.sock" # 以 / 开头为 unix socket,否则为 TCP 端口
|
||||
|
||||
[speedtest]
|
||||
max_download_bytes = 104857600 # 单次下载请求上限(默认 100 MB)
|
||||
max_upload_bytes = 104857600 # 单次上传请求上限(默认 100 MB)
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/` | 测速页面 |
|
||||
| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...}`) |
|
||||
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 100MB) |
|
||||
| POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` |
|
||||
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps}` |
|
||||
| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`) |
|
||||
|
||||
客户端真实 IP 通过 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For` 头识别,
|
||||
排行榜展示时对 IP 打码保护隐私。
|
||||
@@ -0,0 +1,156 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// DatabaseConfig holds database connection settings.
|
||||
type DatabaseConfig struct {
|
||||
Driver string `toml:"driver"`
|
||||
DSN string `toml:"dsn"`
|
||||
}
|
||||
|
||||
// WebConfig holds web server settings.
|
||||
type WebConfig struct {
|
||||
// Addr 监听地址:以 / 开头视为 unix socket,否则视为 TCP 端口
|
||||
Addr string `toml:"addr"`
|
||||
}
|
||||
|
||||
// SpeedtestConfig holds speed test limits.
|
||||
type SpeedtestConfig struct {
|
||||
MaxDownloadBytes int64 `toml:"max_download_bytes"` // 单次下载请求最大字节数
|
||||
MaxUploadBytes int64 `toml:"max_upload_bytes"` // 单次上传请求最大字节数
|
||||
}
|
||||
|
||||
// Config is the top-level configuration structure.
|
||||
type Config struct {
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Speedtest SpeedtestConfig `toml:"speedtest"`
|
||||
}
|
||||
|
||||
func isWindows() bool { return runtime.GOOS == "windows" }
|
||||
|
||||
func etcDir() string {
|
||||
if isWindows() {
|
||||
return WinEtcDir
|
||||
}
|
||||
return LinuxEtcDir
|
||||
}
|
||||
|
||||
func baseDir() string {
|
||||
if isWindows() {
|
||||
return WinBaseDir
|
||||
}
|
||||
return LinuxBaseDir
|
||||
}
|
||||
|
||||
func defaultDSN() string {
|
||||
if isWindows() {
|
||||
return DefaultDSNWin
|
||||
}
|
||||
return DefaultDSNLinux
|
||||
}
|
||||
|
||||
// defaultConfig returns a fully populated Config with default values.
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
Database: DatabaseConfig{
|
||||
Driver: DefaultDBDriver,
|
||||
DSN: defaultDSN(),
|
||||
},
|
||||
Web: WebConfig{
|
||||
Addr: DefaultWebAddr,
|
||||
},
|
||||
Speedtest: SpeedtestConfig{
|
||||
MaxDownloadBytes: DefaultMaxDownloadBytes,
|
||||
MaxUploadBytes: DefaultMaxUploadBytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// configFilePath returns the full path to the configuration file.
|
||||
// 支持通过环境变量 SPEEDTEST_CONFIG 覆盖(本地调试用)。
|
||||
func configFilePath() string {
|
||||
if p := os.Getenv("SPEEDTEST_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(etcDir(), ConfigFileName)
|
||||
}
|
||||
|
||||
// mergeDefaults overlays default values onto the loaded config for any zero/empty fields.
|
||||
func mergeDefaults(cfg *Config, defaults *Config) *Config {
|
||||
if cfg.Database.Driver == "" {
|
||||
cfg.Database.Driver = defaults.Database.Driver
|
||||
}
|
||||
if cfg.Database.DSN == "" {
|
||||
cfg.Database.DSN = defaults.Database.DSN
|
||||
}
|
||||
if cfg.Web.Addr == "" {
|
||||
cfg.Web.Addr = defaults.Web.Addr
|
||||
}
|
||||
if cfg.Speedtest.MaxDownloadBytes == 0 {
|
||||
cfg.Speedtest.MaxDownloadBytes = defaults.Speedtest.MaxDownloadBytes
|
||||
}
|
||||
if cfg.Speedtest.MaxUploadBytes == 0 {
|
||||
cfg.Speedtest.MaxUploadBytes = defaults.Speedtest.MaxUploadBytes
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// writeConfig writes the configuration to the given file path.
|
||||
func writeConfig(path string, cfg *Config) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建配置目录失败 %s: %w", dir, err)
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建配置文件失败 %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := toml.NewEncoder(f)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfig loads the configuration from disk.
|
||||
// If the configuration file does not exist, it creates one with default values.
|
||||
// If the file exists but has missing fields, they are filled with defaults and the file is updated.
|
||||
func LoadConfig() (*Config, error) {
|
||||
path := configFilePath()
|
||||
defaults := defaultConfig()
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if mkErr := writeConfig(path, defaults); mkErr != nil {
|
||||
return nil, mkErr
|
||||
}
|
||||
return defaults, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取配置文件失败 %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
merged := mergeDefaults(cfg, defaults)
|
||||
if writeErr := writeConfig(path, merged); writeErr != nil {
|
||||
return nil, writeErr
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
// Linux path prefixes
|
||||
const (
|
||||
LinuxEtcDir = "/etc/speedtest/"
|
||||
LinuxBaseDir = "/srv/speedtest/"
|
||||
)
|
||||
|
||||
// Windows path prefixes(本地调试用)
|
||||
const (
|
||||
WinEtcDir = "./win/etc/speedtest/"
|
||||
WinBaseDir = "./win/srv/speedtest/"
|
||||
)
|
||||
|
||||
// Default database settings
|
||||
const (
|
||||
DefaultDBDriver = "sqlite"
|
||||
DefaultDSNLinux = "/srv/speedtest/speedtest.db"
|
||||
DefaultDSNWin = "./win/srv/speedtest/speedtest.db"
|
||||
)
|
||||
|
||||
// Default web listen address(unix socket,由 Caddy 反代)
|
||||
const DefaultWebAddr = "/opt/speedtest/web.sock"
|
||||
|
||||
// Default speedtest limits(字节)
|
||||
const (
|
||||
DefaultMaxDownloadBytes = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultMaxUploadBytes = 100 * 1024 * 1024 // 100 MB
|
||||
)
|
||||
|
||||
// ConfigFileName is the name of the configuration file
|
||||
const ConfigFileName = "speedtest.toml"
|
||||
@@ -0,0 +1,45 @@
|
||||
module speedtest
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
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.12 // 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/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.22 // 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.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
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/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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/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.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
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.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/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.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# speedtest 服务安装/管理脚本
|
||||
# 用法:
|
||||
# sudo ./install.sh install — 安装/更新服务
|
||||
# sudo ./install.sh uninstall — 卸载服务
|
||||
# sudo ./install.sh start — 启动服务
|
||||
# sudo ./install.sh stop — 停止服务
|
||||
# sudo ./install.sh restart — 重启服务
|
||||
# sudo ./install.sh status — 查看服务状态
|
||||
#
|
||||
|
||||
source /etc/profile
|
||||
source ~/.bashrc
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ======================== 配置区 ========================
|
||||
SERVICE_NAME="speedtest"
|
||||
SERVICE_USER="speedtest"
|
||||
SERVICE_DESC="SpeedTest - Go 网速测试(延迟/下载/上传 + 排行)"
|
||||
|
||||
# 目录
|
||||
INSTALL_DIR="/opt/speedtest" # 程序安装目录
|
||||
DATA_DIR="/srv/speedtest" # 数据目录(SQLite 数据库)
|
||||
CONFIG_DIR="/etc/speedtest" # 配置文件目录
|
||||
LOG_DIR="/var/log/speedtest" # 日志目录
|
||||
|
||||
# Git 仓库
|
||||
GIT_REPO="https://git.lmve.net/dsh/speedtest.git"
|
||||
GIT_BRANCH="main"
|
||||
|
||||
# 编译临时目录
|
||||
BUILD_DIR="/tmp/speedtest_build"
|
||||
|
||||
# systemd 服务文件路径
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
# ======================== 颜色输出 ========================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||
|
||||
# ======================== 前置检查 ========================
|
||||
check_root() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
error "此脚本需要 root 权限运行,请使用 sudo"
|
||||
fi
|
||||
}
|
||||
|
||||
# ======================== 用户管理 ========================
|
||||
ensure_user() {
|
||||
if id "${SERVICE_USER}" &>/dev/null; then
|
||||
ok "用户 ${SERVICE_USER} 已存在"
|
||||
else
|
||||
info "创建系统用户 ${SERVICE_USER} ..."
|
||||
useradd -r -s /usr/sbin/nologin -d "${INSTALL_DIR}" -c "${SERVICE_DESC}" "${SERVICE_USER}"
|
||||
ok "用户 ${SERVICE_USER} 创建成功"
|
||||
fi
|
||||
}
|
||||
|
||||
# ======================== 目录管理 ========================
|
||||
ensure_dirs() {
|
||||
info "检查并创建关键目录 ..."
|
||||
|
||||
# 安装目录(含模板子目录结构)
|
||||
mkdir -p "${INSTALL_DIR}/internal/web/templates"
|
||||
# 配置目录
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
# 数据目录
|
||||
mkdir -p "${DATA_DIR}"
|
||||
# 日志目录
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
# 设置所有权(每次安装都强制修正,避免旧版遗留权限问题)
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${LOG_DIR}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}"
|
||||
|
||||
# 确保目录可写
|
||||
chmod 755 "${CONFIG_DIR}" "${DATA_DIR}" "${LOG_DIR}" "${INSTALL_DIR}"
|
||||
|
||||
ok "目录结构就绪"
|
||||
}
|
||||
|
||||
# ======================== 编译 ========================
|
||||
build_binary() {
|
||||
info "拉取最新代码 ..."
|
||||
if [[ -d "${BUILD_DIR}/.git" ]]; then
|
||||
cd "${BUILD_DIR}"
|
||||
git fetch --all
|
||||
git reset --hard "origin/${GIT_BRANCH}"
|
||||
info "代码已更新到最新"
|
||||
else
|
||||
rm -rf "${BUILD_DIR}"
|
||||
git clone -b "${GIT_BRANCH}" "${GIT_REPO}" "${BUILD_DIR}"
|
||||
cd "${BUILD_DIR}"
|
||||
info "代码克隆完成"
|
||||
fi
|
||||
|
||||
info "编译 Go 项目(CGO 已启用,SQLite 依赖) ..."
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o speedtest .
|
||||
ok "编译完成: ${BUILD_DIR}/speedtest"
|
||||
}
|
||||
|
||||
# ======================== 部署文件 ========================
|
||||
deploy_files() {
|
||||
info "部署文件到 ${INSTALL_DIR} ..."
|
||||
|
||||
# 停止服务(如果正在运行),避免替换正在使用的二进制
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
||||
info "停止运行中的服务 ..."
|
||||
systemctl stop "${SERVICE_NAME}"
|
||||
fi
|
||||
|
||||
# 复制二进制
|
||||
cp -f "${BUILD_DIR}/speedtest" "${INSTALL_DIR}/speedtest"
|
||||
chmod 755 "${INSTALL_DIR}/speedtest"
|
||||
|
||||
# 复制模板文件(目录结构: internal/web/templates/*.html)
|
||||
cp -f "${BUILD_DIR}/internal/web/templates/"*.html "${INSTALL_DIR}/internal/web/templates/"
|
||||
|
||||
# 配置文件由程序首次启动时自动生成 + 缺失项自动补全,脚本不干预
|
||||
if [[ -f "${CONFIG_DIR}/speedtest.toml" ]]; then
|
||||
info "保留现有配置文件: ${CONFIG_DIR}/speedtest.toml"
|
||||
else
|
||||
info "配置文件不存在,程序首次启动将自动生成"
|
||||
fi
|
||||
|
||||
# 设置安装目录所有权
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
|
||||
ok "文件部署完成"
|
||||
}
|
||||
|
||||
# ======================== systemd 服务 ========================
|
||||
install_service() {
|
||||
info "创建 systemd 服务文件 ..."
|
||||
|
||||
cat > "${SERVICE_FILE}" <<EOF
|
||||
[Unit]
|
||||
Description=${SERVICE_DESC}
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/speedtest
|
||||
ExecReload=/bin/kill -HUP \$MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65536
|
||||
|
||||
# 日志输出到 journald
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=${SERVICE_NAME}
|
||||
|
||||
# 安全加固:专用低权用户运行
|
||||
# ProtectSystem=strict/full 会阻止写入 /etc 和 /opt 等目录,
|
||||
# 导致配置文件和 unix socket 无法创建,得不偿失
|
||||
ProtectHome=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
# 环境变量
|
||||
Environment=GIN_MODE=release
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chmod 644 "${SERVICE_FILE}"
|
||||
systemctl daemon-reload
|
||||
ok "systemd 服务文件已创建"
|
||||
}
|
||||
|
||||
# ======================== 安装主流程 ========================
|
||||
do_install() {
|
||||
info "========== 安装 ${SERVICE_NAME} =========="
|
||||
|
||||
# 1. 检查依赖
|
||||
check_root
|
||||
command -v git &>/dev/null || error "需要 git,请先安装"
|
||||
command -v go &>/dev/null || error "需要 go,请先安装"
|
||||
command -v gcc &>/dev/null || error "需要 gcc(CGO/SQLite 编译依赖),请先安装"
|
||||
|
||||
# 2. 创建用户
|
||||
ensure_user
|
||||
|
||||
# 3. 创建目录
|
||||
ensure_dirs
|
||||
|
||||
# 4. 拉取代码并编译
|
||||
build_binary
|
||||
|
||||
# 5. 部署文件
|
||||
deploy_files
|
||||
|
||||
# 6. 安装服务
|
||||
install_service
|
||||
|
||||
# 7. 启动并设置开机自启
|
||||
info "启动服务 ..."
|
||||
systemctl start "${SERVICE_NAME}"
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
|
||||
sleep 1
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||
ok "服务启动成功!"
|
||||
else
|
||||
error "服务启动失败,请检查日志: journalctl -u ${SERVICE_NAME} -n 50"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "========== 安装完成 =========="
|
||||
echo " 程序目录: ${INSTALL_DIR}"
|
||||
echo " 配置文件: ${CONFIG_DIR}/speedtest.toml"
|
||||
echo " 数据目录: ${DATA_DIR}"
|
||||
echo " 日志查看: journalctl -u ${SERVICE_NAME} -f"
|
||||
echo " 服务管理: systemctl {start|stop|restart|status} ${SERVICE_NAME}"
|
||||
echo ""
|
||||
echo " 监听地址: unix socket ${INSTALL_DIR}/web.sock(由 Caddy 反代)"
|
||||
echo " 访问地址: https://speedtest.lmve.net"
|
||||
echo ""
|
||||
warn " 若 Caddy 尚未配置反代,请编辑 /etc/caddy/Caddyfile 添加:"
|
||||
echo ""
|
||||
echo " speedtest.lmve.net {"
|
||||
echo " reverse_proxy unix//opt/speedtest/web.sock {"
|
||||
echo " header_up X-Real-IP {remote_host}"
|
||||
echo " header_up Host {host}"
|
||||
echo " }"
|
||||
echo " }"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ======================== 卸载 ========================
|
||||
do_uninstall() {
|
||||
info "========== 卸载 ${SERVICE_NAME} =========="
|
||||
check_root
|
||||
|
||||
# 停止服务
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
||||
systemctl stop "${SERVICE_NAME}"
|
||||
info "服务已停止"
|
||||
fi
|
||||
|
||||
# 禁用开机自启
|
||||
if systemctl is-enabled --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
||||
systemctl disable "${SERVICE_NAME}"
|
||||
info "已禁用开机自启"
|
||||
fi
|
||||
|
||||
# 删除服务文件
|
||||
if [[ -f "${SERVICE_FILE}" ]]; then
|
||||
rm -f "${SERVICE_FILE}"
|
||||
systemctl daemon-reload
|
||||
info "服务文件已删除"
|
||||
fi
|
||||
|
||||
# 询问是否删除数据
|
||||
echo ""
|
||||
warn "以下目录包含运行数据,是否删除?"
|
||||
echo " [1] ${INSTALL_DIR} (程序文件+模板)"
|
||||
echo " [2] ${DATA_DIR} (数据库)"
|
||||
echo " [3] ${CONFIG_DIR} (配置文件)"
|
||||
echo " [4] ${LOG_DIR} (日志)"
|
||||
echo " [0] 全部保留(默认)"
|
||||
echo " [a] 全部删除"
|
||||
echo ""
|
||||
read -rp "请选择 [0/a/组合如 14]: " choice
|
||||
|
||||
case "${choice:-0}" in
|
||||
0)
|
||||
info "保留所有数据目录"
|
||||
;;
|
||||
a|A)
|
||||
rm -rf "${INSTALL_DIR}" "${DATA_DIR}" "${CONFIG_DIR}" "${LOG_DIR}"
|
||||
info "所有数据目录已删除"
|
||||
;;
|
||||
*)
|
||||
[[ "${choice}" == *1* ]] && rm -rf "${INSTALL_DIR}" && info "已删除 ${INSTALL_DIR}"
|
||||
[[ "${choice}" == *2* ]] && rm -rf "${DATA_DIR}" && info "已删除 ${DATA_DIR}"
|
||||
[[ "${choice}" == *3* ]] && rm -rf "${CONFIG_DIR}" && info "已删除 ${CONFIG_DIR}"
|
||||
[[ "${choice}" == *4* ]] && rm -rf "${LOG_DIR}" && info "已删除 ${LOG_DIR}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# 删除用户(如果没有其他依赖)
|
||||
if id "${SERVICE_USER}" &>/dev/null; then
|
||||
read -rp "是否删除系统用户 ${SERVICE_USER}?[y/N]: " del_user
|
||||
if [[ "${del_user}" =~ ^[Yy]$ ]]; then
|
||||
userdel "${SERVICE_USER}"
|
||||
info "系统用户 ${SERVICE_USER} 已删除"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 清理编译目录
|
||||
rm -rf "${BUILD_DIR}"
|
||||
|
||||
ok "卸载完成"
|
||||
}
|
||||
|
||||
# ======================== 服务控制 ========================
|
||||
do_start() {
|
||||
check_root
|
||||
systemctl start "${SERVICE_NAME}"
|
||||
sleep 1
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||
ok "服务已启动"
|
||||
else
|
||||
error "服务启动失败,查看日志: journalctl -u ${SERVICE_NAME} -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
check_root
|
||||
systemctl stop "${SERVICE_NAME}"
|
||||
ok "服务已停止"
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
check_root
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
sleep 1
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||
ok "服务已重启"
|
||||
else
|
||||
error "服务重启失败,查看日志: journalctl -u ${SERVICE_NAME} -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
do_status() {
|
||||
systemctl status "${SERVICE_NAME}" --no-pager || true
|
||||
}
|
||||
|
||||
# ======================== 入口 ========================
|
||||
case "${1:-}" in
|
||||
install) do_install ;;
|
||||
uninstall) do_uninstall ;;
|
||||
start) do_start ;;
|
||||
stop) do_stop ;;
|
||||
restart) do_restart ;;
|
||||
status) do_status ;;
|
||||
*)
|
||||
echo "用法: sudo $0 {install|uninstall|start|stop|restart|status}"
|
||||
echo ""
|
||||
echo " install — 完整安装/更新(拉代码+编译+部署+启动+开机自启)"
|
||||
echo " uninstall — 卸载服务(可选保留数据)"
|
||||
echo " start — 启动服务"
|
||||
echo " stop — 停止服务"
|
||||
echo " restart — 重启服务"
|
||||
echo " status — 查看服务状态"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,49 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"speedtest/config"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// InitDB initializes the database connection and performs auto-migration.
|
||||
func InitDB(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
switch cfg.Driver {
|
||||
case "sqlite":
|
||||
dsn := cfg.DSN
|
||||
// 如果 DSN 是默认相对路径,则基于 base dir 解析
|
||||
if dsn == config.DefaultDSNWin || dsn == config.DefaultDSNLinux {
|
||||
dsn = filepath.Join(config.LinuxBaseDir, "speedtest.db")
|
||||
}
|
||||
// 确保 SQLite 父目录存在
|
||||
dir := filepath.Dir(dsn)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据库目录失败 %s: %w", dir, err)
|
||||
}
|
||||
dialector = sqlite.Open(dsn)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库驱动: %s", cfg.Driver)
|
||||
}
|
||||
|
||||
database, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// Auto-migrate all models
|
||||
if err := database.AutoMigrate(&SpeedTestResult{}); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// SpeedTestResult 一次完整的测速结果记录
|
||||
type SpeedTestResult struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClientIP string `gorm:"size:64;index" json:"client_ip"`
|
||||
LatencyMs float64 `json:"latency_ms"` // 延迟(毫秒,取中位数)
|
||||
JitterMs float64 `json:"jitter_ms"` // 抖动(毫秒,平均绝对偏差)
|
||||
DownloadMbps float64 `json:"download_mbps"` // 下载速度(Mbps)
|
||||
UploadMbps float64 `json:"upload_mbps"` // 上传速度(Mbps)
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"speedtest/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResultStore 测速结果数据访问层
|
||||
type ResultStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewResultStore creates a new ResultStore.
|
||||
func NewResultStore(database *gorm.DB) *ResultStore {
|
||||
return &ResultStore{db: database}
|
||||
}
|
||||
|
||||
// Create 保存一次测速结果
|
||||
func (s *ResultStore) Create(r *db.SpeedTestResult) error {
|
||||
return s.db.Create(r).Error
|
||||
}
|
||||
|
||||
// TopDownload 返回下载速度最快的 n 条记录
|
||||
func (s *ResultStore) TopDownload(n int) ([]db.SpeedTestResult, error) {
|
||||
var results []db.SpeedTestResult
|
||||
err := s.db.Order("download_mbps DESC").Limit(n).Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// TopUpload 返回上传速度最快的 n 条记录
|
||||
func (s *ResultStore) TopUpload(n int) ([]db.SpeedTestResult, error) {
|
||||
var results []db.SpeedTestResult
|
||||
err := s.db.Order("upload_mbps DESC").Limit(n).Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// TopLatency 返回延迟最低的 n 条记录
|
||||
func (s *ResultStore) TopLatency(n int) ([]db.SpeedTestResult, error) {
|
||||
var results []db.SpeedTestResult
|
||||
err := s.db.Order("latency_ms ASC").Limit(n).Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// Count 返回测速总次数
|
||||
func (s *ResultStore) Count() (int64, error) {
|
||||
var total int64
|
||||
err := s.db.Model(&db.SpeedTestResult{}).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Stores 聚合所有数据访问层
|
||||
type Stores struct {
|
||||
Results *ResultStore
|
||||
}
|
||||
|
||||
// NewStores creates a new Stores instance.
|
||||
func NewStores(database *gorm.DB) *Stores {
|
||||
return &Stores{
|
||||
Results: NewResultStore(database),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"speedtest/config"
|
||||
"speedtest/internal/db"
|
||||
"speedtest/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
poolChunkSize = 1 << 20 // 1 MB
|
||||
poolChunkN = 16 // 16 MB 随机数据池
|
||||
rankLimit = 10 // 排行榜条数
|
||||
)
|
||||
|
||||
// Handler 测速 HTTP 处理器
|
||||
type Handler struct {
|
||||
stores *store.Stores
|
||||
cfg config.SpeedtestConfig
|
||||
pool [][]byte // 预生成随机数据池(下载测速负载)
|
||||
poolNext atomic.Int64
|
||||
}
|
||||
|
||||
// NewHandler creates a new speedtest Handler.
|
||||
func NewHandler(stores *store.Stores, cfg config.SpeedtestConfig) (*Handler, error) {
|
||||
h := &Handler{stores: stores, cfg: cfg}
|
||||
if err := h.buildPool(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// buildPool 预生成随机数据池,避免下载时实时生成拖慢吞吐
|
||||
func (h *Handler) buildPool() error {
|
||||
h.pool = make([][]byte, poolChunkN)
|
||||
buf := make([]byte, poolChunkSize*poolChunkN)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Errorf("生成随机数据池失败: %w", err)
|
||||
}
|
||||
for i := 0; i < poolChunkN; i++ {
|
||||
h.pool[i] = buf[i*poolChunkSize : (i+1)*poolChunkSize]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientIP 获取真实客户端 IP:优先 Caddy 传递的 X-Real-IP / X-Forwarded-For
|
||||
func clientIP(c *gin.Context) string {
|
||||
if xr := c.GetHeader("X-Real-IP"); xr != "" {
|
||||
if ip := net.ParseIP(strings.TrimSpace(xr)); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
if xf := c.GetHeader("X-Forwarded-For"); xf != "" {
|
||||
first := strings.TrimSpace(strings.Split(xf, ",")[0])
|
||||
if ip := net.ParseIP(first); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
||||
if err != nil {
|
||||
return c.Request.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// maskIP 排行榜展示时对 IP 打码(保留前 3 段)
|
||||
func maskIP(ip string) string {
|
||||
if strings.Contains(ip, ":") {
|
||||
// IPv6:保留前 3 段
|
||||
parts := strings.Split(ip, ":")
|
||||
if len(parts) > 3 {
|
||||
return strings.Join(parts[:3], ":") + ":****"
|
||||
}
|
||||
return ip
|
||||
}
|
||||
parts := strings.Split(ip, ".")
|
||||
if len(parts) == 4 {
|
||||
return strings.Join(parts[:3], ".") + ".*"
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// Ping 延迟探测:返回最小响应,供前端计算 RTT
|
||||
func (h *Handler) Ping(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pong": true,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// parseSize 解析 size 参数(字节),限制在 [1, max]
|
||||
func parseSize(raw string, def, max int64) int64 {
|
||||
size, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || size <= 0 {
|
||||
size = def
|
||||
}
|
||||
if size > max {
|
||||
size = max
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// Download 下载测速:流式输出预生成的随机数据
|
||||
func (h *Handler) Download(c *gin.Context) {
|
||||
max := h.cfg.MaxDownloadBytes
|
||||
if max <= 0 {
|
||||
max = config.DefaultMaxDownloadBytes
|
||||
}
|
||||
size := parseSize(c.Query("size"), 10*1024*1024, max)
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Length", strconv.FormatInt(size, 10))
|
||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
c.Header("Content-Disposition", "attachment; filename=random.dat")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// 每次请求从不同偏移开始,避免数据完全重复
|
||||
start := h.poolNext.Add(1)
|
||||
|
||||
w := c.Writer
|
||||
written := int64(0)
|
||||
for written < size {
|
||||
idx := (start + written/poolChunkSize) % int64(poolChunkN)
|
||||
chunk := h.pool[idx]
|
||||
remaining := size - written
|
||||
if remaining < int64(len(chunk)) {
|
||||
chunk = chunk[:remaining]
|
||||
}
|
||||
if n, err := w.Write(chunk); err != nil {
|
||||
return // 客户端断开,静默结束
|
||||
} else {
|
||||
written += int64(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 上传测速:接收并丢弃请求体,统计接收字节数与耗时
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
max := h.cfg.MaxUploadBytes
|
||||
if max <= 0 {
|
||||
max = config.DefaultMaxUploadBytes
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, max)
|
||||
|
||||
start := time.Now()
|
||||
n, err := io.Copy(io.Discard, c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传数据失败", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
elapsed := time.Since(start).Seconds()
|
||||
mbps := float64(n) * 8 / elapsed / 1e6
|
||||
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"received": n,
|
||||
"elapsed_s": elapsed,
|
||||
"mbps": mbps,
|
||||
})
|
||||
}
|
||||
|
||||
// resultReq 前端提交的测速结果
|
||||
type resultReq struct {
|
||||
LatencyMs float64 `json:"latency_ms"`
|
||||
JitterMs float64 `json:"jitter_ms"`
|
||||
DownloadMbps float64 `json:"download_mbps"`
|
||||
UploadMbps float64 `json:"upload_mbps"`
|
||||
}
|
||||
|
||||
// Result 保存一次测速结果
|
||||
func (h *Handler) Result(c *gin.Context) {
|
||||
var req resultReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数格式错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 基本合理性校验,防止脏数据
|
||||
if req.LatencyMs < 0 || req.LatencyMs > 10000 ||
|
||||
req.JitterMs < 0 || req.JitterMs > 10000 ||
|
||||
req.DownloadMbps < 0 || req.DownloadMbps > 100000 ||
|
||||
req.UploadMbps < 0 || req.UploadMbps > 100000 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数超出合理范围"})
|
||||
return
|
||||
}
|
||||
|
||||
record := &db.SpeedTestResult{
|
||||
ClientIP: clientIP(c),
|
||||
LatencyMs: req.LatencyMs,
|
||||
JitterMs: req.JitterMs,
|
||||
DownloadMbps: req.DownloadMbps,
|
||||
UploadMbps: req.UploadMbps,
|
||||
}
|
||||
if err := h.stores.Results.Create(record); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存结果失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": record.ID})
|
||||
}
|
||||
|
||||
// RankItem 排行榜展示条目(IP 打码)
|
||||
type RankItem struct {
|
||||
ID uint `json:"id"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
LatencyMs float64 `json:"latency_ms"`
|
||||
JitterMs float64 `json:"jitter_ms"`
|
||||
DownloadMbps float64 `json:"download_mbps"`
|
||||
UploadMbps float64 `json:"upload_mbps"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toRankItems(rows []db.SpeedTestResult) []RankItem {
|
||||
items := make([]RankItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, RankItem{
|
||||
ID: r.ID,
|
||||
ClientIP: maskIP(r.ClientIP),
|
||||
LatencyMs: r.LatencyMs,
|
||||
JitterMs: r.JitterMs,
|
||||
DownloadMbps: r.DownloadMbps,
|
||||
UploadMbps: r.UploadMbps,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Rankings 排行榜:下载榜 / 上传榜 / 延迟榜 + 总测试次数
|
||||
func (h *Handler) Rankings(c *gin.Context) {
|
||||
download, _ := h.stores.Results.TopDownload(rankLimit)
|
||||
upload, _ := h.stores.Results.TopUpload(rankLimit)
|
||||
latency, _ := h.stores.Results.TopLatency(rankLimit)
|
||||
total, _ := h.stores.Results.Count()
|
||||
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"download": toRankItems(download),
|
||||
"upload": toRankItems(upload),
|
||||
"latency": toRankItems(latency),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"speedtest/config"
|
||||
"speedtest/internal/store"
|
||||
"speedtest/internal/web/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// WebServer wraps the Gin engine and its dependencies.
|
||||
type WebServer struct {
|
||||
engine *gin.Engine
|
||||
stores *store.Stores
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
// NewWebServer creates a new WebServer, initializes the Gin engine and registers routes.
|
||||
func NewWebServer(cfg config.Config, stores *store.Stores) (*WebServer, error) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
// 不信任默认代理(真实 IP 通过 X-Real-IP 头由 Caddy 注入,自行解析)
|
||||
_ = engine.SetTrustedProxies(nil)
|
||||
|
||||
// 加载 HTML 模板
|
||||
tmpl := template.Must(template.New("").ParseGlob("internal/web/templates/*.html"))
|
||||
engine.SetHTMLTemplate(tmpl)
|
||||
|
||||
handler, err := handlers.NewHandler(stores, cfg.Speedtest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws := &WebServer{
|
||||
engine: engine,
|
||||
stores: stores,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
// 页面
|
||||
engine.GET("/", func(c *gin.Context) {
|
||||
c.HTML(200, "index.html", gin.H{})
|
||||
})
|
||||
|
||||
// 测速 API
|
||||
api := engine.Group("/api")
|
||||
{
|
||||
api.GET("/ping", handler.Ping)
|
||||
api.GET("/download", handler.Download)
|
||||
api.POST("/upload", handler.Upload)
|
||||
api.POST("/result", handler.Result)
|
||||
api.GET("/rankings", handler.Rankings)
|
||||
}
|
||||
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// Start launches the HTTP server on the configured address.
|
||||
// Supports both TCP (e.g. ":8080") and Unix socket (e.g. "/opt/speedtest/web.sock").
|
||||
func (ws *WebServer) Start() error {
|
||||
addr := ws.cfg.Web.Addr
|
||||
|
||||
// Unix socket:地址以 / 开头
|
||||
if strings.HasPrefix(addr, "/") {
|
||||
// 清理旧的 socket 文件
|
||||
os.Remove(addr)
|
||||
|
||||
listener, err := net.Listen("unix", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("监听 Unix socket 失败 %s: %w", addr, err)
|
||||
}
|
||||
// 允许 caddy 等外部进程连接
|
||||
os.Chmod(addr, 0666)
|
||||
|
||||
return ws.engine.RunListener(listener)
|
||||
}
|
||||
|
||||
// TCP 端口
|
||||
return ws.engine.Run(addr)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SpeedTest · 网速测试 - lmve.net</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--card: rgba(255, 255, 255, 0.045);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: #e8ecf6;
|
||||
--muted: #8b93a7;
|
||||
--accent1: #00d4ff;
|
||||
--accent2: #7c5cff;
|
||||
--green: #2ee6a8;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: radial-gradient(1200px 600px at 50% -100px, #17203f 0%, var(--bg) 60%) fixed, var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.wrap { max-width: 860px; margin: 0 auto; padding: 32px 16px 60px; }
|
||||
header { text-align: center; margin-bottom: 28px; }
|
||||
.logo {
|
||||
font-size: 30px; font-weight: 800; letter-spacing: 0.5px;
|
||||
background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.sub { color: var(--muted); font-size: 13px; margin-top: 6px; }
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* ---------- 仪表盘 ---------- */
|
||||
.gauge-card { position: relative; padding: 10px 20px 28px; text-align: center; }
|
||||
.gauge-box { position: relative; width: 360px; height: 300px; margin: 0 auto; }
|
||||
#gauge { width: 360px; height: 300px; }
|
||||
.gauge-center {
|
||||
position: absolute; left: 0; right: 0; top: 96px; text-align: center; pointer-events: none;
|
||||
}
|
||||
#gaugeValue { font-size: 44px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -1px; }
|
||||
#gaugeUnit { color: var(--muted); font-size: 14px; margin-top: 2px; }
|
||||
#phaseLabel { color: var(--muted); font-size: 14px; margin-top: 6px; min-height: 20px; }
|
||||
#startBtn {
|
||||
margin-top: 18px; padding: 13px 56px; font-size: 17px; font-weight: 700; color: #06121f;
|
||||
background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||||
border: none; border-radius: 999px; cursor: pointer; transition: transform .12s, filter .2s, opacity .2s;
|
||||
box-shadow: 0 8px 28px rgba(0, 212, 255, 0.25);
|
||||
}
|
||||
#startBtn:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||||
#startBtn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* ---------- 统计卡片 ---------- */
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 18px; }
|
||||
.stat {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
|
||||
padding: 16px 10px; text-align: center;
|
||||
}
|
||||
.stat .label { color: var(--muted); font-size: 12px; }
|
||||
.stat .value { font-size: 26px; font-weight: 700; margin-top: 6px; font-variant-numeric: tabular-nums; }
|
||||
.stat .unit { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.stat.done { border-color: rgba(46, 230, 168, 0.35); }
|
||||
|
||||
/* ---------- 排行榜 ---------- */
|
||||
.rank-card { margin-top: 18px; padding: 22px 22px 10px; }
|
||||
.rank-head { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.rank-head h2 { font-size: 18px; }
|
||||
.total { color: var(--muted); font-size: 13px; }
|
||||
.tabs { display: flex; gap: 8px; margin: 14px 0 4px; }
|
||||
.tab {
|
||||
padding: 7px 18px; font-size: 13px; color: var(--muted); background: transparent;
|
||||
border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: #06121f; font-weight: 700; border-color: transparent;
|
||||
background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th {
|
||||
text-align: left; color: var(--muted); font-size: 12px; font-weight: 500;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td { padding: 11px 10px; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.04); font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.rank-no {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 8px; font-size: 12px; font-weight: 700;
|
||||
background: rgba(255,255,255,0.07); color: var(--muted);
|
||||
}
|
||||
.rank-no.top1 { background: linear-gradient(135deg, #ffd76a, #ff9d3c); color: #3a2500; }
|
||||
.rank-no.top2 { background: linear-gradient(135deg, #e8ecf6, #aab4cc); color: #232a3d; }
|
||||
.rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; }
|
||||
.speed-val { font-weight: 700; }
|
||||
.ip { color: var(--muted); font-size: 13px; }
|
||||
.time { color: var(--muted); font-size: 12px; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
|
||||
|
||||
footer { text-align: center; color: var(--muted); font-size: 12px; margin-top: 26px; line-height: 1.8; }
|
||||
footer a { color: var(--accent1); text-decoration: none; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.gauge-box { width: 320px; height: 270px; }
|
||||
#gauge { width: 320px; height: 270px; }
|
||||
.gauge-center { top: 84px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">⚡ SpeedTest</div>
|
||||
<div class="sub">speedtest.lmve.net · lmve.net 网速测试服务</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- 仪表盘 -->
|
||||
<section class="card gauge-card">
|
||||
<div class="gauge-box">
|
||||
<canvas id="gauge" width="360" height="300"></canvas>
|
||||
<div class="gauge-center">
|
||||
<div id="gaugeValue">--</div>
|
||||
<div id="gaugeUnit">Mbps</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="phaseLabel">点击下方按钮开始测速(约需 20~40 秒)</div>
|
||||
<button id="startBtn">开始测速</button>
|
||||
</section>
|
||||
|
||||
<!-- 结果统计 -->
|
||||
<section class="stats">
|
||||
<div class="stat" id="cardLatency">
|
||||
<div class="label">延迟</div><div class="value" id="statLatency">--</div><div class="unit">ms</div>
|
||||
</div>
|
||||
<div class="stat" id="cardJitter">
|
||||
<div class="label">抖动</div><div class="value" id="statJitter">--</div><div class="unit">ms</div>
|
||||
</div>
|
||||
<div class="stat" id="cardDownload">
|
||||
<div class="label">下载速度</div><div class="value" id="statDownload">--</div><div class="unit">Mbps</div>
|
||||
</div>
|
||||
<div class="stat" id="cardUpload">
|
||||
<div class="label">上传速度</div><div class="value" id="statUpload">--</div><div class="unit">Mbps</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 排行榜 -->
|
||||
<section class="card rank-card">
|
||||
<div class="rank-head">
|
||||
<h2>🏆 测速排行</h2>
|
||||
<span class="total" id="totalCount"></span>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-type="download">下载排行</button>
|
||||
<button class="tab" data-type="upload">上传排行</button>
|
||||
<button class="tab" data-type="latency">延迟排行</button>
|
||||
</div>
|
||||
<table id="rankTable">
|
||||
<thead><tr><th style="width:64px">#</th><th>IP</th><th id="thSpeed">下载速度</th><th style="width:130px">时间</th></tr></thead>
|
||||
<tbody id="rankBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS<br>
|
||||
每次测速结果自动入库参与排行
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
/* ================= 元素引用 ================= */
|
||||
const gauge = document.getElementById('gauge');
|
||||
const gaugeValue = document.getElementById('gaugeValue');
|
||||
const gaugeUnit = document.getElementById('gaugeUnit');
|
||||
const phaseLabel = document.getElementById('phaseLabel');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const statLatency = document.getElementById('statLatency');
|
||||
const statJitter = document.getElementById('statJitter');
|
||||
const statDownload = document.getElementById('statDownload');
|
||||
const statUpload = document.getElementById('statUpload');
|
||||
const rankBody = document.getElementById('rankBody');
|
||||
const rankTable = document.getElementById('rankTable');
|
||||
const thSpeed = document.getElementById('thSpeed');
|
||||
const totalCount = document.getElementById('totalCount');
|
||||
|
||||
let running = false;
|
||||
let gaugeMode = 'speed'; // speed | latency
|
||||
let rankType = 'download';
|
||||
|
||||
/* ================= 仪表盘 ================= */
|
||||
function drawGauge(frac) {
|
||||
const ctx = gauge.getContext('2d');
|
||||
const W = gauge.width, H = gauge.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const cx = W / 2, cy = H / 2 + 18, r = 118;
|
||||
const start = Math.PI * 0.75, end = Math.PI * 2.25; // 270°
|
||||
const sweep = end - start;
|
||||
|
||||
// 轨道
|
||||
ctx.lineWidth = 16; ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, start, end); ctx.stroke();
|
||||
|
||||
// 进度(渐变)
|
||||
const grad = ctx.createLinearGradient(0, 0, W, H);
|
||||
grad.addColorStop(0, '#00d4ff');
|
||||
grad.addColorStop(1, '#7c5cff');
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, start, start + sweep * Math.min(1, Math.max(0, frac))); ctx.stroke();
|
||||
|
||||
// 刻度
|
||||
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const a = start + sweep * i / 10;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(a) * (r - 28), cy + Math.sin(a) * (r - 28));
|
||||
ctx.lineTo(cx + Math.cos(a) * (r - 34), cy + Math.sin(a) * (r - 34));
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 指针
|
||||
const a = start + sweep * Math.min(1, Math.max(0, frac));
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * (r - 40), cy + Math.sin(a) * (r - 40)); ctx.stroke();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, 7, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
function speedFrac(mbps) {
|
||||
// 对数刻度:1 ~ 1000 Mbps
|
||||
return Math.log10(1 + mbps) / Math.log10(1001);
|
||||
}
|
||||
|
||||
function updateGaugeSpeed(mbps) {
|
||||
gaugeMode = 'speed';
|
||||
gaugeValue.textContent = (mbps >= 100 ? mbps.toFixed(0) : mbps.toFixed(1));
|
||||
gaugeUnit.textContent = 'Mbps';
|
||||
drawGauge(speedFrac(mbps));
|
||||
}
|
||||
|
||||
function updateGaugeLatency(ms) {
|
||||
gaugeMode = 'latency';
|
||||
gaugeValue.textContent = ms.toFixed(0);
|
||||
gaugeUnit.textContent = 'ms';
|
||||
drawGauge(Math.min(1, 100 / Math.max(1, ms)));
|
||||
}
|
||||
|
||||
function setPhase(text) {
|
||||
phaseLabel.textContent = text;
|
||||
}
|
||||
|
||||
/* ================= 延迟测试 ================= */
|
||||
async function pingOnce() {
|
||||
const t0 = performance.now();
|
||||
await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' });
|
||||
return performance.now() - t0;
|
||||
}
|
||||
|
||||
async function pingTest(n) {
|
||||
const rtts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = await pingOnce();
|
||||
rtts.push(t);
|
||||
const mid = [...rtts].sort((a, b) => a - b)[Math.floor(rtts.length / 2)];
|
||||
updateGaugeLatency(mid);
|
||||
if (i < n - 1) await new Promise(r => setTimeout(r, 80));
|
||||
}
|
||||
rtts.sort((a, b) => a - b);
|
||||
const latency = rtts[Math.floor(rtts.length / 2)]; // 中位数
|
||||
const jitter = rtts.reduce((s, v) => s + Math.abs(v - latency), 0) / rtts.length; // 平均绝对偏差
|
||||
return { latency, jitter };
|
||||
}
|
||||
|
||||
/* ================= 下载测试 ================= */
|
||||
async function downloadOnce(sizeBytes) {
|
||||
const t0 = performance.now();
|
||||
const resp = await fetch('/api/download?size=' + sizeBytes + '&t=' + Date.now(), { cache: 'no-store' });
|
||||
if (!resp.ok || !resp.body) throw new Error('下载测试请求失败');
|
||||
const reader = resp.body.getReader();
|
||||
let received = 0, lastT = t0, lastBytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.length;
|
||||
const now = performance.now();
|
||||
if (now - lastT > 200) {
|
||||
updateGaugeSpeed(((received - lastBytes) * 8) / ((now - lastT) / 1000) / 1e6);
|
||||
lastT = now; lastBytes = received;
|
||||
}
|
||||
}
|
||||
const dt = (performance.now() - t0) / 1000;
|
||||
return (received * 8) / dt / 1e6; // Mbps
|
||||
}
|
||||
|
||||
async function downloadTest() {
|
||||
const sizes = [1048576, 2621440, 5242880, 10485760, 26214400]; // 1, 2.5, 5, 10, 25 MB
|
||||
const results = [];
|
||||
for (const size of sizes) {
|
||||
setPhase('下载测速中 ' + (size / 1048576).toFixed(size < 1048576 * 2 ? 0 : 1) + ' MB …');
|
||||
results.push(await downloadOnce(size));
|
||||
}
|
||||
return Math.max(...results);
|
||||
}
|
||||
|
||||
/* ================= 上传测试 ================= */
|
||||
let prngState = (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
|
||||
function randomBytes(size) {
|
||||
const u8 = new Uint8Array(size);
|
||||
let x = prngState;
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0;
|
||||
u8[i] = x & 0xff;
|
||||
if (i + 1 < size) u8[i + 1] = (x >>> 8) & 0xff;
|
||||
if (i + 2 < size) u8[i + 2] = (x >>> 16) & 0xff;
|
||||
if (i + 3 < size) u8[i + 3] = (x >>> 24) & 0xff;
|
||||
}
|
||||
prngState = x;
|
||||
return u8;
|
||||
}
|
||||
|
||||
function uploadOnce(sizeBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = randomBytes(sizeBytes);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const t0 = performance.now();
|
||||
let lastT = t0, lastBytes = 0;
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
const now = performance.now();
|
||||
if (now - lastT > 200) {
|
||||
updateGaugeSpeed(((e.loaded - lastBytes) * 8) / ((now - lastT) / 1000) / 1e6);
|
||||
lastT = now; lastBytes = e.loaded;
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const dt = (performance.now() - t0) / 1000;
|
||||
resolve((sizeBytes * 8) / dt / 1e6);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('上传测试请求失败'));
|
||||
xhr.ontimeout = () => reject(new Error('上传测试超时'));
|
||||
xhr.timeout = 30000;
|
||||
xhr.open('POST', '/api/upload?t=' + Date.now());
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadTest() {
|
||||
const sizes = [1048576, 2621440, 5242880, 10485760]; // 1, 2.5, 5, 10 MB
|
||||
const results = [];
|
||||
for (const size of sizes) {
|
||||
setPhase('上传测速中 ' + (size / 1048576).toFixed(size < 1048576 * 2 ? 0 : 1) + ' MB …');
|
||||
results.push(await uploadOnce(size));
|
||||
}
|
||||
return Math.max(...results);
|
||||
}
|
||||
|
||||
/* ================= 结果提交 ================= */
|
||||
async function submitResult(r) {
|
||||
const resp = await fetch('/api/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
latency_ms: +r.latency.toFixed(2),
|
||||
jitter_ms: +r.jitter.toFixed(2),
|
||||
download_mbps: +r.download.toFixed(2),
|
||||
upload_mbps: +r.upload.toFixed(2),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error('结果提交失败');
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/* ================= 排行榜 ================= */
|
||||
const SPEED_HEAD = { download: '下载速度', upload: '上传速度', latency: '延迟' };
|
||||
|
||||
async function loadRankings() {
|
||||
try {
|
||||
const resp = await fetch('/api/rankings', { cache: 'no-store' });
|
||||
const data = await resp.json();
|
||||
totalCount.textContent = '共 ' + data.total + ' 次测试';
|
||||
renderRanking(data[rankType] || []);
|
||||
} catch (e) {
|
||||
totalCount.textContent = '排行榜加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRanking(rows) {
|
||||
thSpeed.textContent = SPEED_HEAD[rankType];
|
||||
if (!rows.length) {
|
||||
rankBody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据,快来测速抢占榜首!</td></tr>';
|
||||
return;
|
||||
}
|
||||
rankBody.innerHTML = rows.map((r, i) => {
|
||||
const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : '';
|
||||
let speed;
|
||||
if (rankType === 'latency') {
|
||||
speed = '<span class="speed-val" style="color:var(--green)">' + r.latency_ms.toFixed(1) + '</span> ms';
|
||||
} else if (rankType === 'download') {
|
||||
speed = '<span class="speed-val" style="color:var(--accent1)">' + r.download_mbps.toFixed(2) + '</span> Mbps';
|
||||
} else {
|
||||
speed = '<span class="speed-val" style="color:var(--accent2)">' + r.upload_mbps.toFixed(2) + '</span> Mbps';
|
||||
}
|
||||
return '<tr>' +
|
||||
'<td><span class="rank-no ' + cls + '">' + (i + 1) + '</span></td>' +
|
||||
'<td class="ip">' + r.client_ip + '</td>' +
|
||||
'<td>' + speed + '</td>' +
|
||||
'<td class="time">' + r.created_at + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
rankType = tab.dataset.type;
|
||||
loadRankings();
|
||||
});
|
||||
});
|
||||
|
||||
/* ================= 主流程 ================= */
|
||||
function markDone(el) {
|
||||
el.classList.add('done');
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = '测速中…';
|
||||
['cardLatency', 'cardJitter', 'cardDownload', 'cardUpload'].forEach(id => document.getElementById(id).classList.remove('done'));
|
||||
['statLatency', 'statJitter', 'statDownload', 'statUpload'].forEach(id => document.getElementById(id).textContent = '--');
|
||||
drawGauge(0);
|
||||
|
||||
try {
|
||||
// 1. 延迟
|
||||
setPhase('延迟测试中(10 次 ping)…');
|
||||
const { latency, jitter } = await pingTest(10);
|
||||
statLatency.textContent = latency.toFixed(1);
|
||||
statJitter.textContent = jitter.toFixed(1);
|
||||
markDone(document.getElementById('cardLatency'));
|
||||
markDone(document.getElementById('cardJitter'));
|
||||
|
||||
// 2. 下载
|
||||
setPhase('下载测速准备中…');
|
||||
const download = await downloadTest();
|
||||
statDownload.textContent = download.toFixed(2);
|
||||
updateGaugeSpeed(download);
|
||||
markDone(document.getElementById('cardDownload'));
|
||||
|
||||
// 3. 上传
|
||||
setPhase('上传测速准备中…');
|
||||
const upload = await uploadTest();
|
||||
statUpload.textContent = upload.toFixed(2);
|
||||
updateGaugeSpeed(upload);
|
||||
markDone(document.getElementById('cardUpload'));
|
||||
|
||||
// 4. 提交
|
||||
setPhase('正在提交结果…');
|
||||
await submitResult({ latency, jitter, download, upload });
|
||||
await loadRankings();
|
||||
setPhase('✅ 测试完成,结果已记录');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPhase('⚠ 测试中断:' + e.message);
|
||||
} finally {
|
||||
running = false;
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '再次测速';
|
||||
}
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', runTest);
|
||||
|
||||
/* ================= 初始化 ================= */
|
||||
drawGauge(0);
|
||||
loadRankings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"speedtest/config"
|
||||
"speedtest/internal/db"
|
||||
"speedtest/internal/store"
|
||||
"speedtest/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. Load configuration
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
fmt.Println("配置加载成功")
|
||||
|
||||
// 2. Initialize database
|
||||
database, err := db.InitDB(cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
fmt.Println("数据库初始化成功")
|
||||
|
||||
// 3. Create Store layer
|
||||
stores := store.NewStores(database)
|
||||
|
||||
// 4. Start Web server
|
||||
webServer, err := web.NewWebServer(*cfg, stores)
|
||||
if err != nil {
|
||||
log.Fatalf("Web 服务初始化失败: %v", err)
|
||||
}
|
||||
fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr)
|
||||
go func() {
|
||||
if err := webServer.Start(); err != nil {
|
||||
log.Fatalf("Web 服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Println("SpeedTest 网速测试服务启动完成")
|
||||
select {} // Block main goroutine
|
||||
}
|
||||
Reference in New Issue
Block a user