feat: 排行榜标记 IP 来源(公网 IP + 内网来源 server_ip 双维度,便于调试区分设备)

This commit is contained in:
dsh
2026-08-17 09:57:36 -04:00
parent 7794743828
commit 42dd6813af
4 changed files with 58 additions and 28 deletions
+9 -7
View File
@@ -13,10 +13,11 @@
- ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍
(上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载
- ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,IP 自动打码(保留前 3 段)
- 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,公网 IP 打码(保留前 3 段)
同时保存并展示**内网来源 IP**(服务器识别,NAT 环境下用于区分同一公网出口下的不同设备,方便站长调试)
- 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP),
前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注
"内网 / NAT"
"内网"
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
## 项目结构
@@ -109,9 +110,10 @@ speedtest.lmve.net {
| 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, client_ip?}` |
| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_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` |
客户端真实 IP 优先取前端探测的公网出口 IP(`client_ip` 字段),
否则回退到 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For`
排行榜展示时对 IP 打码保护隐私,NAT/内网 IP 标注"内网 / NAT"
客户端展示 IP 优先取前端探测的公网出口 IP(`client_ip` 字段),
否则回退到 Caddy 注入的 `X-Real-IP``server_ip` 记录服务器看到的来源 IP
(NAT 环境下为内网地址,排行榜中作为"来源"标注,供站长调试区分设备)
公网 IP 打码保护隐私,纯内网记录标注"内网"。
+2 -1
View File
@@ -5,7 +5,8 @@ import "time"
// SpeedTestResult 一次完整的测速结果记录
type SpeedTestResult struct {
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"` // 延迟(毫秒,取中位数)
JitterMs float64 `json:"jitter_ms"` // 抖动(毫秒,平均绝对偏差)
DownloadMbps float64 `json:"download_mbps"` // 下载速度(Mbps
+15 -3
View File
@@ -180,6 +180,7 @@ type resultReq struct {
DownloadMbps float64 `json:"download_mbps"`
UploadMbps float64 `json:"upload_mbps"`
ClientIP string `json:"client_ip"` // 可选:前端探测到的公网出口 IP
ServerIP string `json:"server_ip"` // 可选:服务器看到的来源 IP(NAT 下为内网 IP)
}
// Result 保存一次测速结果
@@ -199,15 +200,23 @@ func (h *Handler) Result(c *gin.Context) {
return
}
// 客户端 IP:优先采用前端探测的公网出口 IP(NAT 环境下服务器只能看到内网 IP);
// 客户端展示 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)
}
// 服务器看到的来源 IP(内网 IP,用于调试区分设备):
// 未提供时回退到 Caddy 传递的 X-Real-IP
serverIP := strings.TrimSpace(req.ServerIP)
if net.ParseIP(serverIP) == nil {
serverIP = clientIP(c)
}
record := &db.SpeedTestResult{
ClientIP: ip,
ServerIP: serverIP,
LatencyMs: req.LatencyMs,
JitterMs: req.JitterMs,
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})
}
// RankItem 排行榜展示条目IP 打码)
// RankItem 排行榜展示条目
// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备)
type RankItem struct {
ID uint `json:"id"`
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"`
JitterMs float64 `json:"jitter_ms"`
DownloadMbps float64 `json:"download_mbps"`
@@ -255,6 +266,7 @@ func toRankItems(rows []db.SpeedTestResult) []RankItem {
items = append(items, RankItem{
ID: r.ID,
ClientIP: maskIP(r.ClientIP),
ServerIP: r.ServerIP,
IsPrivateIP: isPrivateIP(r.ClientIP),
LatencyMs: r.LatencyMs,
JitterMs: r.JitterMs,
+28 -13
View File
@@ -106,6 +106,11 @@
.rank-no.top3 { background: linear-gradient(135deg, #f0b08c, #c87a4a); color: #3d1d08; }
.speed-val { font-weight: 700; }
.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; }
.empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
@@ -170,7 +175,7 @@
<button class="tab" data-type="latency">延迟排行</button>
</div>
<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>
</table>
</section>
@@ -429,22 +434,23 @@ function isPrivateIP(ip) {
return false;
}
function showMyIP(ip, fromServer) {
function showMyIP(publicIP, serverIP) {
const el = document.getElementById('myIP');
if (!ip) { el.hidden = true; return; }
if (!publicIP && !serverIP) { el.hidden = true; return; }
el.hidden = false;
if (isPrivateIP(ip)) {
el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)';
el.className = 'myip private';
} else {
el.textContent = '你的公网 IP: ' + ip;
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);
el.className = 'myip private';
}
if (fromServer) el.title = '由服务器识别(经 NAT 转发)';
}
/* ================= 结果提交 ================= */
async function submitResult(r, clientIP) {
async function submitResult(r, clientIP, serverIP) {
const payload = {
latency_ms: +r.latency.toFixed(2),
jitter_ms: +r.jitter.toFixed(2),
@@ -452,6 +458,7 @@ async function submitResult(r, clientIP) {
upload_mbps: +r.upload.toFixed(2),
};
if (clientIP) payload.client_ip = clientIP;
if (serverIP) payload.server_ip = serverIP;
const resp = await fetch('/api/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -485,9 +492,17 @@ function renderRanking(rows) {
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>';
// 公网探测失败,只有服务器识别到的内网 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) {
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
}
}
let speed;
if (rankType === 'latency') {
@@ -562,11 +577,11 @@ async function runTest() {
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);
showMyIP(publicIP, serverIP);
// 5. 提交
setPhase('正在提交结果…');
await submitResult({ latency, jitter, download, upload }, publicIP);
await submitResult({ latency, jitter, download, upload }, publicIP, serverIP);
await loadRankings();
setPhase('✅ 测试完成,结果已记录');
} catch (e) {