mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
[优化] 新增 GeoIP 测试功能,更新相关 API 路由和前端组件
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user