From d9ccccd2801eb51ba195de3e6d06b2160e48522c Mon Sep 17 00:00:00 2001
From: logan
Date: Fri, 18 Sep 2026 12:29:49 +0800
Subject: [PATCH] =?UTF-8?q?=E6=9C=AC=E5=9C=B0=20Mock=20=E6=9C=8D=E5=8A=A1?=
=?UTF-8?q?=20/mock=20+=20=E9=99=90=E6=B5=81=2020QPS/60QPM=20+=20=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E6=9D=A1=E6=AC=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Go Mock 引擎(与前端同语法 @cname/@integer/... 每次请求随机生成,JSON 叶子保持类型)
- mock_endpoints 表 + 发布 API(PUT /api/workspaces/:id/mock,全量替换,单空间 200 个)
- 路由 /mock/:wsid/*path:状态码/延迟(≤5s)/?_method 覆盖;未发布 404
- 限流(IP+空间 滑动窗口):20 QPS / 60 QPM → 429 + Retry-After + 条款提示
- 官网 /terms 服务条款(七节:含禁止滥用——未经授权或滥用有权采取措施、技术手段规避限流属违规)
- UI:HTTP 编辑器「Mock 链接」弹窗(发布/状态码/延迟/模板预览/复制/试一试);
发布自动烘焙智能 Mock 表达式;集合端点去重 + INSERT OR REPLACE
- docs:14-mock 增本地 Mock 服务与限流章节;页脚/429 响应均指向 /terms
- 单测:mocksrv 引擎/限流/路径 5 用例;curl E2E(随机×2、201+延迟、404、连发 30 次触发 429);浏览器发布+试一试通过
---
apps/server/data/opsxapi.db-shm | Bin 32768 -> 32768 bytes
apps/server/data/opsxapi.db-wal | Bin 362592 -> 436752 bytes
apps/server/internal/api/api.go | 30 ++
apps/server/internal/docs/content/14-mock.md | 18 ++
apps/server/internal/mocksrv/engine.go | 288 ++++++++++++++++++
apps/server/internal/mocksrv/mocksrv_test.go | 137 +++++++++
apps/server/internal/mocksrv/server.go | 175 +++++++++++
apps/server/internal/server/router.go | 5 +-
apps/server/internal/site/site.go | 19 +-
apps/server/internal/site/template.html | 2 +-
apps/server/internal/site/terms.html | 77 +++++
apps/server/internal/store/store.go | 60 ++++
packages/ui/src/App.tsx | 8 +
packages/ui/src/account.ts | 16 +
.../src/components/dialogs/MockLinkDialog.tsx | 230 ++++++++++++++
.../ui/src/components/editors/HttpEditor.tsx | 8 +
packages/ui/src/workspaceStore.ts | 3 +-
17 files changed, 1070 insertions(+), 6 deletions(-)
create mode 100644 apps/server/internal/mocksrv/engine.go
create mode 100644 apps/server/internal/mocksrv/mocksrv_test.go
create mode 100644 apps/server/internal/mocksrv/server.go
create mode 100644 apps/server/internal/site/terms.html
create mode 100644 packages/ui/src/components/dialogs/MockLinkDialog.tsx
diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm
index 3613e95cd92acda77dfa7637664081781c70b53c..b4c4bbac040ed27484d13da8e654c2a96d60ef55 100644
GIT binary patch
delta 395
zcmZo@U}|V!s+V}A%K!q55G;@dq(y<)?^+^%Y@g%y66e;2kj}%Z%pHsFHxRP`F-#3e4u!w@
zAmc@c$^V>0m?Iftft=?|lmEDKFa`jbUzs?W8N?Xm8B7@h!Q9C|yoK0<7=js+8PYck
z`g~+!4r53F%DiXdXJ?RMP-U=YaAELc2xmwH3vT`t!o$fJ0%X5tn*1t(hdF{F2_o?#
znS+@zbo0lw?@Y{53~@lg?@a6t4ABfJ3>grPBSQ>BDnllOK+MR%Am9(AMS*zx$xGW#*=HG-IJY*0bRJgaRb0FLoMRz3
zsp^47fdRfTC}hxY>X%R$;JWaAxoY%6w+xW@nILP-U=YaAEKR3vB)r!ovwx
vJNZ=t50LSRY4eL@4rU#W3mETu$
diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal
index c3727be3c75e54ef9ca848d71fa953d2f386f7f2..29c6982cb0c3b1aeefe4457ed2bdbda8d7e3a312 100644
GIT binary patch
delta 2469
zcmeH}e{2(F7{~8tyA^tG3v2`D;JAt7wvP43s1&1Ffez8xG3c^IwshCK8ys}Kvfc)B
z*~VlL{6jNRNd1RI#b6|2h|?^=h(8-4snU{$-Q5
zx%Yja_xrr>^Znf0Uh2>7AG&8(AKpjwk$uz~bkn}XyQfJ@*4Rha9~pb`izD$%(R?e3
zVOS1q3~U5!+40%iU9(AS*}=1B@FT_;8xr|-)B?)bknJ82EJKFM-F0ST9?#=@6-@|*
z1Ti*VWYemZpysR3salJcLc(G;iV`WDI%Ld|d6BzbK;^v7jTfZyDTFg7+I1
zBUdnO^`2dUr%x@}8>kzecl_pVGzLF7Y}UbR?bL0Mm^DF~v&QJ6!=G*S;WfA}klR5zgM1WxuG7vNLGy+3j
zA&++{5lP;uNIQf2_IFIP-iq
zdVBoHa@TW^gFBxTW4Ts1kOs=TKCk1UPnTRY4b)f&O9_pF@4$AVv;NqJfh*sb;h52U
z9RqIy1r#u!F?$$~`8ot>#9Z~Lk?u9)lqfnShv;@UyP8+XA-CujC6`BZl#9-Ccc|Ru
zmdl-jL-5$`4#x_+Yk9~nJDnbnDx&?GZ@9d
zuV4av53YhSFbXch1HT6n$4`?qZcIG9kLH%b4N|bB3l?keWX=EBw#N*vU=%osfv@1|
z^WY?0`5Krvha!1A^Lii>Cmx_3i@{Wpcjg>@=AgHMKvgH0Nel!T^s?S$dYT9mhUXL#
zr3yh&>#H{g%7my=rnX9QYYaOx{R-^l^cla(t>h{;wXJPo<#YAcEwk(QT>r19+i=hlXAIwYiz6)&;
zRt4RN>6$-HCB*uUy<tI5H9!!z0am(Z(!hO@B_F3WiKTmU?`dv4$4L7N!>F7M3ln2^|1Xh6g|Z
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 @@
快速开始
-
+
+
+ ← 返回官网
+ 服务条款
+ 最近更新: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) |
+
+
+ 四、禁止滥用
+
+
若账户或使用行为存在「未经授权或滥用」情形,平台有权采取限流、暂停服务等措施。通过技术手段规避限流(如频繁更换来源地址、伪造标识等)本身即属于违规行为。
+
+
+ - 不得将本地 Mock 服务长期暴露到公网对外提供流量服务,或用于任何压测 / 攻击 / 爬虫目标;
+ - 不得利用服务存储违法违规内容,或侵害第三方(含任何第三方接口、服务)权益;
+ - 不得逆向、干扰、破坏服务及其组件,或绕过任何使用限制与安全措施;
+ - 对违反上述条款的行为,我们有权不经通知采取限流、下线 Mock 接口、停用账号等措施。
+
+
+ 五、数据与隐私
+
+ - 所有数据(账号、团队空间、Mock 接口)默认存储于本机 SQLite(data/opsxapi.db),不经任何第三方中转、不上传外部服务器;
+ - AI 功能直连你自己配置的模型供应商,密钥仅存本机;
+ - 请自行做好数据备份(客户端支持全量备份导出);对未备份的数据丢失不承担责任。
+
+
+ 六、免责声明
+ 本软件按「现状」提供,不承诺适销性或特定用途适用性。因不可抗力、系统维护或依本条款采取措施导致的服务中断,不视为违约。在法律允许范围内,对间接损失不承担赔偿责任。
+
+ 七、条款变更
+ 我们可能随版本更新修订本条款,修订后在官网公示并更新日期;继续使用即视为接受修订后的条款。
+
+ 如有疑问,请通过官网页脚的联系方式反馈。
+
+
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 @@
+
+
+