本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2024年12月21日 统计字数: 17447字 阅读时间: 35分钟阅读 本文链接: https://soulteary.com/2024/12/21/use-ai-to-assist-in-developing-an-open-source-ip-information-tool-part-1.html ----- # 使用 AI 辅助开发一个开源 IP 信息查询工具:一 本文将分享如何借助当下流行的 AI 工具,一步步完成一个开源项目的开发。 ## 写在前面 在写代码时,总是会遇到一些有趣的机缘巧合。前几天,我在翻看自己之前的开源项目时,又看到了 DDNS 相关的讨论。虽然在 2021 年我写过两篇相对详细的教程:《[使用 Nginx 提供 DDNS 服务(前篇)](https://soulteary.com/2021/07/31/use-nginx-to-provide-ddns-service-part-1.html)》和《[使用 Nginx 提供 DDNS 服务(中篇)](https://soulteary.com/2021/08/02/use-nginx-to-provide-ddns-service-part-2.html)》,但总觉得还可以做得更好。 这几天在上海出差上课,本地的网络和算力资源都比较有限。正好借这个机会,快速开发一个轻量的小工具,顺便也回应下之前有朋友问我的问题:在 AI 时代,开发一个简单应用的成本到底有多低? 去年五月份,我写过一篇文章《[AI 加持的代码编写实战:快速实现 Nginx 配置格式化工具](https://soulteary.com/2023/05/20/code-writing-practice-supported-by-ai-quickly-implement-nginx-configuration-formatting-tool.html)》,当时使用的是 ChatGPT,这篇文章中,我们来使用代码能力更强的 Anthropic Claude Sonnet 来完成类似的事情。在这篇文章中,我会尽可能使用对“非程序员”友好的方法,尽量避免使用复杂的 IDE。 项目已经在 Github 开源 [soulteary/ip-helper](https://github.com/soulteary/ip-helper),有需要可以自取,如果觉得有帮助的话,别忘了“一键三连”支持一下。 这个开源小工具的交互设计借鉴了 CIP.CC 的 IP 查询工具。 ![几年前折腾群同学推荐的工具](https://attachment.soulteary.com/2024/12/21/cip.jpg) 我一直觉得 CIP.CC 是个非常实用的工具。简洁明了,能快速提供有价值的参考信息。它整合了三个不同的 IP 数据源。实在要说缺点的话,如果能够公开数据库的来源和版本就更棒了。不过,在当前国内数据库和数据源要么收费要么需要申请的环境下,这类网站可能终将成为互联网的一段历史。 本文之所以能够成文,感谢好朋友高老师(IPIP)提供基础数据支持这个项目,在战胜了各种侵权事件之后,IPIP 的数据目前应该是毫无疑问的第一梯队了,恭喜! 另外,遗憾的是,目前该网站的“纯 IP 信息查询”以及“使用 Telnet、FTP 等方式查询”功能已经无法使用。 所以在这个项目中,我会根据自己的理解来实现并补充这些功能。 好了,让我们从前端到后端,来折腾出来这个小工具。 ## 第一步:使用多模态模型创建基础 UI 界面 2024 年底,各大模型都在推出“多模态”能力,让 AI 不仅能读懂文字,还能理解图片、音视频。让我们一步步用这些能力来搭建一个实用的工具界面吧。 ### 从界面设计开始 我们可以先让模型帮助我们生成一个简洁的 UI 模块设计图:设计一个网页工具,左右分栏布局,右侧是查询界面。 ![使用模型创建产品 UI 模块设计图](https://attachment.soulteary.com/2024/12/21/ui-struct.jpg) 然后,把已有的界面截图(Sketch 画一个,或使用你想借鉴的产品界面)丢给模型,提出一个典型的**模糊**产品需求:用 HTML 和 CSS 实现一个类似的精致界面。 ![使用模型创建信息查询界面](https://attachment.soulteary.com/2024/12/21/step-1-base-ui.jpg) 接下来,我们在新的对话中继续完善布局细节:“使用 CSS 和 HTML 创建一个左右分栏布局,左侧固定 30%,包含 Logo 图片。” ![使用模型创建布局界面](https://attachment.soulteary.com/2024/12/21/step-2-layout-ui.jpg) 好了,界面的设计和代码就都有了,接下来我们需要一个吸引眼球的 Logo。 ### 主视觉 & Logo 设计 这个环节我选择用 Midjourney 来设计:“来一只动感的大熊猫”。关于提示词,你可以自由发挥,创造更酷的版本。如果感兴趣,可以参考我在 2023 年 4 月写的文章《[八十行代码实现开源的 Midjourney、Stable Diffusion “咒语”作图工具](https://soulteary.com/2023/04/05/eighty-lines-of-code-to-implement-the-open-source-midjourney-and-stable-diffusion-spell-drawing-tool.html)》 ![使用模型创建一个主视觉](https://attachment.soulteary.com/2024/12/21/step-3-logo-design.jpg) ### 图片优化 生成的图片往往需要进一步调整。你可以用图片编辑软件调整内容、尺寸和格式: ![你可以选择你习惯的工具](https://attachment.soulteary.com/2024/12/21/editor-logo.jpg) 如果你是 macOS 用户,只想调整图片尺寸,用命令行会更快(这里我们把宽度设为 380 像素): ```go sips -Z 380 /Users/soulteary/Downloads/panda.png --out small-panda.png /Users/soulteary/Downloads/panda.png /Users/soulteary/Lab/github/ip-helper/small-panda.png ``` ### Favicon 制作 别忘了网站还需要一个 Favicon(收藏夹图标)。 我们可以让 AI 基于已有 Logo 设计一个像素化版本:“参考图片,设计一个简单的马赛克版本的 LOGO”。 ![使用模型创建 Favicon](https://attachment.soulteary.com/2024/12/21/step-4-favicon.jpg) 完成这些设计后,我们就可以把 AI 生成的代码保存下来,准备进行下一步的整合处理了。 ### 组装 AI 生成的界面素材 组合好的代码素材,得到的界面类似下面这张图。 ![组合好的基础界面模版](https://attachment.soulteary.com/2024/12/21/mix-ui.jpg) 对于AI 生成的界面素材,我们该如何组装成一个完整的应用界面呢?方法其实很简单。当你有了多个独立的界面组件后,可以通过以下方式将它们整合起来: 最简单的方式是创建一个新的AI对话,并提供明确的整合需求,比如:“将查询工具组件集成到左右布局面板的右侧区域"。 ![使用模型组合前端界面相关元素组件](https://attachment.soulteary.com/2024/12/21/mix-ui-by-ai.jpg) 如果你具备前端开发经验,更推荐手动组合这些代码。这样不仅能优化性能,还能构建出更合理的代码结构,为后续功能扩展打好基础。 我们得到了界面后,接下来就可以来实现基础的后端服务啦。 ## 第二步:完成服务端设计 后端服务的核心任务是获取和解析用户的 IP 信息,并将结果呈现给用户。 按照经典的模块化思路,我们可以把功能划分为以下几个部分:Web 界面渲染模块、IP 信息解析模块、IP 信息 API 接口模块,以及在原始工具基础上新增的多协议支持(包括 Telnet、FTP 等)。 ### 搭建基础服务框架 接下来,我们继续让 AI 助手帮我们生成代码:使用 Gin 实现一个简单的服务,解析命令行参数和环境变量中的端口和域名信息、以及用户口令。 ![使用模型获得基础的服务器代码](https://attachment.soulteary.com/2024/12/21/code-base-server.jpg) 很快,基础框架代码就准备就绪了。这段代码为我们提供了一个运行在 8080 端口的服务器,支持通过命令行参数或环境变量来配置服务端口和域名,同时具备基于 TOKEN 的用户认证功能。 ### 完成和模版的交互 我们先把前面的前端代码保存到项目的 `public/index.template.html` 文件中,同时将 Logo 等静态资源文件也放入 `public` 目录下。同时根据需要优化程序代码,让用户认证和代码交互体验更加自然顺畅。 另外,我们可以搭配使用我在今年年初写的文章《[完善 Golang Gin 框架的静态中间件:Gin-Static](https://soulteary.com/2024/01/03/golang-gin-static-middleware-improves.html)》中介绍的中间件 [soulteary/gin-static](https://github.com/soulteary/gin-static)。这样不仅能让程序支持单文件发布,还能提升整体性能。如果你想深入了解相关原理,可以参考《[深入浅出 Golang 资源嵌入方案:前篇](https://soulteary.com/2022/01/15/explain-the-golang-resource-embedding-solution-part-1.html)》以及查看 [Go-Embed](https://soulteary.com/tags/go-embed.html) 标签下的系列文章。 ```go package main import ( "embed" "flag" "fmt" "io" "log" "net/http" "os" "github.com/gin-gonic/gin" static "github.com/soulteary/gin-static" ) type Config struct { Domain string Port string Token string } // 解析配置参数 func parseConfig() *Config { config := &Config{} // 解析命令行参数 flag.StringVar(&config.Port, "port", "", "服务器端口") flag.StringVar(&config.Domain, "domain", "", "服务器域名") flag.StringVar(&config.Token, "token", "", "API 访问令牌") flag.Parse() // 尝试从环境变量中获取未设置的内容 if config.Port == "" { config.Port = os.Getenv("SERVER_PORT") } if config.Domain == "" { config.Domain = os.Getenv("SERVER_DOMAIN") } if config.Token == "" { config.Token = os.Getenv("TOKEN") } // 使用默认值 if config.Port == "" { config.Port = "8080" } if config.Domain == "" { config.Domain = "localhost" } if config.Token == "" { config.Token = "" log.Println("提醒:为了提高安全性,可以设置 `TOKEN` 环境变量或 `token` 命令行参数") } return config } // 验证请求中的令牌 func authMiddleware(config *Config) gin.HandlerFunc { return func(c *gin.Context) { if config.Token != "" { token := c.Query("token") if token == "" { token = c.GetHeader("X-Token") } if token != config.Token { c.JSON(401, gin.H{"error": "无效的认证令牌"}) c.Abort() return } } c.Next() } } func Get(link string) ([]byte, error) { resp, err := http.Get(link) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("服务器返回非200状态码: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应内容失败: %v", err) } return body, nil } //go:embed public var EmbedFS embed.FS func main() { config := parseConfig() r := gin.Default() r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{ "status": "ok", "domain": config.Domain, }) }) r.Use(static.Serve("/", static.LocalFile("./public", false))) r.Use(authMiddleware(config)) r.GET("/", func(c *gin.Context) { buf, err := Get(fmt.Sprintf("http://localhost:%s/index.template.html", config.Port)) if err != nil { c.String(500, "读取模板文件失败: %v", err) return } c.Data(200, "text/html; charset=utf-8", buf) }) serverAddr := fmt.Sprintf(":%s", config.Port) log.Printf("启动服务器于 %s:%s\n", "config.Domain", config.Port) if err := r.Run(serverAddr); err != nil { log.Fatalf("启动服务器失败: %v", err) } } ``` ### IP 获取和基础分析功能的实现 在与模型的进一步对话中,我们实现更核心的功能:使用 Golang Gin 框架来获取用户访问时的 IP 信息,并判断请求是否经过了代理服务器。 ![使用模型获得分析 IP 功能的代码](https://attachment.soulteary.com/2024/12/21/get-ip.jpg) 获得这段代码后,我们将它与之前的功能进行整合(新增代码有注释)。现在,我们的服务不仅可以获取用户的 IP 信息,还能够基础地判断请求是否通过代理服务器转发。 除了网页展示外,我们还新增了一个 `/ip` 接口,让用户可以直接通过程序获取纯 IP 信息,提供了更灵活的使用方式。 ```go // ... // IPInfo 存储 IP 相关信息 type IPInfo struct { ClientIP string `json:"client_ip"` ProxyIP string `json:"proxy_ip,omitempty"` IsProxy bool `json:"is_proxy"` ForwardedFor string `json:"forwarded_for,omitempty"` RealIP string `json:"real_ip"` } // 获取并分析 IP 信息的中间件 func IPAnalyzer() gin.HandlerFunc { return func(c *gin.Context) { ipInfo := analyzeIP(c) // 将 IP 信息存储到上下文中 c.Set("ip_info", ipInfo) c.Next() } } // 分析 IP 信息 func analyzeIP(c *gin.Context) IPInfo { var ipInfo IPInfo // 获取客户端 IP ipInfo.ClientIP = c.ClientIP() // 获取 X-Forwarded-For 头信息 forwardedFor := c.GetHeader("X-Forwarded-For") if forwardedFor != "" { ipInfo.ForwardedFor = forwardedFor // X-Forwarded-For 可能包含多个 IP,第一个是原始客户端 IP ips := strings.Split(forwardedFor, ",") if len(ips) > 0 { ipInfo.RealIP = strings.TrimSpace(ips[0]) if len(ips) > 1 { ipInfo.IsProxy = true ipInfo.ProxyIP = strings.TrimSpace(ips[len(ips)-1]) } } } else { ipInfo.RealIP = ipInfo.ClientIP } // 获取 X-Real-IP 头信息 xRealIP := c.GetHeader("X-Real-IP") if xRealIP != "" && xRealIP != ipInfo.RealIP { ipInfo.IsProxy = true ipInfo.ProxyIP = ipInfo.ClientIP ipInfo.RealIP = xRealIP } // 检查是否为私有 IP if isPrivateIP(ipInfo.ClientIP) { ipInfo.IsProxy = true } return ipInfo } // 检查是否为私有 IP 地址 func isPrivateIP(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } // 检查是否为私有 IP 范围 privateIPRanges := []struct { start net.IP end net.IP }{ {net.ParseIP("10.0.0.0"), net.ParseIP("10.255.255.255")}, {net.ParseIP("172.16.0.0"), net.ParseIP("172.31.255.255")}, {net.ParseIP("192.168.0.0"), net.ParseIP("192.168.255.255")}, } for _, r := range privateIPRanges { if bytes.Compare(ip, r.start) >= 0 && bytes.Compare(ip, r.end) <= 0 { return true } } return false } //go:embed public var EmbedFS embed.FS func main() { // ... r.Use(static.Serve("/", static.LocalFile("./public", false))) r.Use(authMiddleware(config)) // 使用IP分析中间件 r.Use(IPAnalyzer()) r.GET("/", func(c *gin.Context) { // 先获取 IP 信息 ipInfo, exists := c.Get("ip_info") if !exists { c.JSON(500, gin.H{"error": "IP info not found"}) return } buf, err := Get(fmt.Sprintf("http://localhost:%s/index.template.html", config.Port)) if err != nil { c.String(500, "读取模板文件失败: %v", err) return } // TODO 将 IP 信息传递给模板 fmt.Println(ipInfo) c.Data(200, "text/html; charset=utf-8", buf) }) // 单独提供一个接口,来获取 IP 信息 r.GET("/ip", func(c *gin.Context) { ipInfo, exists := c.Get("ip_info") if !exists { c.JSON(500, gin.H{"error": "IP info not found"}) return } c.JSON(200, ipInfo) }) serverAddr := fmt.Sprintf(":%s", config.Port) log.Printf("启动服务器于 %s:%s\n", "config.Domain", config.Port) if err := r.Run(serverAddr); err != nil { log.Fatalf("启动服务器失败: %v", err) } r.Run(":8080") } ``` 启动程序后,我们可以通过命令行或者直接在浏览器中访问 `http://localhost:8080/ip` 来测试功能。比如使用 curl 命令: ```bash # curl 127.0.0.1:8080/ip {"client_ip":"127.0.0.1","is_proxy":false,"real_ip":"127.0.0.1"} ``` 看到这个返回结果,说明我们的基础功能已经正常运行了。 接下来,我们先不着急处理模板渲染的部分,而是把注意力放在 IP 信息和数据库对接这个核心模块上。 ### 完成 IP 数据库查询功能 在2020年时,因业务需求我曾使用过高老师的 IP 库(通过阿里云购买),并写过两篇关于如何处理本地数据的高性能方案文章:《[阿里云 IP 地理位置库(淘宝IP库)实践(前篇)](https://soulteary.com/2020/10/30/dockerize-aliyun-geoip-part-1.html)》和《[阿里云 IP 地理位置库(淘宝IP库)实践(后篇)](https://soulteary.com/2020/10/31/dockerize-aliyun-geoip-part-2.html)》。 这次为了开发这个小工具,我向高老师获取了精简版数据和解析文档。由于我只需要像文章开头提到的那样解析基础地理信息,所以我选择 fork 了一个 Go SDK 并进行了简化处理。 这次为了完成这个小工具,和高老师要来了精简版的数据,以及[解析文档](https://www.ipip.net/support/code.html),因为我只想和文章开头一样,解析出基础的地理信息,所以我 [fork 了一个 Go SDK 版本](https://github.com/soulteary/ipdb-go),并做了 “青春版化” 处理。 首先,在项目目录中执行以下命令来下载简化版 SDK: ```bash go get github.com/soulteary/ipdb-go ``` 接下来,我们将在之前的代码基础上添加查询功能,并新增一个 `/ip/:ip` 路由,让用户可以查询指定 IP 的数据。 ```go // ... // 帮助我们对数据库中的内容进行去重 // eg: ["CLOUDFLARE.COM","CLOUDFLARE.COM",""] => ["CLOUDFLARE.COM",""] func removeDuplicates(strSlice []string) []string { // 创建一个 map 用于存储唯一的字符串 encountered := make(map[string]bool) result := []string{} // 遍历切片,将未出现过的字符串添加到结果中 for _, str := range strSlice { if !encountered[str] { encountered[str] = true result = append(result, str) } } return result } //go:embed public var EmbedFS embed.FS func main() { config := parseConfig() // 初始化 IP 数据库 db, err := ipdb.NewCity("./data/ipipfree.ipdb") if err != nil { log.Fatal(err) } // 更新 ipdb 文件后可调用 Reload 方法重新加载内容 // db.Reload("./data/ipipfree.ipdb") r := gin.Default() r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{ "status": "ok", "domain": config.Domain, }) }) r.Use(static.Serve("/", static.LocalFile("./public", false))) r.Use(authMiddleware(config)) r.Use(IPAnalyzer()) r.GET("/", func(c *gin.Context) { ipInfo, exists := c.Get("ip_info") if !exists { c.JSON(500, gin.H{"error": "IP info not found"}) return } buf, err := Get(fmt.Sprintf("http://localhost:%s/index.template.html", config.Port)) if err != nil { c.String(500, "读取模板文件失败: %v", err) return } // TODO 将 IP 信息传递给模板 fmt.Println(ipInfo) c.Data(200, "text/html; charset=utf-8", buf) }) // 获取当前请求方的 IP 地址信息 r.GET("/ip", func(c *gin.Context) { ipInfo, exists := c.Get("ip_info") if !exists { c.JSON(500, gin.H{"error": "IP info not found"}) return } c.JSON(200, ipInfo) }) // 获取指定 IP 地址信息 r.GET("/ip/:ip", func(c *gin.Context) { // 获取 URL 中的 IP 地址 ipaddr := c.Param("ip") fmt.Println("ip", ipaddr) if ipaddr == "" { ipInfo, exists := c.Get("ip_info") if !exists { c.JSON(500, gin.H{"error": "IP info not found"}) return } ipaddr = ipInfo.(IPInfo).RealIP } dbInfo, err := db.Find(ipaddr, "CN") if err != nil { dbInfo = []string{"未找到 IP 地址信息"} } dbInfo = removeDuplicates(dbInfo) c.JSON(200, map[string]any{"ip": ipaddr, "info": dbInfo}) }) serverAddr := fmt.Sprintf(":%s", config.Port) log.Printf("启动服务器于 %s:%s\n", "config.Domain", config.Port) if err := r.Run(serverAddr); err != nil { log.Fatalf("启动服务器失败: %v", err) } r.Run(":8080") } ``` 让我们通过命令行或浏览器来验证服务是否正常运行。我们可以测试几个不同的 IP 地址: 首先测试获取当前请求来源的 IP 信息。 ```bash # curl 127.0.0.1:8080/ip {"client_ip":"127.0.0.1","is_proxy":false,"real_ip":"127.0.0.1"} ``` 然后测试查询特定IP地址。 ```bash # curl 127.0.0.1:8080/ip/123.123.123.123 {"info":["中国","北京"],"ip":"123.123.123.123"} ``` 最后测试一个 CloudFlare 的 IP: ```bash # curl 127.0.0.1:8080/ip/1.1.1.1 {"info":["CLOUDFLARE.COM",""],"ip":"1.1.1.1"} ``` ## 第三步:从静态页面到动态网站,数据与界面的整合 我们已经完成了基础架构的搭建工作,现在要进入最后也是最关键的阶段:将数据层和展示层打通,让整个系统真正运转起来。让我们一步步来实现这个目标。 ### 模版和服务数据联动 第一步,我们需要改造之前的静态模板。我们要把原本写死的数据替换成程序可以动态填充的占位符: ![更新模版,添加占位符内容](https://attachment.soulteary.com/2024/12/21/update-template.jpg) 接下来,我们先实现一个基础版本的IP信息查询功能:当用户访问网站首页时,系统会自动获取访问者的IP地址,并展示相关的IP信息。 ```go // ... func main() { // ... r.GET("/", func(c *gin.Context) { ipInfo, exists := c.Get("ip_info") if !exists { c.JSON(500, gin.H{"error": "IP info not found"}) return } // 查询 IP 地址具体信息 dbInfo, err := db.Find(ipInfo.(IPInfo).RealIP, "CN") if err != nil { dbInfo = []string{"未找到 IP 地址信息"} } // 读取默认模版 template, err := Get(fmt.Sprintf("http://localhost:%s/index.template.html", config.Port)) if err != nil { c.String(500, "读取模板文件失败: %v", err) return } // 更新模版中的 IP 地址 template = bytes.ReplaceAll(template, []byte("%IP_ADDR%"), []byte(ipInfo.(IPInfo).ClientIP)) // 更新模版中的域名 template = bytes.ReplaceAll(template, []byte("%DOMAIN%"), []byte(config.Domain)) // 更新模版中的 IP 地址信息 template = bytes.ReplaceAll(template, []byte("%DATA_1_INFO%"), []byte(strings.Join(removeDuplicates(dbInfo), " "))) c.Data(200, "text/html; charset=utf-8", template) }) // ... } ``` 完成模板更新后,我们需要启动服务来验证功能。使用以下命令启动: ```bash SERVER_DOMAIN=localhost:8080 go run main.go ``` 启动服务后,打开浏览器访问 `localhost:8080`,我们就可以看到如下界面: ![页面数据有了正确的联动](https://attachment.soulteary.com/2024/12/21/base-info-ui.jpg) 从界面可以看到,页面的数据联动功能已经正常工作。不过目前使用的数据库还不支持 IPv6 地址的查询(需要使用商业版本或增加其他数据库),导致部分信息展示不符合预期。没关系,接下来我们就来实现按指定 IP 查询的功能。 ### 后端处理前端用户输入 为了让用户能够与我们的应用进行交互,现在让我们对之前的静态 HTML 模板做一些优化。我们将添加一个表单来处理用户输入的 IP 地址。 首先,在 HTML 模版中添加数据表单: ```bash
``` 在这段代码中: - 使用 `form` 标签创建表单,设置 `action="/"` 将数据提交到根路径 - `method="post"` 指定使用 `POST` 方法提交数据 - 输入框中的 `value="%IP_ADDR%"` 用于回显用户之前输入的 IP 地址 接下来,我们需要在后端添加相应的处理逻辑: ```go // ... // 使用 net 包验证 IP 地址 func isValidIPAddress(ip string) bool { if parsedIP := net.ParseIP(ip); parsedIP != nil { return true } return false } // IPForm 定义表单结构 type IPForm struct { IP string `form:"ip" binding:"required"` } func main() { // ... // 处理 POST 请求,解析表单数据 r.POST("/", func(c *gin.Context) { // 获取请求中的 IP 地址信息 ipInfo, exists := c.Get("ip_info") if !exists { c.JSON(500, gin.H{"error": "IP info not found"}) return } // 默认 IP 地址为空 ip := "" var form IPForm // 使用 ShouldBind 绑定表单数据 if err := c.ShouldBind(&form); err != nil { // 如果绑定失败,使用请求中的 IP 地址 ip = ipInfo.(IPInfo).RealIP } else { // 获取到 IP 地址后的处理逻辑 ip = form.IP // 如果 IP 地址不合法,使用请求中的 IP 地址 if !isValidIPAddress(ip) { ip = ipInfo.(IPInfo).RealIP } } c.Redirect(302, fmt.Sprintf("/ip/%s", ip)) }) // ... } ``` 程序首先会记录发起请求的客户端 IP。然后检查用户通过表单提交的 IP 地址是否正确。如果 IP 地址正确,会自动跳转到类似 `/ip/123.123.123.123` 这样的地址来展示 IP 详细信息。如果提交的 IP 地址无效,则会使用客户端的实际 IP 地址进行跳转。 ### 打造统一的接口,适配多种场景 细心的朋友可能注意到了,前面提到的 `/ip/:ip` 接口原本是为命令行工具设计的,默认返回 JSON 格式数据,而不是网页界面。在 CIP 网站的设计中,浏览器访问和命令行调用使用了不同的接口地址。不过通过一些技巧,我们完全可以让同一个接口同时支持这两种使用场景。 先来将 IP 获取和信息查询,以及渲染部分分别抽象为独立的模块: ```go // ... func main() { //... // 获取客户端 IP 信息 getClientIPInfo := func(c *gin.Context, ipaddr string) (resultIP string, resultDBInfo []string, err error) { // 判断是否有传入 IP 地址 if ipaddr == "" { // 如果没有有效 IP,默认使用发起请求的客户端 IP 信息 ipInfo, exists := c.Get("ip_info") if !exists { return resultIP, resultDBInfo, fmt.Errorf("IP info not found") } ipaddr = ipInfo.(IPInfo).RealIP } dbInfo, err := db.Find(ipaddr, "CN") if err != nil { dbInfo = []string{"未找到 IP 地址信息"} } dbInfo = removeDuplicates(dbInfo) return ipaddr, dbInfo, nil } // 渲染模板 renderTemplate := func(globalTemplate []byte, ipaddr string, dbInfo []string) []byte { template := bytes.ReplaceAll(globalTemplate, []byte("%IP_ADDR%"), []byte(ipaddr)) template = bytes.ReplaceAll(template, []byte("%DOMAIN%"), []byte(config.Domain)) template = bytes.ReplaceAll(template, []byte("%DATA_1_INFO%"), []byte(strings.Join(removeDuplicates(dbInfo), " "))) return template } // 渲染 JSON renderJSON := func(ipaddr string, dbInfo []string) map[string]any { return map[string]any{"ip": ipaddr, "info": dbInfo} } globalTemplate := []byte{} r.GET("/", func(c *gin.Context) { // 预缓存模板文件 if len(globalTemplate) == 0 { globalTemplate, err = Get(fmt.Sprintf("http://localhost:%s/index.template.html", config.Port)) if err != nil { log.Fatalf("读取模板文件失败: %v\n", err) return } } // 获取客户端 IP 信息,首页不需要传入 IP 地址 ipAddr, dbInfo, err := getClientIPInfo(c, "") if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } // 返回渲染后的 HTML 内容 c.Data(200, "text/html; charset=utf-8", renderTemplate(globalTemplate, ipAddr, dbInfo)) }) r.GET("/ip/:ip", func(c *gin.Context) { ip := c.Param("ip") // 获取指定 IP 地址的信息 ipAddr, dbInfo, err := getClientIPInfo(c, ip) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, renderJSON(ipAddr, dbInfo)) }) // ... } ``` 接下来,我们要实现一个功能:自动识别访问请求是来自类似 `curl` 的命令行工具,还是来自浏览器。 ```go // ... // 判断请求发起方是否为“下载工具” func IsDownloadTool(userAgent string) bool { // 转换为小写以便不区分大小写比较 ua := strings.ToLower(userAgent) // 常见下载工具的特征字符串 downloadTools := []string{ "curl", "wget", "aria2", "python-requests", "axios", "got", "postman", } for _, tool := range downloadTools { if strings.Contains(ua, tool) { return true } } return false } func main() { // ... r.GET("/", func(c *gin.Context) { if len(globalTemplate) == 0 { globalTemplate, err = Get(fmt.Sprintf("http://localhost:%s/index.template.html", config.Port)) if err != nil { log.Fatalf("读取模板文件失败: %v\n", err) return } } ipAddr, dbInfo, err := getClientIPInfo(c, "") if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } // 获取请求头中的 User-Agent 信息 userAgent := c.GetHeader("User-Agent") // 使用下载工具访问时返回 JSON 格式 if IsDownloadTool(userAgent) { c.JSON(200, renderJSON(ipAddr, dbInfo)) } else { c.Data(200, "text/html; charset=utf-8", renderTemplate(globalTemplate, ipAddr, dbInfo)) } }) r.GET("/ip/:ip", func(c *gin.Context) { ip := c.Param("ip") ipAddr, dbInfo, err := getClientIPInfo(c, ip) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } // 获取请求头中的 User-Agent 信息 userAgent := c.GetHeader("User-Agent") // 使用下载工具访问时返回 JSON 格式 if IsDownloadTool(userAgent) { c.JSON(200, renderJSON(ipAddr, dbInfo)) } else { c.Data(200, "text/html; charset=utf-8", renderTemplate(globalTemplate, ipAddr, dbInfo)) } }) // ... } ``` 经过上面的改进,不管是访问根路径 `/` 还是 `/ip/:ip` 接口,程序都能根据访问方式自动返回合适的格式。浏览器访问会看到格式化的页面,命令行工具访问则获得纯文本结果。这样一来,我们其实可以考虑是否要保留之前专门为命令行工具设计的 `/ip` 接口,因为现在 `/` 已经能够处理这两种场景了。当然,如果特别在意性能,保留专门的接口也是一种选择。 ![支持查询指定 IP 的信息](https://attachment.soulteary.com/2024/12/21/spec-ip-ui.jpg) 和之前一样,重启程序后,我们可以打开浏览器做个简单测试。随便输入一个 IP 地址进行查询,你会发现一切都在按照预期正常运行。 ## 最后 到这里,我们已经实现了这个应用的核心功能。在下一篇文章中,我们将继续探讨本文中的一些遗留问题,看看如何借助 AI 的力量来帮助我们更快地完成应用开发。 --EOF