290054dc8c
DailyQuota 二级闸(单品 1000/日、店铺列表 300/日,0=关;出错放行不误伤); 店铺列表 page_size 上限 50→20(客户端固定传 20 无破坏);公开响应敏感字段 (cost/purchase_price/profit)零暴露回归测试。 明确不做:签名链接(QR 已印刷+UUIDv4 不可枚举)、滑块(杀零门槛分享)、登录墙。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
49 lines
1.5 KiB
Go
49 lines
1.5 KiB
Go
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())
|
||
}
|