高性能IP查询服务端开发日志之前使用IP信息查询的服务也不少了，观察了不少的接口，大部分返回的数据都是json格式的数据，于是我就在想，我能不能也自己开发一个类似的查询工具玩一下？说干就干，在想清楚了软件的架构设计后，我决定开工。架构设计与编程语言选型在架构设计方面，我打算进行前后端彻底解耦，前端为静态网页，后端为纯API程序。对于前端，我计划利用JS来从API获取信息并更新页面对于后端，就做最纯粹的API服务，根据查询请求响应JSON字符串在亲眼见证过PHP，Python这类动态解释型语言的效率噩梦后，我打算使用GO语言进行开发，理由很简单：GO是编译型语言，运行效率比解释型语言要高得多。GO天生就适合开发后端，抗并发能力强。相比于C语言，GO语言要更适合上手。构建前端不得不说，就目前的情况，Gemini仍然是前端之神，我直接让Gemini根据我的想法设计了一套网页，还是相当不错的：代码如下： 猫娘IP地址查询 🐱 🐾 猫娘IP地址查询 您的 IP 地址为 正在获取喵... 🔍 查询喵 🐾 🌍 国家 - 🗺️ 省份 - 🏙️ 城市 - 🏢 ISP 运营商 - 🧭 经纬度 - 💡 开发者 API 说明 🛠️ API 调用说明 × 您可以通过发送 GET 请求到我们的接口来获取 IP 详细信息喵： GET https://api.maoniang.example/v1/ip?address={ip} 📦 返回数据格式示例 (JSON): { "code": 200, "msg": "success", "data": { "country": "美国", "province": "加利福尼亚州", "city": "圣克拉拉", "isp": "阿里云", "latitude": "37.355701", "longitude": "-121.955002" } } 我知道了喵 🐾 但是仔细观察，你会发现使用了实时构建的 Tailwind CSS ，其实这样做对于一个静态页面不太友好，于是我让他按照 Tailwind CSS 的样式，原生帮我写了一版样式。因为处理后代码变多了，我就不在这里放出来了。最后静态文件就三个：[chocola@Neko-X99 static]$ tree . ├── favicon.png ├── index.html └── main.css 1 directory, 3 files后续还有界面的美化，目前确定是这样了：变可爱了，有没有？后端设计因为项目的定位是轻量，所以我不打算依赖外部的数据库。再加上IP查询这个业务场景几乎全是读操作，所以使用SQLite是没问题的，读取这方面没有瓶颈。为了实现高效的 IP 查询，数据库表结构的设计至关重要。核心思路是将文本格式的 IP 地址转换为无符号 32 位整数（uint32），这样不仅运算效率高，也便于进行范围比较。数据库表（例如 ip_info）主要包含三个字段：network_start (INTEGER): 存储该 IP 段的起始地址，以 uint32 格式保存。这是查询的核心，我们会根据这个字段建立索引（CREATE INDEX idx_network_start ON ip_info (network_start);），以确保查询性能。network_end (INTEGER): 存储该 IP 段的结束地址，同样以 uint32 格式保存。用于在查找到候选记录后，进行二次验证，确保目标 IP 确实落在该网段内。ip_info_json (TEXT): 将该 IP 段对应的地理位置、运营商等详细信息，序列化成 JSON 字符串后存储在此字段。这种方案灵活度高，可以方便地增减返回的字段，而无需修改表结构。查询时，我们首先将待查询的 IP 转换为整数，然后利用 WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1 这样的 SQL 语句，借助索引快速定位到最接近且小于等于目标 IP 的那条记录。接着，再用 network_end 字段验证目标 IP 是否在该记录的范围内，从而得出最终结果。这种“先定位，后校验”的方式，配合索引，能够将查询耗时稳定在微秒级别。测试环境：CPU：i5-2410M内存：4GB DDR3 1600MHz硬盘：希捷 320G SATA2 机械硬盘Nginx：模拟真实环境，开启SSL进行反代第一版代码package main import ( "database/sql" "encoding/json" "flag" "fmt" "log" "net" "net/http" "strings" _ "github.com/mattn/go-sqlite3" ) var db *sql.DB // 定义完全匹配前端需求的数据结构 type IPInfo struct { IP string `json:"ip"` Country string `json:"country"` Province string `json:"province"` City string `json:"city"` ISP string `json:"isp"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` } // 定义标准的 API 响应格式 type APIResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data"` // 用 interface{} 以便在出错时返回 nil } // 辅助函数：获取客户端真实 IP func getClientIP(r *http.Request) string { ip := r.Header.Get("X-Real-IP") if ip == "" { ip = r.Header.Get("X-Forwarded-For") } if ip == "" { ip, _, _ = net.SplitHostPort(r.RemoteAddr) } // X-Forwarded-For 可能是逗号分隔的多个IP，取第一个真实的 ip = strings.Split(ip, ",")[0] // 清理可能包含的空格，提升健壮性 return strings.TrimSpace(ip) } func apiHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // 提取查询参数并去除两端空白字符 queryIPStr := strings.TrimSpace(r.URL.Query().Get("ip")) targetIP := queryIPStr if targetIP == "" { targetIP = getClientIP(r) } // 【安全防线 1】：严格限制为合法的 IPv4 格式。任何注入代码都会在这里被直接拦截。 parsedIP := net.ParseIP(targetIP) if parsedIP == nil || parsedIP.To4() == nil { json.NewEncoder(w).Encode(APIResponse{Code: 400, Msg: "非法的 IPv4 地址喵！", Data: nil}) return } // 【安全防线 2】：转为 uint32 整型。彻底杜绝字符型注入。 ipInt := uint32(parsedIP.To4()[0])<<24 | uint32(parsedIP.To4()[1])<<16 | uint32(parsedIP.To4()[2])<<8 | uint32(parsedIP.To4()[3]) var infoJSON string var networkEnd uint32 // 🌟 记得提前声明这个变量喵 // 【性能优化核心】：利用索引极速向下查找最近的一条记录 err := db.QueryRow(` SELECT network_end, ip_info_json FROM ip_info WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1`, ipInt).Scan(&networkEnd, &infoJSON) if err != nil { if err == sql.ErrNoRows { json.NewEncoder(w).Encode(APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) } else { // 避免将底层的数据库错误直接暴露给前端（防止信息泄露） log.Printf("数据库查询错误: %v\n", err) json.NewEncoder(w).Encode(APIResponse{Code: 500, Msg: "数据库查询出错了喵", Data: nil}) } return } // 【关键逻辑校验】：虽然找到了最近的起始 IP，但必须确认目标 IP 是否在这个网段的覆盖范围内 if ipInt > networkEnd { json.NewEncoder(w).Encode(APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) return } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { log.Printf("JSON 解析错误: %v\n", err) json.NewEncoder(w).Encode(APIResponse{Code: 500, Msg: "数据解析失败喵", Data: nil}) return } result := IPInfo{ IP: targetIP, Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } json.NewEncoder(w).Encode(APIResponse{ Code: 200, Msg: "success", Data: result, }) } func main() { // ================= 定义启动参数 ================= // flag.String("参数名", "默认值", "说明文字") dbPath := flag.String("db", "ip_info.db", "SQLite 数据库文件路径") port := flag.String("port", "8080", "API 服务监听端口") // 解析命令行参数 flag.Parse() var err error // 使用参数指定的数据库路径 db, err = sql.Open("sqlite3", *dbPath) if err != nil { log.Fatal("无法打开数据库: ", err) } defer db.Close() http.HandleFunc("/ipinfo", apiHandler) // 拼接监听地址 addr := fmt.Sprintf(":%s", *port) fmt.Printf("猫娘纯净 API 服务启动于 %s 喵... 🐾\n", addr) fmt.Printf("当前使用的数据库文件: %s\n", *dbPath) log.Fatal(http.ListenAndServe(addr, nil)) }测试下来直接就踩了一个大坑：因为没有设置索引，数据库查询效率非常低下，以至于到了难以置信的慢。通过这个命令：CREATE INDEX idx_network_start ON ip_info (network_start);建立好索引后，查询速度直接起飞。从原来每秒只能处理10个请求飙升至每秒可以处理5000+个请求，性能直接翻了500倍！太夸张了！第二版代码虽然第一版代码的性能已经可以做到很优秀，但是我还是希望尝试进一步优化，那就是——数据库存入内存！而而且我不但要存入内存，还要将其转化为Go原生的数据存储格式，进一步优化性能。package main import ( "database/sql" "encoding/json" "flag" "fmt" "log" "net" "net/http" "sort" "strings" "time" _ "github.com/mattn/go-sqlite3" ) var db *sql.DB // --- 全局配置与缓存 --- var ( ipCache []IPRule // 全量内存切片 useMemoryCache bool // 内存模式开关 enableDetailLog bool // 详细日志开关 ) type IPRule struct { NetworkStart uint32 NetworkEnd uint32 Info IPInfo } type IPInfo struct { IP string `json:"ip"` Country string `json:"country"` Province string `json:"province"` City string `json:"city"` ISP string `json:"isp"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` } type APIResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data"` } // 辅助函数：获取客户端真实 IP func getClientIP(r *http.Request) string { ip := r.Header.Get("X-Real-IP") if ip == "" { ip = r.Header.Get("X-Forwarded-For") } if ip == "" { ip, _, _ = net.SplitHostPort(r.RemoteAddr) } ip = strings.Split(ip, ",")[0] return strings.TrimSpace(ip) } // 统一处理响应和日志打印 func sendJSONResponse(w http.ResponseWriter, clientIP, targetIP string, resp APIResponse) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") // 将结构体序列化为 JSON 字节 respBytes, err := json.Marshal(resp) if err != nil { log.Printf("响应序列化失败: %v", err) http.Error(w, `{"code":500,"msg":"系统内部错误","data":null}`, http.StatusInternalServerError) return } // 如果开启了日志，就在这里统一输出 if enableDetailLog { log.Printf("[访问日志] 来源IP: %-15s | 查询IP: %-15s | 结果: %s", clientIP, targetIP, string(respBytes)) } // 写入响应 w.Write(respBytes) } func loadDataToMemory() error { log.Println("正在将数据库载入内存，请稍候喵...") startTime := time.Now() rows, err := db.Query(`SELECT network_start, network_end, ip_info_json FROM ip_info ORDER BY network_start ASC`) if err != nil { return err } defer rows.Close() for rows.Next() { var start, end uint32 var infoJSON string if err := rows.Scan(&start, &end, &infoJSON); err != nil { return err } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { continue } info := IPInfo{ Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } ipCache = append(ipCache, IPRule{ NetworkStart: start, NetworkEnd: end, Info: info, }) } log.Printf("载入完成！共加载了 %d 条规则，耗时 %v 喵！", len(ipCache), time.Since(startTime)) return nil } func apiHandler(w http.ResponseWriter, r *http.Request) { clientIP := getClientIP(r) // 提取实际访问者的 IP，用于日志记录 queryIPStr := strings.TrimSpace(r.URL.Query().Get("ip")) targetIP := queryIPStr if targetIP == "" { targetIP = clientIP } parsedIP := net.ParseIP(targetIP) if parsedIP == nil || parsedIP.To4() == nil { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 400, Msg: "非法的 IPv4 地址喵！", Data: nil}) return } ipInt := uint32(parsedIP.To4()[0])<<24 | uint32(parsedIP.To4()[1])<<16 | uint32(parsedIP.To4()[2])<<8 | uint32(parsedIP.To4()[3]) if useMemoryCache { idx := sort.Search(len(ipCache), func(i int) bool { return ipCache[i].NetworkStart > ipInt }) if idx > 0 { rule := ipCache[idx-1] if ipInt <= rule.NetworkEnd { result := rule.Info result.IP = targetIP sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 200, Msg: "success", Data: result}) return } } sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "内存库里没有找到这个 IP 喵~", Data: nil}) } else { var infoJSON string var networkEnd uint32 err := db.QueryRow(` SELECT network_end, ip_info_json FROM ip_info WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1`, ipInt).Scan(&networkEnd, &infoJSON) if err != nil { if err == sql.ErrNoRows { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) } else { log.Printf("数据库查询错误: %v\n", err) sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 500, Msg: "数据库查询出错了喵", Data: nil}) } return } if ipInt > networkEnd { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) return } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { log.Printf("JSON 解析错误: %v\n", err) sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 500, Msg: "数据解析失败喵", Data: nil}) return } result := IPInfo{ IP: targetIP, Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 200, Msg: "success", Data: result}) } } func main() { dbPath := flag.String("db", "ip_info.db", "SQLite 数据库文件路径") port := flag.String("port", "8080", "API 服务监听端口") memFlag := flag.Bool("mem", false, "是否开启全量内存模式（内存换取极致性能喵~）") logFlag := flag.Bool("log", false, "是否开启详细访问日志输出") flag.Parse() useMemoryCache = *memFlag enableDetailLog = *logFlag // 赋值给全局变量 var err error db, err = sql.Open("sqlite3", *dbPath) if err != nil { log.Fatal("无法打开数据库: ", err) } defer db.Close() if useMemoryCache { if err := loadDataToMemory(); err != nil { log.Fatal("致命错误：无法将数据加载到内存喵: ", err) } } http.HandleFunc("/ipinfo", apiHandler) addr := fmt.Sprintf(":%s", *port) fmt.Printf("猫娘 API 服务启动于 %s 喵...\n", addr) fmt.Printf("当前数据库文件: %s\n", *dbPath) if useMemoryCache { fmt.Println("当前运行模式: [极致性能] 全量内存 + 二分查找") } else { fmt.Println("当前运行模式: [省内存] SQLite 实时查询 (可加 -mem=true 提速)") } if enableDetailLog { fmt.Println("详细访问日志: 已开启 (将在控制台打印每次请求细节)") } else { fmt.Println("详细访问日志: 未开启 (可加 -log=true 开启)") } log.Fatal(http.ListenAndServe(addr, nil)) }另外，我还加上了日志输出的功能，方便进行API调用的监控，可以防范滥用。经过测试，在把数据库存进内存后，可以抗的并发请求从 5000+ 请求/秒 提升到了 7000+ 请求/秒，提升了40%！不过我也观察到，此时Nginx的CPU占用率明显上升，大约占据了40%+的CPU，而Go程序本身的CPU占用率则从50%左右降低到了35%！不过有点奇怪的是，大约有20%的CPU占用率不知道是被什么程序占据了，BTOP没有显示出来。其实在第一版代码的时候，Nginx的CPU占用已经挺高了，请求数量提升后更高。值得注意的是，两个版本的服务端进行极限压力测试的时候，整机的CPU占用率几乎都是100%。不知道各位对这个性能表现感觉如何？分析：“消失”的20% CPU占用去哪了？在测试中，我观察到一个有趣的现象：Nginx 占用了约 40%，Go 程序占用了约 35%，加起来只有 75% 左右，但整机 CPU 却已经打满（100%）。剩下的 20% 去哪了？难道被猫娘偷吃了吗？其实，这并非 BUG，而是高性能网络服务的典型特征——内核态开销（System CPU）。当 QPS 达到 7000+ 时，服务器每秒需要处理数万个网络数据包的接收、发送、上下文切换以及系统调用。这些操作并不发生在 Nginx 或 Go 程序的“用户态”代码中，而是直接由 Linux 内核 接管处理：网络协议栈处理：TCP/IP 包的解析、校验、重组。软中断（SoftIRQs）：网卡中断后的数据搬运。线程调度：在数千个并发连接间快速切换 CPU 时间片。结论：这“消失”的 20% 实际上是被 操作系统内核 消耗掉了，用于支撑如此高频率的网络吞吐。这也侧面证明了我们的程序优化已经非常到位，瓶颈不再在于应用层代码，而在于底层的网络 IO 处理能力。如果再想提升，可能就需要考虑内核参数调优（如调整 TCP 缓冲区、开启 RSS 多队列网卡等）或者升级更强的单核 CPU 了喵！提升程序的兼容性因为我们的项目依赖了go-sqlite3，这要求必须开启CGO进行编译。如果在一般的Linux发行版（如Ubuntu、CentOS）上直接编译，默认会动态链接系统的C语言标准库（glibc）。这必然会导致把程序放到不同年代、不同发行版的服务器上运行时，出现“找不到glibc版本”等兼容性报错。为了达到“一次编译，到处运行”的完美兼容性，我们需要进行纯静态链接打包。但是，在普通发行版上强行静态编译 glibc 是非常痛苦且容易踩坑的。因此，我们需要借助musl这个极其精简的C标准库。使用musl的典型代表就是Alpine Linux（这也是老朋友了，我的云服务器基本上都是这个发行版）。我们可以非常优雅地在 Alpine 环境下进行全静态编译操作：先安装必要的 C 语言编译工具链：apk add go gcc musl-dev进入代码目录后，初始化模块并拉取依赖：go mod init github.com/Chocola-X/NekoIPinfo go mod tidy最后，使用“静态编译参数”进行编译：CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-linkmode external -extldflags -static -s -w" -o neko-ip-api main.go这样编译出来的二进制文件内部已经打包了所有需要的底层库。你把它丢到绝大部分 x86_64 架构的 Linux 发行版上，都可以做到直接开箱即用！结尾项目已经开源到了Github：NekoIPinfo并且我也自己搭建了查询服务，感兴趣的可以来玩玩：NekoIPinfo Demo
利用B站IP查询API更新访客记录IP地理信息偶然的一次机会，在群里面聊天的时候，群友指出纯真数据库的数据并不准确，并且说有一个可以免费调用的IP地理信息库——B站的IP查询API。据说还是用的ipip.net的企业版，这下得狠狠白嫖了。前置任务：修复VisitorLoggerPro插件BUG如果使用过这个插件一段时间，你就应该发现了，在国家统计图表这项，非常的混乱，似乎是只要记录的字符不一致，就会被当作不同的国家处理，例如美国加利福尼亚洛杉矶和美国纽约就被当作不同的国家列在上面。经过深挖代码斗智斗勇，我发现这个问题我无法从根本解决，因为整个代码太奇怪了，而且用的本地IP数据库也都是乱七八糟的，根本没有做好国家的细分地区分类，一股脑返回，导致所有的信息被集中在一起写进了国家字段。于是，我放弃治疗，直接在后面的调用代码中，增加分类逻辑进行分类：这是原来的代码，单独处理和提取了国内省份： foreach ($countryCountsResult as $row) { $countryName = $row['country'] ?: '未知'; $count = $row['count']; if (!isset($countryData[$countryName])) { $countryData[$countryName] = 0; $totalCountries++; } $countryData[$countryName] += $count; if (strpos($countryName, '中国') !== false) { foreach ($provinces as $province) { if (strpos($countryName, $province) !== false) { if (!isset($provinceData[$province])) { $provinceData[$province] = 0; } $provinceData[$province] += $count; break; } } } }我决定在返回国家信息的时候，进行提取合并并返回： foreach ($countryCountsResult as $row) { $rawCountry = $row['country'] ?? ''; $count = (int)($row['count'] ?? 0); if ($rawCountry === '') { $stdCountry = '未知'; } elseif (strpos($rawCountry, '中国') !== false) { // 所有含“中国”的都归为“中国” $stdCountry = '中国'; // 同时尝试提取省份 foreach ($provinces as $province) { if (strpos($rawCountry, $province) !== false) { if (!isset($provinceData[$province])) { $provinceData[$province] = 0; } $provinceData[$province] += $count; break; // 匹配到第一个即停止 } } } else { // 非中国：尝试匹配标准国家名（用于归一化，如“美国加州”→“美国”） $stdCountry = $rawCountry; // 默认保留原值 foreach ($countries as $country) { if (strpos($rawCountry, $country) !== false) { $stdCountry = $country; break; // 匹配到即停止（因列表已按长优先排序） } } } // 累计国家统计 if (!isset($countryData[$stdCountry])) { $countryData[$stdCountry] = 0; $totalCountries++; } $countryData[$stdCountry] += $count; }当然了，需要补上完整的国家地区列表： $countries = [ // 长名称国家（必须放前面） '波斯尼亚和黑塞哥维那', '赤道几内亚', '圣文森特和格林纳丁斯', '特立尼达和多巴哥', '圣多美和普林西比', '阿拉伯联合酋长国', '沙特阿拉伯', '巴布亚新几内亚', '所罗门群岛', '马绍尔群岛', '密克罗尼西亚联邦', '帕劳共和国', '瓦努阿图共和国', '基里巴斯共和国', '瑙鲁共和国', '图瓦卢', '美属萨摩亚', '英属维尔京群岛', '美属维尔京群岛', '北马里亚纳群岛', '福克兰群岛（马尔维纳斯）', '法属圭亚那', '法属波利尼西亚', '法属南部领地', '皮特凯恩群岛', '托克劳', '库克群岛', '纽埃', '新喀里多尼亚', '关岛', '百慕大', '开曼群岛', '安圭拉', '蒙特塞拉特', '荷属圣马丁', '法属圣马丁', '圣皮埃尔和密克隆', '阿森松岛', '特里斯坦-达库尼亚', // 主权国家（按字母拼音排序） '阿富汗', '阿尔巴尼亚', '阿尔及利亚', '安道尔', '安哥拉', '安提瓜和巴布达', '阿根廷', '亚美尼亚', '澳大利亚', '奥地利', '阿塞拜疆', '巴哈马', '巴林', '孟加拉国', '巴巴多斯', '白俄罗斯', '比利时', '伯利兹', '贝宁', '不丹', '玻利维亚', '博茨瓦纳', '巴西', '文莱', '保加利亚', '布基纳法索', '布隆迪', '柬埔寨', '喀麦隆', '加拿大', '佛得角', '中非', '乍得', '智利', '哥伦比亚', '科摩罗', '刚果（布）', '刚果（金）', '哥斯达黎加', '科特迪瓦', '克罗地亚', '古巴', '塞浦路斯', '捷克', '丹麦', '吉布提', '多米尼克', '多米尼加', '厄瓜多尔', '埃及', '萨尔瓦多', '赤道几内亚', '厄立特里亚', '爱沙尼亚', '斯威士兰', '埃塞俄比亚', '斐济', '芬兰', '法国', '加蓬', '冈比亚', '格鲁吉亚', '德国', '加纳', '希腊', '格林纳达', '危地马拉', '几内亚', '几内亚比绍', '圭亚那', '海地', '洪都拉斯', '匈牙利', '冰岛', '印度', '印度尼西亚', '伊朗', '伊拉克', '爱尔兰', '以色列', '意大利', '牙买加', '日本', '约旦', '哈萨克斯坦', '肯尼亚', '基里巴斯', '朝鲜', '韩国', '科威特', '吉尔吉斯斯坦', '老挝', '拉脱维亚', '黎巴嫩', '莱索托', '利比里亚', '利比亚', '列支敦士登', '立陶宛', '卢森堡', '马达加斯加', '马拉维', '马来西亚', '马尔代夫', '马里', '马耳他', '马绍尔群岛', '毛里塔尼亚', '毛里求斯', '墨西哥', '密克罗尼西亚', '摩尔多瓦', '摩纳哥', '蒙古', '黑山', '摩洛哥', '莫桑比克', '缅甸', '纳米比亚', '瑙鲁', '尼泊尔', '荷兰', '新西兰', '尼加拉瓜', '尼日尔', '尼日利亚', '北马其顿', '挪威', '阿曼', '巴基斯坦', '帕劳', '巴拿马', '巴布亚新几内亚', '巴拉圭', '秘鲁', '菲律宾', '波兰', '葡萄牙', '卡塔尔', '罗马尼亚', '俄罗斯', '卢旺达', '圣基茨和尼维斯', '圣卢西亚', '圣马力诺', '圣多美和普林西比', '沙特阿拉伯', '塞内加尔', '塞尔维亚', '塞舌尔', '塞拉利昂', '新加坡', '斯洛伐克', '斯洛文尼亚', '所罗门群岛', '索马里', '南非', '南苏丹', '西班牙', '斯里兰卡', '苏丹', '苏里南', '瑞典', '瑞士', '叙利亚', '塔吉克斯坦', '坦桑尼亚', '泰国', '东帝汶', '多哥', '汤加', '特立尼达和多巴哥', '突尼斯', '土耳其', '土库曼斯坦', '图瓦卢', '乌干达', '乌克兰', '阿联酋', '英国', '美国', '乌拉圭', '乌兹别克斯坦', '瓦努阿图', '梵蒂冈', '委内瑞拉', '越南', '也门', '赞比亚', '津巴布韦' ];虽然我感觉好像把朝鲜放进去并没有什么意义（）修复后的代码已经提交到了我的github仓库：github.com/Chocola-X/VisitorLoggerPro-Enhanced编写更新脚本脚本程序主要的任务就是从数据库里面获取IP，然后从查询API获取到IP的相关信息，再去更新本地记录。考虑到服务器已经是有php环境了，干脆就直接用php来写了。更新已记录IP的IP信息脚本#!/usr/bin/env php <?php // ========== 数据库配置 ========== $DB_HOST = 'localhost'; $DB_PORT = '3306'; $DB_NAME = 'db_name'; $DB_USER = 'db_user'; $DB_PASS = 'www.nekopara.uk'; // =============================== $API_URL_TEMPLATE = 'https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr?ip=%s'; $LOOKBACK_MINUTES = 600; //向前追溯更新的时间，单位分钟 $BATCH_SIZE = 800; //最大更新IP条数 $API_TIMEOUT = 2; $DEBUG = true; // 创建数据库连接 try { $pdo = new PDO( "mysql:host=$DB_HOST;port=$DB_PORT;dbname=$DB_NAME;charset=utf8mb4", $DB_USER, $DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); } catch (PDOException $e) { fwrite(STDERR, "❌ 数据库连接失败: " . $e->getMessage() . "\n"); exit(1); } function queryIpLocation($ip, $apiTemplate, $timeout = 5) { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) { return null; } $url = sprintf($apiTemplate, urlencode($ip)); $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'header' => "User-Agent: Neko-IP-Geo-Updater/1.0\r\nAccept: application/json\r\n" ] ]); $response = @file_get_contents($url, false, $context); if ($response === false) { return null; } $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return null; } if (($data['code'] ?? -1) !== 0) { return null; } if (!isset($data['data']) || !is_array($data['data'])) { return null; } $country = trim($data['data']['country'] ?? ''); $province = trim($data['data']['province'] ?? ''); $city = trim($data['data']['city'] ?? ''); $isp = trim($data['data']['isp'] ?? ''); // 构建去重后的部件列表 $parts = []; // 国家总是加入 if (!empty($country) && $country !== 'Unknown' && $country !== '未知') { $parts[] = $country; } // 省份：仅当不等于国家时才加入 if (!empty($province) && $province !== 'Unknown' && $province !== '未知' && $province !== $country) { $parts[] = $province; } // 城市：仅当不等于国家且不等于省份时才加入 if (!empty($city) && $city !== 'Unknown' && $city !== '未知' && $city !== $country && $city !== $province) { $parts[] = $city; } // ISP 总是加入（只要非空） if (!empty($isp) && $isp !== 'Unknown' && $isp !== '未知') { $parts[] = $isp; } // 去除可能的重复（保险起见再做一次 array_unique，保持顺序） $parts = array_values(array_unique($parts)); $location = implode(' ', $parts); return $location ?: 'Unknown'; } // ====================== // 主逻辑 // ====================== $isCli = php_sapi_name() === 'cli'; $log = function($msg) use ($isCli, $DEBUG) { if (!$DEBUG) return; $line = "[" . date('Y-m-d H:i:s') . "] $msg\n"; if ($isCli) { echo $line; } else { echo htmlspecialchars($line); } }; $log("🔍 开始处理最近 {$LOOKBACK_MINUTES} 分钟内的访客记录（将 country 字段替换为完整地理位置）..."); $stmt = $pdo->prepare(" SELECT id, ip, country FROM `typecho_visitor_log` WHERE `time` >= NOW() - INTERVAL ? MINUTE ORDER BY `time` ASC LIMIT ? "); $stmt->bindValue(1, (int)$LOOKBACK_MINUTES, PDO::PARAM_INT); $stmt->bindValue(2, (int)$BATCH_SIZE, PDO::PARAM_INT); $stmt->execute(); $records = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($records)) { $log("✅ 无符合条件的记录。"); exit(0); } $log("📥 找到 " . count($records) . " 条记录，开始查询 API..."); $updateStmt = $pdo->prepare(" UPDATE `typecho_visitor_log` SET `country` = ? WHERE `id` = ? "); $updated = 0; $failed = 0; foreach ($records as $record) { $id = $record['id']; $ip = $record['ip']; $oldCountry = $record['country']; $fullLocation = queryIpLocation($ip, $API_URL_TEMPLATE, $API_TIMEOUT); if ($fullLocation !== null) { $updateStmt->execute([$fullLocation, $id]); $updated++; $log("✅ ID=$id | IP=$ip | 替换为: \"$fullLocation\""); } else { $failed++; $log("❌ ID=$id | IP=$ip | API 查询失败（保留原值: \"$oldCountry\"）"); } } $log("📊 完成：更新 $updated 条，失败 $failed 条");这个脚本用于更新已经存在的IP记录信息，已经设置了相关的参数便于操作，下面是参数解释：$DB_HOST = 'localhost'; 这部分设置数据库地址，例如localhost或192.168.6.6$DB_PORT = '3306'; 这部分是数据库端口号，例如默认端口3306$DB_NAME = 'db_name'; 这部分设置数据库名称，和网站设置的保持一致即可$DB_USER = 'db_user'; 这部分设置数据库用户名，和网站设置的保持一致即可$DB_PASS = 'www.nekopara.uk'; 这部分设置数据库密码，和网站设置的保持一致即可$LOOKBACK_MINUTES = 600; 这部分是往前追溯更新时间设置，也就是设置更新你执行脚本的这个时间之前的多少分钟内的数据$BATCH_SIZE = 800;这部分是设置最大更新IP数量大小，防止IP太多导致长时间占用运行$API_TIMEOUT = 2; 这是API访问超时时间限制，设置2秒即可，如果网络不稳定可以改大一点把以上的脚本修改好对应信息后，保存为一个php文件（例如update_ip_info.php）丢到服务器的一个目录里面（例如/data/ip_info/），然后进入目录执行：php82 update_ip_info.php耐心等待数据更新完毕即可，如果设置数目较大会需要等待较长时间，可以考虑用tumx来挂着运行。自动更新记录脚本话说回来，我们总不能每次都手动上服务器执行这个更新脚本吧？所以，我们需要一个自动化，批量更新多个网站的脚本：<?php // ========== 多站点数据库配置 ========== $sites = [ [ 'name' => 'www.nekopara.uk', 'host' => 'localhost', 'port' => '3306', 'dbname' => 'typecho', 'user' => 'typecho', 'pass' => 'www.nekopara.uk', ], [ 'name' => 'demo.nekopara.uk', 'host' => 'localhost', 'port' => '3306', 'dbname' => 'demo_typecho', 'user' => 'demo_typecho', 'pass' => 'demo.nekopara.uk', ], // 可继续添加更多站点... ]; // ===================================== $API_URL_TEMPLATE = 'https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr?ip=%s'; $LOOKBACK_MINUTES = 10; $BATCH_SIZE = 500; $API_TIMEOUT = 2; $DEBUG = true; $isCli = php_sapi_name() === 'cli'; $log = function($msg) use ($isCli, $DEBUG) { if (!$DEBUG) return; $line = "[" . date('Y-m-d H:i:s') . "] $msg\n"; if ($isCli) { echo $line; } else { echo htmlspecialchars($line); } }; function queryIpLocation($ip, $apiTemplate, $timeout = 5) { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) { return null; } $url = sprintf($apiTemplate, urlencode($ip)); $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'header' => "User-Agent: Neko-IP-Geo-Updater/1.0\r\nAccept: application/json\r\n" ] ]); $response = @file_get_contents($url, false, $context); if ($response === false) { return null; } $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return null; } if (($data['code'] ?? -1) !== 0) { return null; } if (!isset($data['data']) || !is_array($data['data'])) { return null; } $country = trim($data['data']['country'] ?? ''); $province = trim($data['data']['province'] ?? ''); $city = trim($data['data']['city'] ?? ''); $isp = trim($data['data']['isp'] ?? ''); // 构建去重后的部件列表 $parts = []; // 国家总是加入 if (!empty($country) && $country !== 'Unknown' && $country !== '未知') { $parts[] = $country; } // 省份：仅当不等于国家时才加入 if (!empty($province) && $province !== 'Unknown' && $province !== '未知' && $province !== $country) { $parts[] = $province; } // 城市：仅当不等于国家且不等于省份时才加入 if (!empty($city) && $city !== 'Unknown' && $city !== '未知' && $city !== $country && $city !== $province) { $parts[] = $city; } // ISP 总是加入（只要非空） if (!empty($isp) && $isp !== 'Unknown' && $isp !== '未知') { $parts[] = $isp; } // 去除可能的重复（保险起见再做一次 array_unique，保持顺序） $parts = array_values(array_unique($parts)); $location = implode(' ', $parts); return $location ?: 'Unknown'; } foreach ($sites as $site) { $log("🌐 开始处理站点: {$site['name']} (数据库: {$site['dbname']})"); try { $pdo = new PDO( "mysql:host={$site['host']};port={$site['port']};dbname={$site['dbname']};charset=utf8mb4", $site['user'], $site['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); } catch (PDOException $e) { $log("❌ 连接失败 ({$site['name']}): " . $e->getMessage()); continue; // 跳过这个站点，继续下一个 } // 查询近期记录 $stmt = $pdo->prepare(" SELECT id, ip, country FROM `typecho_visitor_log` WHERE `time` >= NOW() - INTERVAL ? MINUTE ORDER BY `time` ASC LIMIT ? "); $stmt->bindValue(1, (int)$LOOKBACK_MINUTES, PDO::PARAM_INT); $stmt->bindValue(2, (int)$BATCH_SIZE, PDO::PARAM_INT); $stmt->execute(); $records = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($records)) { $log("✅ {$site['name']}: 无新记录"); continue; } $updateStmt = $pdo->prepare(" UPDATE `typecho_visitor_log` SET `country` = ? WHERE `id` = ? "); $updated = 0; $failed = 0; foreach ($records as $record) { $id = $record['id']; $ip = $record['ip']; $oldCountry = $record['country']; $fullLocation = queryIpLocation($ip, $API_URL_TEMPLATE, $API_TIMEOUT); if ($fullLocation !== null) { $updateStmt->execute([$fullLocation, $id]); $updated++; $log("✅ {$site['name']} | ID=$id | IP=$ip => \"$fullLocation\""); } else { $failed++; $log("❌ {$site['name']} | ID=$id | IP=$ip | API 失败（保留: \"$oldCountry\"）"); } } $log("📊 {$site['name']} 完成：更新 $updated 条，失败 $failed 条\n"); } $log("🏁 所有站点处理完毕！");这里我设置了往前更新10分钟内的记录，更新IP记录数量限制在500以内，配合cron计划任务，就可以实现定时自动更新访问IP信息：*/10 * * * * /usr/bin/php82 "/data/ip_info/cron_update_ip.php"这样设置后，服务器就会每10分钟自动去获取IP信息并更新数据库了。