Files
tamabox/internal/security/geoip/geoip.go
T
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
2026-10-30 16:40:44 +08:00

637 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Package geoip 把 IP 解析成地理位置(国家 / 省 / 市 / 运营商 / 坐标)。
//
// 与 internal/security/ipinfo 的分工:ipinfo 用离线指纹判断「这个 IP 是不是
// 云厂商 / CDN / WAF 的出口」,对家宽、企业宽带、移动网络一律返回
// 「未识别(普通网络)」—— 它回答的是「是不是代理」,不是「在哪儿」。
// 肉鸡恰恰全是后一类地址,所以安全汇总必须另有一套真正的 IP 地理位置库。
//
// 数据源策略(多源回退):
// - ip-api.com:批量接口一次 100 个 IP,全球覆盖好,免费版无需 key。
// 用它做批量主干 —— 免费版限 45 次/分钟,但一次请求覆盖 100 个 IP,
// 实际吞吐足够,且把「N 个 IP」压成「N/100 次请求」。
// - 腾讯位置服务:一 IP 一请求,国内 IP 的省市准确度更高、坐标是 GCJ-02
// (与腾讯地图底图对齐)。只用来**精修**国内的 IP,不做批量主干 ——
// 拿它打批量会把几百个 IP 变成几百次 HTTP,既慢又容易撞配额。
//
// 隐私边界:会把攻击者 IP 发给上述第三方。这是刻意取舍(不查就画不出地图),
// 但调用方必须清楚 —— 本包只应处理攻击者地址,绝不要拿它去查真实用户 IP。
package geoip
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// Provider 常量。
const (
// ProviderAuto 缺省:ip-api 批量打底 + 腾讯精修国内。
ProviderAuto = "auto"
// ProviderIPAPI 只用 ip-api.com。
ProviderIPAPI = "ipapi"
// ProviderTencent 只用腾讯位置服务(无 key 时自动降级为 ipapi)。
ProviderTencent = "tencent"
// ProviderNone 不查询任何外部服务。
ProviderNone = "none"
)
const (
// ipAPIBatchSize ip-api 批量接口的硬上限。
ipAPIBatchSize = 100
// ipAPIMinInterval ip-api 免费版限流 45 次/分钟。取 1.4s 间隔(≈43 次/分钟)
// 留一点余量:多个报告同时跑、或用户手动跑 CLI 时不会互相挤爆。
ipAPIMinInterval = 1400 * time.Millisecond
// defaultTencentRefineCap 腾讯精修最多处理多少个 IP。
//
// 必须有上限:周报可能有两千个不同来源 IP,其中一半是国内 —— 逐个请求
// 腾讯会跑十几分钟并吃掉当日免费配额,而报告的价值并不取决于把最后一千个
// 长尾 IP 精确到市。取命中次数最高的前 N 个,够回答「主要从哪儿来」。
defaultTencentRefineCap = 200
// defaultTimeout 单次外部请求超时。
//
// ⚠️ 不能设小。实测 ip-api 免费版的批量接口「22 个 IP」要 4–10 秒,
// 且同一批重复请求会更慢(免费额度在按 IP 做节流)。最早设 15 秒时
// 第一轮真实数据就超时了 —— 免费服务不是快服务,超时要按最坏情况给。
defaultTimeout = 45 * time.Second
// ipAPIAttempts 单个批次最多尝试几次。
// 免费接口偶发慢响应/网关抖动很常见,重试一次成本可接受(有 1.4s 节流兜底),
// 但重试多了只会放大超时。
ipAPIAttempts = 2
// ipAPIRetryDelay 重试前的等待。
ipAPIRetryDelay = 3 * time.Second
)
// Geo 一个 IP 的地理定位结果。
type Geo struct {
Country string
CountryCode string
Region string
City string
ISP string
ASN string
Lat float64
Lon float64
HasCoords bool
Source string
}
// Options 解析器配置。
type Options struct {
// Provider auto / ipapi / tencent / none,空视为 auto。
Provider string
// TencentKey 腾讯位置服务 key,留空则跳过精修。
TencentKey string
// TencentRefineCap 腾讯精修上限,<=0 用默认值。
TencentRefineCap int
// HTTPClient 可注入(测试用),nil 用带超时的默认 client。
HTTPClient *http.Client
}
// Resolver 按配置解析 IP 地理位置。
type Resolver struct {
provider string
tencent string
refineCap int
client *http.Client
// lastReq 串行化 ip-api 请求以遵守限流(多个报告并发时也不会超频)。
mu sync.Mutex
lastReq time.Time
}
// New 构造解析器。provider 值不认识时回退 auto 并告警 ——
// 静默回退会让人以为配置生效了,必须留痕。
func New(opts Options) *Resolver {
p := strings.ToLower(strings.TrimSpace(opts.Provider))
switch p {
case ProviderAuto, ProviderIPAPI, ProviderTencent, ProviderNone:
case "":
p = ProviderAuto
default:
logrus.WithField("value", opts.Provider).
Warn("ip_geo_provider 取值无法识别,已回退 auto(可选值:auto / ipapi / tencent / none)")
p = ProviderAuto
}
// 只选了 tencent 却没配 key:无法工作,降级 ipapi 而不是返回空结果。
if p == ProviderTencent && strings.TrimSpace(opts.TencentKey) == "" {
logrus.Warn("ip_geo_provider = tencent 但未配置 ip_geo_tencent_key,已降级为 ipapi")
p = ProviderIPAPI
}
cap := opts.TencentRefineCap
if cap <= 0 {
cap = defaultTencentRefineCap
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: defaultTimeout}
}
return &Resolver{
provider: p,
tencent: strings.TrimSpace(opts.TencentKey),
refineCap: cap,
client: client,
}
}
// Enabled 报告解析器是否真的会去查外部服务。
func (r *Resolver) Enabled() bool { return r != nil && r.provider != ProviderNone }
// Describe 返回人类可读的数据源描述(写进报告,让读者知道坐标有多可信)。
func (r *Resolver) Describe() string {
if r == nil {
return "未启用地理定位"
}
switch r.provider {
case ProviderNone:
return "未启用地理定位(ip_geo_provider = none)"
case ProviderIPAPI:
return "ip-api.com(批量,城市级)"
case ProviderTencent:
return "腾讯位置服务(城市级,GCJ-02)"
default:
if r.tencent != "" {
return "ip-api.com 批量 + 腾讯位置服务精修国内 IP"
}
return "ip-api.com(批量,城市级;未配置腾讯 key,国内 IP 未精修)"
}
}
// Result 一次批量定位的结果。
//
// ⚠️ 必须区分「查不到」与「没查成」:这是本包最容易埋错的地方。
// - Geo[key] 存在 = 查到了;
// - Unresolved[key] = 服务**明确回答**没有这个 IP 的数据(可以短期缓存,
// 免得每份报告都去重试一个注定查不到的地址);
// - 两者都没有 = 请求本身失败了(超时 / 限流 / 网关抖动)。
// 此时**绝不能**写缓存 —— 把网络故障记成「这个 IP 没有位置」,
// 会让接下来三天的报告都缺这一批 IP,而且没有任何线索说明为什么。
type Result struct {
Geo map[string]Geo
Unresolved map[string]bool
// Requests / Failures 本次发出的请求数与失败的请求数(日志用)。
Requests int
Failures int
}
// Lookup 批量解析。keys 是 shared.LimitKey 的产物(IPv4 精确地址 / IPv6 的
// "xxxx::/64")。
//
// 任何失败都只记日志并返回已拿到的部分结果 —— 报告不该因为外部 API 抖动而失败。
func (r *Resolver) Lookup(ctx context.Context, keys []string) Result {
res := Result{Geo: make(map[string]Geo, len(keys)), Unresolved: make(map[string]bool)}
if r == nil || r.provider == ProviderNone || len(keys) == 0 {
return res
}
// 把缓存键还原成可查询的地址:IPv6 键带 "/64" 后缀,查询时用网络地址
// (geo 库对前缀同样能回答,用基址是常见做法)。
//
// ⚠️ 必须按 keys 的原始顺序建 addrs:调用方(报告)是按命中次数从高到低
// 传进来的,腾讯精修只取前 refineCap 个。如果这里遍历 map 取地址,
// Go 的 map 迭代顺序是随机的 —— 每次运行精修到的 IP 都不一样,
// 配额会花在长尾地址上,而「主要从哪个城市来」恰恰取决于最活跃的那批。
queryable := make(map[string]string, len(keys)) // 查询地址 -> 原始键
addrs := make([]string, 0, len(keys))
for _, key := range keys {
addr, ok := queryAddr(key)
if !ok {
continue
}
if _, dup := queryable[addr]; dup {
continue
}
queryable[addr] = key
addrs = append(addrs, addr)
}
if len(addrs) == 0 {
return res
}
if r.provider == ProviderAuto || r.provider == ProviderIPAPI {
got, unresolved, reqs, fails := r.lookupIPAPI(ctx, addrs)
res.Requests += reqs
res.Failures += fails
for addr, g := range got {
if key, ok := queryable[addr]; ok {
res.Geo[key] = g
}
}
for addr := range unresolved {
if key, ok := queryable[addr]; ok {
res.Unresolved[key] = true
}
}
}
// 腾讯精修:auto 只精修国内,tencent 覆盖全部。
if r.tencent != "" && r.provider != ProviderIPAPI {
var refine []string
for _, addr := range addrs {
key := queryable[addr]
if r.provider == ProviderAuto {
g, ok := res.Geo[key]
// 只精修 ip-api 已判为中国的条目:海外 IP 腾讯给不出省市,
// 查了也是白白消耗配额。
if !ok || !isChina(g.CountryCode) {
continue
}
}
refine = append(refine, addr)
}
if len(refine) > r.refineCap {
refine = refine[:r.refineCap]
}
tgot, tunresolved, treqs, tfails := r.lookupTencent(ctx, refine)
res.Requests += treqs
res.Failures += tfails
for addr, g := range tgot {
if key, ok := queryable[addr]; ok {
// 保留 ip-api 给的国家(腾讯对海外 IP 的 nation 常常是空的),
// 用腾讯的省市与坐标覆盖。
prev := res.Geo[key]
if g.Country == "" {
g.Country, g.CountryCode = prev.Country, prev.CountryCode
}
res.Geo[key] = g
// 精修成功即视为已定位,撤掉可能存在的「查不到」标记。
delete(res.Unresolved, key)
}
}
for addr := range tunresolved {
if key, ok := queryable[addr]; ok {
if _, done := res.Geo[key]; !done {
res.Unresolved[key] = true
}
}
}
}
return res
}
// isChina 判断国家码是否为中国。
//
// 只看 CN:港澳台在多数 IP 库里是 HK/MO/TW 单独编码,它们的城市名当前
// 不会进入腾讯精修 —— 不做特判,宁可少精修也不要把地区归属搞错。
func isChina(code string) bool { return strings.EqualFold(code, "CN") }
// queryAddr 把缓存键还原为可查询的地址;不可查询(私网/回环/非法)返回 false。
func queryAddr(key string) (string, bool) {
base := key
if i := strings.IndexByte(base, '/'); i >= 0 {
base = base[:i]
}
ip := net.ParseIP(base)
if ip == nil || !isPublic(ip) {
return "", false
}
return ip.String(), true
}
// isPublic 排除私网 / 回环 / 链路本地 / 唯一本地地址。
// 这些地址查不出位置,发出去也毫无意义(还会浪费配额)。
func isPublic(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
return false
}
// IPv4 映射到 IPv6 的形式(::ffff:1.2.3.4)统一按 v4 处理。
if v4 := ip.To4(); v4 != nil {
return !(v4[0] == 0 || v4[0] == 10 || v4[0] == 127 ||
(v4[0] == 169 && v4[1] == 254) ||
(v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
(v4[0] == 192 && v4[1] == 168) ||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127))
}
return true
}
// ---- ip-api.com ----
type ipAPIResult struct {
Query string `json:"query"`
Status string `json:"status"`
Message string `json:"message"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
RegionName string `json:"regionName"`
City string `json:"city"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
ISP string `json:"isp"`
AS string `json:"as"`
}
// lookupIPAPI 分批查询 ip-api.com。
//
// 返回三样东西:
// - got:查到的地址 → Geo;
// - unresolved:服务明确回答「这个地址没有数据」的地址;
// - requests / failures:发出的请求数与失败数。
//
// 失败的批次整批都不进 unresolved —— 区别于「服务说没有这个 IP 的数据」。
//
// 免费版只支持 http(https 要 pro)—— 这是该服务的既定限制,不是笔误。
// 请求体里只有攻击者 IP,不含任何站点数据。
func (r *Resolver) lookupIPAPI(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
got := make(map[string]Geo, len(addrs))
unresolved := make(map[string]bool)
requests, failures := 0, 0
for start := 0; start < len(addrs); start += ipAPIBatchSize {
end := start + ipAPIBatchSize
if end > len(addrs) {
end = len(addrs)
}
batch := addrs[start:end]
res, err := r.ipAPIBatchWithRetry(ctx, batch)
requests++
if err != nil {
failures++
logrus.WithError(err).WithField("count", len(batch)).
Warn("ip-api 批量查询失败:这批 IP 本次不定位,且不会写入缓存(下次运行会重试)")
continue
}
for addr, g := range res.geo {
got[addr] = g
}
for addr := range res.unresolved {
unresolved[addr] = true
}
}
return got, unresolved, requests, failures
}
// batchOutcome 一个批次的解析结果。
type batchOutcome struct {
geo map[string]Geo
unresolved map[string]bool
}
// ipAPIBatchWithRetry 带一次重试的批量请求。
//
// 为什么要重试:ip-api 免费版对同一来源有节流,偶发慢响应/网关 5xx 很常见。
// 不重试的话,一次抖动就会让整份报告缺掉最多 100 个 IP(而报告一周只出一次,
// 没有「下个小时再看」的机会)。重试一次的成本是 3 秒等待,值得。
func (r *Resolver) ipAPIBatchWithRetry(ctx context.Context, addrs []string) (*batchOutcome, error) {
var lastErr error
for attempt := 1; attempt <= ipAPIAttempts; attempt++ {
res, err := r.ipAPIBatch(ctx, addrs)
if err == nil {
return res, nil
}
lastErr = err
if attempt < ipAPIAttempts {
logrus.WithError(err).WithField("attempt", attempt).Debug("ip-api 批量查询失败,准备重试")
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(ipAPIRetryDelay):
}
}
}
return nil, lastErr
}
func (r *Resolver) ipAPIBatch(ctx context.Context, addrs []string) (*batchOutcome, error) {
// 按序返回:ip-api 的批量接口保证结果顺序与输入一致,用下标对应最稳
// (不要拿返回的 query 字段当键 —— 它对 IPv6 会做规范化,和输入不逐字相同)。
body, err := json.Marshal(addrs)
if err != nil {
return nil, errors.Wrap(err, "marshal batch")
}
if err := r.waitTurn(); err != nil {
return nil, err
}
url := "http://ip-api.com/batch?fields=status,message,query,country,countryCode,regionName,city,lat,lon,isp,as"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(err, "build request")
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, errors.Errorf("ip-api status %d: %s", resp.StatusCode, string(snippet))
}
var results []ipAPIResult
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return nil, errors.Wrap(err, "decode")
}
out := &batchOutcome{geo: make(map[string]Geo, len(results)), unresolved: make(map[string]bool)}
for i, item := range results {
if i >= len(addrs) {
break
}
if item.Status != "success" {
// 服务明确回答了这个地址没有数据(保留地址 / 未收录)。
// 只有这种情况才允许进 unresolved(也就是允许被缓存成「查不到」)。
out.unresolved[addrs[i]] = true
continue
}
out.geo[addrs[i]] = Geo{
Country: item.Country,
CountryCode: item.CountryCode,
Region: item.RegionName,
City: item.City,
ISP: item.ISP,
ASN: normalizeASN(item.AS),
Lat: item.Lat,
Lon: item.Lon,
// ip-api 对国家级结果也会给一个中心点坐标(精度差但可用),
// 只要不是 (0,0) 就认为有坐标。
HasCoords: item.Lat != 0 || item.Lon != 0,
Source: ProviderIPAPI,
}
}
return out, nil
}
// waitTurn 串行地保证两次 ip-api 请求间隔不小于 ipAPIMinInterval。
func (r *Resolver) waitTurn() error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.lastReq.IsZero() {
if wait := ipAPIMinInterval - time.Since(r.lastReq); wait > 0 {
time.Sleep(wait)
}
}
r.lastReq = time.Now()
return nil
}
// normalizeASN 把 ip-api 的 "AS15169 Google LLC" 拆成 "AS15169"。
func normalizeASN(as string) string {
as = strings.TrimSpace(as)
if as == "" {
return ""
}
if i := strings.IndexByte(as, ' '); i > 0 {
return as[:i]
}
return as
}
// ---- 腾讯位置服务 ----
type tencentResult struct {
Status int `json:"status"`
Message string `json:"message"`
Result struct {
IP string `json:"ip"`
Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"location"`
AdInfo struct {
Nation string `json:"nation"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
} `json:"ad_info"`
} `json:"result"`
}
// tencentOutcome 单个地址的查询结果分类。
type tencentOutcome int
const (
// tencentFound 查到并给出了行政区划。
tencentFound tencentOutcome = iota
// tencentNoData 服务明确回答没有这个地址的数据(status != 0)。
tencentNoData
// tencentFailed 网络 / 解析失败 —— 与「没有数据」不是一回事,不能缓存。
tencentFailed
)
// lookupTencent 逐个查询腾讯 IP 定位(并发度 2,别把免费配额打爆)。
func (r *Resolver) lookupTencent(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
got := make(map[string]Geo, len(addrs))
unresolved := make(map[string]bool)
if len(addrs) == 0 || r.tencent == "" {
return got, unresolved, 0, 0
}
const workers = 2
var (
mu sync.Mutex
wg sync.WaitGroup
sem = make(chan struct{}, workers)
failures int
)
for _, addr := range addrs {
wg.Add(1)
go func(addr string) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
g, oc := r.tencentOne(ctx, addr)
mu.Lock()
defer mu.Unlock()
switch oc {
case tencentFound:
got[addr] = g
case tencentNoData:
unresolved[addr] = true
default:
failures++
}
}(addr)
}
wg.Wait()
return got, unresolved, len(addrs), failures
}
func (r *Resolver) tencentOne(ctx context.Context, addr string) (Geo, tencentOutcome) {
url := fmt.Sprintf("https://apis.map.qq.com/ws/location/v1/ip?ip=%s&key=%s", addr, r.tencent)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Geo{}, tencentFailed
}
resp, err := r.client.Do(req)
if err != nil {
return Geo{}, tencentFailed
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Geo{}, tencentFailed
}
var tr tencentResult
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return Geo{}, tencentFailed
}
// status != 0 表示查询失败(配额用尽、key 无效、IP 无数据等)。
// 只记 Debug:几千个 IP 逐个刷警告会把日志淹掉。
if tr.Status != 0 {
logrus.WithFields(logrus.Fields{"status": tr.Status, "message": tr.Message}).
Debug("腾讯 IP 定位返回非 0 状态")
// 配额用尽 / key 无效属于「配置或额度问题」,不是「这个 IP 没数据」。
// 把它们混为一谈会把整批 IP 缓存成「查不到」,此后三天都不再精修。
return Geo{}, tencentFailed
}
ad := tr.Result.AdInfo
city := ad.City
if city == "" {
city = ad.District
}
if ad.Province == "" && city == "" {
// 状态成功但没有任何行政区划信息:这才是「这个地址没数据」。
return Geo{}, tencentNoData
}
return Geo{
Country: ad.Nation,
CountryCode: tencentCountryCode(ad.Nation),
Region: ad.Province,
City: city,
Lat: tr.Result.Location.Lat,
Lon: tr.Result.Location.Lng,
HasCoords: tr.Result.Location.Lat != 0 || tr.Result.Location.Lng != 0,
Source: ProviderTencent,
}, tencentFound
}
// tencentCountryCode 从腾讯返回的 nation 推国家码。
//
// 不能无脑写 "CN":provider = tencent 时(不先过 ip-api)所有地址都会走这条路,
// 海外 IP 的 nation 会是「美国」之类,硬编码 CN 会把它们统计成中国来源 ——
// 而「攻击来自哪些国家」正是这份报告的核心结论之一。
//
// 只认「中国」这个精确值:港澳台在腾讯的 nation 里是「中国香港」等,
// 与 ip-api 的 HK/MO/TW 编码对不上,留空交给上层按国家名兜底更安全。
func tencentCountryCode(nation string) string {
if strings.TrimSpace(nation) == "中国" {
return "CN"
}
return ""
}