桌面 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:
logan committed 2026-10-16 03:03:36 +08:00
1 parent 6452026fd6
commit 2bb4ef7a95
299 files changed
+6059 -3322

No files matched your search

+288
View File
@@ -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
}
}
+150
View File
@@ -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)
}
}
+226
View File
@@ -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
}