[优化] 新增 GeoIP 测试功能,更新相关 API 路由和前端组件

This commit is contained in:
ryan
2026-03-15 14:43:28 +08:00
parent 5bb25d2203
commit 640dd6c82c
9 changed files with 327 additions and 8 deletions
+36
View File
@@ -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)
}
+1
View File
@@ -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())
+50
View File
@@ -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
}
+64
View File
@@ -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")
}
}
+32 -1
View File
@@ -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
}
+18
View File
@@ -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)
}
}
@@ -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<GeoIPLookupResult>('/option/geoip/lookup', {
method: 'POST',
body: JSON.stringify({ provider, ip }),
});
}
export function getBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
}
@@ -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 更新仓库
</p>
<p className="text-sm text-[var(--foreground-muted)]">
自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。
自动更新和手动更新动作在节点页触发,这里维护 Agent
自更新使用的仓库地址。
</p>
</div>
<div className="mt-4">
@@ -1031,7 +1054,8 @@ export function SettingsPage() {
IP 归属方式
</p>
<p className="text-sm text-[var(--foreground-muted)]">
控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。
控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind
时会按需下载本地 mmdb 数据库。
</p>
</div>
<div className="mt-4">
@@ -1056,6 +1080,84 @@ export function SettingsPage() {
</ResourceSelect>
</ResourceField>
</div>
<div className="mt-5 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<ResourceField
label="测试 IP"
>
<ResourceInput
value={geoIPTestIP}
onChange={(event) =>
setGeoIPTestIP(event.target.value)
}
placeholder="例如 8.8.8.8"
/>
</ResourceField>
<PrimaryButton
type="button"
onClick={handleGeoIPLookup}
disabled={geoIPLookupMutation.isPending}
>
{geoIPLookupMutation.isPending
? '查询中...'
: '查询归属'}
</PrimaryButton>
</div>
<div className="mt-4 space-y-3">
{geoIPLookupMutation.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(geoIPLookupMutation.error)}
/>
) : null}
{geoIPLookupResult ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
查询 IP
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
{geoIPLookupResult.ip}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
国家 / 地区
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
{geoIPLookupResult.name || '—'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
ISO Code
</p>
<div className="mt-2">
<StatusBadge
label={geoIPLookupResult.iso_code || '—'}
variant="info"
/>
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
经纬度
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
{geoIPLookupResult.latitude !== undefined &&
geoIPLookupResult.latitude !== null &&
geoIPLookupResult.longitude !== undefined &&
geoIPLookupResult.longitude !== null
? `${geoIPLookupResult.latitude.toFixed(4)}, ${geoIPLookupResult.longitude.toFixed(4)}`
: '—'}
</p>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
</AppCard>
@@ -1281,7 +1383,9 @@ export function SettingsPage() {
}
disabled={busyKey === 'system-general'}
>
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
{busyKey === 'system-general'
? '保存中...'
: '保存通用设置'}
</PrimaryButton>
</div>
}
@@ -1904,9 +2008,7 @@ export function SettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="设置"
/>
<PageHeader title="设置" />
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
@@ -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;