134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"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
|
|
}
|
|
}
|
|
var row model.Proxy
|
|
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
row.Country, row.City, row.GeoAt = res.Country, res.City, &now
|
|
s.db.WithContext(ctx).Save(&row)
|
|
}
|
|
|
|
// 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)
|
|
}
|