package auth import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" ) const maxHTTPGeoResponseBytes = 64 * 1024 // HTTPIPGeoProviderConfig 描述一个外部 HTTP IP Geo 源;URL 和 header 支持用 {ip} 注入入口 IP。 type HTTPIPGeoProviderConfig struct { URL string Method string CountryJSONPath string Headers map[string]string HTTPClient *http.Client } type edgeCountryProvider struct{} type staticCountryProvider struct { name string countryCode string } type httpIPGeoProvider struct { name string urlTemplate string method string countryJSONPath string headers map[string]string client *http.Client } type unavailableIPGeoProvider struct { name string } // defaultHTTPIPGeoProviders 固定复用 chatapp3 当前线上使用的四个公开 GeoIP 源;配置只保留顺序,不把 URL 下放到 YAML。 var defaultHTTPIPGeoProviders = map[string]HTTPIPGeoProviderConfig{ "primary_geo": { URL: "https://api.ip.sb/geoip/{ip}", Method: http.MethodGet, CountryJSONPath: "country_code", }, "fallback_geo_a": { URL: "https://freeipapi.com/api/json/{ip}", Method: http.MethodGet, CountryJSONPath: "countryCode", }, "fallback_geo_b": { URL: "https://ipwhois.app/json/{ip}?format=json", Method: http.MethodGet, CountryJSONPath: "country_code", }, "fallback_geo_c": { URL: "https://ip.nc.gy/json?ip={ip}", Method: http.MethodGet, CountryJSONPath: "country.iso_code", }, } func BuildIPGeoProviders(names []string) []IPGeoProvider { return BuildIPGeoProvidersWithHTTP(names, defaultHTTPIPGeoProviders) } func BuildIPGeoProvidersWithHTTP(names []string, httpConfigs map[string]HTTPIPGeoProviderConfig) []IPGeoProvider { providers := make([]IPGeoProvider, 0, len(names)) for _, name := range names { name = strings.TrimSpace(name) if name == "" { continue } switch { case strings.EqualFold(name, "edge_country"): providers = append(providers, edgeCountryProvider{}) case strings.HasPrefix(strings.ToLower(name), "static:"): country := normalizeCountryCode(strings.TrimPrefix(name, "static:")) providers = append(providers, staticCountryProvider{name: name, countryCode: country}) case httpConfigs != nil: if config, ok := httpConfigs[name]; ok { providers = append(providers, newHTTPIPGeoProvider(name, config)) } else { providers = append(providers, unavailableIPGeoProvider{name: name}) } default: // 未配置具体 provider 适配器时显式降级为 unavailable,worker 会按顺序继续尝试下一个。 providers = append(providers, unavailableIPGeoProvider{name: name}) } } return providers } func newHTTPIPGeoProvider(name string, config HTTPIPGeoProviderConfig) IPGeoProvider { method := strings.ToUpper(strings.TrimSpace(config.Method)) if method == "" { method = http.MethodGet } client := config.HTTPClient if client == nil { client = http.DefaultClient } headers := make(map[string]string, len(config.Headers)) for key, value := range config.Headers { key = strings.TrimSpace(key) if key == "" { continue } headers[key] = strings.TrimSpace(value) } return httpIPGeoProvider{ name: name, urlTemplate: strings.TrimSpace(config.URL), method: method, countryJSONPath: strings.TrimSpace(config.CountryJSONPath), headers: headers, client: client, } } func (p edgeCountryProvider) Name() string { return "edge_country" } func (p edgeCountryProvider) ResolveCountry(_ context.Context, job authdomain.LoginIPRiskJob) (IPGeoResult, error) { country := normalizeCountryCode(job.EdgeCountryCode) if country == "" { return IPGeoResult{}, xerr.New(xerr.Unavailable, "edge country is empty") } return IPGeoResult{CountryCode: country, Confidence: 60}, nil } func (p staticCountryProvider) Name() string { return p.name } func (p staticCountryProvider) ResolveCountry(_ context.Context, _ authdomain.LoginIPRiskJob) (IPGeoResult, error) { if p.countryCode == "" { return IPGeoResult{}, xerr.New(xerr.Unavailable, "static country is empty") } return IPGeoResult{CountryCode: p.countryCode, Confidence: 100}, nil } func (p httpIPGeoProvider) Name() string { return p.name } func (p httpIPGeoProvider) ResolveCountry(ctx context.Context, job authdomain.LoginIPRiskJob) (IPGeoResult, error) { if strings.TrimSpace(p.urlTemplate) == "" { return IPGeoResult{}, xerr.New(xerr.Unavailable, "http geo provider url is empty") } clientIP := strings.TrimSpace(job.ClientIP) if clientIP == "" { return IPGeoResult{}, xerr.New(xerr.Unavailable, "client ip is empty") } endpoint := renderIPGeoTemplate(p.urlTemplate, clientIP) request, err := http.NewRequestWithContext(ctx, p.method, endpoint, nil) if err != nil { return IPGeoResult{}, err } for key, value := range p.headers { request.Header.Set(key, renderIPGeoTemplate(value, clientIP)) } response, err := p.client.Do(request) if err != nil { return IPGeoResult{}, err } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { return IPGeoResult{}, fmt.Errorf("http geo provider status %d", response.StatusCode) } body, err := io.ReadAll(io.LimitReader(response.Body, maxHTTPGeoResponseBytes+1)) if err != nil { return IPGeoResult{}, err } if len(body) > maxHTTPGeoResponseBytes { return IPGeoResult{}, xerr.New(xerr.Unavailable, "http geo provider response too large") } var payload any if err := json.Unmarshal(body, &payload); err != nil { return IPGeoResult{}, err } country := countryFromHTTPGeoPayload(payload, p.countryJSONPath) if country == "" { return IPGeoResult{}, xerr.New(xerr.Unavailable, "http geo provider country is empty") } return IPGeoResult{CountryCode: country, Confidence: 80}, nil } func renderIPGeoTemplate(template string, clientIP string) string { return strings.ReplaceAll(template, "{ip}", url.QueryEscape(clientIP)) } func countryFromHTTPGeoPayload(payload any, configuredPath string) string { if country := countryAtJSONPath(payload, configuredPath); country != "" { return country } for _, path := range []string{ "country_code", "countryCode", "iso_code", "isoCode", "country.iso_code", "country.country_code", "data.country_code", "data.countryCode", "result.country_code", "result.countryCode", "registered_country.iso_code", } { if country := countryAtJSONPath(payload, path); country != "" { return country } } if text, ok := payload.(string); ok { return normalizeCountryCode(text) } return "" } func countryAtJSONPath(payload any, path string) string { path = strings.TrimSpace(path) if path == "" { return "" } current := payload for _, part := range strings.Split(path, ".") { part = strings.TrimSpace(part) if part == "" { return "" } object, ok := current.(map[string]any) if !ok { return "" } next, ok := object[part] if !ok { return "" } current = next } text, ok := current.(string) if !ok { return "" } return normalizeCountryCode(text) } func (p unavailableIPGeoProvider) Name() string { return p.name } func (p unavailableIPGeoProvider) ResolveCountry(_ context.Context, _ authdomain.LoginIPRiskJob) (IPGeoResult, error) { return IPGeoResult{}, fmt.Errorf("ip geo provider %s is not configured", p.name) }