- 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);浏览器发布+试一试通过
289 lines
9.3 KiB
Go
289 lines
9.3 KiB
Go
// 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
|
|
}
|
|
}
|