- 问题:/mock/{自增空间ID} 可枚举且服务绑定所有网卡,局域网可猜他人空间
- workspaces 新增 mock_token(UNIQUE,随机 8 字节 hex;老库自动 ALTER + 回填)
- /mock/{token}/path:解析失败统一 404(不暴露存在性);限流 key 沿用 token
- /api/me 与发布响应返回 token 形态 base;弹窗 URL 改用 token
- 验证:token URL 正常随机生成;旧数字 ID 与任意伪造 token 均 404
185 lines
3.9 KiB
Go
185 lines
3.9 KiB
Go
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()
|
||
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 实为 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 {
|
||
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
|
||
}
|