Skip to content

短剧出海怎么按 IP 判断用户国家?内容库、币种、合规分级的 Go 网关实现

做短剧出海的团队第一年基本都会撞上同一个坑:内容不是想给谁看就给谁看。一部剧的授权范围可能是「北美 + 东南亚」,另一部剧在某个地区因为审核尺度不能上;订阅价格和币种跟着地区走;连广告素材都得按国家换。这些判断全指向一个问题——这个用户到底是哪国的

客户端上报的位置靠不住:系统语言可以改,App 商店区域可以换,模拟定位是安卓模拟器里的常规操作。于是出口 IP 成了第一层更硬的信号——它不精确,但改起来成本高得多。

这篇用 Go 写一遍网关侧的国别判定:从请求头取真实 IP、查归属地、映射内容库和币种,带上缓存、超时和降级。重点不在「怎么发 HTTP 请求」,而在于这条链路每一个失败分支都不该让用户看不了视频

一、出海团队为什么绕不开国别判断

具体到业务动作,国别判定会出现在五个地方:

  1. 内容库分级。 授权区域、地区审核尺度不同,同一部剧在不同国家可能一部能上、一部要替换结尾。
  2. 定价与币种。 同一集的解锁价格按地区定价,展示币种也跟着变(印尼盾和美元的同价标签完全是两个概念)。
  3. 合规与风控。 部分地区对内容题材有硬性限制,部分地区出现异常集中的新注册要观察(机房 IP、批量设备)。
  4. CDN 与投流。 首屏视频要就近拉流,广告归因也要按国家分组统计。
  5. 客服与运营。 客诉按地区分给对应语种的客服,运营看板按国家看留存。

这五件事的共同输入只有一个:用户的国别。给它一个可靠的默认值,比后面每件事各自瞎猜要省事得多。

二、方案设计

判断顺序按「可信度」从高到低排,IP 只是其中的一层:

用户手动选择的国家(做了就生效,写进账号)
  → 支付/订阅地区(有过付费行为,可信度很高)
    → 出口 IP 归属国家(本次请求的真实网络位置)
      → Accept-Language / App 商店区域(弱信号)
        → 站点默认地区(兜底,保证一定能出内容)

工程上有几个决定成败的细节:

海外 IP 只取国家,不要取城市。 IP9 对国内城市级准确率很高(官方口径中国城市 99.2%、运营商 99.4%),但海外 IP 能稳定拿到的通常只是国家,prov/city 常常是空的。代码里必须容忍空字段,别拿海外 IP 去做城市级逻辑。

缓存是这套方案能不能跑起来的前提。 免费版 60 次/分钟,而一个短剧 App 的日活请求量远在这个数字之上。好在「IP → 国家」的映射变化极慢,本地缓存 TTL 设 12 小时问题不大;同一 IP 的并发请求还要合并成一次真实查询,否则热剧首播瞬间就会撞限速。

超时必须短,降级必须有。 500ms 拿不到结果就走兜底地区。归属地服务抖动导致全站白屏,比「个别用户看到默认地区内容」严重得多。

country_code 不要硬编码进内容逻辑。 用一张映射表把国家映射到内容库、币种、语种,运营要调的时候改配置,不用发版。

三、Go 实现

下面是一个可运行的网关示例,用标准库实现,包含 TTL 缓存、并发合并(singleflight 手写版)、超时和降级。

go
package main

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

const apiEndpoint = "https://ip9.com.cn/get"

// ---------- 接口返回结构(只取用得到的字段) ----------

type geoData struct {
	IP          string `json:"ip"`
	Country     string `json:"country"`
	CountryCode string `json:"country_code"`
	Prov        string `json:"prov"`
	City        string `json:"city"`
	ISP         string `json:"isp"`
	BigArea     string `json:"big_area"`
}

type apiResp struct {
	Ret  int      `json:"ret"`
	Data *geoData `json:"data"` // ret != 200 时可能为空数组,用指针接
}

// ---------- 国家 → 内容库 / 币种 ----------
// 生产环境建议放配置中心或数据库,这里用内存表做示例

type catalog struct {
	Name     string
	Currency string
	Lang     string
}

var countryCatalog = map[string]catalog{
	"us": {"na-catalog", "USD", "en"},
	"ca": {"na-catalog", "CAD", "en"},
	"id": {"sea-catalog", "IDR", "id"},
	"th": {"sea-catalog", "THB", "th"},
	"vn": {"sea-catalog", "VND", "vi"},
	"br": {"latam-catalog", "BRL", "pt"},
	"cn": {"cn-catalog", "CNY", "zh"},
}

var defaultCatalog = catalog{"global-catalog", "USD", "en"}
var restrictedCountries = map[string]bool{"ru": true}

func catalogFor(cc string) (catalog, error) {
	if restrictedCountries[cc] {
		return catalog{}, fmt.Errorf("地区 %s 暂不提供内容", cc)
	}
	if c, ok := countryCatalog[cc]; ok {
		return c, nil
	}
	return defaultCatalog, nil
}

// ---------- 带缓存 + 并发合并的归属地查询 ----------

type cacheEntry struct {
	data *geoData
	exp  time.Time
}

type flight struct {
	done chan struct{}
	data *geoData
}

type Locator struct {
	client   *http.Client
	ttl      time.Duration
	mu       sync.Mutex
	cache    map[string]cacheEntry
	inflight map[string]*flight
}

func NewLocator() *Locator {
	return &Locator{
		client:   &http.Client{Timeout: 500 * time.Millisecond}, // 首屏不能等
		ttl:      12 * time.Hour,
		cache:    make(map[string]cacheEntry),
		inflight: make(map[string]*flight),
	}
}

// Country 返回小写国家代码;任何异常都返回错误,由调用方降级。
func (l *Locator) Country(ctx context.Context, ip string) (string, error) {
	l.mu.Lock()
	if e, ok := l.cache[ip]; ok && e.exp.After(time.Now()) {
		l.mu.Unlock()
		return strings.ToLower(e.data.CountryCode), nil
	}
	if f, ok := l.inflight[ip]; ok { // 同 IP 已有请求在路上,等它的结果
		l.mu.Unlock()
		<-f.done
		if f.data == nil {
			return "", errors.New("inflight lookup failed")
		}
		return strings.ToLower(f.data.CountryCode), nil
	}
	f := &flight{done: make(chan struct{})}
	l.inflight[ip] = f
	l.mu.Unlock()

	data, err := l.fetch(ctx, ip)

	l.mu.Lock()
	delete(l.inflight, ip)
	if err == nil && data != nil {
		l.cache[ip] = cacheEntry{data: data, exp: time.Now().Add(l.ttl)}
	}
	l.mu.Unlock()

	f.data = data
	close(f.done) // 等待者被唤醒,然后各自读 f.data

	if err != nil || data == nil {
		return "", errors.New("lookup failed")
	}
	return strings.ToLower(data.CountryCode), nil
}

func (l *Locator) fetch(ctx context.Context, ip string) (*geoData, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		apiEndpoint+"?ip="+url.QueryEscape(ip), nil)
	if err != nil {
		return nil, err
	}
	resp, err := l.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var body apiResp
	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
		return nil, err
	}
	if body.Ret != 200 || body.Data == nil { // 400 非法 IP / 429 限速
		return nil, fmt.Errorf("api ret=%d", body.Ret)
	}
	return body.Data, nil
}

// ---------- HTTP 网关 ----------

type Gateway struct {
	locator *Locator
}

// 从请求里取真实客户端 IP:优先 X-Forwarded-For 最后一段中的首个公网地址
func (g *Gateway) clientIP(r *http.Request) string {
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		for _, part := range strings.Split(xff, ",") {
			ip := strings.TrimSpace(part)
			if net.ParseIP(ip) != nil && !isPrivate(ip) {
				return ip
			}
		}
	}
	if xr := r.Header.Get("X-Real-IP"); xr != "" {
		if net.ParseIP(xr) != nil {
			return xr
		}
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

func isPrivate(ip string) bool {
	p := net.ParseIP(ip)
	if p == nil {
		return false
	}
	private := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fc00::/7", "::1/128"}
	for _, cidr := range private {
		_, block, _ := net.ParseCIDR(cidr)
		if block.Contains(p) {
			return true
		}
	}
	return false
}

type playResp struct {
	Country  string `json:"country"`
	Catalog  string `json:"catalog"`
	Currency string `json:"currency"`
	Lang     string `json:"lang"`
	Source   string `json:"source"` // ip / default,方便排查
}

func (g *Gateway) Play(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
	defer cancel()

	// 用户手动选择的地区优先(示例:取 query,实际应读账号字段)
	cc := strings.ToLower(r.URL.Query().Get("country"))
	source := "user"
	if cc == "" {
		ip := g.clientIP(r)
		got, err := g.locator.Country(ctx, ip)
		if err != nil || got == "" {
			cc, source = "us", "default" // 降级:绝不因为查归属地失败而拒绝播放
		} else {
			cc, source = got, "ip"
		}
	}

	cat, err := catalogFor(cc)
	if err != nil {
		http.Error(w, `{"error":"region_not_supported"}`, http.StatusForbidden)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	json.NewEncoder(w).Encode(playResp{
		Country:  cc,
		Catalog:  cat.Name,
		Currency: cat.Currency,
		Lang:     cat.Lang,
		Source:   source,
	})
}

func main() {
	gw := &Gateway{locator: NewLocator()}
	http.HandleFunc("/api/play-config", gw.Play)
	log.Println("short-drama gateway listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

跑起来之后可以这样验证,8.8.8.8 是美国的地址、240e:3b1:2f00:1a00::1 是国内的 IPv6:

bash
curl -H 'X-Forwarded-For: 8.8.8.8' 'http://127.0.0.1:8080/api/play-config'
# {"country":"us","catalog":"na-catalog","currency":"USD","lang":"en","source":"ip"}

curl -H 'X-Forwarded-For: 240e:3b1:2f00:1a00::1' 'http://127.0.0.1:8080/api/play-config'
# {"country":"cn","catalog":"cn-catalog","currency":"CNY","lang":"zh","source":"ip"}

curl 'http://127.0.0.1:8080/api/play-config?country=th'   # 用户手动选的地区优先
# {"country":"th","catalog":"sea-catalog","currency":"THB","lang":"th","source":"user"}

建议把 source 字段打进监控:default 的占比突然抬头,说明归属地接口或者本机出网出了问题;ip 占比高但某个国家的付费转化异常,通常要去查是不是代理流量集中。

四、上线前后要注意的几件事

IPv6 用户比你想象的多。 移动网络大量走 IPv6,海外运营商更明显。取 IP 的逻辑不能只 net.ParseIP 判 IPv4 就完事,接口本身是 IPv4/IPv6 都支持的,返回里 ip 字段会原样回显。

海外 IP 字段缺失是常态,不是 bug。8.8.8.8 这类海外地址,provcitybig_area 常常是空字符串,只有国家和运营商有意义(比如会返回 Google Cloud 这类 ISP)。别写「取不到城市就报错」的逻辑。

代理和 VPN 会被判到代理所在国。 这是 IP 判断的天然局限,解法不是提高准确率,而是给用户留出口——账号里允许手动切换地区,切换后写进账号,优先级高于 IP。站内《IP 归属地能改吗》那篇讲过这类情况的实际表现。

缓存分层,别只放进程内存。 单机内存缓存上线初期够用;实例数上去之后,同一 IP 会在每台机器上各查一次,免费额度很快见底。把 ip → country 换成 Redis 共享缓存,TTL 12 小时,命中率通常在 95% 以上。再往上(分钟级十万次以上),就该考虑 VIP 版(18 万次/分钟)或者私有化部署了。

单集解锁价这类敏感场景,IP 只做校验不做定价源。 定价最终要跟支付方式、订阅状态、渠道活动一起算,IP 国家只是校验层:发现「账号注册地在印尼、出口 IP 长期在美元区」这类组合,进风控观察名单,而不是直接改价。

合规上只留国家。 出海业务受各地隐私法规约束,IP 作为个人信息,采集范围收敛到「国家 + 是否机房 IP」这种聚合粒度最稳妥,原始 IP 不留长期存储。做内容分级时,把判定结果(内容库 ID)落库就够了,不必留原始 IP。

别用 IP 直接封禁用户。 归属地数据有误判率,把它当成「默认识别」而不是「判决书」。国内那篇《IP 风控总误伤正常用户》里提过的共享出口问题,在出海场景一样存在——一个海外办公网的出口 IP 后面可能是几百个正常用户。

总结

出海内容产品的国别判定,正确做法不是找一个 100% 准的信源,而是搭一条有优先级、有缓存、有降级的链路:用户手选 > 付费地区 > 出口 IP > 语言头 > 默认地区,中间每一步失败都往下一级走。落地顺序建议先只做一件小事——在播放配置接口里返回 countrycatalog 两个字段,跑两周看真实分布,再决定要不要接定价和合规分级。

查归属地这一步用 IP9 的免费接口就够:https://ip9.com.cn/get?ip=<IP>,不传参数时返回调用方自己的归属地,IPv4/IPv6 都支持,返回国家、国家代码、省市、邮编、区号、运营商、大区(big_area)、城市中心经纬度等字段,免注册、无需鉴权,免费版 60 次/分钟(批量或者高并发场景记得先做本地缓存和并发合并)。官网:https://www.ip9.com.cn