feat: 排行榜不再展示任何IP,用户列显示地区(ip2region v4 离线库)+浏览器(UA解析);IP仅后台保存,页面上方显示IP+地区
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"speedtest/config"
|
||||
"speedtest/internal/db"
|
||||
"speedtest/internal/geo"
|
||||
"speedtest/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -28,13 +29,18 @@ const (
|
||||
type Handler struct {
|
||||
stores *store.Stores
|
||||
cfg config.SpeedtestConfig
|
||||
pool [][]byte // 预生成随机数据池(下载测速负载)
|
||||
geo *geo.Geo // IP 地区解析(离线)
|
||||
pool [][]byte // 预生成随机数据池(下载测速负载)
|
||||
poolNext atomic.Int64
|
||||
}
|
||||
|
||||
// NewHandler creates a new speedtest Handler.
|
||||
func NewHandler(stores *store.Stores, cfg config.SpeedtestConfig) (*Handler, error) {
|
||||
h := &Handler{stores: stores, cfg: cfg}
|
||||
h := &Handler{
|
||||
stores: stores,
|
||||
cfg: cfg,
|
||||
geo: geo.New("data/ip2region_v4.xdb", "/opt/speedtest/data/ip2region_v4.xdb"),
|
||||
}
|
||||
if err := h.buildPool(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -74,31 +80,16 @@ func clientIP(c *gin.Context) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// maskIP 排行榜展示时对 IP 打码(保留前 3 段)
|
||||
func maskIP(ip string) string {
|
||||
if strings.Contains(ip, ":") {
|
||||
// IPv6:保留前 3 段
|
||||
parts := strings.Split(ip, ":")
|
||||
if len(parts) > 3 {
|
||||
return strings.Join(parts[:3], ":") + ":****"
|
||||
}
|
||||
return ip
|
||||
}
|
||||
parts := strings.Split(ip, ".")
|
||||
if len(parts) == 4 {
|
||||
return strings.Join(parts[:3], ".") + ".*"
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// Ping 延迟探测:返回最小响应,供前端计算 RTT;同时回传服务器看到的客户端 IP
|
||||
// (NAT 环境下可能是内网 IP,前端会用公网探测结果替代)
|
||||
// Ping 延迟探测:返回最小响应,供前端计算 RTT;
|
||||
// 同时回传客户端 IP(页面上方展示用)与其地区
|
||||
func (h *Handler) Ping(c *gin.Context) {
|
||||
ip := clientIP(c)
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pong": true,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
"client_ip": clientIP(c),
|
||||
"client_ip": ip,
|
||||
"location": h.geo.Search(ip),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -217,6 +208,8 @@ func (h *Handler) Result(c *gin.Context) {
|
||||
record := &db.SpeedTestResult{
|
||||
ClientIP: ip,
|
||||
ServerIP: serverIP,
|
||||
Location: h.geo.Search(ip),
|
||||
UserAgent: truncate(c.Request.UserAgent(), 255),
|
||||
LatencyMs: req.LatencyMs,
|
||||
JitterMs: req.JitterMs,
|
||||
DownloadMbps: req.DownloadMbps,
|
||||
@@ -230,15 +223,62 @@ func (h *Handler) Result(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": record.ID})
|
||||
}
|
||||
|
||||
// RankItem 排行榜展示条目
|
||||
// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备)
|
||||
// truncate 截断字符串到 n 个字节
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// parseBrowser 从 User-Agent 解析简短的浏览器 + 系统信息,如 "Chrome · Windows"
|
||||
func parseBrowser(ua string) string {
|
||||
lower := strings.ToLower(ua)
|
||||
|
||||
browser := "其他"
|
||||
switch {
|
||||
case strings.Contains(lower, "edg/"):
|
||||
browser = "Edge"
|
||||
case strings.Contains(lower, "chrome"):
|
||||
browser = "Chrome"
|
||||
case strings.Contains(lower, "firefox"):
|
||||
browser = "Firefox"
|
||||
case strings.Contains(lower, "micromessenger"):
|
||||
browser = "微信"
|
||||
case strings.Contains(lower, "qqbrowser") || strings.Contains(lower, " qq/"):
|
||||
browser = "QQ浏览器"
|
||||
case strings.Contains(lower, "ucbrowser"):
|
||||
browser = "UC浏览器"
|
||||
case strings.Contains(lower, "opera") || strings.Contains(lower, "opr/"):
|
||||
browser = "Opera"
|
||||
case strings.Contains(lower, "safari"):
|
||||
browser = "Safari"
|
||||
}
|
||||
|
||||
osName := "其他系统"
|
||||
switch {
|
||||
case strings.Contains(lower, "windows"):
|
||||
osName = "Windows"
|
||||
case strings.Contains(lower, "android"):
|
||||
osName = "Android"
|
||||
case strings.Contains(lower, "iphone") || strings.Contains(lower, "ipad") || strings.Contains(lower, "ios"):
|
||||
osName = "iOS"
|
||||
case strings.Contains(lower, "mac os") || strings.Contains(lower, "macintosh"):
|
||||
osName = "macOS"
|
||||
case strings.Contains(lower, "linux"):
|
||||
osName = "Linux"
|
||||
}
|
||||
|
||||
return browser + " · " + osName
|
||||
}
|
||||
|
||||
// RankItem 排行榜展示条目(不含任何 IP 信息——IP 仅后台保存,不对外展示)
|
||||
type RankItem struct {
|
||||
ID uint `json:"id"`
|
||||
Rank int `json:"rank"` // 名次(含附加记录的真实名次)
|
||||
IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩")
|
||||
ClientIP string `json:"client_ip"`
|
||||
ServerIP string `json:"server_ip"`
|
||||
IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景)
|
||||
Rank int `json:"rank"` // 名次(含附加记录的真实名次)
|
||||
IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩")
|
||||
Location string `json:"location"` // 用户地区(ip2region)
|
||||
Browser string `json:"browser"` // 浏览器 + 系统(由 UA 解析)
|
||||
LatencyMs float64 `json:"latency_ms"`
|
||||
JitterMs float64 `json:"jitter_ms"`
|
||||
DownloadMbps float64 `json:"download_mbps"`
|
||||
@@ -246,30 +286,13 @@ 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),
|
||||
ServerIP: r.ServerIP,
|
||||
IsPrivateIP: isPrivateIP(r.ClientIP),
|
||||
Location: r.Location,
|
||||
Browser: parseBrowser(r.UserAgent),
|
||||
LatencyMs: r.LatencyMs,
|
||||
JitterMs: r.JitterMs,
|
||||
DownloadMbps: r.DownloadMbps,
|
||||
|
||||
@@ -109,12 +109,8 @@
|
||||
.val-up { font-weight: 700; color: var(--accent2); }
|
||||
.val-la { font-weight: 700; color: var(--green); }
|
||||
.unit-sm { color: var(--muted); font-size: 11px; font-weight: 400; }
|
||||
.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;
|
||||
}
|
||||
.loc { font-size: 13px; }
|
||||
.ua { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.time { color: var(--muted); font-size: 12px; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; }
|
||||
.rank-hint { text-align: center; color: var(--muted); font-size: 12px; padding: 12px 0 8px; }
|
||||
@@ -178,7 +174,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:56px">#</th>
|
||||
<th>IP</th>
|
||||
<th>用户</th>
|
||||
<th class="sortable" data-field="latency">延迟 <span class="arrow"></span></th>
|
||||
<th class="sortable active" data-field="download">下载 <span class="arrow">↓</span></th>
|
||||
<th class="sortable" data-field="upload">上传 <span class="arrow"></span></th>
|
||||
@@ -430,7 +426,7 @@ function isPrivateIP(ip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function showMyIP(ip) {
|
||||
function showMyIP(ip, location) {
|
||||
const el = document.getElementById('myIP');
|
||||
if (!ip) { el.hidden = true; return; }
|
||||
el.hidden = false;
|
||||
@@ -438,7 +434,7 @@ function showMyIP(ip) {
|
||||
el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)';
|
||||
el.className = 'myip private';
|
||||
} else {
|
||||
el.textContent = '你的公网 IP: ' + ip;
|
||||
el.textContent = '你的公网 IP: ' + ip + (location ? ' · ' + location : '');
|
||||
el.className = 'myip';
|
||||
}
|
||||
}
|
||||
@@ -488,23 +484,12 @@ function renderRanking(rows) {
|
||||
}
|
||||
rankBody.innerHTML = rows.map(r => {
|
||||
const cls = r.rank === 1 ? 'top1' : r.rank === 2 ? 'top2' : r.rank === 3 ? 'top3' : '';
|
||||
let ipHtml;
|
||||
if (r.is_private_ip) {
|
||||
// 纯内网记录:显示内网 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>';
|
||||
// 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备)
|
||||
if (r.server_ip && isPrivateIP(r.server_ip)) {
|
||||
ipHtml += '<span class="ip-src" title="服务器识别到的来源 IP">← ' + r.server_ip + '</span>';
|
||||
}
|
||||
}
|
||||
// 用户信息:地区 + 浏览器(不展示任何 IP,IP 仅后台保存)
|
||||
const user = '<div class="loc">' + (r.location || '未知地区') + '</div>' +
|
||||
'<div class="ua">' + (r.browser || '未知设备') + '</div>';
|
||||
return '<tr' + (r.is_mine ? ' class="mine"' : '') + '>' +
|
||||
'<td><span class="rank-no ' + cls + '">' + r.rank + '</span></td>' +
|
||||
'<td>' + ipHtml + (r.is_mine ? '<span class="mine-tag">我的成绩</span>' : '') + '</td>' +
|
||||
'<td>' + user + (r.is_mine ? '<span class="mine-tag">我的成绩</span>' : '') + '</td>' +
|
||||
'<td class="val-la">' + r.latency_ms.toFixed(1) + ' <span class="unit-sm">ms</span></td>' +
|
||||
'<td class="val-dl">' + r.download_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
|
||||
'<td class="val-up">' + r.upload_mbps.toFixed(2) + ' <span class="unit-sm">Mbps</span></td>' +
|
||||
@@ -572,10 +557,11 @@ async function runTest() {
|
||||
updateGaugeSpeed(upload);
|
||||
markDone(document.getElementById('cardUpload'));
|
||||
|
||||
// 4. 展示 IP(服务器识别,单向伪装后为真实公网 IP)
|
||||
// 4. 展示 IP 与地区(页面上方徽标;排行榜不展示 IP)
|
||||
try {
|
||||
const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' });
|
||||
showMyIP((await pr.json()).client_ip || '');
|
||||
const pi = await pr.json();
|
||||
showMyIP(pi.client_ip || '', pi.location || '');
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 5. 提交(记录我的成绩 ID,用于排行榜高亮)
|
||||
|
||||
Reference in New Issue
Block a user