dual check v0.1

This commit is contained in:
cancerai committed 2026-08-29 23:34:01 +08:00
1 parent 78bad1fa3d
commit c137299016
36 files changed
+3727 -81

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
// Package middleware 提供 HTTP 中间件
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件
// - 白名单 origin 精确匹配(生产用)
// - 同时自动放行 http(s)://localhost:* 所有端口(开发期 Flutter Web/React Vite 随机端口)
// - 不在范围内的请求不设置 Allow-Origin 头,浏览器会自行阻止
func CORS(allowedOrigins []string) gin.HandlerFunc {
originSet := make(map[string]bool, len(allowedOrigins))
for _, o := range allowedOrigins {
originSet[strings.TrimRight(o, "/")] = true
}
return func(c *gin.Context) {
origin := strings.TrimRight(c.GetHeader("Origin"), "/")
if origin == "" {
c.Next()
return
}
// 是否允许:白名单精确匹配 或 localhost 通配
allowed := originSet[origin] || isLocalhostOrigin(origin)
if c.Request.Method == http.MethodOptions {
if allowed {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID")
c.Writer.Header().Set("Access-Control-Max-Age", "86400")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}
c.AbortWithStatus(http.StatusNoContent)
return
}
if allowed {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}
c.Next()
}
}
// isLocalhostOrigin 匹配 http://localhost:* 和 http://127.0.0.1:*
// 开发期 Flutter Web / Vite / CRA 等工具常用随机端口
func isLocalhostOrigin(origin string) bool {
return strings.HasPrefix(origin, "http://localhost:") ||
strings.HasPrefix(origin, "http://127.0.0.1:") ||
strings.HasPrefix(origin, "http://[::1]:")
}
+29
View File
@@ -0,0 +1,29 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Logger 基于 zap 的请求日志中间件
// 只记录 method/path/status/latency/request_id,**不记录请求体**(避免泄露用户输入)
func Logger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latency := time.Since(start)
logger.Info("HTTP",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Int("size", c.Writer.Size()),
zap.String("remote_ip", c.ClientIP()),
zap.String("user_agent", c.Request.UserAgent()),
zap.String("request_id", c.GetString("request_id")),
zap.Duration("latency", latency),
)
}
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/ulule/limiter/v3"
"github.com/ulule/limiter/v3/drivers/store/memory"
)
// RateLimitConfig 限流配置
type RateLimitConfig struct {
GlobalRPS float64
PerIPRPS float64
PerIPBurst int
}
// RateLimiter 双维度限流:全局令牌桶 + 单 IP 令牌桶
// 开发/测试环境可用内存存储,生产环境建议换 Redis 存储
func RateLimiter(cfg RateLimitConfig) gin.HandlerFunc {
store := memory.NewStore()
// 全局限流
globalRate := limiter.Rate{
Period: time.Second,
Limit: int64(cfg.GlobalRPS),
}
globalLimiter := limiter.New(store, globalRate)
// 单 IP 限流
perIPRate := limiter.Rate{
Period: time.Second,
Limit: int64(cfg.PerIPRPS),
}
perIPLimiter := limiter.New(store, perIPRate)
return func(c *gin.Context) {
// 1. 检查全局限流
globalCtx, err := globalLimiter.Get(c.Request.Context(), "global")
if err != nil || globalCtx.Reached {
c.JSON(http.StatusTooManyRequests, gin.H{
"code": 4290,
"message": "server busy, please retry later",
})
c.Abort()
return
}
// 2. 检查单 IP 限流
ip := c.ClientIP()
ipCtx, err := perIPLimiter.Get(c.Request.Context(), "ip:"+ip)
if err != nil || ipCtx.Reached {
c.JSON(http.StatusTooManyRequests, gin.H{
"code": 4291,
"message": "too many requests from this IP",
})
c.Abort()
return
}
c.Next()
}
}
+22
View File
@@ -0,0 +1,22 @@
package middleware
import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Recover 捕获 panic 并返回 500
func Recover(logger *zap.Logger) gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, err interface{}) {
logger.Error("panic recovered",
zap.Any("error", err),
zap.String("path", c.Request.URL.Path),
zap.String("request_id", c.GetString("request_id")),
)
c.JSON(500, gin.H{
"code": 5000,
"message": "internal server error",
})
c.Abort()
})
}
+27
View File
@@ -0,0 +1,27 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
)
// RequestID 为每个请求生成唯一 ID,放入 context 和响应头
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
// 优先使用客户端传来的 X-Request-ID
id := c.GetHeader("X-Request-ID")
if id == "" {
b := make([]byte, 12)
if _, err := rand.Read(b); err == nil {
id = hex.EncodeToString(b)
} else {
id = "unknown"
}
}
c.Set("request_id", id)
c.Header("X-Request-ID", id)
c.Next()
}
}