- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
227 lines
5.2 KiB
Go
227 lines
5.2 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()
|
||
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
|
||
}
|