diff --git a/atsf_server/controller/geoip.go b/atsf_server/controller/geoip.go
new file mode 100644
index 00000000..5eeea27b
--- /dev/null
+++ b/atsf_server/controller/geoip.go
@@ -0,0 +1,36 @@
+package controller
+
+import (
+ "atsflare/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type geoIPLookupRequest struct {
+ Provider string `json:"provider"`
+ IP string `json:"ip"`
+}
+
+// LookupGeoIP godoc
+// @Summary Test GeoIP lookup
+// @Tags Options
+// @Accept json
+// @Produce json
+// @Param payload body geoIPLookupRequest true "GeoIP lookup payload"
+// @Success 200 {object} map[string]interface{}
+// @Failure 400 {object} map[string]interface{}
+// @Router /api/option/geoip/lookup [post]
+func LookupGeoIP(c *gin.Context) {
+ var request geoIPLookupRequest
+ if err := decodeJSONBody(c.Request.Body, &request); err != nil {
+ respondBadRequest(c, "")
+ return
+ }
+
+ view, err := service.LookupGeoIP(request.Provider, request.IP)
+ if err != nil {
+ respondFailure(c, err.Error())
+ return
+ }
+ respondSuccess(c, view)
+}
diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go
index ea5b39e8..312f9335 100644
--- a/atsf_server/router/api-router.go
+++ b/atsf_server/router/api-router.go
@@ -54,6 +54,7 @@ func SetApiRouter(router *gin.Engine) {
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
+ optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
}
updateRoute := apiRouter.Group("/update")
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
diff --git a/atsf_server/service/geoip_lookup.go b/atsf_server/service/geoip_lookup.go
new file mode 100644
index 00000000..ea9e0a0d
--- /dev/null
+++ b/atsf_server/service/geoip_lookup.go
@@ -0,0 +1,50 @@
+package service
+
+import (
+ "atsflare/utils/geoip"
+ "errors"
+ "net"
+ "strings"
+)
+
+type GeoIPLookupView struct {
+ Provider string `json:"provider"`
+ IP string `json:"ip"`
+ ISOCode string `json:"iso_code"`
+ Name string `json:"name"`
+ Latitude *float64 `json:"latitude,omitempty"`
+ Longitude *float64 `json:"longitude,omitempty"`
+}
+
+func LookupGeoIP(provider string, rawIP string) (*GeoIPLookupView, error) {
+ trimmedProvider := strings.TrimSpace(provider)
+ if !geoip.IsValidProvider(trimmedProvider) {
+ return nil, errors.New("归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo")
+ }
+
+ trimmedIP := strings.TrimSpace(rawIP)
+ if trimmedIP == "" {
+ return nil, errors.New("IP 不能为空")
+ }
+ parsedIP := net.ParseIP(trimmedIP)
+ if parsedIP == nil {
+ return nil, errors.New("IP 格式无效")
+ }
+
+ info, err := geoip.LookupGeoInfoWithProvider(trimmedProvider, parsedIP)
+ if err != nil {
+ return nil, err
+ }
+ if info == nil {
+ return nil, errors.New("未获取到 IP 归属结果")
+ }
+
+ return &GeoIPLookupView{
+ Provider: trimmedProvider,
+ IP: parsedIP.String(),
+ ISOCode: info.ISOCode,
+ Name: info.Name,
+ Latitude: info.Latitude,
+ Longitude: info.Longitude,
+ }, nil
+}
diff --git a/atsf_server/service/geoip_lookup_test.go b/atsf_server/service/geoip_lookup_test.go
new file mode 100644
index 00000000..375d490a
--- /dev/null
+++ b/atsf_server/service/geoip_lookup_test.go
@@ -0,0 +1,64 @@
+package service
+
+import (
+ "atsflare/utils/geoip"
+ "net"
+ "testing"
+)
+
+type fakeLookupProvider struct{}
+
+func (f *fakeLookupProvider) Name() string {
+ return "fake-lookup"
+}
+
+func (f *fakeLookupProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) {
+ return &geoip.GeoInfo{
+ ISOCode: "US",
+ Name: "United States",
+ Latitude: geoipFloat(37.7749),
+ Longitude: geoipFloat(-122.4194),
+ }, nil
+}
+
+func (f *fakeLookupProvider) UpdateDatabase() error {
+ return nil
+}
+
+func (f *fakeLookupProvider) Close() error {
+ return nil
+}
+
+func TestLookupGeoIP(t *testing.T) {
+ previousFactory := geoip.ProviderFactoryForTest()
+ geoip.SetProviderFactoryForTest(func(provider string) (geoip.GeoIPService, error) {
+ return &fakeLookupProvider{}, nil
+ })
+ defer geoip.SetProviderFactoryForTest(previousFactory)
+
+ view, err := LookupGeoIP("ipinfo", "8.8.8.8")
+ if err != nil {
+ t.Fatalf("LookupGeoIP failed: %v", err)
+ }
+ if view.Provider != "ipinfo" {
+ t.Fatalf("expected provider ipinfo, got %s", view.Provider)
+ }
+ if view.IP != "8.8.8.8" {
+ t.Fatalf("expected IP 8.8.8.8, got %s", view.IP)
+ }
+ if view.ISOCode != "US" || view.Name != "United States" {
+ t.Fatalf("unexpected lookup view: %+v", view)
+ }
+ if view.Latitude == nil || view.Longitude == nil {
+ t.Fatalf("expected coordinates, got %+v", view)
+ }
+}
+
+func TestLookupGeoIPRejectsInvalidInput(t *testing.T) {
+ if _, err := LookupGeoIP("invalid", "8.8.8.8"); err == nil {
+ t.Fatal("expected invalid provider to fail")
+ }
+ if _, err := LookupGeoIP("ipinfo", "not-an-ip"); err == nil {
+ t.Fatal("expected invalid IP to fail")
+ }
+}
diff --git a/atsf_server/utils/geoip/geoip.go b/atsf_server/utils/geoip/geoip.go
index 4dcc6133..3579bdeb 100644
--- a/atsf_server/utils/geoip/geoip.go
+++ b/atsf_server/utils/geoip/geoip.go
@@ -16,6 +16,7 @@ import (
var CurrentProvider GeoIPService
var geoCache *providerCache
var providerMutex sync.RWMutex
+var providerFactory = newProvider
const (
ProviderDisabled = "disabled"
@@ -111,7 +112,7 @@ func GetRegionUnicodeEmoji(isoCode string) string {
func InitGeoIP() {
providerName := normalizeProvider(common.GeoIPProvider)
- nextProvider, err := newProvider(providerName)
+ nextProvider, err := providerFactory(providerName)
if err != nil {
slog.Error("initialize GeoIP provider failed", "provider", providerName, "error", err)
nextProvider = &EmptyProvider{}
@@ -142,6 +143,24 @@ func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
return info, err
}
+func LookupGeoInfoWithProvider(providerName string, ip net.IP) (*GeoInfo, error) {
+ if ip == nil {
+ return nil, fmt.Errorf("IP address cannot be nil")
+ }
+
+ provider, err := providerFactory(normalizeProvider(providerName))
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := provider.Close(); closeErr != nil {
+ slog.Warn("close temporary GeoIP provider failed", "provider", provider.Name(), "error", closeErr)
+ }
+ }()
+
+ return provider.GetGeoInfo(ip)
+}
+
func UpdateDatabase() error {
err := getProvider().UpdateDatabase()
if err == nil {
@@ -210,3 +229,15 @@ func getProvider() GeoIPService {
func float64Pointer(value float64) *float64 {
return &value
}
+
+func ProviderFactoryForTest() func(string) (GeoIPService, error) {
+ return providerFactory
+}
+
+func SetProviderFactoryForTest(factory func(string) (GeoIPService, error)) {
+ if factory == nil {
+ providerFactory = newProvider
+ return
+ }
+ providerFactory = factory
+}
diff --git a/atsf_server/utils/geoip/geoip_test.go b/atsf_server/utils/geoip/geoip_test.go
index 0e64d0af..c3726d0a 100644
--- a/atsf_server/utils/geoip/geoip_test.go
+++ b/atsf_server/utils/geoip/geoip_test.go
@@ -79,3 +79,21 @@ func TestIsValidProvider(t *testing.T) {
}
}
}
+
+func TestLookupGeoInfoWithProviderUsesTemporaryProvider(t *testing.T) {
+ previousFactory := providerFactory
+ providerFactory = func(provider string) (GeoIPService, error) {
+ return &fakeProvider{}, nil
+ }
+ defer func() {
+ providerFactory = previousFactory
+ }()
+
+ info, err := LookupGeoInfoWithProvider("ipinfo", net.ParseIP("8.8.8.8"))
+ if err != nil {
+ t.Fatalf("expected lookup to succeed, got %v", err)
+ }
+ if info == nil || info.ISOCode != "CN" || info.Name != "China" {
+ t.Fatalf("unexpected geo info: %#v", info)
+ }
+}
diff --git a/atsf_server/web/features/settings/api/settings.ts b/atsf_server/web/features/settings/api/settings.ts
index 00151b4b..16d52fb5 100644
--- a/atsf_server/web/features/settings/api/settings.ts
+++ b/atsf_server/web/features/settings/api/settings.ts
@@ -2,6 +2,7 @@ import { apiRequest } from '@/lib/api/client';
import type {
BootstrapTokenPayload,
+ GeoIPLookupResult,
OptionItem,
SettingsProfile,
UpdateSelfPayload,
@@ -18,6 +19,13 @@ export function updateOption(key: string, value: string) {
});
}
+export function lookupGeoIP(provider: string, ip: string) {
+ return apiRequest
- 自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。 + 自动更新和手动更新动作在节点页触发,这里维护 Agent + 自更新使用的仓库地址。
- 控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。 + 控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind + 时会按需下载本地 mmdb 数据库。
+ 查询 IP +
++ {geoIPLookupResult.ip} +
++ 国家 / 地区 +
++ {geoIPLookupResult.name || '—'} +
++ ISO Code +
++ 经纬度 +
++ {geoIPLookupResult.latitude !== undefined && + geoIPLookupResult.latitude !== null && + geoIPLookupResult.longitude !== undefined && + geoIPLookupResult.longitude !== null + ? `${geoIPLookupResult.latitude.toFixed(4)}, ${geoIPLookupResult.longitude.toFixed(4)}` + : '—'} +
+