From 640dd6c82c750afeea2357b1f69154e4328a2e56 Mon Sep 17 00:00:00 2001
From: ryan
Date: Sun, 15 Mar 2026 14:43:28 +0800
Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=96=B0=E5=A2=9E=20Geo?=
=?UTF-8?q?IP=20=E6=B5=8B=E8=AF=95=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=B4?=
=?UTF-8?q?=E6=96=B0=E7=9B=B8=E5=85=B3=20API=20=E8=B7=AF=E7=94=B1=E5=92=8C?=
=?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=BB=84=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
atsf_server/controller/geoip.go | 36 ++++++
atsf_server/router/api-router.go | 1 +
atsf_server/service/geoip_lookup.go | 50 ++++++++
atsf_server/service/geoip_lookup_test.go | 64 ++++++++++
atsf_server/utils/geoip/geoip.go | 33 ++++-
atsf_server/utils/geoip/geoip_test.go | 18 +++
.../web/features/settings/api/settings.ts | 8 ++
.../settings/components/settings-page.tsx | 116 ++++++++++++++++--
atsf_server/web/features/settings/types.ts | 9 ++
9 files changed, 327 insertions(+), 8 deletions(-)
create mode 100644 atsf_server/controller/geoip.go
create mode 100644 atsf_server/service/geoip_lookup.go
create mode 100644 atsf_server/service/geoip_lookup_test.go
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('/option/geoip/lookup', {
+ method: 'POST',
+ body: JSON.stringify({ provider, ip }),
+ });
+}
+
export function getBootstrapToken() {
return apiRequest('/nodes/bootstrap-token');
}
diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx
index 456bc3aa..155f9ebf 100644
--- a/atsf_server/web/features/settings/components/settings-page.tsx
+++ b/atsf_server/web/features/settings/components/settings-page.tsx
@@ -21,12 +21,14 @@ import {
getBootstrapToken,
getOptions,
getSettingsProfile,
+ lookupGeoIP,
rotateBootstrapToken,
updateOption,
updateSelf,
} from '@/features/settings/api/settings';
import type {
BootstrapTokenPayload,
+ GeoIPLookupResult,
OptionItem,
UpdateSelfPayload,
} from '@/features/settings/types';
@@ -230,6 +232,7 @@ export function SettingsPage() {
const [emailAddress, setEmailAddress] = useState('');
const [emailCode, setEmailCode] = useState('');
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
+ const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
const isRoot = (user?.role ?? 0) >= 100;
@@ -443,6 +446,11 @@ export function SettingsPage() {
},
});
+ const geoIPLookupMutation = useMutation({
+ mutationFn: ({ provider, ip }: { provider: string; ip: string }) =>
+ lookupGeoIP(provider, ip),
+ });
+
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
const discoveryCommand =
isRoot && operationFields.ServerAddress && discoveryToken
@@ -505,6 +513,17 @@ export function SettingsPage() {
setFeedback({ tone: 'success', message: successMessage });
};
+ const handleGeoIPLookup = () => {
+ geoIPLookupMutation.reset();
+ geoIPLookupMutation.mutate({
+ provider: operationFields.GeoIPProvider,
+ ip: geoIPTestIP.trim(),
+ });
+ };
+
+ const geoIPLookupResult: GeoIPLookupResult | undefined =
+ geoIPLookupMutation.data;
+
const handleProfileSave = () => {
void runBusyAction('profile', async () => {
await updateSelf({
@@ -946,7 +965,10 @@ export function SettingsPage() {
[
['AgentHeartbeatInterval', String(heartbeat)],
['NodeOfflineThreshold', String(offline)],
- ['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()],
+ [
+ 'AgentUpdateRepo',
+ operationFields.AgentUpdateRepo.trim(),
+ ],
['GeoIPProvider', operationFields.GeoIPProvider],
],
'运维设置已保存。',
@@ -1006,7 +1028,8 @@ export function SettingsPage() {
Agent 更新仓库
- 自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。
+ 自动更新和手动更新动作在节点页触发,这里维护 Agent
+ 自更新使用的仓库地址。
@@ -1031,7 +1054,8 @@ export function SettingsPage() {
IP 归属方式
- 控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。
+ 控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind
+ 时会按需下载本地 mmdb 数据库。
@@ -1056,6 +1080,84 @@ export function SettingsPage() {
+
+
+
+
+ setGeoIPTestIP(event.target.value)
+ }
+ placeholder="例如 8.8.8.8"
+ />
+
+
+ {geoIPLookupMutation.isPending
+ ? '查询中...'
+ : '查询归属'}
+
+
+
+
+ {geoIPLookupMutation.isError ? (
+
+ ) : null}
+
+ {geoIPLookupResult ? (
+
+
+
+ 查询 IP
+
+
+ {geoIPLookupResult.ip}
+
+
+
+
+ 国家 / 地区
+
+
+ {geoIPLookupResult.name || '—'}
+
+
+
+
+
+ 经纬度
+
+
+ {geoIPLookupResult.latitude !== undefined &&
+ geoIPLookupResult.latitude !== null &&
+ geoIPLookupResult.longitude !== undefined &&
+ geoIPLookupResult.longitude !== null
+ ? `${geoIPLookupResult.latitude.toFixed(4)}, ${geoIPLookupResult.longitude.toFixed(4)}`
+ : '—'}
+
+
+
+ ) : null}
+
+
@@ -1281,7 +1383,9 @@ export function SettingsPage() {
}
disabled={busyKey === 'system-general'}
>
- {busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
+ {busyKey === 'system-general'
+ ? '保存中...'
+ : '保存通用设置'}
}
@@ -1904,9 +2008,7 @@ export function SettingsPage() {
return (
-
+
{feedback ? (
diff --git a/atsf_server/web/features/settings/types.ts b/atsf_server/web/features/settings/types.ts
index baba25bf..00d7f374 100644
--- a/atsf_server/web/features/settings/types.ts
+++ b/atsf_server/web/features/settings/types.ts
@@ -9,6 +9,15 @@ export interface BootstrapTokenPayload {
discovery_token: string;
}
+export interface GeoIPLookupResult {
+ provider: string;
+ ip: string;
+ iso_code: string;
+ name: string;
+ latitude?: number | null;
+ longitude?: number | null;
+}
+
export interface UpdateSelfPayload {
username: string;
display_name: string;