Files
sub2api/backend/internal/pkg/ipgeo/ipgeo.go
2026-05-15 22:33:43 +08:00

113 lines
2.7 KiB
Go

// Package ipgeo provides best-effort IP geolocation lookup for audit display.
package ipgeo
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
type Info struct {
IPAddress string
Country string
CountryCode string
Region string
City string
Location string
}
func Lookup(ctx context.Context, rawIP string) (*Info, error) {
ip := strings.TrimSpace(rawIP)
parsed := net.ParseIP(ip)
if parsed == nil {
return nil, fmt.Errorf("invalid ip")
}
if isLocalIP(parsed) {
return &Info{IPAddress: ip}, nil
}
endpoint := "http://ip-api.com/json/" + url.PathEscape(ip) + "?lang=zh-CN&fields=status,message,country,countryCode,regionName,region,city,query"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("lookup failed: status %d", resp.StatusCode)
}
var body struct {
Status string `json:"status"`
Message string `json:"message"`
Query string `json:"query"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
Region string `json:"region"`
RegionName string `json:"regionName"`
City string `json:"city"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
if strings.ToLower(body.Status) != "success" {
if body.Message == "" {
body.Message = "ip lookup failed"
}
return nil, errors.New(body.Message)
}
region := strings.TrimSpace(body.RegionName)
if region == "" {
region = strings.TrimSpace(body.Region)
}
info := &Info{
IPAddress: firstNonEmpty(body.Query, ip),
Country: strings.TrimSpace(body.Country),
CountryCode: strings.TrimSpace(body.CountryCode),
Region: region,
City: strings.TrimSpace(body.City),
}
info.Location = formatLocation(info.Country, info.Region, info.City)
return info, nil
}
func isLocalIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
func formatLocation(parts ...string) string {
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key := strings.ToLower(part)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, part)
}
return strings.Join(out, " ")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}