Appearance
短剧出海怎么按 IP 判断用户国家?内容库、币种、合规分级的 Go 网关实现
做短剧出海的团队第一年基本都会撞上同一个坑:内容不是想给谁看就给谁看。一部剧的授权范围可能是「北美 + 东南亚」,另一部剧在某个地区因为审核尺度不能上;订阅价格和币种跟着地区走;连广告素材都得按国家换。这些判断全指向一个问题——这个用户到底是哪国的。
客户端上报的位置靠不住:系统语言可以改,App 商店区域可以换,模拟定位是安卓模拟器里的常规操作。于是出口 IP 成了第一层更硬的信号——它不精确,但改起来成本高得多。
这篇用 Go 写一遍网关侧的国别判定:从请求头取真实 IP、查归属地、映射内容库和币种,带上缓存、超时和降级。重点不在「怎么发 HTTP 请求」,而在于这条链路每一个失败分支都不该让用户看不了视频。
一、出海团队为什么绕不开国别判断
具体到业务动作,国别判定会出现在五个地方:
- 内容库分级。 授权区域、地区审核尺度不同,同一部剧在不同国家可能一部能上、一部要替换结尾。
- 定价与币种。 同一集的解锁价格按地区定价,展示币种也跟着变(印尼盾和美元的同价标签完全是两个概念)。
- 合规与风控。 部分地区对内容题材有硬性限制,部分地区出现异常集中的新注册要观察(机房 IP、批量设备)。
- CDN 与投流。 首屏视频要就近拉流,广告归因也要按国家分组统计。
- 客服与运营。 客诉按地区分给对应语种的客服,运营看板按国家看留存。
这五件事的共同输入只有一个:用户的国别。给它一个可靠的默认值,比后面每件事各自瞎猜要省事得多。
二、方案设计
判断顺序按「可信度」从高到低排,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 这类海外地址,prov、city、big_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 > 语言头 > 默认地区,中间每一步失败都往下一级走。落地顺序建议先只做一件小事——在播放配置接口里返回 country 和 catalog 两个字段,跑两周看真实分布,再决定要不要接定价和合规分级。
查归属地这一步用 IP9 的免费接口就够:https://ip9.com.cn/get?ip=<IP>,不传参数时返回调用方自己的归属地,IPv4/IPv6 都支持,返回国家、国家代码、省市、邮编、区号、运营商、大区(big_area)、城市中心经纬度等字段,免注册、无需鉴权,免费版 60 次/分钟(批量或者高并发场景记得先做本地缓存和并发合并)。官网:https://www.ip9.com.cn