feat: 固定测速时长(下载/上传各10秒,负载按速率自适应填充)+ 移除前端 ipinfo/ipify 公网IP探测(服务器直识别)

This commit is contained in:
dsh
2026-08-17 10:20:11 -04:00
parent 42dd6813af
commit 3c691c6a84
2 changed files with 65 additions and 86 deletions
+9 -11
View File
@@ -10,14 +10,13 @@
## 功能特性 ## 功能特性
-**延迟测试**: 前端连续 10 次 ping,取中位数作为延迟、平均绝对偏差作为抖动 -**延迟测试**: 前端连续 10 次 ping,取中位数作为延迟、平均绝对偏差作为抖动
- ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍 - ⬇️ **下载测试**: 固定测速 10 秒——负载根据实测速率自适应(每轮约 2 秒),
(上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载 快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成
- ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值 - ⬆️ **上传测试**: 同样的固定 10 秒自适应策略
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段); - 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段);
同时保存并展示**内网来源 IP**(服务器识别,NAT 环境下用于区分同一公网出口下的不同设备,方便站长调试) 内网来源 IP(NAT 场景)完整标注,方便站长调试区分同一出口下的不同设备
- 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP), - 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IPCaddy `X-Real-IP`),
前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注 不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP
"内网"
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口 - 🔒 **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?}` | | 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` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 与来源 `server_ip` |
客户端展示 IP 优先取前端探测的公网出口 IP`client_ip` 字段), 客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务;
否则回退到 Caddy 注入的 `X-Real-IP``server_ip` 记录服务器看到的来源 IP `client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私;
NAT 环境下为内网地址,排行榜中作为"来源"标注,供站长调试区分设备 NAT 场景下的内网来源 IP 在排行榜中完整标注,供站长调试区分设备。
公网 IP 打码保护隐私,纯内网记录标注"内网"。
+56 -75
View File
@@ -143,7 +143,7 @@
<div id="gaugeUnit">Mbps</div> <div id="gaugeUnit">Mbps</div>
</div> </div>
</div> </div>
<div id="phaseLabel">点击下方按钮开始测速(约需 15~30 秒)</div> <div id="phaseLabel">点击下方按钮开始测速(下载/上传各约 10 秒)</div>
<button id="startBtn">开始测速</button> <button id="startBtn">开始测速</button>
</section> </section>
@@ -183,7 +183,7 @@
<footer> <footer>
SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS<br> SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS<br>
每次测速结果自动入库参与排行 · 公网 IP 经外部服务探测(服务器位于 NAT 后) 每次测速结果自动入库参与排行 · 下载/上传各固定测速 10 秒
</footer> </footer>
</div> </div>
@@ -365,65 +365,56 @@ function uploadOnce(sizeBytes) {
} }
/* ================= 自适应测速阶段 ================= /* ================= 自适应测速阶段 =================
* 目标:每轮负载运行 ~2 秒(保证测量准确),负载逐轮翻倍; * 每个阶段(下载/上传)固定测 10 秒:负载根据上一轮实测速率自适应,
* 阶段最短 5 秒,最多 6 轮。慢速连接 1-2 轮即达到单轮 2 秒, * 让每轮约 2 秒。快速连接自动加大负载填满 10 秒;慢速连接用小负载
* 快速连接自动加大负载拉长测试时间 * 也能在 10 秒内完成,不会因为网速慢而拖长测试。
*/ */
const MB = 1048576; const MB = 1048576;
const PHASE_MIN_MS = 5000; const PHASE_DURATION_MS = 10000;
const ROUND_TARGET_MS = 2000; 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 results = [];
const phaseStart = performance.now(); const phaseStart = performance.now();
let size = minBytes; let size = MIN_BYTES;
for (let rounds = 0; rounds < MAX_ROUNDS; rounds++) { while (performance.now() - phaseStart < PHASE_DURATION_MS) {
setPhase(label + ' ' + (size / MB).toFixed(0) + ' MB …'); setPhase(label + ' ' + (size / MB).toFixed(size < 10 * MB ? 1 : 0) + ' MB …');
const r = await runOnce(size); const r = await runOnce(size);
results.push(r.mbps); results.push(r.mbps);
const elapsed = performance.now() - phaseStart; const elapsed = performance.now() - phaseStart;
const dataEnough = r.ms >= ROUND_TARGET_MS || size >= maxBytes; if (elapsed >= PHASE_DURATION_MS) break;
const timeEnough = elapsed >= PHASE_MIN_MS && rounds >= 1; const remaining = PHASE_DURATION_MS - elapsed;
if (dataEnough || timeEnough) break;
size = Math.min(size * 2, maxBytes); // 下一轮负载:按实测速率取约 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() { async function downloadTest() {
return runPhase(downloadOnce, 1 * MB, 400 * MB, '下载测速中'); return runPhase(downloadOnce, 400 * MB, '下载测速中');
} }
async function uploadTest() { async function uploadTest() {
return runPhase(uploadOnce, 1 * MB, 256 * MB, '上传测速中'); return runPhase(uploadOnce, 256 * MB, '上传测速中');
} }
/* ================= 公网 IP 探测 ================= /* ================= IP 展示 =================
* 服务器在 NAT 后面(公网访问经路由器转发,服务器只能看到内网 IP), * 路由器已配置单向伪装,服务器直接看到真实公网 IP(X-Real-IP),
* 因此通过外部服务探测用户真实的公网出口 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) { function isPrivateIP(ip) {
if (!ip) return false; if (!ip) return false;
if (ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.')) return true; if (ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.')) return true;
@@ -434,35 +425,30 @@ function isPrivateIP(ip) {
return false; return false;
} }
function showMyIP(publicIP, serverIP) { function showMyIP(ip) {
const el = document.getElementById('myIP'); const el = document.getElementById('myIP');
if (!publicIP && !serverIP) { el.hidden = true; return; } if (!ip) { el.hidden = true; return; }
el.hidden = false; el.hidden = false;
const pub = publicIP && !isPrivateIP(publicIP) ? publicIP : ''; if (isPrivateIP(ip)) {
const srv = serverIP && serverIP !== publicIP ? serverIP : ''; el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)';
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'; el.className = 'myip private';
} else {
el.textContent = '你的公网 IP: ' + ip;
el.className = 'myip';
} }
} }
/* ================= 结果提交 ================= */ /* ================= 结果提交 ================= */
async function submitResult(r, clientIP, serverIP) { async function submitResult(r) {
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;
const resp = await fetch('/api/result', { const resp = await fetch('/api/result', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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('结果提交失败'); if (!resp.ok) throw new Error('结果提交失败');
return resp.json(); return resp.json();
@@ -492,15 +478,16 @@ function renderRanking(rows) {
const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : ''; const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : '';
let ipHtml; let ipHtml;
if (r.is_private_ip) { if (r.is_private_ip) {
// 公网探测失败,只有服务器识别到的内网 IP完整显示,便于调试 // 纯内网记录(无公网直连场景):显示内网 IP 完整,便于调试
ipHtml = '<span class="ip">' + r.client_ip + '</span><span class="ip-tag">内网</span>'; ipHtml = '<span class="ip">' + r.client_ip + '</span><span class="ip-tag">内网</span>';
if (r.server_ip && r.server_ip !== r.client_ip) { if (r.server_ip && r.server_ip !== r.client_ip) {
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>'; ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
} }
} else { } else {
ipHtml = '<span class="ip">' + r.client_ip + '</span>'; ipHtml = '<span class="ip">' + r.client_ip + '</span>';
// 公网 IP + 内网来源NAT 环境下区分同一出口下的不同设备) // 仅在来源为内网时标注NAT 场景区分同一出口下的不同设备)
if (r.server_ip && r.server_ip !== r.client_ip) { // 公网直连时 client_ip 与 server_ip 一致,无需重复展示
if (r.server_ip && isPrivateIP(r.server_ip)) {
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>'; ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
} }
} }
@@ -544,11 +531,6 @@ async function runTest() {
['statLatency', 'statJitter', 'statDownload', 'statUpload'].forEach(id => document.getElementById(id).textContent = '--'); ['statLatency', 'statJitter', 'statDownload', 'statUpload'].forEach(id => document.getElementById(id).textContent = '--');
drawGauge(0); drawGauge(0);
// 并行探测公网出口 IP(不阻塞测速流程)
let publicIP = '';
let serverIP = '';
detectPublicIP().then(ip => { if (ip && !isPrivateIP(ip)) publicIP = ip; });
try { try {
// 1. 延迟 // 1. 延迟
setPhase('延迟测试中(10 次 ping)…'); setPhase('延迟测试中(10 次 ping)…');
@@ -558,30 +540,29 @@ async function runTest() {
markDone(document.getElementById('cardLatency')); markDone(document.getElementById('cardLatency'));
markDone(document.getElementById('cardJitter')); markDone(document.getElementById('cardJitter'));
// 2. 下载(自适应时长,阶段最短 5 秒) // 2. 下载(固定 10 秒)
setPhase('下载测速准备中…'); setPhase('下载测速准备中…');
const download = await downloadTest(); const download = await downloadTest();
statDownload.textContent = download.toFixed(2); statDownload.textContent = download.toFixed(2);
updateGaugeSpeed(download); updateGaugeSpeed(download);
markDone(document.getElementById('cardDownload')); markDone(document.getElementById('cardDownload'));
// 3. 上传(自适应时长,阶段最短 5 秒) // 3. 上传(固定 10 秒)
setPhase('上传测速准备中…'); setPhase('上传测速准备中…');
const upload = await uploadTest(); const upload = await uploadTest();
statUpload.textContent = upload.toFixed(2); statUpload.textContent = upload.toFixed(2);
updateGaugeSpeed(upload); updateGaugeSpeed(upload);
markDone(document.getElementById('cardUpload')); markDone(document.getElementById('cardUpload'));
// 4. 展示 IP:优先公网探测结果,失败则用服务器识别结果(可能为内网 // 4. 展示 IP(服务器识别,单向伪装后为真实公网 IP
try { try {
const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' }); 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 */ } } catch (e) { /* ignore */ }
showMyIP(publicIP, serverIP);
// 5. 提交 // 5. 提交
setPhase('正在提交结果…'); setPhase('正在提交结果…');
await submitResult({ latency, jitter, download, upload }, publicIP, serverIP); await submitResult({ latency, jitter, download, upload });
await loadRankings(); await loadRankings();
setPhase('✅ 测试完成,结果已记录'); setPhase('✅ 测试完成,结果已记录');
} catch (e) { } catch (e) {