Skip to content

Go 怎么给请求打上 IP 归属地标签?一个中间件,地区灰度和风控规则都能用

新功能要按地区灰度、活动要限地区参加、每条请求日志想带上访客省市——三个需求找上来,别让每个业务各查一遍 IP 归属地:代码重复不说,免费接口的配额几下就被打满。正确姿势是在入口写一个中间件,查一次、缓存住、把归属地塞进请求上下文,下游谁要用谁取。

一、典型场景:三个需求盯上同一个数据

  1. 地区灰度放量:新版本不敢全量,先放广东、浙江两个省试点。判断依据就是请求 IP 的省份。
  2. 风控规则:注册接口想对某些地区收紧(比如机房 IP 集中的地区加验证),或者活动只允许指定省份参加。
  3. 日志审计:每条访问日志带上 prov/city/isp,事后按地域排查问题不用再翻接口。

这三件事要是各写各的,等于同一个 IP 被查三遍——在线接口免费版限速 60 次/分钟/IP,服务器出口就一个公网 IP,全站共享这个额度,几下就 429。所以归属地查询必须收敛到一层,并且只在真正需要的时候才发查询。

二、方案设计:一个中间件,三件事

入口中间件做三步:

  1. 取真实客户端 IP:服务挂在 Nginx/网关后面时,RemoteAddr 拿到的是反代地址,必须从 X-Forwarded-For 里取。这里有个安全细节:从右往左取第一个不在"可信代理名单"里的 IP。直接取最左会被用户伪造——他随便塞个 X-Forwarded-For: 1.2.3.4 就能伪装成任何地区,灰度白名单瞬间失效。
  2. 查归属地 + 缓存:调 https://ip9.com.cn/get?ip=<ip>,返回 JSON 含国家/省/市/运营商等字段;内存缓存 24 小时,同一 IP 一天最多查一次。免费版免注册,IPv4/IPv6 都支持。
  3. 注入上下文:把归属地结构体挂到请求 context 上,handler 里 GeoFrom(ctx) 直接读,规则判断和业务代码解耦。

关键设计是懒查询:中间件先看当前请求路径有没有注册过属地规则,没有就直接放行,一次接口都不调。这样免费额度只花在刀刃上。

三、Go 实现(零第三方依赖,标准库一把梭)

go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"
)

type Geo struct {
	IP      string
	Country string
	Prov    string
	City    string
	ISP     string
}

type geoKey struct{}

// ---------- 1. 取真实 IP:从右往左跳过可信代理 ----------
var trustedProxies = map[string]bool{
	"127.0.0.1": true, // 这里填你自己 Nginx/网关的 IP
	"::1":       true,
}

func realClientIP(r *http.Request) string {
	xff := r.Header.Get("X-Forwarded-For")
	if xff != "" {
		parts := strings.Split(xff, ",")
		for i := len(parts) - 1; i >= 0; i-- {
			ip := strings.TrimSpace(parts[i])
			if !trustedProxies[ip] {
				return ip
			}
		}
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err == nil {
		return host
	}
	return r.RemoteAddr
}

// ---------- 2. 查归属地(带 24h 缓存) ----------
type cacheItem struct {
	geo      Geo
	expireAt time.Time
}

var (
	cacheMu sync.RWMutex
	cache   = map[string]cacheItem{}
	client  = &http.Client{Timeout: 2 * time.Second}
)

func lookupGeo(ip string) Geo {
	cacheMu.RLock()
	item, ok := cache[ip]
	cacheMu.RUnlock()
	if ok && time.Now().Before(item.expireAt) {
		return item.geo
	}

	geo := Geo{IP: ip}
	var resp struct {
		Ret  int `json:"ret"`
		Data struct {
			Country string `json:"country"`
			Prov    string `json:"prov"`
			City    string `json:"city"`
			ISP     string `json:"isp"`
		} `json:"data"`
	}
	r, err := client.Get("https://ip9.com.cn/get?ip=" + ip)
	if err == nil {
		err = json.NewDecoder(r.Body).Decode(&resp)
		r.Body.Close()
	}
	if err == nil && resp.Ret == 200 {
		geo.Country, geo.Prov, geo.City, geo.ISP =
			resp.Data.Country, resp.Data.Prov, resp.Data.City, resp.Data.ISP
	} // 查失败/被限流(429)就返回空 Geo,调用方自行降级

	cacheMu.Lock()
	cache[ip] = cacheItem{geo: geo, expireAt: time.Now().Add(24 * time.Hour)}
	cacheMu.Unlock()
	return geo
}

func GeoFrom(ctx context.Context) Geo {
	if g, ok := ctx.Value(geoKey{}).(Geo); ok {
		return g
	}
	return Geo{}
}

// ---------- 3. 中间件:带规则的路径才查询 ----------
type geoRule struct {
	pathPrefix string
	handler    http.Handler
}

func WithGeo(next http.Handler, needGeo func(*http.Request) bool) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if needGeo == nil || needGeo(r) {
			geo := lookupGeo(realClientIP(r))
			r = r.WithContext(context.WithValue(r.Context(), geoKey{}, geo))
		}
		next.ServeHTTP(w, r)
	})
}

// ---------- 使用示例:地区灰度 + 日志 ----------
func main() {
	// 只有 /api/v2/ 前缀的请求才需要属地(懒查询,省免费额度)
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v2/feature", func(w http.ResponseWriter, r *http.Request) {
		geo := GeoFrom(r.Context())
		// 地区灰度:新功能先放广东(guangdong)、浙江(zhejiang)
		if geo.Prov != "广东" && geo.Prov != "浙江" {
			fmt.Fprint(w, `{"code":403,"msg":"灰度中,暂未开放"}`)
			return
		}
		fmt.Fprint(w, `{"code":0,"msg":"新功能上线"}`)
	})
	mux.HandleFunc("/api/v1/old", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, `{"code":0,"msg":"老接口"}`)
	})

	handler := WithGeo(mux, func(r *http.Request) bool {
		return strings.HasPrefix(r.URL.Path, "/api/v2/")
	})
	http.ListenAndServe(":8080", handler)
}

跑起来后模拟两拨请求:

bash
# 模拟广东用户(中间件查到 广东 后放行)
curl -H 'X-Forwarded-For: 1.2.3.4' http://127.0.0.1:8080/api/v2/feature
# 假设 1.2.3.4 归属地非粤浙 → 返回 403 灰度中

中间件返回的 Geo 同时在 handler 里被读到,往日志里打 geo.Prov 就是地域审计。想换 Gin?把 WithGeo 改成 gin.HandlerFuncc.Set("geo", geo) 即可,判断逻辑一行不用动。

四、落地注意事项

  • 信任链是安全底线:灰度/风控规则依赖省份,就必须保证 IP 取不错。Nginx 层要配 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for(把直连 IP 追加到末尾),Go 侧只信任名单内的反代。直接读最左的写法等于给攻击者发了一张"任意地区"通行证。
  • 缓存 TTL 别太短:运营商 IP 段是按月级别变的,24 小时缓存完全够,还能把免费接口的调用压到"每个 IP 每天 1 次"。
  • 失败必须放行:接口超时、429 限流时返回空 Geo,规则判断要按"查不到=放行"设计,否则接口一抖整个灰度区全 403,事故比 bug 大。高危操作(如支付)想 Fail-Closed,单独加开关。
  • 额度估算:免费版 60 次/分钟是"每 IP 每分钟",服务器出口就一个 IP,等于全站每分钟 60 次。靠懒查询 + 24h 缓存,日常流量没问题;真有几万新增 IP 要打标,再上付费档或换离线库方案。

总结

把归属地查询收敛成一层中间件:真实 IP 取对、缓存挂上、按需查询、失败放行,灰度、风控、日志三处复用同一个 Geo 标签。接口用 https://ip9.com.cn/get?ip=<ip> 免费版就行,免注册、支持 IPv4/IPv6,返回省市区和运营商字段够上述规则用了。官网:https://www.ip9.com.cn