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

227 lines
5.2 KiB
Go
Raw Permalink 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"
"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
}