✨ feat(server): 新增每 IP 请求限流

- 新增 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 配置项(此前仅存在于代码)
This commit is contained in:
chenxin committed 2026-09-30 00:24:58 +08:00
1 parent 822d3fe444
commit f89bee8ddc
5 files changed
+112 -38

No files matched your search

+5 -4
View File
@@ -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 番茄小说配置
+73 -6
View File
@@ -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",