diff --git a/README.md b/README.md index 9b89aeb..852697c 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ - ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍 (上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载 - ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值 -- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,IP 自动打码(保留前 3 段) +- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段); + 同时保存并展示**内网来源 IP**(服务器识别,NAT 环境下用于区分同一公网出口下的不同设备,方便站长调试) - 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP), 前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注 - "内网 / NAT" + "内网" - 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口 ## 项目结构 @@ -109,9 +110,10 @@ speedtest.lmve.net { | GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...}`) | | 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?}` | -| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 标注) | +| 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`) | -客户端真实 IP 优先取前端探测的公网出口 IP(`client_ip` 字段), -否则回退到 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For`; -排行榜展示时对 IP 打码保护隐私,NAT/内网 IP 标注"内网 / NAT"。 +客户端展示 IP 优先取前端探测的公网出口 IP(`client_ip` 字段), +否则回退到 Caddy 注入的 `X-Real-IP`;`server_ip` 记录服务器看到的来源 IP +(NAT 环境下为内网地址,排行榜中作为"来源"标注,供站长调试区分设备)。 +公网 IP 打码保护隐私,纯内网记录标注"内网"。 diff --git a/internal/db/models.go b/internal/db/models.go index 5355f09..923fe2b 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -5,10 +5,11 @@ 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) + ClientIP string `gorm:"size:64;index" json:"client_ip"` // 展示 IP(优先前端探测的公网出口 IP) + ServerIP string `gorm:"size:64" json:"server_ip"` // 服务器看到的来源 IP(NAT 环境下为内网 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/web/handlers/speedtest.go b/internal/web/handlers/speedtest.go index 44662f7..d3e0527 100644 --- a/internal/web/handlers/speedtest.go +++ b/internal/web/handlers/speedtest.go @@ -180,6 +180,7 @@ type resultReq struct { DownloadMbps float64 `json:"download_mbps"` UploadMbps float64 `json:"upload_mbps"` ClientIP string `json:"client_ip"` // 可选:前端探测到的公网出口 IP + ServerIP string `json:"server_ip"` // 可选:服务器看到的来源 IP(NAT 下为内网 IP) } // Result 保存一次测速结果 @@ -199,15 +200,23 @@ func (h *Handler) Result(c *gin.Context) { return } - // 客户端 IP:优先采用前端探测的公网出口 IP(NAT 环境下服务器只能看到内网 IP); + // 客户端展示 IP:优先采用前端探测的公网出口 IP(NAT 环境下服务器只能看到内网 IP); // 未提供或格式非法(含回环地址伪造)时回退到 Caddy 传递的 X-Real-IP ip := strings.TrimSpace(req.ClientIP) if parsed := net.ParseIP(ip); parsed == nil || parsed.IsLoopback() { ip = clientIP(c) } + // 服务器看到的来源 IP(内网 IP,用于调试区分设备): + // 未提供时回退到 Caddy 传递的 X-Real-IP + serverIP := strings.TrimSpace(req.ServerIP) + if net.ParseIP(serverIP) == nil { + serverIP = clientIP(c) + } + record := &db.SpeedTestResult{ ClientIP: ip, + ServerIP: serverIP, LatencyMs: req.LatencyMs, JitterMs: req.JitterMs, DownloadMbps: req.DownloadMbps, @@ -221,11 +230,13 @@ func (h *Handler) Result(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "id": record.ID}) } -// RankItem 排行榜展示条目(IP 打码) +// RankItem 排行榜展示条目 +// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备) type RankItem struct { ID uint `json:"id"` ClientIP string `json:"client_ip"` - IsPrivateIP bool `json:"is_private_ip"` // NAT/内网环境(如路由器网关),前端标注展示 + ServerIP string `json:"server_ip"` + IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景) LatencyMs float64 `json:"latency_ms"` JitterMs float64 `json:"jitter_ms"` DownloadMbps float64 `json:"download_mbps"` @@ -255,6 +266,7 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem { items = append(items, RankItem{ ID: r.ID, ClientIP: maskIP(r.ClientIP), + ServerIP: r.ServerIP, IsPrivateIP: isPrivateIP(r.ClientIP), LatencyMs: r.LatencyMs, JitterMs: r.JitterMs, diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 1b06ed5..fc9c64e 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -106,6 +106,11 @@ .rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; } .speed-val { font-weight: 700; } .ip { color: var(--muted); font-size: 13px; } + .ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; } + .ip-tag { + display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 11px; + color: #ffb45e; border: 1px solid rgba(255, 180, 94, 0.4); border-radius: 999px; vertical-align: 1px; + } .time { color: var(--muted); font-size: 12px; } .empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; } @@ -170,7 +175,7 @@ - +
#IP下载速度时间
#IP / 来源下载速度时间
加载中…
@@ -429,22 +434,23 @@ function isPrivateIP(ip) { return false; } -function showMyIP(ip, fromServer) { +function showMyIP(publicIP, serverIP) { const el = document.getElementById('myIP'); - if (!ip) { el.hidden = true; return; } + if (!publicIP && !serverIP) { el.hidden = true; return; } el.hidden = false; - if (isPrivateIP(ip)) { - el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)'; - el.className = 'myip private'; - } else { - el.textContent = '你的公网 IP: ' + ip; + const pub = publicIP && !isPrivateIP(publicIP) ? publicIP : ''; + const srv = serverIP && serverIP !== publicIP ? serverIP : ''; + if (pub) { + el.textContent = srv ? '你的公网 IP: ' + pub + ' · 内网来源: ' + srv : '你的公网 IP: ' + pub; el.className = 'myip'; + } else { + el.textContent = srv ? '你的 IP: ' + srv + '(内网 / NAT 环境)' : '你的 IP: ' + (publicIP || serverIP); + el.className = 'myip private'; } - if (fromServer) el.title = '由服务器识别(经 NAT 转发)'; } /* ================= 结果提交 ================= */ -async function submitResult(r, clientIP) { +async function submitResult(r, clientIP, serverIP) { const payload = { latency_ms: +r.latency.toFixed(2), jitter_ms: +r.jitter.toFixed(2), @@ -452,6 +458,7 @@ async function submitResult(r, clientIP) { upload_mbps: +r.upload.toFixed(2), }; if (clientIP) payload.client_ip = clientIP; + if (serverIP) payload.server_ip = serverIP; const resp = await fetch('/api/result', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -485,9 +492,17 @@ function renderRanking(rows) { const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : ''; let ipHtml; if (r.is_private_ip) { - ipHtml = '内网 / NAT'; + // 公网探测失败,只有服务器识别到的内网 IP(完整显示,便于调试) + ipHtml = '' + r.client_ip + '内网'; + if (r.server_ip && r.server_ip !== r.client_ip) { + ipHtml += '← ' + r.server_ip + ''; + } } else { ipHtml = '' + r.client_ip + ''; + // 公网 IP + 内网来源(NAT 环境下区分同一出口下的不同设备) + if (r.server_ip && r.server_ip !== r.client_ip) { + ipHtml += '← ' + r.server_ip + ''; + } } let speed; if (rankType === 'latency') { @@ -562,11 +577,11 @@ async function runTest() { const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' }); serverIP = (await pr.json()).client_ip || ''; } catch (e) { /* ignore */ } - showMyIP(publicIP || serverIP, !publicIP); + showMyIP(publicIP, serverIP); // 5. 提交 setPhase('正在提交结果…'); - await submitResult({ latency, jitter, download, upload }, publicIP); + await submitResult({ latency, jitter, download, upload }, publicIP, serverIP); await loadRankings(); setPhase('✅ 测试完成,结果已记录'); } catch (e) {