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 个渲染测试失败。
This commit is contained in:
tamakyi
2026-10-30 16:40:44 +08:00
parent 08bbd17751
commit d690355cdc
33 changed files with 5198 additions and 1 deletions
+636
View File
@@ -0,0 +1,636 @@
// 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 ""
}