diff --git a/README.md b/README.md index 852697c..701de07 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,13 @@ ## 功能特性 - ⚡ **延迟测试**: 前端连续 10 次 ping,取中位数作为延迟、平均绝对偏差作为抖动 -- ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍 - (上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载 -- ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值 +- ⬇️ **下载测试**: 固定测速 10 秒——负载根据实测速率自适应(每轮约 2 秒), + 快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成 +- ⬆️ **上传测试**: 同样的固定 10 秒自适应策略 - 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段); - 同时保存并展示**内网来源 IP**(服务器识别,NAT 环境下用于区分同一公网出口下的不同设备,方便站长调试) -- 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP), - 前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注 - "内网" + 内网来源 IP(NAT 场景)完整标注,方便站长调试区分同一出口下的不同设备 +- 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IP(Caddy `X-Real-IP`), + 不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP - 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口 ## 项目结构 @@ -113,7 +112,6 @@ speedtest.lmve.net { | 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`;`server_ip` 记录服务器看到的来源 IP -(NAT 环境下为内网地址,排行榜中作为"来源"标注,供站长调试区分设备)。 -公网 IP 打码保护隐私,纯内网记录标注"内网"。 +客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务; +`client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私; +NAT 场景下的内网来源 IP 在排行榜中完整标注,供站长调试区分设备。 diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index fc9c64e..7303fc2 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -143,7 +143,7 @@
Mbps
-
点击下方按钮开始测速(约需 15~30 秒)
+
点击下方按钮开始测速(下载/上传各约 10 秒)
@@ -183,7 +183,7 @@ @@ -365,65 +365,56 @@ function uploadOnce(sizeBytes) { } /* ================= 自适应测速阶段 ================= - * 目标:每轮负载运行 ~2 秒(保证测量准确),负载逐轮翻倍; - * 阶段最短 5 秒,最多 6 轮。慢速连接 1-2 轮即达到单轮 2 秒, - * 快速连接自动加大负载拉长测试时间。 + * 每个阶段(下载/上传)固定测 10 秒:负载根据上一轮实测速率自适应, + * 让每轮约 2 秒。快速连接自动加大负载填满 10 秒;慢速连接用小负载 + * 也能在 10 秒内完成,不会因为网速慢而拖长测试。 */ const MB = 1048576; -const PHASE_MIN_MS = 5000; +const PHASE_DURATION_MS = 10000; const ROUND_TARGET_MS = 2000; -const MAX_ROUNDS = 6; +const MIN_BYTES = 256 * 1024; // 0.25 MB,照顾慢速连接 -async function runPhase(runOnce, minBytes, maxBytes, label) { +async function runPhase(runOnce, maxBytes, label) { const results = []; const phaseStart = performance.now(); - let size = minBytes; - for (let rounds = 0; rounds < MAX_ROUNDS; rounds++) { - setPhase(label + ' ' + (size / MB).toFixed(0) + ' MB …'); + let size = MIN_BYTES; + while (performance.now() - phaseStart < PHASE_DURATION_MS) { + setPhase(label + ' ' + (size / MB).toFixed(size < 10 * MB ? 1 : 0) + ' MB …'); const r = await runOnce(size); results.push(r.mbps); + const elapsed = performance.now() - phaseStart; - const dataEnough = r.ms >= ROUND_TARGET_MS || size >= maxBytes; - const timeEnough = elapsed >= PHASE_MIN_MS && rounds >= 1; - if (dataEnough || timeEnough) break; - size = Math.min(size * 2, maxBytes); + if (elapsed >= PHASE_DURATION_MS) break; + const remaining = PHASE_DURATION_MS - elapsed; + + // 下一轮负载:按实测速率取约 2 秒的数据量 + const rateBps = (r.mbps * 1e6) / 8; // bytes/s + let next = Math.max(MIN_BYTES, Math.min(Math.round((rateBps * ROUND_TARGET_MS) / 1000), maxBytes)); + // 速率没让负载变大时翻倍推进,避免原地踏步 + if (next <= size && r.ms < ROUND_TARGET_MS) next = Math.min(size * 2, maxBytes); + // 若下一轮按估算会跑完剩余时间,把负载裁剪到恰好填满剩余时间(收尾轮) + const nextEstMs = (next * 1000) / Math.max(rateBps, 1); + if (nextEstMs > remaining) { + next = Math.round((rateBps * remaining) / 1000); + if (next < 1) break; + } + size = next; } - return Math.max(...results); + return results.length ? Math.max(...results) : 0; } async function downloadTest() { - return runPhase(downloadOnce, 1 * MB, 400 * MB, '下载测速中'); + return runPhase(downloadOnce, 400 * MB, '下载测速中'); } async function uploadTest() { - return runPhase(uploadOnce, 1 * MB, 256 * MB, '上传测速中'); + return runPhase(uploadOnce, 256 * MB, '上传测速中'); } -/* ================= 公网 IP 探测 ================= - * 服务器在 NAT 后面(公网访问经路由器转发,服务器只能看到内网 IP), - * 因此通过外部服务探测用户真实的公网出口 IP,用于展示与排行榜。 +/* ================= IP 展示 ================= + * 路由器已配置单向伪装,服务器直接看到真实公网 IP(X-Real-IP), + * 无需前端探测外部服务。内网 IP 仅出现在未配置公网直连的 NAT 场景。 */ -async function detectPublicIP() { - const sources = [ - { url: 'https://ipinfo.io/ip', type: 'text' }, - { url: 'https://api.ipify.org?format=json', type: 'json' }, - ]; - for (const s of sources) { - try { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 4000); - const resp = await fetch(s.url, { signal: ctrl.signal }); - clearTimeout(timer); - if (!resp.ok) continue; - let ip; - if (s.type === 'json') ip = (await resp.json()).ip; - else ip = (await resp.text()).trim(); - if (typeof ip === 'string' && ip && /^[\d.a-fA-F:]+$/.test(ip)) return ip; - } catch (e) { /* 尝试下一个源 */ } - } - return ''; -} - function isPrivateIP(ip) { if (!ip) return false; if (ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.')) return true; @@ -434,35 +425,30 @@ function isPrivateIP(ip) { return false; } -function showMyIP(publicIP, serverIP) { +function showMyIP(ip) { const el = document.getElementById('myIP'); - if (!publicIP && !serverIP) { el.hidden = true; return; } + if (!ip) { el.hidden = true; return; } el.hidden = false; - 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); + if (isPrivateIP(ip)) { + el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)'; el.className = 'myip private'; + } else { + el.textContent = '你的公网 IP: ' + ip; + el.className = 'myip'; } } /* ================= 结果提交 ================= */ -async function submitResult(r, clientIP, serverIP) { - const payload = { - 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 (clientIP) payload.client_ip = clientIP; - if (serverIP) payload.server_ip = serverIP; +async function submitResult(r) { const resp = await fetch('/api/result', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + 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(); @@ -492,15 +478,16 @@ function renderRanking(rows) { const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : ''; let ipHtml; if (r.is_private_ip) { - // 公网探测失败,只有服务器识别到的内网 IP(完整显示,便于调试) + // 纯内网记录(无公网直连场景):显示内网 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) { + // 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备); + // 公网直连时 client_ip 与 server_ip 一致,无需重复展示 + if (r.server_ip && isPrivateIP(r.server_ip)) { ipHtml += '← ' + r.server_ip + ''; } } @@ -544,11 +531,6 @@ async function runTest() { ['statLatency', 'statJitter', 'statDownload', 'statUpload'].forEach(id => document.getElementById(id).textContent = '--'); drawGauge(0); - // 并行探测公网出口 IP(不阻塞测速流程) - let publicIP = ''; - let serverIP = ''; - detectPublicIP().then(ip => { if (ip && !isPrivateIP(ip)) publicIP = ip; }); - try { // 1. 延迟 setPhase('延迟测试中(10 次 ping)…'); @@ -558,30 +540,29 @@ async function runTest() { markDone(document.getElementById('cardLatency')); markDone(document.getElementById('cardJitter')); - // 2. 下载(自适应时长,阶段最短 5 秒) + // 2. 下载(固定 10 秒) setPhase('下载测速准备中…'); const download = await downloadTest(); statDownload.textContent = download.toFixed(2); updateGaugeSpeed(download); markDone(document.getElementById('cardDownload')); - // 3. 上传(自适应时长,阶段最短 5 秒) + // 3. 上传(固定 10 秒) setPhase('上传测速准备中…'); const upload = await uploadTest(); statUpload.textContent = upload.toFixed(2); updateGaugeSpeed(upload); markDone(document.getElementById('cardUpload')); - // 4. 展示 IP:优先公网探测结果,失败则用服务器识别结果(可能为内网) + // 4. 展示 IP(服务器识别,单向伪装后为真实公网 IP) try { const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' }); - serverIP = (await pr.json()).client_ip || ''; + showMyIP((await pr.json()).client_ip || ''); } catch (e) { /* ignore */ } - showMyIP(publicIP, serverIP); // 5. 提交 setPhase('正在提交结果…'); - await submitResult({ latency, jitter, download, upload }, publicIP, serverIP); + await submitResult({ latency, jitter, download, upload }); await loadRankings(); setPhase('✅ 测试完成,结果已记录'); } catch (e) {