feat: 固定测速时长(下载/上传各10秒,负载按速率自适应填充)+ 移除前端 ipinfo/ipify 公网IP探测(服务器直识别)
This commit is contained in:
@@ -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 识别**: 服务器直接识别客户端真实 IP(Caddy `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 打码保护隐私,纯内网记录标注"内网"。
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user