桌面 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,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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
|
||||
}
|
||||
|
||||
func TestResponseBodyFormats(t *testing.T) {
|
||||
for _, body := range []string{"@cname", `{"name":"@cname"}`, "plain text\n"} {
|
||||
if got := responseBody("text/plain", body); got != body {
|
||||
t.Fatalf("text changed: %q", got)
|
||||
}
|
||||
}
|
||||
var decoded map[string]int
|
||||
got := responseBody("application/json; charset=utf-8", `{"n":"@integer(1,1)"}`)
|
||||
if err := json.Unmarshal([]byte(got), &decoded); err != nil || decoded["n"] != 1 {
|
||||
t.Fatalf("JSON template not expanded: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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