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) {