本地 Mock 服务 /mock + 限流 20QPS/60QPM + 服务条款
- Go Mock 引擎(与前端同语法 @cname/@integer/... 每次请求随机生成,JSON 叶子保持类型) - mock_endpoints 表 + 发布 API(PUT /api/workspaces/:id/mock,全量替换,单空间 200 个) - 路由 /mock/:wsid/*path:状态码/延迟(≤5s)/?_method 覆盖;未发布 404 - 限流(IP+空间 滑动窗口):20 QPS / 60 QPM → 429 + Retry-After + 条款提示 - 官网 /terms 服务条款(七节:含禁止滥用——未经授权或滥用有权采取措施、技术手段规避限流属违规) - UI:HTTP 编辑器「Mock 链接」弹窗(发布/状态码/延迟/模板预览/复制/试一试); 发布自动烘焙智能 Mock 表达式;集合端点去重 + INSERT OR REPLACE - docs:14-mock 增本地 Mock 服务与限流章节;页脚/429 响应均指向 /terms - 单测:mocksrv 引擎/限流/路径 5 用例;curl E2E(随机×2、201+延迟、404、连发 30 次触发 429);浏览器发布+试一试通过
This commit is contained in:
1 parent
522e5d6300
commit
d9ccccd280
17 files changed
+1070
-6
No files matched your search
@@ -0,0 +1,175 @@
|
||||
package mocksrv
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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)
|
||||
}
|
||||
|
||||
/* ---------------- 限流: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()
|
||||
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}
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
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) {
|
||||
wsid := c.Param("wsid")
|
||||
path := normalizePath(c.Param("path"))
|
||||
method := strings.ToUpper(c.Query("_method"))
|
||||
if method == "" {
|
||||
method = strings.ToUpper(c.Request.Method)
|
||||
}
|
||||
if method == "HEAD" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
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, _ := strconv.ParseInt(wsid, 10, 64)
|
||||
ep, found, err := s.store.FindMockEndpoint(wsID, method, 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(GenerateJSON(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
|
||||
}
|
||||
Reference in new issue
Block a user