桌面 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

Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"log"
"os"
"strings"
"opsxapi/server/internal/server"
)
func main() {
addr := resolveListenAddr()
mockAddr := strings.TrimSpace(os.Getenv("OPSXAPI_MOCK_ADDR"))
if mockAddr == "" {
mockAddr = "0.0.0.0:8788"
}
mockRouter := server.NewMockRouter()
go func() {
log.Printf("OpsxAPI private-network Mock listening on %s", mockAddr)
if err := mockRouter.Run(mockAddr); err != nil {
log.Fatalf("Mock listener failed: %v", err)
}
}()
log.Printf("OpsxAPI server listening on %s", addr)
if err := server.NewRouter().Run(addr); err != nil {
log.Fatal(err)
}
}
// resolveListenAddr:默认仅本机 127.0.0.1:8787(Mock 本机优先)。
// 显式对外:OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=0.0.0.0:8787 / :8787。
func resolveListenAddr() string {
if v := strings.TrimSpace(os.Getenv("OPSXAPI_ADDR")); v != "" {
return v
}
pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXAPI_BIND_PUBLIC")))
if pub == "1" || pub == "true" || pub == "yes" {
return ":8787"
}
return "127.0.0.1:8787"
}
+9
View File
@@ -0,0 +1,9 @@
server:
# 默认仅本机;公网需显式 OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=:8787
addr: "127.0.0.1:8787"
proxy:
timeout_ms: 30000
# Environment overrides:
# OPSXAPI_ADDR=127.0.0.1:8787
# OPSXAPI_BIND_PUBLIC=1
# OPSXAPI_PROXY_TIMEOUT_MS=30000
Binary file not shown.
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
module opsxapi/server
go 1.25.0
require (
github.com/gin-gonic/gin v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/yuin/goldmark v1.8.6
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.59.0 // indirect
)
+113
View File
@@ -0,0 +1,113 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+589
View File
@@ -0,0 +1,589 @@
// Package api:账号 / 团队 / 云端空间同步接口(Bearer Token 鉴权)。
package api
import (
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"opsxapi/server/internal/mocksrv"
"opsxapi/server/internal/store"
)
type API struct {
store *store.Store
}
func New(path string) (*API, error) {
s, err := store.Open(path)
if err != nil {
return nil, err
}
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)
g.POST("/auth/login", a.handleLogin)
g.POST("/auth/logout", a.auth(a.handleLogout))
g.GET("/me", a.auth(a.handleMe))
g.POST("/teams", a.auth(a.handleCreateTeam))
g.PATCH("/teams/:id", a.auth(a.handleRenameTeam))
g.DELETE("/teams/:id", a.auth(a.handleDeleteTeam))
g.POST("/teams/:id/members", a.auth(a.handleAddMember))
g.GET("/teams/:id/members", a.auth(a.handleListMembers))
g.PUT("/teams/:id/members/:uid", a.auth(a.handleUpdateSpecialty))
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))
g.GET("/mock/local", a.handleLocalMockStatus)
g.GET("/docs/local", a.localDocs)
g.PUT("/docs/local", a.publishLocalDocs)
g.DELETE("/docs/local/:projectId", a.revokeLocalDocs)
g.PATCH("/mock/local", a.handleLocalMockToggle)
g.PUT("/mock/local/access", a.handleLocalMockAccess)
g.PUT("/mock/local", a.handlePublishLocalMock)
g.DELETE("/mock/local", a.handleRevokeLocalMock)
g.DELETE("/workspaces/:id/mock", a.auth(a.handleRevokeMock))
}
/* ---------------- 中间件 ---------------- */
func (a *API) auth(fn func(c *gin.Context, user *store.User)) gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
token := strings.TrimSpace(strings.TrimPrefix(h, "Bearer"))
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
user, err := a.store.UserByToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"})
return
}
c.Set("token", token)
fn(c, user)
}
}
func userID(c *gin.Context) int64 { return c.GetInt64("uid") }
/* ---------------- 鉴权 ---------------- */
func (a *API) handleRegister(c *gin.Context) {
var req struct {
Name string `json:"name"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写有效邮箱与至少 6 位密码"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = strings.SplitN(req.Email, "@", 2)[0]
}
if _, err := a.store.UserByEmail(strings.ToLower(req.Email)); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已注册"})
return
}
user, err := a.store.CreateUser(name, strings.ToLower(req.Email), req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()})
return
}
token, err := a.store.IssueToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"})
return
}
c.JSON(http.StatusCreated, gin.H{"token": token, "user": user})
}
func (a *API) handleLogin(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写邮箱与密码"})
return
}
user, err := a.store.CheckPassword(strings.ToLower(req.Email), req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码不正确"})
return
}
token, err := a.store.IssueToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "user": user})
}
func (a *API) handleLogout(c *gin.Context, _ *store.User) {
if token, _ := c.Get("token"); token != nil {
_ = a.store.DeleteToken(token.(string))
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
/* ---------------- 账号概览 ---------------- */
func (a *API) handleMe(c *gin.Context, user *store.User) {
teams, err := a.store.TeamsOfUser(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
spaces, err := a.store.WorkspacesOfUser(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// data 不随列表下发
for i := range spaces {
spaces[i].Data = ""
}
c.JSON(http.StatusOK, gin.H{"user": user, "teams": teams, "workspaces": spaces, "serverTime": time.Now().Unix()})
}
/* ---------------- 团队 ---------------- */
func (a *API) handleCreateTeam(c *gin.Context, user *store.User) {
var req struct {
Name string `json:"name" binding:"required,min=1,max=40"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写团队名称"})
return
}
team, err := a.store.CreateTeam(user.ID, strings.TrimSpace(req.Name))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()})
return
}
c.JSON(http.StatusCreated, team)
}
func (a *API) handleAddMember(c *gin.Context, user *store.User) {
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
return
}
owner, err := a.store.IsTeamOwner(teamID, user.ID)
if err != nil || !owner {
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可添加成员"})
return
}
var req struct {
Email string `json:"email" binding:"required,email"`
Specialty string `json:"specialty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写成员邮箱"})
return
}
member, err := a.store.AddTeamMember(teamID, strings.ToLower(req.Email), trimSpecialty(req.Specialty))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "member": member})
}
// handleRenameTeam 重命名团队(仅创建者)
func (a *API) handleRenameTeam(c *gin.Context, user *store.User) {
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
return
}
owner, err := a.store.IsTeamOwner(teamID, user.ID)
if err != nil || !owner {
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可重命名团队"})
return
}
var req struct {
Name string `json:"name" binding:"required,min=1,max=40"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "团队名称需 1-40 个字符"})
return
}
if err := a.store.RenameTeam(teamID, strings.TrimSpace(req.Name)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重命名失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// handleDeleteTeam 删除团队(仅创建者):成员、共享空间与 Mock 数据一并删除
func (a *API) handleDeleteTeam(c *gin.Context, user *store.User) {
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
return
}
owner, err := a.store.IsTeamOwner(teamID, user.ID)
if err != nil || !owner {
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可删除团队"})
return
}
if err := a.store.DeleteTeam(teamID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// handleListMembers 团队成员列表(创建者与成员均可查看)
func (a *API) handleListMembers(c *gin.Context, user *store.User) {
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
return
}
member, err := a.store.IsTeamMember(teamID, user.ID)
if err != nil || !member {
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队成员可查看成员列表"})
return
}
members, err := a.store.TeamMembers(teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"members": members})
}
// handleUpdateSpecialty 更新成员擅长:创建者可改任何人,成员只能改自己
func (a *API) handleUpdateSpecialty(c *gin.Context, user *store.User) {
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
return
}
uid, err := strconv.ParseInt(c.Param("uid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的成员"})
return
}
owner, err := a.store.IsTeamOwner(teamID, user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !owner && uid != user.ID {
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可修改他人擅长,成员可修改自己的"})
return
}
var req struct {
Specialty string `json:"specialty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := a.store.UpdateMemberSpecialty(teamID, uid, trimSpecialty(req.Specialty)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "该成员不在团队中"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// trimSpecialty 擅长标签去空白、压长度
func trimSpecialty(s string) string {
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
r := []rune(s)
if len(r) > 60 {
return string(r[:60])
}
return s
}
/* ---------------- 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"`
TTLSeconds int64 `json:"ttlSeconds"`
}
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 req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"})
return
}
if err := a.store.ReplaceMockEndpoints(ws.ID, req.Endpoints); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
return
}
var expiresAt int64
if req.TTLSeconds > 0 {
expiresAt = time.Now().Unix() + req.TTLSeconds
}
if err := a.store.SetMockExpiry(ws.ID, expiresAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()})
return
}
base := ws.MockToken
if base == "" {
base = strconv.FormatInt(ws.ID, 10)
}
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base, "expiresAt": expiresAt})
}
func (a *API) handleRevokeMock(c *gin.Context, user *store.User) {
ws := a.workspaceOf(c, user)
if ws == nil {
return
}
if _, err := a.store.RevokeMock(ws.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
/* ---------------- 工作区同步 ---------------- */
func (a *API) workspaceOf(c *gin.Context, user *store.User) *store.Workspace {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的空间"})
return nil
}
ok, err := a.store.CanAccessWorkspace(id, user.ID)
if err != nil || !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该空间"})
return nil
}
ws, err := a.store.WorkspaceByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "空间不存在"})
return nil
}
return ws
}
func (a *API) handleGetWorkspace(c *gin.Context, user *store.User) {
ws := a.workspaceOf(c, user)
if ws == nil {
return
}
c.JSON(http.StatusOK, ws)
}
func (a *API) handlePutWorkspace(c *gin.Context, user *store.User) {
ws := a.workspaceOf(c, user)
if ws == nil {
return
}
var req struct {
Data string `json:"data" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少数据体"})
return
}
if len(req.Data) > 20*1024*1024 {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "工作区数据超过 20MB 上限"})
return
}
now, err := a.store.SaveWorkspace(ws.ID, req.Data)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updatedAt": now})
}
/* ---------------- 本机联调 Mock(不绑账号) ---------------- */
func (a *API) handlePublishLocalMock(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "本机联调发布仅允许从 127.0.0.1 / ::1 调用"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5*1024*1024)
var req struct {
Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
TTLSeconds int64 `json:"ttlSeconds"`
}
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
}
for _, ep := range req.Endpoints {
method := strings.ToUpper(ep.Method)
allowed := method == "GET" || method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE" || method == "HEAD" || method == "OPTIONS"
if !allowed || !strings.HasPrefix(ep.Path, "/") || strings.ContainsAny(ep.Path, "?#\r\n") || ep.Status < 200 || ep.Status > 599 || ep.DelayMs < 0 || ep.DelayMs > 30000 || strings.ContainsAny(ep.ContentType, "\r\n") {
c.JSON(400, gin.H{"error": "Mock 接口参数无效:检查方法、路径、状态码(200~599)和延迟(0~30000ms)"})
return
}
}
if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"})
return
}
wsID, token, err := a.store.EnsureLocalMockWorkspace()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()})
return
}
if err := a.store.ReplaceMockEndpoints(wsID, req.Endpoints); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
return
}
var expiresAt int64
if req.TTLSeconds > 0 {
expiresAt = time.Now().Unix() + req.TTLSeconds
}
if err := a.store.SetMockExpiry(wsID, expiresAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + token, "token": token, "expiresAt": expiresAt})
}
func (a *API) handleRevokeLocalMock(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "本机联调作废仅允许从 127.0.0.1 / ::1 调用"})
return
}
wsID, _, err := a.store.EnsureLocalMockWorkspace()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()})
return
}
if _, err := a.store.RevokeMock(wsID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func isLoopbackRequest(c *gin.Context) bool {
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
if err != nil {
host = c.Request.RemoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func (a *API) handleLocalMockStatus(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(403, gin.H{"error": "仅允许本机管理 Mock"})
return
}
endpoints, token, expires, err := a.store.LocalMockSnapshot()
if err != nil {
c.JSON(500, gin.H{"error": "读取本机 Mock 失败"})
return
}
addr := os.Getenv("OPSXAPI_MOCK_ADDR")
if addr == "" {
addr = "0.0.0.0:8788"
}
bindHost, port, err := net.SplitHostPort(addr)
if err != nil {
c.JSON(500, gin.H{"error": "Mock 监听地址配置无效"})
return
}
addresses := []string{"http://127.0.0.1:" + port}
if list, err := net.InterfaceAddrs(); err == nil && (bindHost == "" || bindHost == "0.0.0.0" || bindHost == "::") {
for _, item := range list {
if ipnet, ok := item.(*net.IPNet); ok && ipnet.IP.To4() != nil && ipnet.IP.IsPrivate() {
addresses = append(addresses, "http://"+net.JoinHostPort(ipnet.IP.String(), port))
}
}
}
if ip := net.ParseIP(bindHost); ip != nil && ip.IsPrivate() {
addresses = append(addresses, "http://"+net.JoinHostPort(bindHost, port))
}
enabled, err := a.store.MockEnabled()
if err != nil {
c.JSON(500, gin.H{"error": "读取 Mock 服务状态失败"})
return
}
bearerRequired, bearerConfigured, err := a.store.MockAccess()
if err != nil {
c.JSON(500, gin.H{"error": "读取 Mock 访问控制失败"})
return
}
c.JSON(200, gin.H{"enabled": enabled, "bearerRequired": bearerRequired, "bearerConfigured": bearerConfigured, "endpoints": endpoints, "token": token, "expiresAt": expires, "active": len(endpoints) > 0 && (expires == 0 || time.Now().Unix() < expires), "allowPublic": os.Getenv("OPSXAPI_MOCK_ALLOW_PUBLIC") == "1", "listenAddr": addr, "addresses": addresses})
}
func (a *API) handleLocalMockAccess(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "仅允许本机配置 Mock 访问控制"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
Token string `json:"token"`
}
if c.ShouldBindJSON(&req) != nil || req.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定是否启用 Bearer 验证"})
return
}
if *req.Enabled {
if len(req.Token) < 16 || len(req.Token) > 256 || strings.TrimSpace(req.Token) != req.Token {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bearer 密钥须为 16~256 字符且不能有首尾空格"})
return
}
}
if err := a.store.SetMockAccess(*req.Enabled, req.Token); err != nil {
c.JSON(500, gin.H{"error": "保存 Mock 访问控制失败"})
return
}
c.JSON(200, gin.H{"ok": true})
}
func (a *API) handleLocalMockToggle(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(403, gin.H{"error": "仅允许本机管理 Mock"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil || req.Enabled == nil {
c.JSON(400, gin.H{"error": "缺少 enabled"})
return
}
if err := a.store.SetMockEnabled(*req.Enabled); err != nil {
c.JSON(500, gin.H{"error": "更新 Mock 服务状态失败"})
return
}
c.JSON(200, gin.H{"enabled": *req.Enabled})
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func (a *API) localDocs(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(403, gin.H{"error": "仅允许本机管理文档"})
return
}
docs, err := a.store.PublishedDocs()
if err != nil {
c.JSON(500, gin.H{"error": "文档读取失败"})
return
}
c.JSON(200, gin.H{"projects": docs})
}
func (a *API) publishLocalDocs(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(403, gin.H{"error": "仅允许本机发布文档"})
return
}
var req struct {
ProjectID string `json:"projectId"`
Name string `json:"name"`
Endpoints []json.RawMessage `json:"endpoints"`
}
data, err := io.ReadAll(io.LimitReader(c.Request.Body, 2<<20+1))
if err != nil || len(data) > 2<<20 || json.Unmarshal(data, &req) != nil || len(req.Endpoints) > 500 || len(req.Endpoints) == 0 || len(req.ProjectID) > 128 || len(req.Name) > 200 || strings.TrimSpace(req.ProjectID) == "" || strings.TrimSpace(req.Name) == "" {
c.JSON(400, gin.H{"error": "文档无效或超过限制(2 MB / 500 个接口)"})
return
}
for _, endpoint := range req.Endpoints {
var item struct {
Method string `json:"method"`
Path string `json:"path"`
Name string `json:"name"`
}
if json.Unmarshal(endpoint, &item) != nil || item.Name == "" || item.Method == "" || !strings.HasPrefix(item.Path, "/") {
c.JSON(400, gin.H{"error": "接口方法、名称或路径无效"})
return
}
}
doc, err := a.store.PublishDoc(req.ProjectID, req.Name, string(data))
if err != nil {
c.JSON(500, gin.H{"error": "文档发布失败"})
return
}
c.JSON(200, doc)
}
func (a *API) revokeLocalDocs(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(403, gin.H{"error": "仅允许本机管理文档"})
return
}
if err := a.store.RevokeDoc(c.Param("projectId")); err != nil {
c.JSON(500, gin.H{"error": "撤销文档失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+120
View File
@@ -0,0 +1,120 @@
package api
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"opsxapi/server/internal/mocksrv"
)
func TestLocalMockLifecycleAndPeerBoundary(t *testing.T) {
gin.SetMode(gin.TestMode)
a, err := New(filepath.Join(t.TempDir(), "mock.db"))
if err != nil {
t.Fatal(err)
}
defer a.Store().Close()
r := gin.New()
_ = r.SetTrustedProxies(nil)
a.Register(r)
mocksrv.New(a.Store()).Register(r)
callWithBearer := func(method, path, body, peer, bearer string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.RemoteAddr = peer
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
req.Header.Set("X-Forwarded-For", "127.0.0.1")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
call := func(method, path, body, peer string) *httptest.ResponseRecorder {
return callWithBearer(method, path, body, peer, "")
}
if w := call("GET", "/api/mock/local", "", "192.168.1.5:3000"); w.Code != 403 {
t.Fatalf("remote management: %d", w.Code)
}
payload := `{"endpoints":[{"method":"POST","path":"/demo","status":201,"contentType":"application/json","body":"{\"ok\":true}","delayMs":0}],"ttlSeconds":0}`
if w := call("PUT", "/api/mock/local", payload, "127.0.0.1:3000"); w.Code != 200 {
t.Fatal(w.Body.String())
}
w := call("GET", "/api/mock/local", "", "127.0.0.1:3000")
var status struct {
Token string `json:"token"`
Active bool `json:"active"`
Endpoints []mocksrv.Endpoint `json:"endpoints"`
}
if err = json.Unmarshal(w.Body.Bytes(), &status); err != nil {
t.Fatal(err)
}
if !status.Active || len(status.Endpoints) != 1 {
t.Fatal(w.Body.String())
}
path := "/mock/" + status.Token + "/demo"
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 201 {
t.Fatal(w.Code, w.Body.String())
}
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"test-secret-1234567890"}`, "192.168.1.5:3000"); w.Code != 403 {
t.Fatalf("remote access control: %d", w.Code)
}
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"short"}`, "127.0.0.1:3000"); w.Code != 400 {
t.Fatalf("short token accepted: %d", w.Code)
}
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"test-secret-1234567890"}`, "127.0.0.1:3000"); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = call("GET", "/api/mock/local", "", "127.0.0.1:3000"); w.Code != 200 || !strings.Contains(w.Body.String(), `"bearerRequired":true`) || strings.Contains(w.Body.String(), "test-secret-1234567890") {
t.Fatalf("status leaked secret or wrong access state: %s", w.Body.String())
}
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("missing bearer: %d", w.Code)
}
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "wrong-secret-1234567890"); w.Code != 401 {
t.Fatalf("wrong bearer: %d", w.Code)
}
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "test-secret-1234567890"); w.Code != 201 {
t.Fatalf("correct bearer: %d %s", w.Code, w.Body.String())
}
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"replacement-1234567890"}`, "127.0.0.1:3000"); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "test-secret-1234567890"); w.Code != 401 {
t.Fatalf("old bearer still works after replacement: %d", w.Code)
}
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "replacement-1234567890"); w.Code != 201 {
t.Fatalf("replacement bearer: %d", w.Code)
}
if w = call("PUT", "/api/mock/local/access", `{"enabled":false}`, "127.0.0.1:3000"); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 201 {
t.Fatalf("disabled bearer still required: %d", w.Code)
}
if w = call("PATCH", "/api/mock/local", `{"enabled":false}`, "192.168.1.5:3000"); w.Code != 403 {
t.Fatal("remote toggle allowed")
}
if w = call("PATCH", "/api/mock/local", `{"enabled":false}`, "127.0.0.1:3000"); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 503 {
t.Fatal("disabled mock still responds", w.Code)
}
if w = call("PATCH", "/api/mock/local", `{"enabled":true}`, "127.0.0.1:3000"); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 201 {
t.Fatal("toggle lost mock data", w.Code)
}
if w = call("DELETE", "/api/mock/local", "", "127.0.0.1:3000"); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 404 {
t.Fatalf("revoked token: %d", w.Code)
}
}
+246
View File
@@ -0,0 +1,246 @@
/* OpsxAPI 文档站 —— 清爽专业风格(浅色) */
:root {
--blue: #2563eb;
--blue-dark: #1d4ed8;
--blue-soft: rgba(37, 99, 235, 0.08);
--bg: #f6f8fb;
--panel: #ffffff;
--border: #e4e9f1;
--text: #1c2434;
--text-2: #5b6880;
--text-3: #98a2b3;
--code-bg: #f3f6fb;
--code-border: #e1e8f3;
--green: #059669;
--amber: #b45309;
--red: #dc2626;
--sidebar-w: 262px;
--toc-w: 220px;
--topbar-h: 56px;
--radius: 10px;
--shadow: 0 1px 3px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.05);
--mono: "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 72px; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14.5px;
line-height: 1.75;
}
a { color: var(--blue); text-decoration: none; }
.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; }
a:hover { color: var(--blue-dark); text-decoration: underline; }
/* ---------- 顶栏 ---------- */
.topbar {
position: sticky; top: 0; z-index: 50;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: saturate(1.4) blur(8px);
border-bottom: 1px solid var(--border);
}
.topbar-inner {
max-width: 1440px; margin: 0 auto; height: var(--topbar-h);
display: flex; align-items: center; gap: 14px; padding: 0 20px;
}
.menu-btn {
display: none; border: 1px solid var(--border); background: #fff;
border-radius: 8px; width: 34px; height: 34px; cursor: pointer; color: var(--text-2);
place-items: center;
}
.menu-btn .i { width: 17px; height: 17px; }
.brand { display: flex; align-items: center; gap: 9px; text-decoration: none !important; }
.brand-logo { display: inline-flex; }
.brand-logo svg { display: block; border-radius: 7px; }
.brand-name { font-size: 16px; font-weight: 700; color: var(--text); }
.brand-name em { font-style: normal; font-weight: 500; color: var(--text-2); margin-left: 2px; }
.ver-badge {
font-size: 11px; font-weight: 600; color: var(--blue);
background: var(--blue-soft); border: 1px solid rgba(37, 99, 235, 0.22);
padding: 1px 9px; border-radius: 999px; white-space: nowrap;
}
.search { position: relative; flex: 1; max-width: 420px; margin-left: 8px; }
.search input {
width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 9px;
background: var(--bg); padding: 0 14px 0 34px; font-size: 13.5px; color: var(--text); outline: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='none' stroke='%2398a2b3' stroke-width='2' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: 12px center;
}
.search input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px var(--blue-soft); }
.search-panel {
position: absolute; top: 44px; left: 0; right: 0; background: #fff;
border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow);
max-height: 420px; overflow: auto; padding: 6px;
}
.search-item { display: block; padding: 8px 12px; border-radius: 8px; text-decoration: none !important; }
.search-item:hover, .search-item.sel { background: var(--blue-soft); }
.search-item .si-title { font-weight: 600; color: var(--text); font-size: 13.5px; }
.search-item .si-group { color: var(--text-3); font-size: 11px; margin-left: 6px; }
.search-item .si-snippet { color: var(--text-2); font-size: 12px; margin-top: 2px; display: block; }
.search-empty { padding: 14px; text-align: center; color: var(--text-3); font-size: 13px; }
.top-links { margin-left: auto; display: flex; gap: 16px; align-items: center; }
.top-links a { font-size: 13.5px; font-weight: 500; }
/* ---------- 三栏布局 ---------- */
.layout {
max-width: 1440px; margin: 0 auto; display: grid;
grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--toc-w);
gap: 0 28px; padding: 24px 20px 60px;
}
/* 侧栏 */
.sidebar { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-right: 8px; }
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: #d6dde8; border-radius: 4px; }
.nav-group { margin-bottom: 18px; }
.nav-group-title {
font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-3); padding: 0 10px 6px;
}
.nav-link {
display: block; padding: 6px 10px; border-radius: 8px; font-size: 13.5px;
color: var(--text-2); text-decoration: none !important; border-left: 2px solid transparent;
}
.nav-link:hover { background: rgba(37, 99, 235, 0.06); color: var(--text); }
.nav-link.active {
background: var(--blue-soft); color: var(--blue-dark); font-weight: 600;
border-left-color: var(--blue);
}
.sidebar-foot { margin-top: 24px; padding: 12px 10px 0; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--text-3); }
/* 内容 */
.content { min-width: 0; max-width: 860px; }
.breadcrumb { font-size: 12.5px; color: var(--text-3); margin-bottom: 10px; }
.breadcrumb span { margin: 0 6px; }
.page-title { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 6px; line-height: 1.3; }
.page-meta { font-size: 12.5px; color: var(--text-3); margin-bottom: 22px; }
/* Markdown 排版 */
.md h2 { font-size: 21px; font-weight: 700; margin: 38px 0 12px; padding-top: 8px; letter-spacing: -0.01em; }
.md h3 { font-size: 16.5px; font-weight: 700; margin: 26px 0 8px; }
.md h2 + h3, .md h1 + h2 { margin-top: 18px; }
.md p { margin: 10px 0; color: #2a3348; }
.md ul, .md ol { padding-left: 22px; margin: 10px 0; }
.md li { margin: 5px 0; color: #2a3348; }
.md li::marker { color: var(--blue); }
.md strong { color: var(--text); }
.md hr { border: none; border-top: 1px solid var(--border); margin: 30px 0; }
.md img { max-width: 100%; border-radius: var(--radius); border: 1px solid var(--border); }
.md blockquote {
margin: 14px 0; padding: 10px 16px; border-left: 3px solid var(--blue);
background: var(--blue-soft); border-radius: 0 8px 8px 0; color: #33415e;
}
/* 表格 */
.md table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13.5px; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: 0 0 0 1px var(--border); }
.md th, .md td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
.md th { background: #f9fbfe; font-weight: 600; color: var(--text); white-space: nowrap; }
.md tr:last-child td { border-bottom: none; }
/* 代码块 */
.md code { font-family: var(--mono); font-size: 12.8px; background: var(--code-bg); border: 1px solid var(--code-border); border-radius: 5px; padding: 1px 6px; color: #334155; }
.md pre {
position: relative; background: #0f172a; color: #dbe4f3; border-radius: var(--radius);
padding: 16px 16px 14px; overflow-x: auto; margin: 16px 0; font-size: 13px; line-height: 1.65;
}
.md pre code { background: none; border: none; color: inherit; padding: 0; font-size: inherit; }
.md pre .lang-tag {
position: absolute; top: 8px; right: 12px; font-size: 10.5px; color: #7c8db0;
font-family: var(--mono); text-transform: lowercase; letter-spacing: 0.04em;
}
.md pre .copy-btn {
position: absolute; top: 6px; right: 60px; font-size: 11px; color: #9fb0d0;
background: rgba(255, 255, 255, 0.07); border: none; border-radius: 6px; padding: 2px 9px; cursor: pointer;
opacity: 0; transition: opacity 0.15s;
}
.md pre:hover .copy-btn { opacity: 1; }
.md pre .copy-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.14); }
/* 行内高亮 token */
.md pre .tok-key { color: #7dd3fc; }
.md pre .tok-str { color: #86efac; }
.md pre .tok-num { color: #fcd34d; }
.md pre .tok-com { color: #64748b; font-style: italic; }
/* 提示块(通过 blockquote 首字符约定) */
.md blockquote.tip { border-left-color: var(--green); background: rgba(5, 150, 105, 0.07); }
.md blockquote.warn { border-left-color: var(--amber); background: rgba(180, 83, 9, 0.07); }
/* 上下页 */
.pn-nav { display: flex; gap: 14px; margin-top: 48px; }
.pn {
flex: 1; border: 1px solid var(--border); background: #fff; border-radius: var(--radius);
padding: 13px 16px; text-decoration: none !important; transition: border-color 0.15s, box-shadow 0.15s;
}
.pn:hover { border-color: var(--blue); box-shadow: var(--shadow); }
.pn.next { text-align: right; }
.pn-label { display: block; font-size: 11.5px; color: var(--text-3); }
.pn-title { display: block; font-weight: 600; color: var(--text); font-size: 14px; margin-top: 2px; }
/* 反馈 */
.feedback {
margin-top: 28px; padding: 16px 18px; border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; color: var(--text-2); font-size: 13px; flex-wrap: wrap;
}
.fb-btn {
border: 1px solid var(--border); background: #fff; border-radius: 8px; padding: 5px 12px;
font-size: 12.5px; cursor: pointer; color: var(--text-2); transition: all 0.15s;
}
.fb-btn:hover { border-color: var(--blue); color: var(--blue); }
.fb-btn.done { border-color: var(--green); color: var(--green); background: rgba(5, 150, 105, 0.06); }
.fb-thanks { color: var(--green); font-size: 12.5px; }
/* 右侧目录 */
.toc { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-top: 34px; }
.toc-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; color: var(--text-3); margin-bottom: 8px; }
.toc-nav { display: flex; flex-direction: column; gap: 1px; border-left: 2px solid var(--border); padding-left: 0; }
.toc-nav a {
font-size: 12.5px; color: var(--text-2); padding: 4px 0 4px 12px; text-decoration: none !important;
border-left: 2px solid transparent; margin-left: -2px; line-height: 1.5;
}
.toc-nav a:hover { color: var(--blue); }
.toc-nav a.toc-l3 { padding-left: 26px; font-size: 12px; color: var(--text-3); }
.toc-nav a.active { color: var(--blue); border-left-color: var(--blue); font-weight: 600; }
.print-btn {
margin-top: 18px; width: 100%; border: 1px solid var(--border); background: #fff;
border-radius: 8px; padding: 7px; font-size: 12.5px; color: var(--text-2); cursor: pointer;
}
.print-btn:hover { border-color: var(--blue); color: var(--blue); }
/* 404 */
.doc-404 { display: grid; place-items: center; min-height: 100vh; text-align: center; }
.doc-404 h1 { font-size: 56px; margin: 0; color: var(--blue); }
.doc-404 .btn {
display: inline-block; margin-top: 12px; background: var(--blue); color: #fff;
padding: 8px 18px; border-radius: 8px; text-decoration: none !important; font-weight: 600;
}
/* ---------- 响应式 ---------- */
@media (max-width: 1200px) {
.layout { grid-template-columns: var(--sidebar-w) minmax(0, 1fr); }
.toc { display: none; }
}
@media (max-width: 880px) {
.menu-btn { display: inline-grid; place-items: center; }
.layout { grid-template-columns: minmax(0, 1fr); padding-top: 12px; }
.sidebar {
position: fixed; z-index: 60; left: 0; top: var(--topbar-h); bottom: 0;
width: 280px; background: #fff; border-right: 1px solid var(--border);
padding: 16px; transform: translateX(-100%); transition: transform 0.2s; max-height: none;
}
.sidebar.open { transform: none; box-shadow: var(--shadow); }
.ver-badge { display: none; }
.top-links a { font-size: 12.5px; }
.page-title { font-size: 24px; }
}
/* ---------- 打印 ---------- */
@media print {
.topbar, .sidebar, .toc, .pn-nav, .feedback, .menu-btn { display: none !important; }
.layout { display: block; max-width: none; padding: 0; }
body { background: #fff; }
.md pre { background: #f1f5f9 !important; color: #1e293b !important; }
.md pre .copy-btn, .md pre .lang-tag { display: none; }
}
+219
View File
@@ -0,0 +1,219 @@
/* OpsxAPI 文档站交互:搜索 / 代码复制 / 目录高亮 / 反馈 / 移动端导航 */
(function () {
"use strict";
/* ---------- 代码块:语言标签 + 复制按钮 + 轻量高亮 ---------- */
document.querySelectorAll("article.md pre").forEach(function (pre) {
var code = pre.querySelector("code");
if (!code) return;
var lang = (code.className.match(/language-([\w-]+)/) || [, ""])[1];
if (lang) {
var tag = document.createElement("span");
tag.className = "lang-tag";
tag.textContent = lang;
pre.appendChild(tag);
}
// 复制按钮
var btn = document.createElement("button");
btn.className = "copy-btn";
btn.type = "button";
btn.textContent = "复制";
btn.addEventListener("click", function () {
navigator.clipboard.writeText(code.textContent).then(
function () {
btn.textContent = "已复制 ✓";
setTimeout(function () { btn.textContent = "复制"; }, 1400);
},
function () { btn.textContent = "复制失败"; }
);
});
pre.appendChild(btn);
// JSON / bash 轻量高亮
if (lang === "json") highlightJSON(code);
else if (lang === "bash" || lang === "sh" || lang === "shell") highlightBash(code);
});
function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function highlightJSON(code) {
var src = code.textContent;
code.innerHTML = esc(src).replace(
/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
function (m, str, colon, kw, num) {
if (str && colon) return '<span class="tok-key">' + str + "</span>" + colon;
if (str) return '<span class="tok-str">' + str + "</span>";
if (kw) return '<span class="tok-num">' + kw + "</span>";
if (num) return '<span class="tok-num">' + num + "</span>";
return m;
}
);
}
function highlightBash(code) {
var lines = code.textContent.split("\n");
code.innerHTML = lines
.map(function (line) {
if (/^\s*#/.test(line)) return '<span class="tok-com">' + esc(line) + "</span>";
return esc(line).replace(/(^|\s)(-{1,2}[\w-]+)/g, '$1<span class="tok-key">$2</span>');
})
.join("\n");
}
/* ---------- blockquote 提示样式约定:> **提示** / > **注意** ---------- */
document.querySelectorAll("article.md blockquote").forEach(function (bq) {
var t = (bq.textContent || "").trim();
if (/^(注意|警告|⚠)/.test(t)) bq.classList.add("warn");
else if (/^(提示|说明|💡|Tip)/i.test(t)) bq.classList.add("tip");
});
/* ---------- 搜索 ---------- */
var input = document.getElementById("search-input");
var panel = document.getElementById("search-panel");
var entries = null;
var selIndex = -1;
function ensureIndex(cb) {
if (entries) return cb();
fetch("/docs/search-data.json")
.then(function (r) { return r.json(); })
.then(function (data) { entries = data; cb(); })
.catch(function () { entries = []; cb(); });
}
function snippet(text, q) {
var i = text.toLowerCase().indexOf(q);
if (i < 0) return text.slice(0, 60) + "…";
var start = Math.max(0, i - 24);
return (start > 0 ? "…" : "") + text.slice(start, i + q.length + 40) + "…";
}
function doSearch() {
var q = input.value.trim().toLowerCase();
selIndex = -1;
if (!q) { panel.hidden = true; return; }
ensureIndex(function () {
var hits = [];
entries.forEach(function (e) {
var titleHit = e.title.toLowerCase().indexOf(q) >= 0;
var idx = e.text.toLowerCase().indexOf(q);
if (titleHit || idx >= 0) {
hits.push({
slug: e.slug,
title: e.title,
group: e.group,
score: (titleHit ? 10 : 0) + (idx === 0 ? 3 : 0),
snippet: titleHit ? snippet(e.text, q) : snippet(e.text, q),
});
}
});
hits.sort(function (a, b) { return b.score - a.score; });
hits = hits.slice(0, 8);
if (!hits.length) {
panel.innerHTML = '<div class="search-empty">没有找到相关内容,换个关键词试试</div>';
} else {
panel.innerHTML = hits
.map(function (h) {
return (
'<a class="search-item" href="/docs/' + h.slug + '">' +
'<span class="si-title">' + h.title + '</span><span class="si-group">' + h.group + "</span>" +
'<span class="si-snippet">' + h.snippet + "</span></a>"
);
})
.join("");
}
panel.hidden = false;
});
}
var debounce;
if (input) {
input.addEventListener("input", function () {
clearTimeout(debounce);
debounce = setTimeout(doSearch, 160);
});
input.addEventListener("focus", function () {
if (input.value.trim()) doSearch();
});
input.addEventListener("keydown", function (e) {
var items = panel.querySelectorAll(".search-item");
if (e.key === "ArrowDown" && items.length) {
e.preventDefault();
selIndex = Math.min(selIndex + 1, items.length - 1);
} else if (e.key === "ArrowUp" && items.length) {
e.preventDefault();
selIndex = Math.max(selIndex - 1, 0);
} else if (e.key === "Enter") {
if (selIndex >= 0 && items[selIndex]) { location.href = items[selIndex].href; return; }
var first = items[0];
if (first) location.href = first.href;
return;
} else if (e.key === "Escape") {
panel.hidden = true;
return;
} else {
return;
}
items.forEach(function (el, i) { el.classList.toggle("sel", i === selIndex); });
});
document.addEventListener("click", function (e) {
if (!e.target.closest(".search")) panel.hidden = true;
});
}
/* ---------- 目录滚动高亮 ---------- */
var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".toc-nav a"));
if (tocLinks.length && "IntersectionObserver" in window) {
var byId = {};
tocLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; });
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) {
tocLinks.forEach(function (a) { a.classList.remove("active"); });
var a = byId[en.target.id];
if (a) a.classList.add("active");
}
});
},
{ rootMargin: "-72px 0px -70% 0px", threshold: 0 }
);
document.querySelectorAll("article.md h2[id], article.md h3[id]").forEach(function (h) { io.observe(h); });
}
/* ---------- 反馈组件 ---------- */
var fb = document.getElementById("feedback");
if (fb) {
var pageKey = "opsx-docs-fb:" + location.pathname;
var voted = null;
try { voted = localStorage.getItem(pageKey); } catch (e) {}
var btns = fb.querySelectorAll(".fb-btn");
var thanks = document.getElementById("fb-thanks");
function mark(v) {
btns.forEach(function (b) {
if (b.dataset.v === v) b.classList.add("done");
});
thanks.hidden = false;
try { localStorage.setItem(pageKey, v); } catch (e) {}
}
if (voted) mark(voted);
btns.forEach(function (b) {
b.addEventListener("click", function () { mark(b.dataset.v); });
});
}
/* ---------- 打印 ---------- */
var printBtn = document.getElementById("print-btn");
if (printBtn) printBtn.addEventListener("click", function () { window.print(); });
/* ---------- 移动端侧栏 ---------- */
var menuBtn = document.getElementById("menu-btn");
var sidebar = document.getElementById("sidebar");
if (menuBtn && sidebar) {
menuBtn.addEventListener("click", function () { sidebar.classList.toggle("open"); });
document.addEventListener("click", function (e) {
if (sidebar.classList.contains("open") && !e.target.closest(".sidebar") && !e.target.closest(".menu-btn")) {
sidebar.classList.remove("open");
}
});
}
})();
+36
View File
@@ -0,0 +1,36 @@
OpsxAPI 是一款**本地优先**的专业接口工作台,面向运维与开发,把 HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT 六种协议的调试放进同一个窗口。
无需注册、无需联网同步,数据全部保存在本地浏览器(localStorage),导出备份随时带走。
## 功能总览
| 能力 | 说明 |
| --- | --- |
| 六种协议 | HTTP / GraphQL / WebSocket / SSE / Socket.IO / MQTT |
| 集合管理 | 目录树、搜索、右键菜单、拖拽移动、多标签页 |
| 多环境变量 | `{{baseUrl}}`、`{{token}}` 全局替换,一键切换环境 |
| 数据导入 | OpenAPI/Swagger、Postman、Apifox、Apipost、cURL、HAR、Insomnia、Hoppscotch、Markdown 等 |
| 数据导出 | OpenAPI 3.0、Postman v2.1、Markdown 文档、全量备份 |
| 代码生成 | cURL / JavaScript fetch / Python requests / Go net/http |
| 代理能力 | 本地 Gin 代理绕开 CORS;WebSocket 隧道支持自定义握手 Header |
| 双端形态 | 浏览器(Web)与桌面端(Tauri)共用同一套界面 |
## 支持的协议与场景
| 协议 | 典型场景 | 特色 |
| --- | --- | --- |
| HTTP / REST | 调试 REST 接口、运维平台 API | 7 种方法、6 种请求体、3 种鉴权 |
| GraphQL | 查询网关、中台接口 | Query + Variables 独立编辑 |
| WebSocket | 实时推送、行情、IM | 自定义握手 Header(经本地隧道) |
| SSE | AI 流式输出、监控事件流 | 直连失败自动切代理、断点续传 |
| Socket.IO | Node 实时应用 | 事件监听、emit 参数、path/auth 配置 |
| MQTT | IoT 设备、消息总线 | 通配符订阅、QoS、自动重连 |
## 为什么选 OpsxAPI
- **数据主权**:所有集合、环境、历史保存在本机,不经过任何第三方服务器。
- **开箱即用**:`./run.sh web` 一条命令启动;首次打开自带覆盖全部协议的示例集合。
- **工程友好**:导入自动建目录、路径参数自动转变量;导出即标准 OpenAPI,可回流 Apifox / Swagger 生态。
- **轻量克制**:一个 Go 二进制 + 一份前端,资源占用小,内网可部署。
> **提示**:想快速感受?直接看下一章《快速开始》,30 秒发出第一个请求。
@@ -0,0 +1,48 @@
## 环境要求
| 组件 | 版本 | 用途 |
| --- | --- | --- |
| Node.js | ≥ 20 | Web 前端(Vite) |
| Go | ≥ 1.23 | 本地代理服务(Gin) |
| Rust + Cargo | 可选 | 仅编译桌面端(Tauri)时需要 |
## 启动
```bash
git clone <你的仓库地址> opsxapi
cd opsxapi
make install # 安装依赖(npm install,含桌面端)
./run.sh web # 启动:Gin(:8787) + Web UI(:5173)
```
看到下面的输出即可打开浏览器:
```bash
[OK] 后端 UP http://127.0.0.1:8787/health
[OK] Web UP http://127.0.0.1:5173
```
| 命令 | 说明 |
| --- | --- |
| `./run.sh web` | 浏览器版(推荐) |
| `./run.sh gui` | 桌面窗口(Tauri,共用同一 UI) |
| `./run.sh all` | Web + 桌面一起开 |
| `./run.sh status` / `stop` | 查看状态 / 停止 |
## 30 秒发出第一个请求
1. 打开 <http://127.0.0.1:5173>,左侧是内置的「示例集合」。
2. 点击 **HTTP · httpbin → 获取请求回显**。
3. 点击右上角 **发送**。
4. 下方响应区显示 `200`、耗时与 JSON 高亮响应体 —— 完成!
## 桌面端(可选)
```bash
./run.sh gui
```
桌面端复用同一份界面与数据通道,HTTP 请求走 Tauri 原生网络栈,不依赖浏览器 CORS。
> **提示**:首次打开会自动创建两个环境(正式 / 本地)和一个覆盖全部协议的示例集合,可随时在「设置」中重置。
@@ -0,0 +1,41 @@
OpsxAPI 主界面分为五个区域:
## 顶栏
| 元素 | 作用 |
| --- | --- |
| + 新建 | 快捷创建各协议请求 / 目录(默认建在第一个集合) |
| 导入 / 导出 | 打开数据导入、导出对话框 |
| 环境选择器 | 切换当前环境(变量随之生效),「⚙ 环境」管理环境与变量 |
| 🌙 / ☀️ | 明暗主题切换 |
| ⋯ | 设置(主题、超时、重置工作区) |
## 左侧集合树
- 点击请求 → 在标签页中打开;点击目录 → 展开 / 折叠。
- **右键**任意节点:新建、重命名、复制、导出、删除。
- **拖拽**请求或目录到另一个目录,即可移动(支持跨集合)。
- 顶部搜索框按名称 / 方法 / URL 实时过滤。
- 底部为**历史记录**:显示最近请求的状态码与耗时,点击可回跳到对应接口。
## 标签页栏
同时打开多个接口,中键或 × 关闭;右键支持「关闭其他 / 关闭全部」。正在连接中的实时协议标签会显示呼吸圆点。
## 请求编辑区(上半部分)
按协议呈现不同编辑器:
| 协议 | 标签页 |
| --- | --- |
| HTTP | Params · Body · Headers · Cookies · Auth · 说明 |
| GraphQL | Query · Variables · Headers · Auth |
| WebSocket | 消息 · 握手配置 · 说明 |
| SSE | 事件流 · 连接配置 · 说明 |
| Socket.IO | 事件 · 连接配置 · 说明 |
| MQTT | 消息 · Broker / 订阅 · 发布 · 说明 |
## 响应区(下半部分)
- **HTTP / GraphQL**:响应体(Pretty JSON 高亮)、Raw、响应头,右上角状态 / 耗时 / 大小 / 复制。
- **实时协议**:分色消息日志(接收 · 绿、发送 · 蓝、系统 · 灰、错误 · 红),顶部实时统计收发条数与连接时长。
+47
View File
@@ -0,0 +1,47 @@
## 发送请求
1. 选择方法(GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS)。
2. 输入 URL,支持 `{{baseUrl}}/path` 形式的变量(见《环境与变量》)。
3. 点击 **发送**,或直接在 URL 输入框按 **Enter**。
请求默认经本地 Gin 代理(`/v1/proxy/http`)发出,天然绕开浏览器 CORS 限制。
## Params(查询参数)
键值对编辑器,每行可勾选启用 / 停用、填写说明。URL 中已有的查询串会被拆分到表格里,两边双向同步。
## Body(请求体)
| 模式 | 适用场景 |
| --- | --- |
| none | GET 等无体请求 |
| JSON | REST 接口(默认自动补 `Content-Type: application/json`,带格式化按钮) |
| XML / Text / HTML | 原始报文 |
| x-www-form-urlencoded | 表单提交 |
| form-data | multipart 文本字段(文件上传请用代码生成的 cURL) |
## Headers / Cookies
- Headers:键值对,支持 `{{变量}}`;`Authorization`、`Cookie` 由 Auth / Cookies 标签统一管理时避免重复。
- Cookies:键值对,发送时自动拼接为 `Cookie` 请求头。
## 代码生成
点击 URL 栏右侧 **`</>` 代码**,可生成:
- cURL(可直接在终端执行)
- JavaScript fetch
- Python requests
- Go net/http
生成内容保留 `{{变量}}` 原样,便于粘贴到脚本后再替换。
## 响应面板
| 标签 | 内容 |
| --- | --- |
| 响应体 | JSON 自动格式化 + 语法高亮;其他类型原样展示 |
| Raw | 未格式化的原始文本 |
| 响应头 | 全部响应头表格 |
右上角常驻 **状态码 / 耗时 / 大小 / Content-Type**,并提供「复制体」按钮。
+29
View File
@@ -0,0 +1,29 @@
在 HTTP / GraphQL / SSE 请求的 **Auth** 标签中选择鉴权方式:
## Bearer Token
```text
Authorization: Bearer <token>
```
Token 支持 `{{token}}` 变量引用 —— 推荐把 token 放进环境变量,一次配置全局生效。
## Basic Auth
填写用户名 / 密码,自动进行 Base64 编码后放入 `Authorization` 头。
## API Key
| 字段 | 说明 |
| --- | --- |
| Key 名 | 如 `X-API-Key` |
| 值 | 支持 `{{变量}}` |
| 位置 | Header(默认)或 Query 参数 |
## 组合规则
- 鉴权产生的 Header 会与 Headers 标签中的同名项合并(鉴权优先)。
- API Key 放 Query 时,会追加到 Params,不会重复出现在 Headers。
- 导入 OpenAPI / Postman 时,文档中声明的 `securitySchemes` 会自动映射到对应鉴权方式。
> **注意**:鉴权信息保存在本地集合数据中。如需分享集合,请先通过「导出」审查是否包含敏感 token。
+67
View File
@@ -0,0 +1,67 @@
## 前置 URL
环境可配置**前置 URL**(如 `https://api.example.com`)。配置后,请求里只需写相对路径:
```text
前置 URL:https://api.example.com
请求路径:/graphql
实际请求:https://api.example.com/graphql
```
- URL 输入框会以**前缀块 + 路径**的形式一体展示,前缀部分不可误编辑(点击可跳转环境管理);
- **切换环境即自动换成对应服务地址**,无需改请求;
- 路径以 `/` 开头(或为空)才启用拼接;写完整 `https://…` 地址的请求不受影响;
- 未配置前置 URL 的环境,请求仍可写完整 URL 或用 `{{变量名}}`。
> 历史数据自动迁移:旧的 `{{baseUrl}}/path` 会被自动转为「前置 URL + /path」,`baseUrl` 变量写入环境的前置 URL 字段。
## 变量语法
在 URL、Params、Headers、Cookies、Body、鉴权字段中,使用双花括号引用变量:
```text
{{baseUrl}}/v1/users?page={{page}}
Authorization: Bearer {{token}}
```
发送时统一替换为**当前启用环境**中同名变量的值;未定义的变量保留原样,便于发现遗漏。
## 切换环境
顶栏环境切换器显示**彩色单字徽标 + 环境名 + 变量数**,点击弹出环境列表(当前环境带「当前」标记),一眼防误发;底部可进入「管理环境」或「不使用环境」。
## 管理环境
顶栏 **环境** 按钮打开管理弹窗(左侧环境列表 + 右侧编辑区):
- **左栏**:环境列表(徽标 + 名称 + 当前标记),底部虚线按钮新建环境。
- **右栏**:提示条(变量引用语法 + 文档直达)、环境重命名、设为当前 / 取消使用、**复制环境**、删除;变量表支持**按名称 / 描述搜索**、逐行启用停用、内联编辑。
- 所有修改即时生效并自动保存。
## 输入 {{ 自动补全
在 URL 输入框或任意「值」输入框键入 `{{`,会弹出当前环境的变量浮层:
- ↑↓ 选择、Enter / Tab 插入完整的 `{{变量名}}`;Esc 关闭。
- 浮层同时显示变量值,选之前即可确认。
内置示例环境:
| 环境 | baseUrl | token |
| --- | --- | --- |
| 正式 | `https://httpbin.org` | demo-token |
| 本地 | `http://127.0.0.1:8787` | dev-token |
## 变量从哪来自动生成
| 来源 | 行为 |
| --- | --- |
| 导入 OpenAPI / Swagger | `servers` 地址写入 `baseUrl`;路径参数 `{id}` 转为 `{{id}}` 并写入示例值 |
| 导入 Postman | `collection.variable` 写入当前环境 |
| 手动输入 | 在环境面板随时增改 |
## 实际地址预览
URL 含 `{{ }}` 时,编辑器会在 URL 栏下方实时显示替换后的实际地址,切换环境立即更新。
> **提示**:路径参数用变量而不是写死,是接口可复用的关键 —— 复制接口后只改变量值即可调试另一个资源。
@@ -0,0 +1,32 @@
## 编辑查询
GraphQL 请求由三部分组成:
| 部分 | 说明 |
| --- | --- |
| Query | GraphQL 查询语句,支持变量定义 `$code: ID!` |
| Variables | JSON 对象,随查询一起发送;非法 JSON 会红框提示 |
| Headers / Auth | 与 HTTP 请求一致 |
## 发送
点击 **执行**(或 URL 栏按 Enter)。实际发送的是标准 GraphQL over HTTP:
```json
{
"query": "query ($code: ID!) { country(code: $code) { name capital currency } }",
"variables": { "code": "CN" }
}
```
响应面板会自动格式化 `data` / `errors`,字段名高亮。
## 内置示例
示例集合中的「国家查询 · countries」连接公共 GraphQL 服务 <https://countries.trevorblades.com/graphql>,无需鉴权,可直接体验。
## 最佳实践
- 把 endpoint 存为 `{{baseUrl}}/graphql`,环境切换即切换网关。
- 需要鉴权时在 Auth 标签配置 Bearer,不必手写 Header。
- 想看实际报文?点 `</>` 代码 生成 cURL,即为此查询的完整 HTTP 请求。
@@ -0,0 +1,34 @@
## 连接
1. 填写 `ws://` 或 `wss://` 地址。
2. 点击 **连接**;状态点变绿(已连接)后即可收发消息。
3. 再次点击按钮即断开。
## 消息收发
- **消息日志**:接收(绿)/ 发送(蓝)/ 系统(灰)/ 错误(红)分色展示,顶部统计 ↑↓ 条数与连接时长。
- **发送框**:支持 JSON / 纯文本;**⌘/Ctrl + Enter** 快速发送;「格式化 JSON」一键美化。
## 子协议
「握手配置」标签中的**子协议**填逗号分隔列表,例如:
```text
graphql-transport-ws, v1.json
```
## 自定义握手 Header(隧道模式)
浏览器原生 WebSocket **不能**携带自定义 Header(如 `Authorization`、`X-API-Key`)。OpsxAPI 的处理方式:
1. 「握手配置」中填写 Header 后,工具自动改走**本地 Gin 隧道**:
- 浏览器 → Gin `/v1/proxy/ws/attach`(普通 WS)
- Gin → 目标服务器(携带你填的全部 Header)
2. 不填 Header 时直连目标,零额外开销。
> **注意**:隧道要求本地 Gin 服务(:8787)在运行,即用 `./run.sh web` / `gui` 启动的默认形态。
## 调试技巧
- 消息过多时点「清空」;日志上限 500 条,超出自动滚动淘汰。
- 把常用报文保存在「说明」里,或为不同报文建多个 WS 请求节点。
+29
View File
@@ -0,0 +1,29 @@
## 连接事件流
1. 填写 SSE 地址(如 `https://stream.wikimedia.org/v2/stream/recentchange`)。
2. 点击 **连接**,事件按到达顺序流入消息日志。
3. 每条消息按 `event:` 名称归类显示;`data` 为 JSON 时自动格式化。
## 直连与代理自动切换
- 默认**直连**目标站(fetch 流式读取,延迟最低)。
- 直连因 CORS / 网络失败时,自动降级走**本地 Gin 流式代理**,日志中会提示切换过程,无需手工干预。
## 断点续传(Last-Event-ID)
「连接配置」中填写 Last-Event-ID,连接时以 `Last-Event-ID` 请求头携带,支持断点续传的流会在该事件之后继续推送。
## 连接配置项
| 配置 | 说明 |
| --- | --- |
| Query 参数 / Headers | 以键值对附加 |
| Last-Event-ID | 断点续传游标 |
| 仅显示 data 内容 | 开启后只显示 `data:` 载荷;关闭则连同 `event:` 名称一起显示 |
| 鉴权 | 与 HTTP 相同(Bearer / Basic / API Key) |
## 典型场景
- LLM 流式输出接口(`text/event-stream`)
- 监控 / 审计事件流
- 需要长期挂机观察的推送通道
@@ -0,0 +1,45 @@
## 连接
1. 填写服务地址(**不含** `/socket.io` 路径),如 `http://127.0.0.1:3000`。
2. 点击 **连接**,状态变为「已连接」后即可收发事件。
## 监听事件
「连接配置」→ **监听事件** 填逗号分隔的事件名:
```text
message, notification
```
留空则接收全部服务端事件(onAny 模式)。事件名在每条日志中标注。
## 发送(emit)
「事件」标签底部的发送区:
- **事件名**:如 `message`
- **参数**:JSON 数组,作为 `socket.emit(event, ...args)` 的参数
```json
["hello", 42]
```
## 连接选项
以 JSON 配置传给 socket.io-client:
```json
{
"path": "/socket.io",
"transports": ["websocket"],
"auth": { "token": "{{token}}" }
}
```
| 选项 | 说明 |
| --- | --- |
| path | 服务端挂载路径,默认 `/socket.io` |
| transports | `websocket` / `polling`,默认两者皆可 |
| auth | 握手鉴权载荷,支持 `{{变量}}` |
> **提示**:浏览器直连外部站点同样受 CORS 限制,Socket.IO 服务端需配置对应 origin,或把服务与本工具部署在同源下。
+41
View File
@@ -0,0 +1,41 @@
## 连接 Broker
浏览器环境要求 **WebSocket 形式**的 Broker 地址:
```text
wss://broker.emqx.io:8084/mqtt
```
| 配置 | 说明 |
| --- | --- |
| 用户名 / 密码 | 可选,支持 `{{变量}}` |
| ClientId | 默认随机生成;同一 Broker 上必须唯一 |
| QoS | 0 至多一次 · 1 至少一次 · 2 恰好一次 |
## 订阅
「Broker / 订阅」中填写逗号分隔的主题,支持通配符:
```text
opsxapi/demo, sensor/+, home/#
```
> **注意**:订阅在**建立连接时**生效,修改主题后需断开重连。
## 发布
「发布」标签配置主题与 Payload(JSON / 纯文本),点击发布按钮即送达;已发布消息以蓝色出现在日志中并标注主题。
## 公共测试 Broker
| Broker | 地址 |
| --- | --- |
| EMQX | `wss://broker.emqx.io:8084/mqtt` |
| HiveMQ | `wss://broker.hivemq.com:8884/mqtt` |
两个浏览器窗口连接同一主题,即可互相收发验证。
## 行为说明
- 断线自动重连(3 秒间隔),状态点显示「重连中」。
- 消息日志上限 500 条;QoS 与主题逐条标注。
+40
View File
@@ -0,0 +1,40 @@
顶栏 **导入** 打开导入对话框,支持三种来源:
| 来源 | 说明 |
| --- | --- |
| 粘贴文本 / cURL | 直接粘贴任意受支持格式的内容 |
| 文件导入 | 选择或拖入 `.json` / `.yaml` / `.har` / `.md` / `.txt` |
| URL 导入(Swagger) | 填写 OpenAPI 地址,经本地 Gin 代理拉取,避开 CORS |
格式**自动识别**,也可手动指定。
## 支持格式
| 格式 | 细节 |
| --- | --- |
| OpenAPI 3.0 / 3.1、Swagger 2.0 | JSON 或 YAML;Apifox 导出的 OpenAPI 同样适用 |
| Postman Collection | v2.0 / v2.1,含鉴权、变量、GraphQL body |
| Apipost 项目 | `api_collection` 格式(宽容解析) |
| cURL | 引号、续行、`-d` / `-F` / `-u` / `--data-urlencode`、cookie、代理等参数 |
| HAR | 按域名分组、相同请求去重 |
| Insomnia | 导出文件 v3 / v4,含 workspace / group 层级 |
| Hoppscotch | 集合 JSON(数组或单对象) |
| Markdown | 提取文中的 ```curl 代码块 |
| OpsxAPI 备份 | 全量集合 + 环境,用于迁移恢复 |
YApi、Apidoc、Apizza、Eolink 请先在原平台导出为 **OpenAPI 或 Postman** 格式,再导入本工具。
## 导入时自动完成
| 原始内容 | 转换结果 |
| --- | --- |
| OpenAPI `tags` | 目录分组 |
| 路径参数 `/pets/{petId}` | `{{petId}}` 变量 + 环境示例值 |
| `servers` 地址 | 当前环境 `baseUrl` 变量 |
| requestBody schema | JSON 示例请求体 |
| securitySchemes | Bearer / Basic / API Key 鉴权配置 |
| Postman `variable` | 当前环境变量 |
## 结果确认
导入完成显示汇总:新建集合、接口数量、写入的环境变量与告警(如跳过的文件上传字段)。集合名重复时自动追加序号。
+30
View File
@@ -0,0 +1,30 @@
顶栏 **导出**(多集合时可选择目标集合):
| 格式 | 用途 |
| --- | --- |
| OpenAPI 3.0 · JSON / YAML | 回流 Apifox、Swagger UI、代码生成器 |
| Postman v2.1 | 直接导入 Postman |
| Markdown 接口文档 | 带目录、参数表格、请求体代码块,可放 Wiki |
| 全量备份 | 全部集合 + 环境,用于迁移与灾备 |
每种格式均支持**复制到剪贴板**与**下载文件**;对话框内实时预览导出内容。
## 全量备份与恢复
- 备份文件是包含 `__opsxapi__: "backup"` 标记的 JSON。
- 恢复:导入对话框 → 粘贴备份内容或选择备份文件 → 格式自动识别为「OpsxAPI 备份」。
## 代码生成(单请求)
在 HTTP / GraphQL 编辑器点 **`</>` 代码**:
| 目标 | 说明 |
| --- | --- |
| cURL | 含方法、头、体、cookie,可直接执行 |
| JavaScript fetch | `async/await` 风格 |
| Python requests | 含 `import requests` 完整脚本 |
| Go net/http | 完整 `main` 函数 |
`{{变量}}` 保留原样输出,方便接入你自己的配置体系。
> **提示**:导出的 OpenAPI 中,目录名会成为 `tags`,请求说明写入 `description`,鉴权自动生成 `securitySchemes`。
+90
View File
@@ -0,0 +1,90 @@
# Mock 数据
联调时后端接口没就绪?OpsxAPI 内置 **Mock.js 风格的数据生成器**,两种用法覆盖绝大多数场景:
1. **显式表达式** —— 在参数值、Body 中直接写 `@cname`、`@integer(1,100)`;
2. **智能匹配** —— 开启后按**字段名**自动套用规则表(`username` → 姓名、`email` → 邮箱…),一行不用写。
## 开启与配置
顶栏 **⋯ → Mock 数据**(或「设置 → Mock 数据」):
- **启用 Mock 数据**:总开关,关闭后 `@` 表达式按普通文本原样发送;
- **智能 Mock 匹配**:JSON Body 字段值未写表达式时,按字段名匹配规则自动生成;
- **智能匹配规则表**:数据类型(string / number / integer / boolean)、匹配方式(通配符 / 完全匹配 / 正则)、匹配规则(如 `*name*`)、Mock 表达式、备注与启用开关,支持搜索、新增、删除与恢复默认。
规则**从上到下**匹配,先命中先用;类型需与 JSON 字段实际类型一致(KV 参数一律按 string 匹配)。
## 表达式速查
| 表达式 | 说明 | 示例输出 |
| --- | --- | --- |
| `@cname` | 中文姓名 | `王国强` |
| `@email` | 邮箱 | `kx8f2@qq.com` |
| `@phone` | 手机号 | `138****2733` 形式 11 位 |
| `@id` | 身份证号 | 18 位 |
| `@guid` | UUID / GUID | `3f2b…-…` |
| `@integer(1, 100)` | 整数 | `42` |
| `@float(1, 9999, 2)` | 小数(保留 2 位) | `128.50` |
| `@boolean` | 布尔 | `true` |
| `@string(8, 16)` | 随机字符串 | `aZ3k…` |
| `@datetime(YYYY-MM-DD HH:mm:ss)` | 日期时间 | `2026-09-17 14:30:00` |
| `@date(YYYY-MM-DD)` / `@time(HH:mm:ss)` | 日期 / 时间 | — |
| `@timestamp` | 毫秒时间戳 | `1760000000000` |
| `@url` / `@domain` / `@ip` | URL / 域名 / IPv4 | — |
| `@province` / `@city(true)` / `@county(true)` | 省市区 | `广东省深圳市南山区` |
| `@ctitle(4, 12)` / `@csentence(10, 24)` | 中文标题 / 句子 | — |
| `@image(200x200)` | 图片地址 | picsum 随机图 |
| `@pick("A", "B", "C")` | 从列表中选择 | `B` |
未列出的写法会**原样发送**,可放心与真实值混用。
## 在请求中使用
**Params / x-www-form-urlencoded / form-data**:值一栏直接填表达式。
```text
username = @cname
page = @integer(1, 10)
```
**JSON Body**:叶子值写表达式,或交给智能匹配:
```json
{
"username": "@cname",
"email": "@email",
"age": "@integer(18, 60)",
"vip": "@boolean"
}
```
Body 工具栏提供两个辅助按钮:
- **智能填充**:按规则把 JSON 各字段值批量替换为对应 `@` 表达式,生成可编辑的 Mock 模板;
- **Mock 预览**:即时查看发送时实际生成的数据,不必真的发请求。
## 生效范围
- 生效:Query 参数、JSON / XML / Text Body、x-www-form-urlencoded、form-data、GraphQL Variables;
- 不生效: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/{空间随机地址}{路径}`(每空间一个 16 位随机片段,不可枚举、不暴露归属),例如 `http://127.0.0.1:8787/mock/9f2c7a1b3d5e4f60/api/users`。
特点:
- **每次请求随机生成**:响应模板里的 `@cname`、`@integer(...)` 等表达式由服务端实时求值;
- 发布时自动把智能 Mock 规则烘焙进模板(开启智能匹配时);
- 非 GET 接口可用 `?_method=POST` 快速验证;延迟最高 5 秒,用于模拟慢接口 / loading 态。
### 频率限制(防滥用)
本地 Mock 服务按 **IP + 空间** 限流:**20 QPS(每秒 20 次)或 60 QPM(每分钟 60 次)**,超限返回 `429` 与 `Retry-After: 60`。依据[服务条款](/terms):未经授权或滥用行为平台有权采取限流、暂停服务等措施;**通过技术手段规避限流属于违规行为**。Mock 定位是联调辅助,请勿当作对外流量服务使用。
+39
View File
@@ -0,0 +1,39 @@
## 新建
| 入口 | 行为 |
| --- | --- |
| 顶栏「+ 新建」 | 快捷菜单,建在第一个集合 |
| 树右键「新建请求 / 目录」 | 精确指定位置 |
| 新建对话框 | 选择类型(六协议 + 目录)、命名、目标集合与目录 |
接口名自动避免重复(追加序号)。
## 集合树操作
| 操作 | 方式 |
| --- | --- |
| 重命名 | 右键 → 重命名 |
| 复制 | 右键 → 复制(深拷贝子树,名字加「副本」) |
| 移动 | 拖拽到目标目录(支持跨集合) |
| 删除 | 右键 → 删除(直接生效,请先备份重要数据) |
| 搜索 | 侧栏搜索框,匹配名称 / 方法 / URL |
## 标签页
- 点击树节点在标签页打开;同时打开数量不限。
- 中键 / × 关闭;右键「关闭其他 / 关闭全部」。
- 关闭实时协议(WS / SSE / MQTT…)标签时自动断开连接。
## 历史记录
- 每次 HTTP / GraphQL 发送记录:时间、方法、接口名、状态码,失败标红。
- 点击记录跳回对应接口;最多保留 100 条;可一键清空。
## 数据存储与备份
- 位置:浏览器 localStorage(键 `opsxapi.workspace.v2`),自动保存(防抖 400ms)。
- 内容:集合、环境、历史、设置。标签页会话与实时消息不落盘。
- 备份:导出 → 全量备份;恢复:导入备份文件。
- 重置:设置 → 重置工作区(恢复初始示例,请先备份)。
> **注意**:桌面端与浏览器是两份独立的 localStorage。跨端迁移请使用全量备份 / 导入。
@@ -0,0 +1,41 @@
# 自动化测试
把日常联调的接口串成**场景用例**,一条龙验证业务流程。三种触发方式共用同一套用例模型:
| 方式 | 说明 | 适合 |
| --- | --- | --- |
| 手动运行 | 用例编排好后点「运行」 | 日常自测、回归 |
| AI 生成 | 模型按接口清单自动编排步骤与断言 | 快速起盘、补断言思路 |
| 定时任务 | 应用运行期间按间隔自动执行 | 持续监控、冒烟 |
## 场景用例
从侧栏底部「自动化测试」进入。用例 = 有序步骤列表,每个步骤:
- **引用一个 HTTP 接口**(从集合中选择,可跨集合、跨目录);
- **断言**:状态码 / 耗时 / Header / Body 四类目标,支持 等于、不等于、大于、小于、包含、存在 与 **JSONPath** 取值比较;
- **变量提取**:`$.data.id` → 环境变量 `petId`,后续步骤 URL / Body 用 `{{petId}}` 引用,实现「创建 → 查询 → 删除」链式传值;
- 可单独禁用、上下移动、删除。
用例级选项「**失败后继续**」:默认失败即停(快速定位首个问题);打开后跑完全部步骤再看整体报告。
## 运行报告
- 统计卡:通过 / 失败步骤数、总耗时、断言数、通过率;
- 步骤明细:方法 + URL + 状态码 + 耗时,逐条断言的期望 / 实际对照(可按 通过 / 失败 过滤);
- 「再次运行」一键回归;「导出报告」生成 Markdown 存档或归档到缺陷单;
- 每个用例保留最近 50 次运行记录,可下拉切换历史报告。
## 定时任务
用例头部的 ⏱ 按钮或左侧「定时任务」入口:
- 间隔可选 5 / 15 / 30 / 60 分钟、6 小时、每天;
- 独立 启用 / 暂停 开关,显示下次运行时间与上次结果;
- **应用保持运行时生效**:OpsxAPI 窗口(或 Web 页面)开着才会按时触发并记录报告,关闭后自动暂停 —— 适合工作时段的持续监控,不能替代服务器侧的 CI。
> 提示:定时触发的请求同样走当前环境与 Mock 设置;跨环境回归前先在顶栏切换环境。
## AI 生成用例
见 [AI 助手](/docs/ai)。配置模型供应商后,「AI 生成」会读取集合的 HTTP 接口清单(方法、URL、Body 示例),按业务依赖编排步骤、断言与变量提取,生成前可附加一句要求(如「重点覆盖登录态与分页」),预览确认后保存。
+42
View File
@@ -0,0 +1,42 @@
# AI 助手
接入 **OpenAI 兼容协议**的大模型,为自动化测试提供 AI 能力。模型调用直连供应商;供应商配置(包括密钥)随当前工作区保存,团队工作区会同步工作区数据。
## 配置模型供应商
**设置 → 大模型**,选择预设并点击「+」,在右侧填写模型 ID、密钥和 Base URL,测试连接后点击「保存配置」。内置常用预设:
| 供应商 | Base URL | 模型示例 |
| --- | --- | --- |
| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` |
| 智谱 GLM | `https://open.bigmodel.cn/api/paas/v4` | `glm-4-flash` |
| Moonshot Kimi | `https://api.moonshot.cn/v1` | `moonshot-v1-8k` |
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` |
| Ollama 本地 | `http://127.0.0.1:11434/v1` | `qwen2.5:7b` |
- **API Key**:Ollama 等本地服务可留空;
- **测试连接**:发一条 ping 验证地址 / 密钥 / 模型名;离线时也可先保存,之后再测试;
- 多个供应商时可在左侧选择,保存后点「设为默认」指定自动化测试使用的模型。
## AI 生成场景用例
自动化测试视图 →「**AI 生成**」:
1. 选择集合(AI 读取其中全部 HTTP 接口:方法、URL、Body 示例);
2. 可选填写一句要求,如「覆盖下单 → 支付 → 查询流程,重点断言金额字段」;
3. 模型按业务依赖编排步骤,输出断言与变量提取(如 `$.data.token → token`,后续步骤用 `{{token}}`);
4. 预览生成的步骤清单(未识别的接口会自动跳过并提示),确认后保存为正式用例。
生成结果只是起点 —— 步骤、断言、提取规则都可再手动调整。
## 隐私与边界
- API Key 随当前工作区数据保存;团队工作区会同步,导出全量备份时也会包含。分享工作区或备份文件前请检查密钥;
- 供应商收到的内容为接口清单摘要(方法 / URL / Body 片段)与你的指令,**不含**环境变量值与 API Key;
- 未配置供应商时,Mock 数据、环境变量等离线功能不受影响。
## 常见问题
**生成失败?** 依次检查:Base URL 是否带 `/v1`、模型名是否属于该供应商、Key 是否有效(用「测试连接」验证)、本机能否直连该域名(内网代理环境注意放行)。
**想用公司内网模型?** 添加自定义供应商,Base URL 填内网网关地址即可(需兼容 OpenAI Chat Completions 协议)。
@@ -0,0 +1,75 @@
本地服务(Gin)默认监听 `:8787`,可用环境变量 `OPSXAPI_ADDR` 修改。所有端点仅监听本机回环,供 Web UI 与桌面端调用。
## 端点总览
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/health` | 健康检查 |
| POST | `/v1/proxy/http` | HTTP 代理(全方法、自定义头、超时) |
| POST | `/v1/proxy/http/stream` | NDJSON 流式代理(SSE / chunked 透传) |
| POST | `/v1/proxy/ws/connect` | 建立到目标 WS 的隧道会话 |
| GET | `/v1/proxy/ws/attach` | 浏览器 attach 到隧道会话(双向桥接) |
| POST | `/v1/proxy/ws/close` | 关闭隧道会话 |
| GET | `/docs` | 本文档站 |
## HTTP 代理
```bash
curl -s -X POST http://127.0.0.1:8787/v1/proxy/http \
-H 'Content-Type: application/json' \
-d '{"method":"GET","url":"https://httpbin.org/get","headers":{}}'
```
响应:
```json
{
"status": 200,
"headers": { "content-type": "application/json" },
"body": "{ ... }",
"timeMs": 320,
"size": 274
}
```
请求体 `body.mode` 支持 `json` / `xml` / `text` / `html` / `urlencoded` / `form-data`,`raw` 为原始报文;`timeoutMs` 可选。
## 流式代理
请求体与 HTTP 代理一致,响应为 NDJSON 事件流:
```text
{"type":"meta","status":200,"headers":{...}}
{"type":"chunk","data":"<base64>","size":1024}
{"type":"end","timeMs":1200,"size":8192}
```
## WebSocket 隧道
```bash
# 1. 建立会话(可带自定义 Header)
curl -s -X POST http://127.0.0.1:8787/v1/proxy/ws/connect \
-H 'Content-Type: application/json' \
-d '{"url":"wss://echo.example.com","headers":{"Authorization":"Bearer t"}}'
# → {"sessionId":"..."}
# 2. 浏览器 / 客户端 attach(普通 WebSocket)
# ws://127.0.0.1:8787/v1/proxy/ws/attach?sessionId=...
# 3. 关闭
curl -s -X POST http://127.0.0.1:8787/v1/proxy/ws/close \
-H 'Content-Type: application/json' -d '{"sessionId":"..."}'
```
attach 后第一条消息为控制帧 `{"type":"open"}`;此后浏览器与目标服务器双向透传。
## 安全边界
- 仅允许 `http/https`(代理)与 `ws/wss`(隧道)目标。
- 拒绝云元数据地址(169.254.169.254 等)防 SSRF。
- 单响应上限 10 MiB;隧道会话 10 分钟无活动自动回收。
## 部署建议
- 开发:`./run.sh web`(Vite :5173 反代 `/v1`、`/docs` 到 :8787)。
- 生产:`npm run build:web` 产物交给任意静态服务器,`opsxapi-server` 独立部署;生产环境建议置于内网并按需增加鉴权反向代理。
+45
View File
@@ -0,0 +1,45 @@
## 请求报 CORS 错误?
Web 端请求默认经本地 Gin 代理(gin-proxy)发出,不会遇到 CORS。若仍报错:
- 确认 Gin 在运行:`curl http://127.0.0.1:8787/health`。
- 用 `./run.sh web` / `gui` 场景启动(Vite 已配置 `/v1` 反代)。
- 桌面端优先走 Tauri 原生网络栈,同样不受 CORS 限制。
## 浏览器 WebSocket 不能加自定义 Header?
规范限制。在 WS「握手配置」里填写 Header 后,OpsxAPI 自动改走本地 Gin 隧道(见《WebSocket》章节),Header 由 Gin 在握手时代为携带。不填 Header 则直连。
## 外部 wss:// 地址连不上(DNS 失败)?
本机网络环境可能限制部分域名解析。可先用 `curl -v wss://…` 或换用公共 echo 服务验证;自建服务请确认防火墙与证书。
## MQTT 一定要 wss:// 吗?
浏览器只能通过 WebSocket 连 MQTT。常见 Broker 的 WS 端口:EMQX `8084/mqtt`(TLS)、`8083/mqtt`(明文,仅 http 页面)。`mqtt://` TCP 直连仅在桌面原生环境可行。
## 数据存在哪里?换电脑怎么办?
集合 / 环境 / 历史保存在浏览器 localStorage。迁移:**导出 → 全量备份** 生成 JSON,在新设备 **导入** 即可。
## 误删了接口 / 想回到初始状态?
设置(顶栏 ⋯)→ 重置工作区,恢复内置示例集合。日常请养成导出备份的习惯。
## SSE 事件流看着卡住了?
- 事件频率低(如告警流)属正常,等待即可。
- 部分网关会缓冲 SSE,工具会在直连失败时自动切换 Gin 流式代理;若仍无事件,检查中间层(Nginx 需关闭 `proxy_buffering`)。
## 发送按钮点击后没反应?
查看响应区是否有红色错误提示(如代理不可达)。`</>` 代码 生成 cURL 在终端执行可快速分辨是工具问题还是网络 / 接口问题。
## 支持文件上传吗?
form-data 当前支持文本字段;文件上传请用 `</>` 代码 生成的 cURL / 脚本执行。
## 更多问题?
- 服务端能力与边界:见《服务端 API 参考》
- 各协议用法:见左侧「请求调试」分组
@@ -0,0 +1,41 @@
# 账号与团队
登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`server/data/opsxapi.db`),不上传任何第三方。
## 注册 / 登录
最左**活动栏**底部 **登录** 按钮打开弹窗:
- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位);
- 登录态保存在本机,令牌失效会自动清除。
## 空间切换
活动栏**顶部空间徽标**(未登录时为产品 Logo,云端空间显示彩色徽标)点击弹出空间列表:
| 空间 | 说明 |
| --- | --- |
| 本地空间 | 未登录时默认;数据在浏览器 localStorage,不上云 |
| 我的空间 | 个人云端空间,注册即有;换浏览器 / 换设备登录即得同一份数据 |
| 团队 · ×× | 团队共享空间,成员读写同一份数据 |
- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**;
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),活动栏底部头像上的状态点显示 已同步 / 同步中(闪烁)/ 同步失败(红);
- 切回本地空间时恢复离开前的本地快照,两端互不覆盖;
- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。
## 团队管理
活动栏账号菜单或空间菜单 → **团队管理**:
- **新建团队**:创建后自动获得同名共享空间;
- **重命名 / 删除**:团队卡片右上角的 ✎ / 🗑(仅创建者可见)。重命名就地保存;删除需二次确认,将同时删除成员关系、共享空间与已发布 Mock 数据,不可恢复(若当前正停在该团队空间,会自动切回本地空间);
- **添加成员**:输入已注册成员邮箱(创建者可操作),邀请时可顺手点选**擅长**预设;成员登录后即可看到并切换到该团队空间;
- **成员与擅长**:每个团队卡片展示成员列表(头像 / 名称 / 角色 / 擅长标签)。擅长支持预设点选(前端、后端、全栈、测试、运维、移动端、数据库、AI、接口设计、安全)与自定义输入,空格或顿号分隔;创建者可修改任何成员的擅长,成员只能修改自己的(行尾「擅长」按钮)。
## 数据与安全
- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废;
- 云端空间单份数据上限 20MB;
- 团队空间权限:全体成员可读写,仅创建者可管理成员与修改他人擅长;
- 不登录不影响任何本地功能。
+155
View File
@@ -0,0 +1,155 @@
{
"productVersion": "1.0",
"groups": [
{
"name": "开始使用",
"pages": [
{
"file": "01-intro.md",
"slug": "intro",
"title": "产品介绍",
"updated": "2026-09-17"
},
{
"file": "02-quickstart.md",
"slug": "quickstart",
"title": "快速开始",
"updated": "2026-09-17"
},
{
"file": "03-interface.md",
"slug": "interface",
"title": "界面导览",
"updated": "2026-09-17"
}
]
},
{
"name": "请求调试",
"pages": [
{
"file": "04-http.md",
"slug": "http",
"title": "发送 HTTP 请求",
"updated": "2026-09-17"
},
{
"file": "05-auth.md",
"slug": "auth",
"title": "接口鉴权",
"updated": "2026-09-17"
},
{
"file": "06-env.md",
"slug": "env",
"title": "环境与变量",
"updated": "2026-09-17"
},
{
"file": "07-graphql.md",
"slug": "graphql",
"title": "GraphQL",
"updated": "2026-09-17"
},
{
"file": "08-websocket.md",
"slug": "websocket",
"title": "WebSocket",
"updated": "2026-09-17"
},
{
"file": "09-sse.md",
"slug": "sse",
"title": "SSE 事件流",
"updated": "2026-09-17"
},
{
"file": "10-socketio.md",
"slug": "socketio",
"title": "Socket.IO",
"updated": "2026-09-17"
},
{
"file": "11-mqtt.md",
"slug": "mqtt",
"title": "MQTT",
"updated": "2026-09-17"
}
]
},
{
"name": "数据管理",
"pages": [
{
"file": "12-import.md",
"slug": "import",
"title": "数据导入",
"updated": "2026-09-17"
},
{
"file": "13-export.md",
"slug": "export",
"title": "导出与代码生成",
"updated": "2026-09-17"
},
{
"file": "14-mock.md",
"slug": "mock",
"title": "Mock 数据",
"updated": "2026-09-17"
},
{
"file": "15-manage.md",
"slug": "manage",
"title": "集合与历史",
"updated": "2026-09-17"
}
]
},
{
"name": "自动化测试与 AI",
"pages": [
{
"file": "16-testing.md",
"slug": "testing",
"title": "自动化测试",
"updated": "2026-09-17"
},
{
"file": "17-ai.md",
"slug": "ai",
"title": "AI 助手",
"updated": "2026-09-17"
}
]
},
{
"name": "账号与团队",
"pages": [
{
"file": "20-account.md",
"slug": "account",
"title": "账号与团队",
"updated": "2026-09-17"
}
]
},
{
"name": "参考",
"pages": [
{
"file": "18-server-api.md",
"slug": "server-api",
"title": "服务端 API 参考",
"updated": "2026-09-17"
},
{
"file": "19-faq.md",
"slug": "faq",
"title": "常见问题",
"updated": "2026-09-17"
}
]
}
]
}
+264
View File
@@ -0,0 +1,264 @@
// Package docs 在 /docs 路径提供产品用户文档(Markdown 嵌入二进制,SSR 渲染)。
package docs
import (
"embed"
"encoding/json"
"fmt"
"net/http"
"path"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
gmhtml "github.com/yuin/goldmark/renderer/html"
)
//go:embed content assets template.html
var files embed.FS
type PageDef struct {
File string `json:"file"`
Slug string `json:"slug"`
Title string `json:"title"`
Updated string `json:"updated,omitempty"`
}
type GroupDef struct {
Name string `json:"name"`
Pages []PageDef `json:"pages"`
}
type Manifest struct {
ProductVersion string `json:"productVersion"`
Groups []GroupDef `json:"groups"`
}
// TocItem 本页目录项
type TocItem struct {
ID string `json:"id"`
Level int `json:"level"`
Text string `json:"text"`
}
type SearchEntry struct {
Slug string `json:"slug"`
Title string `json:"title"`
Group string `json:"group"`
Text string `json:"text"`
}
type NavLink struct {
Slug string
Title string
}
type Page struct {
Slug string
Title string
Group string
Updated string
HTML string
TOC []TocItem
Prev *NavLink
Next *NavLink
}
// Handler 文档服务
type Handler struct {
manifest Manifest
pages map[string]*Page
order []string
searchData []byte
tpl string
}
func New() *Handler {
h := &Handler{pages: map[string]*Page{}}
raw, err := files.ReadFile("content/manifest.json")
if err != nil {
panic("docs: manifest.json 缺失: " + err.Error())
}
if err := json.Unmarshal(raw, &h.manifest); err != nil {
panic("docs: manifest.json 解析失败: " + err.Error())
}
tplRaw, err := files.ReadFile("template.html")
if err != nil {
panic("docs: template.html 缺失: " + err.Error())
}
h.tpl = string(tplRaw)
md := goldmark.New(
goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList),
goldmark.WithRendererOptions(gmhtml.WithHardWraps()),
)
search := []SearchEntry{}
var prev *NavLink
for _, group := range h.manifest.Groups {
for _, def := range group.Pages {
src, err := files.ReadFile("content/" + def.File)
if err != nil {
panic("docs: 缺少内容文件 " + def.File)
}
var buf strings.Builder
if err := md.Convert(src, &buf); err != nil {
panic("docs: 渲染失败 " + def.File + ": " + err.Error())
}
body, toc := injectHeadingIDs(buf.String())
page := &Page{
Slug: def.Slug,
Title: def.Title,
Group: group.Name,
Updated: def.Updated,
HTML: body,
TOC: toc,
Prev: prev,
}
h.pages[def.Slug] = page
h.order = append(h.order, def.Slug)
prev = &NavLink{Slug: def.Slug, Title: def.Title}
search = append(search, SearchEntry{
Slug: def.Slug,
Title: def.Title,
Group: group.Name,
Text: plainText(string(src)),
})
}
}
// 下一页链接
for i, slug := range h.order {
if i+1 < len(h.order) {
next := h.pages[h.order[i+1]]
h.pages[slug].Next = &NavLink{Slug: next.Slug, Title: next.Title}
}
}
searchJSON, _ := json.Marshal(search)
h.searchData = searchJSON
return h
}
// Register 注册路由(单一 catch-all,避免静态/参数段冲突)
func (h *Handler) Register(r *gin.Engine) {
r.GET("/docs", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/docs/"+h.order[0])
})
r.GET("/docs/*path", h.dispatch)
}
func (h *Handler) dispatch(c *gin.Context) {
p := strings.Trim(c.Param("path"), "/")
switch {
case p == "":
c.Redirect(http.StatusFound, "/docs/"+h.order[0])
case p == "search-data.json":
h.SearchData(c)
case strings.HasPrefix(p, "assets/"):
c.Params = []gin.Param{{Key: "file", Value: "/" + strings.TrimPrefix(p, "assets")}}
h.Asset(c)
default:
// 取第一段作为 slug(忽略子路径)
slug := p
if i := strings.IndexByte(p, '/'); i >= 0 {
slug = p[:i]
}
c.Params = []gin.Param{{Key: "slug", Value: slug}}
h.Page(c)
}
}
// Page 渲染文档页
func (h *Handler) Page(c *gin.Context) {
slug := c.Param("slug")
page, ok := h.pages[slug]
if !ok {
c.Status(http.StatusNotFound)
_, _ = c.Writer.WriteString(notFoundHTML(h.order[0])) //nolint:errcheck
return
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-cache")
_, _ = c.Writer.WriteString(renderPage(h, page))
}
// SearchData 搜索索引
func (h *Handler) SearchData(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", h.searchData)
}
// Asset 静态资源
func (h *Handler) Asset(c *gin.Context) {
name := path.Clean(c.Param("file"))
if strings.Contains(name, "..") {
c.Status(http.StatusBadRequest)
return
}
data, err := files.ReadFile("assets" + name)
if err != nil {
c.Status(http.StatusNotFound)
return
}
switch {
case strings.HasSuffix(name, ".css"):
c.Header("Content-Type", "text/css; charset=utf-8")
case strings.HasSuffix(name, ".js"):
c.Header("Content-Type", "application/javascript; charset=utf-8")
case strings.HasSuffix(name, ".svg"):
c.Header("Content-Type", "image/svg+xml")
default:
c.Header("Content-Type", "application/octet-stream")
}
c.Header("Cache-Control", "public, max-age=3600")
c.Data(http.StatusOK, "", data)
}
// ManifestExport 供测试使用
func (h *Handler) ManifestExport() Manifest { return h.manifest }
func (h *Handler) FirstSlug() string { return h.order[0] }
var (
hRe = regexp.MustCompile(`(?s)<(h[23])>(.*?)</h[23]>`)
tagRe = regexp.MustCompile(`<[^>]+>`)
)
// injectHeadingIDs 给 h2/h3 加锚点 id,并产出目录
func injectHeadingIDs(html string) (string, []TocItem) {
var toc []TocItem
counter := 0
out := hRe.ReplaceAllStringFunc(html, func(m string) string {
sub := hRe.FindStringSubmatch(m)
tag, inner := sub[1], sub[2]
counter++
id := fmt.Sprintf("sec-%d", counter)
text := strings.TrimSpace(tagRe.ReplaceAllString(inner, ""))
level := 2
if tag == "h3" {
level = 3
}
toc = append(toc, TocItem{ID: id, Level: level, Text: text})
return fmt.Sprintf(`<%s id="%s">%s</%s>`, tag, id, inner, tag)
})
return out, toc
}
var mdNoise = regexp.MustCompile("(?s)```.*?```|`[^`]*`|!\\[[^\\]]*\\]\\([^)]*\\)|\\[[^\\]]*\\]\\([^)]*\\)|[#>*_\\-|]+")
// plainText 供搜索索引的纯文本
func plainText(src string) string {
s := mdNoise.ReplaceAllString(src, " ")
s = strings.Join(strings.Fields(s), " ")
if len(s) > 6000 {
s = s[:6000]
}
return s
}
func notFoundHTML(home string) string {
return fmt.Sprintf(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>404 · OpsxAPI 文档</title>
<link rel="stylesheet" href="/docs/assets/docs.css"></head><body class="doc-404">
<div class="wrap"><h1>404</h1><p>文档页面不存在或已被移动。</p><a class="btn" href="%s">返回文档首页</a></div>
</body></html>`, home)
}
+122
View File
@@ -0,0 +1,122 @@
package docs
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func newTestRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
New().Register(r)
return r
}
func TestDocsIndexRedirect(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil))
if w.Code != http.StatusFound {
t.Fatalf("expect 302, got %d", w.Code)
}
if !strings.HasPrefix(w.Header().Get("Location"), "/docs/") {
t.Fatalf("bad redirect: %s", w.Header().Get("Location"))
}
}
func TestDocsAllPagesRender(t *testing.T) {
h := New()
r := newTestRouter(t)
for _, slug := range h.order {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/docs/"+slug, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("page %s: expect 200, got %d", slug, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "<title>") || !strings.Contains(body, `class="md"`) {
t.Fatalf("page %s: html structure incomplete", slug)
}
// 正文有渲染出的标题与段落
if !strings.Contains(body, "<h1") {
t.Fatalf("page %s: missing h1", slug)
}
// 侧栏包含全部分组
for _, g := range h.manifest.Groups {
if !strings.Contains(body, g.Name) {
t.Fatalf("page %s: sidebar missing group %s", slug, g.Name)
}
}
}
}
func TestDocsPageContainsTOCAndNav(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/http", nil))
body := w.Body.String()
if !strings.Contains(body, "sec-1") {
t.Fatal("expect heading anchors")
}
if !strings.Contains(body, "上一页") {
t.Fatal("expect prev link")
}
}
func TestDocsSearchData(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/search-data.json", nil))
if w.Code != http.StatusOK {
t.Fatalf("expect 200, got %d", w.Code)
}
var entries []SearchEntry
if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil {
t.Fatalf("invalid json: %v", err)
}
if len(entries) < 10 {
t.Fatalf("expect >= 10 search entries, got %d", len(entries))
}
// MQTT 页应可被检索(标题或正文命中)
found := false
for _, e := range entries {
if e.Slug == "mqtt" && (e.Title == "MQTT" || strings.Contains(e.Text, "MQTT")) {
found = true
}
}
if !found {
t.Fatal("mqtt entry missing searchable text")
}
}
func TestDocsAssetsAnd404(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/assets/docs.css", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "text/css") {
t.Fatalf("css asset: %d %s", w.Code, w.Header().Get("Content-Type"))
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/assets/docs.js", nil))
if w.Code != http.StatusOK {
t.Fatalf("js asset: %d", w.Code)
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/not-exist-page", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("404 page: %d", w.Code)
}
if !strings.Contains(w.Body.String(), "返回文档首页") {
t.Fatal("404 page missing home link")
}
}
+25
View File
@@ -0,0 +1,25 @@
package docs
import (
"html/template"
"strings"
)
func renderPage(h *Handler, p *Page) string {
tpl, err := template.New("doc").Parse(h.tpl)
if err != nil {
return "<!doctype html><meta charset=utf-8><title>error</title>template error: " + template.HTMLEscapeString(err.Error())
}
var sb strings.Builder
data := map[string]any{
"Manifest": h.manifest,
"Page": p,
"TOC": p.TOC,
"Content": template.HTML(p.HTML), //nolint:gosec —— 内容为内置受信 Markdown
}
if err := tpl.Execute(&sb, data); err != nil {
return "<!doctype html><meta charset=utf-8><title>error</title>render error: " + template.HTMLEscapeString(err.Error())
}
return sb.String()
}
+110
View File
@@ -0,0 +1,110 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Page.Title}} · OpsxAPI 文档</title>
<meta name="description" content="OpsxAPI 接口工作台用户文档 — HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT 一站式调试,支持 OpenAPI/Postman/cURL 等格式导入导出。">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232563eb'/%3E%3Ctext x='16' y='22' font-size='18' font-weight='bold' fill='%23fff' text-anchor='middle' font-family='sans-serif'%3EO%3C/text%3E%3C/svg%3E">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%2264%22%20y2%3D%2264%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%232563EB%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2306B6D4%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22url%28%23g%29%22%2F%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M17%2025h26%22%2F%3E%3Cpath%20d%3D%22M37%2019l6%206-6%206%22%2F%3E%3Cpath%20d%3D%22M47%2039H21%22%2F%3E%3Cpath%20d%3D%22M27%2033l-6%206%206%206%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<link rel="stylesheet" href="/docs/assets/docs.css">
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 5h16" /> <path d="M4 12h16" /> <path d="M4 19h16" /></symbol>
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" /> <path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" /> <rect x="6" y="14" width="12" height="8" rx="1" /></symbol>
<symbol id="i-thumbs-up" viewBox="0 0 24 24"><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" /> <path d="M7 10v12" /></symbol>
<symbol id="i-thumbs-down" viewBox="0 0 24 24"><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" /> <path d="M17 14V2" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24"><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /> <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09" /> <path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05" /></symbol>
<symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /> <path d="m9 12 2 2 4-4" /></symbol>
<symbol id="i-arrow-left-right" viewBox="0 0 24 24"><path d="M8 3 4 7l4 4" /> <path d="M4 7h16" /> <path d="m16 21 4-4-4-4" /> <path d="M20 17H4" /></symbol>
<symbol id="i-plug-zap" viewBox="0 0 24 24"><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z" /> <path d="m2 22 3-3" /> <path d="M7.5 13.5 10 11" /> <path d="M10.5 16.5 13 14" /> <path d="m18 3-4 4h6l-4 4" /></symbol>
<symbol id="i-package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /> <path d="M12 22V12" /> <polyline points="3.29 7 12 12 20.71 7" /> <path d="m7.5 4.27 9 5.15" /></symbol>
<symbol id="i-folder-tree" viewBox="0 0 24 24"><path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" /> <path d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" /> <path d="M3 5a2 2 0 0 0 2 2h3" /> <path d="M3 3v13a2 2 0 0 0 2 2h3" /></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /> <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /> <path d="M2 12h20" /></symbol>
<symbol id="i-hexagon" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z" /></symbol>
<symbol id="i-radio" viewBox="0 0 24 24"><path d="M16.247 7.761a6 6 0 0 1 0 8.478" /> <path d="M19.075 4.933a10 10 0 0 1 0 14.134" /> <path d="M4.925 19.067a10 10 0 0 1 0-14.134" /> <path d="M7.753 16.239a6 6 0 0 1 0-8.478" /> <circle cx="12" cy="12" r="2" /></symbol>
<symbol id="i-mailbox" viewBox="0 0 24 24"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z" /> <polyline points="15,9 18,9 18,11" /> <path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2" /> <line x1="6" x2="7" y1="10" y2="10" /></symbol>
<symbol id="i-code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6" /> <path d="m8 6-6 6 6 6" /></symbol>
<symbol id="i-book-open" viewBox="0 0 24 24"><path d="M12 5v16" /> <path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" /></symbol>
<symbol id="i-sprout" viewBox="0 0 24 24"><path d="M14 9.536V7a4 4 0 0 1 4-4h1.5a.5.5 0 0 1 .5.5V5a4 4 0 0 1-4 4 4 4 0 0 0-4 4c0 2 1 3 1 5a5 5 0 0 1-1 3" /> <path d="M4 9a5 5 0 0 1 8 4 5 5 0 0 1-8-4" /> <path d="M5 21h14" /></symbol>
<symbol id="i-graduation-cap" viewBox="0 0 24 24"><path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" /> <path d="M22 10v6" /> <path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" /></symbol>
<symbol id="i-credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2" /> <line x1="2" x2="22" y1="10" y2="10" /> <path d="M6 14h2" /></symbol>
<symbol id="i-megaphone" viewBox="0 0 24 24"><path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" /> <path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14" /> <path d="M8 6v8" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-library" viewBox="0 0 24 24"><path d="m16 6 4 14" /> <path d="M12 6v14" /> <path d="M8 8v12" /> <path d="M4 4v16" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
</svg>
<header class="topbar">
<div class="topbar-inner">
<button class="menu-btn" id="menu-btn" aria-label="打开导航"><svg class="i"><use href="#i-menu"/></svg></button>
<a class="brand" href="/docs">
<span class="brand-logo"><svg width="28" height="28" viewBox="0 0 64 64" aria-label="OpsxAPI"><defs><linearGradient id="lg-docs" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2563EB"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-docs)"/><g stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"><path d="M17 25h26"/><path d="M37 19l6 6-6 6"/><path d="M47 39H21"/><path d="M27 33l-6 6 6 6"/></g></svg></span>
<span class="brand-name">OpsxAPI <em>文档</em></span>
</a>
<span class="ver-badge">v{{.Manifest.ProductVersion}}</span>
<div class="search">
<input id="search-input" type="search" placeholder="搜索文档…" autocomplete="off" spellcheck="false">
<div class="search-panel" id="search-panel" hidden></div>
</div>
<nav class="top-links">
<a href="/home">官网</a>
<a href="/" target="_blank" rel="noopener">打开工作台 ↗</a>
</nav>
</div>
</header>
<div class="layout" id="layout">
<aside class="sidebar" id="sidebar">
{{range .Manifest.Groups}}
<div class="nav-group">
<div class="nav-group-title">{{.Name}}</div>
{{range .Pages}}
<a class="nav-link{{if eq .Slug $.Page.Slug}} active{{end}}" href="/docs/{{.Slug}}">{{.Title}}</a>
{{end}}
</div>
{{end}}
<div class="sidebar-foot">© 2026 OpsxAPI · 本地优先的接口工作台</div>
</aside>
<main class="content">
<div class="breadcrumb">文档 <span>/</span> {{.Page.Group}} <span>/</span> {{.Page.Title}}</div>
<h1 class="page-title">{{.Page.Title}}</h1>
{{if .Page.Updated}}<div class="page-meta">更新于 {{.Page.Updated}}</div>{{end}}
<article class="md">
{{.Content}}
</article>
<div class="pn-nav">
{{with .Page.Prev}}
<a class="pn prev" href="/docs/{{.Slug}}"><span class="pn-label">← 上一页</span><span class="pn-title">{{.Title}}</span></a>
{{else}}<span class="pn"></span>{{end}}
{{with .Page.Next}}
<a class="pn next" href="/docs/{{.Slug}}"><span class="pn-label">下一页 →</span><span class="pn-title">{{.Title}}</span></a>
{{else}}<span class="pn"></span>{{end}}
</div>
<div class="feedback" id="feedback">
<span class="feedback-q">本页内容是否对您有帮助?</span>
<button class="fb-btn" data-v="yes"><svg class="i"><use href="#i-thumbs-up"/></svg> 有帮助</button>
<button class="fb-btn" data-v="no"><svg class="i"><use href="#i-thumbs-down"/></svg> 没有帮助</button>
<span class="fb-thanks" id="fb-thanks" hidden>感谢您的反馈!</span>
</div>
</main>
<aside class="toc">
<div class="toc-title">本页目录</div>
<nav class="toc-nav">
{{range .TOC}}<a class="toc-l{{.Level}}" href="#{{.ID}}">{{.Text}}</a>
{{end}}
</nav>
<button class="print-btn" id="print-btn"><svg class="i"><use href="#i-printer"/></svg> 打印 / 导出 PDF</button>
</aside>
</div>
<script src="/docs/assets/docs.js" defer></script>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
// Package echo 提供本地 HTTP 回显,供自动化回归套件使用(不依赖外网 httpbin)。
package echo
import (
"encoding/base64"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// Mount registers /v1/echo/* routes.
func Mount(r *gin.Engine) {
e := r.Group("/v1/echo")
e.GET("/get", getEcho)
e.POST("/json", jsonEcho)
e.POST("/form", formEcho)
e.POST("/multipart", multipartEcho)
e.GET("/headers", headersEcho)
e.GET("/cookies", cookiesEcho)
e.GET("/bearer", bearerEcho)
e.GET("/basic", basicEcho)
}
func baseMeta(c *gin.Context) gin.H {
hdrs := gin.H{}
for k, v := range c.Request.Header {
if len(v) == 1 {
hdrs[k] = v[0]
} else {
hdrs[k] = v
}
}
q := gin.H{}
for k, v := range c.Request.URL.Query() {
if len(v) == 1 {
q[k] = v[0]
} else {
q[k] = v
}
}
return gin.H{
"ok": true,
"service": "opsxapi-echo",
"method": c.Request.Method,
"path": c.Request.URL.Path,
"query": q,
"headers": hdrs,
}
}
func getEcho(c *gin.Context) {
c.JSON(http.StatusOK, baseMeta(c))
}
func jsonEcho(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
out := baseMeta(c)
out["bodyRaw"] = string(body)
c.JSON(http.StatusOK, out)
}
func formEcho(c *gin.Context) {
_ = c.Request.ParseForm()
form := gin.H{}
for k, v := range c.Request.PostForm {
if len(v) == 1 {
form[k] = v[0]
} else {
form[k] = v
}
}
out := baseMeta(c)
out["form"] = form
c.JSON(http.StatusOK, out)
}
func multipartEcho(c *gin.Context) {
_ = c.Request.ParseMultipartForm(8 << 20)
form := gin.H{}
if c.Request.MultipartForm != nil {
for k, v := range c.Request.MultipartForm.Value {
if len(v) == 1 {
form[k] = v[0]
} else {
form[k] = v
}
}
}
out := baseMeta(c)
out["form"] = form
c.JSON(http.StatusOK, out)
}
func headersEcho(c *gin.Context) {
out := baseMeta(c)
out["xOpsxProbe"] = c.GetHeader("X-Opsx-Probe")
c.JSON(http.StatusOK, out)
}
func cookiesEcho(c *gin.Context) {
out := baseMeta(c)
cookies := gin.H{}
for _, ck := range c.Request.Cookies() {
cookies[ck.Name] = ck.Value
}
// 浏览器禁止脚本设置 Cookie 头;回归用例改走 X-Opsx-Cookie
if raw := c.GetHeader("X-Opsx-Cookie"); raw != "" {
for _, part := range strings.Split(raw, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.SplitN(part, "=", 2)
if len(kv) == 2 {
cookies[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
}
out["cookies"] = cookies
c.JSON(http.StatusOK, out)
}
func bearerEcho(c *gin.Context) {
auth := c.GetHeader("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "missing bearer"})
return
}
out := baseMeta(c)
out["authenticated"] = true
out["token"] = strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
c.JSON(http.StatusOK, out)
}
func basicEcho(c *gin.Context) {
auth := c.GetHeader("Authorization")
if !strings.HasPrefix(auth, "Basic ") {
c.Header("WWW-Authenticate", `Basic realm="opsxapi"`)
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "missing basic"})
return
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.TrimPrefix(auth, "Basic ")))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "bad basic"})
return
}
parts := strings.SplitN(string(raw), ":", 2)
out := baseMeta(c)
out["authenticated"] = true
out["user"] = parts[0]
c.JSON(http.StatusOK, out)
}
+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
}
+36
View File
@@ -0,0 +1,36 @@
package proxy
import (
"fmt"
"net"
"net/url"
"strings"
)
var blockedHosts = map[string]struct{}{
"169.254.169.254": {},
"metadata.google.internal": {},
}
// ValidateURL applies MVP SSRF basics. Week 2 should add configurable CIDR
// deny-lists and DNS-rebinding protection before exposing this beyond localhost.
func ValidateURL(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("only http/https schemes are allowed")
}
if u.Hostname() == "" {
return nil, fmt.Errorf("url host is required")
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if _, blocked := blockedHosts[host]; blocked {
return nil, fmt.Errorf("blocked metadata host")
}
if ip := net.ParseIP(host); ip != nil && ip.Equal(net.ParseIP("169.254.169.254")) {
return nil, fmt.Errorf("blocked metadata address")
}
return u, nil
}
+23
View File
@@ -0,0 +1,23 @@
package proxy
import "testing"
func TestValidateURL(t *testing.T) {
for _, tc := range []struct {
raw string
ok bool
}{
{"https://example.com/a", true},
{"file:///etc/passwd", false},
{"http://169.254.169.254/latest/meta-data", false},
{"http://metadata.google.internal/computeMetadata/v1", false},
} {
_, err := ValidateURL(tc.raw)
if tc.ok && err != nil {
t.Fatalf("%s: %v", tc.raw, err)
}
if !tc.ok && err == nil {
t.Fatalf("expected rejection: %s", tc.raw)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
package proxy
import (
"bytes"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const maxResponseBytes = 10 << 20 // 10 MiB MVP safety cap
type Handler struct {
BaseClient *http.Client
}
func NewHandler() *Handler {
return &Handler{BaseClient: &http.Client{Timeout: 30 * time.Second}}
}
func normalizeMethod(raw string) string {
m := strings.ToUpper(strings.TrimSpace(raw))
switch m {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodDelete, http.MethodHead, http.MethodOptions:
return m
default:
return http.MethodGet
}
}
func (h *Handler) ProxyHTTP(c *gin.Context) {
var spec HttpSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()})
return
}
method := normalizeMethod(spec.Method)
u, err := ValidateURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := u.Query()
for key, value := range spec.Query {
query.Set(key, value)
}
u.RawQuery = query.Encode()
var body io.Reader
if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet && method != http.MethodHead {
body = bytes.NewBufferString(spec.Body.Raw)
}
req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()})
return
}
for key, value := range spec.Headers {
// Hop-by-hop headers are managed by net/http.
if !isHopByHop(key) {
req.Header.Set(key, value)
}
}
if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := h.BaseClient
if spec.TimeoutMS > 0 {
client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond}
}
started := time.Now()
res, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()})
return
}
defer res.Body.Close()
payload, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes+1))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "cannot read upstream response", "detail": err.Error()})
return
}
if len(payload) > maxResponseBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream response exceeds 10 MiB limit"})
return
}
headers := make(map[string]string, len(res.Header))
for key, values := range res.Header {
headers[strings.ToLower(key)] = strings.Join(values, ", ")
}
c.JSON(http.StatusOK, HttpResult{
Status: res.StatusCode,
Headers: headers,
Body: string(payload),
TimeMS: time.Since(started).Milliseconds(),
Size: len(payload),
})
}
func isHopByHop(name string) bool {
switch strings.ToLower(name) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length":
return true
default:
return false
}
}
+145
View File
@@ -0,0 +1,145 @@
package proxy
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// ProxyHTTPStream 以 NDJSON 流式转发响应(SSE / chunked / 大响应体)。
// 事件序列:meta → chunk* → end | error
type streamEvent struct {
Type string `json:"type"`
Status int `json:"status,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Data string `json:"data,omitempty"`
Message string `json:"message,omitempty"`
TimeMS int64 `json:"timeMs,omitempty"`
Size int `json:"size,omitempty"`
}
func (h *Handler) ProxyHTTPStream(c *gin.Context) {
var spec HttpSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()})
return
}
method := normalizeMethod(spec.Method)
u, err := ValidateURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := u.Query()
for key, value := range spec.Query {
query.Set(key, value)
}
u.RawQuery = query.Encode()
var body io.Reader
if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet && method != http.MethodHead {
body = bytes.NewBufferString(spec.Body.Raw)
}
req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()})
return
}
for key, value := range spec.Headers {
if !isHopByHop(key) {
req.Header.Set(key, value)
}
}
if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := h.BaseClient
if spec.TimeoutMS > 0 {
client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond}
} else {
// 流式场景默认不设整体超时(SSE 长连接),交给客户端断开
client = &http.Client{}
}
started := time.Now()
res, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()})
return
}
defer res.Body.Close()
headers := make(map[string]string, len(res.Header))
for key, values := range res.Header {
headers[key] = joinNonEmpty(values)
}
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
return
}
c.Header("Content-Type", "application/x-ndjson")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
enc := json.NewEncoder(c.Writer)
writeEvent := func(ev streamEvent) bool {
if err := enc.Encode(ev); err != nil {
return false
}
flusher.Flush()
return true
}
headers["content-type"] = res.Header.Get("Content-Type")
if !writeEvent(streamEvent{Type: "meta", Status: res.StatusCode, Headers: headers}) {
return
}
reader := bufio.NewReaderSize(res.Body, 32*1024)
buf := make([]byte, 16*1024)
total := 0
for {
n, readErr := reader.Read(buf)
if n > 0 {
total += n
ok := writeEvent(streamEvent{
Type: "chunk",
Data: base64.StdEncoding.EncodeToString(buf[:n]),
Size: n,
})
if !ok {
return
}
}
if readErr != nil {
break
}
if c.Request.Context().Err() != nil {
return
}
}
writeEvent(streamEvent{Type: "end", TimeMS: time.Since(started).Milliseconds(), Size: total})
}
func joinNonEmpty(values []string) string {
out := ""
for i, v := range values {
if i > 0 {
out += ", "
}
out += v
}
return out
}
+23
View File
@@ -0,0 +1,23 @@
package proxy
type BodySpec struct {
Mode string `json:"mode"`
Raw string `json:"raw,omitempty"`
}
type HttpSpec struct {
Method string `json:"method" binding:"required"`
URL string `json:"url" binding:"required"`
Headers map[string]string `json:"headers"`
Query map[string]string `json:"query,omitempty"`
Body *BodySpec `json:"body,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
}
type HttpResult struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
TimeMS int64 `json:"timeMs"`
Size int `json:"size"`
}
+236
View File
@@ -0,0 +1,236 @@
package proxy
import (
"crypto/rand"
"encoding/hex"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// WSTunnel 让浏览器借道 Gin 连接带自定义 Header 的 WebSocket:
//
// POST /v1/proxy/ws/connect {url, headers, protocols} → {sessionId}
// GET /v1/proxy/ws/attach?sessionId=… (浏览器 WebSocket 双向桥)
// POST /v1/proxy/ws/close {sessionId}
type wsTunnelSpec struct {
URL string `json:"url" binding:"required"`
Headers map[string]string `json:"headers,omitempty"`
Protocols []string `json:"protocols,omitempty"`
}
type wsTunnelSession struct {
id string
target *websocket.Conn
attach chan *websocket.Conn
createdAt time.Time
}
type WSTunnel struct {
mu sync.Mutex
sessions map[string]*wsTunnelSession
upgrader websocket.Upgrader
}
func NewWSTunnel() *WSTunnel {
t := &WSTunnel{
sessions: map[string]*wsTunnelSession{},
upgrader: websocket.Upgrader{
ReadBufferSize: 32 * 1024,
WriteBufferSize: 32 * 1024,
CheckOrigin: func(r *http.Request) bool {
// 仅本地 UI 使用;Origin 校验交给 CORS 中间件
return true
},
},
}
go t.gc()
return t
}
func (t *WSTunnel) gc() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
t.mu.Lock()
for id, s := range t.sessions {
// 未 attach 或空闲超过 10 分钟的会话回收
if time.Since(s.createdAt) > 10*time.Minute {
_ = s.target.Close()
delete(t.sessions, id)
}
}
t.mu.Unlock()
}
}
// ValidateWSURL 校验 ws/wss 目标(http/https 自动换算)
func ValidateWSURL(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
switch u.Scheme {
case "ws", "http":
u.Scheme = "ws"
case "wss", "https":
u.Scheme = "wss"
default:
return nil, errOnlyWS
}
if u.Hostname() == "" {
return nil, errEmptyHost
}
if _, blocked := blockedHosts[strings.ToLower(u.Hostname())]; blocked {
return nil, errBlockedHost
}
return u, nil
}
var (
errOnlyWS = &wsTunnelError{"only ws/wss schemes are allowed"}
errEmptyHost = &wsTunnelError{"url host is required"}
errBlockedHost = &wsTunnelError{"blocked metadata host"}
)
type wsTunnelError struct{ msg string }
func (e *wsTunnelError) Error() string { return e.msg }
func (t *WSTunnel) Connect(c *gin.Context) {
var spec wsTunnelSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spec", "detail": err.Error()})
return
}
u, err := ValidateWSURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
dialHeader := http.Header{}
for key, value := range spec.Headers {
if !isHopByHop(key) {
dialHeader.Set(key, value)
}
}
target, _, err := dialer.Dial(u.String(), dialHeader)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "dial failed", "detail": err.Error()})
return
}
id := randomSessionID()
session := &wsTunnelSession{id: id, target: target, attach: make(chan *websocket.Conn, 1), createdAt: time.Now()}
t.mu.Lock()
t.sessions[id] = session
t.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"sessionId": id})
// 30 秒内没有浏览器 attach 就回收
go func() {
select {
case client := <-session.attach:
t.bridge(client, session)
case <-time.After(30 * time.Second):
t.mu.Lock()
if s, ok := t.sessions[id]; ok {
_ = s.target.Close()
delete(t.sessions, id)
}
t.mu.Unlock()
}
}()
}
func (t *WSTunnel) Attach(c *gin.Context) {
id := c.Query("sessionId")
t.mu.Lock()
session, ok := t.sessions[id]
t.mu.Unlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
client, err := t.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
select {
case session.attach <- client:
// bridge goroutine 已在 Connect 中启动
default:
_ = client.Close()
}
}
func (t *WSTunnel) Close(c *gin.Context) {
var body struct {
SessionID string `json:"sessionId"`
}
_ = c.ShouldBindJSON(&body)
t.mu.Lock()
session, ok := t.sessions[body.SessionID]
if ok {
delete(t.sessions, body.SessionID)
}
t.mu.Unlock()
if ok {
_ = session.target.Close()
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// bridge 双向转发:浏览器 → 目标;目标 → 浏览器
func (t *WSTunnel) bridge(client *websocket.Conn, session *wsTunnelSession) {
defer func() {
_ = client.Close()
_ = session.target.Close()
t.mu.Lock()
delete(t.sessions, session.id)
t.mu.Unlock()
}()
done := make(chan struct{})
// 浏览器 → 目标
go func() {
defer close(done)
for {
msgType, data, err := client.ReadMessage()
if err != nil {
return
}
if err := session.target.WriteMessage(msgType, data); err != nil {
return
}
}
}()
// 目标 → 浏览器(bridge 协议:控制帧 + 数据帧)
_ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"open"}`))
for {
msgType, data, err := session.target.ReadMessage()
if err != nil {
_ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"closed","reason":"target closed"}`))
return
}
if err := client.WriteMessage(msgType, data); err != nil {
return
}
}
}
func randomSessionID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return time.Now().Format("20060102150405.000000000")
}
return hex.EncodeToString(b)
}
@@ -0,0 +1,48 @@
package server
import (
"net/http/httptest"
"path/filepath"
"testing"
)
func TestMockListenerDoesNotExposeManagement(t *testing.T) {
t.Setenv("OPSXAPI_DB", filepath.Join(t.TempDir(), "mock.db"))
r := NewMockRouter()
for _, tc := range []struct {
path, peer string
status int
}{
{"/api/mock/local", "192.168.1.5:3000", 404},
{"/health", "192.168.1.5:3000", 200},
{"/health", "8.8.8.8:3000", 403},
{"/v1/proxy/http", "192.168.1.5:3000", 404},
} {
req := httptest.NewRequest("GET", tc.path, nil)
req.RemoteAddr = tc.peer
req.Header.Set("X-Forwarded-For", "127.0.0.1")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tc.status {
t.Fatalf("%s %s: %d", tc.path, tc.peer, w.Code)
}
}
}
func TestPublicMockStillIsolatesManagement(t *testing.T) {
t.Setenv("OPSXAPI_DB", filepath.Join(t.TempDir(), "mock.db"))
t.Setenv("OPSXAPI_MOCK_ALLOW_PUBLIC", "1")
r := NewMockRouter()
for _, tc := range []struct {
path string
code int
}{{"/health", 200}, {"/api/mock/local", 404}, {"/v1/proxy/http", 404}} {
req := httptest.NewRequest("GET", tc.path, nil)
req.RemoteAddr = "8.8.8.8:3000"
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tc.code {
t.Fatalf("%s: %d", tc.path, w.Code)
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package server
import (
"net"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"opsxapi/server/internal/api"
"opsxapi/server/internal/docs"
"opsxapi/server/internal/echo"
"opsxapi/server/internal/mocksrv"
"opsxapi/server/internal/proxy"
"opsxapi/server/internal/site"
)
func NewRouter() *gin.Engine {
r := gin.New()
_ = r.SetTrustedProxies(nil)
r.Use(gin.Logger(), gin.Recovery(), localhostCORS())
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxapi"})
})
p := proxy.NewHandler()
r.POST("/v1/proxy/http", p.ProxyHTTP)
r.POST("/v1/proxy/http/stream", p.ProxyHTTPStream)
tunnel := proxy.NewWSTunnel()
r.POST("/v1/proxy/ws/connect", tunnel.Connect)
r.GET("/v1/proxy/ws/attach", tunnel.Attach)
r.POST("/v1/proxy/ws/close", tunnel.Close)
// 账号 / 团队 / 云端空间(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)
if !mountHomepage(r) {
docs.New().Register(r)
site.New().Register(r)
}
return r
}
// mountHomepage 本地预览 homepage/dist(落地页 + 文档)。不改 homepage/ 源。
func mountHomepage(r *gin.Engine) bool {
for _, dir := range []string{
os.Getenv("OPSXAPI_HOMEPAGE"),
"../../homepage/dist",
"../homepage/dist",
"homepage/dist",
} {
if dir == "" {
continue
}
if st, err := os.Stat(dir); err == nil && st.IsDir() {
fs := http.FileServer(http.Dir(dir))
r.NoRoute(func(c *gin.Context) {
p := c.Request.URL.Path
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/v1/") {
c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"})
return
}
upath := strings.TrimPrefix(p, "/")
if upath != "" {
if st2, err2 := os.Stat(filepath.Join(dir, upath)); err2 == nil && st2.IsDir() {
c.Request.URL.Path = p + "/"
} else if !strings.Contains(filepath.Base(upath), ".") {
if _, err3 := os.Stat(filepath.Join(dir, upath+".html")); err3 == nil {
c.Request.URL.Path = p + ".html"
}
}
}
fs.ServeHTTP(c.Writer, c.Request)
})
return true
}
}
return false
}
func localhostCORS() gin.HandlerFunc {
allowed := map[string]struct{}{
"http://localhost:5173": {},
"http://127.0.0.1:5173": {},
"http://localhost:1420": {},
"http://127.0.0.1:1420": {},
"tauri://localhost": {},
"http://tauri.localhost": {},
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// NewMockRouter exposes only mock responses to private-network peers. No account,
// publishing, workspace, or proxy routes are registered on this listener.
func NewMockRouter() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery(), func(c *gin.Context) {
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
ip := net.ParseIP(host)
if err != nil || ip == nil || (os.Getenv("OPSXAPI_MOCK_ALLOW_PUBLIC") != "1" && !ip.IsPrivate() && !ip.IsLoopback()) {
c.AbortWithStatus(403)
return
}
c.Next()
})
a, err := api.New(os.Getenv("OPSXAPI_DB"))
if err != nil {
panic(err)
}
mocksrv.New(a.Store()).Register(r)
registerSharedDocs(r, a.Store())
r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true, "service": "opsxapi-mock"}) })
return r
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"database/sql"
"encoding/json"
"html/template"
"net/http"
"github.com/gin-gonic/gin"
"opsxapi/server/internal/store"
)
type sharedDocument struct {
Name string `json:"name"`
Endpoints []struct {
Method string `json:"method"`
Path string `json:"path"`
Name string `json:"name"`
Description string `json:"description"`
Parameters []struct {
Name string `json:"name"`
In string `json:"in"`
Required bool `json:"required"`
Description string `json:"description"`
Example string `json:"example"`
} `json:"parameters"`
RequestBody *struct {
ContentType string `json:"contentType"`
Example string `json:"example"`
} `json:"requestBody"`
Responses []struct {
Status string `json:"status"`
Description string `json:"description"`
ContentType string `json:"contentType"`
Example string `json:"example"`
} `json:"responses"`
MockURL string `json:"mockUrl"`
} `json:"endpoints"`
}
var sharedDocsTemplate = template.Must(template.New("docs").Parse(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Name}} · API 文档</title><style>body{font:15px/1.6 system-ui,sans-serif;margin:0;background:#0d1524;color:#e6edf8}header{padding:20px 28px;border-bottom:1px solid #33445d}main{display:grid;grid-template-columns:minmax(190px,260px) minmax(0,1fr);max-width:1300px;margin:auto}nav{padding:20px;border-right:1px solid #33445d}nav a{display:block;color:#93c5fd;padding:5px 0;text-decoration:none;overflow-wrap:anywhere}article{padding:14px 28px;border-bottom:1px solid #33445d}code,pre{font:13px/1.5 ui-monospace,monospace;overflow-wrap:anywhere}pre{white-space:pre-wrap;background:#162238;border-radius:8px;padding:14px;overflow:auto}section{min-width:0}small,.muted{color:#aabbd3}h2{overflow-wrap:anywhere}table{border-collapse:collapse;width:100%}td,th{padding:8px;border-bottom:1px solid #33445d;text-align:left}a{color:#93c5fd}@media(max-width:680px){main{display:block}nav{border-right:0;border-bottom:1px solid #33445d}}</style></head><body><header><h1>{{.Name}}</h1><small>自托管 API 文档 · 只读快照</small></header><main><nav>{{range $i,$e:=.Endpoints}}<a href="#api-{{$i}}">{{$e.Method}} {{$e.Path}}</a>{{end}}</nav><section>{{range $i,$e:=.Endpoints}}<article id="api-{{$i}}"><h2>{{$e.Name}}</h2><p><code>{{$e.Method}} {{$e.Path}}</code></p><p class="muted">{{$e.Description}}</p>{{if $e.Parameters}}<h3>请求参数</h3><table><tr><th>参数</th><th>位置</th><th>必填</th><th>示例</th><th>说明</th></tr>{{range $e.Parameters}}<tr><td>{{.Name}}</td><td>{{.In}}</td><td>{{if .Required}}是{{else}}否{{end}}</td><td>{{.Example}}</td><td>{{.Description}}</td></tr>{{end}}</table>{{end}}{{with $e.RequestBody}}<h3>请求体 · {{.ContentType}}</h3><pre>{{.Example}}</pre>{{end}}{{range $e.Responses}}<h3>HTTP {{.Status}} · {{.ContentType}}</h3><p>{{.Description}}</p><pre>{{.Example}}</pre>{{end}}{{if $e.MockURL}}<h3>Mock 地址</h3><p><code>{{$e.MockURL}}</code></p>{{end}}</article>{{end}}</section></main></body></html>`))
func registerSharedDocs(r *gin.Engine, s *store.Store) {
r.GET("/docs/:token", func(c *gin.Context) {
record, err := s.PublishedDocByToken(c.Param("token"))
if err == sql.ErrNoRows {
c.AbortWithStatus(404)
return
}
if err != nil {
c.AbortWithStatus(500)
return
}
var doc sharedDocument
if json.Unmarshal([]byte(record.Content), &doc) != nil {
c.AbortWithStatus(500)
return
}
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
c.Header("X-Content-Type-Options", "nosniff")
c.Status(http.StatusOK)
c.Header("Content-Type", "text/html; charset=utf-8")
_ = sharedDocsTemplate.Execute(c.Writer, doc)
})
}
@@ -0,0 +1,59 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func TestSharedDocsAreExplicitReadOnlyAndRevocable(t *testing.T) {
t.Setenv("OPSXAPI_DB", filepath.Join(t.TempDir(), "docs.db"))
admin := NewRouter()
public := NewMockRouter()
call := func(router interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}, method, path, body, peer string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.RemoteAddr = peer
out := httptest.NewRecorder()
router.ServeHTTP(out, req)
return out
}
payload := `{"projectId":"one","name":"<script>alert(1)</script>","endpoints":[{"method":"GET","path":"/pets","name":"Pets","description":"<img src=x onerror=alert(1)>","responses":[{"status":"200","example":"{\"ok\":true}"}]}]}`
if w := call(admin, "PUT", "/api/docs/local", payload, "192.168.0.2:1000"); w.Code != 403 {
t.Fatalf("remote publish: %d", w.Code)
}
w := call(admin, "PUT", "/api/docs/local", payload, "127.0.0.1:1000")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var saved struct {
Token string `json:"token"`
}
if err := json.Unmarshal(w.Body.Bytes(), &saved); err != nil {
t.Fatal(err)
}
if saved.Token == "" {
t.Fatal("missing token")
}
path := "/docs/" + saved.Token
if w = call(public, "GET", path, "", "8.8.8.8:1000"); w.Code != 403 {
t.Fatalf("public peer allowed: %d", w.Code)
}
w = call(public, "GET", path, "", "192.168.0.2:1000")
if w.Code != 200 || strings.Contains(w.Body.String(), "<script>") || strings.Contains(w.Body.String(), "<img") || !strings.Contains(w.Body.String(), "Pets") {
t.Fatal(w.Code, w.Body.String())
}
if w = call(public, "POST", path, "", "192.168.0.2:1000"); w.Code != 404 {
t.Fatalf("shared page accepts writes: %d", w.Code)
}
if w = call(admin, "DELETE", "/api/docs/local/one", "", "127.0.0.1:1000"); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if w = call(public, "GET", path, "", "192.168.0.2:1000"); w.Code != 404 {
t.Fatalf("revoked page still visible: %d", w.Code)
}
}
+282
View File
@@ -0,0 +1,282 @@
/* OpsxAPI 官网 —— 营销单页样式 */
:root {
--blue: #2563eb;
--blue-dark: #1d4ed8;
--blue-soft: rgba(37, 99, 235, 0.08);
--bg: #ffffff;
--bg-alt: #f6f8fc;
--border: #e4e9f1;
--text: #111827;
--text-2: #5b6880;
--text-3: #98a2b3;
--green: #059669;
--amber: #d97706;
--radius: 14px;
--shadow: 0 1px 3px rgba(16,24,40,.06), 0 12px 32px rgba(16,24,40,.07);
--mono: "JetBrains Mono", SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; scroll-behavior: smooth; }
body {
margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
color: var(--text); background: var(--bg); font-size: 15px; line-height: 1.75;
}
a { color: var(--blue); text-decoration: none; }
h1,h2,h3 { letter-spacing: -0.015em; }
section[id] { scroll-margin-top: 76px; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
.wrap.narrow { max-width: 820px; }
/* ---------- SVG 图标(lucide sprite) ---------- */
.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; }
.i-lg { width: 28px; height: 28px; vertical-align: middle; }
code { font-family: var(--mono); font-size: 13px; background: var(--blue-soft); color: var(--blue-dark); border-radius: 5px; padding: 1px 6px; }
/* ---------- 按钮 ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border-radius: 10px; padding: 9px 20px; font-size: 14.5px; font-weight: 600; cursor: pointer;
transition: all .18s; border: 1px solid transparent; white-space: nowrap;
}
.btn-lg { padding: 12px 26px; font-size: 15.5px; }
.btn-primary { background: var(--blue); color: #fff !important; box-shadow: 0 6px 18px rgba(37,99,235,.28); }
.btn-primary:hover { background: var(--blue-dark); transform: translateY(-1px); }
.btn-ghost { border-color: var(--border); color: var(--text) !important; background: #fff; }
.btn-ghost:hover { border-color: var(--blue); color: var(--blue) !important; }
.btn-inverse { background: #fff; color: var(--blue) !important; }
.btn-inverse:hover { transform: translateY(-1px); }
.btn-outline { border-color: rgba(255,255,255,.5); color: #fff !important; }
.btn-outline:hover { border-color: #fff; background: rgba(255,255,255,.1); }
/* ---------- 导航 ---------- */
.nav {
position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,.9);
backdrop-filter: blur(10px); border-bottom: 1px solid var(--border);
}
.nav-inner { max-width: 1120px; margin: 0 auto; padding: 0 24px; height: 60px; display: flex; align-items: center; gap: 28px; }
.brand { display: flex; align-items: center; gap: 9px; }
.brand-logo { display: inline-flex; }
.brand-logo svg { display: block; border-radius: 8px; }
.brand-name { font-size: 17px; font-weight: 800; color: var(--text) !important; }
.nav-links { display: flex; gap: 22px; }
.nav-links a { font-size: 14px; font-weight: 500; color: var(--text-2) !important; }
.nav-links a:hover { color: var(--blue) !important; }
.nav-cta { margin-left: auto; }
/* ---------- Hero ---------- */
.hero {
position: relative; overflow: hidden;
background:
radial-gradient(1200px 500px at 85% -10%, rgba(37,99,235,.14), transparent 60%),
radial-gradient(800px 400px at -10% 10%, rgba(5,150,105,.08), transparent 55%),
linear-gradient(180deg, #f8faff 0%, #fff 100%);
border-bottom: 1px solid var(--border);
}
.hero-inner {
max-width: 1120px; margin: 0 auto; padding: 72px 24px 64px;
display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 48px; align-items: center;
}
.hero-badge {
display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--blue-dark);
background: #fff; border: 1px solid rgba(37,99,235,.25); border-radius: 999px; padding: 4px 14px;
box-shadow: 0 2px 8px rgba(37,99,235,.1); margin-bottom: 18px;
}
.hero h1 { font-size: 44px; line-height: 1.22; font-weight: 800; margin: 0 0 16px; }
.hero h1 em { font-style: normal; color: var(--blue); }
.hero-sub { color: var(--text-2); font-size: 16px; margin: 0 0 26px; }
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; }
.hero-actions.center { justify-content: center; }
.hero-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; }
.hero-pills span {
font-size: 12px; color: var(--text-2); background: #fff; border: 1px solid var(--border);
padding: 3px 12px; border-radius: 999px;
}
/* ---------- Mock 界面 ---------- */
.mock {
border-radius: 14px; overflow: hidden; box-shadow: var(--shadow); border: 1px solid #1e2b45;
background: #0d1526; transform: perspective(1400px) rotateY(-4deg) rotateX(1.5deg);
}
.mock-titlebar {
display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 12px;
background: #101a30; border-bottom: 1px solid #1e2b45;
}
.mock-titlebar i { width: 11px; height: 11px; border-radius: 50%; background: #3a4a6b; }
.mock-titlebar i:nth-child(1) { background: #f87171; } .mock-titlebar i:nth-child(2) { background: #fbbf24; } .mock-titlebar i:nth-child(3) { background: #34d399; }
.mock-titlebar span { margin-left: 8px; color: #8b99b0; font-size: 11px; }
.mock-body { display: grid; grid-template-columns: 158px minmax(0,1fr); min-height: 320px; }
.mock-side { border-right: 1px solid #1e2b45; padding: 10px 8px; background: #0c1322; }
.mock-search { height: 22px; border-radius: 6px; background: #16213a; margin-bottom: 10px; }
.mock-group { color: #8b99b0; font-size: 11px; padding: 6px 6px 3px; }
.mock-item { display: flex; align-items: center; gap: 6px; color: #aab8d0; font-size: 11.5px; padding: 4px 6px; border-radius: 6px; }
.mock-item.active { background: rgba(37,99,235,.18); color: #93c5fd; }
.mi { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; opacity: .8; }
.m-get { color: #34d399; font-style: normal; font-size: 9.5px; min-width: 30px; }
.m-post { color: #fbbf24; font-style: normal; font-size: 9.5px; min-width: 30px; }
.mock-main { padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; }
.mock-urlbar { display: flex; align-items: center; gap: 8px; }
.mock-url {
flex: 1; background: #0a1120; border: 1px solid #22304f; border-radius: 7px; color: #cbd5e1;
font-family: var(--mono); font-size: 11px; padding: 6px 10px;
}
.mock-send {
background: var(--blue); color: #fff; font-size: 11px; font-weight: 600;
border-radius: 7px; padding: 6px 14px;
}
.mock-req, .mock-resp { border: 1px solid #1e2b45; border-radius: 9px; padding: 8px 10px; background: #0c1322; }
.mock-req .tab, .mock-resp .tab { color: #5c6a83; font-size: 10.5px; margin-right: 12px; }
.mock-req .tab.on { color: #93c5fd; border-bottom: 2px solid var(--blue); padding-bottom: 2px; }
.mock-resp .tab.on { color: #6ee7b7; border-bottom: 2px solid var(--green); padding-bottom: 2px; }
.mock-status { float: right; color: #34d399; font-size: 10px; font-family: var(--mono); }
.mock pre {
margin: 8px 0 2px; font-family: var(--mono); font-size: 11px; line-height: 1.6; color: #cbd5e1;
white-space: pre-wrap;
}
.mock pre i { font-style: normal; color: #7dd3fc; }
.mock pre u { text-decoration: none; color: #86efac; }
/* ---------- 通用 Section ---------- */
.section { padding: 72px 0; }
.section.alt { background: var(--bg-alt); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.sec-title { font-size: 30px; font-weight: 800; text-align: center; margin: 0 0 8px; }
.sec-sub { text-align: center; color: var(--text-2); margin: 0 0 40px; }
.grid { display: grid; gap: 18px; }
.g3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.g4 { grid-template-columns: repeat(4, minmax(0,1fr)); }
/* ---------- 卡片 ---------- */
.card {
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
padding: 22px; box-shadow: 0 1px 2px rgba(16,24,40,.04); transition: all .18s;
}
.card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: rgba(37,99,235,.35); }
.card h3 { font-size: 16px; margin: 0 0 6px; }
.card p { color: var(--text-2); font-size: 13.5px; margin: 0; }
.card-icon { color: var(--blue); margin-bottom: 10px; }
.card-icon .i-lg { width: 30px; height: 30px; }
.card.row { display: flex; gap: 14px; align-items: flex-start; }
.card.row .card-icon { margin: 2px 0 0; }
.card.mini h3 { font-size: 15px; display: flex; align-items: center; gap: 8px; }
.card.mini h3 .i-lg { width: 20px; height: 20px; color: var(--blue); }
.card.mini p { font-size: 13px; }
/* ---------- 协议 ---------- */
.proto {
background: #fff; border: 1px solid var(--border); border-left: 3px solid var(--blue);
border-radius: 12px; padding: 18px 20px;
}
.proto h3 { margin: 0 0 6px; font-size: 15.5px; display: flex; align-items: center; gap: 8px; }
.proto h3 .i { color: var(--blue); width: 17px; height: 17px; }
.proto p { margin: 0; color: var(--text-2); font-size: 13.5px; }
/* ---------- 导入导出 ---------- */
.badges { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-bottom: 18px; }
.badges span {
background: #fff; border: 1px solid var(--border); border-radius: 999px;
padding: 6px 16px; font-size: 13px; font-weight: 500; color: var(--text);
}
.badges span.more { border-style: dashed; color: var(--text-2); }
.footnote { text-align: center; color: var(--text-3); font-size: 12.5px; margin: 10px 0 0; }
.exchange-actions { display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; margin-top: 26px; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 8px 16px;
font-size: 13px; font-weight: 600; color: var(--text-2);
}
.chip.big { background: var(--blue); border-color: var(--blue); color: #fff; padding: 10px 22px; }
.exchange-arrow { color: var(--text-3); font-size: 15px; }
/* ---------- 对比表 ---------- */
.table-wrap { overflow-x: auto; }
.cmp { border-collapse: collapse; width: 100%; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 0 0 1px var(--border); font-size: 14px; }
.cmp th, .cmp td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
.cmp th { background: #f9fbfe; font-size: 13.5px; }
.cmp tr:last-child td { border-bottom: none; }
.cmp td.hl { background: rgba(37,99,235,.05); color: var(--blue-dark); }
.cmp th.hl { background: var(--blue); color: #fff; }
/* ---------- 定价 ---------- */
.pricing { display: grid; grid-template-columns: repeat(2, minmax(0, 380px)); gap: 22px; justify-content: center; }
.pricing.three { grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 1080px; margin: 0 auto; }
.price-card {
position: relative; background: #fff; border: 1px solid var(--border); border-radius: 16px;
padding: 30px 28px 26px; box-shadow: 0 1px 2px rgba(16,24,40,.04); display: flex; flex-direction: column;
}
.price-card.featured { border: 2px solid var(--blue); box-shadow: 0 12px 36px rgba(37,99,235,.16); }
.price-card.enterprise {
background: linear-gradient(160deg, #101a30 0%, #0d1526 70%);
border: 1px solid #2a3b5e; box-shadow: 0 14px 40px rgba(13,21,38,.4);
}
.price-card.enterprise .plan { color: #8b99b0; }
.price-card.enterprise .price b { color: #fff; font-size: 36px; }
.price-card.enterprise .price span { color: #8b99b0; }
.price-card.enterprise .plan-sub { color: #8b99b0; }
.price-card.enterprise li { color: #aab8d0; border-bottom-color: #22304f; }
.price-card.enterprise .deal-note {
margin-top: 14px; font-size: 11.5px; line-height: 1.7; color: #8b99b0;
background: rgba(255,255,255,.05); border: 1px dashed #2a3b5e; border-radius: 9px; padding: 9px 12px;
}
.price-card.enterprise .deal-note b { color: #93c5fd; }
.btn-dark { background: #fff; color: #0d1526 !important; }
.btn-dark:hover { transform: translateY(-1px); background: #e8eefc; }
.plan-tag {
position: absolute; top: -13px; left: 50%; transform: translateX(-50%);
background: var(--blue); color: #fff; font-size: 12px; font-weight: 700;
padding: 3px 14px; border-radius: 999px; white-space: nowrap;
}
.plan-tag.dark { background: #0d1526; border: 1px solid #2a3b5e; }
.plan { font-size: 16px; font-weight: 700; color: var(--text-2); }
.price { margin: 8px 0 2px; }
.price b { font-size: 44px; font-weight: 800; letter-spacing: -.02em; color: var(--text); }
.price span { color: var(--text-3); font-size: 14px; margin-left: 4px; }
.plan-sub { color: var(--text-3); font-size: 13px; margin-bottom: 18px; }
.price-card ul { list-style: none; padding: 0; margin: 0 0 22px; flex: 1; }
.price-card li { font-size: 13.5px; color: var(--text-2); padding: 6px 0; border-bottom: 1px dashed var(--border); }
.price-card li:last-child { border-bottom: none; }
.price-card .btn { width: 100%; }
.growth-title { text-align: center; font-size: 20px; font-weight: 700; margin: 52px 0 22px; }
/* ---------- FAQ ---------- */
.faq details {
background: #fff; border: 1px solid var(--border); border-radius: 12px;
padding: 16px 20px; margin-bottom: 12px;
}
.faq summary { font-weight: 600; cursor: pointer; font-size: 15px; list-style: none; display: flex; justify-content: space-between; }
.faq summary::after { content: "+"; color: var(--text-3); font-weight: 400; }
.faq details[open] summary::after { content: "-"; }
.faq details p { margin: 10px 0 0; color: var(--text-2); font-size: 14px; }
/* ---------- CTA ---------- */
.cta {
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #3b82f6 100%);
color: #fff; text-align: center; padding: 72px 24px;
}
.cta h2 { font-size: 32px; font-weight: 800; margin: 0 0 10px; }
.cta p { color: rgba(255,255,255,.85); margin: 0 0 28px; }
/* ---------- Footer ---------- */
.footer { background: #0d1526; color: #8b99b0; padding: 52px 0 0; font-size: 13.5px; }
.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; padding-bottom: 36px; }
.footer .brand-name { color: #fff !important; }
.footer .f-logo { display: block; border-radius: 8px; }
.footer .brand { display: flex; align-items: center; gap: 9px; }
.footer-desc { margin: 12px 0 0; font-size: 13px; line-height: 1.8; }
.footer h4 { color: #fff; font-size: 13.5px; margin: 4px 0 12px; }
.footer a { display: block; color: #8b99b0 !important; padding: 4px 0; font-size: 13px; }
.footer a:hover { color: #fff !important; }
.footer-bar { border-top: 1px solid #1e2b45; text-align: center; padding: 18px; font-size: 12px; color: #5c6a83; }
/* ---------- 响应式 ---------- */
@media (max-width: 1020px) {
.hero-inner { grid-template-columns: 1fr; padding-top: 52px; }
.mock { transform: none; }
.g4 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.g3 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.pricing, .pricing.three { grid-template-columns: minmax(0, 420px); }
.footer-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 680px) {
.nav-links { display: none; }
.hero h1 { font-size: 32px; }
.sec-title { font-size: 24px; }
.g3, .g4 { grid-template-columns: 1fr; }
.footer-grid { grid-template-columns: 1fr; }
}
+80
View File
@@ -0,0 +1,80 @@
// Package site 在 /home(及 /)提供产品官网单页(SSR,资源嵌入二进制)。
package site
import (
"embed"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed template.html terms.html assets
var files embed.FS
// Handler 官网服务
type Handler struct {
html string
terms string
}
func New() *Handler {
raw, err := files.ReadFile("template.html")
if err != nil {
panic("site: template.html 缺失: " + err.Error())
}
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 均渲染官网
func (h *Handler) Register(r *gin.Engine) {
r.GET("/", h.Page)
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 渲染官网
func (h *Handler) Page(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-cache")
_, _ = c.Writer.WriteString(h.html)
}
// Asset 静态资源
func (h *Handler) Asset(c *gin.Context) {
name := strings.TrimPrefix(c.Param("file"), "/")
if strings.Contains(name, "..") {
c.Status(http.StatusBadRequest)
return
}
data, err := files.ReadFile("assets/" + name)
if err != nil {
c.Status(http.StatusNotFound)
return
}
switch {
case strings.HasSuffix(name, ".css"):
c.Header("Content-Type", "text/css; charset=utf-8")
case strings.HasSuffix(name, ".js"):
c.Header("Content-Type", "application/javascript; charset=utf-8")
case strings.HasSuffix(name, ".svg"):
c.Header("Content-Type", "image/svg+xml")
default:
c.Header("Content-Type", "application/octet-stream")
}
c.Header("Cache-Control", "public, max-age=3600")
c.Data(http.StatusOK, "", data)
}
+63
View File
@@ -0,0 +1,63 @@
package site
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func newTestRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
New().Register(r)
return r
}
func TestSitePageRender(t *testing.T) {
r := newTestRouter(t)
for _, path := range []string{"/", "/home", "/index.html"} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK {
t.Fatalf("%s: expect 200, got %d", path, w.Code)
}
body := w.Body.String()
for _, want := range []string{
"<title>OpsxAPI", // 标题
`id="features"`, // 功能区
`id="pricing"`, // 定价区
"¥99", // 入门价
"¥8,000", // 企业档标价
"¥30,000", // 买断标价
"¥5,000–8,000", // 成交区间
"永久买断", // 买断 FAQ
"早鸟创始价", // 让利策略
"Socket.IO", // 协议覆盖
"hello@opsxapi.dev", // 联系方式
`href="/docs"`, // 文档互链
`href="/site/assets/site.css"`, // 样式资源
} {
if !strings.Contains(body, want) {
t.Fatalf("%s: missing %q", path, want)
}
}
}
}
func TestSiteAsset(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/site/assets/site.css", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "text/css") {
t.Fatalf("css: %d %s", w.Code, w.Header().Get("Content-Type"))
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/site/assets/../template.html", nil))
if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound {
t.Fatalf("path traversal should be rejected, got %d", w.Code)
}
}
+357
View File
@@ -0,0 +1,357 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpsxAPI — 本地优先的全协议接口工作台</title>
<meta name="description" content="OpsxAPI:HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT 一站式调试。本地优先数据主权,OpenAPI/Postman/cURL 全格式导入导出。个人版 ¥99/年,团队版 ¥99/人/年。">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232563eb'/%3E%3Ctext x='16' y='22' font-size='18' font-weight='bold' fill='%23fff' text-anchor='middle' font-family='sans-serif'%3EO%3C/text%3E%3C/svg%3E">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%2264%22%20y2%3D%2264%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%232563EB%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2306B6D4%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22url%28%23g%29%22%2F%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M17%2025h26%22%2F%3E%3Cpath%20d%3D%22M37%2019l6%206-6%206%22%2F%3E%3Cpath%20d%3D%22M47%2039H21%22%2F%3E%3Cpath%20d%3D%22M27%2033l-6%206%206%206%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<link rel="stylesheet" href="/site/assets/site.css">
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-rocket" viewBox="0 0 24 24"><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /> <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09" /> <path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05" /></symbol>
<symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /> <path d="m9 12 2 2 4-4" /></symbol>
<symbol id="i-arrow-left-right" viewBox="0 0 24 24"><path d="M8 3 4 7l4 4" /> <path d="M4 7h16" /> <path d="m16 21 4-4-4-4" /> <path d="M20 17H4" /></symbol>
<symbol id="i-plug-zap" viewBox="0 0 24 24"><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z" /> <path d="m2 22 3-3" /> <path d="M7.5 13.5 10 11" /> <path d="M10.5 16.5 13 14" /> <path d="m18 3-4 4h6l-4 4" /></symbol>
<symbol id="i-package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /> <path d="M12 22V12" /> <polyline points="3.29 7 12 12 20.71 7" /> <path d="m7.5 4.27 9 5.15" /></symbol>
<symbol id="i-folder-tree" viewBox="0 0 24 24"><path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" /> <path d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" /> <path d="M3 5a2 2 0 0 0 2 2h3" /> <path d="M3 3v13a2 2 0 0 0 2 2h3" /></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /> <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /> <path d="M2 12h20" /></symbol>
<symbol id="i-hexagon" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z" /></symbol>
<symbol id="i-radio" viewBox="0 0 24 24"><path d="M16.247 7.761a6 6 0 0 1 0 8.478" /> <path d="M19.075 4.933a10 10 0 0 1 0 14.134" /> <path d="M4.925 19.067a10 10 0 0 1 0-14.134" /> <path d="M7.753 16.239a6 6 0 0 1 0-8.478" /> <circle cx="12" cy="12" r="2" /></symbol>
<symbol id="i-mailbox" viewBox="0 0 24 24"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z" /> <polyline points="15,9 18,9 18,11" /> <path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2" /> <line x1="6" x2="7" y1="10" y2="10" /></symbol>
<symbol id="i-code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6" /> <path d="m8 6-6 6 6 6" /></symbol>
<symbol id="i-book-open" viewBox="0 0 24 24"><path d="M12 5v16" /> <path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" /></symbol>
<symbol id="i-sprout" viewBox="0 0 24 24"><path d="M14 9.536V7a4 4 0 0 1 4-4h1.5a.5.5 0 0 1 .5.5V5a4 4 0 0 1-4 4 4 4 0 0 0-4 4c0 2 1 3 1 5a5 5 0 0 1-1 3" /> <path d="M4 9a5 5 0 0 1 8 4 5 5 0 0 1-8-4" /> <path d="M5 21h14" /></symbol>
<symbol id="i-graduation-cap" viewBox="0 0 24 24"><path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" /> <path d="M22 10v6" /> <path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" /></symbol>
<symbol id="i-credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2" /> <line x1="2" x2="22" y1="10" y2="10" /> <path d="M6 14h2" /></symbol>
<symbol id="i-megaphone" viewBox="0 0 24 24"><path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" /> <path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14" /> <path d="M8 6v8" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-library" viewBox="0 0 24 24"><path d="m16 6 4 14" /> <path d="M12 6v14" /> <path d="M8 8v12" /> <path d="M4 4v16" /></symbol>
</svg>
<!-- ===================== 导航 ===================== -->
<header class="nav">
<div class="nav-inner">
<a class="brand" href="/home">
<span class="brand-logo"><svg width="30" height="30" viewBox="0 0 64 64" aria-label="OpsxAPI"><defs><linearGradient id="lg-site" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2563EB"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-site)"/><g stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"><path d="M17 25h26"/><path d="M37 19l6 6-6 6"/><path d="M47 39H21"/><path d="M27 33l-6 6 6 6"/></g></svg></span>
<span class="brand-name">OpsxAPI</span>
</a>
<nav class="nav-links">
<a href="#features">功能</a>
<a href="#protocols">协议</a>
<a href="#exchange">导入导出</a>
<a href="#pricing">定价</a>
<a href="/docs" target="_blank" rel="noopener">文档</a>
</nav>
<div class="nav-cta">
<a class="btn btn-primary" href="/" target="_blank" rel="noopener">打开工作台 ↗</a>
</div>
</div>
</header>
<!-- ===================== Hero ===================== -->
<section class="hero">
<div class="hero-inner">
<div class="hero-copy">
<div class="hero-badge"><svg class="i"><use href="#i-rocket"/></svg> v1.0 正式发布 · 数据 100% 本地</div>
<h1>一个工作台,<br>调完<em>六种协议</em>的接口</h1>
<p class="hero-sub">
HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT。<br>
集合管理、多环境变量、全格式导入导出、代码生成、内置文档站 —— 运维与开发的自托管接口利器。
</p>
<div class="hero-actions">
<a class="btn btn-primary btn-lg" href="/" target="_blank" rel="noopener">立即开始(免费体验)</a>
<a class="btn btn-ghost btn-lg" href="/docs/quickstart" target="_blank" rel="noopener">快速开始 →</a>
</div>
<div class="hero-pills">
<span>本地优先</span><span>自定义 WS 握手头</span><span>SSE 断点续传</span><span>OpenAPI 双向同步</span>
</div>
</div>
<!-- 产品界面示意(纯 CSS 绘制) -->
<div class="mock" aria-hidden="true">
<div class="mock-titlebar"><i></i><i></i><i></i><span>OpsxAPI · 示例集合</span></div>
<div class="mock-body">
<div class="mock-side">
<div class="mock-search"></div>
<div class="mock-group"><svg class="mi"><use href="#i-library"/></svg> 示例集合</div>
<div class="mock-item"><b class="m-get">GET</b>获取请求回显</div>
<div class="mock-item"><b class="m-post">POST</b>提交 JSON</div>
<div class="mock-item"><b class="m-get">GET</b>Bearer 鉴权示例</div>
<div class="mock-group"><svg class="mi"><use href="#i-radio"/></svg> 实时协议</div>
<div class="mock-item active"><svg class="mi"><use href="#i-zap"/></svg> WebSocket · echo</div>
<div class="mock-item"><svg class="mi"><use href="#i-mailbox"/></svg> MQTT · EMQX</div>
<div class="mock-group"><svg class="mi"><use href="#i-hexagon"/></svg> GraphQL</div>
<div class="mock-item">国家查询 · countries</div>
</div>
<div class="mock-main">
<div class="mock-urlbar">
<b class="m-post">POST</b>
<span class="mock-url">{{baseUrl}}/v1/orders</span>
<span class="mock-send">发送</span>
</div>
<div class="mock-req">
<span class="tab on">Body</span><span class="tab">Params</span><span class="tab">Headers</span><span class="tab">Auth</span>
<pre>{
<i>"sku"</i>: <u>"A-1"</u>,
<i>"qty"</i>: <u>2</u>
}</pre>
</div>
<div class="mock-resp">
<span class="tab on">响应体</span>
<span class="mock-status">200 · 87ms · 274B</span>
<pre>{
<i>"orderId"</i>: <u>"ord_9f2a"</u>,
<i>"status"</i>: <u>"created"</u>,
<i>"paid"</i>: <u>false</u>
}</pre>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ===================== 为什么选 ===================== -->
<section class="section">
<div class="wrap">
<h2 class="sec-title">为什么团队选择 OpsxAPI</h2>
<p class="sec-sub">不追赶云端协作的花活,专注把「调试接口」这件事做到极致。</p>
<div class="grid g4">
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-shield-check"/></svg></div>
<h3>本地优先,数据主权</h3>
<p>集合、环境、历史全部保存在本机,不经过任何第三方服务器。内网、涉密项目放心用。</p>
</div>
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-arrow-left-right"/></svg></div>
<h3>六协议一个窗口</h3>
<p>HTTP / GraphQL / WebSocket / SSE / Socket.IO / MQTT 统一工作台,实时消息分色日志。</p>
</div>
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-plug-zap"/></svg></div>
<h3>浏览器也能带握手头</h3>
<p>内置 WebSocket 隧道与流式代理,突破浏览器 CORS 与 Header 限制,调试网关级接口。</p>
</div>
<div class="card">
<div class="card-icon"><svg class="i-lg"><use href="#i-package"/></svg></div>
<h3>生态互通</h3>
<p>Apifox / Postman / Swagger / cURL / HAR 一键导入导出,迁移零成本,文档即代码。</p>
</div>
</div>
</div>
</section>
<!-- ===================== 功能 ===================== -->
<section class="section alt" id="features">
<div class="wrap">
<h2 class="sec-title">开箱即用的专业能力</h2>
<p class="sec-sub">从单人调试到团队交付,该有的都有。</p>
<div class="grid g3">
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-folder-tree"/></svg></div><div><h3>集合与多标签</h3><p>目录树、搜索、右键菜单、拖拽移动;多标签并行调试。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-globe"/></svg></div><div><h3>多环境变量</h3><p><code>{{baseUrl}}</code> 全局替换,正式 / 测试 / 本地一键切换。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-shield-check"/></svg></div><div><h3>鉴权管理</h3><p>Bearer / Basic / API Key,token 走变量,一处配置全局生效。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-zap"/></svg></div><div><h3>实时协议调试</h3><p>WS 握手头、SSE 断点续传、Socket.IO 事件收发、MQTT 通配符订阅。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-code"/></svg></div><div><h3>代码生成</h3><p>任意请求一键生成 cURL / fetch / Python / Go,直接进脚本与 CI。</p></div></div>
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-book-open"/></svg></div><div><h3>内置文档站</h3><p>随服务自带 /docs 用户文档与 Markdown 接口文档导出,交付即完整。</p></div></div>
</div>
</div>
</section>
<!-- ===================== 协议 ===================== -->
<section class="section" id="protocols">
<div class="wrap">
<h2 class="sec-title">六种协议,全覆盖</h2>
<p class="sec-sub">每个协议都有为它定制的编辑器,而不是塞进同一个表单。</p>
<div class="grid g3">
<div class="proto"><h3><svg class="i"><use href="#i-globe"/></svg> HTTP / REST</h3><p>7 种方法 · 6 种请求体 · 响应 Pretty 高亮 · 历史回跳</p></div>
<div class="proto"><h3><svg class="i"><use href="#i-hexagon"/></svg> GraphQL</h3><p>Query + Variables 编辑 · errors 高亮 · 生成 cURL 报文</p></div>
<div class="proto"><h3><svg class="i"><use href="#i-zap"/></svg> WebSocket</h3><p>自定义握手 Header(本地隧道)· 子协议 · 分色消息日志</p></div>
<div class="proto"><h3><svg class="i"><use href="#i-radio"/></svg> SSE</h3><p>直连失败自动切代理 · Last-Event-ID 续传 · 事件按名称归类</p></div>
<div class="proto"><h3><svg class="i"><use href="#i-plug-zap"/></svg> Socket.IO</h3><p>事件监听(onAny)· emit 参数 JSON · path / auth 配置</p></div>
<div class="proto"><h3><svg class="i"><use href="#i-mailbox"/></svg> MQTT</h3><p>wss Broker · 通配符订阅 · QoS 0/1/2 · 断线自动重连</p></div>
</div>
</div>
</section>
<!-- ===================== 导入导出 ===================== -->
<section class="section alt" id="exchange">
<div class="wrap">
<h2 class="sec-title">导入导出,迁移零成本</h2>
<p class="sec-sub">粘贴、文件、URL 三种来源,格式自动识别。</p>
<div class="badges">
<span>OpenAPI 3.0 / 3.1</span><span>Swagger 2.0</span><span>Apifox</span><span>Postman v2.1</span>
<span>Apipost</span><span>cURL</span><span>HAR</span><span>Insomnia</span><span>Hoppscotch</span><span>Markdown</span>
<span class="more">YApi / Apidoc / Apizza / Eolink ①</span>
</div>
<p class="footnote">① 经 OpenAPI / Postman 格式中转导入。导入自动:tag 建目录、路径参数转变量、schema 生成示例体、security 映射鉴权。</p>
<div class="exchange-actions">
<div class="chip"><svg class="i"><use href="#i-download"/></svg> 导入</div><div class="exchange-arrow">──→</div><div class="chip big">OpsxAPI</div><div class="exchange-arrow">──→</div>
<div class="chip"><svg class="i"><use href="#i-upload"/></svg> OpenAPI / Postman</div><div class="chip"><svg class="i"><use href="#i-upload"/></svg> Markdown 文档</div><div class="chip"><svg class="i"><use href="#i-upload"/></svg> 代码生成</div><div class="chip"><svg class="i"><use href="#i-upload"/></svg> 全量备份</div>
</div>
</div>
</section>
<!-- ===================== 对比 ===================== -->
<section class="section" id="compare">
<div class="wrap">
<h2 class="sec-title">与主流工具的对比</h2>
<p class="sec-sub">价格为各官网公开标价(2026-09 整理),以官方为准。</p>
<div class="table-wrap">
<table class="cmp">
<thead><tr><th>能力</th><th class="hl">OpsxAPI</th><th>Apifox</th><th>Apipost</th></tr></thead>
<tbody>
<tr><td>入门付费价格</td><td class="hl"><b>¥99/年</b>(个人)</td><td>¥99/人/年②</td><td>免费 + 商业版</td></tr>
<tr><td>专业档价格</td><td class="hl"><b>¥99/人/年</b>(团队)</td><td>¥199/人/年</td><td>按席位订阅</td></tr>
<tr><td>数据存储位置</td><td class="hl"><b>100% 本地</b></td><td>云端 / 私有化</td><td>云端 / 私有化</td></tr>
<tr><td>调试协议</td><td class="hl">HTTP · GraphQL · WS · SSE · Socket.IO · MQTT</td><td>HTTP · WS · GraphQL</td><td>HTTP · WS · gRPC 等</td></tr>
<tr><td>WS 自定义握手 Header(Web)</td><td class="hl"><b>✓ 内置隧道</b></td><td>桌面端支持</td><td>桌面端支持</td></tr>
<tr><td>自托管部署</td><td class="hl"><b>单二进制 + 静态页</b></td><td>私有化付费</td><td>私有化付费</td></tr>
<tr><td>私有化 / 买断参考价</td><td class="hl"><b>¥8,000/年起 · ¥30,000 买断</b></td><td>询价</td><td>询价</td></tr>
<tr><td>内置用户文档站</td><td class="hl"><b>✓ /docs</b></td><td>—</td><td>—</td></tr>
</tbody>
</table>
</div>
<p class="footnote">② Apifox 免费版即含大部分调试能力,付费档面向团队协作;对比仅帮助按需选型,各有所长。</p>
</div>
</section>
<!-- ===================== 定价 ===================== -->
<section class="section alt" id="pricing">
<div class="wrap">
<h2 class="sec-title">简单透明的定价</h2>
<p class="sec-sub">先在工作台免费用起来,需要授权与服务时再付费。</p>
<div class="pricing three">
<div class="price-card">
<div class="plan">个人版</div>
<div class="price"><b>¥99</b><span>/ 年</span></div>
<div class="plan-sub">单设备授权 · 全功能</div>
<ul>
<li>✓ 六种协议全部调试能力</li>
<li>✓ 集合 / 环境 / 历史无限量</li>
<li>✓ 全格式导入导出 + 代码生成</li>
<li>✓ 桌面端 + Web 双形态</li>
<li>✓ 1 年免费升级</li>
</ul>
<a class="btn btn-primary" href="#contact">购买咨询</a>
</div>
<div class="price-card featured">
<div class="plan-tag">高性价比之选</div>
<div class="plan">团队版</div>
<div class="price"><b>¥99</b><span>/ 人 / 年</span></div>
<div class="plan-sub">按席位授权 · 5 席起</div>
<ul>
<li>✓ 个人版全部能力</li>
<li>✓ 集合批量导入分发</li>
<li>✓ 内网自托管(单二进制)</li>
<li>✓ 优先技术支持</li>
<li>✓ 1 年免费升级</li>
</ul>
<a class="btn btn-primary" href="#contact">联系商务</a>
</div>
<div class="price-card enterprise">
<div class="plan-tag dark">私有化 · 单法人不限席位</div>
<div class="plan">企业私有化版</div>
<div class="price"><b>¥8,000</b><span>/ 年 · 标价</span></div>
<div class="plan-sub">或 ¥30,000 一次性永久买断(标价)</div>
<ul>
<li>✓ 整机 / 内网部署,数据不出企业</li>
<li>✓ 不限席位,团队版全部能力</li>
<li>✓ 远程部署协助 + 专属支持</li>
<li>✓ 定制开发通道(另行报价)</li>
<li>✓ 买断可选:永久使用 + 1 年升级</li>
</ul>
<a class="btn btn-dark" href="#contact">获取报价</a>
<div class="deal-note">
标价透明,成交灵活:经早鸟 / 教育 / 多年预付 / 案例合作,常见成交区间 <b>¥5,000–8,000/年</b>、买断 <b>¥15,000–30,000</b>。
</div>
</div>
</div>
<!-- 让利策略 -->
<h3 class="growth-title">早鸟与让利计划 —— 把优惠变成共同成长</h3>
<div class="grid g4">
<div class="card mini">
<h3><svg class="i-lg"><use href="#i-sprout"/></svg> 早鸟创始价</h3>
<p>前 20 个付费席位享 <b>5-7 折永久优惠</b>,并进入创始客户名单,参与需求优先排期。</p>
</div>
<div class="card mini">
<h3><svg class="i-lg"><use href="#i-graduation-cap"/></svg> 教育 / 开源</h3>
<p>高校师生、开源维护者凭证享 <b>6-8 折</b>。教育用途可申请批量授权。</p>
</div>
<div class="card mini">
<h3><svg class="i-lg"><use href="#i-credit-card"/></svg> 多年预付</h3>
<p>一次预付 2 年 <b>9 折</b>、3 年 <b>8 折</b>,锁定当期价格,改善双方现金流。</p>
</div>
<div class="card mini">
<h3><svg class="i-lg"><use href="#i-megaphone"/></svg> 案例换授权</h3>
<p>愿意公开使用案例、撰写评测或作为推荐客户的,可获 <b>额外折扣或免费席位</b>。</p>
</div>
</div>
<p class="footnote">以上为官网标价机制;最终成交价支持按规模与场景商务沟通,多年预付 / 服务范围(部署协助、定制开发)可组合。</p>
</div>
</section>
<!-- ===================== FAQ ===================== -->
<section class="section" id="faq">
<div class="wrap narrow">
<h2 class="sec-title">购买常见问题</h2>
<div class="faq">
<details open><summary>授权如何交付?包含哪些内容?</summary><p>邮件交付授权码与部署包(单二进制 + Web 静态资源),含使用文档。团队版附内网部署指南。</p></details>
<details><summary>一年后不续费还能用吗?</summary><p>可以。授权期内下载的版本可 <b>永久使用</b>,续费获得后续升级与支持。</p></details>
<details><summary>团队版怎么计费?人员变动怎么办?</summary><p>按启用席位每年计费,5 席起。人员变动可在授权期内置换席位,无需额外费用。</p></details>
<details><summary>可以开发票吗?</summary><p>支持增值税普通发票与专票,对公转账或线上支付均可。</p></details>
<details><summary>我们能自己部署吗?安全吗?</summary><p>完全可以,这正是 OpsxAPI 的设计初衷:数据 100% 本地,服务仅监听本机回环,内网部署无外联。</p></details>
<details><summary>可以永久买断吗?</summary><p>可以。企业私有化版支持一次性买断(标价 ¥30,000,含 1 年免费升级与支持),买断版本<b>永久使用</b>,续费仅用于继续获得升级。订阅档同理:授权期内下载的版本永久可用。</p></details>
<details><summary>预算有限,有优惠空间吗?</summary><p>我们不直接降价,但提供四种「让利换增长」机制:<b>早鸟创始价</b>(前 20 席 5–7 折永久)、<b>教育 / 开源</b> 6–8 折、<b>多年预付</b> 8–9 折、<b>以案例 / 评测换折扣</b>。相比按年订阅的 SaaS,5 人以上团队通常 1–2 年即可回本;可自行部署(我们提供文档)或以案例分享换取创始价。</p></details>
<details><summary>先试用再决定可以吗?</summary><p>欢迎。工作台全部调试能力可直接体验;授权版差异主要在商业支持与升级服务。</p></details>
</div>
</div>
</section>
<!-- ===================== CTA / 联系 ===================== -->
<section class="cta" id="contact">
<div class="wrap">
<h2>把接口调试收回自己手里</h2>
<p>现在打开工作台,30 秒发出第一个请求;采购事宜邮件联系,当天回复。</p>
<div class="hero-actions center">
<a class="btn btn-inverse btn-lg" href="/" target="_blank" rel="noopener">打开工作台 ↗</a>
<a class="btn btn-outline btn-lg" href="mailto:hello@opsxapi.dev?subject=OpsxAPI%20购买咨询">hello@opsxapi.dev</a>
</div>
</div>
</section>
<!-- ===================== Footer ===================== -->
<footer class="footer">
<div class="wrap footer-grid">
<div>
<div class="brand"><span class="brand-logo"><svg width="30" height="30" viewBox="0 0 64 64" aria-label="OpsxAPI"><defs><linearGradient id="lg-site" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2563EB"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-site)"/><g stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"><path d="M17 25h26"/><path d="M37 19l6 6-6 6"/><path d="M47 39H21"/><path d="M27 33l-6 6 6 6"/></g></svg></span><span class="brand-name">OpsxAPI</span></div>
<p class="footer-desc">本地优先的全协议接口工作台。<br>数据在自己手里,才是真的效率工具。</p>
</div>
<div>
<h4>产品</h4>
<a href="#features">功能特性</a>
<a href="#pricing">定价与让利</a>
<a href="#compare">工具对比</a>
</div>
<div>
<h4>资源</h4>
<a href="/docs" target="_blank" rel="noopener">用户文档</a>
<a href="/docs/server-api" target="_blank" rel="noopener">服务端 API</a>
<a href="/docs/faq" target="_blank" rel="noopener">常见问题</a>
</div>
<div>
<h4>支持</h4>
<a href="mailto:hello@opsxapi.dev">hello@opsxapi.dev</a>
<a href="/docs/quickstart" target="_blank" rel="noopener">快速开始</a>
</div>
</div>
<div class="footer-bar">© 2026 OpsxAPI · <a href="/terms" target="_blank">服务条款</a> · 本页由单二进制内置提供(/home)</div>
</footer>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>服务条款 · OpsxAPI</title>
<meta name="description" content="OpsxAPI 服务条款:使用规范、频率限制与禁止滥用条款" />
<link rel="icon" type="image/svg+xml" href="/site/assets/favicon.svg" />
<link rel="stylesheet" href="/site/assets/site.css" />
<style>
.terms-wrap { max-width: 820px; margin: 0 auto; padding: 48px 24px 80px; }
.terms-wrap h1 { font-size: 30px; margin-bottom: 8px; }
.terms-meta { color: var(--muted, #6b7280); font-size: 13px; margin-bottom: 32px; }
.terms-wrap h2 { font-size: 18px; margin: 32px 0 12px; }
.terms-wrap p, .terms-wrap li { line-height: 1.9; font-size: 14px; color: var(--text, #374151); }
.terms-wrap ul { padding-left: 20px; }
.terms-box { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px 20px; margin: 12px 0; background: #f9fafb; }
.terms-box strong { color: #111827; }
table.limits { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 14px; }
table.limits th, table.limits td { border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }
table.limits th { background: #f9fafb; }
.back-home { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 24px; color: #2563eb; text-decoration: none; font-size: 14px; }
</style>
</head>
<body>
<main class="terms-wrap">
<a class="back-home" href="/home">← 返回官网</a>
<h1>服务条款</h1>
<div class="terms-meta">最近更新:2026 年 9 月 17 日 · 生效范围:OpsxAPI 本地服务(含官网 /docs、/home、/mock 及 /api)</div>
<h2>一、服务说明</h2>
<p>OpsxAPI 是运行在你本机的接口调试与 Mock 服务。官网(/home)、文档(/docs)、账号与云端空间(/api)及本地 Mock 服务(/mock)默认仅监听 127.0.0.1,供个人与团队内部开发联调使用。</p>
<h2>二、账号与安全</h2>
<ul>
<li>注册需提供有效邮箱,密码经加盐哈希后存储;请妥善保管凭据,退出登录即作废令牌。</li>
<li>团队成员凭邮箱邀请加入,共享空间内数据对全体成员可见;仅团队创建者可管理成员。</li>
<li>冒用他人身份注册、批量恶意注册账号的行为将被禁止。</li>
</ul>
<h2>三、频率限制</h2>
<p>为保障本地服务稳定,Mock 接口(/mock)实施以下默认限流,超限返回 <strong>HTTP 429</strong> 与 <code>Retry-After</code>:</p>
<table class="limits">
<tr><th>类别</th><th>阈值</th><th>超出行为</th></tr>
<tr><td>通用 API 请求频率</td><td>60 QPM(每分钟 60 次)或 20 QPS(每秒 20 次)</td><td>返回 429 错误</td></tr>
<tr><td>单空间 Mock 接口数</td><td>200 个</td><td>发布请求被拒绝(400)</td></tr>
<tr><td>云端空间数据体积</td><td>20 MB</td><td>保存请求被拒绝(413)</td></tr>
</table>
<h2>四、禁止滥用</h2>
<div class="terms-box">
<p><strong>若账户或使用行为存在「未经授权或滥用」情形,平台有权采取限流、暂停服务等措施。</strong>通过技术手段规避限流(如频繁更换来源地址、伪造标识等)本身即属于违规行为。</p>
</div>
<ul>
<li>不得将本地 Mock 服务长期暴露到公网对外提供流量服务,或用于任何压测 / 攻击 / 爬虫目标;</li>
<li>不得利用服务存储违法违规内容,或侵害第三方(含任何第三方接口、服务)权益;</li>
<li>不得逆向、干扰、破坏服务及其组件,或绕过任何使用限制与安全措施;</li>
<li>对违反上述条款的行为,我们有权不经通知采取限流、下线 Mock 接口、停用账号等措施。</li>
</ul>
<h2>五、数据与隐私</h2>
<ul>
<li>所有数据(账号、团队空间、Mock 接口)默认存储于本机 SQLite(data/opsxapi.db),不经任何第三方中转、不上传外部服务器;</li>
<li>AI 功能直连你自己配置的模型供应商,密钥仅存本机;</li>
<li>请自行做好数据备份(客户端支持全量备份导出);对未备份的数据丢失不承担责任。</li>
</ul>
<h2>六、免责声明</h2>
<p>本软件按「现状」提供,不承诺适销性或特定用途适用性。因不可抗力、系统维护或依本条款采取措施导致的服务中断,不视为违约。在法律允许范围内,对间接损失不承担赔偿责任。</p>
<h2>七、条款变更</h2>
<p>我们可能随版本更新修订本条款,修订后在官网公示并更新日期;继续使用即视为接受修订后的条款。</p>
<p class="terms-meta" style="margin-top:40px">如有疑问,请通过官网页脚的联系方式反馈。</p>
</main>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
package store
import (
"database/sql"
"errors"
"time"
)
type PublishedDoc struct {
ProjectID string `json:"projectId"`
Name string `json:"name"`
Token string `json:"token"`
Content string `json:"-"`
UpdatedAt int64 `json:"updatedAt"`
}
func (s *Store) PublishedDocs() ([]PublishedDoc, error) {
rows, err := s.db.Query(`SELECT project_id,name,token,updated_at FROM published_docs ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
docs := []PublishedDoc{}
for rows.Next() {
var doc PublishedDoc
if err := rows.Scan(&doc.ProjectID, &doc.Name, &doc.Token, &doc.UpdatedAt); err != nil {
return nil, err
}
docs = append(docs, doc)
}
return docs, rows.Err()
}
func (s *Store) PublishDoc(projectID, name, content string) (PublishedDoc, error) {
var token string
err := s.db.QueryRow(`SELECT token FROM published_docs WHERE project_id=?`, projectID).Scan(&token)
if errors.Is(err, sql.ErrNoRows) {
token = newMockToken()
} else if err != nil {
return PublishedDoc{}, err
}
doc := PublishedDoc{ProjectID: projectID, Name: name, Token: token, UpdatedAt: time.Now().Unix()}
_, err = s.db.Exec(`INSERT INTO published_docs(project_id,name,token,content,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(project_id) DO UPDATE SET name=excluded.name,content=excluded.content,updated_at=excluded.updated_at`, projectID, name, token, content, doc.UpdatedAt)
return doc, err
}
func (s *Store) PublishedDocByToken(token string) (PublishedDoc, error) {
var doc PublishedDoc
err := s.db.QueryRow(`SELECT project_id,name,token,content,updated_at FROM published_docs WHERE token=?`, token).Scan(&doc.ProjectID, &doc.Name, &doc.Token, &doc.Content, &doc.UpdatedAt)
return doc, err
}
func (s *Store) RevokeDoc(projectID string) error {
_, err := s.db.Exec(`DELETE FROM published_docs WHERE project_id=?`, projectID)
return err
}
+698
View File
@@ -0,0 +1,698 @@
// Package store:SQLite 轻量存储(账号 / 团队 / 云端工作区)。
// 纯 Go 驱动 modernc.org/sqlite,无需 cgo;库文件默认 ./data/opsxapi.db。
package store
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"opsxapi/server/internal/mocksrv"
_ "modernc.org/sqlite"
)
var ErrNotFound = errors.New("not found")
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
if path == "" {
path = "data/opsxapi.db"
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // sqlite 单写者,避免锁竞争
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS mock_service (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1);
INSERT OR IGNORE INTO mock_service(id,enabled) VALUES(1,1);
CREATE TABLE IF NOT EXISTS published_docs (project_id TEXT PRIMARY KEY, name TEXT NOT NULL, token TEXT NOT NULL UNIQUE, content TEXT NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS team_members (
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
specialty TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
PRIMARY KEY (team_id, user_id)
);
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
team_id INTEGER REFERENCES teams(id) ON DELETE CASCADE,
owner_id INTEGER NOT NULL REFERENCES users(id),
data TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
mock_token TEXT UNIQUE
);
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);
`)
if err != nil {
return err
}
// 老库补列 + 回填随机 token(幂等)
if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil {
_, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`)
}
_, _ = s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_expires_at INTEGER NOT NULL DEFAULT 0`)
_, _ = s.db.Exec(`ALTER TABLE mock_service ADD COLUMN require_bearer INTEGER NOT NULL DEFAULT 0`)
_, _ = s.db.Exec(`ALTER TABLE mock_service ADD COLUMN bearer_hash TEXT NOT NULL DEFAULT ''`)
// 老库补擅长列(幂等)
_, _ = s.db.Exec(`ALTER TABLE team_members ADD COLUMN specialty TEXT NOT NULL DEFAULT ''`)
rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`)
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
for _, id := range ids {
if _, err := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, newMockToken(), id); err != nil {
return err
}
}
return nil
}
// newMockToken 16 位随机 hex(URL 片段,不可枚举)
func newMockToken() string {
raw := make([]byte, 8)
_, _ = rand.Read(raw)
return hex.EncodeToString(raw)
}
/* ---------------- 密码 ---------------- */
func hashPassword(password string) string {
salt := make([]byte, 12)
_, _ = rand.Read(salt)
sum := sha256.Sum256(append(salt, []byte(password)...))
return hex.EncodeToString(salt) + "$" + hex.EncodeToString(sum[:])
}
func verifyPassword(password, stored string) bool {
idx := -1
for i := 0; i < len(stored); i++ {
if stored[i] == '$' {
idx = i
break
}
}
if idx <= 0 {
return false
}
salt, _ := hex.DecodeString(stored[:idx])
sum := sha256.Sum256(append(salt, []byte(password)...))
return hex.EncodeToString(sum[:]) == stored[idx+1:]
}
/* ---------------- 用户 / 令牌 ---------------- */
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func (s *Store) CreateUser(name, email, password string) (*User, error) {
res, err := s.db.Exec(
`INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`,
name, email, hashPassword(password), time.Now().Unix(),
)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
// 个人空间随账号创建
if _, err := s.db.Exec(
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,NULL,?,'',?)`,
"我的空间", id, time.Now().Unix(),
); err != nil {
return nil, err
}
return &User{ID: id, Name: name, Email: email}, nil
}
func (s *Store) UserByEmail(email string) (*User, error) {
u := &User{}
err := s.db.QueryRow(`SELECT id, name, email FROM users WHERE email = ?`, email).
Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
func (s *Store) CheckPassword(email, password string) (*User, error) {
var hash string
u := &User{}
err := s.db.QueryRow(`SELECT id, name, email, pass_hash FROM users WHERE email = ?`, email).
Scan(&u.ID, &u.Name, &u.Email, &hash)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if !verifyPassword(password, hash) {
return nil, ErrNotFound
}
return u, nil
}
func (s *Store) IssueToken(userID int64) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := hex.EncodeToString(raw)
_, err := s.db.Exec(`INSERT INTO tokens(token, user_id, created_at) VALUES(?,?,?)`, token, userID, time.Now().Unix())
return token, err
}
func (s *Store) UserByToken(token string) (*User, error) {
u := &User{}
err := s.db.QueryRow(
`SELECT u.id, u.name, u.email FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?`, token,
).Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
func (s *Store) DeleteToken(token string) error {
_, err := s.db.Exec(`DELETE FROM tokens WHERE token = ?`, token)
return err
}
/* ---------------- 团队 ---------------- */
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Owner string `json:"owner"`
}
func (s *Store) CreateTeam(ownerID int64, name string) (*Team, error) {
now := time.Now().Unix()
res, err := s.db.Exec(`INSERT INTO teams(name, owner_id, created_at) VALUES(?,?,?)`, name, ownerID, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
if _, err := s.db.Exec(`INSERT INTO team_members(team_id, user_id, role, created_at) VALUES(?,?,'owner',?)`, id, ownerID, now); err != nil {
return nil, err
}
if _, err := s.db.Exec(
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,?,?,'',?,?)`,
name, id, ownerID, now, newMockToken(),
); err != nil {
return nil, err
}
return &Team{ID: id, Name: name, Role: "owner"}, nil
}
func (s *Store) TeamsOfUser(userID int64) ([]Team, error) {
rows, err := s.db.Query(`
SELECT t.id, t.name, m.role, IFNULL((SELECT u.name FROM users u WHERE u.id = t.owner_id), '')
FROM team_members m JOIN teams t ON t.id = m.team_id
WHERE m.user_id = ? ORDER BY t.id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Team
for rows.Next() {
var t Team
if err := rows.Scan(&t.ID, &t.Name, &t.Role, &t.Owner); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// TeamMember 团队成员(含擅长标签,多个以空白或顿号分隔)
type TeamMember struct {
UserID int64 `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Specialty string `json:"specialty"`
}
// TeamMembers 团队成员列表:创建者在最前,其余按加入时间
func (s *Store) TeamMembers(teamID int64) ([]TeamMember, error) {
rows, err := s.db.Query(`
SELECT m.user_id, IFNULL(u.name, ''), IFNULL(u.email, ''), m.role, m.specialty
FROM team_members m LEFT JOIN users u ON u.id = m.user_id
WHERE m.team_id = ?
ORDER BY CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, m.created_at`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TeamMember
for rows.Next() {
var m TeamMember
if err := rows.Scan(&m.UserID, &m.Name, &m.Email, &m.Role, &m.Specialty); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// IsTeamMember 是否为团队成员(创建者亦为成员)
func (s *Store) IsTeamMember(teamID, userID int64) (bool, error) {
var one int
err := s.db.QueryRow(`SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// UpdateMemberSpecialty 更新成员擅长(须先确认调用者是创建者或本人)
func (s *Store) UpdateMemberSpecialty(teamID, userID int64, specialty string) error {
res, err := s.db.Exec(`UPDATE team_members SET specialty = ? WHERE team_id = ? AND user_id = ?`, specialty, teamID, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) AddTeamMember(teamID int64, email string, specialty string) (*User, error) {
u, err := s.UserByEmail(email)
if err != nil {
return nil, err
}
_, err = s.db.Exec(
`INSERT OR IGNORE INTO team_members(team_id, user_id, role, specialty, created_at) VALUES(?,?,'member',?,?)`,
teamID, u.ID, specialty, time.Now().Unix(),
)
return u, err
}
func (s *Store) IsTeamOwner(teamID, userID int64) (bool, error) {
var one int
err := s.db.QueryRow(`SELECT 1 FROM teams WHERE id = ? AND owner_id = ?`, teamID, userID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// RenameTeam 重命名团队(权限由 API 层校验)
func (s *Store) RenameTeam(teamID int64, name string) error {
res, err := s.db.Exec(`UPDATE teams SET name = ? WHERE id = ?`, name, teamID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// DeleteTeam 删除团队:成员 / 团队空间 / 其 Mock 接口随外键级联清理
func (s *Store) DeleteTeam(teamID int64) error {
res, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
/* ---------------- 工作区(云端空间) ---------------- */
type Workspace struct {
ID int64 `json:"id"`
Name string `json:"name"`
TeamID *int64 `json:"teamId"`
OwnerID int64 `json:"ownerId"`
Data string `json:"data"`
UpdatedAt int64 `json:"updatedAt"`
MockToken string `json:"mockToken"`
}
// WorkspacesOfUser:个人空间 + 所在团队的空间(含个人空间自动补齐)
func (s *Store) WorkspacesOfUser(userID int64) ([]Workspace, error) {
// 确保个人空间存在(兼容老数据)
var one int
err := s.db.QueryRow(`SELECT 1 FROM workspaces WHERE owner_id = ? AND team_id IS NULL`, userID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
if _, err := s.db.Exec(
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`,
"我的空间", userID, time.Now().Unix(), newMockToken(),
); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
rows, err := s.db.Query(`
SELECT w.id, w.name, w.team_id, w.owner_id, IFNULL(w.data,''), w.updated_at, IFNULL(w.mock_token,'')
FROM workspaces w
WHERE w.owner_id = ?
OR w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
ORDER BY w.team_id IS NOT NULL, w.id`, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Workspace
for rows.Next() {
var w Workspace
if err := rows.Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
// CanAccessWorkspace:本人个人空间或其所在团队的空间
func (s *Store) CanAccessWorkspace(wsID, userID int64) (bool, error) {
var one int
err := s.db.QueryRow(`
SELECT 1 FROM workspaces w
WHERE w.id = ? AND (w.owner_id = ?
OR (w.team_id IS NOT NULL AND w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))`,
wsID, userID, userID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func (s *Store) WorkspaceByID(wsID int64) (*Workspace, error) {
w := &Workspace{}
err := s.db.QueryRow(
`SELECT id, name, team_id, owner_id, IFNULL(data,''), updated_at, IFNULL(mock_token,'') FROM workspaces WHERE id = ?`, wsID,
).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
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
}
// ResolveMockToken:mock token → workspace id(不存在 / 已过期 / 已作废 均返回 false)
func (s *Store) ResolveMockToken(token string) (int64, bool, error) {
var id int64
var expiresAt int64
err := s.db.QueryRow(
`SELECT id, IFNULL(mock_expires_at, 0) FROM workspaces WHERE mock_token = ? AND mock_token != ''`,
token,
).Scan(&id, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
if expiresAt > 0 && time.Now().Unix() >= expiresAt {
return 0, false, nil
}
return id, true, nil
}
// SetMockExpiry 设置可选 TTL(expiresAtUnix=0 表示不过期)。
func (s *Store) SetMockExpiry(workspaceID int64, expiresAtUnix int64) error {
_, err := s.db.Exec(`UPDATE workspaces SET mock_expires_at = ? WHERE id = ?`, expiresAtUnix, workspaceID)
return err
}
// RevokeMock 作废当前 mock token:轮换新 token、清零过期、清空 endpoints;旧链接立刻失效。
func (s *Store) RevokeMock(workspaceID int64) (newToken string, err error) {
newToken = newMockToken()
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE workspaces SET mock_token = ?, mock_expires_at = 0 WHERE id = ?`,
newToken, workspaceID,
); err != nil {
return "", err
}
if _, err := tx.Exec(`DELETE FROM mock_endpoints WHERE workspace_id = ?`, workspaceID); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return newToken, nil
}
const localMockWorkspaceName = "__opsxapi_local_mock__"
const localMockUserEmail = "__local_mock__@opsxapi.internal"
// ensureLocalMockOwner 系统账号,仅作 FK 锚点,不出现在登录流程。
func (s *Store) ensureLocalMockOwner() (int64, error) {
var id int64
err := s.db.QueryRow(`SELECT id FROM users WHERE email = ?`, localMockUserEmail).Scan(&id)
if err == nil {
return id, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
res, err := s.db.Exec(
`INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`,
"本机联调", localMockUserEmail, hashPassword(newMockToken()), time.Now().Unix(),
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// EnsureLocalMockWorkspace 为本机联调预留的系统空间(不绑真实用户账号)。
func (s *Store) EnsureLocalMockWorkspace() (id int64, token string, err error) {
err = s.db.QueryRow(
`SELECT id, IFNULL(mock_token,'') FROM workspaces WHERE name = ? AND team_id IS NULL LIMIT 1`,
localMockWorkspaceName,
).Scan(&id, &token)
if err == nil {
if token == "" {
token = newMockToken()
if _, e := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, token, id); e != nil {
return 0, "", e
}
}
return id, token, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, "", err
}
ownerID, err := s.ensureLocalMockOwner()
if err != nil {
return 0, "", err
}
token = newMockToken()
now := time.Now().Unix()
res, err := s.db.Exec(
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`,
localMockWorkspaceName, ownerID, now, token,
)
if err != nil {
return 0, "", err
}
id, err = res.LastInsertId()
return id, token, err
}
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)
return now, err
}
// LocalMockSnapshot returns only this device's published mock data.
func (s *Store) LocalMockSnapshot() ([]mocksrv.Endpoint, string, int64, error) {
id, token, err := s.EnsureLocalMockWorkspace()
if err != nil {
return nil, "", 0, err
}
var expires int64
if err = s.db.QueryRow(`SELECT IFNULL(mock_expires_at,0) FROM workspaces WHERE id=?`, id).Scan(&expires); err != nil {
return nil, "", 0, err
}
rows, err := s.db.Query(`SELECT method,path,status,content_type,body,delay_ms FROM mock_endpoints WHERE workspace_id=? ORDER BY path,method`, id)
if err != nil {
return nil, "", 0, err
}
defer rows.Close()
endpoints := []mocksrv.Endpoint{}
for rows.Next() {
var e mocksrv.Endpoint
if err = rows.Scan(&e.Method, &e.Path, &e.Status, &e.ContentType, &e.Body, &e.DelayMs); err != nil {
return nil, "", 0, err
}
endpoints = append(endpoints, e)
}
return endpoints, token, expires, rows.Err()
}
func (s *Store) MockEnabled() (bool, error) {
var enabled int
err := s.db.QueryRow(`SELECT enabled FROM mock_service WHERE id=1`).Scan(&enabled)
return enabled != 0, err
}
func (s *Store) SetMockEnabled(enabled bool) error {
_, err := s.db.Exec(`UPDATE mock_service SET enabled=? WHERE id=1`, enabled)
return err
}
// MockAccess returns only whether a bearer credential is configured, never its hash.
func (s *Store) MockAccess() (required, configured bool, err error) {
var enabled int
var hash string
err = s.db.QueryRow(`SELECT require_bearer,bearer_hash FROM mock_service WHERE id=1`).Scan(&enabled, &hash)
return enabled != 0, hash != "", err
}
func (s *Store) SetMockAccess(enabled bool, token string) error {
if !enabled {
_, err := s.db.Exec(`UPDATE mock_service SET require_bearer=0,bearer_hash='' WHERE id=1`)
return err
}
sum := sha256.Sum256([]byte(token))
_, err := s.db.Exec(`UPDATE mock_service SET require_bearer=1,bearer_hash=? WHERE id=1`, hex.EncodeToString(sum[:]))
return err
}
func (s *Store) CheckMockBearer(token string) (bool, error) {
var hash string
if err := s.db.QueryRow(`SELECT bearer_hash FROM mock_service WHERE id=1`).Scan(&hash); err != nil {
return false, err
}
if hash == "" || token == "" {
return false, nil
}
sum := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare([]byte(hash), []byte(hex.EncodeToString(sum[:]))) == 1, nil
}