feat(ip-location): implement IP location querying and GeoIP database management

This commit is contained in:
Tension 2025-11-23 22:38:55 +08:00
parent e3999ba75f
commit c4166cef6b
10 changed files with 237 additions and 1 deletions

View File

@ -17,6 +17,14 @@ type (
VersionResponse {
Version string `json:"version"`
}
QueryIPLocationRequest {
IP string `form:"ip" validate:"required"`
}
QueryIPLocationResponse {
Country string `json:"country"`
Region string `json:"region,omitempty"`
City string `json:"city"`
}
)
@server (
@ -36,5 +44,9 @@ service ppanel {
@doc "Get Version"
@handler GetVersion
get /version returns (VersionResponse)
@doc "Query IP Location"
@handler QueryIPLocation
get /ip/location (QueryIPLocationRequest) returns (QueryIPLocationResponse)
}

View File

@ -0,0 +1,39 @@
Host: 0.0.0.0
Port: 8080
TLS:
Enable: false
CertFile: ""
KeyFile: ""
Debug: false
JwtAuth:
AccessSecret: 4672dccd-2c50-4833-8e17-81e6c2119893
AccessExpire: 604800
Logger:
ServiceName: PPanel
Mode: console
Encoding: plain
TimeFormat: "2006-01-02 15:04:05.000"
Path: logs
Level: info
MaxContentLength: 0
Compress: false
Stat: true
KeepDays: 0
StackCooldownMillis: 100
MaxBackups: 0
MaxSize: 0
Rotation: daily
FileTimeFormat: 2006-01-02T15:04:05.000Z07:00
MySQL:
Addr: localhost:3306
Username: root
Password: password
Dbname: ppanel_dev
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
MaxIdleConns: 10
MaxOpenConns: 10
SlowThreshold: 1000
Redis:
Host: 127.0.0.1:6379
Pass: ""
DB: 0

2
go.mod
View File

@ -60,6 +60,7 @@ require (
github.com/fatih/color v1.18.0
github.com/goccy/go-json v0.10.4
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/oschwald/geoip2-golang v1.13.0
github.com/spaolacci/murmur3 v1.1.0
google.golang.org/grpc v1.64.1
google.golang.org/protobuf v1.36.3
@ -117,6 +118,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect

4
go.sum
View File

@ -285,6 +285,10 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA=
github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=

View File

@ -0,0 +1,26 @@
package tool
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/tool"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// QueryIPLocationHandler Query IP Location
func QueryIPLocationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.QueryIPLocationRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := tool.NewQueryIPLocationLogic(c.Request.Context(), svcCtx)
resp, err := l.QueryIPLocation(&req)
result.HttpResult(c, resp, err)
}
}

View File

@ -10,7 +10,6 @@ import (
// Get Client
func GetClientHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := common.NewGetClientLogic(c.Request.Context(), svcCtx)
resp, err := l.GetClient()
result.HttpResult(c, resp, err)

View File

@ -0,0 +1,57 @@
package tool
import (
"context"
"net"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type QueryIPLocationLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewQueryIPLocationLogic Query IP Location
func NewQueryIPLocationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryIPLocationLogic {
return &QueryIPLocationLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryIPLocationLogic) QueryIPLocation(req *types.QueryIPLocationRequest) (resp *types.QueryIPLocationResponse, err error) {
if l.svcCtx.GeoIP == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), " GeoIP database not configured")
}
ip := net.ParseIP(req.IP)
record, err := l.svcCtx.GeoIP.DB.City(ip)
if err != nil {
l.Errorf("Failed to query IP location: %v", err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to query IP location")
}
var country, region, city string
if record.Country.Names != nil {
country = record.Country.Names["en"]
}
if len(record.Subdivisions) > 0 && record.Subdivisions[0].Names != nil {
region = record.Subdivisions[0].Names["en"]
}
if record.City.Names != nil {
city = record.City.Names["en"]
}
return &types.QueryIPLocationResponse{
Country: country,
Region: region,
City: city,
}, nil
}

74
internal/svc/mmdb.go Normal file
View File

@ -0,0 +1,74 @@
package svc
import (
"io"
"net/http"
"os"
"path/filepath"
"github.com/oschwald/geoip2-golang"
"github.com/perfect-panel/server/pkg/logger"
)
const GeoIPDBURL = "https://raw.githubusercontent.com/adysec/IP_database/main/geolite/GeoLite2-City.mmdb"
type IPLocation struct {
Path string
DB *geoip2.Reader
}
func NewIPLocation(path string) (*IPLocation, error) {
// 检查文件是否存在
if _, err := os.Stat(path); os.IsNotExist(err) {
logger.Infof("[GeoIP] Database not found, downloading from %s", GeoIPDBURL)
// 文件不存在,下载数据库
err := DownloadGeoIPDatabase(GeoIPDBURL, path)
if err != nil {
logger.Errorf("[GeoIP] Failed to download database: %v", err.Error())
return nil, err
}
logger.Infof("[GeoIP] Database downloaded successfully")
}
db, err := geoip2.Open(path)
if err != nil {
return nil, err
}
return &IPLocation{
Path: path,
DB: db,
}, nil
}
func (ipLoc *IPLocation) Close() error {
return ipLoc.DB.Close()
}
func DownloadGeoIPDatabase(url, path string) error {
// 创建路径, 确保目录存在
err := os.MkdirAll(filepath.Dir(path), 0755)
if err != nil {
logger.Errorf("[GeoIP] Failed to create directory: %v", err.Error())
return err
}
// 创建文件
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
// 请求远程文件
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// 保存文件
_, err = io.Copy(out, resp.Body)
return err
}

View File

@ -37,6 +37,7 @@ type ServiceContext struct {
Config config.Config
Queue *asynq.Client
ExchangeRate float64
GeoIP *IPLocation
//NodeCache *cache.NodeCacheClient
AuthModel auth.Model
@ -68,9 +69,17 @@ func NewServiceContext(c config.Config) *ServiceContext {
db, err := orm.ConnectMysql(orm.Mysql{
Config: c.MySQL,
})
if err != nil {
panic(err.Error())
}
// IP location initialize
geoIP, err := NewIPLocation("./cache/GeoLite2-City.mmdb")
if err != nil {
panic(err.Error())
}
rds := redis.NewClient(&redis.Options{
Addr: c.Redis.Host,
Password: c.Redis.Pass,
@ -89,6 +98,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
Config: c,
Queue: NewAsynqClient(c),
ExchangeRate: 1.0,
GeoIP: geoIP,
//NodeCache: cache.NewNodeCacheClient(rds),
AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds),

View File

@ -1571,6 +1571,16 @@ type QueryDocumentListResponse struct {
List []Document `json:"list"`
}
type QueryIPLocationRequest struct {
IP string `form:"ip" validate:"required"`
}
type QueryIPLocationResponse struct {
Country string `json:"country"`
Region string `json:"regio,omitempty"`
City string `json:"city"`
}
type QueryNodeTagResponse struct {
Tags []string `json:"tags"`
}
@ -2598,6 +2608,7 @@ type UserSubscribe struct {
Token string `json:"token"`
Status uint8 `json:"status"`
Short string `json:"short"`
Note string `json:"note"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@ -2617,6 +2628,7 @@ type UserSubscribeDetail struct {
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
Note string `json:"note"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@ -2635,6 +2647,7 @@ type UserSubscribeInfo struct {
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
Note string `json:"note"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
IsTryOut bool `json:"is_try_out"`