feat: speedtest 网速测试服务(Go+Gin+SQLite,延迟/下载/上传 + 排行榜)

This commit is contained in:
dsh
2026-08-17 09:32:05 -04:00
commit 3b48eea5d1
15 changed files with 1855 additions and 0 deletions
+49
View File
@@ -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
}
+14
View File
@@ -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"`
}
+50
View File
@@ -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
}
+17
View File
@@ -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),
}
}
+255
View File
@@ -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),
})
}
+89
View File
@@ -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)
}
+491
View File
@@ -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>