// 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 report import ( "context" "time" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/security/geoip" ) // 缓存 TTL。 // // 成功结果留 90 天:一个 IP 的国家/省份几乎不会变,城市级偶尔变, // 90 天足够新。失败结果只留 3 天:失败多为临时原因(对方 API 超时、 // 限流),留久了等于永久放弃这个地址。 const ( geoSuccessTTL = 90 * 24 * time.Hour geoFailureTTL = 3 * 24 * time.Hour ) // GeoResolver 是定位器的最小接口(*geoip.Resolver 实现它)。 // // 用接口而不是具体类型:这个函数的核心是缓存语义(TTL、失败短期不重试、 // 写回),必须能在不联网、不依赖真实 IP 库的情况下测到。 type GeoResolver interface { Lookup(ctx context.Context, keys []string) geoip.Result Enabled() bool } // ResolveWithCache 解析一批 IP 的地理位置,优先用缓存。 // // keys 是 shared.LimitKey 的产物,顺序会被保留用于「腾讯精修取前 N 个」—— // 调用方应按命中次数从高到低传入,这样配额优先花在最活跃的来源上。 // // ⚠️ 写缓存的判定是这里全部的价值所在,务必分清三种结果: // - Result.Geo[key] 存在 → 查到了,写成功缓存(90 天) // - Result.Unresolved[key] → 服务明确说没有这个地址的数据,写失败缓存(3 天), // 免得每份报告都去重试一个注定查不到的地址 // - 两个 map 里都没有 → 这次请求本身没成功(超时 / 限流 / 网关 5xx)。 // **绝不写缓存**。早先的实现在这里也写 Failed,结果是一次网络抖动就把整批 // IP 钉成「查不到」三天,此后每份报告的定位覆盖率都莫名其妙变成 0, // 而且日志里没有任何线索指向真正的原因。 func ResolveWithCache(ctx context.Context, cache db.IPGeoStore, resolver GeoResolver, keys []string) map[string]geoip.Geo { out := make(map[string]geoip.Geo, len(keys)) if len(keys) == 0 { return out } cached, err := cache.GetMany(ctx, keys) if err != nil { // 缓存读失败不该让报告失败:退化成「全部现查」, // 代价是可能多花点配额,但报告照出。 logrus.WithError(err).Warn("读取 IP 地理缓存失败,本次将全部重新查询") cached = nil } now := time.Now() var misses []string seen := make(map[string]bool, len(keys)) for _, key := range keys { if seen[key] { continue } seen[key] = true rec, ok := cached[key] if ok && fresh(rec, now) { if !rec.Failed { out[key] = geoip.Geo{ Country: rec.Country, CountryCode: rec.CountryCode, Region: rec.Region, City: rec.City, ISP: rec.ISP, ASN: rec.ASN, Lat: rec.Lat, Lon: rec.Lon, HasCoords: rec.HasCoords, Source: rec.Source, } } // 命中但 Failed:明确记为「查不到」,不再重试,也不进 out。 continue } misses = append(misses, key) } if len(misses) == 0 || !resolver.Enabled() { return out } resolved := resolver.Lookup(ctx, misses) if resolved.Failures > 0 { logrus.WithFields(logrus.Fields{ "keys": len(misses), "requests": resolved.Requests, "failures": resolved.Failures, "located": len(resolved.Geo), }).Warn("IP 定位请求部分失败:失败的那些不进缓存,下次报告会重新查询") } // 写回缓存:成功的与「服务明确说没有」的都写,网络失败的跳过。 records := make([]*db.IPGeoRecord, 0, len(misses)) for _, key := range misses { if g, ok := resolved.Geo[key]; ok { out[key] = g records = append(records, &db.IPGeoRecord{ IPKey: key, Country: g.Country, CountryCode: g.CountryCode, Region: g.Region, City: g.City, ISP: g.ISP, ASN: g.ASN, Lat: g.Lat, Lon: g.Lon, HasCoords: g.HasCoords, Source: g.Source, ResolvedAt: now, }) continue } if resolved.Unresolved[key] { records = append(records, &db.IPGeoRecord{ IPKey: key, ResolvedAt: now, Failed: true, }) } } if len(records) == 0 { return out } if err := cache.UpsertMany(ctx, records); err != nil { logrus.WithError(err).Warn("写入 IP 地理缓存失败(不影响本次报告)") } return out } // fresh 判断缓存记录是否还在有效期内。 func fresh(rec *db.IPGeoRecord, now time.Time) bool { if rec == nil { return false } ttl := geoSuccessTTL if rec.Failed { ttl = geoFailureTTL } return now.Sub(rec.ResolvedAt) < ttl }