diff --git a/README.md b/README.md index 701de07..13a35b0 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ - ⬇️ **下载测试**: 固定测速 10 秒——负载根据实测速率自适应(每轮约 2 秒), 快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成 - ⬆️ **上传测试**: 同样的固定 10 秒自适应策略 -- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段); - 内网来源 IP(NAT 场景)完整标注,方便站长调试区分同一出口下的不同设备 +- 🏆 **排行榜**: 单表同时展示延迟 / 下载 / 上传,**点击列头动态排序**(再点切换升降序, + 延迟默认升序、速度默认降序);测速完成后**自动高亮"我的成绩"**(含真实名次, + localStorage 持久化,刷新页面仍高亮);公网 IP 打码,内网来源 IP 完整标注方便调试 - 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IP(Caddy `X-Real-IP`), 不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP - 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口 @@ -110,7 +111,7 @@ speedtest.lmve.net { | GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB) | | POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` | | POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps, client_ip?, server_ip?}` | -| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 与来源 `server_ip`) | +| GET | `/api/rankings?sort=&order=&include_id=` | 排行榜单表:`sort` ∈ download/upload/latency,`order` ∈ asc/desc,`include_id` 附加指定记录(真实名次 + `is_mine`) | 客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务; `client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私; diff --git a/internal/store/result_store.go b/internal/store/result_store.go index 10bcee3..394a783 100644 --- a/internal/store/result_store.go +++ b/internal/store/result_store.go @@ -21,25 +21,50 @@ func (s *ResultStore) Create(r *db.SpeedTestResult) error { return s.db.Create(r).Error } +// TopBy 按指定列排序取前 n 条(col 为白名单列名,asc 控制方向) +func (s *ResultStore) TopBy(col string, asc bool, n int) ([]db.SpeedTestResult, error) { + var results []db.SpeedTestResult + order := col + " DESC" + if asc { + order = col + " ASC" + } + err := s.db.Order(order).Limit(n).Find(&results).Error + return results, err +} + +// GetByID 按 ID 查询单条记录 +func (s *ResultStore) GetByID(id uint) (*db.SpeedTestResult, error) { + var r db.SpeedTestResult + if err := s.db.First(&r, id).Error; err != nil { + return nil, err + } + return &r, nil +} + +// CountBetter 统计比指定值更优的记录数(用于计算记录的真实排名) +func (s *ResultStore) CountBetter(col string, asc bool, value float64) (int64, error) { + op := ">" + if asc { + op = "<" + } + var n int64 + err := s.db.Model(&db.SpeedTestResult{}).Where(col+" "+op+" ?", value).Count(&n).Error + return n, err +} + // 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 + return s.TopBy("download_mbps", false, n) } // 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 + return s.TopBy("upload_mbps", false, n) } // 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 + return s.TopBy("latency_ms", true, n) } // Count 返回测速总次数 diff --git a/internal/web/handlers/speedtest.go b/internal/web/handlers/speedtest.go index d3e0527..686eec6 100644 --- a/internal/web/handlers/speedtest.go +++ b/internal/web/handlers/speedtest.go @@ -234,6 +234,8 @@ func (h *Handler) Result(c *gin.Context) { // ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备) type RankItem struct { ID uint `json:"id"` + Rank int `json:"rank"` // 名次(含附加记录的真实名次) + IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩") ClientIP string `json:"client_ip"` ServerIP string `json:"server_ip"` IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景) @@ -278,18 +280,68 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem { return items } -// Rankings 排行榜:下载榜 / 上传榜 / 延迟榜 + 总测试次数 +// Rankings 排行榜:单表多指标,按 sort/order 动态排序; +// include_id 指定"我的记录"——不在榜内时附加到末尾(带真实名次与 is_mine 标记),供前端高亮 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() + sortField := c.DefaultQuery("sort", "download") + order := c.DefaultQuery("order", "desc") + // 列名白名单,防注入 + var col string + switch sortField { + case "upload": + col = "upload_mbps" + case "latency": + col = "latency_ms" + default: + sortField, col = "download", "download_mbps" + } + asc := order == "asc" + + rows, _ := h.stores.Results.TopBy(col, asc, rankLimit) + items := toRankItems(rows) + for i := range items { + items[i].Rank = i + 1 + } + + // 附加"我的记录"(不在榜内时) + if idStr := strings.TrimSpace(c.Query("include_id")); idStr != "" { + if id, err := strconv.ParseUint(idStr, 10, 64); err == nil && id > 0 { + inList := false + for _, it := range items { + if uint64(it.ID) == id { + inList = true + break + } + } + if !inList { + if rec, err := h.stores.Results.GetByID(uint(id)); err == nil { + item := toRankItems([]db.SpeedTestResult{*rec})[0] + var val float64 + switch col { + case "upload_mbps": + val = rec.UploadMbps + case "latency_ms": + val = rec.LatencyMs + default: + val = rec.DownloadMbps + } + if better, err := h.stores.Results.CountBetter(col, asc, val); err == nil { + item.Rank = int(better) + 1 // 真实名次 + } + item.IsMine = true + items = append(items, item) + } + } + } + } + + 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), + "total": total, + "sort": sortField, + "order": order, + "items": items, }) } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 7303fc2..3b6a793 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -79,23 +79,19 @@ .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); } + th.sortable { cursor: pointer; user-select: none; transition: color .15s; white-space: nowrap; } + th.sortable:hover { color: var(--text); } + th.sortable.active { color: var(--accent1); } + .arrow { font-size: 11px; } + table { width: 100%; border-collapse: collapse; margin-top: 10px; } 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; } + tr.mine { background: rgba(0, 212, 255, 0.10); box-shadow: inset 3px 0 0 var(--accent1); } + tr.mine td { border-bottom-color: rgba(0, 212, 255, 0.12); } .rank-no { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 8px; font-size: 12px; font-weight: 700; @@ -104,7 +100,15 @@ .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; } + .mine-tag { + display: inline-block; margin-left: 8px; padding: 1px 8px; font-size: 11px; font-weight: 700; + color: #06121f; background: linear-gradient(90deg, var(--accent1), var(--accent2)); + border-radius: 999px; vertical-align: 1px; + } + .val-dl { font-weight: 700; color: var(--accent1); } + .val-up { font-weight: 700; color: var(--accent2); } + .val-la { font-weight: 700; color: var(--green); } + .unit-sm { color: var(--muted); font-size: 11px; font-weight: 400; } .ip { color: var(--muted); font-size: 13px; } .ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; } .ip-tag { @@ -113,6 +117,7 @@ } .time { color: var(--muted); font-size: 12px; } .empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; } + .rank-hint { text-align: center; color: var(--muted); font-size: 12px; padding: 12px 0 8px; } 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; } @@ -169,15 +174,20 @@
| # | IP / 来源 | 下载速度 | 时间 | ||
|---|---|---|---|---|---|
| 加载中… | |||||
| # | +IP | +延迟 | +下载 ↓ | +上传 | +时间 | +
| 加载中… | |||||