feat(backend): 公开接口反爬轻量版——per-IP 日配额 + 列表收紧(todo #3)

DailyQuota 二级闸(单品 1000/日、店铺列表 300/日,0=关;出错放行不误伤);
店铺列表 page_size 上限 50→20(客户端固定传 20 无破坏);公开响应敏感字段
(cost/purchase_price/profit)零暴露回归测试。
明确不做:签名链接(QR 已印刷+UUIDv4 不可枚举)、滑块(杀零门槛分享)、登录墙。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-05 10:42:23 +08:00
parent 3c687e5e0b
commit 290054dc8c
5 changed files with 221 additions and 4 deletions
+48
View File
@@ -0,0 +1,48 @@
package middleware
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/ratelimit"
)
// DailyQuota 同一 IP 的自然日累计配额——分钟级限流之上的第二道反爬闸。
// key = "dq:<scope>:<yyyymmdd>:<ip>"TTL 26h(跨午夜/时钟偏移裕量,键按日期
// 自然分片,旧键过期自灭)。limit<=0 或总开关关闭时放行;计数器出错(redis
// 降级窗口等)也放行——宁可漏限不误伤真实客户。
func DailyQuota(scope string, limit func() int) gin.HandlerFunc {
return func(c *gin.Context) {
lim := limit()
if !config.C.RateLimit.Enabled || lim <= 0 {
c.Next()
return
}
ip := c.ClientIP()
if ip == "" {
c.Next()
return
}
now := time.Now()
key := fmt.Sprintf("dq:%s:%s:%s", scope, now.Format("20060102"), ip)
n, err := ratelimit.Default().Counter().Incr(key, 26*time.Hour)
if err == nil && n > int64(lim) {
c.Header("Retry-After", strconv.Itoa(secondsToMidnight(now)))
c.AbortWithStatusJSON(http.StatusTooManyRequests,
gin.H{"error": "今日访问次数已达上限,请明日再试"})
return
}
c.Next()
}
}
// secondsToMidnight 距下一个本地零点的秒数(日配额的自然重置时刻)。
func secondsToMidnight(now time.Time) int {
next := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1)
return int(next.Sub(now).Seconds())
}
@@ -0,0 +1,75 @@
package middleware
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/config"
)
func newQuotaRouter(scope string, limit int) *gin.Engine {
r := gin.New()
r.GET("/", DailyQuota(scope, func() int { return limit }), func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
return r
}
func TestDailyQuotaExceeded429(t *testing.T) {
enableRateLimit(t)
r := newQuotaRouter("tq1", 3)
for i := 0; i < 3; i++ {
if w := hit(r, "7.7.7.7"); w.Code != http.StatusOK {
t.Fatalf("配额内第 %d 发应放行,得到 %d", i+1, w.Code)
}
}
w := hit(r, "7.7.7.7")
if w.Code != http.StatusTooManyRequests {
t.Fatalf("超日配额应 429,得到 %d", w.Code)
}
if w.Header().Get("Retry-After") == "" {
t.Fatal("429 应带 Retry-After(到午夜秒数)")
}
// 不同 IP 独立
if w := hit(r, "8.8.8.8"); w.Code != http.StatusOK {
t.Fatalf("其他 IP 应不受影响,得到 %d", w.Code)
}
}
func TestDailyQuotaScopesIndependent(t *testing.T) {
enableRateLimit(t)
a := newQuotaRouter("tq2a", 1)
b := newQuotaRouter("tq2b", 1)
if w := hit(a, "9.9.9.9"); w.Code != http.StatusOK {
t.Fatalf("scope a 首发应放行,得到 %d", w.Code)
}
if w := hit(b, "9.9.9.9"); w.Code != http.StatusOK {
t.Fatalf("scope b 有独立配额池,应放行,得到 %d", w.Code)
}
if w := hit(a, "9.9.9.9"); w.Code != http.StatusTooManyRequests {
t.Fatalf("scope a 第二发应 429,得到 %d", w.Code)
}
}
func TestDailyQuotaDisabledOrZeroPassthrough(t *testing.T) {
// limit=0 → 该闸关闭
enableRateLimit(t)
r := newQuotaRouter("tq3", 0)
for i := 0; i < 5; i++ {
if w := hit(r, "10.0.0.1"); w.Code != http.StatusOK {
t.Fatalf("limit=0 第 %d 发应放行,得到 %d", i+1, w.Code)
}
}
// 总开关关闭 → 放行
prev := config.C.RateLimit.Enabled
config.C.RateLimit.Enabled = false
defer func() { config.C.RateLimit.Enabled = prev }()
r2 := newQuotaRouter("tq4", 1)
for i := 0; i < 5; i++ {
if w := hit(r2, "10.0.0.2"); w.Code != http.StatusOK {
t.Fatalf("总开关关闭第 %d 发应放行,得到 %d", i+1, w.Code)
}
}
}