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
+56 -75
View File
@@ -143,7 +143,7 @@
<div id="gaugeUnit">Mbps</div>
</div>
</div>
<div id="phaseLabel">点击下方按钮开始测速(约需 15~30 秒)</div>
<div id="phaseLabel">点击下方按钮开始测速(下载/上传各约 10 秒)</div>
<button id="startBtn">开始测速</button>
</section>
@@ -183,7 +183,7 @@
<footer>
SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS<br>
每次测速结果自动入库参与排行 · 公网 IP 经外部服务探测(服务器位于 NAT 后)
每次测速结果自动入库参与排行 · 下载/上传各固定测速 10 秒
</footer>
</div>
@@ -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 = '<span class="ip">' + r.client_ip + '</span><span class="ip-tag">内网</span>';
if (r.server_ip && r.server_ip !== r.client_ip) {
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
}
} else {
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
// 公网 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 += '<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 = '--');
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) {