add
This commit is contained in:
3
go.mod
3
go.mod
@@ -94,12 +94,15 @@ require (
|
|||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260313013624-04e51e218220 // indirect
|
||||||
github.com/mailru/easyjson v0.7.6 // indirect
|
github.com/mailru/easyjson v0.7.6 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
|
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -248,6 +248,8 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx
|
|||||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260313013624-04e51e218220 h1:FLQyP/6tTsTEtAhcIq/kS/zkDEMdOMon0I70pXVehOU=
|
||||||
|
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260313013624-04e51e218220/go.mod h1:+mNMTBuDMdEGhWzoQgc6kBdqeaQpWh5ba8zqmp2MxCU=
|
||||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||||
@@ -266,6 +268,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
|
|||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||||
|
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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
articleEntities "tyapi-server/internal/domains/article/entities"
|
articleEntities "tyapi-server/internal/domains/article/entities"
|
||||||
|
|
||||||
// 统计域实体
|
// 统计域实体
|
||||||
|
securityEntities "tyapi-server/internal/domains/security/entities"
|
||||||
statisticsEntities "tyapi-server/internal/domains/statistics/entities"
|
statisticsEntities "tyapi-server/internal/domains/statistics/entities"
|
||||||
|
|
||||||
apiEntities "tyapi-server/internal/domains/api/entities"
|
apiEntities "tyapi-server/internal/domains/api/entities"
|
||||||
@@ -256,6 +257,7 @@ func (a *Application) autoMigrate(db *gorm.DB) error {
|
|||||||
&statisticsEntities.StatisticsMetric{},
|
&statisticsEntities.StatisticsMetric{},
|
||||||
&statisticsEntities.StatisticsDashboard{},
|
&statisticsEntities.StatisticsDashboard{},
|
||||||
&statisticsEntities.StatisticsReport{},
|
&statisticsEntities.StatisticsReport{},
|
||||||
|
&securityEntities.SuspiciousIPRecord{},
|
||||||
|
|
||||||
// api
|
// api
|
||||||
&apiEntities.ApiUser{},
|
&apiEntities.ApiUser{},
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import (
|
|||||||
"tyapi-server/internal/shared/hooks"
|
"tyapi-server/internal/shared/hooks"
|
||||||
sharedhttp "tyapi-server/internal/shared/http"
|
sharedhttp "tyapi-server/internal/shared/http"
|
||||||
"tyapi-server/internal/shared/interfaces"
|
"tyapi-server/internal/shared/interfaces"
|
||||||
|
"tyapi-server/internal/shared/ipgeo"
|
||||||
"tyapi-server/internal/shared/logger"
|
"tyapi-server/internal/shared/logger"
|
||||||
"tyapi-server/internal/shared/metrics"
|
"tyapi-server/internal/shared/metrics"
|
||||||
"tyapi-server/internal/shared/middleware"
|
"tyapi-server/internal/shared/middleware"
|
||||||
@@ -418,6 +419,7 @@ func NewContainer() *Container {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
sharedhttp.NewGinRouter,
|
sharedhttp.NewGinRouter,
|
||||||
|
ipgeo.NewLocator,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 中间件组件
|
// 中间件组件
|
||||||
@@ -428,7 +430,7 @@ func NewContainer() *Container {
|
|||||||
middleware.NewCORSMiddleware,
|
middleware.NewCORSMiddleware,
|
||||||
middleware.NewRateLimitMiddleware,
|
middleware.NewRateLimitMiddleware,
|
||||||
// 每日限流中间件
|
// 每日限流中间件
|
||||||
func(cfg *config.Config, redis *redis.Client, response interfaces.ResponseBuilder, logger *zap.Logger) *middleware.DailyRateLimitMiddleware {
|
func(cfg *config.Config, redis *redis.Client, db *gorm.DB, response interfaces.ResponseBuilder, logger *zap.Logger) *middleware.DailyRateLimitMiddleware {
|
||||||
limitConfig := middleware.DailyRateLimitConfig{
|
limitConfig := middleware.DailyRateLimitConfig{
|
||||||
MaxRequestsPerDay: cfg.DailyRateLimit.MaxRequestsPerDay,
|
MaxRequestsPerDay: cfg.DailyRateLimit.MaxRequestsPerDay,
|
||||||
MaxRequestsPerIP: cfg.DailyRateLimit.MaxRequestsPerIP,
|
MaxRequestsPerIP: cfg.DailyRateLimit.MaxRequestsPerIP,
|
||||||
@@ -452,7 +454,7 @@ func NewContainer() *Container {
|
|||||||
// 排除域名配置
|
// 排除域名配置
|
||||||
ExcludeDomains: cfg.DailyRateLimit.ExcludeDomains,
|
ExcludeDomains: cfg.DailyRateLimit.ExcludeDomains,
|
||||||
}
|
}
|
||||||
return middleware.NewDailyRateLimitMiddleware(cfg, redis, response, logger, limitConfig)
|
return middleware.NewDailyRateLimitMiddleware(cfg, redis, db, response, logger, limitConfig)
|
||||||
},
|
},
|
||||||
NewRequestLoggerMiddlewareWrapper,
|
NewRequestLoggerMiddlewareWrapper,
|
||||||
middleware.NewJWTAuthMiddleware,
|
middleware.NewJWTAuthMiddleware,
|
||||||
@@ -1244,6 +1246,8 @@ func NewContainer() *Container {
|
|||||||
handlers.NewApiHandler,
|
handlers.NewApiHandler,
|
||||||
// 统计HTTP处理器
|
// 统计HTTP处理器
|
||||||
handlers.NewStatisticsHandler,
|
handlers.NewStatisticsHandler,
|
||||||
|
// 管理员安全HTTP处理器
|
||||||
|
handlers.NewAdminSecurityHandler,
|
||||||
// 文章HTTP处理器
|
// 文章HTTP处理器
|
||||||
func(
|
func(
|
||||||
appService article.ArticleApplicationService,
|
appService article.ArticleApplicationService,
|
||||||
@@ -1335,6 +1339,8 @@ func NewContainer() *Container {
|
|||||||
routes.NewApiRoutes,
|
routes.NewApiRoutes,
|
||||||
// 统计路由
|
// 统计路由
|
||||||
routes.NewStatisticsRoutes,
|
routes.NewStatisticsRoutes,
|
||||||
|
// 管理员安全路由
|
||||||
|
routes.NewAdminSecurityRoutes,
|
||||||
// PDFG路由
|
// PDFG路由
|
||||||
routes.NewPDFGRoutes,
|
routes.NewPDFGRoutes,
|
||||||
// 企业报告页面路由
|
// 企业报告页面路由
|
||||||
@@ -1453,6 +1459,7 @@ func RegisterRoutes(
|
|||||||
announcementRoutes *routes.AnnouncementRoutes,
|
announcementRoutes *routes.AnnouncementRoutes,
|
||||||
apiRoutes *routes.ApiRoutes,
|
apiRoutes *routes.ApiRoutes,
|
||||||
statisticsRoutes *routes.StatisticsRoutes,
|
statisticsRoutes *routes.StatisticsRoutes,
|
||||||
|
adminSecurityRoutes *routes.AdminSecurityRoutes,
|
||||||
pdfgRoutes *routes.PDFGRoutes,
|
pdfgRoutes *routes.PDFGRoutes,
|
||||||
qyglReportRoutes *routes.QYGLReportRoutes,
|
qyglReportRoutes *routes.QYGLReportRoutes,
|
||||||
jwtAuth *middleware.JWTAuthMiddleware,
|
jwtAuth *middleware.JWTAuthMiddleware,
|
||||||
@@ -1479,6 +1486,7 @@ func RegisterRoutes(
|
|||||||
articleRoutes.Register(router)
|
articleRoutes.Register(router)
|
||||||
announcementRoutes.Register(router)
|
announcementRoutes.Register(router)
|
||||||
statisticsRoutes.Register(router)
|
statisticsRoutes.Register(router)
|
||||||
|
adminSecurityRoutes.Register(router)
|
||||||
pdfgRoutes.Register(router)
|
pdfgRoutes.Register(router)
|
||||||
qyglReportRoutes.Register(router)
|
qyglReportRoutes.Register(router)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ func ProcessYYSYK9R4Request(ctx context.Context, params []byte, deps *processors
|
|||||||
return nil, errors.Join(processors.ErrInvalidParam, err)
|
return nil, errors.Join(processors.ErrInvalidParam, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建数据宝入参:姓名、身份证、手机号、银行卡号(sign 外的业务参数可按需 AES 加密后作为 bodyData)
|
|
||||||
reqParams := map[string]interface{}{
|
reqParams := map[string]interface{}{
|
||||||
"key": "c115708d915451da8f34a23e144dda6b",
|
"key": "c115708d915451da8f34a23e144dda6b",
|
||||||
"name": paramsDto.Name,
|
"name": paramsDto.Name,
|
||||||
|
|||||||
21
internal/domains/security/entities/suspicious_ip_record.go
Normal file
21
internal/domains/security/entities/suspicious_ip_record.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SuspiciousIPRecord 可疑IP请求记录
|
||||||
|
type SuspiciousIPRecord struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
IP string `gorm:"type:varchar(64);not null;index:idx_ip_created,priority:1" json:"ip"`
|
||||||
|
Path string `gorm:"type:varchar(255);not null;index:idx_path_created,priority:1" json:"path"`
|
||||||
|
Method string `gorm:"type:varchar(16);not null;default:GET" json:"method"`
|
||||||
|
RequestCount int `gorm:"not null;default:1" json:"request_count"`
|
||||||
|
WindowSeconds int `gorm:"not null;default:10" json:"window_seconds"`
|
||||||
|
TriggerReason string `gorm:"type:varchar(64);not null;default:rate_limit" json:"trigger_reason"`
|
||||||
|
UserAgent string `gorm:"type:varchar(512);not null;default:''" json:"user_agent"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_ip_created,priority:2;index:idx_path_created,priority:2;index:idx_created" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (SuspiciousIPRecord) TableName() string {
|
||||||
|
return "suspicious_ip_records"
|
||||||
|
}
|
||||||
168
internal/infrastructure/http/handlers/admin_security_handler.go
Normal file
168
internal/infrastructure/http/handlers/admin_security_handler.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
securityEntities "tyapi-server/internal/domains/security/entities"
|
||||||
|
"tyapi-server/internal/shared/interfaces"
|
||||||
|
"tyapi-server/internal/shared/ipgeo"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminSecurityHandler 管理员安全数据处理器
|
||||||
|
type AdminSecurityHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
responseBuilder interfaces.ResponseBuilder
|
||||||
|
logger *zap.Logger
|
||||||
|
ipLocator *ipgeo.Locator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminSecurityHandler(
|
||||||
|
db *gorm.DB,
|
||||||
|
responseBuilder interfaces.ResponseBuilder,
|
||||||
|
logger *zap.Logger,
|
||||||
|
ipLocator *ipgeo.Locator,
|
||||||
|
) *AdminSecurityHandler {
|
||||||
|
return &AdminSecurityHandler{
|
||||||
|
db: db,
|
||||||
|
responseBuilder: responseBuilder,
|
||||||
|
logger: logger,
|
||||||
|
ipLocator: ipLocator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminSecurityHandler) getIntQuery(c *gin.Context, key string, defaultValue int) int {
|
||||||
|
if value := c.Query(key); value != "" {
|
||||||
|
if intValue, err := strconv.Atoi(value); err == nil && intValue > 0 {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminSecurityHandler) parseRange(c *gin.Context) (time.Time, time.Time, bool) {
|
||||||
|
startTime := time.Now().Add(-24 * time.Hour)
|
||||||
|
endTime := time.Now()
|
||||||
|
|
||||||
|
if start := strings.TrimSpace(c.Query("start_time")); start != "" {
|
||||||
|
t, err := time.Parse("2006-01-02 15:04:05", start)
|
||||||
|
if err != nil {
|
||||||
|
h.responseBuilder.BadRequest(c, "start_time格式错误,示例:2026-03-19 10:00:00")
|
||||||
|
return time.Time{}, time.Time{}, false
|
||||||
|
}
|
||||||
|
startTime = t
|
||||||
|
}
|
||||||
|
if end := strings.TrimSpace(c.Query("end_time")); end != "" {
|
||||||
|
t, err := time.Parse("2006-01-02 15:04:05", end)
|
||||||
|
if err != nil {
|
||||||
|
h.responseBuilder.BadRequest(c, "end_time格式错误,示例:2026-03-19 12:00:00")
|
||||||
|
return time.Time{}, time.Time{}, false
|
||||||
|
}
|
||||||
|
endTime = t
|
||||||
|
}
|
||||||
|
return startTime, endTime, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSuspiciousIPs 获取可疑IP列表
|
||||||
|
func (h *AdminSecurityHandler) ListSuspiciousIPs(c *gin.Context) {
|
||||||
|
page := h.getIntQuery(c, "page", 1)
|
||||||
|
pageSize := h.getIntQuery(c, "page_size", 20)
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
startTime, endTime, ok := h.parseRange(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := strings.TrimSpace(c.Query("ip"))
|
||||||
|
path := strings.TrimSpace(c.Query("path"))
|
||||||
|
|
||||||
|
query := h.db.Model(&securityEntities.SuspiciousIPRecord{}).
|
||||||
|
Where("created_at >= ? AND created_at <= ?", startTime, endTime)
|
||||||
|
if ip != "" {
|
||||||
|
query = query.Where("ip = ?", ip)
|
||||||
|
}
|
||||||
|
if path != "" {
|
||||||
|
query = query.Where("path LIKE ?", "%"+path+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
h.logger.Error("查询可疑IP总数失败", zap.Error(err))
|
||||||
|
h.responseBuilder.InternalError(c, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []securityEntities.SuspiciousIPRecord
|
||||||
|
if err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error; err != nil {
|
||||||
|
h.logger.Error("查询可疑IP列表失败", zap.Error(err))
|
||||||
|
h.responseBuilder.InternalError(c, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseBuilder.Success(c, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoStreamRow struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSuspiciousIPGeoStream 获取地球请求流数据
|
||||||
|
func (h *AdminSecurityHandler) GetSuspiciousIPGeoStream(c *gin.Context) {
|
||||||
|
startTime, endTime, ok := h.parseRange(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
topN := h.getIntQuery(c, "top_n", 200)
|
||||||
|
if topN > 1000 {
|
||||||
|
topN = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []geoStreamRow
|
||||||
|
err := h.db.Model(&securityEntities.SuspiciousIPRecord{}).
|
||||||
|
Select("ip, path, COUNT(1) as count").
|
||||||
|
Where("created_at >= ? AND created_at <= ?", startTime, endTime).
|
||||||
|
Group("ip, path").
|
||||||
|
Order("count DESC").
|
||||||
|
Limit(topN).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("查询地球请求流失败", zap.Error(err))
|
||||||
|
h.responseBuilder.InternalError(c, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标固定服务器点位(上海)
|
||||||
|
const serverName = "TYAPI-Server"
|
||||||
|
const serverLng = 121.4737
|
||||||
|
const serverLat = 31.2304
|
||||||
|
|
||||||
|
result := make([]gin.H, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
record := securityEntities.SuspiciousIPRecord{IP: row.IP}
|
||||||
|
fromName, fromLng, fromLat := h.ipLocator.ToGeoPoint(record)
|
||||||
|
result = append(result, gin.H{
|
||||||
|
"from_name": fromName,
|
||||||
|
"from_lng": fromLng,
|
||||||
|
"from_lat": fromLat,
|
||||||
|
"to_name": serverName,
|
||||||
|
"to_lng": serverLng,
|
||||||
|
"to_lat": serverLat,
|
||||||
|
"value": row.Count,
|
||||||
|
"path": row.Path,
|
||||||
|
"ip": row.IP,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseBuilder.Success(c, result, "获取成功")
|
||||||
|
}
|
||||||
39
internal/infrastructure/http/routes/admin_security_routes.go
Normal file
39
internal/infrastructure/http/routes/admin_security_routes.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tyapi-server/internal/infrastructure/http/handlers"
|
||||||
|
sharedhttp "tyapi-server/internal/shared/http"
|
||||||
|
"tyapi-server/internal/shared/middleware"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminSecurityRoutes 管理端安全路由
|
||||||
|
type AdminSecurityRoutes struct {
|
||||||
|
handler *handlers.AdminSecurityHandler
|
||||||
|
admin *middleware.AdminAuthMiddleware
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminSecurityRoutes(
|
||||||
|
handler *handlers.AdminSecurityHandler,
|
||||||
|
admin *middleware.AdminAuthMiddleware,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *AdminSecurityRoutes {
|
||||||
|
return &AdminSecurityRoutes{
|
||||||
|
handler: handler,
|
||||||
|
admin: admin,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminSecurityRoutes) Register(router *sharedhttp.GinRouter) {
|
||||||
|
engine := router.GetEngine()
|
||||||
|
group := engine.Group("/api/v1/admin/security")
|
||||||
|
group.Use(r.admin.Handle())
|
||||||
|
{
|
||||||
|
group.GET("/suspicious-ip/list", r.handler.ListSuspiciousIPs)
|
||||||
|
group.GET("/suspicious-ip/geo-stream", r.handler.GetSuspiciousIPGeoStream)
|
||||||
|
}
|
||||||
|
r.logger.Info("管理员安全路由注册完成")
|
||||||
|
}
|
||||||
26
internal/shared/ipgeo/city_coords.go
Normal file
26
internal/shared/ipgeo/city_coords.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package ipgeo
|
||||||
|
|
||||||
|
// Coord 城市经纬度
|
||||||
|
type Coord struct {
|
||||||
|
Lng float64
|
||||||
|
Lat float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CityCoordinates MVP阶段常用城市坐标
|
||||||
|
var CityCoordinates = map[string]Coord{
|
||||||
|
"北京市": {Lng: 116.4074, Lat: 39.9042},
|
||||||
|
"上海市": {Lng: 121.4737, Lat: 31.2304},
|
||||||
|
"广州市": {Lng: 113.2644, Lat: 23.1291},
|
||||||
|
"深圳市": {Lng: 114.0579, Lat: 22.5431},
|
||||||
|
"杭州市": {Lng: 120.1551, Lat: 30.2741},
|
||||||
|
"成都市": {Lng: 104.0665, Lat: 30.5728},
|
||||||
|
"武汉市": {Lng: 114.3055, Lat: 30.5928},
|
||||||
|
"西安市": {Lng: 108.9398, Lat: 34.3416},
|
||||||
|
"南京市": {Lng: 118.7969, Lat: 32.0603},
|
||||||
|
"苏州市": {Lng: 120.5853, Lat: 31.2989},
|
||||||
|
"重庆市": {Lng: 106.5516, Lat: 29.5630},
|
||||||
|
"天津市": {Lng: 117.2009, Lat: 39.0842},
|
||||||
|
"郑州市": {Lng: 113.6254, Lat: 34.7466},
|
||||||
|
"长沙市": {Lng: 112.9388, Lat: 28.2282},
|
||||||
|
"青岛市": {Lng: 120.3826, Lat: 36.0671},
|
||||||
|
}
|
||||||
134
internal/shared/ipgeo/ip_locator.go
Normal file
134
internal/shared/ipgeo/ip_locator.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package ipgeo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"tyapi-server/internal/domains/security/entities"
|
||||||
|
|
||||||
|
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Location IP解析后的地理信息
|
||||||
|
type Location struct {
|
||||||
|
Country string
|
||||||
|
Province string
|
||||||
|
City string
|
||||||
|
ISP string
|
||||||
|
Region string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locator IP地理定位器
|
||||||
|
type Locator struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
searcher *xdb.Searcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocator 创建定位器,优先读取 resources/ipgeo/ip2region.xdb
|
||||||
|
func NewLocator(logger *zap.Logger) *Locator {
|
||||||
|
locator := &Locator{logger: logger}
|
||||||
|
dbPath := filepath.Join("resources", "ipgeo", "ip2region.xdb")
|
||||||
|
|
||||||
|
cBuff, err := xdb.LoadContentFromFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("加载ip2region库失败,将使用降级定位", zap.String("db_path", dbPath), zap.Error(err))
|
||||||
|
return locator
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := xdb.LoadHeaderFromBuff(cBuff)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("读取ip2region头信息失败,将使用降级定位", zap.Error(err))
|
||||||
|
return locator
|
||||||
|
}
|
||||||
|
version, err := xdb.VersionFromHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("解析ip2region版本失败,将使用降级定位", zap.Error(err))
|
||||||
|
return locator
|
||||||
|
}
|
||||||
|
|
||||||
|
searcher, err := xdb.NewWithBuffer(version, cBuff)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("初始化ip2region搜索器失败,将使用降级定位", zap.Error(err))
|
||||||
|
return locator
|
||||||
|
}
|
||||||
|
locator.searcher = searcher
|
||||||
|
|
||||||
|
logger.Info("ip2region定位器初始化成功", zap.String("db_path", dbPath))
|
||||||
|
return locator
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupByIP 根据IP定位,失败返回 false
|
||||||
|
func (l *Locator) LookupByIP(ip string) (Location, bool) {
|
||||||
|
if ip == "" || isPrivateOrLocalIP(ip) || l.searcher == nil {
|
||||||
|
return Location{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
region, err := l.searcher.SearchByStr(ip)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Debug("ip2region查询失败", zap.String("ip", ip), zap.Error(err))
|
||||||
|
return Location{}, false
|
||||||
|
}
|
||||||
|
loc := parseRegion(region)
|
||||||
|
if loc.Region == "" {
|
||||||
|
return Location{}, false
|
||||||
|
}
|
||||||
|
return loc, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToGeoPoint 将记录转换为地球飞线起点
|
||||||
|
func (l *Locator) ToGeoPoint(record entities.SuspiciousIPRecord) (fromName string, lng float64, lat float64) {
|
||||||
|
// 默认降级坐标:北京
|
||||||
|
const defaultLng = 116.4074
|
||||||
|
const defaultLat = 39.9042
|
||||||
|
|
||||||
|
loc, ok := l.LookupByIP(record.IP)
|
||||||
|
if !ok {
|
||||||
|
return record.IP, defaultLng, defaultLat
|
||||||
|
}
|
||||||
|
|
||||||
|
cityName := strings.TrimSpace(loc.City)
|
||||||
|
if cityName == "" || cityName == "0" {
|
||||||
|
cityName = strings.TrimSpace(loc.Province)
|
||||||
|
}
|
||||||
|
if cityName == "" || cityName == "0" {
|
||||||
|
return record.IP, defaultLng, defaultLat
|
||||||
|
}
|
||||||
|
|
||||||
|
coord, exists := CityCoordinates[cityName]
|
||||||
|
if !exists {
|
||||||
|
// 降级:未命中城市映射,回默认坐标
|
||||||
|
return cityName, defaultLng, defaultLat
|
||||||
|
}
|
||||||
|
return cityName, coord.Lng, coord.Lat
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRegion(region string) Location {
|
||||||
|
parts := strings.Split(region, "|")
|
||||||
|
for len(parts) < 5 {
|
||||||
|
parts = append(parts, "")
|
||||||
|
}
|
||||||
|
return Location{
|
||||||
|
Country: normalizeField(parts[0]),
|
||||||
|
Region: normalizeField(parts[1]),
|
||||||
|
Province: normalizeField(parts[2]),
|
||||||
|
City: normalizeField(parts[3]),
|
||||||
|
ISP: normalizeField(parts[4]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeField(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "0" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrivateOrLocalIP(ip string) bool {
|
||||||
|
parsed := net.ParseIP(ip)
|
||||||
|
if parsed == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsUnspecified() || parsed.IsLinkLocalUnicast()
|
||||||
|
}
|
||||||
@@ -3,16 +3,19 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tyapi-server/internal/config"
|
"tyapi-server/internal/config"
|
||||||
|
securityEntities "tyapi-server/internal/domains/security/entities"
|
||||||
"tyapi-server/internal/shared/interfaces"
|
"tyapi-server/internal/shared/interfaces"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DailyRateLimitConfig 每日限流配置
|
// DailyRateLimitConfig 每日限流配置
|
||||||
@@ -45,6 +48,7 @@ type DailyRateLimitConfig struct {
|
|||||||
type DailyRateLimitMiddleware struct {
|
type DailyRateLimitMiddleware struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
|
db *gorm.DB
|
||||||
response interfaces.ResponseBuilder
|
response interfaces.ResponseBuilder
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
limitConfig DailyRateLimitConfig
|
limitConfig DailyRateLimitConfig
|
||||||
@@ -54,6 +58,7 @@ type DailyRateLimitMiddleware struct {
|
|||||||
func NewDailyRateLimitMiddleware(
|
func NewDailyRateLimitMiddleware(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
redis *redis.Client,
|
redis *redis.Client,
|
||||||
|
db *gorm.DB,
|
||||||
response interfaces.ResponseBuilder,
|
response interfaces.ResponseBuilder,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
limitConfig DailyRateLimitConfig,
|
limitConfig DailyRateLimitConfig,
|
||||||
@@ -78,6 +83,7 @@ func NewDailyRateLimitMiddleware(
|
|||||||
return &DailyRateLimitMiddleware{
|
return &DailyRateLimitMiddleware{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
redis: redis,
|
redis: redis,
|
||||||
|
db: db,
|
||||||
response: response,
|
response: response,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
limitConfig: limitConfig,
|
limitConfig: limitConfig,
|
||||||
@@ -154,7 +160,9 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 检查并发限制
|
// 4. 检查并发限制
|
||||||
if err := m.checkConcurrentLimit(ctx, clientIP); err != nil {
|
concurrentCount, err := m.checkConcurrentLimit(ctx, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
m.recordSuspiciousRequest(c, clientIP, "daily_concurrent_limit")
|
||||||
m.logger.Warn("并发请求超限",
|
m.logger.Warn("并发请求超限",
|
||||||
zap.String("ip", clientIP),
|
zap.String("ip", clientIP),
|
||||||
zap.String("request_id", c.GetString("request_id")),
|
zap.String("request_id", c.GetString("request_id")),
|
||||||
@@ -163,9 +171,14 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if m.shouldRecordNearLimit(concurrentCount, m.limitConfig.MaxConcurrent) {
|
||||||
|
m.recordSuspiciousRequest(c, clientIP, "daily_concurrent_limit")
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 检查接口总请求次数限制
|
// 5. 检查接口总请求次数限制
|
||||||
if err := m.checkTotalLimit(ctx); err != nil {
|
totalCount, err := m.checkTotalLimit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.recordSuspiciousRequest(c, clientIP, "daily_total_limit")
|
||||||
m.logger.Warn("接口总请求次数超限",
|
m.logger.Warn("接口总请求次数超限",
|
||||||
zap.String("ip", clientIP),
|
zap.String("ip", clientIP),
|
||||||
zap.String("request_id", c.GetString("request_id")),
|
zap.String("request_id", c.GetString("request_id")),
|
||||||
@@ -175,9 +188,14 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if m.shouldRecordNearLimit(totalCount+1, m.limitConfig.MaxRequestsPerDay) {
|
||||||
|
m.recordSuspiciousRequest(c, clientIP, "daily_total_limit")
|
||||||
|
}
|
||||||
|
|
||||||
// 6. 检查IP限制
|
// 6. 检查IP限制
|
||||||
if err := m.checkIPLimit(ctx, clientIP); err != nil {
|
ipCount, err := m.checkIPLimit(ctx, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
m.recordSuspiciousRequest(c, clientIP, "daily_ip_limit")
|
||||||
m.logger.Warn("IP请求次数超限",
|
m.logger.Warn("IP请求次数超限",
|
||||||
zap.String("ip", clientIP),
|
zap.String("ip", clientIP),
|
||||||
zap.String("request_id", c.GetString("request_id")),
|
zap.String("request_id", c.GetString("request_id")),
|
||||||
@@ -187,6 +205,9 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if m.shouldRecordNearLimit(ipCount+1, m.limitConfig.MaxRequestsPerIP) {
|
||||||
|
m.recordSuspiciousRequest(c, clientIP, "daily_ip_limit")
|
||||||
|
}
|
||||||
|
|
||||||
// 7. 增加计数
|
// 7. 增加计数
|
||||||
m.incrementCounters(ctx, clientIP)
|
m.incrementCounters(ctx, clientIP)
|
||||||
@@ -198,6 +219,38 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *DailyRateLimitMiddleware) recordSuspiciousRequest(c *gin.Context, ip, reason string) {
|
||||||
|
if m.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := securityEntities.SuspiciousIPRecord{
|
||||||
|
IP: ip,
|
||||||
|
Path: c.Request.URL.Path,
|
||||||
|
Method: c.Request.Method,
|
||||||
|
RequestCount: 1,
|
||||||
|
WindowSeconds: int(m.limitConfig.TTL.Seconds()),
|
||||||
|
TriggerReason: reason,
|
||||||
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
|
}
|
||||||
|
if record.WindowSeconds <= 0 {
|
||||||
|
record.WindowSeconds = 10
|
||||||
|
}
|
||||||
|
if err := m.db.Create(&record).Error; err != nil {
|
||||||
|
m.logger.Warn("记录每日限流可疑IP失败", zap.String("ip", ip), zap.String("reason", reason), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DailyRateLimitMiddleware) shouldRecordNearLimit(current, max int) bool {
|
||||||
|
if max <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
threshold := int(math.Ceil(float64(max) * 0.8))
|
||||||
|
if threshold < 1 {
|
||||||
|
threshold = 1
|
||||||
|
}
|
||||||
|
return current >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
// isExcludedDomain 检查域名是否在排除列表中
|
// isExcludedDomain 检查域名是否在排除列表中
|
||||||
func (m *DailyRateLimitMiddleware) isExcludedDomain(host string) bool {
|
func (m *DailyRateLimitMiddleware) isExcludedDomain(host string) bool {
|
||||||
for _, excludeDomain := range m.limitConfig.ExcludeDomains {
|
for _, excludeDomain := range m.limitConfig.ExcludeDomains {
|
||||||
@@ -360,13 +413,13 @@ func (m *DailyRateLimitMiddleware) checkReferer(c *gin.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkConcurrentLimit 检查并发限制
|
// checkConcurrentLimit 检查并发限制
|
||||||
func (m *DailyRateLimitMiddleware) checkConcurrentLimit(ctx context.Context, clientIP string) error {
|
func (m *DailyRateLimitMiddleware) checkConcurrentLimit(ctx context.Context, clientIP string) (int, error) {
|
||||||
key := fmt.Sprintf("%s:concurrent:%s", m.limitConfig.KeyPrefix, clientIP)
|
key := fmt.Sprintf("%s:concurrent:%s", m.limitConfig.KeyPrefix, clientIP)
|
||||||
|
|
||||||
// 获取当前并发数
|
// 获取当前并发数
|
||||||
current, err := m.redis.Get(ctx, key).Result()
|
current, err := m.redis.Get(ctx, key).Result()
|
||||||
if err != nil && err != redis.Nil {
|
if err != nil && err != redis.Nil {
|
||||||
return fmt.Errorf("获取并发计数失败: %w", err)
|
return 0, fmt.Errorf("获取并发计数失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentCount := 0
|
currentCount := 0
|
||||||
@@ -377,7 +430,7 @@ func (m *DailyRateLimitMiddleware) checkConcurrentLimit(ctx context.Context, cli
|
|||||||
}
|
}
|
||||||
|
|
||||||
if currentCount >= m.limitConfig.MaxConcurrent {
|
if currentCount >= m.limitConfig.MaxConcurrent {
|
||||||
return fmt.Errorf("并发请求超限: %d", currentCount)
|
return currentCount, fmt.Errorf("并发请求超限: %d", currentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 增加并发计数
|
// 增加并发计数
|
||||||
@@ -390,7 +443,7 @@ func (m *DailyRateLimitMiddleware) checkConcurrentLimit(ctx context.Context, cli
|
|||||||
m.logger.Error("增加并发计数失败", zap.String("key", key), zap.Error(err))
|
m.logger.Error("增加并发计数失败", zap.String("key", key), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return currentCount + 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientIP 获取客户端IP地址(增强版)
|
// getClientIP 获取客户端IP地址(增强版)
|
||||||
@@ -435,35 +488,35 @@ func (m *DailyRateLimitMiddleware) getClientIP(c *gin.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkTotalLimit 检查接口总请求次数限制
|
// checkTotalLimit 检查接口总请求次数限制
|
||||||
func (m *DailyRateLimitMiddleware) checkTotalLimit(ctx context.Context) error {
|
func (m *DailyRateLimitMiddleware) checkTotalLimit(ctx context.Context) (int, error) {
|
||||||
key := fmt.Sprintf("%s:total:%s", m.limitConfig.KeyPrefix, m.getDateKey())
|
key := fmt.Sprintf("%s:total:%s", m.limitConfig.KeyPrefix, m.getDateKey())
|
||||||
|
|
||||||
count, err := m.getCounter(ctx, key)
|
count, err := m.getCounter(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("获取总请求计数失败: %w", err)
|
return 0, fmt.Errorf("获取总请求计数失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count >= m.limitConfig.MaxRequestsPerDay {
|
if count >= m.limitConfig.MaxRequestsPerDay {
|
||||||
return fmt.Errorf("接口今日总请求次数已达上限 %d", m.limitConfig.MaxRequestsPerDay)
|
return count, fmt.Errorf("接口今日总请求次数已达上限 %d", m.limitConfig.MaxRequestsPerDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkIPLimit 检查IP限制
|
// checkIPLimit 检查IP限制
|
||||||
func (m *DailyRateLimitMiddleware) checkIPLimit(ctx context.Context, clientIP string) error {
|
func (m *DailyRateLimitMiddleware) checkIPLimit(ctx context.Context, clientIP string) (int, error) {
|
||||||
key := fmt.Sprintf("%s:ip:%s:%s", m.limitConfig.KeyPrefix, clientIP, m.getDateKey())
|
key := fmt.Sprintf("%s:ip:%s:%s", m.limitConfig.KeyPrefix, clientIP, m.getDateKey())
|
||||||
|
|
||||||
count, err := m.getCounter(ctx, key)
|
count, err := m.getCounter(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("获取IP计数失败: %w", err)
|
return 0, fmt.Errorf("获取IP计数失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count >= m.limitConfig.MaxRequestsPerIP {
|
if count >= m.limitConfig.MaxRequestsPerIP {
|
||||||
return fmt.Errorf("IP %s 今日请求次数已达上限 %d", clientIP, m.limitConfig.MaxRequestsPerIP)
|
return count, fmt.Errorf("IP %s 今日请求次数已达上限 %d", clientIP, m.limitConfig.MaxRequestsPerIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// incrementCounters 增加计数器
|
// incrementCounters 增加计数器
|
||||||
|
|||||||
@@ -5,25 +5,32 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"tyapi-server/internal/config"
|
"tyapi-server/internal/config"
|
||||||
|
securityEntities "tyapi-server/internal/domains/security/entities"
|
||||||
"tyapi-server/internal/shared/interfaces"
|
"tyapi-server/internal/shared/interfaces"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RateLimitMiddleware 限流中间件
|
// RateLimitMiddleware 限流中间件
|
||||||
type RateLimitMiddleware struct {
|
type RateLimitMiddleware struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
response interfaces.ResponseBuilder
|
response interfaces.ResponseBuilder
|
||||||
|
db *gorm.DB
|
||||||
|
logger *zap.Logger
|
||||||
limiters map[string]*rate.Limiter
|
limiters map[string]*rate.Limiter
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRateLimitMiddleware 创建限流中间件
|
// NewRateLimitMiddleware 创建限流中间件
|
||||||
func NewRateLimitMiddleware(cfg *config.Config, response interfaces.ResponseBuilder) *RateLimitMiddleware {
|
func NewRateLimitMiddleware(cfg *config.Config, response interfaces.ResponseBuilder, db *gorm.DB, logger *zap.Logger) *RateLimitMiddleware {
|
||||||
return &RateLimitMiddleware{
|
return &RateLimitMiddleware{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
response: response,
|
response: response,
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
limiters: make(map[string]*rate.Limiter),
|
limiters: make(map[string]*rate.Limiter),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +56,8 @@ func (m *RateLimitMiddleware) Handle() gin.HandlerFunc {
|
|||||||
|
|
||||||
// 检查是否允许请求
|
// 检查是否允许请求
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
|
m.recordSuspiciousRequest(c, clientID, "rate_limit")
|
||||||
|
|
||||||
// 添加限流头部信息
|
// 添加限流头部信息
|
||||||
c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", m.config.RateLimit.Requests))
|
c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", m.config.RateLimit.Requests))
|
||||||
c.Header("X-RateLimit-Window", m.config.RateLimit.Window.String())
|
c.Header("X-RateLimit-Window", m.config.RateLimit.Window.String())
|
||||||
@@ -68,6 +77,28 @@ func (m *RateLimitMiddleware) Handle() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *RateLimitMiddleware) recordSuspiciousRequest(c *gin.Context, ip, reason string) {
|
||||||
|
if m.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
windowSeconds := int(m.config.RateLimit.Window.Seconds())
|
||||||
|
if windowSeconds <= 0 {
|
||||||
|
windowSeconds = 1
|
||||||
|
}
|
||||||
|
record := securityEntities.SuspiciousIPRecord{
|
||||||
|
IP: ip,
|
||||||
|
Path: c.Request.URL.Path,
|
||||||
|
Method: c.Request.Method,
|
||||||
|
RequestCount: 1,
|
||||||
|
WindowSeconds: windowSeconds,
|
||||||
|
TriggerReason: reason,
|
||||||
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
|
}
|
||||||
|
if err := m.db.Create(&record).Error; err != nil && m.logger != nil {
|
||||||
|
m.logger.Warn("记录可疑IP失败", zap.String("ip", ip), zap.String("path", record.Path), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IsGlobal 是否为全局中间件
|
// IsGlobal 是否为全局中间件
|
||||||
func (m *RateLimitMiddleware) IsGlobal() bool {
|
func (m *RateLimitMiddleware) IsGlobal() bool {
|
||||||
return true
|
return true
|
||||||
|
|||||||
BIN
resources/ipgeo/ip2region_v4.xdb
Normal file
BIN
resources/ipgeo/ip2region_v4.xdb
Normal file
Binary file not shown.
BIN
resources/ipgeo/ip2region_v6.xdb
Normal file
BIN
resources/ipgeo/ip2region_v6.xdb
Normal file
Binary file not shown.
Reference in New Issue
Block a user