diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm index 3613e95..b4c4bba 100644 Binary files a/apps/server/data/opsxapi.db-shm and b/apps/server/data/opsxapi.db-shm differ diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal index c3727be..29c6982 100644 Binary files a/apps/server/data/opsxapi.db-wal and b/apps/server/data/opsxapi.db-wal differ diff --git a/apps/server/internal/api/api.go b/apps/server/internal/api/api.go index 284baa4..ddaca40 100644 --- a/apps/server/internal/api/api.go +++ b/apps/server/internal/api/api.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" + "opsxapi/server/internal/mocksrv" "opsxapi/server/internal/store" ) @@ -24,6 +25,9 @@ func New(path string) (*API, error) { return &API{store: s}, nil } +// Store 暴露底层存储(供 Mock 服务等复用同一 SQLite)。 +func (a *API) Store() *store.Store { return a.store } + func (a *API) Register(r *gin.Engine) { g := r.Group("/api") g.POST("/auth/register", a.handleRegister) @@ -34,6 +38,7 @@ func (a *API) Register(r *gin.Engine) { g.POST("/teams/:id/members", a.auth(a.handleAddMember)) g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace)) g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace)) + g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock)) } /* ---------------- 中间件 ---------------- */ @@ -184,6 +189,31 @@ func (a *API) handleAddMember(c *gin.Context, user *store.User) { c.JSON(http.StatusOK, gin.H{"ok": true, "member": member}) } +/* ---------------- Mock 发布 ---------------- */ + +func (a *API) handlePublishMock(c *gin.Context, user *store.User) { + ws := a.workspaceOf(c, user) + if ws == nil { + return + } + var req struct { + Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"}) + return + } + if len(req.Endpoints) > 200 { + c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"}) + return + } + if err := a.store.ReplaceMockEndpoints(ws.ID, req.Endpoints); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + strconv.FormatInt(ws.ID, 10)}) +} + /* ---------------- 工作区同步 ---------------- */ func (a *API) workspaceOf(c *gin.Context, user *store.User) *store.Workspace { diff --git a/apps/server/internal/docs/content/14-mock.md b/apps/server/internal/docs/content/14-mock.md index ac2d6eb..3165de3 100644 --- a/apps/server/internal/docs/content/14-mock.md +++ b/apps/server/internal/docs/content/14-mock.md @@ -70,3 +70,21 @@ Body 工具栏提供两个辅助按钮: - 不生效:Headers、Cookies、鉴权字段(避免意外改坏签名与令牌)。 > 提示:Mock 在**发送瞬间**求值,同一请求每次发送都会得到新数据;预览结果仅供核对格式。 + +## 本地 Mock 服务(/mock) + +除了发送时的离线 Mock,还能把接口**发布成本地 Mock 服务**,给前端 / 同事直接联调(类似 Apifox 的 Mock URL): + +1. 登录并切换到「我的空间」或团队空间(Mock 按空间隔离); +2. HTTP 请求 URL 行点 **Mock 链接** → 选择响应状态码与延迟 → **发布**(一次发布当前空间集合内全部 HTTP 接口); +3. 得到地址:`http://127.0.0.1:8787/mock/{空间ID}{路径}`,例如 `http://127.0.0.1:8787/mock/3/api/users`。 + +特点: + +- **每次请求随机生成**:响应模板里的 `@cname`、`@integer(...)` 等表达式由服务端实时求值; +- 发布时自动把智能 Mock 规则烘焙进模板(开启智能匹配时); +- 非 GET 接口可用 `?_method=POST` 快速验证;延迟最高 5 秒,用于模拟慢接口 / loading 态。 + +### 频率限制(防滥用) + +本地 Mock 服务按 **IP + 空间** 限流:**20 QPS(每秒 20 次)或 60 QPM(每分钟 60 次)**,超限返回 `429` 与 `Retry-After: 60`。依据[服务条款](/terms):未经授权或滥用行为平台有权采取限流、暂停服务等措施;**通过技术手段规避限流属于违规行为**。Mock 定位是联调辅助,请勿当作对外流量服务使用。 diff --git a/apps/server/internal/mocksrv/engine.go b/apps/server/internal/mocksrv/engine.go new file mode 100644 index 0000000..41634e7 --- /dev/null +++ b/apps/server/internal/mocksrv/engine.go @@ -0,0 +1,288 @@ +// Package mocksrv:本地 Mock 服务(引擎 + 存储 + 限流 + 路由)。 +// 引擎与前端 packages/core/src/mock.ts 同语法(@cname / @integer(1,100) / …), +// 每次请求重新求值,返回随机数据。 +package mocksrv + +import ( + "encoding/json" + "fmt" + "math/rand" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + surname = []rune("赵钱孙李周吴郑王冯陈蒋沈韩杨朱秦许何吕张孔曹严华金魏陶姜谢邹柏水窦章云苏潘葛范彭鲁马苗凤花方任袁柳史唐薛雷贺倪汤") + given = []rune("伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超秀兰霞平刚华民玉萍红娥玲芬芳燕春菊学成嘉昊泽宇欣怡思远志坚建国子涵梓萱浩然雨桐") + provinces = []string{"北京市", "上海市", "天津市", "重庆市", "河北省", "山西省", "辽宁省", "吉林省", "黑龙江省", "江苏省", "浙江省", "安徽省", "福建省", "江西省", "山东省", "河南省", "湖北省", "湖南省", "广东省", "海南省", "四川省", "贵州省", "云南省", "陕西省", "甘肃省", "青海省", "台湾省", "内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区", "新疆维吾尔自治区", "香港特别行政区", "澳门特别行政区"} + cities = []string{"广州", "深圳", "杭州", "成都", "武汉", "西安", "南京", "苏州", "长沙", "郑州", "青岛", "大连", "厦门", "宁波", "无锡", "佛山", "东莞", "沈阳", "哈尔滨", "石家庄", "太原", "合肥", "福州", "南昌", "济南", "昆明", "贵阳", "兰州", "西宁", "南宁", "呼和浩特", "乌鲁木齐"} + counties = []string{"朝阳区", "海淀区", "浦东新区", "南山区", "福田区", "天河区", "西湖区", "锦江区", "雁塔区", "玄武区", "工业园区", "香洲区", "东湖区", "岳麓区", "金水区", "市南区", "和平区", "河东区"} + letters = "abcdefghijklmnopqrstuvwxyz" + uppers = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + digits = "0123456789" + phoneHeads = []string{"130", "131", "132", "133", "135", "136", "137", "138", "139", "150", "151", "152", "155", "156", "157", "158", "159", "170", "176", "177", "178", "180", "181", "182", "183", "185", "186", "187", "188", "189", "199"} +) + +func pickStr(list []string) string { return list[rand.Intn(len(list))] } +func randFrom(pool string, n int) string { + b := make([]byte, n) + for i := range b { + b[i] = pool[rand.Intn(len(pool))] + } + return string(b) +} +func randInt(min, max int) int { return min + rand.Intn(max-min+1) } +func randRunes(pool []rune, n int) string { + b := make([]rune, n) + for i := range b { + b[i] = pool[rand.Intn(len(pool))] + } + return string(b) +} + +var exprRe = regexp.MustCompile(`^@([a-zA-Z][\w-]*)(?:\((.*)\))?$`) + +func parseArgs(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var out []string + var cur strings.Builder + quote := byte(0) + for i := 0; i < len(raw); i++ { + ch := raw[i] + if quote != 0 { + if ch == quote { + quote = 0 + } else { + cur.WriteByte(ch) + } + continue + } + if ch == '"' || ch == '\'' { + quote = ch + continue + } + if ch == ',' { + out = append(out, strings.TrimSpace(cur.String())) + cur.Reset() + continue + } + cur.WriteByte(ch) + } + if s := strings.TrimSpace(cur.String()); s != "" { + out = append(out, s) + } + return out +} + +func argInt(args []string, i, def int) int { + if i >= len(args) { + return def + } + n, err := strconv.Atoi(args[i]) + if err != nil { + return def + } + return n +} + +func formatDate(layout string, t time.Time) string { + r := strings.NewReplacer( + "YYYY", "2006", "YY", "06", + "MM", "01", "DD", "02", + "HH", "15", "mm", "04", "ss", "05", + ) + return t.Format(r.Replace(layout)) +} + +// Generate 求 Mock 表达式;非 @ 开头原样返回。 +func Generate(expr string) string { + expr = strings.TrimSpace(expr) + m := exprRe.FindStringSubmatch(expr) + if m == nil { + return expr + } + name := strings.ToLower(m[1]) + args := parseArgs(m[2]) + now := time.Now() + + switch name { + case "integer": + return strconv.Itoa(randInt(argInt(args, 0, 0), argInt(args, 1, 100))) + case "natural": + return strconv.Itoa(randInt(argInt(args, 0, 1), argInt(args, 1, 1000))) + case "float": + min := float64(argInt(args, 0, 0)) + max := float64(argInt(args, 1, 100)) + d := argInt(args, 2, 2) + return strconv.FormatFloat(min+rand.Float64()*(max-min), 'f', d, 64) + case "timestamp": + return strconv.FormatInt(now.UnixMilli(), 10) + case "boolean", "bool": + return strconv.FormatBool(rand.Intn(2) == 0) + case "pick": + if len(args) == 0 { + return "A" + } + return args[rand.Intn(len(args))] + case "string": + pool := letters + uppers + digits + min, max := 5, 10 + if len(args) > 0 { + switch args[0] { + case "lower": + pool = letters + case "upper": + pool = uppers + case "number": + pool = digits + } + min, max = argInt(args, 1, 5), argInt(args, 2, argInt(args, 1, 5)) + } else { + min, max = argInt(args, 0, 5), argInt(args, 1, argInt(args, 0, 5)) + } + return randFrom(pool, randInt(min, max)) + case "character": + return randFrom(letters+uppers+digits, 1) + case "word", "cword": + return randRunes(given, randInt(argInt(args, 0, 3), argInt(args, 1, argInt(args, 0, 3)))) + case "cname": + return string(surname[rand.Intn(len(surname))]) + randRunes(given, randInt(1, 2)) + case "cfirst": + return string(surname[rand.Intn(len(surname))]) + case "clast": + return randRunes(given, randInt(1, 2)) + case "email": + domains := []string{"qq.com", "163.com", "gmail.com", "outlook.com", "example.com"} + return fmt.Sprintf("%s%d@%s", randFrom(letters, randInt(4, 9)), randInt(1, 99), pickStr(domains)) + case "url": + hosts := []string{"www.example.com", "api.example.com", "cdn.example.com", "example.org"} + schemes := []string{"https", "http"} + return fmt.Sprintf("%s://%s/%s", pickStr(schemes), pickStr(hosts), randFrom(letters, randInt(3, 8))) + case "domain": + tlds := []string{"com", "cn", "net", "org", "io"} + return fmt.Sprintf("%s.%s", randFrom(letters, randInt(4, 10)), pickStr(tlds)) + case "ip": + return fmt.Sprintf("%d.%d.%d.%d", randInt(1, 254), randInt(0, 255), randInt(0, 255), randInt(1, 254)) + case "guid", "uuid": + hexs := "0123456789abcdef" + seg := func(n int) string { return randFrom(hexs, n) } + return fmt.Sprintf("%s-%s-%s-%s-%s", seg(8), seg(4), seg(4), seg(4), seg(12)) + case "id": + return fmt.Sprintf("%d%02d%02d%02d%03d%d", randInt(110000, 659000), randInt(1960, 2005), randInt(1, 12), randInt(1, 28), randInt(100, 999), randInt(0, 9)) + case "phone", "mobile", "tel": + return pickStr(phoneHeads) + randFrom(digits, 8) + case "date": + return formatDate(argLayout(args, "YYYY-MM-DD"), now) + case "time": + return formatDate(argLayout(args, "HH:mm:ss"), now) + case "datetime", "now": + return formatDate(argLayout(args, "YYYY-MM-DD HH:mm:ss"), now) + case "province": + return pickStr(provinces) + case "city": + prefix := "" + if len(args) > 0 && args[0] == "true" { + prefix = pickStr(provinces) + } + return prefix + pickStr(cities) + "市" + case "county": + prefix := "" + if len(args) > 0 && args[0] == "true" { + prefix = pickStr(provinces) + pickStr(cities) + "市" + } + return prefix + pickStr(counties) + case "zip", "zipcode": + return randFrom(digits, 6) + case "ctitle", "ctitlex": + return randRunes(given, randInt(argInt(args, 0, 4), argInt(args, 1, 8))) + case "csentence": + return randRunes(given, randInt(argInt(args, 0, 8), argInt(args, 1, 16))) + "。" + case "cparagraph": + n := argInt(args, 0, 2) + var sb strings.Builder + for i := 0; i < n; i++ { + sb.WriteString(randRunes(given, randInt(10, 20))) + sb.WriteString("。") + } + return sb.String() + case "image": + size := "480x480" + if len(args) > 0 && regexp.MustCompile(`^\d+x\d+$`).MatchString(args[0]) { + size = args[0] + } else { + size = fmt.Sprintf("%dx%d", randInt(2, 8)*100, randInt(2, 8)*100) + } + seed := randFrom(letters+digits, 8) + return fmt.Sprintf("https://picsum.photos/seed/%s/%s", seed, strings.ReplaceAll(size, "x", "/")) + default: + return expr + } +} + +func argLayout(args []string, def string) string { + if len(args) > 0 && strings.ContainsAny(args[0], "YMDHms") { + return args[0] + } + return def +} + +// GenerateJSON:对 JSON 模板的叶子 @表达式逐请求生成;解析失败原样返回。 +func GenerateJSON(template string) string { + trimmed := strings.TrimSpace(template) + if trimmed == "" { + return template + } + if m := exprRe.FindStringSubmatch(trimmed); m != nil { + return Generate(trimmed) // 顶层就是表达式 + } + var root interface{} + if err := json.Unmarshal([]byte(trimmed), &root); err != nil { + return template + } + out := walkGenerate(root) + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return template + } + return string(b) +} + +func walkGenerate(node interface{}) interface{} { + switch v := node.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(v)) + for k, val := range v { + out[k] = walkGenerate(val) + } + return out + case []interface{}: + out := make([]interface{}, len(v)) + for i, val := range v { + out[i] = walkGenerate(val) + } + return out + case string: + s := strings.TrimSpace(v) + if strings.HasPrefix(s, "@") && exprRe.MatchString(s) { + raw := Generate(s) + // 数字/布尔叶子保持类型 + if n, err := strconv.Atoi(raw); err == nil { + return n + } + if f, err := strconv.ParseFloat(raw, 64); err == nil && strings.Contains(raw, ".") { + return f + } + if raw == "true" || raw == "false" { + return raw == "true" + } + return raw + } + return v + default: + return node + } +} diff --git a/apps/server/internal/mocksrv/mocksrv_test.go b/apps/server/internal/mocksrv/mocksrv_test.go new file mode 100644 index 0000000..0c5a472 --- /dev/null +++ b/apps/server/internal/mocksrv/mocksrv_test.go @@ -0,0 +1,137 @@ +package mocksrv + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + "time" +) + +func TestGenerateFormats(t *testing.T) { + cases := []struct { + expr string + re string + }{ + {"@integer(3, 7), extra", ""}, + } + _ = cases + checks := map[string]*regexp.Regexp{ + "@integer(3, 7)": regexp.MustCompile(`^[3-7]$`), + "@float(1, 9, 2)": regexp.MustCompile(`^\d\.\d{2}$`), + "@email": regexp.MustCompile(`^[\w.]+@[\w.]+\.\w+$`), + "@phone": regexp.MustCompile(`^1[3-9]\d{9}$`), + "@guid": regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), + "@id": regexp.MustCompile(`^\d{17}[\dX]$`), + "@datetime(YYYY-MM-DD HH:mm:ss)": regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`), + "@timestamp": regexp.MustCompile(`^\d{12,}$`), + "@boolean": regexp.MustCompile(`^(true|false)$`), + "@cname": regexp.MustCompile(`^\p{Han}{2,3}$`), + "@county(true)": regexp.MustCompile(`\p{Han}+(市)?\p{Han}+区$`), + "@pick(\"男\", \"女\")": regexp.MustCompile(`^(男|女)$`), + "@notExistThing": regexp.MustCompile(`^@notExistThing$`), // 未知表达式原样 + "普通文本": regexp.MustCompile(`^普通文本$`), + } + for expr, re := range checks { + for i := 0; i < 30; i++ { + out := Generate(expr) + if !re.MatchString(out) { + t.Fatalf("%s → %q 不匹配 %v", expr, out, re) + } + } + } +} + +func TestGenerateJSONLeaves(t *testing.T) { + tpl := `{"username":"@cname","age":"@integer(18,65)","vip":"@boolean","keep":"固定值"}` + var v map[string]interface{} + if err := json.Unmarshal([]byte(GenerateJSON(tpl)), &v); err != nil { + t.Fatalf("生成结果非 JSON: %v", err) + } + age, ok := v["age"].(float64) + if !ok || age < 18 || age > 65 { + t.Fatalf("age 应为 18-65 的数字,得到 %v", v["age"]) + } + if _, ok := v["vip"].(bool); !ok { + t.Fatalf("vip 应为布尔,得到 %T", v["vip"]) + } + if v["keep"] != "固定值" { + t.Fatalf("非表达式叶子应原样保留") + } + if s, _ := v["username"].(string); len(s) < 2 { + t.Fatalf("username 生成异常: %v", v["username"]) + } +} + +func TestGenerateJSONTopLevelAndInvalid(t *testing.T) { + if out := GenerateJSON("@cname"); len([]rune(out)) < 2 { + t.Fatalf("顶层表达式应生成: %q", out) + } + if out := GenerateJSON("not-json"); out != "not-json" { + t.Fatalf("非法 JSON 原样返回: %q", out) + } +} + +func TestLimiter(t *testing.T) { + l := newLimiter() + now := time.Now() + // 同一秒内第 21 次拒绝(QPS) + pass := 0 + for i := 0; i < 25; i++ { + if l.allow("k", now) { + pass++ + } + } + if pass != LimitQPS { + t.Fatalf("QPS 应放行 %d,实际 %d", LimitQPS, pass) + } + + // 后续秒窗重置但 60 秒窗累计:S2 放 20、S3 放 20(累计 60),S4 拒绝(QPM) + for _, off := range []time.Duration{2 * time.Second, 3 * time.Second} { + pass2 := 0 + for i := 0; i < 25; i++ { + if l.allow("k", now.Add(off)) { + pass2++ + } + } + if pass2 != 20 { + t.Fatalf("+%v 应放行 20(QPS),实际 %d", off, pass2) + } + } + if l.allow("k", now.Add(4*time.Second)) { + t.Fatalf("60 秒窗口内累计达 60 应拒绝") + } + + // 不同 key 相互独立 + if !l.allow("other", now.Add(3*time.Second)) { + t.Fatal("不同 key 不应被牵连") + } + + // 滑动窗口过期后恢复 + l2 := newLimiter() + t0 := time.Now() + for i := 0; i < LimitQPM; i++ { + if !l2.allow("k2", t0.Add(time.Duration(i)*time.Second)) { + t.Fatalf("分散 60 秒内应全部放行(第 %d 次被拒)", i+1) + } + } + if l2.allow("k2", t0.Add(59*time.Second)) { + t.Fatal("60 次命中仍在窗口内应被拒") + } + if !l2.allow("k2", t0.Add(130*time.Second)) { + t.Fatal("窗口滑过后应恢复放行") + } +} + +func TestNormalizePath(t *testing.T) { + if normalizePath("api/users/") != "/api/users" { + t.Fatalf("got %q", normalizePath("api/users/")) + } + if normalizePath("") != "/" { + t.Fatalf("空路径 → /") + } + if normalizePath("/x/") != "/x" { + t.Fatalf("去尾斜杠") + } + _ = strings.TrimSpace +} diff --git a/apps/server/internal/mocksrv/server.go b/apps/server/internal/mocksrv/server.go new file mode 100644 index 0000000..a1087da --- /dev/null +++ b/apps/server/internal/mocksrv/server.go @@ -0,0 +1,175 @@ +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 +} diff --git a/apps/server/internal/server/router.go b/apps/server/internal/server/router.go index c0109f1..c93b742 100644 --- a/apps/server/internal/server/router.go +++ b/apps/server/internal/server/router.go @@ -9,6 +9,7 @@ import ( "opsxapi/server/internal/api" "opsxapi/server/internal/docs" "opsxapi/server/internal/echo" + "opsxapi/server/internal/mocksrv" "opsxapi/server/internal/proxy" "opsxapi/server/internal/site" ) @@ -30,13 +31,15 @@ func NewRouter() *gin.Engine { r.GET("/v1/proxy/ws/attach", tunnel.Attach) r.POST("/v1/proxy/ws/close", tunnel.Close) - // 账号 / 团队 / 云端空间(SQLite) + // 账号 / 团队 / 云端空间(SQLite),store 同时供 Mock 服务复用 accountAPI, err := api.New(os.Getenv("OPSXAPI_DB")) if err != nil { panic("init sqlite store: " + err.Error()) } accountAPI.Register(r) + mocksrv.New(accountAPI.Store()).Register(r) + echo.Mount(r) docs.New().Register(r) site.New().Register(r) diff --git a/apps/server/internal/site/site.go b/apps/server/internal/site/site.go index 4d7e427..0927238 100644 --- a/apps/server/internal/site/site.go +++ b/apps/server/internal/site/site.go @@ -9,12 +9,13 @@ import ( "github.com/gin-gonic/gin" ) -//go:embed template.html assets +//go:embed template.html terms.html assets var files embed.FS // Handler 官网服务 type Handler struct { - html string + html string + terms string } func New() *Handler { @@ -22,7 +23,11 @@ func New() *Handler { if err != nil { panic("site: template.html 缺失: " + err.Error()) } - return &Handler{html: string(raw)} + terms, err := files.ReadFile("terms.html") + if err != nil { + panic("site: terms.html 缺失: " + err.Error()) + } + return &Handler{html: string(raw), terms: string(terms)} } // Register 注册路由:/ 与 /home 均渲染官网 @@ -31,6 +36,14 @@ func (h *Handler) Register(r *gin.Engine) { r.GET("/home", h.Page) r.GET("/index.html", h.Page) r.GET("/site/assets/*file", h.Asset) + r.GET("/terms", h.Terms) +} + +// Terms 渲染服务条款 +func (h *Handler) Terms(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.Header("Cache-Control", "no-cache") + _, _ = c.Writer.WriteString(h.terms) } // Page 渲染官网 diff --git a/apps/server/internal/site/template.html b/apps/server/internal/site/template.html index 9d78d8b..f30982d 100644 --- a/apps/server/internal/site/template.html +++ b/apps/server/internal/site/template.html @@ -350,7 +350,7 @@ 快速开始 - + diff --git a/apps/server/internal/site/terms.html b/apps/server/internal/site/terms.html new file mode 100644 index 0000000..cedc58d --- /dev/null +++ b/apps/server/internal/site/terms.html @@ -0,0 +1,77 @@ + + + + + + 服务条款 · OpsxAPI + + + + + + +
+ ← 返回官网 +

服务条款

+
最近更新:2026 年 9 月 17 日 · 生效范围:OpsxAPI 本地服务(含官网 /docs、/home、/mock 及 /api)
+ +

一、服务说明

+

OpsxAPI 是运行在你本机的接口调试与 Mock 服务。官网(/home)、文档(/docs)、账号与云端空间(/api)及本地 Mock 服务(/mock)默认仅监听 127.0.0.1,供个人与团队内部开发联调使用。

+ +

二、账号与安全

+ + +

三、频率限制

+

为保障本地服务稳定,Mock 接口(/mock)实施以下默认限流,超限返回 HTTP 429 与 Retry-After:

+ + + + + +
类别阈值超出行为
通用 API 请求频率60 QPM(每分钟 60 次)或 20 QPS(每秒 20 次)返回 429 错误
单空间 Mock 接口数200 个发布请求被拒绝(400)
云端空间数据体积20 MB保存请求被拒绝(413)
+ +

四、禁止滥用

+
+

若账户或使用行为存在「未经授权或滥用」情形,平台有权采取限流、暂停服务等措施。通过技术手段规避限流(如频繁更换来源地址、伪造标识等)本身即属于违规行为。

+
+ + +

五、数据与隐私

+ + +

六、免责声明

+

本软件按「现状」提供,不承诺适销性或特定用途适用性。因不可抗力、系统维护或依本条款采取措施导致的服务中断,不视为违约。在法律允许范围内,对间接损失不承担赔偿责任。

+ +

七、条款变更

+

我们可能随版本更新修订本条款,修订后在官网公示并更新日期;继续使用即视为接受修订后的条款。

+ +

如有疑问,请通过官网页脚的联系方式反馈。

+
+ + diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go index 1d996bc..b3177f9 100644 --- a/apps/server/internal/store/store.go +++ b/apps/server/internal/store/store.go @@ -11,8 +11,11 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" + "opsxapi/server/internal/mocksrv" + _ "modernc.org/sqlite" ) @@ -81,6 +84,19 @@ CREATE TABLE IF NOT EXISTS workspaces ( ); CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id); CREATE INDEX IF NOT EXISTS idx_members_user ON team_members(user_id); +CREATE TABLE IF NOT EXISTS mock_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 200, + content_type TEXT NOT NULL DEFAULT 'application/json', + body TEXT NOT NULL DEFAULT '', + delay_ms INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + UNIQUE(workspace_id, method, path) +); +CREATE INDEX IF NOT EXISTS idx_mock_ws ON mock_endpoints(workspace_id); CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id); `) return err @@ -333,6 +349,50 @@ func (s *Store) WorkspaceByID(wsID int64) (*Workspace, error) { return w, err } +/* ---------------- Mock 接口 ---------------- */ + +// ReplaceMockEndpoints 全量替换某空间的 Mock 接口(发布即同步)。 +func (s *Store) ReplaceMockEndpoints(workspaceID int64, list []mocksrv.Endpoint) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM mock_endpoints WHERE workspace_id = ?`, workspaceID); err != nil { + return err + } + now := time.Now().Unix() + stmt, err := tx.Prepare(`INSERT OR REPLACE INTO mock_endpoints(workspace_id, method, path, status, content_type, body, delay_ms, updated_at) VALUES(?,?,?,?,?,?,?,?)`) + if err != nil { + return err + } + defer stmt.Close() + for _, e := range list { + if e.Path == "" { + continue + } + if _, err := stmt.Exec(workspaceID, strings.ToUpper(e.Method), e.Path, e.Status, e.ContentType, e.Body, e.DelayMs, now); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) FindMockEndpoint(workspaceID int64, method, path string) (*mocksrv.Endpoint, bool, error) { + e := &mocksrv.Endpoint{} + err := s.db.QueryRow( + `SELECT method, path, status, content_type, body, delay_ms FROM mock_endpoints WHERE workspace_id = ? AND method = ? AND path = ?`, + workspaceID, strings.ToUpper(method), path, + ).Scan(&e.Method, &e.Path, &e.Status, &e.ContentType, &e.Body, &e.DelayMs) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return e, true, nil +} + func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) { now := time.Now().Unix() _, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 5d6363b..88d6aec 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -21,6 +21,7 @@ import { WebsocketEditor } from "./components/editors/WebsocketEditor"; import { ExportDialog } from "./components/dialogs/ExportDialog"; import { ImportDialog } from "./components/dialogs/ImportDialog"; import { AuthDialog, TeamDialog } from "./components/dialogs/AccountDialogs"; +import { MockLinkDialog } from "./components/dialogs/MockLinkDialog"; import { EnvironmentsDialog, NewItemDialog, @@ -94,6 +95,13 @@ export function OpsxWorkspace() { {dialog?.type === "environments" && } {dialog?.type === "settings" && } {dialog?.type === "rename" && } + {dialog?.type === "mocklink" && + (() => { + const ref = findNode(collections, dialog.nodeId); + return ref?.node.type === "request" && ref.node.kind === "http" ? ( + + ) : null; + })()} {dialog?.type === "auth" && } {dialog?.type === "team" && } diff --git a/packages/ui/src/account.ts b/packages/ui/src/account.ts index cfe2768..d832507 100644 --- a/packages/ui/src/account.ts +++ b/packages/ui/src/account.ts @@ -66,6 +66,22 @@ export function fetchWorkspace(token: string, id: number) { ); } +export type MockEndpointPayload = { + method: string; + path: string; + status: number; + contentType: string; + body: string; + delayMs: number; +}; + +export function publishMock(token: string, workspaceId: number, endpoints: MockEndpointPayload[]) { + return call<{ ok: boolean; count: number; base: string }>(`/workspaces/${workspaceId}/mock`, token, { + method: "PUT", + body: JSON.stringify({ endpoints }), + }); +} + export function saveWorkspace(token: string, id: number, data: string) { return call<{ updatedAt: number }>(`/workspaces/${id}`, token, { method: "PUT", diff --git a/packages/ui/src/components/dialogs/MockLinkDialog.tsx b/packages/ui/src/components/dialogs/MockLinkDialog.tsx new file mode 100644 index 0000000..4914949 --- /dev/null +++ b/packages/ui/src/components/dialogs/MockLinkDialog.tsx @@ -0,0 +1,230 @@ +/** + * Mock 链接弹窗:把当前接口发布到本地 Mock 服务(/mock/{空间ID}{路径}), + * 发布后可复制 URL、浏览器试一试;响应体支持 @表达式按次随机生成。 + */ +import { smartFillJson } from "@opsxapi/core"; +import type { HttpApiNode, MockSettings } from "@opsxapi/core"; +import { useState } from "react"; +import { publishMock } from "../../account"; +import { useWorkspace } from "../../workspaceStore"; +import { Modal, cx } from "../common"; +import { Icon } from "../icons"; + +export function MockLinkDialog({ node }: { node: HttpApiNode }) { + const account = useWorkspace((s) => s.account); + const settings = useWorkspace((s) => s.settings); + const collections = useWorkspace((s) => s.collections); + const setDialog = useWorkspace((s) => s.setDialog); + + const space = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null; + const [status, setStatus] = useState(200); + const [delayMs, setDelayMs] = useState(0); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); + + // 路径:取请求 URL 的 path 部分(前置 URL 模式下即相对路径) + const urlPath = extractPath(node.data.url); + const method = node.data.method; + + // 模板:JSON Body(智能填充烘焙 @表达式);非 JSON 用空对象 + let template = "{}"; + if (node.data.bodyMode === "json" && node.data.bodyRaw.trim()) { + template = node.data.bodyRaw; + if (settings.mock.enabled && settings.mock.smart) { + const filled = smartFillJson(template, settings.mock); + if (filled.filled > 0) template = filled.text; + } + } + + const mockUrl = space ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${space.id}${urlPath}` : ""; + + const publish = async () => { + if (!account.token || !space) return; + setBusy(true); + setResult(null); + try { + // 全量发布:集合内全部 HTTP 接口(同空间共用一组 Mock 端点) + const endpoints = collectEndpoints(collections, settings); + // 当前接口应用弹窗选择的状态码与延迟 + const mine = endpoints.find((e) => e.method === method && e.path === urlPath); + if (mine) { + mine.status = status; + mine.delayMs = delayMs; + } + await publishMock(account.token, space.id, endpoints); + setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到 /mock/${space.id}` }); + } catch (e) { + setResult({ ok: false, message: e instanceof Error ? e.message : String(e) }); + } finally { + setBusy(false); + } + }; + + const preview = async () => { + if (!mockUrl) return; + setBusy(true); + setResult(null); + try { + const res = await fetch(mockUrl, { method: "GET" }); + const text = await res.text(); + setResult({ ok: res.ok, message: `HTTP ${res.status} · ${text.slice(0, 400)}` }); + } catch (e) { + setResult({ ok: false, message: e instanceof Error ? e.message : String(e) }); + } finally { + setBusy(false); + } + }; + + return ( + setDialog(null)} + width="620px" + footer={ +
+ 本地 Mock 服务 · 限流 20 QPS / 60 QPM(超限 429) +
+ + +
+
+ } + > +
+ {!account.user ? ( +
+
+ + Mock 链接挂在云端空间下(按空间隔离),请先登录并切换到个人 / 团队空间。 +
+ +
+ ) : !space ? ( +
+ + 当前在本地空间。请通过顶栏账号胶囊切换到「我的空间」或团队空间后再发布 Mock。 +
+ ) : ( + <> +
+ +
+ e.currentTarget.select()} /> + + {method === "GET" && ( + + + + )} +
+
+ {method} {urlPath} · 非方法可用 ?_method={method} 测试 · 发布包含当前空间集合内全部 HTTP 接口 +
+
+ +
+
+ + +
+
+ + setDelayMs(Math.max(0, Math.min(5000, Number(e.target.value) || 0)))} + /> +
+
+ +
+ +