package mocksrv import ( "net/http" "strings" "sync" "time" "github.com/gin-gonic/gin" ) /* ---------------- 存储 ---------------- */ // Endpoint 一条已发布的 Mock 接口。 type Endpoint struct { Method string `json:"method"` Path string `json:"path"` Status int `json:"status"` ContentType string `json:"contentType"` Body string `json:"body"` DelayMs int `json:"delayMs"` } type storeAPI interface { ReplaceMockEndpoints(workspaceID int64, list []Endpoint) error FindMockEndpoint(workspaceID int64, method, path string) (*Endpoint, bool, error) ResolveMockToken(token string) (int64, bool, error) } /* ---------------- 限流:20 QPS / 60 QPM(按 IP + 空间,滑动窗口) ---------------- */ const ( LimitQPS = 20 LimitQPM = 60 ) type slot struct { at int64 // 秒时间戳 n int64 } type bucket struct { slots [60]slot } type limiter struct { mu sync.Mutex buckets map[string]*bucket } func newLimiter() *limiter { return &limiter{buckets: map[string]*bucket{}} } // allow 滑动窗口判定:最近 1 秒 ≤ 20 且最近 60 秒 ≤ 60。 func (l *limiter) allow(key string, now time.Time) bool { l.mu.Lock() defer l.mu.Unlock() b, ok := l.buckets[key] if !ok { b = &bucket{} l.buckets[key] = b if len(l.buckets) > 10000 { // 粗略防膨胀 l.buckets = map[string]*bucket{key: b} } } s := now.Unix() cur := &b.slots[s%60] if cur.at != s { cur.at = s cur.n = 0 } var minCount int64 for i := range b.slots { if b.slots[i].at > s-60 { minCount += b.slots[i].n } } if cur.n >= LimitQPS || minCount >= LimitQPM { return false } cur.n++ return true } /* ---------------- 服务 ---------------- */ type Server struct { store storeAPI lim *limiter } func New(store storeAPI) *Server { return &Server{store: store, lim: newLimiter()} } func (s *Server) Register(r *gin.Engine) { g := r.Group("/mock") g.Any("/:wsid/*path", s.handle) } // handle:发布查询 → 限流 → 延迟 → 生成 func (s *Server) handle(c *gin.Context) { if state, ok := s.store.(interface{ MockEnabled() (bool, error) }); ok { enabled, err := state.MockEnabled() if err != nil || !enabled { c.JSON(503, gin.H{"error": "Mock HTTP 响应已关闭"}) return } } if access, ok := s.store.(interface { MockAccess() (bool, bool, error) CheckMockBearer(string) (bool, error) }); ok { required, _, err := access.MockAccess() if err != nil { c.JSON(500, gin.H{"error": "Mock 访问控制读取失败"}) return } if required { authorization := c.GetHeader("Authorization") parts := strings.SplitN(authorization, " ", 2) valid := false if len(parts) == 2 && parts[0] == "Bearer" { valid, err = access.CheckMockBearer(parts[1]) } if err != nil { c.JSON(500, gin.H{"error": "Mock 访问控制读取失败"}) return } if !valid { c.Header("WWW-Authenticate", `Bearer realm="OpsxAPI Mock"`) c.JSON(http.StatusUnauthorized, gin.H{"error": "此 Mock 需要 Authorization: Bearer <密钥>"}) return } } } wsid := c.Param("wsid") path := normalizePath(c.Param("path")) method := strings.ToUpper(c.Query("_method")) if method == "" { method = strings.ToUpper(c.Request.Method) } key := clientIP(c) + "|" + wsid if !s.lim.allow(key, time.Now()) { c.Header("Retry-After", "60") c.JSON(http.StatusTooManyRequests, gin.H{ "error": "请求过于频繁,已限流(20 QPS / 60 QPM)", "terms": "/terms", "reason": "服务条款明确禁止滥用:通过技术手段规避限流属于违规行为", }) return } // wsid 实为 mock_token:不可枚举、不暴露空间归属;解析失败统一 404 wsID, ok, err := s.store.ResolveMockToken(wsid) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"}) return } if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "Mock 地址无效或已失效"}) return } ep, found, err := s.store.FindMockEndpoint(wsID, method, path) if err == nil && !found && method == "HEAD" { ep, found, err = s.store.FindMockEndpoint(wsID, "GET", path) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"}) return } if !found { c.JSON(http.StatusNotFound, gin.H{ "error": "Mock 接口不存在:" + method + " " + path + ",请先在客户端「Mock」页面中发布", }) return } if ep.DelayMs > 0 { if ep.DelayMs > 5000 { ep.DelayMs = 5000 } time.Sleep(time.Duration(ep.DelayMs) * time.Millisecond) } ct := ep.ContentType if ct == "" { ct = "application/json" } c.Data(ep.statusOrOK(), ct, []byte(responseBody(ct, ep.Body))) } func (e *Endpoint) statusOrOK() int { if e.Status <= 0 { return http.StatusOK } return e.Status } func normalizePath(p string) string { if p == "" { return "/" } if !strings.HasPrefix(p, "/") { p = "/" + p } if len(p) > 1 { p = strings.TrimSuffix(p, "/") } return p } func clientIP(c *gin.Context) string { ip := c.ClientIP() if ip == "" { ip = "unknown" } return ip } // Text responses remain literal; only JSON responses expand template expressions. func responseBody(contentType, body string) string { if strings.Contains(strings.ToLower(contentType), "json") { return GenerateJSON(body) } return body }