diff --git a/go.mod b/go.mod index f7f573f..5e480be 100644 --- a/go.mod +++ b/go.mod @@ -94,12 +94,15 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // 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/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // 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/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/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect diff --git a/go.sum b/go.sum index d031843..e9e4a0d 100644 --- a/go.sum +++ b/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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 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-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 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/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/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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= diff --git a/internal/app/app.go b/internal/app/app.go index 5e9c6c2..65acf9b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,6 +26,7 @@ import ( articleEntities "tyapi-server/internal/domains/article/entities" // 统计域实体 + securityEntities "tyapi-server/internal/domains/security/entities" statisticsEntities "tyapi-server/internal/domains/statistics/entities" apiEntities "tyapi-server/internal/domains/api/entities" @@ -256,6 +257,7 @@ func (a *Application) autoMigrate(db *gorm.DB) error { &statisticsEntities.StatisticsMetric{}, &statisticsEntities.StatisticsDashboard{}, &statisticsEntities.StatisticsReport{}, + &securityEntities.SuspiciousIPRecord{}, // api &apiEntities.ApiUser{}, diff --git a/internal/application/certification/dto/responses/certification_responses.go b/internal/application/certification/dto/responses/certification_responses.go index 5327d0f..2d2c9c9 100644 --- a/internal/application/certification/dto/responses/certification_responses.go +++ b/internal/application/certification/dto/responses/certification_responses.go @@ -112,14 +112,14 @@ type SystemHealthStatus struct { // AdminSubmitRecordItem 管理端提交记录列表项 type AdminSubmitRecordItem struct { - ID string `json:"id"` - UserID string `json:"user_id"` - CompanyName string `json:"company_name"` - UnifiedSocialCode string `json:"unified_social_code"` - LegalPersonName string `json:"legal_person_name"` - SubmitAt time.Time `json:"submit_at"` - Status string `json:"status"` - CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准:info_pending_review/info_submitted/info_rejected 等 + ID string `json:"id"` + UserID string `json:"user_id"` + CompanyName string `json:"company_name"` + UnifiedSocialCode string `json:"unified_social_code"` + LegalPersonName string `json:"legal_person_name"` + SubmitAt time.Time `json:"submit_at"` + Status string `json:"status"` + CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准:info_pending_review/info_submitted/info_rejected 等 } // AdminSubmitRecordDetail 管理端提交记录详情(含完整信息与图片 URL) diff --git a/internal/container/container.go b/internal/container/container.go index 7648d8b..451a843 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -67,6 +67,7 @@ import ( "tyapi-server/internal/shared/hooks" sharedhttp "tyapi-server/internal/shared/http" "tyapi-server/internal/shared/interfaces" + "tyapi-server/internal/shared/ipgeo" "tyapi-server/internal/shared/logger" "tyapi-server/internal/shared/metrics" "tyapi-server/internal/shared/middleware" @@ -239,19 +240,19 @@ func NewContainer() *Container { }, // 短信服务 sms.NewAliSMSService, - // 验证码服务 - fx.Annotate( - func(cfg *config.Config) *captcha.CaptchaService { - return captcha.NewCaptchaService(captcha.CaptchaConfig{ - AccessKeyID: cfg.SMS.AccessKeyID, - AccessKeySecret: cfg.SMS.AccessKeySecret, - EndpointURL: cfg.SMS.CaptchaEndpoint, - SceneID: cfg.SMS.SceneID, - EncryptKey: cfg.SMS.CaptchaSecret, // 加密模式 ekey(Base64 编码的 32 字节) - }) - }, - fx.ResultTags(`name:"captchaService"`), - ), + // 验证码服务 + fx.Annotate( + func(cfg *config.Config) *captcha.CaptchaService { + return captcha.NewCaptchaService(captcha.CaptchaConfig{ + AccessKeyID: cfg.SMS.AccessKeyID, + AccessKeySecret: cfg.SMS.AccessKeySecret, + EndpointURL: cfg.SMS.CaptchaEndpoint, + SceneID: cfg.SMS.SceneID, + EncryptKey: cfg.SMS.CaptchaSecret, // 加密模式 ekey(Base64 编码的 32 字节) + }) + }, + fx.ResultTags(`name:"captchaService"`), + ), // 邮件服务 fx.Annotate( func(cfg *config.Config, logger *zap.Logger) *email.QQEmailService { @@ -418,6 +419,7 @@ func NewContainer() *Container { ) }, sharedhttp.NewGinRouter, + ipgeo.NewLocator, ), // 中间件组件 @@ -428,7 +430,7 @@ func NewContainer() *Container { middleware.NewCORSMiddleware, 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{ MaxRequestsPerDay: cfg.DailyRateLimit.MaxRequestsPerDay, MaxRequestsPerIP: cfg.DailyRateLimit.MaxRequestsPerIP, @@ -452,7 +454,7 @@ func NewContainer() *Container { // 排除域名配置 ExcludeDomains: cfg.DailyRateLimit.ExcludeDomains, } - return middleware.NewDailyRateLimitMiddleware(cfg, redis, response, logger, limitConfig) + return middleware.NewDailyRateLimitMiddleware(cfg, redis, db, response, logger, limitConfig) }, NewRequestLoggerMiddlewareWrapper, middleware.NewJWTAuthMiddleware, @@ -1244,6 +1246,8 @@ func NewContainer() *Container { handlers.NewApiHandler, // 统计HTTP处理器 handlers.NewStatisticsHandler, + // 管理员安全HTTP处理器 + handlers.NewAdminSecurityHandler, // 文章HTTP处理器 func( appService article.ArticleApplicationService, @@ -1335,6 +1339,8 @@ func NewContainer() *Container { routes.NewApiRoutes, // 统计路由 routes.NewStatisticsRoutes, + // 管理员安全路由 + routes.NewAdminSecurityRoutes, // PDFG路由 routes.NewPDFGRoutes, // 企业报告页面路由 @@ -1453,6 +1459,7 @@ func RegisterRoutes( announcementRoutes *routes.AnnouncementRoutes, apiRoutes *routes.ApiRoutes, statisticsRoutes *routes.StatisticsRoutes, + adminSecurityRoutes *routes.AdminSecurityRoutes, pdfgRoutes *routes.PDFGRoutes, qyglReportRoutes *routes.QYGLReportRoutes, jwtAuth *middleware.JWTAuthMiddleware, @@ -1479,6 +1486,7 @@ func RegisterRoutes( articleRoutes.Register(router) announcementRoutes.Register(router) statisticsRoutes.Register(router) + adminSecurityRoutes.Register(router) pdfgRoutes.Register(router) qyglReportRoutes.Register(router) diff --git a/internal/domains/api/services/processors/yysy/yysyk9r4_processor.go b/internal/domains/api/services/processors/yysy/yysyk9r4_processor.go index 456922f..d37e07a 100644 --- a/internal/domains/api/services/processors/yysy/yysyk9r4_processor.go +++ b/internal/domains/api/services/processors/yysy/yysyk9r4_processor.go @@ -21,7 +21,6 @@ func ProcessYYSYK9R4Request(ctx context.Context, params []byte, deps *processors return nil, errors.Join(processors.ErrInvalidParam, err) } - // 构建数据宝入参:姓名、身份证、手机号、银行卡号(sign 外的业务参数可按需 AES 加密后作为 bodyData) reqParams := map[string]interface{}{ "key": "c115708d915451da8f34a23e144dda6b", "name": paramsDto.Name, diff --git a/internal/domains/security/entities/suspicious_ip_record.go b/internal/domains/security/entities/suspicious_ip_record.go new file mode 100644 index 0000000..926803d --- /dev/null +++ b/internal/domains/security/entities/suspicious_ip_record.go @@ -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" +} diff --git a/internal/infrastructure/http/handlers/admin_security_handler.go b/internal/infrastructure/http/handlers/admin_security_handler.go new file mode 100644 index 0000000..7c9e4d2 --- /dev/null +++ b/internal/infrastructure/http/handlers/admin_security_handler.go @@ -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, "获取成功") +} diff --git a/internal/infrastructure/http/routes/admin_security_routes.go b/internal/infrastructure/http/routes/admin_security_routes.go new file mode 100644 index 0000000..6616f85 --- /dev/null +++ b/internal/infrastructure/http/routes/admin_security_routes.go @@ -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("管理员安全路由注册完成") +} diff --git a/internal/shared/ipgeo/city_coords.go b/internal/shared/ipgeo/city_coords.go new file mode 100644 index 0000000..896b639 --- /dev/null +++ b/internal/shared/ipgeo/city_coords.go @@ -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}, +} diff --git a/internal/shared/ipgeo/ip_locator.go b/internal/shared/ipgeo/ip_locator.go new file mode 100644 index 0000000..341a7df --- /dev/null +++ b/internal/shared/ipgeo/ip_locator.go @@ -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() +} diff --git a/internal/shared/middleware/daily_rate_limit.go b/internal/shared/middleware/daily_rate_limit.go index 0d1cd69..122b92a 100644 --- a/internal/shared/middleware/daily_rate_limit.go +++ b/internal/shared/middleware/daily_rate_limit.go @@ -3,16 +3,19 @@ package middleware import ( "context" "fmt" + "math" "strconv" "strings" "time" "tyapi-server/internal/config" + securityEntities "tyapi-server/internal/domains/security/entities" "tyapi-server/internal/shared/interfaces" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "go.uber.org/zap" + "gorm.io/gorm" ) // DailyRateLimitConfig 每日限流配置 @@ -45,6 +48,7 @@ type DailyRateLimitConfig struct { type DailyRateLimitMiddleware struct { config *config.Config redis *redis.Client + db *gorm.DB response interfaces.ResponseBuilder logger *zap.Logger limitConfig DailyRateLimitConfig @@ -54,6 +58,7 @@ type DailyRateLimitMiddleware struct { func NewDailyRateLimitMiddleware( cfg *config.Config, redis *redis.Client, + db *gorm.DB, response interfaces.ResponseBuilder, logger *zap.Logger, limitConfig DailyRateLimitConfig, @@ -78,6 +83,7 @@ func NewDailyRateLimitMiddleware( return &DailyRateLimitMiddleware{ config: cfg, redis: redis, + db: db, response: response, logger: logger, limitConfig: limitConfig, @@ -154,7 +160,9 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc { } // 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("并发请求超限", zap.String("ip", clientIP), zap.String("request_id", c.GetString("request_id")), @@ -163,9 +171,14 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc { c.Abort() return } + if m.shouldRecordNearLimit(concurrentCount, m.limitConfig.MaxConcurrent) { + m.recordSuspiciousRequest(c, clientIP, "daily_concurrent_limit") + } // 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("接口总请求次数超限", zap.String("ip", clientIP), zap.String("request_id", c.GetString("request_id")), @@ -175,9 +188,14 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc { c.Abort() return } + if m.shouldRecordNearLimit(totalCount+1, m.limitConfig.MaxRequestsPerDay) { + m.recordSuspiciousRequest(c, clientIP, "daily_total_limit") + } // 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请求次数超限", zap.String("ip", clientIP), zap.String("request_id", c.GetString("request_id")), @@ -187,6 +205,9 @@ func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc { c.Abort() return } + if m.shouldRecordNearLimit(ipCount+1, m.limitConfig.MaxRequestsPerIP) { + m.recordSuspiciousRequest(c, clientIP, "daily_ip_limit") + } // 7. 增加计数 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 检查域名是否在排除列表中 func (m *DailyRateLimitMiddleware) isExcludedDomain(host string) bool { for _, excludeDomain := range m.limitConfig.ExcludeDomains { @@ -360,13 +413,13 @@ func (m *DailyRateLimitMiddleware) checkReferer(c *gin.Context) error { } // 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) // 获取当前并发数 current, err := m.redis.Get(ctx, key).Result() if err != nil && err != redis.Nil { - return fmt.Errorf("获取并发计数失败: %w", err) + return 0, fmt.Errorf("获取并发计数失败: %w", err) } currentCount := 0 @@ -377,7 +430,7 @@ func (m *DailyRateLimitMiddleware) checkConcurrentLimit(ctx context.Context, cli } 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)) } - return nil + return currentCount + 1, nil } // getClientIP 获取客户端IP地址(增强版) @@ -435,35 +488,35 @@ func (m *DailyRateLimitMiddleware) getClientIP(c *gin.Context) string { } // 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()) count, err := m.getCounter(ctx, key) if err != nil { - return fmt.Errorf("获取总请求计数失败: %w", err) + return 0, fmt.Errorf("获取总请求计数失败: %w", err) } 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限制 -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()) count, err := m.getCounter(ctx, key) if err != nil { - return fmt.Errorf("获取IP计数失败: %w", err) + return 0, fmt.Errorf("获取IP计数失败: %w", err) } 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 增加计数器 diff --git a/internal/shared/middleware/ratelimit.go b/internal/shared/middleware/ratelimit.go index d070599..eeaff0d 100644 --- a/internal/shared/middleware/ratelimit.go +++ b/internal/shared/middleware/ratelimit.go @@ -5,25 +5,32 @@ import ( "sync" "time" "tyapi-server/internal/config" + securityEntities "tyapi-server/internal/domains/security/entities" "tyapi-server/internal/shared/interfaces" "github.com/gin-gonic/gin" + "go.uber.org/zap" "golang.org/x/time/rate" + "gorm.io/gorm" ) // RateLimitMiddleware 限流中间件 type RateLimitMiddleware struct { config *config.Config response interfaces.ResponseBuilder + db *gorm.DB + logger *zap.Logger limiters map[string]*rate.Limiter mutex sync.RWMutex } // 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{ config: cfg, response: response, + db: db, + logger: logger, limiters: make(map[string]*rate.Limiter), } } @@ -49,6 +56,8 @@ func (m *RateLimitMiddleware) Handle() gin.HandlerFunc { // 检查是否允许请求 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-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 是否为全局中间件 func (m *RateLimitMiddleware) IsGlobal() bool { return true diff --git a/resources/ipgeo/ip2region_v4.xdb b/resources/ipgeo/ip2region_v4.xdb new file mode 100644 index 0000000..707ea3d Binary files /dev/null and b/resources/ipgeo/ip2region_v4.xdb differ diff --git a/resources/ipgeo/ip2region_v6.xdb b/resources/ipgeo/ip2region_v6.xdb new file mode 100644 index 0000000..e833678 Binary files /dev/null and b/resources/ipgeo/ip2region_v6.xdb differ