feat: 排行榜不再展示任何IP,用户列显示地区(ip2region v4 离线库)+浏览器(UA解析);IP仅后台保存,页面上方显示IP+地区

This commit is contained in:
dsh
2026-08-17 23:10:02 -04:00
parent 780e84e67e
commit d8917a666a
9 changed files with 229 additions and 83 deletions
+11 -7
View File
@@ -14,10 +14,14 @@
快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成
- ⬆️ **上传测试**: 同样的固定 10 秒自适应策略
- 🏆 **排行榜**: 单表同时展示延迟 / 下载 / 上传,**点击列头动态排序**(再点切换升降序,
延迟默认升序、速度默认降序);测速完成后**自动高亮"我的成绩"**(含真实名次,
localStorage 持久化,刷新页面仍高亮);公网 IP 打码,内网来源 IP 完整标注方便调试
- 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IP(Caddy `X-Real-IP`),
不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP
延迟默认升序、速度/时间默认降序);**用户列显示地区 + 浏览器**(离线 ip2region 解析地区、
UA 解析浏览器系统),**不展示任何 IP**;测速完成后**自动高亮"我的成绩"**(含真实名次,
localStorage 持久化,刷新页面仍高亮)
- 🌐 **IP 处理**: 客户端 IP 仅后台保存(数据库),用于地区解析与调试,**不在排行榜展示**;
页面上方徽标显示"你的公网 IP · 地区";服务器直接识别 IP(Caddy `X-Real-IP`),
无外部 IP 查询依赖
- 📍 **地区解析**: 内置 ip2region v4 离线库(`data/ip2region_v4.xdb`11MB),纯本地解析
零外部依赖,加载失败时地区优雅降级为"未知地区"
- 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口
## 项目结构
@@ -107,11 +111,11 @@ speedtest.lmve.net {
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/` | 测速页面 |
| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...}` |
| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...,"location":...}` |
| 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?, server_ip?}` |
| GET | `/api/rankings?sort=&order=&include_id=` | 排行榜单表:`sort` ∈ download/upload/latency`order` ∈ asc/desc`include_id` 附加指定记录(真实名次 + `is_mine` |
| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps}`IP/UA 由服务器识别) |
| GET | `/api/rankings?sort=&order=&include_id=` | 排行榜单表:`sort` ∈ download/upload/latency/created`order` ∈ asc/desc`include_id` 附加指定记录(真实名次 + `is_mine`;条目含 `location`/`browser`**不含 IP** |
客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务;
`client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私;
Binary file not shown.
+1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/BurntSushi/toml v1.4.0
github.com/gin-gonic/gin v1.12.0
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260814054437-800d19424237
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
+2
View File
@@ -42,6 +42,8 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260814054437-800d19424237 h1:MnELyo/XfIhNDG7YjVUHkEW0CsgONASbV7SWqVL6/gw=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260814054437-800d19424237/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+4
View File
@@ -128,6 +128,10 @@ deploy_files() {
# 复制模板文件(目录结构: internal/web/templates/*.html
cp -f "${BUILD_DIR}/internal/web/templates/"*.html "${INSTALL_DIR}/internal/web/templates/"
# 复制 ip2region 地区库(离线 GeoIP 数据)
mkdir -p "${INSTALL_DIR}/data"
cp -f "${BUILD_DIR}/data/ip2region_v4.xdb" "${INSTALL_DIR}/data/ip2region_v4.xdb"
# 配置文件由程序首次启动时自动生成 + 缺失项自动补全,脚本不干预
if [[ -f "${CONFIG_DIR}/speedtest.toml" ]]; then
info "保留现有配置文件: ${CONFIG_DIR}/speedtest.toml"
+4 -2
View File
@@ -5,8 +5,10 @@ import "time"
// SpeedTestResult 一次完整的测速结果记录
type SpeedTestResult struct {
ID uint `gorm:"primaryKey" json:"id"`
ClientIP string `gorm:"size:64;index" json:"client_ip"` // 展示 IP(优先前端探测的公网出口 IP
ServerIP string `gorm:"size:64" json:"server_ip"` // 服务器看到的来源 IPNAT 环境下为内网 IP,用于区分设备
ClientIP string `gorm:"size:64;index" json:"client_ip"` // 客户端 IP(仅后台保存,不对外展示
ServerIP string `gorm:"size:64" json:"server_ip"` // 服务器看到的来源 IPNAT 调试用,仅后台保存
Location string `gorm:"size:64" json:"location"` // 地区(ip2region 离线解析)
UserAgent string `gorm:"size:256" json:"user_agent"` // 浏览器 UA(仅后台保存)
LatencyMs float64 `json:"latency_ms"` // 延迟(毫秒,取中位数)
JitterMs float64 `json:"jitter_ms"` // 抖动(毫秒,平均绝对偏差)
DownloadMbps float64 `json:"download_mbps"` // 下载速度(Mbps
+124
View File
@@ -0,0 +1,124 @@
package geo
import (
"log"
"os"
"strings"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
)
// Geo 基于 ip2region v4 离线库的 IP 地区解析器(纯本地,无外部依赖)
type Geo struct {
searcher *xdb.Searcher
}
// New 按顺序尝试数据文件路径加载;全部失败时返回可用但禁用的实例(地区解析优雅降级)
func New(paths ...string) *Geo {
var dbPath string
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
dbPath = p
break
}
}
if dbPath == "" {
log.Printf("[geo] 未找到 ip2region 数据文件(尝试: %v),地区解析已禁用", paths)
return &Geo{}
}
handle, err := os.Open(dbPath)
if err != nil {
log.Printf("[geo] 打开 %s 失败: %v", dbPath, err)
return &Geo{}
}
defer handle.Close()
header, err := xdb.LoadHeader(handle)
if err != nil {
log.Printf("[geo] 读取 %s 头部失败: %v", dbPath, err)
return &Geo{}
}
version, err := xdb.VersionFromHeader(header)
if err != nil {
log.Printf("[geo] 识别 %s 版本失败: %v", dbPath, err)
return &Geo{}
}
// 整库载入内存,查询性能最好(约 11MB)
buff, err := xdb.LoadContentFromFile(dbPath)
if err != nil {
log.Printf("[geo] 加载 %s 数据失败: %v", dbPath, err)
return &Geo{}
}
searcher, err := xdb.NewWithBuffer(version, buff)
if err != nil {
log.Printf("[geo] 初始化搜索器失败: %v", err)
return &Geo{}
}
log.Printf("[geo] ip2region 已加载(%.0f MB%s", float64(len(buff))/1024/1024, dbPath)
return &Geo{searcher: searcher}
}
// Search 返回友好地区名(如 "广东省深圳市 电信"、"新加坡");解析失败返回 ""
func (g *Geo) Search(ip string) string {
if g == nil || g.searcher == nil || ip == "" {
return ""
}
region, err := g.searcher.Search(ip)
if err != nil {
return ""
}
return Format(region)
}
// Format 把 ip2region v4 原始格式 "国家|省|市|ISP|国家码" 转为友好显示
// 例:中国|江苏省|南京市|0|CN → "江苏省南京市";中国|上海|上海市|电信|CN → "上海市 电信"
// United States|California|0|Google LLC|US → "United States California · Google LLC"
func Format(region string) string {
parts := strings.Split(region, "|")
if len(parts) < 5 {
return region
}
country, province, city, isp := parts[0], parts[1], parts[2], parts[3]
if country == "" || country == "0" || country == "Reserved" {
return "" // 保留地址/内网/IPv6 不在 v4 库中
}
if isp == "0" {
isp = ""
}
if country == "中国" {
loc := ""
switch {
case province != "" && province != "0" && city != "" && city != "0":
if city == province || strings.Contains(city, province) {
loc = city // 直辖市:"上海市"
} else {
loc = province + city // "江苏省南京市"
}
case province != "" && province != "0":
loc = province
default:
loc = "中国"
}
if isp != "" {
loc += " " + isp
}
return loc
}
// 国外:国家 + 州/市(英文)
loc := country
switch {
case province != "" && province != "0":
loc += " " + province
case city != "" && city != "0":
loc += " " + city
}
if isp != "" {
loc += " · " + isp
}
return loc
}
+71 -48
View File
@@ -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,
+12 -26
View File
@@ -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,用于排行榜高亮)