From 3b48eea5d144db401e2832850337cfc96b32e3fc Mon Sep 17 00:00:00 2001 From: dsh Date: Mon, 17 Aug 2026 09:32:05 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20speedtest=20=E7=BD=91=E9=80=9F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=9C=8D=E5=8A=A1=EF=BC=88Go+Gin+SQLite=EF=BC=8C?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F/=E4=B8=8B=E8=BD=BD/=E4=B8=8A=E4=BC=A0=20+=20?= =?UTF-8?q?=E6=8E=92=E8=A1=8C=E6=A6=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 36 +++ README.md | 109 +++++++ config/config.go | 156 +++++++++ config/defaults.go | 32 ++ go.mod | 45 +++ go.sum | 101 ++++++ install.sh | 366 +++++++++++++++++++++ internal/db/db.go | 49 +++ internal/db/models.go | 14 + internal/store/result_store.go | 50 +++ internal/store/stores.go | 17 + internal/web/handlers/speedtest.go | 255 +++++++++++++++ internal/web/server.go | 89 ++++++ internal/web/templates/index.html | 491 +++++++++++++++++++++++++++++ main.go | 45 +++ 15 files changed, 1855 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.go create mode 100644 config/defaults.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 install.sh create mode 100644 internal/db/db.go create mode 100644 internal/db/models.go create mode 100644 internal/store/result_store.go create mode 100644 internal/store/stores.go create mode 100644 internal/web/handlers/speedtest.go create mode 100644 internal/web/server.go create mode 100644 internal/web/templates/index.html create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa1d5cf --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3047238 --- /dev/null +++ b/README.md @@ -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 打码保护隐私。 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..fe39b8f --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/config/defaults.go b/config/defaults.go new file mode 100644 index 0000000..ed9c652 --- /dev/null +++ b/config/defaults.go @@ -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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8a30180 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7362b24 --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c7bd18e --- /dev/null +++ b/install.sh @@ -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}" </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 diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..d6f51d6 --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..5355f09 --- /dev/null +++ b/internal/db/models.go @@ -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"` +} diff --git a/internal/store/result_store.go b/internal/store/result_store.go new file mode 100644 index 0000000..10bcee3 --- /dev/null +++ b/internal/store/result_store.go @@ -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 +} diff --git a/internal/store/stores.go b/internal/store/stores.go new file mode 100644 index 0000000..5de8d09 --- /dev/null +++ b/internal/store/stores.go @@ -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), + } +} diff --git a/internal/web/handlers/speedtest.go b/internal/web/handlers/speedtest.go new file mode 100644 index 0000000..94d232e --- /dev/null +++ b/internal/web/handlers/speedtest.go @@ -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), + }) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..18b6508 --- /dev/null +++ b/internal/web/server.go @@ -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) +} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html new file mode 100644 index 0000000..ac83e2e --- /dev/null +++ b/internal/web/templates/index.html @@ -0,0 +1,491 @@ + + + + + +SpeedTest · 网速测试 - lmve.net + + + +
+
+ +
speedtest.lmve.net · lmve.net 网速测试服务
+
+ +
+ +
+
+ +
+
--
+
Mbps
+
+
+
点击下方按钮开始测速(约需 20~40 秒)
+ +
+ + +
+
+
延迟
--
ms
+
+
+
抖动
--
ms
+
+
+
下载速度
--
Mbps
+
+
+
上传速度
--
Mbps
+
+
+ + +
+
+

🏆 测速排行

+ +
+
+ + + +
+ + + +
#IP下载速度时间
加载中…
+
+
+ +
+ SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS
+ 每次测速结果自动入库参与排行 +
+
+ + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..3d24389 --- /dev/null +++ b/main.go @@ -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 +}