feat: 排行榜标记 IP 来源(公网 IP + 内网来源 server_ip 双维度,便于调试区分设备)
This commit is contained in:
@@ -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 打码保护隐私,纯内网记录标注"内网"。
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user