feat: 排行榜标记 IP 来源(公网 IP + 内网来源 server_ip 双维度,便于调试区分设备)
This commit is contained in:
@@ -13,10 +13,11 @@
|
|||||||
- ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍
|
- ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍
|
||||||
(上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载
|
(上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载
|
||||||
- ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值
|
- ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值
|
||||||
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,IP 自动打码(保留前 3 段)
|
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段);
|
||||||
|
同时保存并展示**内网来源 IP**(服务器识别,NAT 环境下用于区分同一公网出口下的不同设备,方便站长调试)
|
||||||
- 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP),
|
- 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP),
|
||||||
前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注
|
前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注
|
||||||
"内网 / NAT"
|
"内网"
|
||||||
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
|
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -109,9 +110,10 @@ speedtest.lmve.net {
|
|||||||
| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...}`) |
|
| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...}`) |
|
||||||
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB) |
|
| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB) |
|
||||||
| POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` |
|
| POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` |
|
||||||
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps, client_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` 标注) |
|
| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 与来源 `server_ip`) |
|
||||||
|
|
||||||
客户端真实 IP 优先取前端探测的公网出口 IP(`client_ip` 字段),
|
客户端展示 IP 优先取前端探测的公网出口 IP(`client_ip` 字段),
|
||||||
否则回退到 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For`;
|
否则回退到 Caddy 注入的 `X-Real-IP`;`server_ip` 记录服务器看到的来源 IP
|
||||||
排行榜展示时对 IP 打码保护隐私,NAT/内网 IP 标注"内网 / NAT"。
|
(NAT 环境下为内网地址,排行榜中作为"来源"标注,供站长调试区分设备)。
|
||||||
|
公网 IP 打码保护隐私,纯内网记录标注"内网"。
|
||||||
@@ -5,7 +5,8 @@ import "time"
|
|||||||
// SpeedTestResult 一次完整的测速结果记录
|
// SpeedTestResult 一次完整的测速结果记录
|
||||||
type SpeedTestResult struct {
|
type SpeedTestResult struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
ClientIP string `gorm:"size:64;index" json:"client_ip"`
|
ClientIP string `gorm:"size:64;index" json:"client_ip"` // 展示 IP(优先前端探测的公网出口 IP)
|
||||||
|
ServerIP string `gorm:"size:64" json:"server_ip"` // 服务器看到的来源 IP(NAT 环境下为内网 IP,用于区分设备)
|
||||||
LatencyMs float64 `json:"latency_ms"` // 延迟(毫秒,取中位数)
|
LatencyMs float64 `json:"latency_ms"` // 延迟(毫秒,取中位数)
|
||||||
JitterMs float64 `json:"jitter_ms"` // 抖动(毫秒,平均绝对偏差)
|
JitterMs float64 `json:"jitter_ms"` // 抖动(毫秒,平均绝对偏差)
|
||||||
DownloadMbps float64 `json:"download_mbps"` // 下载速度(Mbps)
|
DownloadMbps float64 `json:"download_mbps"` // 下载速度(Mbps)
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ type resultReq struct {
|
|||||||
DownloadMbps float64 `json:"download_mbps"`
|
DownloadMbps float64 `json:"download_mbps"`
|
||||||
UploadMbps float64 `json:"upload_mbps"`
|
UploadMbps float64 `json:"upload_mbps"`
|
||||||
ClientIP string `json:"client_ip"` // 可选:前端探测到的公网出口 IP
|
ClientIP string `json:"client_ip"` // 可选:前端探测到的公网出口 IP
|
||||||
|
ServerIP string `json:"server_ip"` // 可选:服务器看到的来源 IP(NAT 下为内网 IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result 保存一次测速结果
|
// Result 保存一次测速结果
|
||||||
@@ -199,15 +200,23 @@ func (h *Handler) Result(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端 IP:优先采用前端探测的公网出口 IP(NAT 环境下服务器只能看到内网 IP);
|
// 客户端展示 IP:优先采用前端探测的公网出口 IP(NAT 环境下服务器只能看到内网 IP);
|
||||||
// 未提供或格式非法(含回环地址伪造)时回退到 Caddy 传递的 X-Real-IP
|
// 未提供或格式非法(含回环地址伪造)时回退到 Caddy 传递的 X-Real-IP
|
||||||
ip := strings.TrimSpace(req.ClientIP)
|
ip := strings.TrimSpace(req.ClientIP)
|
||||||
if parsed := net.ParseIP(ip); parsed == nil || parsed.IsLoopback() {
|
if parsed := net.ParseIP(ip); parsed == nil || parsed.IsLoopback() {
|
||||||
ip = clientIP(c)
|
ip = clientIP(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务器看到的来源 IP(内网 IP,用于调试区分设备):
|
||||||
|
// 未提供时回退到 Caddy 传递的 X-Real-IP
|
||||||
|
serverIP := strings.TrimSpace(req.ServerIP)
|
||||||
|
if net.ParseIP(serverIP) == nil {
|
||||||
|
serverIP = clientIP(c)
|
||||||
|
}
|
||||||
|
|
||||||
record := &db.SpeedTestResult{
|
record := &db.SpeedTestResult{
|
||||||
ClientIP: ip,
|
ClientIP: ip,
|
||||||
|
ServerIP: serverIP,
|
||||||
LatencyMs: req.LatencyMs,
|
LatencyMs: req.LatencyMs,
|
||||||
JitterMs: req.JitterMs,
|
JitterMs: req.JitterMs,
|
||||||
DownloadMbps: req.DownloadMbps,
|
DownloadMbps: req.DownloadMbps,
|
||||||
@@ -221,11 +230,13 @@ func (h *Handler) Result(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": record.ID})
|
c.JSON(http.StatusOK, gin.H{"ok": true, "id": record.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RankItem 排行榜展示条目(IP 打码)
|
// RankItem 排行榜展示条目
|
||||||
|
// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备)
|
||||||
type RankItem struct {
|
type RankItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
ClientIP string `json:"client_ip"`
|
ClientIP string `json:"client_ip"`
|
||||||
IsPrivateIP bool `json:"is_private_ip"` // NAT/内网环境(如路由器网关),前端标注展示
|
ServerIP string `json:"server_ip"`
|
||||||
|
IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景)
|
||||||
LatencyMs float64 `json:"latency_ms"`
|
LatencyMs float64 `json:"latency_ms"`
|
||||||
JitterMs float64 `json:"jitter_ms"`
|
JitterMs float64 `json:"jitter_ms"`
|
||||||
DownloadMbps float64 `json:"download_mbps"`
|
DownloadMbps float64 `json:"download_mbps"`
|
||||||
@@ -255,6 +266,7 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem {
|
|||||||
items = append(items, RankItem{
|
items = append(items, RankItem{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
ClientIP: maskIP(r.ClientIP),
|
ClientIP: maskIP(r.ClientIP),
|
||||||
|
ServerIP: r.ServerIP,
|
||||||
IsPrivateIP: isPrivateIP(r.ClientIP),
|
IsPrivateIP: isPrivateIP(r.ClientIP),
|
||||||
LatencyMs: r.LatencyMs,
|
LatencyMs: r.LatencyMs,
|
||||||
JitterMs: r.JitterMs,
|
JitterMs: r.JitterMs,
|
||||||
|
|||||||
@@ -106,6 +106,11 @@
|
|||||||
.rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; }
|
.rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; }
|
||||||
.speed-val { font-weight: 700; }
|
.speed-val { font-weight: 700; }
|
||||||
.ip { color: var(--muted); font-size: 13px; }
|
.ip { color: var(--muted); font-size: 13px; }
|
||||||
|
.ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; }
|
||||||
|
.ip-tag {
|
||||||
|
display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 11px;
|
||||||
|
color: #ffb45e; border: 1px solid rgba(255, 180, 94, 0.4); border-radius: 999px; vertical-align: 1px;
|
||||||
|
}
|
||||||
.time { color: var(--muted); font-size: 12px; }
|
.time { color: var(--muted); font-size: 12px; }
|
||||||
.empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
|
.empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
|
||||||
|
|
||||||
@@ -170,7 +175,7 @@
|
|||||||
<button class="tab" data-type="latency">延迟排行</button>
|
<button class="tab" data-type="latency">延迟排行</button>
|
||||||
</div>
|
</div>
|
||||||
<table id="rankTable">
|
<table id="rankTable">
|
||||||
<thead><tr><th style="width:64px">#</th><th>IP</th><th id="thSpeed">下载速度</th><th style="width:130px">时间</th></tr></thead>
|
<thead><tr><th style="width:64px">#</th><th>IP / 来源</th><th id="thSpeed">下载速度</th><th style="width:130px">时间</th></tr></thead>
|
||||||
<tbody id="rankBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody>
|
<tbody id="rankBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -429,22 +434,23 @@ function isPrivateIP(ip) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMyIP(ip, fromServer) {
|
function showMyIP(publicIP, serverIP) {
|
||||||
const el = document.getElementById('myIP');
|
const el = document.getElementById('myIP');
|
||||||
if (!ip) { el.hidden = true; return; }
|
if (!publicIP && !serverIP) { el.hidden = true; return; }
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
if (isPrivateIP(ip)) {
|
const pub = publicIP && !isPrivateIP(publicIP) ? publicIP : '';
|
||||||
el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)';
|
const srv = serverIP && serverIP !== publicIP ? serverIP : '';
|
||||||
el.className = 'myip private';
|
if (pub) {
|
||||||
} else {
|
el.textContent = srv ? '你的公网 IP: ' + pub + ' · 内网来源: ' + srv : '你的公网 IP: ' + pub;
|
||||||
el.textContent = '你的公网 IP: ' + ip;
|
|
||||||
el.className = 'myip';
|
el.className = 'myip';
|
||||||
|
} else {
|
||||||
|
el.textContent = srv ? '你的 IP: ' + srv + '(内网 / NAT 环境)' : '你的 IP: ' + (publicIP || serverIP);
|
||||||
|
el.className = 'myip private';
|
||||||
}
|
}
|
||||||
if (fromServer) el.title = '由服务器识别(经 NAT 转发)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 结果提交 ================= */
|
/* ================= 结果提交 ================= */
|
||||||
async function submitResult(r, clientIP) {
|
async function submitResult(r, clientIP, serverIP) {
|
||||||
const payload = {
|
const payload = {
|
||||||
latency_ms: +r.latency.toFixed(2),
|
latency_ms: +r.latency.toFixed(2),
|
||||||
jitter_ms: +r.jitter.toFixed(2),
|
jitter_ms: +r.jitter.toFixed(2),
|
||||||
@@ -452,6 +458,7 @@ async function submitResult(r, clientIP) {
|
|||||||
upload_mbps: +r.upload.toFixed(2),
|
upload_mbps: +r.upload.toFixed(2),
|
||||||
};
|
};
|
||||||
if (clientIP) payload.client_ip = clientIP;
|
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' },
|
||||||
@@ -485,9 +492,17 @@ 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) {
|
||||||
ipHtml = '<span class="ip" title="' + r.client_ip + '(经 NAT 转发,无法获取公网 IP)">内网 / NAT</span>';
|
// 公网探测失败,只有服务器识别到的内网 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 {
|
} else {
|
||||||
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
|
ipHtml = '<span class="ip">' + r.client_ip + '</span>';
|
||||||
|
// 公网 IP + 内网来源(NAT 环境下区分同一出口下的不同设备)
|
||||||
|
if (r.server_ip && r.server_ip !== r.client_ip) {
|
||||||
|
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let speed;
|
let speed;
|
||||||
if (rankType === 'latency') {
|
if (rankType === 'latency') {
|
||||||
@@ -562,11 +577,11 @@ async function runTest() {
|
|||||||
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 || '';
|
serverIP = (await pr.json()).client_ip || '';
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
showMyIP(publicIP || serverIP, !publicIP);
|
showMyIP(publicIP, serverIP);
|
||||||
|
|
||||||
// 5. 提交
|
// 5. 提交
|
||||||
setPhase('正在提交结果…');
|
setPhase('正在提交结果…');
|
||||||
await submitResult({ latency, jitter, download, upload }, publicIP);
|
await submitResult({ latency, jitter, download, upload }, publicIP, serverIP);
|
||||||
await loadRankings();
|
await loadRankings();
|
||||||
setPhase('✅ 测试完成,结果已记录');
|
setPhase('✅ 测试完成,结果已记录');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user