feat: 自适应测速时长(阶段最短5秒,负载翻倍至512MB)+ 公网IP探测与内网标注
This commit is contained in:
@@ -10,9 +10,13 @@
|
||||
## 功能特性
|
||||
|
||||
- ⚡ **延迟测试**: 前端连续 10 次 ping,取中位数作为延迟、平均绝对偏差作为抖动
|
||||
- ⬇️ **下载测试**: 渐进式下载 1 / 2.5 / 5 / 10 / 25 MB 随机数据,实时仪表盘,取最优值
|
||||
- ⬆️ **上传测试**: 渐进式上传 1 / 2.5 / 5 / 10 MB 伪随机数据,实时仪表盘,取最优值
|
||||
- ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍
|
||||
(上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载
|
||||
- ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值
|
||||
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,IP 自动打码(保留前 3 段)
|
||||
- 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP),
|
||||
前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注
|
||||
"内网 / NAT"
|
||||
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
|
||||
|
||||
## 项目结构
|
||||
@@ -90,20 +94,24 @@ speedtest.lmve.net {
|
||||
addr = "/opt/speedtest/web.sock" # 以 / 开头为 unix socket,否则为 TCP 端口
|
||||
|
||||
[speedtest]
|
||||
max_download_bytes = 104857600 # 单次下载请求上限(默认 100 MB)
|
||||
max_upload_bytes = 104857600 # 单次上传请求上限(默认 100 MB)
|
||||
max_download_bytes = 536870912 # 单次下载请求上限(默认 512 MB)
|
||||
max_upload_bytes = 536870912 # 单次上传请求上限(默认 512 MB)
|
||||
```
|
||||
|
||||
> 注:默认上限 512MB 服务于前端自适应测速(快速连接负载会翻倍增长);
|
||||
> 旧版本配置中的 100MB 上限可手动调大,或删除配置行后重启让程序补全默认值。
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/` | 测速页面 |
|
||||
| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...}`) |
|
||||
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 100MB) |
|
||||
| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...}`) |
|
||||
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB) |
|
||||
| POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` |
|
||||
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps}` |
|
||||
| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`) |
|
||||
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps, client_ip?}` |
|
||||
| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 标注) |
|
||||
|
||||
客户端真实 IP 通过 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For` 头识别,
|
||||
排行榜展示时对 IP 打码保护隐私。
|
||||
客户端真实 IP 优先取前端探测的公网出口 IP(`client_ip` 字段),
|
||||
否则回退到 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For`;
|
||||
排行榜展示时对 IP 打码保护隐私,NAT/内网 IP 标注"内网 / NAT"。
|
||||
+4
-2
@@ -23,9 +23,11 @@ const (
|
||||
const DefaultWebAddr = "/opt/speedtest/web.sock"
|
||||
|
||||
// Default speedtest limits(字节)
|
||||
// 前端测速为自适应负载(单轮目标 ~2 秒、阶段最短 5 秒),
|
||||
// 高速连接下负载会翻倍增长,因此上限放宽到 512 MB
|
||||
const (
|
||||
DefaultMaxDownloadBytes = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultMaxUploadBytes = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultMaxDownloadBytes = 512 * 1024 * 1024 // 512 MB
|
||||
DefaultMaxUploadBytes = 512 * 1024 * 1024 // 512 MB
|
||||
)
|
||||
|
||||
// ConfigFileName is the name of the configuration file
|
||||
|
||||
@@ -91,12 +91,14 @@ func maskIP(ip string) string {
|
||||
return ip
|
||||
}
|
||||
|
||||
// Ping 延迟探测:返回最小响应,供前端计算 RTT
|
||||
// Ping 延迟探测:返回最小响应,供前端计算 RTT;同时回传服务器看到的客户端 IP
|
||||
// (NAT 环境下可能是内网 IP,前端会用公网探测结果替代)
|
||||
func (h *Handler) Ping(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pong": true,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
"pong": true,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
"client_ip": clientIP(c),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -177,6 +179,7 @@ type resultReq struct {
|
||||
JitterMs float64 `json:"jitter_ms"`
|
||||
DownloadMbps float64 `json:"download_mbps"`
|
||||
UploadMbps float64 `json:"upload_mbps"`
|
||||
ClientIP string `json:"client_ip"` // 可选:前端探测到的公网出口 IP
|
||||
}
|
||||
|
||||
// Result 保存一次测速结果
|
||||
@@ -196,8 +199,15 @@ func (h *Handler) Result(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 客户端 IP:优先采用前端探测的公网出口 IP(NAT 环境下服务器只能看到内网 IP);
|
||||
// 未提供或格式非法(含回环地址伪造)时回退到 Caddy 传递的 X-Real-IP
|
||||
ip := strings.TrimSpace(req.ClientIP)
|
||||
if parsed := net.ParseIP(ip); parsed == nil || parsed.IsLoopback() {
|
||||
ip = clientIP(c)
|
||||
}
|
||||
|
||||
record := &db.SpeedTestResult{
|
||||
ClientIP: clientIP(c),
|
||||
ClientIP: ip,
|
||||
LatencyMs: req.LatencyMs,
|
||||
JitterMs: req.JitterMs,
|
||||
DownloadMbps: req.DownloadMbps,
|
||||
@@ -215,6 +225,7 @@ func (h *Handler) Result(c *gin.Context) {
|
||||
type RankItem struct {
|
||||
ID uint `json:"id"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
IsPrivateIP bool `json:"is_private_ip"` // NAT/内网环境(如路由器网关),前端标注展示
|
||||
LatencyMs float64 `json:"latency_ms"`
|
||||
JitterMs float64 `json:"jitter_ms"`
|
||||
DownloadMbps float64 `json:"download_mbps"`
|
||||
@@ -222,12 +233,29 @@ type RankItem struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// isPrivateIP 判断 IP 是否为内网/保留地址(NAT 网关、局域网、回环等)
|
||||
func isPrivateIP(ip string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
return true
|
||||
}
|
||||
if parsed.IsLoopback() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
if parsed.IsPrivate() || parsed.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
// IPv4 兼容段(IsPrivate 已覆盖 10/8、172.16/12、192.168/16)
|
||||
return false
|
||||
}
|
||||
|
||||
func toRankItems(rows []db.SpeedTestResult) []RankItem {
|
||||
items := make([]RankItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, RankItem{
|
||||
ID: r.ID,
|
||||
ClientIP: maskIP(r.ClientIP),
|
||||
IsPrivateIP: isPrivateIP(r.ClientIP),
|
||||
LatencyMs: r.LatencyMs,
|
||||
JitterMs: r.JitterMs,
|
||||
DownloadMbps: r.DownloadMbps,
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.sub { color: var(--muted); font-size: 13px; margin-top: 6px; }
|
||||
.myip {
|
||||
display: inline-block; margin-top: 10px; padding: 5px 14px; font-size: 13px;
|
||||
color: var(--accent1); background: rgba(0, 212, 255, 0.08);
|
||||
border: 1px solid rgba(0, 212, 255, 0.25); border-radius: 999px;
|
||||
}
|
||||
.myip.private { color: #ffb45e; background: rgba(255, 180, 94, 0.08); border-color: rgba(255, 180, 94, 0.3); }
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
@@ -119,6 +125,7 @@
|
||||
<header>
|
||||
<div class="logo">⚡ SpeedTest</div>
|
||||
<div class="sub">speedtest.lmve.net · lmve.net 网速测试服务</div>
|
||||
<div id="myIP" class="myip" hidden></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
@@ -131,7 +138,7 @@
|
||||
<div id="gaugeUnit">Mbps</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="phaseLabel">点击下方按钮开始测速(约需 20~40 秒)</div>
|
||||
<div id="phaseLabel">点击下方按钮开始测速(约需 15~30 秒)</div>
|
||||
<button id="startBtn">开始测速</button>
|
||||
</section>
|
||||
|
||||
@@ -171,7 +178,7 @@
|
||||
|
||||
<footer>
|
||||
SpeedTest · Go + Gin + SQLite · 由 Caddy 反代提供 HTTPS<br>
|
||||
每次测速结果自动入库参与排行
|
||||
每次测速结果自动入库参与排行 · 公网 IP 经外部服务探测(服务器位于 NAT 后)
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +291,14 @@ async function pingTest(n) {
|
||||
/* ================= 下载测试 ================= */
|
||||
async function downloadOnce(sizeBytes) {
|
||||
const t0 = performance.now();
|
||||
const resp = await fetch('/api/download?size=' + sizeBytes + '&t=' + Date.now(), { cache: 'no-store' });
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 60000);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch('/api/download?size=' + sizeBytes + '&t=' + Date.now(), { cache: 'no-store', signal: ctrl.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!resp.ok || !resp.body) throw new Error('下载测试请求失败');
|
||||
const reader = resp.body.getReader();
|
||||
let received = 0, lastT = t0, lastBytes = 0;
|
||||
@@ -298,18 +312,8 @@ async function downloadOnce(sizeBytes) {
|
||||
lastT = now; lastBytes = received;
|
||||
}
|
||||
}
|
||||
const dt = (performance.now() - t0) / 1000;
|
||||
return (received * 8) / dt / 1e6; // Mbps
|
||||
}
|
||||
|
||||
async function downloadTest() {
|
||||
const sizes = [1048576, 2621440, 5242880, 10485760, 26214400]; // 1, 2.5, 5, 10, 25 MB
|
||||
const results = [];
|
||||
for (const size of sizes) {
|
||||
setPhase('下载测速中 ' + (size / 1048576).toFixed(size < 1048576 * 2 ? 0 : 1) + ' MB …');
|
||||
results.push(await downloadOnce(size));
|
||||
}
|
||||
return Math.max(...results);
|
||||
const ms = performance.now() - t0;
|
||||
return { mbps: (received * 8) / (ms / 1000) / 1e6, ms };
|
||||
}
|
||||
|
||||
/* ================= 上传测试 ================= */
|
||||
@@ -343,39 +347,115 @@ function uploadOnce(sizeBytes) {
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const dt = (performance.now() - t0) / 1000;
|
||||
resolve((sizeBytes * 8) / dt / 1e6);
|
||||
const ms = performance.now() - t0;
|
||||
resolve({ mbps: (sizeBytes * 8) / (ms / 1000) / 1e6, ms });
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('上传测试请求失败'));
|
||||
xhr.ontimeout = () => reject(new Error('上传测试超时'));
|
||||
xhr.timeout = 30000;
|
||||
xhr.timeout = 60000;
|
||||
xhr.open('POST', '/api/upload?t=' + Date.now());
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadTest() {
|
||||
const sizes = [1048576, 2621440, 5242880, 10485760]; // 1, 2.5, 5, 10 MB
|
||||
/* ================= 自适应测速阶段 =================
|
||||
* 目标:每轮负载运行 ~2 秒(保证测量准确),负载逐轮翻倍;
|
||||
* 阶段最短 5 秒,最多 6 轮。慢速连接 1-2 轮即达到单轮 2 秒,
|
||||
* 快速连接自动加大负载拉长测试时间。
|
||||
*/
|
||||
const MB = 1048576;
|
||||
const PHASE_MIN_MS = 5000;
|
||||
const ROUND_TARGET_MS = 2000;
|
||||
const MAX_ROUNDS = 6;
|
||||
|
||||
async function runPhase(runOnce, minBytes, maxBytes, label) {
|
||||
const results = [];
|
||||
for (const size of sizes) {
|
||||
setPhase('上传测速中 ' + (size / 1048576).toFixed(size < 1048576 * 2 ? 0 : 1) + ' MB …');
|
||||
results.push(await uploadOnce(size));
|
||||
const phaseStart = performance.now();
|
||||
let size = minBytes;
|
||||
for (let rounds = 0; rounds < MAX_ROUNDS; rounds++) {
|
||||
setPhase(label + ' ' + (size / MB).toFixed(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);
|
||||
}
|
||||
return Math.max(...results);
|
||||
}
|
||||
|
||||
async function downloadTest() {
|
||||
return runPhase(downloadOnce, 1 * MB, 400 * MB, '下载测速中');
|
||||
}
|
||||
|
||||
async function uploadTest() {
|
||||
return runPhase(uploadOnce, 1 * MB, 256 * MB, '上传测速中');
|
||||
}
|
||||
|
||||
/* ================= 公网 IP 探测 =================
|
||||
* 服务器在 NAT 后面(公网访问经路由器转发,服务器只能看到内网 IP),
|
||||
* 因此通过外部服务探测用户真实的公网出口 IP,用于展示与排行榜。
|
||||
*/
|
||||
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;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
||||
if (ip.startsWith('169.254.')) return true;
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === '::1' || lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function showMyIP(ip, fromServer) {
|
||||
const el = document.getElementById('myIP');
|
||||
if (!ip) { el.hidden = true; return; }
|
||||
el.hidden = false;
|
||||
if (isPrivateIP(ip)) {
|
||||
el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)';
|
||||
el.className = 'myip private';
|
||||
} else {
|
||||
el.textContent = '你的公网 IP: ' + ip;
|
||||
el.className = 'myip';
|
||||
}
|
||||
if (fromServer) el.title = '由服务器识别(经 NAT 转发)';
|
||||
}
|
||||
|
||||
/* ================= 结果提交 ================= */
|
||||
async function submitResult(r) {
|
||||
async function submitResult(r, clientIP) {
|
||||
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;
|
||||
const resp = await fetch('/api/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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),
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('结果提交失败');
|
||||
return resp.json();
|
||||
@@ -403,6 +483,12 @@ function renderRanking(rows) {
|
||||
}
|
||||
rankBody.innerHTML = rows.map((r, i) => {
|
||||
const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : '';
|
||||
let ipHtml;
|
||||
if (r.is_private_ip) {
|
||||
ipHtml = '<span class="ip" title="' + r.client_ip + '(经 NAT 转发,无法获取公网 IP)">内网 / NAT</span>';
|
||||
} else {
|
||||
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
|
||||
}
|
||||
let speed;
|
||||
if (rankType === 'latency') {
|
||||
speed = '<span class="speed-val" style="color:var(--green)">' + r.latency_ms.toFixed(1) + '</span> ms';
|
||||
@@ -413,7 +499,7 @@ function renderRanking(rows) {
|
||||
}
|
||||
return '<tr>' +
|
||||
'<td><span class="rank-no ' + cls + '">' + (i + 1) + '</span></td>' +
|
||||
'<td class="ip">' + r.client_ip + '</td>' +
|
||||
'<td>' + ipHtml + '</td>' +
|
||||
'<td>' + speed + '</td>' +
|
||||
'<td class="time">' + r.created_at + '</td>' +
|
||||
'</tr>';
|
||||
@@ -443,6 +529,11 @@ 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)…');
|
||||
@@ -452,23 +543,30 @@ async function runTest() {
|
||||
markDone(document.getElementById('cardLatency'));
|
||||
markDone(document.getElementById('cardJitter'));
|
||||
|
||||
// 2. 下载
|
||||
// 2. 下载(自适应时长,阶段最短 5 秒)
|
||||
setPhase('下载测速准备中…');
|
||||
const download = await downloadTest();
|
||||
statDownload.textContent = download.toFixed(2);
|
||||
updateGaugeSpeed(download);
|
||||
markDone(document.getElementById('cardDownload'));
|
||||
|
||||
// 3. 上传
|
||||
// 3. 上传(自适应时长,阶段最短 5 秒)
|
||||
setPhase('上传测速准备中…');
|
||||
const upload = await uploadTest();
|
||||
statUpload.textContent = upload.toFixed(2);
|
||||
updateGaugeSpeed(upload);
|
||||
markDone(document.getElementById('cardUpload'));
|
||||
|
||||
// 4. 提交
|
||||
// 4. 展示 IP:优先公网探测结果,失败则用服务器识别结果(可能为内网)
|
||||
try {
|
||||
const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' });
|
||||
serverIP = (await pr.json()).client_ip || '';
|
||||
} catch (e) { /* ignore */ }
|
||||
showMyIP(publicIP || serverIP, !publicIP);
|
||||
|
||||
// 5. 提交
|
||||
setPhase('正在提交结果…');
|
||||
await submitResult({ latency, jitter, download, upload });
|
||||
await submitResult({ latency, jitter, download, upload }, publicIP);
|
||||
await loadRankings();
|
||||
setPhase('✅ 测试完成,结果已记录');
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user