package service import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "time" "oci-portal/internal/model" ) // geoProbeTimeout 覆盖单次探测(含 fallback)的总时长。 const geoProbeTimeout = 20 * time.Second // geoResult 是代理出口地理探测结果;空值表示探测失败(前端显示「未知」)。 type geoResult struct { Country string City string } // geoEndpoint 是单个出口地理 API 的地址与解析器。 type geoEndpoint struct { url string parse func([]byte) (geoResult, bool) } // geoEndpoints 依次尝试的免费出口地理 API,第一个成功即返回。 // 主选 ip-api.com:免费 45 req/min(按出口 IP 计)、直接返回中文,但仅提供 http; // 备选 ipwho.is:https、无显式限频,仅英文。均不带 IP 参数,即查代理出口本身。 var geoEndpoints = []geoEndpoint{ {url: "http://ip-api.com/json/?fields=status,country,city&lang=zh-CN", parse: parseIPAPI}, {url: "https://ipwho.is/?fields=success,country,city", parse: parseIPWhois}, } // parseIPAPI 解析 ip-api.com 响应。 func parseIPAPI(b []byte) (geoResult, bool) { var r struct { Status string `json:"status"` Country string `json:"country"` City string `json:"city"` } if json.Unmarshal(b, &r) != nil || r.Status != "success" || r.Country == "" { return geoResult{}, false } return geoResult{Country: r.Country, City: r.City}, true } // parseIPWhois 解析 ipwho.is 响应。 func parseIPWhois(b []byte) (geoResult, bool) { var r struct { Success bool `json:"success"` Country string `json:"country"` City string `json:"city"` } if json.Unmarshal(b, &r) != nil || !r.Success || r.Country == "" { return geoResult{}, false } return geoResult{Country: r.Country, City: r.City}, true } // probeGeoOnce 经指定 client 请求单个 geo API;任何失败返回 false。 func probeGeoOnce(ctx context.Context, hc *http.Client, url string, parse func([]byte) (geoResult, bool)) (geoResult, bool) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return geoResult{}, false } resp, err := hc.Do(req) if err != nil { return geoResult{}, false } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) if err != nil || resp.StatusCode != http.StatusOK { return geoResult{}, false } return parse(body) } // probeGeoAsync 后台探测出口地理并回填;创建 / 更新后调用,Wait 收敛。 func (s *ProxyService) probeGeoAsync(id uint) { s.wg.Add(1) go func() { defer s.wg.Done() ctx, cancel := context.WithTimeout(context.Background(), geoProbeTimeout) defer cancel() s.probeGeo(ctx, id) }() } // probeGeo 经该代理链路依次尝试 geo API 并写回地区字段; // 请求全部失败也写空值+探测时间(前端显示「未知」),避免主机变更后残留旧地区; // client 构造失败(spec 非法)则不动数据。 func (s *ProxyService) probeGeo(ctx context.Context, id uint) { spec, err := s.SpecOf(ctx, &id) if err != nil { return } hc := s.geoClientFor(spec) if hc == nil { return } res := geoResult{} for _, ep := range geoEndpoints { if r, ok := probeGeoOnce(ctx, hc, ep.url, ep.parse); ok { res = r break } } s.persistGeo(ctx, id, res) } // persistGeo 条件更新地区字段:代理已被删除时零命中放弃——整行 Save 零命中会 // 回退 Create 把删掉的代理复活;错误也不能静默吞掉。 func (s *ProxyService) persistGeo(ctx context.Context, id uint, res geoResult) { now := time.Now() tx := s.db.WithContext(ctx).Model(&model.Proxy{}).Where("id = ?", id). Updates(map[string]any{"country": res.Country, "city": res.City, "geo_at": &now}) if tx.Error != nil { log.Printf("proxy geo persist %d: %v", id, tx.Error) } } // Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。 func (s *ProxyService) Probe(ctx context.Context, id uint) (ProxyView, error) { var row model.Proxy if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err) } pctx, cancel := context.WithTimeout(ctx, geoProbeTimeout) defer cancel() s.probeGeo(pctx, id) if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err) } return s.viewOf(ctx, &row) }