Appearance
直播账号「人在深圳、开播在东北」怎么查?用 IP 属地识别异地开播和代播(Go 实现)
一个主播的粉丝群里吵起来了:她主页写着「深圳宝妈,每天晚八点播」,声音口音却是东北的,评论区还贴了「行程码」。运营去后台翻推流日志,发现这个账号最近七天用了三个不同省份的出口 IP。账号是谁在播、在哪播,推流网关的日志里其实写得清清楚楚,只是没人把 IP 翻译成地名而已。
一、直播平台为什么盯开播属地
三类需求叠在一起:
- 平台合规与账号安全:账号租借、代播、工作室批量开号(一个房间几十个「新人主播」),这些行为在属地维度上会留下痕迹——多个账号共用同一个出口 IP,或者同一个账号的登录地和开播地长期不一致。
- 公会管理:公会签了二十个主播,实际开播的可能只有三个场地,属地数据能看出「谁在替谁播」。
- 主播属地展示:主流平台的主页和直播间都会展示属地,规则基本是按出口 IP 推断,多数到省级。用户看得到,就意味着平台必须能解释它怎么来的。
二、IP 属地能证明什么,不能证明什么
先把边界立住,不然规则一上线就误伤:
- 它是出口网络的属地,不是人站在哪。家里宽带、公司专线、手机流量三种出口,属地可能三个答案;挂了代理或加速器,属地直接跟着节点走。
- 精度是城市级,接口返回的经纬度是城市中心点,拿来做「精确到街道」的判断是错的。
- 动态 IP 会在同一城市换段,同省范围的小幅变化不算异常,别一看到 IP 变了就告警。
- 单看属地证明不了作弊。出差的、回老家开播的、用小机房做推流中转的,都会落在「异常」区间里。属地只出线索,人工核验和后续问询才是处置依据。
三、Go 实现:一次跑出两条线索
下面这个单文件程序吃的是「开播事件」(时间、账号、IP、事件类型,生产环境从推流网关日志按行取),输出属地明细,顺带跑两条规则:同一账号属地跳变、多个账号共用同一出口 IP。
go
// live_ip_audit.go —— 直播开播属地审计:账号租借 / 异地开播 / 多号共出口
// 用法: go run live_ip_audit.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const apiBase = "https://ip9.com.cn/get"
// 示例:开播事件(时间, 账号, 出口IP, 事件类型)。生产环境从推流网关日志里按行取。
var sample = []string{
"2026-10-06 20:01,live_1001,111.18.31.7,start_push",
"2026-10-06 20:03,live_1001,120.79.4.66,login",
"2026-10-05 21:10,live_1001,117.136.52.18,start_push",
"2026-10-06 20:05,live_1002,120.79.4.66,start_push",
"2026-10-06 20:07,live_1003,120.79.4.66,start_push",
"2026-10-06 20:09,live_1004,120.79.4.66,start_push",
"2026-10-06 21:00,live_2001,203.0.113.9,start_push",
}
type rec struct {
Time, Acct, IP, Event string
Region string // "省 市" 或 "国家"
}
type geoResp struct {
Ret int `json:"ret"`
Data struct {
Country string `json:"country"`
Prov string `json:"prov"`
City string `json:"city"`
ISP string `json:"isp"`
IPType string `json:"ip_type"` // VIP 版字段:ISP/BUS/IDC
} `json:"data"`
}
var (
mu sync.Mutex
cache = map[string]geoResp{}
last = time.Now().Add(-time.Second) // 限速游标:免费版 60 次/分钟
)
func region(ip string) geoResp {
mu.Lock()
if g, ok := cache[ip]; ok {
mu.Unlock()
return g
}
// 免费版 60 次/分钟 → 每次至少间隔 1.05 秒,宁慢勿触发 429
if wait := 1050*time.Millisecond - time.Since(last); wait > 0 {
time.Sleep(wait)
}
last = time.Now()
mu.Unlock()
var g geoResp
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(apiBase + "?ip=" + url.QueryEscape(ip))
if err != nil {
return g
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&g); err != nil || g.Ret != 200 {
return geoResp{}
}
mu.Lock()
cache[ip] = g
mu.Unlock()
return g
}
func label(g geoResp) string {
switch {
case g.Ret != 200:
return "未知"
case g.Data.City != "":
return g.Data.Prov + " " + g.Data.City
case g.Data.Prov != "":
return g.Data.Prov
default:
return g.Data.Country
}
}
func main() {
var rows []rec
for _, line := range sample {
p := strings.Split(line, ",")
if len(p) != 4 {
continue
}
g := region(p[2])
rows = append(rows, rec{p[0], p[1], p[2], p[3], label(g)})
}
fmt.Println("== 开播属地明细 ==")
for _, r := range rows {
fmt.Printf("%s %-9s %-15s %-12s %s\n", r.Time, r.Acct, r.IP, r.Event, r.Region)
}
// 线索一:同一账号开播属地跨省跳变(含登录地 ≠ 开播地)
fmt.Println("\n== 线索一:账号属地跳变 ==")
seen := map[string]map[string]bool{}
for _, r := range rows {
if seen[r.Acct] == nil {
seen[r.Acct] = map[string]bool{}
}
seen[r.Acct][r.Region] = true
}
for acct, set := range seen {
if len(set) > 1 {
list := make([]string, 0, len(set))
for k := range set {
list = append(list, k)
}
fmt.Printf(" %-9s 出现 %d 个属地: %s\n", acct, len(set), strings.Join(list, " / "))
}
}
// 线索二:多个账号共用同一出口 IP(代播 / 租号 / 工作室)
fmt.Println("\n== 线索二:出口 IP 聚合 ==")
byIP := map[string][]string{}
for _, r := range rows {
if r.Event == "start_push" {
byIP[r.IP] = append(byIP[r.IP], r.Acct)
}
}
for ip, accts := range byIP {
if len(accts) >= 3 {
fmt.Printf(" %-15s %d 个账号同 IP 开播: %s\n", ip, len(accts), strings.Join(accts, ","))
}
}
}go run live_ip_audit.go 的真实输出(示例 IP 是真实可查的):
== 开播属地明细 ==
2026-10-06 20:01 live_1001 111.18.31.7 start_push 陕西 铜川
2026-10-06 20:03 live_1001 120.79.4.66 login 广东 深圳
2026-10-05 21:10 live_1001 117.136.52.18 start_push 湖北 武汉
2026-10-06 20:05 live_1002 120.79.4.66 start_push 广东 深圳
2026-10-06 20:07 live_1003 120.79.4.66 start_push 广东 深圳
2026-10-06 20:09 live_1004 120.79.4.66 start_push 广东 深圳
2026-10-06 21:00 live_2001 203.0.113.9 start_push 保留
== 线索一:账号属地跳变 ==
live_1001 出现 3 个属地: 湖北 武汉 / 陕西 铜川 / 广东 深圳
== 线索二:出口 IP 聚合 ==
120.79.4.66 3 个账号同 IP 开播: live_1002,live_1003,live_1004代码里三个细节值得抄:
last这个限速游标是整个程序唯一的节流阀,免费版 60 次/分钟,串行 1.05 秒一次最稳;真上生产要审计十万条日志,就该上 VIP 版(18 万次/分钟)或者本地缓存落库,只查没见过的 IP。cache加sync.Mutex:IP 重复率很高——一个 wifi 下十个账号、一个机房几百个主播,缓存能省掉大部分请求。- 超时 3 秒、解析失败一律返回空结构体,
label()里统一降级成「未知」,审计程序最忌讳因为接口抖一下就把 200 个主播标成异常。
还想再准一层,可以看 ip_type:出口是 IDC(机房)的账号要格外留意,正常主播用家庭宽带(ISP),批量养号的工作室往往走机房或代理池。这个字段是 VIP 版提供的,免费版没有。
四、阈值怎么定,误伤怎么兜
「几个属地算异常」没有标准答案,但有几个坑必须绕开:
- 同省变化不算。家庭宽带动态 IP 每天换段很正常,只要还是「广东 广州 / 广东 佛山」,别当跳变处理。建议规则写成「同一账号 7 天内出现 ≥3 个不同省份的开播属地」再进人工池。
- 共享出口要豁免。公司、园区、校园网、直播基地都是一个出口几百号人,同一 IP 上挂十几个账号不稀奇——所以出口 IP 聚合的结果要跟「该 IP 是否出现在白名单场地」比对,再定风险等级。
- 别忘了解释成本。真出差的主播被降权,客服电话会打爆。规则命中只做「标记 + 排队人工核验」,不直接断流、不直接扣分。
- 日志本身要规范:开播事件记一次、登录事件记一次,都带 IP 和端口字段;日志里的 IP 属于个人信息,按合规要求设留存期限、做脱敏,只保留聚合指标。
总结
推流网关的每行日志里都躺着一个 IP,把它翻译成地名,账号租借、代播、工作室批量开播这些靠人工盯不过来的问题就变成了跑批任务。几百行的 Go 程序一晚上就能把一周的数据过一遍,先出线索清单,再人工核。查询用 https://ip9.com.cn/get?ip=<ip> 就行,免费版不用注册、支持 IPv6,主要是限速 60 次/分钟,批量跑的时候限速和缓存别省;要区分家庭宽带和机房出口(ip_type),或者要区县粒度,再看官网 https://www.ip9.com.cn 的 VIP 说明。