桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
1 parent
6452026fd6
commit
2bb4ef7a95
299 files changed
+6059
-3322
No files matched your search
@@ -0,0 +1,226 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user