From f89bee8ddce36d04a75d4bab764bd41dade24384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Wed, 30 Sep 2026 00:24:58 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(server):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=AF=8F=20IP=20=E8=AF=B7=E6=B1=82=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RateLimit 中间件:golang.org/x/time/rate 令牌桶,超出返回 429 - server.rate_limit_qps 配置(默认 0 不限制,突发 10),公网部署建议开启 - 业务路由统一接入 限流 → 鉴权 中间件链;/healthz 与静态资源不限流 - X-Forwarded-For 优先取真实客户端 IP(反代场景),visitor 表定时清理防泄漏 - config.yaml 补齐 protect_all 与 rate_limit_qps 配置项(此前仅存在于代码) --- cmd/fanqie/main.go | 59 ++++++++++++++------------- go.mod | 1 + go.sum | 2 + internal/config/config.go | 9 +++-- internal/handlers/common.go | 79 ++++++++++++++++++++++++++++++++++--- 5 files changed, 112 insertions(+), 38 deletions(-) diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go index a9e523c..fc25243 100644 --- a/cmd/fanqie/main.go +++ b/cmd/fanqie/main.go @@ -40,10 +40,10 @@ func main() { os.Exit(1) } defer fqapi.CloseRedis() - + // 加载统计数据 handlers.LoadStatistics() - + // 启动统计数据定期保存 handlers.StartStatisticsSaver() defer handlers.StopStatisticsSaver() // 程序退出时保存统计数据 @@ -99,9 +99,9 @@ func initAlgorithm() { if config.GetAppConfig() != nil { algos.GlobalAlgorithmSwitcher.SwitchAlgorithm(config.GetAppConfig().Fanqie.Algorithm) logger.Info(fmt.Sprintf("当前算法: %s", config.GetAppConfig().Fanqie.Algorithm)) - + algos.GlobalSignatureSwitcher.SwitchSignature(config.GetAppConfig().Fanqie.Signature) - + // 根据签名方法显示详细信息 var signatureDetails string if config.GetAppConfig().Fanqie.Signature == "sign4" { @@ -121,7 +121,7 @@ func initDeviceKeyPool() error { pool := fqapi.GetDeviceKeyPool() if err := pool.Initialize(); err != nil { return fmt.Errorf("初始化设备密钥池失败: %w", err) -} + } // 显示池状态 stats := pool.GetStats() @@ -140,29 +140,32 @@ func setupRoutes() { http.HandleFunc("/healthz", handlers.HandleHealth) - // API端点(protect_all 开启时要求访问密钥;代理路由不包,URL 由服务端生成不含 key) - http.HandleFunc("/search", handlers.OptionalAccessKey(handlers.HandleSearch)) - http.HandleFunc("/detail", handlers.OptionalAccessKey(handlers.HandleDetail)) - http.HandleFunc("/info", handlers.OptionalAccessKey(handlers.HandleDetail)) - http.HandleFunc("/catalog", handlers.OptionalAccessKey(handlers.HandleCatalog)) - http.HandleFunc("/directory/all_items", handlers.OptionalAccessKey(handlers.HandleCatalog)) - http.HandleFunc("/multi-detail", handlers.OptionalAccessKey(handlers.HandleMultiDetail)) - http.HandleFunc("/multi-content", handlers.OptionalAccessKey(handlers.HandleMultiContent)) - http.HandleFunc("/content", handlers.OptionalAccessKey(handlers.HandleContent)) - http.HandleFunc("/item-summary", handlers.OptionalAccessKey(handlers.HandleItemSummary)) - http.HandleFunc("/category-front", handlers.OptionalAccessKey(handlers.HandleCategoryFront)) - http.HandleFunc("/category-landing", handlers.OptionalAccessKey(handlers.HandleCategoryLanding)) - http.HandleFunc("/audio", handlers.OptionalAccessKey(handlers.HandleAudio)) - http.HandleFunc("/audio/proxy/", handlers.HandleAudioProxy) - http.HandleFunc("/video", handlers.OptionalAccessKey(handlers.HandleVideo)) - http.HandleFunc("/manga", handlers.OptionalAccessKey(handlers.HandleManga)) - http.HandleFunc("/manga/image/", handlers.HandleMangaImageProxy) + // API端点:限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key) + wrap := func(h http.HandlerFunc) http.HandlerFunc { + return handlers.RateLimit(handlers.OptionalAccessKey(h)) + } + http.HandleFunc("/search", wrap(handlers.HandleSearch)) + http.HandleFunc("/detail", wrap(handlers.HandleDetail)) + http.HandleFunc("/info", wrap(handlers.HandleDetail)) + http.HandleFunc("/catalog", wrap(handlers.HandleCatalog)) + http.HandleFunc("/directory/all_items", wrap(handlers.HandleCatalog)) + http.HandleFunc("/multi-detail", wrap(handlers.HandleMultiDetail)) + http.HandleFunc("/multi-content", wrap(handlers.HandleMultiContent)) + http.HandleFunc("/content", wrap(handlers.HandleContent)) + http.HandleFunc("/item-summary", wrap(handlers.HandleItemSummary)) + http.HandleFunc("/category-front", wrap(handlers.HandleCategoryFront)) + http.HandleFunc("/category-landing", wrap(handlers.HandleCategoryLanding)) + http.HandleFunc("/audio", wrap(handlers.HandleAudio)) + http.HandleFunc("/audio/proxy/", wrap(handlers.HandleAudioProxy)) + http.HandleFunc("/video", wrap(handlers.HandleVideo)) + http.HandleFunc("/manga", wrap(handlers.HandleManga)) + http.HandleFunc("/manga/image/", wrap(handlers.HandleMangaImageProxy)) // 评论相关端点 - http.HandleFunc("/chapter_review", handlers.OptionalAccessKey(handlers.HandleChapterReview)) - http.HandleFunc("/review", handlers.OptionalAccessKey(handlers.HandleReview)) - http.HandleFunc("/review_html", handlers.OptionalAccessKey(handlers.HandleReviewHTML)) - + http.HandleFunc("/chapter_review", wrap(handlers.HandleChapterReview)) + http.HandleFunc("/review", wrap(handlers.HandleReview)) + http.HandleFunc("/review_html", wrap(handlers.HandleReviewHTML)) + // 表情图片静态文件服务 http.Handle("/emoji/", http.StripPrefix("/emoji/", http.FileServer(http.Dir("emoji")))) } @@ -171,7 +174,7 @@ func setupRoutes() { func startServer() { host := "0.0.0.0" port := "40180" - + if config.GetAppConfig() != nil { if config.GetAppConfig().Server.Host != "" { host = config.GetAppConfig().Server.Host @@ -180,7 +183,7 @@ func startServer() { port = config.GetAppConfig().Server.Port } } - + addr := host + ":" + port server := &http.Server{ Addr: addr, diff --git a/go.mod b/go.mod index 35bd25d..4eb657f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.18 github.com/redis/go-redis/v9 v9.22.0 github.com/spf13/viper v1.18.2 + golang.org/x/time v0.5.0 ) require ( diff --git a/go.sum b/go.sum index 38b7c6b..1fdcd16 100644 --- a/go.sum +++ b/go.sum @@ -114,6 +114,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/config/config.go b/internal/config/config.go index f88f4ff..1c5d3f8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,10 +19,11 @@ type Config struct { // ServerConfig 服务器配置 type ServerConfig struct { - Port string `mapstructure:"port"` - Host string `mapstructure:"host"` - AccessKey string `mapstructure:"access_key"` - ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启) + Port string `mapstructure:"port"` + Host string `mapstructure:"host"` + AccessKey string `mapstructure:"access_key"` + ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启) + RateLimitQPS float64 `mapstructure:"rate_limit_qps"` // 每 IP 每秒请求上限,0=不限制 } // FanqieConfig 番茄小说配置 diff --git a/internal/handlers/common.go b/internal/handlers/common.go index c7d7097..6aa3f0e 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -6,9 +6,14 @@ import ( "fanqie/internal/config" "fanqie/internal/fqapi" "fmt" + "net" "net/http" + "strings" + "sync" "sync/atomic" "time" + + "golang.org/x/time/rate" ) var ( @@ -62,7 +67,7 @@ func LoadStatistics() { fmt.Printf("加载统计数据失败: %v\n", err) return } - + for k, v := range stats { if c, ok := requestStats[k]; ok { c.Store(v) @@ -106,23 +111,85 @@ func respondError(w http.ResponseWriter, msg string, isClientErr bool) { respondJSON(w, status, map[string]interface{}{"code": -1, "msg": msg}) } +// RateLimit 每 IP 限流(server.rate_limit_qps > 0 时启用) +// ponytail: 配置启动时读取一次,热更新不生效;visitor 表由后台协程清理防泄漏 +func RateLimit(handler http.HandlerFunc) http.HandlerFunc { + cfg := config.GetAppConfig() + qps := 0.0 + if cfg != nil { + qps = cfg.Server.RateLimitQPS + } + if qps <= 0 { + return handler + } + + const burst = 10 + var mu sync.Mutex + limitters := map[string]*rate.Limiter{} + lastSeen := map[string]time.Time{} + + go func() { + for range time.Tick(time.Minute) { + mu.Lock() + for ip, t := range lastSeen { + if time.Since(t) > 10*time.Minute { + delete(lastSeen, ip) + delete(limitters, ip) + } + } + mu.Unlock() + } + }() + + return func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + mu.Lock() + l, ok := limitters[ip] + if !ok { + l = rate.NewLimiter(rate.Limit(qps), burst) + limitters[ip] = l + } + lastSeen[ip] = time.Now() + mu.Unlock() + + if !l.Allow() { + respondJSON(w, http.StatusTooManyRequests, map[string]interface{}{ + "code": -1, "msg": "请求过于频繁,请稍后再试", + }) + return + } + handler(w, r) + } +} + +// clientIP 取客户端 IP:优先 X-Forwarded-For 首段(反代场景),否则 RemoteAddr +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + // RequireAccessKey 验证访问密钥中间件 func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cfg := config.GetConfig() - + // 如果未配置 access_key 或为空,则不启用验证 if cfg == nil || cfg.Server.AccessKey == "" { handler(w, r) return } - + // 从查询参数或 Header 中获取 key key := r.URL.Query().Get("key") if key == "" { key = r.Header.Get("X-Access-Key") } - + // 验证密钥(常量时间比较,防时序攻击) if subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.AccessKey)) != 1 { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -164,11 +231,11 @@ func HandleMain(w http.ResponseWriter, r *http.Request) { // HandleSelfInfo 设备信息处理 func HandleSelfInfo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - + // 获取设备池统计信息 pool := fqapi.GetDeviceKeyPool() poolStats := pool.GetStats() - + response := map[string]interface{}{ "code": 0, "message": "SUCCESS",