Files
opsxapi/apps/server/internal/mocksrv/server.go
T
logan d9ccccd280 本地 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);浏览器发布+试一试通过
2026-09-18 12:29:49 +08:00

176 lines
3.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}