feat: 排行榜标记 IP 来源(公网 IP + 内网来源 server_ip 双维度,便于调试区分设备)
This commit is contained in:
4 files changed
+58
-28
No files matched your search
@@ -5,10 +5,11 @@ import "time"
|
||||
// SpeedTestResult 一次完整的测速结果记录
|
||||
type SpeedTestResult struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClientIP string `gorm:"size:64;index" json:"client_ip"`
|
||||
LatencyMs float64 `json:"latency_ms"` // 延迟(毫秒,取中位数)
|
||||
JitterMs float64 `json:"jitter_ms"` // 抖动(毫秒,平均绝对偏差)
|
||||
DownloadMbps float64 `json:"download_mbps"` // 下载速度(Mbps)
|
||||
UploadMbps float64 `json:"upload_mbps"` // 上传速度(Mbps)
|
||||
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)
|
||||
UploadMbps float64 `json:"upload_mbps"` // 上传速度(Mbps)
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
@@ -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