chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)
This commit is contained in:
1 parent
a9510235bb
commit
456fa6954b
203 files changed
+18787
-48
No files matched your search
@@ -4,11 +4,16 @@ version = "0.1.0"
|
||||
description = "OpsxDB Tauri desktop shell"
|
||||
authors = ["OpsxDB"]
|
||||
edition = "2021"
|
||||
default-run = "OpsxDB"
|
||||
|
||||
[lib]
|
||||
name = "opsxdb_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "OpsxDB"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"opsxdb/server/internal/server"
|
||||
)
|
||||
|
||||
// Version 服务端版本,/health 响应携带供桌面 sidecar 版本握手。
|
||||
const Version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
addr := resolveListenAddr()
|
||||
token := resolveToken(isPublicBind(addr) || bindPublicEnv())
|
||||
server.Version = Version
|
||||
if token != "" {
|
||||
log.Printf("OpsxDB server listening on %s(已启用访问令牌)", addr)
|
||||
} else {
|
||||
log.Printf("OpsxDB server listening on %s", addr)
|
||||
}
|
||||
if err := server.NewRouter(token).Run(addr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveListenAddr:默认仅本机 127.0.0.1:8788(桌面 sidecar 同机访问)。
|
||||
// 显式对外:OPSXDB_BIND_PUBLIC=1 或 OPSXDB_ADDR=:8788 / 0.0.0.0:8788。
|
||||
func resolveListenAddr() string {
|
||||
if v := strings.TrimSpace(os.Getenv("OPSXDB_ADDR")); v != "" {
|
||||
return v
|
||||
}
|
||||
if bindPublicEnv() {
|
||||
return ":8788"
|
||||
}
|
||||
return "127.0.0.1:8788"
|
||||
}
|
||||
|
||||
func bindPublicEnv() bool {
|
||||
pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXDB_BIND_PUBLIC")))
|
||||
return pub == "1" || pub == "true" || pub == "yes"
|
||||
}
|
||||
|
||||
// isPublicBind 判断监听地址是否绑在非回环地址上。
|
||||
func isPublicBind(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if host == "" {
|
||||
return true // ":8788" 监听全部网卡
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return !ip.IsLoopback()
|
||||
}
|
||||
return host != "localhost"
|
||||
}
|
||||
|
||||
func tokenFilePath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".opsxdb", "token"), nil
|
||||
}
|
||||
|
||||
// resolveToken 生效规则:
|
||||
// - 优先 env OPSXDB_TOKEN(sidecar 拉起路径即强制 token);
|
||||
// - 否则读 ~/.opsxdb/token;
|
||||
// - force(绑公网)时仍无 token → crypto/rand 生成 32B hex,写 0600 文件并打印一次;
|
||||
// - 本机 dev(不 force)无 token → 返回空,中间件直通。
|
||||
func resolveToken(force bool) string {
|
||||
if t := strings.TrimSpace(os.Getenv("OPSXDB_TOKEN")); t != "" {
|
||||
return t
|
||||
}
|
||||
p, err := tokenFilePath()
|
||||
if err == nil {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
if t := strings.TrimSpace(string(b)); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
if !force {
|
||||
return ""
|
||||
}
|
||||
t, err := genToken()
|
||||
if err != nil {
|
||||
log.Fatalf("公网监听必须配置访问令牌,且自动生成失败: %v(请设置 OPSXDB_TOKEN)", err)
|
||||
}
|
||||
if p, err := tokenFilePath(); err == nil {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0700); err == nil {
|
||||
if err := os.WriteFile(p, []byte(t+"\n"), 0600); err != nil {
|
||||
log.Printf("写入 token 文件失败(仅本次进程有效): %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("已生成访问令牌(仅显示一次,已写入 ~/.opsxdb/token): %s", t)
|
||||
return t
|
||||
}
|
||||
|
||||
func genToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
server:
|
||||
addr: ":8788"
|
||||
proxy:
|
||||
timeout_ms: 30000
|
||||
# Environment overrides:
|
||||
# OPSXDB_ADDR=:8788
|
||||
# OPSXDB_PROXY_TIMEOUT_MS=30000
|
||||
@@ -1,64 +0,0 @@
|
||||
module opsxdb/server
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/microsoft/go-mssqldb v1.8.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/sijms/go-ora/v2 v2.8.22
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
golang.org/x/crypto v0.48.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/alicebob/miniredis/v2 v2.39.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.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.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // 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 v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
@@ -1,187 +0,0 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
|
||||
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
|
||||
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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sijms/go-ora/v2 v2.8.22 h1:3ABgRzVKxS439cEgSLjFKutIwOyhnyi4oOSBywEdOlU=
|
||||
github.com/sijms/go-ora/v2 v2.8.22/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
|
||||
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -1,89 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config AI 配置;provider: stub|openai(OpenAI 兼容协议)。
|
||||
type Config struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".opsxdb", "ai.json"), nil
|
||||
}
|
||||
|
||||
// LoadConfig 读取配置:~/.opsxdb/ai.json 优先,env(OPSXDB_AI_*)兜底逐字段补充。
|
||||
func LoadConfig() Config {
|
||||
cfg := Config{
|
||||
Provider: strings.TrimSpace(os.Getenv("OPSXDB_AI_PROVIDER")),
|
||||
BaseURL: strings.TrimSpace(os.Getenv("OPSXDB_AI_BASE_URL")),
|
||||
APIKey: strings.TrimSpace(os.Getenv("OPSXDB_AI_API_KEY")),
|
||||
Model: strings.TrimSpace(os.Getenv("OPSXDB_AI_MODEL")),
|
||||
}
|
||||
if p, err := configPath(); err == nil {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
var f Config
|
||||
if json.Unmarshal(b, &f) == nil {
|
||||
if f.Provider != "" {
|
||||
cfg.Provider = f.Provider
|
||||
}
|
||||
if f.BaseURL != "" {
|
||||
cfg.BaseURL = f.BaseURL
|
||||
}
|
||||
if f.APIKey != "" {
|
||||
cfg.APIKey = f.APIKey
|
||||
}
|
||||
if f.Model != "" {
|
||||
cfg.Model = f.Model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.Provider == "" {
|
||||
if cfg.APIKey != "" {
|
||||
cfg.Provider = "openai"
|
||||
} else {
|
||||
cfg.Provider = "stub"
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveConfig 写 ~/.opsxdb/ai.json(0600,与本机 token 同威胁模型)。
|
||||
func SaveConfig(c Config) error {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, b, 0600)
|
||||
}
|
||||
|
||||
// MaskedKey 返回脱敏后的 key 展示形式(sk-***)。
|
||||
func (c Config) MaskedKey() string {
|
||||
k := c.APIKey
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 6 {
|
||||
return "***"
|
||||
}
|
||||
return k[:3] + "***"
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
defaultOpenAIModel = "gpt-4o-mini"
|
||||
)
|
||||
|
||||
// OpenAIProvider OpenAI 兼容协议实现(DeepSeek/通义/本地 vLLM 同协议可用)。
|
||||
type OpenAIProvider struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func NewOpenAIProvider(cfg Config) *OpenAIProvider {
|
||||
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultOpenAIBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(cfg.Model)
|
||||
if model == "" {
|
||||
model = defaultOpenAIModel
|
||||
}
|
||||
return &OpenAIProvider{
|
||||
baseURL: base,
|
||||
apiKey: cfg.APIKey,
|
||||
model: model,
|
||||
hc: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string { return "openai" }
|
||||
|
||||
type chatCompletionReq struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
type chatCompletionRes struct {
|
||||
Choices []struct {
|
||||
Message Message `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Complete 单次非流式调用;ctx 全透传,HTTP 层 60s 超时兜底。
|
||||
func (p *OpenAIProvider) Complete(ctx context.Context, req ChatReq) (ChatRes, error) {
|
||||
model := p.model
|
||||
if strings.TrimSpace(req.Model) != "" {
|
||||
model = strings.TrimSpace(req.Model)
|
||||
}
|
||||
payload := chatCompletionReq{
|
||||
Model: model,
|
||||
Messages: BuildMessages(req),
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ChatRes{}, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return ChatRes{}, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
|
||||
resp, err := p.hc.Do(httpReq)
|
||||
if err != nil {
|
||||
return ChatRes{}, fmt.Errorf("AI 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return ChatRes{}, fmt.Errorf("读取 AI 响应失败: %w", err)
|
||||
}
|
||||
var out chatCompletionRes
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return ChatRes{}, fmt.Errorf("解析 AI 响应失败(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if out.Error != nil {
|
||||
return ChatRes{}, fmt.Errorf("AI 服务返回错误: %s", out.Error.Message)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ChatRes{}, fmt.Errorf("AI 服务 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return ChatRes{}, fmt.Errorf("AI 服务未返回候选结果")
|
||||
}
|
||||
content := strings.TrimSpace(out.Choices[0].Message.Content)
|
||||
return ChatRes{Content: content, SQL: extractSQL(content)}, nil
|
||||
}
|
||||
|
||||
var sqlFence = regexp.MustCompile("(?s)```sql\\s*\n(.+?)```")
|
||||
|
||||
// extractSQL 从模型回复中提取 ```sql 代码块内容。
|
||||
func extractSQL(content string) string {
|
||||
m := sqlFence.FindStringSubmatch(content)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// schemaContextCap 注入给模型的库表结构上下文长度上限。
|
||||
const schemaContextCap = 4000
|
||||
|
||||
// BuildMessages 按 action 选 system 模板装配对话;req.Schema 非空时注入库表列上下文。
|
||||
func BuildMessages(req ChatReq) []Message {
|
||||
driver := req.Driver
|
||||
if driver == "" {
|
||||
driver = "mysql"
|
||||
}
|
||||
sys := "你是资深数据库工程师助手,服务于数据库客户端 OpsxDB。回答使用中文,SQL 代码放在 ```sql 代码块中。"
|
||||
var task string
|
||||
switch strings.ToLower(req.Action) {
|
||||
case "generate", "ask":
|
||||
task = fmt.Sprintf("根据用户描述生成一条 %s 方言的 SQL。只输出可执行 SQL 与极简说明。", driver)
|
||||
case "explain":
|
||||
task = "逐部分解释用户给出的 SQL:访问路径、索引使用、潜在代价。"
|
||||
case "optimize":
|
||||
task = "审查用户给出的 SQL 并给出优化版本:索引建议、重写建议、说明理由。"
|
||||
case "fix":
|
||||
task = "根据报错信息修复用户给出的 SQL,指出错误原因并给出修正后的完整 SQL。"
|
||||
case "dialect":
|
||||
task = fmt.Sprintf("把用户给出的 SQL 转换为 %s 方言,保持语义一致。", driver)
|
||||
case "sample":
|
||||
task = "为当前库生成一条有代表性的示例查询 SQL。"
|
||||
default:
|
||||
task = "回答用户关于数据库 / SQL 的问题。"
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(sys)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(task)
|
||||
if req.Database != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n当前数据库: %s(%s)", req.Database, driver))
|
||||
}
|
||||
if s := strings.TrimSpace(req.Schema); s != "" {
|
||||
if len(s) > schemaContextCap {
|
||||
s = s[:schemaContextCap]
|
||||
}
|
||||
sb.WriteString("\n当前数据库结构:\n")
|
||||
sb.WriteString(s)
|
||||
}
|
||||
|
||||
var user strings.Builder
|
||||
if p := strings.TrimSpace(req.Prompt); p != "" {
|
||||
user.WriteString(p)
|
||||
}
|
||||
if s := strings.TrimSpace(req.SQL); s != "" {
|
||||
if user.Len() > 0 {
|
||||
user.WriteString("\n\n")
|
||||
}
|
||||
user.WriteString("SQL:\n")
|
||||
user.WriteString(s)
|
||||
}
|
||||
if e := strings.TrimSpace(req.Error); e != "" {
|
||||
user.WriteString("\n\n报错:\n")
|
||||
user.WriteString(e)
|
||||
}
|
||||
if user.Len() == 0 {
|
||||
user.WriteString("你好")
|
||||
}
|
||||
return []Message{
|
||||
{Role: "system", Content: sb.String()},
|
||||
{Role: "user", Content: user.String()},
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package ai
|
||||
|
||||
import "context"
|
||||
|
||||
// Message 对话消息(OpenAI 兼容协议)。
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatReq 在 stub 时代字段上演进,前端契约不变;新增 schema/model 可选字段。
|
||||
type ChatReq struct {
|
||||
Action string `json:"action"`
|
||||
Prompt string `json:"prompt"`
|
||||
SQL string `json:"sql"`
|
||||
Driver string `json:"driver"`
|
||||
Database string `json:"database"`
|
||||
Error string `json:"error"`
|
||||
Schema string `json:"schema,omitempty"` // 前端注入的库表列上下文
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
type ChatRes struct {
|
||||
Content string `json:"content"`
|
||||
SQL string `json:"sql,omitempty"`
|
||||
}
|
||||
|
||||
// Provider AI 提供方抽象。本轮只做非流式 Complete;
|
||||
// Stream(ctx, req) (<-chan string, error) 预留,SSE 流式留待后续版本。
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Complete(ctx context.Context, req ChatReq) (ChatRes, error)
|
||||
}
|
||||
|
||||
// Default 按当前配置选择 Provider;未配置 API Key 时回落 stub(离线占位)。
|
||||
func Default() Provider {
|
||||
cfg := LoadConfig()
|
||||
switch cfg.Provider {
|
||||
case "openai":
|
||||
if cfg.APIKey != "" {
|
||||
return NewOpenAIProvider(cfg)
|
||||
}
|
||||
}
|
||||
return stubProvider{}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// stubProvider 离线占位实现:未配置真实模型时的兜底,内容为字符串模板。
|
||||
type stubProvider struct{}
|
||||
|
||||
func (stubProvider) Name() string { return "stub" }
|
||||
|
||||
func (stubProvider) Complete(_ context.Context, req ChatReq) (ChatRes, error) {
|
||||
return chatStub(req), nil
|
||||
}
|
||||
|
||||
func chatStub(req ChatReq) ChatRes {
|
||||
driver := req.Driver
|
||||
if driver == "" {
|
||||
driver = "mysql"
|
||||
}
|
||||
db := req.Database
|
||||
if db == "" {
|
||||
db = "your_db"
|
||||
}
|
||||
sql := strings.TrimSpace(req.SQL)
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
|
||||
switch strings.ToLower(req.Action) {
|
||||
case "generate", "ask":
|
||||
q := prompt
|
||||
if q == "" {
|
||||
q = "示例查询"
|
||||
}
|
||||
gen := fmt.Sprintf("-- 根据描述生成(%s / %s)\n-- 需求: %s\nSELECT *\nFROM `%s`.some_table\nWHERE 1=1\nLIMIT 100;", driver, db, q, db)
|
||||
return ChatRes{
|
||||
Content: fmt.Sprintf("已根据「%s」生成 SQL(stub,可替换为真实模型)。\n\n```sql\n%s\n```", q, gen),
|
||||
SQL: gen,
|
||||
}
|
||||
case "explain":
|
||||
if sql == "" {
|
||||
sql = "SELECT 1"
|
||||
}
|
||||
return ChatRes{Content: fmt.Sprintf("## 解释 SQL\n\n```sql\n%s\n```\n\n- 这是一条在 **%s** 上执行的查询。\n- 建议检查 WHERE 条件是否走索引,避免 `SELECT *` 拉取过大结果集。\n- (stub 回复)", sql, driver)}
|
||||
case "optimize":
|
||||
opt := sql
|
||||
if opt == "" {
|
||||
opt = "SELECT id, name FROM users WHERE status = 1 LIMIT 100;"
|
||||
} else if strings.Contains(strings.ToUpper(opt), "SELECT *") {
|
||||
opt = strings.Replace(opt, "*", "id /* TODO: 显式列 */", 1)
|
||||
}
|
||||
opt = strings.TrimSpace(opt)
|
||||
if !strings.Contains(strings.ToUpper(opt), "LIMIT") {
|
||||
opt = opt + "\nLIMIT 100;"
|
||||
}
|
||||
return ChatRes{
|
||||
Content: fmt.Sprintf("## 优化建议\n\n1. 避免不必要的全表扫描\n2. 显式列出列名\n3. 加合理 LIMIT\n\n```sql\n%s\n```", opt),
|
||||
SQL: opt,
|
||||
}
|
||||
case "fix":
|
||||
fixed := sql
|
||||
if fixed == "" {
|
||||
fixed = "SELECT 1 AS ok;"
|
||||
}
|
||||
msg := req.Error
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return ChatRes{
|
||||
Content: fmt.Sprintf("## 修复错误\n\n原错误:`%s`\n\n尝试修正后的 SQL:\n\n```sql\n%s\n```\n\n(stub:请结合真实报错继续调整)", msg, fixed),
|
||||
SQL: fixed,
|
||||
}
|
||||
case "dialect":
|
||||
out := sql
|
||||
if out == "" {
|
||||
out = "SELECT * FROM dual"
|
||||
}
|
||||
out = strings.ReplaceAll(out, "`", "\"")
|
||||
return ChatRes{
|
||||
Content: fmt.Sprintf("## 方言转换 → PostgreSQL 风格(示例)\n\n```sql\n%s\n```", out),
|
||||
SQL: out,
|
||||
}
|
||||
case "sample":
|
||||
sample := fmt.Sprintf("SELECT id, name, created_at\nFROM `%s`.users\nORDER BY id DESC\nLIMIT 20;", db)
|
||||
return ChatRes{
|
||||
Content: fmt.Sprintf("## 样例 SQL(%s)\n\n```sql\n%s\n```", driver, sample),
|
||||
SQL: sample,
|
||||
}
|
||||
default:
|
||||
return ChatRes{Content: "未知 AI 动作。可用:ask / generate / explain / optimize / fix / dialect / sample"}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,379 +0,0 @@
|
||||
package db
|
||||
|
||||
// agent_e2e_test.go —— JDBC agent 桥端到端验证(真实 JVM + 真实 JDBC 驱动)。
|
||||
// 前置:本机 JDK、agent jar(OPSXDB_AGENT_JAR 或默认 ~/.opsxdb/agents/jdbc/)。
|
||||
// 触发:OPSXDB_E2E_JDBC=1 才运行(CI 无 JVM 时自动跳过)。
|
||||
// 覆盖:StartAgent 全链路(gson 校验/JVM 启动/握手)→ open → DDL/DML → query → 元数据。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestE2EAgentSQLiteBridge(t *testing.T) {
|
||||
if os.Getenv("OPSXDB_E2E_JDBC") != "1" {
|
||||
t.Skip("置 OPSXDB_E2E_JDBC=1 并备好 JDK 与 agent jar 后运行")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
jars := filepath.Join(home, ".opsxdb", "agents", "jdbc", "drivers", "sqlite-e2e")
|
||||
if _, err := os.Stat(filepath.Join(jars, "sqlite-jdbc-3.46.1.3.jar")); err != nil {
|
||||
t.Skipf("缺 sqlite-jdbc jar: %s", jars)
|
||||
}
|
||||
t.Setenv("OPSXDB_E2E_JARS", jars)
|
||||
|
||||
spec := AgentSpec{
|
||||
DriverKey: "e2e-sqlite",
|
||||
DriverClass: "org.sqlite.JDBC",
|
||||
URLTemplate: "jdbc:sqlite:{{.Database}}",
|
||||
JarsDirEnv: "OPSXDB_E2E_JARS",
|
||||
}
|
||||
agentDriverSpecs[spec.DriverKey] = spec // 注册进表,AgentConfigured 才能按 JarsDirEnv 解析
|
||||
t.Cleanup(func() { delete(agentDriverSpecs, spec.DriverKey) })
|
||||
cli, err := StartAgent(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("StartAgent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cli.Shutdown(context.Background()) })
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "e2e.db")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
handle := ""
|
||||
{
|
||||
var res struct {
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:sqlite:" + dbPath, "user": "", "password": ""}, &res); err != nil {
|
||||
t.Fatalf("open: %v (stderr=%s)", err, cli.stderrTail.String())
|
||||
}
|
||||
handle = res.Handle
|
||||
}
|
||||
mustExec := func(sql string) {
|
||||
t.Helper()
|
||||
var r struct {
|
||||
AffectedRows int64 `json:"affectedRows"`
|
||||
}
|
||||
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": sql, "timeoutSec": 30}, &r); err != nil {
|
||||
t.Fatalf("exec %q: %v (stderr=%s)", sql, err, cli.stderrTail.String())
|
||||
}
|
||||
}
|
||||
mustExec("CREATE TABLE demo(id INTEGER PRIMARY KEY, name TEXT)")
|
||||
mustExec("INSERT INTO demo(name) VALUES ('opsx-e2e')")
|
||||
|
||||
{
|
||||
var res struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
}
|
||||
if err := cli.call(ctx, "query", map[string]any{"handle": handle, "sql": "SELECT id, name FROM demo", "maxRows": 100, "timeoutSec": 30}, &res); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if len(res.Rows) != 1 || res.Rows[0]["name"] != "opsx-e2e" {
|
||||
t.Fatalf("rows = %+v", res.Rows)
|
||||
}
|
||||
}
|
||||
{
|
||||
var res struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
if err := cli.call(ctx, "metaTables", map[string]any{"handle": handle, "schema": ""}, &res); err != nil {
|
||||
t.Fatalf("metaTables: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, n := range res.Names {
|
||||
if n == "demo" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("metaTables 未包含 demo: %v", res.Names)
|
||||
}
|
||||
}
|
||||
{
|
||||
var res struct {
|
||||
Columns []ColumnInfo `json:"columns"`
|
||||
}
|
||||
if err := cli.call(ctx, "metaColumns", map[string]any{"handle": handle, "schema": "", "table": "demo"}, &res); err != nil {
|
||||
t.Fatalf("metaColumns: %v", err)
|
||||
}
|
||||
if len(res.Columns) != 2 {
|
||||
t.Fatalf("columns = %+v", res.Columns)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EElasticsearch —— 真实 ES 8(docker,安全关闭)+ 官方 x-pack-sql-jdbc 走 agent 桥全链路。
|
||||
// 触发:OPSXDB_E2E_ES=1;前置 ES :9200 可连、~/.opsxdb/agents/jdbc/drivers/elasticsearch/ 有官方 JDBC jar。
|
||||
func TestE2EElasticsearch(t *testing.T) {
|
||||
if os.Getenv("OPSXDB_E2E_ES") != "1" {
|
||||
t.Skip("置 OPSXDB_E2E_ES=1 并启动 ES :9200 后运行")
|
||||
}
|
||||
// 种子文档(让 SHOW TABLES / SELECT 有真实对象)
|
||||
req, _ := http.NewRequest(http.MethodPut, "http://127.0.0.1:9200/e2e_smoke/_doc/1?refresh=true", strings.NewReader(`{"name":"opsx","n":42}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Skipf("ES :9200 不可达: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
t.Fatalf("种子文档写入失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c := ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200}
|
||||
if err := TestConnection(c); err != nil {
|
||||
t.Fatalf("TestConnection: %v", err)
|
||||
}
|
||||
dbs, err := ListDatabases(context.Background(), c)
|
||||
if err != nil || len(dbs) != 1 || dbs[0] != "default" {
|
||||
t.Fatalf("ListDatabases = %v, %v", dbs, err)
|
||||
}
|
||||
cc := c
|
||||
cc.Database = "default"
|
||||
tables, err := ListTables(context.Background(), cc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTables: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, n := range tables {
|
||||
if n == "e2e_smoke" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tables 未含 e2e_smoke: %v", tables)
|
||||
}
|
||||
r := ExecSQL(context.Background(), ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200, Database: "default", SQL: "SELECT name, n FROM e2e_smoke", Limit: 100})
|
||||
if r.Error != "" {
|
||||
t.Fatalf("ExecSQL: %s (stderr 见测试日志)", r.Error)
|
||||
}
|
||||
if r.RowCount < 1 {
|
||||
t.Fatalf("rows = %+v", r.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EZooKeeper —— 真实 ZK(docker)+ OpsxZkAgent 全链路:连接/浏览/读写/删除。
|
||||
// 触发:OPSXDB_E2E_ZK=1;前置 ZK :2181、~/.opsxdb/agents/jdbc/drivers/zookeeper/ 有官方 jar。
|
||||
func TestE2EZooKeeper(t *testing.T) {
|
||||
if os.Getenv("OPSXDB_E2E_ZK") != "1" {
|
||||
t.Skip("置 OPSXDB_E2E_ZK=1 并启动 ZK :2181 后运行")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181}
|
||||
if err := TestConnection(c); err != nil {
|
||||
t.Skipf("ZK :2181 不可达: %v", err)
|
||||
}
|
||||
dbs, err := ListDatabases(ctx, c)
|
||||
if err != nil || len(dbs) == 0 {
|
||||
t.Fatalf("ListDatabases = %v, %v", dbs, err)
|
||||
}
|
||||
hasZk := false
|
||||
for _, n := range dbs {
|
||||
if n == "zookeeper" {
|
||||
hasZk = true
|
||||
}
|
||||
}
|
||||
if !hasZk {
|
||||
t.Fatalf("根下应含 zookeeper: %v", dbs)
|
||||
}
|
||||
cc := c
|
||||
cc.Database = "zookeeper"
|
||||
tables, err := ListTables(ctx, cc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTables: %v", err)
|
||||
}
|
||||
hasConfig := false
|
||||
for _, n := range tables {
|
||||
if n == "config" {
|
||||
hasConfig = true
|
||||
}
|
||||
}
|
||||
if !hasConfig {
|
||||
t.Fatalf("/zookeeper 下应含 config: %v", tables)
|
||||
}
|
||||
// 读:GET
|
||||
r := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, Database: "zookeeper", SQL: "GET /zookeeper/config", Limit: 100})
|
||||
if r.Error != "" {
|
||||
t.Fatalf("GET: %s", r.Error)
|
||||
}
|
||||
if r.RowCount != 1 || r.Rows[0]["path"] != "/zookeeper/config" {
|
||||
t.Fatalf("GET rows = %+v", r.Rows)
|
||||
}
|
||||
// 写:CREATE → GET → SET → DELETE(DELETE 无 WHERE 属危险语句,走确认通道)
|
||||
w := func(sql string) {
|
||||
t.Helper()
|
||||
res := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: sql, ConfirmDanger: true})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("%s → %s", sql, res.Error)
|
||||
}
|
||||
}
|
||||
w("CREATE /opsxe2e hello-opsx")
|
||||
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
|
||||
if r.Rows[0]["data"] != "hello-opsx" {
|
||||
t.Fatalf("CREATE 后 data = %v", r.Rows[0]["data"])
|
||||
}
|
||||
w("SET /opsxe2e updated")
|
||||
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
|
||||
if r.Rows[0]["data"] != "updated" {
|
||||
t.Fatalf("SET 后 data = %v", r.Rows[0]["data"])
|
||||
}
|
||||
// 危险语句未确认时必须先返回 NeedConfirm
|
||||
guard := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "DELETE /opsxe2e"})
|
||||
if !guard.NeedConfirm {
|
||||
t.Fatalf("DELETE 未确认应返回 NeedConfirm,got %+v", guard)
|
||||
}
|
||||
w("DELETE /opsxe2e")
|
||||
}
|
||||
|
||||
// TestE2EInfluxDB —— 真实 InfluxDB 2.7(docker)原生 HTTP 全链路:bucket/measurement/Flux 查询。
|
||||
// 触发:OPSXDB_E2E_INFLUX=1;前置 InfluxDB :8086(org opsx / token opsxdb-demo-token / bucket demo)。
|
||||
func TestE2EInfluxDB(t *testing.T) {
|
||||
if os.Getenv("OPSXDB_E2E_INFLUX") != "1" {
|
||||
t.Skip("置 OPSXDB_E2E_INFLUX=1 并启动 InfluxDB :8086 后运行")
|
||||
}
|
||||
// 种子点(行协议写入)
|
||||
wreq, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8086/api/v2/write?org=opsx&bucket=demo&precision=s",
|
||||
strings.NewReader("e2e_m,site=local v=42"))
|
||||
wreq.Header.Set("Authorization", "Token opsxdb-demo-token")
|
||||
wreq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
wresp, err := http.DefaultClient.Do(wreq)
|
||||
if err != nil {
|
||||
t.Skipf("InfluxDB :8086 不可达: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, wresp.Body)
|
||||
wresp.Body.Close()
|
||||
if wresp.StatusCode >= 400 {
|
||||
t.Fatalf("种子写入失败: HTTP %d", wresp.StatusCode)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond) // 存储引擎落盘
|
||||
|
||||
c := ConnReq{Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token"}
|
||||
if err := TestConnection(c); err != nil {
|
||||
t.Fatalf("TestConnection: %v", err)
|
||||
}
|
||||
dbs, err := ListDatabases(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDatabases: %v", err)
|
||||
}
|
||||
hasDemo := false
|
||||
for _, n := range dbs {
|
||||
if n == "demo" {
|
||||
hasDemo = true
|
||||
}
|
||||
}
|
||||
if !hasDemo {
|
||||
t.Fatalf("buckets 未含 demo: %v", dbs)
|
||||
}
|
||||
cc := c
|
||||
cc.Database = "demo"
|
||||
tables, err := ListTables(context.Background(), cc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTables: %v", err)
|
||||
}
|
||||
hasM := false
|
||||
for _, n := range tables {
|
||||
if n == "e2e_m" {
|
||||
hasM = true
|
||||
}
|
||||
}
|
||||
if !hasM {
|
||||
t.Fatalf("measurements 未含 e2e_m: %v", tables)
|
||||
}
|
||||
r := ExecSQL(context.Background(), ConnReq{
|
||||
Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token",
|
||||
SQL: `from(bucket: "demo") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "e2e_m")`,
|
||||
Limit: 100,
|
||||
})
|
||||
if r.Error != "" {
|
||||
t.Fatalf("Flux 查询: %s", r.Error)
|
||||
}
|
||||
if r.RowCount < 1 {
|
||||
t.Fatalf("rows = %+v", r.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EMongoDB —— 真实 MongoDB 7 + OpsxMongoAgent 全链路:库/集合浏览、DSL 查改增删、危险确认。
|
||||
// 触发:OPSXDB_E2E_MONGO=1;前置 MongoDB :27017、~/.opsxdb/agents/jdbc/drivers/mongodb/ 有官方 jar。
|
||||
func TestE2EMongoDB(t *testing.T) {
|
||||
if os.Getenv("OPSXDB_E2E_MONGO") != "1" {
|
||||
t.Skip("置 OPSXDB_E2E_MONGO=1 并启动 MongoDB :27017 后运行")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017}
|
||||
if err := TestConnection(c); err != nil {
|
||||
t.Skipf("MongoDB :27017 不可达: %v", err)
|
||||
}
|
||||
// 种子:插入文档(确认通道)
|
||||
seed := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.insertOne({"name":"opsx","n":7})`, ConfirmDanger: false})
|
||||
if seed.Error != "" {
|
||||
t.Fatalf("insertOne: %s", seed.Error)
|
||||
}
|
||||
dbs, err := ListDatabases(ctx, c)
|
||||
if err != nil || len(dbs) == 0 {
|
||||
t.Fatalf("ListDatabases = %v, %v", dbs, err)
|
||||
}
|
||||
hasDb := false
|
||||
for _, n := range dbs {
|
||||
if n == "opsxe2e" {
|
||||
hasDb = true
|
||||
}
|
||||
}
|
||||
if !hasDb {
|
||||
t.Fatalf("库列表未含 opsxe2e: %v", dbs)
|
||||
}
|
||||
cc := c
|
||||
cc.Database = "opsxe2e"
|
||||
tables, err := ListTables(ctx, cc)
|
||||
if err != nil || len(tables) == 0 {
|
||||
t.Fatalf("ListTables = %v, %v", tables, err)
|
||||
}
|
||||
found := false
|
||||
for _, n := range tables {
|
||||
if n == "c1" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("集合列表未含 c1: %v", tables)
|
||||
}
|
||||
cols, err := ListColumns(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", Table: "c1"})
|
||||
if err != nil || len(cols) == 0 {
|
||||
t.Fatalf("ListColumns = %v, %v", cols, err)
|
||||
}
|
||||
// 查询
|
||||
q := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.find({"name":"opsx"})`, Limit: 100})
|
||||
if q.Error != "" || q.RowCount < 1 {
|
||||
t.Fatalf("find: err=%s rows=%+v", q.Error, q.Rows)
|
||||
}
|
||||
// 危险确认:deleteMany 未确认必须先返回 NeedConfirm
|
||||
guard := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`})
|
||||
if !guard.NeedConfirm {
|
||||
t.Fatalf("deleteMany 未确认应 NeedConfirm,got %+v", guard)
|
||||
}
|
||||
// 更新 + 删除(确认通道)
|
||||
u := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.updateOne({"name":"opsx"}, {"$set":{"n":8}})`, ConfirmDanger: true})
|
||||
if u.Error != "" {
|
||||
t.Fatalf("updateOne: %s", u.Error)
|
||||
}
|
||||
d := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`, ConfirmDanger: true})
|
||||
if d.Error != "" {
|
||||
t.Fatalf("deleteMany: %s", d.Error)
|
||||
}
|
||||
cnt := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.count({"name":"opsx"})`})
|
||||
if fmt.Sprintf("%v", cnt.Rows[0]["n"]) != "0" {
|
||||
t.Fatalf("count after delete = %v", cnt.Rows[0]["n"])
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
package db
|
||||
|
||||
// agent_test.go —— JDBC agent 桥的协议层测试。
|
||||
// 用 helper process 扮演 JVM agent(TestMain 拦截 -agent-stub),
|
||||
// 无需本机安装 Java 即可验证:握手、请求/响应路由、超时、进程退出、并发串行写。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain 拦截 stub 模式:测试二进制被当成 agent 子进程启动时直接进入协议循环。
|
||||
func TestMain(m *testing.M) {
|
||||
for _, a := range os.Args {
|
||||
if a == "-agent-stub" {
|
||||
runAgentStub()
|
||||
return
|
||||
}
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// runAgentStub 扮演 OpsxJdbcAgent 的最小协议面(与 agents/jdbc/OpsxJdbcAgent.java 对应)。
|
||||
func runAgentStub() {
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
announce := agentResponse{ID: 0, OK: true}
|
||||
announce.Result = json.RawMessage(`{"agent":"opsx-agent-jdbc","version":"0.1.0","protocol":1}`)
|
||||
write := func(r agentResponse) {
|
||||
b, _ := json.Marshal(r)
|
||||
_, _ = out.WriteString(string(b) + "\n")
|
||||
out.Flush()
|
||||
}
|
||||
write(announce)
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
in.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for in.Scan() {
|
||||
var req agentRequest
|
||||
if json.Unmarshal(in.Bytes(), &req) != nil {
|
||||
continue
|
||||
}
|
||||
args := req.Args
|
||||
fail := func(msg string) agentResponse { return agentResponse{ID: req.ID, OK: false, Error: msg} }
|
||||
switch req.Op {
|
||||
case "hello":
|
||||
r := agentResponse{ID: req.ID, OK: true}
|
||||
r.Result = json.RawMessage(`{"agent":"opsx-agent-jdbc","version":"0.1.0","protocol":1}`)
|
||||
write(r)
|
||||
case "open":
|
||||
if args["url"] == "" || args["url"] == nil {
|
||||
write(fail("missing url"))
|
||||
break
|
||||
}
|
||||
r := agentResponse{ID: req.ID, OK: true}
|
||||
r.Result = json.RawMessage(`{"handle":"h1"}`)
|
||||
write(r)
|
||||
case "ping":
|
||||
write(agentResponse{ID: req.ID, OK: true})
|
||||
case "query":
|
||||
if strings.Contains(fmt.Sprintf("%v", args["sql"]), "SLOW") {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
r := agentResponse{ID: req.ID, OK: true}
|
||||
r.Result = json.RawMessage(`{"columns":["id","name"],"rows":[{"id":"1","name":"demo"}],"truncated":false}`)
|
||||
write(r)
|
||||
case "exec":
|
||||
r := agentResponse{ID: req.ID, OK: true}
|
||||
r.Result = json.RawMessage(`{"affectedRows":3}`)
|
||||
write(r)
|
||||
case "metaDatabases":
|
||||
r := agentResponse{ID: req.ID, OK: true}
|
||||
r.Result = json.RawMessage(`{"names":["MAIN","SYS"]}`)
|
||||
write(r)
|
||||
case "close":
|
||||
write(agentResponse{ID: req.ID, OK: true})
|
||||
case "shutdown":
|
||||
write(agentResponse{ID: req.ID, OK: true})
|
||||
os.Exit(0)
|
||||
default:
|
||||
write(fail("unknown op: " + req.Op))
|
||||
}
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// TestAgentStubHelper 占位:stub 子进程以 -test.run=TestAgentStubHelper 启动时
|
||||
// 不会走到这里(TestMain 已拦截),但名字必须存在以匹配 -test.run。
|
||||
func TestAgentStubHelper(t *testing.T) {}
|
||||
|
||||
func startStubAgent(t *testing.T) *AgentClient {
|
||||
t.Helper()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestAgentStubHelper", "--", "-agent-stub")
|
||||
cli, err := StartAgentFromCmd(cmd, agentDriverSpecs["dameng"])
|
||||
if err != nil {
|
||||
t.Fatalf("StartAgentFromCmd: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cli.Shutdown(context.Background()) })
|
||||
return cli
|
||||
}
|
||||
|
||||
func TestAgentClientRoundTrip(t *testing.T) {
|
||||
cli := startStubAgent(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// open → query → exec → metaDatabases → ping
|
||||
handle := ""
|
||||
{
|
||||
var res struct {
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:dm://127.0.0.1:5236", "user": "SYSDBA"}, &res); err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if res.Handle != "h1" {
|
||||
t.Fatalf("handle = %q, want h1", res.Handle)
|
||||
}
|
||||
handle = res.Handle
|
||||
}
|
||||
{
|
||||
var res struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
}
|
||||
if err := cli.call(ctx, "query", map[string]any{"handle": handle, "sql": "SELECT 1", "maxRows": 100}, &res); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if len(res.Columns) != 2 || res.Rows[0]["name"] != "demo" {
|
||||
t.Fatalf("query result mismatch: %+v", res)
|
||||
}
|
||||
}
|
||||
{
|
||||
var res struct {
|
||||
AffectedRows int64 `json:"affectedRows"`
|
||||
}
|
||||
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": "UPDATE t SET a=1"}, &res); err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
if res.AffectedRows != 3 {
|
||||
t.Fatalf("affectedRows = %d, want 3", res.AffectedRows)
|
||||
}
|
||||
}
|
||||
{
|
||||
var res struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
if err := cli.call(ctx, "metaDatabases", map[string]any{"handle": handle}, &res); err != nil {
|
||||
t.Fatalf("metaDatabases: %v", err)
|
||||
}
|
||||
if len(res.Names) != 2 || res.Names[0] != "MAIN" {
|
||||
t.Fatalf("names = %v", res.Names)
|
||||
}
|
||||
}
|
||||
if err := cli.call(ctx, "ping", map[string]any{"handle": handle}, nil); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCallTimeout(t *testing.T) {
|
||||
cli := startStubAgent(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
var res map[string]any
|
||||
err := cli.call(ctx, "query", map[string]any{"handle": "h1", "sql": "SELECT SLOW", "maxRows": 10}, &res)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want DeadlineExceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProcessExit(t *testing.T) {
|
||||
cli := startStubAgent(t)
|
||||
cli.Kill() // 杀进程后再调用,必须报进程退出而非挂死
|
||||
var res map[string]any
|
||||
err := cli.call(context.Background(), "ping", map[string]any{"handle": "h1"}, &res)
|
||||
if err == nil {
|
||||
t.Fatal("期望进程退出错误,got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConcurrentCalls(t *testing.T) {
|
||||
cli := startStubAgent(t)
|
||||
ctx := context.Background()
|
||||
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:dm://h:5236"}, nil); err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
const n = 24
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
var res struct {
|
||||
Columns []string `json:"columns"`
|
||||
}
|
||||
errs[i] = cli.call(ctx, "query", map[string]any{"handle": "h1", "sql": "SELECT 1", "maxRows": 1}, &res)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAgentURL(t *testing.T) {
|
||||
spec := agentDriverSpecs["dameng"]
|
||||
url, err := renderAgentURL(spec, ConnReq{Host: "127.0.0.1", Port: 5236})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if url != "jdbc:dm://127.0.0.1:5236" {
|
||||
t.Fatalf("url = %q", url)
|
||||
}
|
||||
if _, err := renderAgentURL(spec, ConnReq{Host: "", Port: 5236}); err == nil {
|
||||
t.Fatal("空主机必须报错")
|
||||
}
|
||||
if _, err := renderAgentURL(spec, ConnReq{Host: "127.0.0.1", Port: 0}); err == nil {
|
||||
t.Fatal("零端口必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPinnedChecksum(t *testing.T) {
|
||||
payload := []byte("fake-gson-bytes")
|
||||
good := sha1.Sum(payload)
|
||||
cases := []struct {
|
||||
name string
|
||||
body []byte
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"校验通过", payload, hex.EncodeToString(good[:]), false},
|
||||
{"校验失败拒绝落盘", []byte("tampered"), hex.EncodeToString(good[:]), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(tc.body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
dest := t.TempDir() + "/gson.jar"
|
||||
err := downloadPinned(srv.URL, dest, tc.want)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("期望校验失败")
|
||||
}
|
||||
if !tc.wantErr {
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(dest); statErr != nil {
|
||||
t.Fatalf("文件未落盘: %v", statErr)
|
||||
}
|
||||
} else {
|
||||
if _, statErr := os.Stat(dest); statErr == nil {
|
||||
t.Fatal("校验失败时文件不应存在")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfigured(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("OPSXDB_DAMENG_JARS", dir)
|
||||
if ok, _ := AgentConfigured("dameng"); ok {
|
||||
t.Fatal("空目录不应判为已配置")
|
||||
}
|
||||
if err := os.WriteFile(dir+"/DmJdbcDriver-8.1.3.140.jar", []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := AgentConfigured("dameng"); !ok {
|
||||
t.Fatal("放置 jar 后应判为已配置")
|
||||
}
|
||||
if IsAgentDriver("mysql") {
|
||||
t.Fatal("mysql 不应是 agent 驱动")
|
||||
}
|
||||
if !IsAgentDriver("dameng") {
|
||||
t.Fatal("dameng 应是 agent 驱动")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentRuntimeOfflinePack —— 网络不可用(镜像指向无效 + sha256 不匹配)时,
|
||||
// ensureAgentRuntime 必须从离线驱动包导入 jar(pack.sh 产物)。
|
||||
func TestEnsureAgentRuntimeOfflinePack(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("OPSXDB_HOME", home)
|
||||
t.Setenv("OPSXDB_AGENT_JAR", filepath.Join(home, "agent", "opsx-agent-jdbc.jar"))
|
||||
// 网络必失败:镜像指向本地空端口 + 错误的 sha256
|
||||
t.Setenv("MAVEN_MIRROR", "http://127.0.0.1:1/none")
|
||||
badSHA := sha1.Sum([]byte("bad"))
|
||||
spec := AgentSpec{
|
||||
DriverKey: "e2e-offline",
|
||||
DriverClass: "x",
|
||||
URLTemplate: "jdbc:x://{{.Host}}:{{.Port}}",
|
||||
JarsDirEnv: "OPSXDB_OFFLINE_JARS",
|
||||
}
|
||||
agentDriverSpecs[spec.DriverKey] = spec
|
||||
t.Cleanup(func() { delete(agentDriverSpecs, spec.DriverKey) })
|
||||
agentDriverArtifacts[spec.DriverKey] = []agentArtifact{
|
||||
{"demo.jar", "com.nope:demo:1.0", hex.EncodeToString(badSHA[:])},
|
||||
}
|
||||
|
||||
// 构造离线包:manifest + 正确的 jar 内容(sha256 以包内 manifest 为准)
|
||||
jarBytes := []byte("fake-jar-content")
|
||||
files := map[string]string{"drivers/e2e-offline/demo.jar": string(jarBytes)}
|
||||
manifestFiles := []map[string]string{{"path": "drivers/e2e-offline/demo.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(jarBytes))}}
|
||||
zipPath := filepath.Join(home, "offline.zip")
|
||||
if err := writeTestZip(zipPath, manifestFiles, files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("OPSXDB_OFFLINE_ZIP", zipPath)
|
||||
|
||||
// agent jar 也在包里(确保 ensureAgentJar 的离线路径不拦路)
|
||||
if err := importFromOfflinePack(map[string]string{"pack/agent/opsx-agent-jdbc.jar": filepath.Join(home, "agent", "opsx-agent-jdbc.jar")}); err != nil {
|
||||
// agent jar 不在此包属预期——单独构造一份最小 agent 条目
|
||||
agentBytes := []byte("fake-agent")
|
||||
mf2 := []map[string]string{{"path": "agent/opsx-agent-jdbc.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(agentBytes))}}
|
||||
f2 := map[string]string{"agent/opsx-agent-jdbc.jar": string(agentBytes)}
|
||||
if werr := writeTestZip(zipPath, append(manifestFiles, mf2...), mergeMaps(files, f2)); werr != nil {
|
||||
t.Fatal(werr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureAgentRuntime(spec); err != nil {
|
||||
t.Fatalf("ensureAgentRuntime 应走离线包成功: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(home, "drivers", "e2e-offline", "demo.jar"))
|
||||
if err != nil || string(got) != string(jarBytes) {
|
||||
t.Fatalf("离线导入内容不符: %v", err)
|
||||
}
|
||||
// 负例:manifest hash 不符必须拒
|
||||
jarBytes2 := []byte("tampered")
|
||||
mf3 := []map[string]string{{"path": "drivers/e2e-offline/demo.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(jarBytes2))}}
|
||||
if err := writeTestZip(zipPath, mf3, map[string]string{"drivers/e2e-offline/demo.jar": "not-the-same-content"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Remove(filepath.Join(home, "drivers", "e2e-offline", "demo.jar"))
|
||||
if err := ensureAgentRuntime(spec); err == nil {
|
||||
t.Fatal("manifest 与实际内容不符时必须失败")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestZip(path string, manifestFiles []map[string]string, files map[string]string) error {
|
||||
zf, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zf.Close()
|
||||
zw := zip.NewWriter(zf)
|
||||
mf, err := zw.Create("pack/manifest.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"bundleVersion": 1, "files": manifestFiles})
|
||||
if _, err := mf.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
for rel, content := range files {
|
||||
w, err := zw.Create("pack/" + rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func mergeMaps(a, b map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range a {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range b {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 危险语句规则:词边界匹配,作用于 StripComments 之后的纯代码文本。
|
||||
// 词表:DROP/TRUNCATE/ALTER/GRANT/REVOKE/CREATE USER|ROLE/RENAME TABLE/
|
||||
// REPLACE INTO/LOAD DATA/INTO OUTFILE|DUMPFILE/xp_cmdshell/COPY..PROGRAM/EXEC/CALL。
|
||||
var dangerPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
desc string
|
||||
}{
|
||||
{regexp.MustCompile(`\bDROP\b`), "DROP"},
|
||||
{regexp.MustCompile(`\bTRUNCATE\b`), "TRUNCATE"},
|
||||
{regexp.MustCompile(`\bALTER\b`), "ALTER"},
|
||||
{regexp.MustCompile(`\bGRANT\b`), "GRANT"},
|
||||
{regexp.MustCompile(`\bREVOKE\b`), "REVOKE"},
|
||||
{regexp.MustCompile(`\bCREATE\s+(USER|ROLE)\b`), "CREATE USER/ROLE"},
|
||||
{regexp.MustCompile(`\bRENAME\s+TABLE\b`), "RENAME TABLE"},
|
||||
{regexp.MustCompile(`\bREPLACE\s+INTO\b`), "REPLACE INTO"},
|
||||
{regexp.MustCompile(`\bLOAD\s+DATA\b`), "LOAD DATA"},
|
||||
{regexp.MustCompile(`\bINTO\s+(OUTFILE|DUMPFILE)\b`), "INTO OUTFILE/DUMPFILE"},
|
||||
{regexp.MustCompile(`(?i)\bxp_cmdshell\b`), "xp_cmdshell"},
|
||||
{regexp.MustCompile(`\bCOPY\b[^;]*\b(FROM|TO)\s+PROGRAM\b`), "COPY FROM/TO PROGRAM"},
|
||||
}
|
||||
|
||||
var (
|
||||
reWriteNoWhere = regexp.MustCompile(`\b(DELETE|UPDATE)\b`)
|
||||
reWhere = regexp.MustCompile(`\bWHERE\b`)
|
||||
reExecCallHead = regexp.MustCompile(`^(EXEC|EXECUTE|CALL)\b`)
|
||||
)
|
||||
|
||||
// CheckDangerousSQL 服务端权威的危险语句判定(前端预检仅为 UX)。
|
||||
// 逐语句检查;注释/字符串内的关键字不计;MySQL /*! */ 可执行注释内容参与检查;
|
||||
// WITH 包裹的写操作因全文扫描 CTE 体同样命中。
|
||||
// 返回命中的危险原因清单(去重)。
|
||||
func CheckDangerousSQL(driver, sqlText string) []string {
|
||||
_ = driver // 词表当前各驱动通用,保留参数便于后续按方言细化
|
||||
seen := map[string]struct{}{}
|
||||
var hits []string
|
||||
add := func(desc, stmt string) {
|
||||
if _, ok := seen[desc]; ok {
|
||||
return
|
||||
}
|
||||
seen[desc] = struct{}{}
|
||||
hits = append(hits, desc+": "+truncateSQL(stmt, 80))
|
||||
}
|
||||
for _, stmt := range SplitStatements(sqlText) {
|
||||
code := strings.ToUpper(StripComments(stmt))
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
for _, p := range dangerPatterns {
|
||||
if p.re.MatchString(code) {
|
||||
add(p.desc, stmt)
|
||||
}
|
||||
}
|
||||
// DELETE/UPDATE 无 WHERE(含 WITH CTE 体内的写操作)
|
||||
if reWriteNoWhere.MatchString(code) && !reWhere.MatchString(code) {
|
||||
kw := reWriteNoWhere.FindString(code)
|
||||
add(kw+" 无 WHERE", stmt)
|
||||
}
|
||||
// 存储过程/动态执行仅匹配语句开头,降低误报
|
||||
if m := reExecCallHead.FindString(code); m != "" {
|
||||
add(m, stmt)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// IsDangerousSQL 兼容旧调用:命中任意危险规则即 true。
|
||||
func IsDangerousSQL(sql string) bool {
|
||||
return len(CheckDangerousSQL("", sql)) > 0
|
||||
}
|
||||
|
||||
func truncateSQL(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func stripSQLLead(sql string) string {
|
||||
s := strings.TrimSpace(sql)
|
||||
for {
|
||||
if strings.HasPrefix(s, "--") {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = strings.TrimSpace(s[i+1:])
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, "/*") {
|
||||
if i := strings.Index(s, "*/"); i >= 0 {
|
||||
s = strings.TrimSpace(s[i+2:])
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
break
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// 危险语句命中/放行矩阵,含四类历史绕过:多语句、/*! */ 可执行注释、
|
||||
// 注释吞掉 WHERE、WITH CTE 包裹写操作。
|
||||
func TestCheckDangerousSQLMatrix(t *testing.T) {
|
||||
dangerous := []string{
|
||||
"DROP TABLE users",
|
||||
"drop table users", // 大小写
|
||||
"TRUNCATE TABLE t", // TRUNCATE
|
||||
"ALTER TABLE t ADD COLUMN c int", // ALTER(误报方向维持现状)
|
||||
"GRANT ALL ON *.* TO 'u'", // GRANT
|
||||
"REVOKE ALL ON *.* FROM 'u'", // REVOKE
|
||||
"CREATE USER 'u'@'%' IDENTIFIED BY 'p'", // CREATE USER
|
||||
"CREATE ROLE app", // CREATE ROLE
|
||||
"RENAME TABLE a TO b", // RENAME TABLE
|
||||
"REPLACE INTO t VALUES (1)", // REPLACE INTO
|
||||
"LOAD DATA INFILE '/tmp/x' INTO TABLE t", // LOAD DATA
|
||||
"SELECT * INTO OUTFILE '/tmp/x' FROM t", // INTO OUTFILE
|
||||
"SELECT * INTO DUMPFILE '/tmp/x' FROM t", // INTO DUMPFILE
|
||||
"EXEC xp_cmdshell 'dir'", // xp_cmdshell
|
||||
"EXEC sp_configure 'x'", // EXEC 开头
|
||||
"CALL do_thing()", // CALL 开头
|
||||
"COPY t FROM PROGRAM 'cmd'", // COPY FROM PROGRAM
|
||||
"DELETE FROM users", // DELETE 无 WHERE
|
||||
"UPDATE users SET x=1", // UPDATE 无 WHERE
|
||||
"SELECT 1; DROP TABLE t", // 多语句绕过
|
||||
"/*!50000 DROP TABLE t*/", // MySQL 可执行注释
|
||||
"SELECT 1; /*!40101 SET @x = 1*/; DROP TABLE t", // 可执行注释 + 多语句
|
||||
"UPDATE t SET x=1 -- WHERE id=2", // 注释吞掉 WHERE
|
||||
"UPDATE t SET x=1 # WHERE id=2", // # 注释吞掉 WHERE
|
||||
"UPDATE t SET x=1 /* WHERE id=2 */", // 块注释吞掉 WHERE
|
||||
"WITH x AS (SELECT 1) DELETE FROM t", // CTE 包裹 DELETE 无 WHERE
|
||||
"WITH x AS (SELECT 1) UPDATE t SET a=1", // CTE 包裹 UPDATE 无 WHERE
|
||||
}
|
||||
for _, sql := range dangerous {
|
||||
if hits := CheckDangerousSQL("mysql", sql); len(hits) == 0 {
|
||||
t.Errorf("应命中但未命中: %q", sql)
|
||||
}
|
||||
}
|
||||
|
||||
safe := []string{
|
||||
"SELECT * FROM users",
|
||||
"SELECT * FROM users WHERE id = 1",
|
||||
"DELETE FROM users WHERE id = 1",
|
||||
"UPDATE users SET x = 1 WHERE id = 2",
|
||||
"UPDATE users SET x = 1\nWHERE id = 2",
|
||||
"INSERT INTO t VALUES (1)",
|
||||
"CREATE TABLE t (id int)",
|
||||
"SELECT 'DROP TABLE x'", // 字符串内关键字
|
||||
"SELECT * FROM t WHERE note = 'truncate it'", // 字符串内关键字
|
||||
"-- DROP TABLE t\nSELECT 1", // 注释内关键字
|
||||
"SELECT * FROM t /* DROP TABLE x */ WHERE 1=1", // 块注释内关键字
|
||||
"SELECT * FROM updated WHERE id = 1", // 词边界:updated 不算 UPDATE
|
||||
"SELECT 1; SELECT 2",
|
||||
"WITH x AS (SELECT 1) SELECT * FROM x",
|
||||
"SHOW TABLES",
|
||||
"EXPLAIN SELECT 1",
|
||||
}
|
||||
for _, sql := range safe {
|
||||
if hits := CheckDangerousSQL("mysql", sql); len(hits) > 0 {
|
||||
t.Errorf("误报: %q → %v", sql, hits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旧接口兼容:IsDangerousSQL 委托 CheckDangerousSQL。
|
||||
func TestIsDangerousSQLCompat(t *testing.T) {
|
||||
if !IsDangerousSQL("DROP TABLE users") {
|
||||
t.Fatal("drop")
|
||||
}
|
||||
if IsDangerousSQL("SELECT * FROM users") {
|
||||
t.Fatal("select")
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetDDL 返回表/视图的建表语句。mysql 族走 SHOW CREATE TABLE,sqlite 读 sqlite_master,
|
||||
// pg/mssql/oracle 用 ListColumns 元数据在 Go 侧拼装 CREATE TABLE。
|
||||
func GetDDL(ctx context.Context, c ConnReq) (string, error) {
|
||||
ctx = reqCtx(ctx)
|
||||
if c.Table == "" {
|
||||
return "", fmt.Errorf("缺少表名")
|
||||
}
|
||||
if isRedis(c.Driver) {
|
||||
return "", fmt.Errorf("该驱动不支持 DDL 查看")
|
||||
}
|
||||
// pg/mssql/oracle:复用列元数据拼装,避免再查一次库
|
||||
switch {
|
||||
case isPostgresFamily(c.Driver), isMSSQL(c.Driver), driverKey(c.Driver) == "oracle":
|
||||
cols, err := ListColumns(ctx, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildDDLFromColumns(c.Driver, c.Table, cols)
|
||||
}
|
||||
var out string
|
||||
err := withDB(c, func(db *sql.DB) error {
|
||||
var err error
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
out, err = getDDLMySQL(ctx, db, c)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
out, err = getDDLSQLite(ctx, db, c.Table)
|
||||
default:
|
||||
err = unsupportedDriverErr(c)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getDDLMySQL SHOW CREATE TABLE 第二列即 DDL(列名随对象类型变化:Create Table/Create View)。
|
||||
func getDDLMySQL(ctx context.Context, dbh *sql.DB, c ConnReq) (string, error) {
|
||||
ident := quoteMySQLIdent(c.Table)
|
||||
if c.Database != "" {
|
||||
ident = quoteMySQLIdent(c.Database) + "." + ident
|
||||
}
|
||||
rows, err := dbh.QueryContext(ctx, "SHOW CREATE TABLE "+ident)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
var name string
|
||||
var ddl sql.NullString
|
||||
if err := rows.Scan(&name, &ddl); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ddl.Valid {
|
||||
return "", fmt.Errorf("无法读取 %s 的 DDL", c.Table)
|
||||
}
|
||||
return ddl.String, nil
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", fmt.Errorf("表 %s 不存在", c.Table)
|
||||
}
|
||||
|
||||
// getDDLSQLite sqlite_master 的 sql 列即建表语句。
|
||||
func getDDLSQLite(ctx context.Context, dbh *sql.DB, table string) (string, error) {
|
||||
var ddl sql.NullString
|
||||
err := dbh.QueryRowContext(ctx, `SELECT sql FROM sqlite_master WHERE name = ?`, table).Scan(&ddl)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("表 %s 不存在", table)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ddl.Valid {
|
||||
return "", fmt.Errorf("表 %s 无 DDL 记录", table)
|
||||
}
|
||||
return ddl.String, nil
|
||||
}
|
||||
|
||||
// buildDDLFromColumns 用列元数据拼装 CREATE TABLE(类型直接用 ColumnInfo.Type)。
|
||||
func buildDDLFromColumns(driver, table string, cols []ColumnInfo) (string, error) {
|
||||
if len(cols) == 0 {
|
||||
return "", fmt.Errorf("表 %s 不存在或无列", table)
|
||||
}
|
||||
var pks []string
|
||||
for _, col := range cols {
|
||||
if col.Key == "PRI" {
|
||||
pks = append(pks, quoteIdentFor(driver, col.Name))
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "CREATE TABLE %s (\n", quoteTableNameFor(driver, table))
|
||||
for i, col := range cols {
|
||||
line := " " + quoteIdentFor(driver, col.Name) + " " + col.Type
|
||||
if !col.Nullable {
|
||||
line += " NOT NULL"
|
||||
}
|
||||
// 非最后一列、或后面还有 PRIMARY KEY 子句时需要逗号
|
||||
if i < len(cols)-1 || len(pks) > 0 {
|
||||
line += ","
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
if len(pks) > 0 {
|
||||
b.WriteString(" PRIMARY KEY (" + strings.Join(pks, ", ") + ")\n")
|
||||
}
|
||||
b.WriteString(");\n")
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newSQLiteFile 建一个 sqlite 测试库文件并执行建表/插数语句,返回 ConnReq。
|
||||
func newSQLiteFile(t *testing.T, stmts ...string) ConnReq {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range stmts {
|
||||
res := ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: path, SQL: s})
|
||||
if res.Error != "" {
|
||||
t.Fatalf("初始化语句失败 %q: %s", s, res.Error)
|
||||
}
|
||||
}
|
||||
return ConnReq{Driver: "sqlite", Database: path}
|
||||
}
|
||||
|
||||
// R2-B5:sqlite 返回 sqlite_master 中的原始建表语句。
|
||||
func TestGetDDLSQLite(t *testing.T) {
|
||||
c := newSQLiteFile(t, `CREATE TABLE t_person (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER)`)
|
||||
c.Table = "t_person"
|
||||
ddl, err := GetDDL(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ddl, "CREATE TABLE t_person") || !strings.Contains(ddl, "name TEXT") {
|
||||
t.Fatalf("DDL 内容不符: %s", ddl)
|
||||
}
|
||||
|
||||
c.Table = "no_such_table"
|
||||
if _, err := GetDDL(context.Background(), c); err == nil {
|
||||
t.Fatal("不存在的表应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDDLRedis(t *testing.T) {
|
||||
_, err := GetDDL(context.Background(), ConnReq{Driver: "redis", Table: "k"})
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("redis 应报不支持: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDDLMissingTable(t *testing.T) {
|
||||
_, err := GetDDL(context.Background(), ConnReq{Driver: "sqlite", Table: ""})
|
||||
if err == nil {
|
||||
t.Fatal("空表名应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// R2-B5:pg/mssql/oracle 族用列元数据拼装,各 family 引号不同。
|
||||
func TestBuildDDLFromColumns(t *testing.T) {
|
||||
cols := []ColumnInfo{
|
||||
{Name: "id", Type: "integer", Nullable: false, Key: "PRI"},
|
||||
{Name: "na\"me", Type: "text", Nullable: true},
|
||||
}
|
||||
// pg:双引号加倍
|
||||
ddl, err := buildDDLFromColumns("postgres", "public.t", cols)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ddl, `"public"."t"`) || !strings.Contains(ddl, `"na""me" text`) ||
|
||||
!strings.Contains(ddl, `"id" integer NOT NULL,`) || !strings.Contains(ddl, `PRIMARY KEY ("id")`) {
|
||||
t.Fatalf("pg DDL 不符:\n%s", ddl)
|
||||
}
|
||||
// mssql:中括号
|
||||
ddl, err = buildDDLFromColumns("mssql", "dbo.t", cols)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ddl, `[id] integer NOT NULL`) || !strings.Contains(ddl, `PRIMARY KEY ([id])`) {
|
||||
t.Fatalf("mssql DDL 不符:\n%s", ddl)
|
||||
}
|
||||
// oracle:原名
|
||||
ddl, err = buildDDLFromColumns("oracle", "T", cols)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ddl, `CREATE TABLE T (`) || !strings.Contains(ddl, `PRIMARY KEY (id)`) {
|
||||
t.Fatalf("oracle DDL 不符:\n%s", ddl)
|
||||
}
|
||||
// 无列报错
|
||||
if _, err := buildDDLFromColumns("postgres", "t", nil); err == nil {
|
||||
t.Fatal("无列应报错")
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package db
|
||||
|
||||
// es.go —— Elasticsearch 原生接入(HTTP + X-Pack SQL REST API)。
|
||||
//
|
||||
// 为什么不用官方 JDBC:x-pack-sql-jdbc 在 basic 许可下直接拒绝工作
|
||||
// ("current license is non-compliant for [jdbc]",实测 8.15.3),而 SQL REST API
|
||||
// 在 basic 许可可用 —— 走 net/http 零依赖接入,也免除 JVM 负担。
|
||||
//
|
||||
// 能力边界(v1,对标 SQL 工作台):
|
||||
// - 对象树:单库节点 "default" → SHOW TABLES 列出索引
|
||||
// - 查询:SQL 编辑器里的 ES SQL(SELECT/SHOW/DESC…)直发 /_sql
|
||||
// - ES SQL 只读:DDL/DML 由 ES 自身拒绝;危险语句拦截仍在本层先过一遍
|
||||
// - 分页:/_sql 为游标式,前端 offset 翻页暂不支持(首屏 fetch_size 截断)
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func isES(d string) bool { return driverKey(d) == "elasticsearch" }
|
||||
|
||||
func esBase(c ConnReq) string {
|
||||
host := strings.TrimSpace(c.Host)
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 9200
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%d", host, port)
|
||||
}
|
||||
|
||||
// esDo 发请求并在设置了用户时启用 Basic Auth(ES 开启 security 时用)。
|
||||
func esDo(ctx context.Context, c ConnReq, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, esBase(c)+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.User != "" {
|
||||
req.SetBasicAuth(c.User, c.Password)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("ES HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func esPing(c ConnReq) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
var info struct {
|
||||
Tagline string `json:"tagline"`
|
||||
}
|
||||
if err := esDo(ctx, c, http.MethodGet, "/", nil, &info); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Tagline == "" {
|
||||
return fmt.Errorf("ES 响应异常:缺少 tagline")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// esSQLResult /_sql 的 JSON 响应(format=json)。
|
||||
type esSQLResult struct {
|
||||
Columns []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"columns"`
|
||||
Rows [][]interface{} `json:"rows"`
|
||||
}
|
||||
|
||||
func esRunSQL(ctx context.Context, c ConnReq, sqlText string, fetchSize int) (ExecResult, error) {
|
||||
var r esSQLResult
|
||||
body := map[string]any{"query": sqlText}
|
||||
if fetchSize > 0 {
|
||||
body["fetch_size"] = fetchSize
|
||||
}
|
||||
if err := esDo(ctx, c, http.MethodPost, "/_sql", body, &r); err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
cols := make([]string, 0, len(r.Columns))
|
||||
for _, col := range r.Columns {
|
||||
cols = append(cols, col.Name)
|
||||
}
|
||||
rows := make([]map[string]interface{}, 0, len(r.Rows))
|
||||
for _, row := range r.Rows {
|
||||
m := make(map[string]interface{}, len(cols))
|
||||
for i, v := range row {
|
||||
if i < len(cols) {
|
||||
m[cols[i]] = v
|
||||
}
|
||||
}
|
||||
rows = append(rows, m)
|
||||
}
|
||||
return ExecResult{
|
||||
Columns: cols,
|
||||
Rows: rows,
|
||||
RowCount: len(rows),
|
||||
Truncated: fetchSize > 0 && len(rows) >= fetchSize,
|
||||
Limit: fetchSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func esListDatabases() []string { return []string{"default"} }
|
||||
|
||||
func esListTables(ctx context.Context, c ConnReq) ([]string, error) {
|
||||
res, err := esRunSQL(ctx, c, "SHOW TABLES", 10000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []string{}
|
||||
if len(res.Columns) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
// SHOW TABLES 列为 [catalog, name, type, kind]:取 "name" 列(ES 8 实测),退化为首列
|
||||
idx := 0
|
||||
for i, col := range res.Columns {
|
||||
if col == "name" {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, row := range res.Rows {
|
||||
if v, ok := row[res.Columns[idx]].(string); ok && v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func esListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
|
||||
table := strings.TrimSpace(c.Table)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("缺少表名")
|
||||
}
|
||||
res, err := esRunSQL(ctx, c, "DESCRIBE \""+strings.ReplaceAll(table, "\"", "")+"\"", 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols := []ColumnInfo{}
|
||||
for _, row := range res.Rows {
|
||||
name, _ := row["column"].(string)
|
||||
typ, _ := row["type"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, ColumnInfo{Name: name, Type: typ, Nullable: true})
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
// esExecSQL 全部按查询处理:ES SQL 只读,写语句由 ES 拒绝;危险语句在 ops.go 入口已拦。
|
||||
func esExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
|
||||
if c.Offset > 0 {
|
||||
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "ES 游标分页暂不支持翻页(仅首屏,可缩小范围重查)", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
limit := rowLimit(c)
|
||||
if limit > 10000 {
|
||||
limit = 10000
|
||||
}
|
||||
res, err := esRunSQL(ctx, c, strings.TrimSpace(c.SQL), limit)
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
res.TimeMs = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultExportLimit = 100000
|
||||
MaxExportLimit = 500000
|
||||
exportFlushRows = 512
|
||||
)
|
||||
|
||||
// ExportAllowedQuery 导出专用的只读校验:首个关键词须为 SELECT/WITH/SHOW/DESCRIBE/EXPLAIN。
|
||||
func ExportAllowedQuery(sqlText string) bool {
|
||||
s := stripSQLLead(strings.TrimSpace(sqlText))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
up := strings.ToUpper(s)
|
||||
for _, p := range []string{"SELECT", "WITH", "SHOW", "DESCRIBE", "EXPLAIN"} {
|
||||
if strings.HasPrefix(up, p+" ") || up == p || strings.HasPrefix(up, p+"\t") || strings.HasPrefix(up, p+"\n") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SelectAllSQL 表模式导出的取数 SQL:按 family 引用(支持 schema.table 多段)。
|
||||
func SelectAllSQL(driver, table string) string {
|
||||
return "SELECT * FROM " + quoteTableNameFor(driver, table)
|
||||
}
|
||||
|
||||
// ExportQuery 把查询结果按 c.Format(csv/json/insert)流式编码写入 w,避免全量载入内存。
|
||||
// limit <= 0 用默认上限 100000,硬顶 MaxExportLimit。行值经 normalize 保真(同 ExecSQL)。
|
||||
func ExportQuery(ctx context.Context, c ConnReq, w io.Writer) error {
|
||||
ctx = reqCtx(ctx)
|
||||
format := strings.ToLower(strings.TrimSpace(c.Format))
|
||||
sqlText := strings.TrimSpace(c.SQL)
|
||||
if sqlText == "" {
|
||||
return fmt.Errorf("缺少导出 SQL")
|
||||
}
|
||||
limit := c.Limit
|
||||
if limit <= 0 {
|
||||
limit = DefaultExportLimit
|
||||
}
|
||||
if limit > MaxExportLimit {
|
||||
limit = MaxExportLimit
|
||||
}
|
||||
return withDB(c, func(db *sql.DB) error {
|
||||
rows, err := db.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bw := bufio.NewWriterSize(w, 64*1024)
|
||||
enc := newExportEncoder(bw, format, c.Driver, exportTargetTable(c), cols)
|
||||
if err := enc.begin(); err != nil {
|
||||
return err
|
||||
}
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
raw := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range raw {
|
||||
ptrs[i] = &raw[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return err
|
||||
}
|
||||
vals := make([]interface{}, len(cols))
|
||||
for i := range raw {
|
||||
vals[i] = normalize(raw[i])
|
||||
}
|
||||
if err := enc.row(vals); err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
if n%exportFlushRows == 0 {
|
||||
if err := bw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if n >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := enc.end(); err != nil {
|
||||
return err
|
||||
}
|
||||
return bw.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
// exportTargetTable insert 格式的目标表名:显式 table 优先,否则用通用名。
|
||||
func exportTargetTable(c ConnReq) string {
|
||||
if strings.TrimSpace(c.Table) != "" {
|
||||
return c.Table
|
||||
}
|
||||
return "result"
|
||||
}
|
||||
|
||||
// exportEncoder 按格式把行流式编码进 buffered writer。
|
||||
type exportEncoder struct {
|
||||
bw *bufio.Writer
|
||||
format string
|
||||
driver string
|
||||
table string
|
||||
cols []string
|
||||
csvW *csv.Writer
|
||||
// jsonPending 上一行 JSON,延迟一行写出,使行间逗号紧跟行尾而非独占一行
|
||||
jsonPending string
|
||||
n int
|
||||
}
|
||||
|
||||
func newExportEncoder(bw *bufio.Writer, format, driver, table string, cols []string) *exportEncoder {
|
||||
return &exportEncoder{bw: bw, format: format, driver: driver, table: table, cols: cols}
|
||||
}
|
||||
|
||||
func (e *exportEncoder) begin() error {
|
||||
switch e.format {
|
||||
case "csv":
|
||||
e.csvW = csv.NewWriter(e.bw)
|
||||
return e.csvW.Write(e.cols)
|
||||
case "json":
|
||||
_, err := e.bw.WriteString("[\n")
|
||||
return err
|
||||
case "insert":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("不支持的导出格式: %s", e.format)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *exportEncoder) row(vals []interface{}) error {
|
||||
e.n++
|
||||
switch e.format {
|
||||
case "csv":
|
||||
rec := make([]string, len(vals))
|
||||
for i, v := range vals {
|
||||
rec[i] = csvCell(v)
|
||||
}
|
||||
return e.csvW.Write(rec)
|
||||
case "json":
|
||||
m := make(map[string]interface{}, len(e.cols))
|
||||
for i, col := range e.cols {
|
||||
m[col] = vals[i]
|
||||
}
|
||||
var rb bytes.Buffer
|
||||
if err := json.NewEncoder(&rb).Encode(m); err != nil {
|
||||
return err
|
||||
}
|
||||
line := strings.TrimSuffix(rb.String(), "\n")
|
||||
if e.jsonPending != "" {
|
||||
if _, err := e.bw.WriteString(e.jsonPending + ",\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
e.jsonPending = line
|
||||
return nil
|
||||
case "insert":
|
||||
var b strings.Builder
|
||||
b.WriteString("INSERT INTO ")
|
||||
b.WriteString(quoteTableNameFor(e.driver, e.table))
|
||||
b.WriteString(" (")
|
||||
for i, col := range e.cols {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(quoteIdentFor(e.driver, col))
|
||||
}
|
||||
b.WriteString(") VALUES (")
|
||||
for i, v := range vals {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(sqlLiteral(v))
|
||||
}
|
||||
b.WriteString(");\n")
|
||||
_, err := e.bw.WriteString(b.String())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *exportEncoder) end() error {
|
||||
switch e.format {
|
||||
case "csv":
|
||||
e.csvW.Flush()
|
||||
return e.csvW.Error()
|
||||
case "json":
|
||||
if e.jsonPending != "" {
|
||||
if _, err := e.bw.WriteString(e.jsonPending + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
e.jsonPending = ""
|
||||
}
|
||||
_, err := e.bw.WriteString("]\n")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func csvCell(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
// sqlLiteral insert 字面量:NULL/数字原样,字符串单引号 doubling。
|
||||
func sqlLiteral(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "NULL"
|
||||
case string:
|
||||
return "'" + strings.ReplaceAll(t, "'", "''") + "'"
|
||||
case bool:
|
||||
if t {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
case time.Time:
|
||||
return "'" + t.Format(time.RFC3339Nano) + "'"
|
||||
case []byte:
|
||||
return "'" + strings.ReplaceAll(string(t), "'", "''") + "'"
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newExportConn(t *testing.T) ConnReq {
|
||||
return newSQLiteFile(t,
|
||||
`CREATE TABLE t_export (id INTEGER PRIMARY KEY, name TEXT, note TEXT)`,
|
||||
`INSERT INTO t_export (id, name, note) VALUES (1, 'alice', 'hi, "q"')`,
|
||||
`INSERT INTO t_export (id, name, note) VALUES (2, 'bo''b', NULL)`,
|
||||
`INSERT INTO t_export (id, name, note) VALUES (3, 'carol', 'line1`+"\n"+`line2')`,
|
||||
)
|
||||
}
|
||||
|
||||
// R2-B7:csv 输出形状(表头 + csv 引号转义由 encoding/csv 负责)。
|
||||
func TestExportCSV(t *testing.T) {
|
||||
c := newExportConn(t)
|
||||
c.SQL = `SELECT id, name, note FROM t_export ORDER BY id`
|
||||
c.Format = "csv"
|
||||
var buf bytes.Buffer
|
||||
if err := ExportQuery(context.Background(), c, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.HasPrefix(out, "id,name,note\n") {
|
||||
t.Fatalf("csv 表头错误: %q", out)
|
||||
}
|
||||
// 含逗号/引号的字段整体加引号、内部引号 doubling
|
||||
if !strings.Contains(out, `"hi, ""q"""`) {
|
||||
t.Fatalf("csv 转义错误: %q", out)
|
||||
}
|
||||
// 单引号 doubling 是 SQL 字面量层;csv 层原样输出 'bo''b' 存好的 bo'b
|
||||
if !strings.Contains(out, "2,bo'b,") {
|
||||
t.Fatalf("csv 内容错误: %q", out)
|
||||
}
|
||||
// 多行字面量会使单条记录跨物理行,用 csv.Reader 按记录解析
|
||||
recs, err := csv.NewReader(strings.NewReader(out)).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("csv 输出应可解析: %v", err)
|
||||
}
|
||||
if len(recs) != 4 { // 表头 + 3 行
|
||||
t.Fatalf("应为表头+3 行: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportJSON(t *testing.T) {
|
||||
c := newExportConn(t)
|
||||
c.SQL = `SELECT id, name, note FROM t_export ORDER BY id`
|
||||
c.Format = "json"
|
||||
var buf bytes.Buffer
|
||||
if err := ExportQuery(context.Background(), c, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("json 输出应可解析: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("应为 3 行: %v", rows)
|
||||
}
|
||||
if rows[0]["name"] != "alice" || rows[1]["note"] != nil {
|
||||
t.Fatalf("行值错误: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportInsert(t *testing.T) {
|
||||
c := newExportConn(t)
|
||||
c.SQL = `SELECT id, name, note FROM t_export ORDER BY id`
|
||||
c.Format = "insert"
|
||||
c.Table = "t_export"
|
||||
var buf bytes.Buffer
|
||||
if err := ExportQuery(context.Background(), c, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `INSERT INTO "t_export" ("id", "name", "note") VALUES (1, 'alice', 'hi, "q"');`) {
|
||||
t.Fatalf("insert 首行错误: %q", out)
|
||||
}
|
||||
// 字符串单引号 doubling;NULL 原样
|
||||
if !strings.Contains(out, `VALUES (2, 'bo''b', NULL);`) {
|
||||
t.Fatalf("insert 字面量错误: %q", out)
|
||||
}
|
||||
// 多行字面量内换行原样保留
|
||||
if !strings.Contains(out, "line1\nline2") {
|
||||
t.Fatalf("insert 换行字面量错误: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// limit 生效(含默认上限收敛)。
|
||||
func TestExportLimit(t *testing.T) {
|
||||
c := newExportConn(t)
|
||||
c.SQL = `SELECT id FROM t_export ORDER BY id`
|
||||
c.Format = "csv"
|
||||
c.Limit = 2
|
||||
var buf bytes.Buffer
|
||||
if err := ExportQuery(context.Background(), c, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(rows) != 3 { // 表头 + 2 行
|
||||
t.Fatalf("limit=2 应只导 2 行: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportAllowedQuery(t *testing.T) {
|
||||
for _, ok := range []string{
|
||||
"SELECT * FROM t", "WITH x AS (SELECT 1) SELECT * FROM x",
|
||||
"SHOW TABLES", "DESCRIBE t", "EXPLAIN SELECT 1",
|
||||
"-- c\nSELECT 1",
|
||||
} {
|
||||
if !ExportAllowedQuery(ok) {
|
||||
t.Fatalf("应允许只读: %q", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"DELETE FROM t", "INSERT INTO t VALUES (1)", "DROP TABLE t",
|
||||
"UPDATE t SET a=1", "", " ",
|
||||
} {
|
||||
if ExportAllowedQuery(bad) {
|
||||
t.Fatalf("不应允许: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportBadFormat(t *testing.T) {
|
||||
c := newExportConn(t)
|
||||
c.SQL = "SELECT 1"
|
||||
c.Format = "xml"
|
||||
var buf bytes.Buffer
|
||||
if err := ExportQuery(context.Background(), c, &buf); err == nil {
|
||||
t.Fatal("未知格式应报错")
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package db
|
||||
|
||||
// family_e2e_test.go —— 协议兼容族真实例验收(MySQL 族:TiDB;PG 族:openGauss)。
|
||||
// 触发:OPSXDB_E2E_TIDB=1 / OPSXDB_E2E_GAUSS=1;验证族内真实例后注册表才可翻 verified。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestE2ETiDB —— 真实 TiDB(pingcap/tidb 单容器,MySQL 协议族)。
|
||||
func TestE2ETiDB(t *testing.T) {
|
||||
if os.Getenv("OPSXDB_E2E_TIDB") != "1" {
|
||||
t.Skip("置 OPSXDB_E2E_TIDB=1 并启动 TiDB :4000 后运行(docker run -p 4000:4000 pingcap/tidb:v7.5.0)")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := ConnReq{Driver: "tidb", Host: "127.0.0.1", Port: 4000, User: "root"}
|
||||
if err := TestConnection(c); err != nil {
|
||||
t.Skipf("TiDB :4000 不可达: %v", err)
|
||||
}
|
||||
dbs, err := ListDatabases(ctx, c)
|
||||
if err != nil || len(dbs) == 0 {
|
||||
t.Fatalf("ListDatabases = %v, %v", dbs, err)
|
||||
}
|
||||
// 建库建表写读
|
||||
must := func(sql string, db string) ExecResult {
|
||||
t.Helper()
|
||||
return ExecSQL(ctx, ConnReq{Driver: "tidb", Host: "127.0.0.1", Port: 4000, User: "root", Database: db, SQL: sql, ConfirmDanger: true})
|
||||
}
|
||||
if r := must("CREATE DATABASE IF NOT EXISTS opsxe2e", ""); r.Error != "" {
|
||||
t.Fatalf("create db: %s", r.Error)
|
||||
}
|
||||
if r := must("CREATE TABLE IF NOT EXISTS fam_t(id INT PRIMARY KEY, name VARCHAR(32))", "opsxe2e"); r.Error != "" {
|
||||
t.Fatalf("create table: %s", r.Error)
|
||||
}
|
||||
if r := must(`INSERT INTO fam_t VALUES (1, 'tidb-ok')`, "opsxe2e"); r.Error != "" {
|
||||
t.Fatalf("insert: %s", r.Error)
|
||||
}
|
||||
r := must("SELECT * FROM fam_t", "opsxe2e")
|
||||
if r.Error != "" || r.RowCount != 1 || r.Rows[0]["name"] != "tidb-ok" {
|
||||
t.Fatalf("select: err=%s rows=%+v", r.Error, r.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EOpenGauss —— 真实 openGauss(PG 协议族;Apple Silicon 需 --platform linux/amd64)。
|
||||
func TestE2EOpenGauss(t *testing.T) {
|
||||
if os.Getenv("OPSXDB_E2E_GAUSS") != "1" {
|
||||
t.Skip("置 OPSXDB_E2E_GAUSS=1 并启动 openGauss :5432 后运行")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := ConnReq{Driver: "gaussdb", Host: "127.0.0.1", Port: 5432, User: "omm", Password: "Demo@123", Database: "postgres"}
|
||||
if err := TestConnection(c); err != nil {
|
||||
t.Skipf("openGauss :5432 不可达: %v", err)
|
||||
}
|
||||
dbs, err := ListDatabases(ctx, c)
|
||||
if err != nil || len(dbs) == 0 {
|
||||
t.Fatalf("ListDatabases = %v, %v", dbs, err)
|
||||
}
|
||||
q := ExecSQL(ctx, ConnReq{Driver: "gaussdb", Host: "127.0.0.1", Port: 5432, User: "omm", Password: "Demo@123", Database: "postgres", SQL: "SELECT 1 AS one"})
|
||||
if q.Error != "" {
|
||||
t.Fatalf("select: %s", q.Error)
|
||||
}
|
||||
if q.RowCount != 1 {
|
||||
t.Fatalf("rows = %+v", q.Rows)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package db
|
||||
|
||||
import "strings"
|
||||
|
||||
// quoteMySQLIdent 反引号标识符转义:内部反引号加倍(“),与前端 sql.ts quoteIdent 对齐。
|
||||
func quoteMySQLIdent(s string) string {
|
||||
return "`" + strings.ReplaceAll(s, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func quotePGIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
// quoteIdentFor 按驱动族引用标识符:mysql 反引号加倍、pg/sqlite 双引号加倍、
|
||||
// mssql 中括号加倍、oracle 沿用原名(大写约定)。
|
||||
func quoteIdentFor(driver, name string) string {
|
||||
switch {
|
||||
case isMySQLFamily(driver):
|
||||
return quoteMySQLIdent(name)
|
||||
case isMSSQL(driver):
|
||||
return "[" + strings.ReplaceAll(name, "]", "]]") + "]"
|
||||
case driverKey(driver) == "oracle":
|
||||
return name
|
||||
case driverKey(driver) == "sqlite":
|
||||
return quoteSQLiteIdent(name)
|
||||
default:
|
||||
return quotePGIdent(name)
|
||||
}
|
||||
}
|
||||
|
||||
// quoteTableNameFor 引用表名(支持 schema.table 多段,oracle 除外沿用原名)。
|
||||
func quoteTableNameFor(driver, table string) string {
|
||||
if driverKey(driver) == "oracle" {
|
||||
return table
|
||||
}
|
||||
parts := strings.Split(table, ".")
|
||||
for i := range parts {
|
||||
parts[i] = quoteIdentFor(driver, parts[i])
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func splitSchemaTable(name string) (schema, table string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if i := strings.LastIndex(name, "."); i > 0 {
|
||||
return name[:i], name[i+1:]
|
||||
}
|
||||
return "", name
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// inflightCancels 在执行的请求注册表:requestId → cancel,供 /api/db/cancel 远程取消。
|
||||
// 请求完成(正常/异常)必须 UnregisterInflight,避免 map 泄漏。
|
||||
var inflightCancels = struct {
|
||||
sync.Mutex
|
||||
m map[string]context.CancelFunc
|
||||
}{m: make(map[string]context.CancelFunc)}
|
||||
|
||||
// RegisterInflight 注册一个可按 requestId 取消的在执行请求。
|
||||
// requestID 为空或 cancel 为 nil 时忽略。
|
||||
func RegisterInflight(requestID string, cancel context.CancelFunc) {
|
||||
if requestID == "" || cancel == nil {
|
||||
return
|
||||
}
|
||||
inflightCancels.Lock()
|
||||
defer inflightCancels.Unlock()
|
||||
inflightCancels.m[requestID] = cancel
|
||||
}
|
||||
|
||||
// UnregisterInflight 请求结束时注销(幂等)。
|
||||
func UnregisterInflight(requestID string) {
|
||||
inflightCancels.Lock()
|
||||
defer inflightCancels.Unlock()
|
||||
delete(inflightCancels.m, requestID)
|
||||
}
|
||||
|
||||
// CancelInflight 按 requestId 取消在执行的请求;不存在则返回 false。
|
||||
// 取消后即注销,重复取消返回 false。
|
||||
func CancelInflight(requestID string) bool {
|
||||
inflightCancels.Lock()
|
||||
cancel, ok := inflightCancels.m[requestID]
|
||||
if ok {
|
||||
delete(inflightCancels.m, requestID)
|
||||
}
|
||||
inflightCancels.Unlock()
|
||||
if ok {
|
||||
cancel()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R2-B4:注册→取消→CancelFunc 被调用;注销后 CancelInflight 返回 false。
|
||||
func TestInflightCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
const id = "req-cancel-1"
|
||||
RegisterInflight(id, cancel)
|
||||
if !CancelInflight(id) {
|
||||
t.Fatal("已注册应能取消")
|
||||
}
|
||||
if ctx.Err() != context.Canceled {
|
||||
t.Fatal("CancelFunc 应已被调用")
|
||||
}
|
||||
// 取消后即注销,重复取消返回 false
|
||||
if CancelInflight(id) {
|
||||
t.Fatal("注销后 CancelInflight 应返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInflightUnregister(t *testing.T) {
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
const id = "req-unregister-1"
|
||||
RegisterInflight(id, cancel)
|
||||
UnregisterInflight(id)
|
||||
if CancelInflight(id) {
|
||||
t.Fatal("注销后不应再取消到")
|
||||
}
|
||||
// 幂等:重复注销不 panic
|
||||
UnregisterInflight(id)
|
||||
}
|
||||
|
||||
// 空 id / nil cancel 注册应被忽略。
|
||||
func TestInflightRegisterGuards(t *testing.T) {
|
||||
RegisterInflight("", func() {})
|
||||
if CancelInflight("") {
|
||||
t.Fatal("空 requestId 不应注册成功")
|
||||
}
|
||||
RegisterInflight("req-nil-cancel", nil)
|
||||
if CancelInflight("req-nil-cancel") {
|
||||
t.Fatal("nil cancel 不应注册成功")
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package db
|
||||
|
||||
// influx.go —— InfluxDB 2.x 原生接入(HTTP API,零第三方依赖)。
|
||||
//
|
||||
// 字段映射(连接表单):
|
||||
//
|
||||
// 主机/端口 → 服务地址(默认 :8086)
|
||||
// 用户 → org(如 opsx)
|
||||
// 密码 → admin token(如 opsxdb-demo-token)
|
||||
// 库名 → bucket(如 demo)
|
||||
//
|
||||
// 能力:对象树 bucket → measurement(Flux schema.measurements);
|
||||
// SQL 编辑器直写 Flux(from(bucket: ...) |> range(...) ...),结果按注解 CSV 解析为表格。
|
||||
// Flux 只读(写走 /write 专用通道),危险语句拦截在 ops.go 入口统一过一遍。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func isInflux(d string) bool { return driverKey(d) == "influxdb" }
|
||||
|
||||
func ifxBase(c ConnReq) string {
|
||||
host := strings.TrimSpace(c.Host)
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 8086
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%d", host, port)
|
||||
}
|
||||
|
||||
func ifxToken(c ConnReq) string { return c.Password }
|
||||
|
||||
// ifxDo 统一请求:Token 鉴权 + org 查询参数。
|
||||
func ifxDo(ctx context.Context, c ConnReq, method, path string, body io.Reader, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, ifxBase(c)+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ifxToken(c) != "" {
|
||||
req.Header.Set("Authorization", "Token "+ifxToken(c))
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("InfluxDB HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ifxOrg(c ConnReq) (string, error) {
|
||||
org := strings.TrimSpace(c.User)
|
||||
if org == "" {
|
||||
return "", fmt.Errorf("InfluxDB 2.x 需要填「用户」= org 名(如 opsx)")
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func ifxPing(c ConnReq) error {
|
||||
org, err := ifxOrg(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
var out struct {
|
||||
Buckets []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"buckets"`
|
||||
}
|
||||
return ifxDo(ctx, c, http.MethodGet, "/api/v2/buckets?org="+org, nil, &out)
|
||||
}
|
||||
|
||||
func ifxListDatabases(ctx context.Context, c ConnReq) ([]string, error) {
|
||||
org, err := ifxOrg(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Buckets []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"buckets"`
|
||||
}
|
||||
if err := ifxDo(ctx, c, http.MethodGet, "/api/v2/buckets?org="+org, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := []string{}
|
||||
for _, b := range out.Buckets {
|
||||
names = append(names, b.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// ifxQueryFlux 跑 Flux 并把注解 CSV 解析为表格。
|
||||
func ifxQueryFlux(ctx context.Context, c ConnReq, flux string, maxRows int) (ExecResult, error) {
|
||||
org, err := ifxOrg(c)
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
body := map[string]string{"query": flux, "type": "flux"}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ifxBase(c)+"/api/v2/query?org="+org, strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/csv")
|
||||
if ifxToken(c) != "" {
|
||||
req.Header.Set("Authorization", "Token "+ifxToken(c))
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return ExecResult{}, fmt.Errorf("InfluxDB HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return parseAnnotatedCSV(string(raw), maxRows)
|
||||
}
|
||||
|
||||
// parseAnnotatedCSV 解析 InfluxDB 注解 CSV:
|
||||
// #group/#datatype/#default 注释行 + 空行 + header 行 + 数据行。
|
||||
func parseAnnotatedCSV(text string, maxRows int) (ExecResult, error) {
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
headerIdx := -1
|
||||
var header []string
|
||||
for i, ln := range lines {
|
||||
s := strings.TrimSpace(ln)
|
||||
if s == "" || strings.HasPrefix(s, "#") {
|
||||
continue
|
||||
}
|
||||
headerIdx = i
|
||||
header = strings.Split(ln, ",")
|
||||
break
|
||||
}
|
||||
if headerIdx < 0 {
|
||||
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}}, nil
|
||||
}
|
||||
res := ExecResult{Columns: header, Rows: []map[string]interface{}{}}
|
||||
for _, ln := range lines[headerIdx+1:] {
|
||||
if strings.TrimSpace(ln) == "" {
|
||||
continue
|
||||
}
|
||||
cells := strings.Split(ln, ",")
|
||||
row := make(map[string]interface{}, len(header))
|
||||
for i, col := range header {
|
||||
v := ""
|
||||
if i < len(cells) {
|
||||
v = cells[i]
|
||||
}
|
||||
row[col] = v
|
||||
}
|
||||
res.Rows = append(res.Rows, row)
|
||||
if len(res.Rows) >= maxRows {
|
||||
res.Truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
res.RowCount = len(res.Rows)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func ifxListTables(ctx context.Context, c ConnReq) ([]string, error) {
|
||||
bucket := strings.TrimSpace(c.Database)
|
||||
if bucket == "" {
|
||||
return nil, fmt.Errorf("InfluxDB 列表需要库名(bucket)")
|
||||
}
|
||||
flux := fmt.Sprintf("import \"influxdata/influxdb/schema\"\nschema.measurements(bucket: \"%s\")", strings.ReplaceAll(bucket, "\"", ""))
|
||||
res, err := ifxQueryFlux(ctx, c, flux, 10000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []string{}
|
||||
if len(res.Columns) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
// schema.measurements 输出列为 [,result,table,_value]:优先 _value 列,退化为最后一列
|
||||
idx := len(res.Columns) - 1
|
||||
for i, col := range res.Columns {
|
||||
if col == "_value" {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
col := res.Columns[idx]
|
||||
for _, row := range res.Rows {
|
||||
if v, ok := row[col].(string); ok && v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ifxExecSQL 编辑器内容按 Flux 处理(只读)。
|
||||
func ifxExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
|
||||
if c.Offset > 0 {
|
||||
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "InfluxDB Flux 结果暂不支持翻页(可缩小 range 重查)", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
limit := rowLimit(c)
|
||||
if limit > MaxRowLimit {
|
||||
limit = MaxRowLimit
|
||||
}
|
||||
res, err := ifxQueryFlux(ctx, c, strings.TrimSpace(c.SQL), limit)
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
res.TimeMs = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
|
||||
// ifxListColumns measurement 的字段探查:Flux schema.fieldKeys + tagKeys 合并。
|
||||
func ifxListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
|
||||
bucket := strings.TrimSpace(c.Database)
|
||||
table := strings.TrimSpace(c.Table)
|
||||
if bucket == "" || table == "" {
|
||||
return nil, fmt.Errorf("InfluxDB 列探查需要库名与 measurement")
|
||||
}
|
||||
safeB := strings.ReplaceAll(bucket, "\"", "")
|
||||
safeM := strings.ReplaceAll(table, "\"", "")
|
||||
flux := fmt.Sprintf(`import "influxdata/influxdb/schema"
|
||||
union(tables: [
|
||||
schema.fieldKeys(bucket: "%s", predicate: (r) => r._measurement == "%s"),
|
||||
schema.tagKeys(bucket: "%s", predicate: (r) => r._measurement == "%s")
|
||||
])`, safeB, safeM, safeB, safeM)
|
||||
res, err := ifxQueryFlux(ctx, c, flux, 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols := []ColumnInfo{}
|
||||
seen := map[string]bool{}
|
||||
if len(res.Columns) == 0 {
|
||||
return cols, nil
|
||||
}
|
||||
last := res.Columns[len(res.Columns)-1]
|
||||
for _, row := range res.Rows {
|
||||
name, _ := row[last].(string)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
cols = append(cols, ColumnInfo{Name: name, Type: "tag/field", Nullable: true})
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
)
|
||||
|
||||
func openMSSQL(c ConnReq) (*sql.DB, error) {
|
||||
dsn, err := mssqlDSN(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sql.Open("sqlserver", dsn)
|
||||
}
|
||||
|
||||
// mssqlDSN 拼 sqlserver:// DSN;账号密码走 url.UserPassword 编码,
|
||||
// 修复含空格 / '+' 等特殊字符密码连接失败的问题(url.QueryEscape 不适用 userinfo)。
|
||||
func mssqlDSN(c ConnReq) (string, error) {
|
||||
host := c.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 1433
|
||||
}
|
||||
user := c.User
|
||||
if user == "" {
|
||||
user = "sa"
|
||||
}
|
||||
dbName := c.Database
|
||||
if dbName == "" {
|
||||
dbName = "master"
|
||||
}
|
||||
mode := EffectiveSSLMode(c)
|
||||
q := url.Values{}
|
||||
q.Set("database", dbName)
|
||||
q.Set("connection timeout", "5")
|
||||
switch mode {
|
||||
case "disable":
|
||||
q.Set("encrypt", "disable")
|
||||
case "skip-verify":
|
||||
q.Set("encrypt", "true")
|
||||
q.Set("TrustServerCertificate", "true")
|
||||
if ca := strings.TrimSpace(c.SSLCA); ca != "" {
|
||||
q.Set("certificate", ca)
|
||||
}
|
||||
case "require", "verify-ca":
|
||||
q.Set("encrypt", "true")
|
||||
q.Set("TrustServerCertificate", "false")
|
||||
ca := strings.TrimSpace(c.SSLCA)
|
||||
if mode == "verify-ca" && ca == "" {
|
||||
return "", fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
|
||||
}
|
||||
if ca != "" {
|
||||
// go-mssqldb msdsn certificate= builds RootCAs from PEM/DER
|
||||
q.Set("certificate", ca)
|
||||
}
|
||||
if host != "" {
|
||||
q.Set("hostnameincertificate", host)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: "sqlserver",
|
||||
User: url.UserPassword(user, c.Password),
|
||||
Host: fmt.Sprintf("%s:%d", host, port),
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func listDatabasesMSSQL(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT name FROM sys.databases WHERE name NOT IN ('master','tempdb','model','msdb') ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listTablesMSSQL(ctx context.Context, db *sql.DB, database string) ([]string, error) {
|
||||
_ = database
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT TABLE_SCHEMA + '.' + TABLE_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY 1`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listColumnsMSSQL(ctx context.Context, dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||
schema, t := splitSchemaTable(table)
|
||||
if schema == "" {
|
||||
schema = "dbo"
|
||||
}
|
||||
// 关联 sys.indexes / sys.index_columns 补主键标记(命中则 Key='PRI')
|
||||
rows, err := dbh.QueryContext(ctx, `
|
||||
SELECT c.COLUMN_NAME, c.DATA_TYPE,
|
||||
ISNULL(CAST(c.CHARACTER_MAXIMUM_LENGTH AS varchar), ''),
|
||||
c.IS_NULLABLE, c.COLUMN_DEFAULT,
|
||||
CASE WHEN pk.name IS NOT NULL THEN 'PRI' ELSE '' END
|
||||
FROM INFORMATION_SCHEMA.COLUMNS c
|
||||
LEFT JOIN sys.tables t ON t.name = c.TABLE_NAME AND SCHEMA_NAME(t.schema_id) = c.TABLE_SCHEMA
|
||||
LEFT JOIN sys.indexes i ON i.object_id = t.object_id AND i.is_primary_key = 1
|
||||
LEFT JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
||||
LEFT JOIN sys.columns pk ON pk.object_id = ic.object_id AND pk.column_id = ic.column_id AND pk.name = c.COLUMN_NAME
|
||||
WHERE c.TABLE_SCHEMA = @p1 AND c.TABLE_NAME = @p2
|
||||
ORDER BY c.ORDINAL_POSITION`, schema, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var name, typ, extra, nullable, key string
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&name, &typ, &extra, &nullable, &def, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if extra != "" && extra != "-1" {
|
||||
typ = typ + "(" + extra + ")"
|
||||
}
|
||||
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES", Key: key}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func openMySQL(c ConnReq) (*sql.DB, error) {
|
||||
host := c.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 3306
|
||||
}
|
||||
user := c.User
|
||||
if user == "" {
|
||||
user = "root"
|
||||
}
|
||||
cfg := mysqldriver.NewConfig()
|
||||
cfg.User = user
|
||||
cfg.Passwd = c.Password
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
cfg.DBName = c.Database
|
||||
cfg.ParseTime = true
|
||||
cfg.Timeout = 5 * time.Second
|
||||
cfg.ReadTimeout = 30 * time.Second
|
||||
cfg.WriteTimeout = 30 * time.Second
|
||||
cfg.Loc = time.Local
|
||||
cfg.Params = map[string]string{"charset": "utf8mb4"}
|
||||
mode := EffectiveSSLMode(c)
|
||||
switch mode {
|
||||
case "disable":
|
||||
// no TLS
|
||||
case "skip-verify":
|
||||
if !hasTLSMaterial(c) {
|
||||
cfg.TLSConfig = "skip-verify"
|
||||
} else {
|
||||
name, err := registerMySQLTLS(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.TLSConfig = name
|
||||
}
|
||||
case "require", "verify-ca":
|
||||
if !hasTLSMaterial(c) && mode == "require" {
|
||||
cfg.TLSConfig = "true"
|
||||
} else {
|
||||
name, err := registerMySQLTLS(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.TLSConfig = name
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||
}
|
||||
return sql.Open("mysql", cfg.FormatDSN())
|
||||
}
|
||||
|
||||
// hasTLSMaterial 是否提供了任何 CA/客户端证书材料(路径或 PEM 内容)。
|
||||
func hasTLSMaterial(c ConnReq) bool {
|
||||
return strings.TrimSpace(c.SSLCA) != "" ||
|
||||
strings.TrimSpace(c.SSLCert) != "" ||
|
||||
strings.TrimSpace(c.SSLKey) != "" ||
|
||||
strings.TrimSpace(c.SSLCAContent) != "" ||
|
||||
strings.TrimSpace(c.SSLCertContent) != "" ||
|
||||
strings.TrimSpace(c.SSLKeyContent) != ""
|
||||
}
|
||||
|
||||
func registerMySQLTLS(c ConnReq) (string, error) {
|
||||
tlsCfg, err := tlsConfigFrom(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
return "", fmt.Errorf("SSL 已启用但未能构建 TLS 配置")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(EffectiveSSLMode(c) + "|" + c.SSLCA + "|" + c.SSLCert + "|" + c.SSLKey +
|
||||
"|" + c.SSLCAContent + "|" + c.SSLCertContent + "|" + c.SSLKeyContent + "|" + c.TLSServerName + "|" + c.Host))
|
||||
name := "opsxdb-" + hex.EncodeToString(sum[:8])
|
||||
mysqldriver.DeregisterTLSConfig(name)
|
||||
if err := mysqldriver.RegisterTLSConfig(name, tlsCfg); err != nil {
|
||||
return "", fmt.Errorf("注册 MySQL TLS 配置失败: %w", err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func listDatabasesMySQL(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, "SHOW DATABASES")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listTablesMySQL(ctx context.Context, db *sql.DB, database string) ([]string, error) {
|
||||
q := "SHOW TABLES"
|
||||
if database != "" {
|
||||
q = "SHOW TABLES FROM " + quoteMySQLIdent(database)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listColumnsMySQL(ctx context.Context, db *sql.DB, database, table string) ([]ColumnInfo, error) {
|
||||
ident := quoteMySQLIdent(table)
|
||||
if database != "" {
|
||||
ident = quoteMySQLIdent(database) + "." + ident
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, "SHOW FULL COLUMNS FROM "+ident)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var field, typ, null, key string
|
||||
var collation, extra, priv, comment sql.NullString
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&field, &typ, &collation, &null, &key, &def, &extra, &priv, &comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col := ColumnInfo{Name: field, Type: typ, Nullable: null == "YES", Key: key, Comment: comment.String}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func driverKey(d string) string {
|
||||
return strings.ToLower(strings.TrimSpace(d))
|
||||
}
|
||||
|
||||
func isMySQLFamily(d string) bool {
|
||||
switch driverKey(d) {
|
||||
case "mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPostgresFamily(d string) bool {
|
||||
switch driverKey(d) {
|
||||
case "postgresql", "postgres", "gaussdb", "opengauss", "kingbase":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMSSQL(d string) bool {
|
||||
switch driverKey(d) {
|
||||
case "sqlserver", "mssql":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Open 按驱动打开 *sql.DB
|
||||
func Open(c ConnReq) (*sql.DB, error) {
|
||||
var (
|
||||
db *sql.DB
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
db, err = openMySQL(c)
|
||||
case isPostgresFamily(c.Driver):
|
||||
db, err = openPostgres(c)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
db, err = openMSSQL(c)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
db, err = openOracle(c)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
db, err = openSQLite(c)
|
||||
default:
|
||||
return nil, fmt.Errorf("驱动「%s」尚未实现(当前支持: mysql/mariadb/tidb、postgresql、sqlserver、oracle、sqlite、redis)", c.Driver)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetConnMaxLifetime(2 * time.Minute)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute) // 防空闲连接堆积
|
||||
db.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(2)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func TestConnection(c ConnReq) error {
|
||||
if isAgentDriver(c.Driver) {
|
||||
// JDBC agent 桥的 TCP 由 JVM 侧发起,SSH 隧道(本地端口转发)对 agent 不生效,明确拒绝
|
||||
if c.SSHEnabled {
|
||||
return fmt.Errorf("%s 走 JDBC agent,暂不支持 SSH 隧道", c.Driver)
|
||||
}
|
||||
if err := agentPing(c); err != nil {
|
||||
return fmt.Errorf("数据库: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dial, tun, err := ApplySSHTunnel(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tun != nil {
|
||||
defer tun.Close()
|
||||
}
|
||||
if err := testDial(dial); err != nil {
|
||||
return fmt.Errorf("数据库: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testDial(c ConnReq) error {
|
||||
if isRedis(c.Driver) {
|
||||
return testRedis(c)
|
||||
}
|
||||
if isES(c.Driver) {
|
||||
return esPing(c)
|
||||
}
|
||||
if isInflux(c.Driver) {
|
||||
return ifxPing(c)
|
||||
}
|
||||
if isAgentDriver(c.Driver) {
|
||||
return agentPing(c)
|
||||
}
|
||||
db, err := Open(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return db.Ping()
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// withDB 统一获取/释放 *sql.DB:
|
||||
// - c.SessionID != "" → 会话内按 database 池化复用,不 Close;
|
||||
// - 否则 → ApplySSHTunnel(c) + Open + defer Close(短生命周期隧道随请求关闭)。
|
||||
func withDB(c ConnReq, fn func(*sql.DB) error) error {
|
||||
if c.SessionID != "" {
|
||||
s, err := GetSession(c.SessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := s.sqlDB(c.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(db)
|
||||
}
|
||||
dial, tun, err := ApplySSHTunnel(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tun != nil {
|
||||
defer tun.Close()
|
||||
}
|
||||
db, err := Open(dial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return fn(db)
|
||||
}
|
||||
|
||||
func unsupportedDriverErr(c ConnReq) error {
|
||||
return fmt.Errorf("不支持的驱动「%s」(当前支持: mysql/mariadb/tidb、postgresql、sqlserver、oracle、sqlite、redis;agent 桥: dameng)", c.Driver)
|
||||
}
|
||||
|
||||
func reqCtx(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func ListDatabases(ctx context.Context, c ConnReq) ([]string, error) {
|
||||
ctx = reqCtx(ctx)
|
||||
if isRedis(c.Driver) {
|
||||
return listDatabasesRedis(ctx, c)
|
||||
}
|
||||
if isES(c.Driver) {
|
||||
return esListDatabases(), nil
|
||||
}
|
||||
if isInflux(c.Driver) {
|
||||
return ifxListDatabases(ctx, c)
|
||||
}
|
||||
if isAgentDriver(c.Driver) {
|
||||
return agentListDatabases(ctx, c)
|
||||
}
|
||||
cc := c
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
cc.Database = ""
|
||||
case isPostgresFamily(c.Driver):
|
||||
if cc.Database == "" {
|
||||
cc.Database = "postgres"
|
||||
}
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
if cc.Database == "" {
|
||||
cc.Database = "master"
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
err := withDB(cc, func(db *sql.DB) error {
|
||||
var err error
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
out, err = listDatabasesMySQL(ctx, db)
|
||||
case isPostgresFamily(c.Driver):
|
||||
out, err = listDatabasesPostgres(ctx, db)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
out, err = listDatabasesMSSQL(ctx, db)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
out, err = listDatabasesOracle(ctx, db)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
out, err = listDatabasesSQLite(db)
|
||||
default:
|
||||
err = unsupportedDriverErr(c)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListTables(ctx context.Context, c ConnReq) ([]string, error) {
|
||||
ctx = reqCtx(ctx)
|
||||
if isRedis(c.Driver) {
|
||||
return listTablesRedis(ctx, c)
|
||||
}
|
||||
if isES(c.Driver) {
|
||||
return esListTables(ctx, c)
|
||||
}
|
||||
if isInflux(c.Driver) {
|
||||
return ifxListTables(ctx, c)
|
||||
}
|
||||
if isAgentDriver(c.Driver) {
|
||||
return agentListTables(ctx, c)
|
||||
}
|
||||
var out []string
|
||||
err := withDB(c, func(db *sql.DB) error {
|
||||
var err error
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
out, err = listTablesMySQL(ctx, db, c.Database)
|
||||
case isPostgresFamily(c.Driver):
|
||||
out, err = listTablesPostgres(ctx, db, c.Database)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
out, err = listTablesMSSQL(ctx, db, c.Database)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
out, err = listTablesOracle(ctx, db, c.Database)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
out, err = listTablesSQLite(ctx, db, c.Database)
|
||||
default:
|
||||
err = unsupportedDriverErr(c)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
|
||||
ctx = reqCtx(ctx)
|
||||
if c.Table == "" {
|
||||
return nil, fmt.Errorf("缺少表名")
|
||||
}
|
||||
if isRedis(c.Driver) {
|
||||
return listColumnsRedis(ctx, c, c.Table)
|
||||
}
|
||||
if isES(c.Driver) {
|
||||
return esListColumns(ctx, c)
|
||||
}
|
||||
if isInflux(c.Driver) {
|
||||
return ifxListColumns(ctx, c)
|
||||
}
|
||||
if isAgentDriver(c.Driver) {
|
||||
return agentListColumns(ctx, c)
|
||||
}
|
||||
var out []ColumnInfo
|
||||
err := withDB(c, func(db *sql.DB) error {
|
||||
var err error
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
out, err = listColumnsMySQL(ctx, db, c.Database, c.Table)
|
||||
case isPostgresFamily(c.Driver):
|
||||
out, err = listColumnsPostgres(ctx, db, c.Database, c.Table)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
out, err = listColumnsMSSQL(ctx, db, c.Database, c.Table)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
out, err = listColumnsOracle(ctx, db, c.Database, c.Table)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
out, err = listColumnsSQLite(ctx, db, c.Database, c.Table)
|
||||
default:
|
||||
err = unsupportedDriverErr(c)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rowLimit(c ConnReq) int {
|
||||
n := c.Limit
|
||||
if n <= 0 {
|
||||
n = DefaultRowLimit
|
||||
}
|
||||
if n > MaxRowLimit {
|
||||
n = MaxRowLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func errMsg(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "已取消"
|
||||
}
|
||||
// client gone / deadline tied to request cancel
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return err.Error()
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "context canceled") {
|
||||
return "已取消"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func execFail(start time.Time, err error) ExecResult {
|
||||
return ExecResult{
|
||||
Columns: []string{},
|
||||
Rows: []map[string]interface{}{},
|
||||
TimeMs: time.Since(start).Milliseconds(),
|
||||
Error: errMsg(err),
|
||||
}
|
||||
}
|
||||
|
||||
// needConfirm 构造危险语句确认响应(不执行)。
|
||||
func needConfirm(start time.Time, hits []string) ExecResult {
|
||||
return ExecResult{
|
||||
Columns: []string{},
|
||||
Rows: []map[string]interface{}{},
|
||||
TimeMs: time.Since(start).Milliseconds(),
|
||||
Error: "检测到危险语句: " + strings.Join(hits, "; "),
|
||||
NeedConfirm: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ExecSQL 执行查询 / 非查询语句。ctx 来自 HTTP 请求,客户端断开/Abort 时可取消 DB 调用。
|
||||
func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
|
||||
start := time.Now()
|
||||
ctx = reqCtx(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
if strings.TrimSpace(c.SQL) == "" {
|
||||
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "empty sql"}
|
||||
}
|
||||
if isRedis(c.Driver) {
|
||||
return execRedis(ctx, c)
|
||||
}
|
||||
// 服务端权威危险语句拦截:未确认时返回 needConfirm,不执行
|
||||
if !c.ConfirmDanger {
|
||||
dangerSQL := c.SQL
|
||||
if driverKey(c.Driver) == "mongodb" {
|
||||
// Mongo DSL 动词映射进词表:deleteMany 等无 WHERE 概念,按 DELETE/UPDATE 同等管控
|
||||
dangerSQL = mongoVerbAsSQL(dangerSQL)
|
||||
}
|
||||
if hits := CheckDangerousSQL(c.Driver, dangerSQL); len(hits) > 0 {
|
||||
return needConfirm(start, hits)
|
||||
}
|
||||
}
|
||||
if isAgentDriver(c.Driver) {
|
||||
return agentExecSQL(ctx, c, start)
|
||||
}
|
||||
if isES(c.Driver) {
|
||||
return esExecSQL(ctx, c, start)
|
||||
}
|
||||
if isInflux(c.Driver) {
|
||||
return ifxExecSQL(ctx, c, start)
|
||||
}
|
||||
var res ExecResult
|
||||
err := withDB(c, func(db *sql.DB) error {
|
||||
res = execOnDB(ctx, db, start, c)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func execOnDB(ctx context.Context, db *sql.DB, start time.Time, c ConnReq) ExecResult {
|
||||
if !isQuerySQL(c.SQL) {
|
||||
r, err := db.ExecContext(ctx, c.SQL)
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
affected, _ := r.RowsAffected()
|
||||
return ExecResult{
|
||||
Columns: []string{"affected_rows"},
|
||||
Rows: []map[string]interface{}{{"affected_rows": affected}},
|
||||
RowCount: 1,
|
||||
TimeMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(ctx, c.SQL)
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
limit := rowLimit(c)
|
||||
offset := c.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var resultRows []map[string]interface{}
|
||||
truncated := false
|
||||
skipped := 0
|
||||
for rows.Next() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
// offset 分页:先丢弃前 offset 行
|
||||
if skipped < offset {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
raw := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range raw {
|
||||
ptrs[i] = &raw[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
m := make(map[string]interface{}, len(cols))
|
||||
for i, col := range cols {
|
||||
m[col] = normalize(raw[i])
|
||||
}
|
||||
resultRows = append(resultRows, m)
|
||||
if len(resultRows) >= limit {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
if resultRows == nil {
|
||||
resultRows = []map[string]interface{}{}
|
||||
}
|
||||
return ExecResult{
|
||||
Columns: cols,
|
||||
Rows: resultRows,
|
||||
RowCount: len(resultRows),
|
||||
TimeMs: time.Since(start).Milliseconds(),
|
||||
Truncated: truncated,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
// ExplainSQL 服务端统一的执行计划入口(替代前端方言包装)。
|
||||
// mysql/pg/sqlite 走行列计划;mssql 用 SHOWPLAN_XML(钉住单连接);oracle 走 DBMS_XPLAN。
|
||||
func ExplainSQL(ctx context.Context, c ConnReq) ExplainResult {
|
||||
start := time.Now()
|
||||
ctx = reqCtx(ctx)
|
||||
sqlText := strings.TrimSpace(c.SQL)
|
||||
if sqlText == "" {
|
||||
return ExplainResult{Error: "empty sql", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
sqlText = strings.TrimSuffix(sqlText, ";")
|
||||
if isRedis(c.Driver) {
|
||||
return ExplainResult{Error: "Redis 不支持执行计划", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
if isES(c.Driver) {
|
||||
return ExplainResult{Error: "ES SQL 不支持执行计划", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
if isInflux(c.Driver) {
|
||||
return ExplainResult{Error: "InfluxDB 不支持执行计划", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
if isAgentDriver(c.Driver) {
|
||||
return agentExplain(ctx, c, sqlText)
|
||||
}
|
||||
// 危险语句不做计划
|
||||
if hits := CheckDangerousSQL(c.Driver, sqlText); len(hits) > 0 {
|
||||
return ExplainResult{Error: "检测到危险语句: " + strings.Join(hits, "; "), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
var res ExplainResult
|
||||
err := withDB(c, func(db *sql.DB) error {
|
||||
res = explainOnDB(ctx, db, c, sqlText)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ExplainResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
res.TimeMs = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
|
||||
func explainOnDB(ctx context.Context, db *sql.DB, c ConnReq, sqlText string) ExplainResult {
|
||||
fail := func(err error) ExplainResult {
|
||||
return ExplainResult{Error: errMsg(err)}
|
||||
}
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
return explainRows(ctx, db, "EXPLAIN "+sqlText)
|
||||
case isPostgresFamily(c.Driver):
|
||||
return explainRows(ctx, db, "EXPLAIN "+sqlText)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
return explainRows(ctx, db, "EXPLAIN QUERY PLAN "+sqlText)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
// SET SHOWPLAN_XML 必须与查询同连接,钉住单连接
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.ExecContext(ctx, "SET SHOWPLAN_XML ON"); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer func() { _, _ = conn.ExecContext(context.Background(), "SET SHOWPLAN_XML OFF") }()
|
||||
rows, err := conn.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var sb strings.Builder
|
||||
for rows.Next() {
|
||||
var s sql.NullString
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
sb.WriteString(s.String)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return ExplainResult{Text: sb.String()}
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
if _, err := db.ExecContext(ctx, "EXPLAIN PLAN FOR "+sqlText); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, "SELECT plan_table_output FROM TABLE(DBMS_XPLAN.DISPLAY())")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var sb strings.Builder
|
||||
for rows.Next() {
|
||||
var s sql.NullString
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
sb.WriteString(s.String)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return ExplainResult{Text: sb.String()}
|
||||
default:
|
||||
return ExplainResult{Error: errMsg(unsupportedDriverErr(c))}
|
||||
}
|
||||
}
|
||||
|
||||
// explainRows 以行列形式读取执行计划(mysql/pg/sqlite)。
|
||||
func explainRows(ctx context.Context, db *sql.DB, q string) ExplainResult {
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return ExplainResult{Error: errMsg(err)}
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return ExplainResult{Error: errMsg(err)}
|
||||
}
|
||||
out := []map[string]interface{}{}
|
||||
for rows.Next() {
|
||||
raw := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range raw {
|
||||
ptrs[i] = &raw[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return ExplainResult{Error: errMsg(err)}
|
||||
}
|
||||
m := make(map[string]interface{}, len(cols))
|
||||
for i, col := range cols {
|
||||
m[col] = normalize(raw[i])
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ExplainResult{Error: errMsg(err)}
|
||||
}
|
||||
return ExplainResult{Columns: cols, Rows: out}
|
||||
}
|
||||
|
||||
func isQuerySQL(sql string) bool {
|
||||
s := stripSQLLead(sql)
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
up := strings.ToUpper(s)
|
||||
for _, p := range []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH", "PRAGMA"} {
|
||||
if strings.HasPrefix(up, p+" ") || up == p || strings.HasPrefix(up, p+"\t") || strings.HasPrefix(up, p+"\n") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsQuerySQL(t *testing.T) {
|
||||
if !isQuerySQL("SELECT 1") {
|
||||
t.Fatal("select")
|
||||
}
|
||||
if isQuerySQL("DELETE FROM t") {
|
||||
t.Fatal("delete is not query")
|
||||
}
|
||||
if !isQuerySQL("-- hi\nSELECT 1") {
|
||||
t.Fatal("comment then select")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDangerousSQL(t *testing.T) {
|
||||
if !IsDangerousSQL("DROP TABLE users") {
|
||||
t.Fatal("drop")
|
||||
}
|
||||
if !IsDangerousSQL("DELETE FROM users") {
|
||||
t.Fatal("delete no where")
|
||||
}
|
||||
if IsDangerousSQL("DELETE FROM users WHERE id=1") {
|
||||
t.Fatal("delete with where is ok")
|
||||
}
|
||||
if IsDangerousSQL("SELECT * FROM users") {
|
||||
t.Fatal("select")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrMsgCanceled(t *testing.T) {
|
||||
if got := errMsg(context.Canceled); got != "已取消" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
ora "github.com/sijms/go-ora/v2"
|
||||
)
|
||||
|
||||
func openOracle(c ConnReq) (*sql.DB, error) {
|
||||
host := c.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 1521
|
||||
}
|
||||
user := c.User
|
||||
if user == "" {
|
||||
user = "system"
|
||||
}
|
||||
service := c.Service
|
||||
if service == "" {
|
||||
if c.Database != "" {
|
||||
service = c.Database
|
||||
} else {
|
||||
service = "XEPDB1"
|
||||
}
|
||||
}
|
||||
dsn := fmt.Sprintf("oracle://%s:%s@%s:%d/%s", url.PathEscape(user), url.PathEscape(c.Password), host, port, service)
|
||||
mode := EffectiveSSLMode(c)
|
||||
if mode != "disable" {
|
||||
q := url.Values{}
|
||||
q.Set("SSL", "true")
|
||||
if mode == "skip-verify" {
|
||||
q.Set("SSL VERIFY", "false")
|
||||
} else {
|
||||
q.Set("SSL VERIFY", "true")
|
||||
}
|
||||
dsn = dsn + "?" + q.Encode()
|
||||
}
|
||||
|
||||
tlsCfg, err := tlsConfigFrom(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tlsCfg != nil {
|
||||
connector := ora.NewConnector(dsn)
|
||||
oc, ok := connector.(*ora.OracleConnector)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("oracle: connector 类型异常")
|
||||
}
|
||||
oc.WithTLSConfig(tlsCfg)
|
||||
return sql.OpenDB(oc), nil
|
||||
}
|
||||
return sql.Open("oracle", dsn)
|
||||
}
|
||||
|
||||
func listDatabasesOracle(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
// 返回当前用户可见 schema
|
||||
rows, err := db.QueryContext(ctx, `SELECT username FROM all_users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listTablesOracle(ctx context.Context, db *sql.DB, schema string) ([]string, error) {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if schema == "" {
|
||||
rows, err = db.QueryContext(ctx, `SELECT table_name FROM user_tables ORDER BY table_name`)
|
||||
} else {
|
||||
rows, err = db.QueryContext(ctx, `SELECT table_name FROM all_tables WHERE owner = UPPER(:1) ORDER BY table_name`, schema)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listColumnsOracle(ctx context.Context, dbh *sql.DB, schema, table string) ([]ColumnInfo, error) {
|
||||
owner, t := splitSchemaTable(table)
|
||||
if owner == "" {
|
||||
owner = schema
|
||||
}
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
// 关联约束元数据补主键标记(命中则 Key='PRI');沿用既有 UPPER() 绑定约定
|
||||
if owner == "" {
|
||||
rows, err = dbh.QueryContext(ctx, `
|
||||
SELECT c.column_name, c.data_type, c.nullable, c.data_default,
|
||||
CASE WHEN acc.column_name IS NOT NULL THEN 'PRI' ELSE '' END
|
||||
FROM user_tab_columns c
|
||||
LEFT JOIN user_constraints ac ON ac.table_name = UPPER(:1) AND ac.constraint_type = 'P'
|
||||
LEFT JOIN user_cons_columns acc ON acc.constraint_name = ac.constraint_name
|
||||
AND acc.table_name = ac.table_name AND acc.column_name = c.column_name
|
||||
WHERE c.table_name = UPPER(:2)
|
||||
ORDER BY c.column_id`, t, t)
|
||||
} else {
|
||||
rows, err = dbh.QueryContext(ctx, `
|
||||
SELECT c.column_name, c.data_type, c.nullable, c.data_default,
|
||||
CASE WHEN acc.column_name IS NOT NULL THEN 'PRI' ELSE '' END
|
||||
FROM all_tab_columns c
|
||||
LEFT JOIN all_constraints ac ON ac.owner = UPPER(:1) AND ac.table_name = UPPER(:2) AND ac.constraint_type = 'P'
|
||||
LEFT JOIN all_cons_columns acc ON acc.owner = ac.owner AND acc.constraint_name = ac.constraint_name
|
||||
AND acc.column_name = c.column_name
|
||||
WHERE c.owner = UPPER(:3) AND c.table_name = UPPER(:4)
|
||||
ORDER BY c.column_id`, owner, t, owner, t)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var name, typ, nullable, key string
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&name, &typ, &nullable, &def, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "Y", Key: key}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func openPostgres(c ConnReq) (*sql.DB, error) {
|
||||
host := c.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
user := c.User
|
||||
if user == "" {
|
||||
user = "postgres"
|
||||
}
|
||||
dbName := c.Database
|
||||
if dbName == "" {
|
||||
dbName = "postgres"
|
||||
}
|
||||
mode := EffectiveSSLMode(c)
|
||||
sslmode := "disable"
|
||||
switch mode {
|
||||
case "disable":
|
||||
sslmode = "disable"
|
||||
case "require", "skip-verify":
|
||||
// libpq require ≈ encrypt without CA/hostname verify
|
||||
sslmode = "require"
|
||||
case "verify-ca":
|
||||
sslmode = "verify-ca"
|
||||
if strings.TrimSpace(c.SSLCA) == "" {
|
||||
if strings.TrimSpace(c.SSLCAContent) != "" {
|
||||
return nil, fmt.Errorf("verify-ca 模式需要 CA 证书路径(pg 驱动暂不支持证书内容,请改用 sslCA)")
|
||||
}
|
||||
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=5",
|
||||
pqEscape(host), port, pqEscape(user), pqEscape(c.Password), pqEscape(dbName), sslmode)
|
||||
if ca := strings.TrimSpace(c.SSLCA); ca != "" {
|
||||
fmt.Fprintf(&b, " sslrootcert=%s", pqEscape(ca))
|
||||
}
|
||||
if cert := strings.TrimSpace(c.SSLCert); cert != "" {
|
||||
fmt.Fprintf(&b, " sslcert=%s", pqEscape(cert))
|
||||
}
|
||||
if key := strings.TrimSpace(c.SSLKey); key != "" {
|
||||
fmt.Fprintf(&b, " sslkey=%s", pqEscape(key))
|
||||
}
|
||||
return sql.Open("pgx", b.String())
|
||||
}
|
||||
|
||||
func pqEscape(v string) string {
|
||||
if v == "" {
|
||||
return "''"
|
||||
}
|
||||
needs := strings.ContainsAny(v, " '\\")
|
||||
if !needs {
|
||||
return v
|
||||
}
|
||||
return "'" + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `'`, `\'`) + "'"
|
||||
}
|
||||
|
||||
func listDatabasesPostgres(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listTablesPostgres(ctx context.Context, db *sql.DB, database string) ([]string, error) {
|
||||
// 当前连接库下的 public 表;切库需重新 Open
|
||||
_ = database
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT CASE WHEN schemaname = 'public' THEN tablename ELSE schemaname || '.' || tablename END
|
||||
FROM pg_catalog.pg_tables
|
||||
WHERE schemaname NOT IN ('pg_catalog','information_schema')
|
||||
ORDER BY 1`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listColumnsPostgres(ctx context.Context, dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||
schema, t := splitSchemaTable(table)
|
||||
if schema == "" {
|
||||
schema = "public"
|
||||
}
|
||||
// to_regclass 解析失败(表不存在/跨 schema)返回 NULL 而非报错,主键标记自然落空
|
||||
rows, err := dbh.QueryContext(ctx, `
|
||||
SELECT c.column_name, c.data_type,
|
||||
COALESCE(c.character_maximum_length::text, c.numeric_precision::text, ''),
|
||||
c.is_nullable, c.column_default,
|
||||
CASE WHEN a.attname IS NOT NULL THEN 'PRI' ELSE '' END
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN pg_index i ON i.indrelid = to_regclass(quote_ident($1)||'.'||quote_ident($2)) AND i.indisprimary
|
||||
LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attname = c.column_name AND a.attnum = ANY(i.indkey)
|
||||
WHERE c.table_schema = $1 AND c.table_name = $2
|
||||
ORDER BY c.ordinal_position`, schema, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var name, typ, extra, nullable, key string
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&name, &typ, &extra, &nullable, &def, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if extra != "" {
|
||||
typ = typ + "(" + extra + ")"
|
||||
}
|
||||
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES", Key: key}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,164 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
func redisTestReq(t *testing.T, mr *miniredis.Miniredis) ConnReq {
|
||||
t.Helper()
|
||||
host, portStr, err := net.SplitHostPort(mr.Addr())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return ConnReq{Driver: "redis", Host: host, Port: port, Database: "0"}
|
||||
}
|
||||
|
||||
// 写命令白名单:SET EX / HSET / EXPIRE / MSET / INCR 等应直接可用。
|
||||
func TestExecRedisWriteWhitelist(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mr.Close()
|
||||
base := redisTestReq(t, mr)
|
||||
ctx := context.Background()
|
||||
|
||||
run := func(sql string) ExecResult {
|
||||
c := base
|
||||
c.SQL = sql
|
||||
return execRedis(ctx, c)
|
||||
}
|
||||
|
||||
if r := run(`SET k1 "v with space" EX 100`); r.Error != "" {
|
||||
t.Fatalf("SET EX: %s", r.Error)
|
||||
}
|
||||
if v, _ := mr.Get("k1"); v != "v with space" {
|
||||
t.Fatalf("SET 引号分组值被破坏: %q", v)
|
||||
}
|
||||
if ttl := mr.TTL("k1"); ttl != 100*time.Second {
|
||||
t.Fatalf("SET EX TTL: %v", ttl)
|
||||
}
|
||||
|
||||
if r := run("HSET h1 f1 v1 f2 v2"); r.Error != "" {
|
||||
t.Fatalf("HSET: %s", r.Error)
|
||||
}
|
||||
if mr.HGet("h1", "f2") != "v2" {
|
||||
t.Fatal("HSET 未生效")
|
||||
}
|
||||
|
||||
if r := run("EXPIRE h1 60"); r.Error != "" {
|
||||
t.Fatalf("EXPIRE: %s", r.Error)
|
||||
}
|
||||
if ttl := mr.TTL("h1"); ttl != 60*time.Second {
|
||||
t.Fatalf("EXPIRE 未生效: %v", ttl)
|
||||
}
|
||||
if r := run("PERSIST h1"); r.Error != "" {
|
||||
t.Fatalf("PERSIST: %s", r.Error)
|
||||
}
|
||||
if ttl := mr.TTL("h1"); ttl != 0 {
|
||||
t.Fatalf("PERSIST 未生效: %v", ttl)
|
||||
}
|
||||
|
||||
if r := run("MSET a 1 b 2"); r.Error != "" {
|
||||
t.Fatalf("MSET: %s", r.Error)
|
||||
}
|
||||
if r := run("MGET a b"); r.Error != "" || r.RowCount != 2 {
|
||||
t.Fatalf("MGET: %#v", r)
|
||||
}
|
||||
if r := run("INCR n1"); r.Error != "" {
|
||||
t.Fatalf("INCR: %s", r.Error)
|
||||
}
|
||||
if r := run("SADD s1 m1 m2"); r.Error != "" {
|
||||
t.Fatalf("SADD: %s", r.Error)
|
||||
}
|
||||
if r := run("ZADD z1 1.5 zm"); r.Error != "" {
|
||||
t.Fatalf("ZADD: %s", r.Error)
|
||||
}
|
||||
if r := run("LPUSH l1 x y"); r.Error != "" {
|
||||
t.Fatalf("LPUSH: %s", r.Error)
|
||||
}
|
||||
if r := run("RENAME a a2"); r.Error != "" {
|
||||
t.Fatalf("RENAME: %s", r.Error)
|
||||
}
|
||||
if !mr.Exists("a2") {
|
||||
t.Fatal("RENAME 未生效")
|
||||
}
|
||||
}
|
||||
|
||||
// 危险命令二段确认:FLUSHALL/KEYS 未确认不执行,确认后放行。
|
||||
func TestExecRedisDangerConfirm(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mr.Close()
|
||||
base := redisTestReq(t, mr)
|
||||
ctx := context.Background()
|
||||
_ = mr.Set("k", "v")
|
||||
|
||||
run := func(sql string, confirm bool) ExecResult {
|
||||
c := base
|
||||
c.SQL = sql
|
||||
c.ConfirmDanger = confirm
|
||||
return execRedis(ctx, c)
|
||||
}
|
||||
|
||||
if r := run("FLUSHALL", false); !r.NeedConfirm {
|
||||
t.Fatalf("FLUSHALL 未确认应 needConfirm: %#v", r)
|
||||
}
|
||||
if !mr.Exists("k") {
|
||||
t.Fatal("未确认的 FLUSHALL 不应执行")
|
||||
}
|
||||
if r := run("KEYS *", false); !r.NeedConfirm {
|
||||
t.Fatalf("KEYS 未确认应 needConfirm: %#v", r)
|
||||
}
|
||||
if r := run("KEYS *", true); r.Error != "" || r.RowCount != 1 {
|
||||
t.Fatalf("KEYS 确认后应放行: %#v", r)
|
||||
}
|
||||
if r := run("FLUSHDB", true); r.Error != "" {
|
||||
t.Fatalf("FLUSHDB 确认后应放行: %#v", r)
|
||||
}
|
||||
if mr.Exists("k") {
|
||||
t.Fatal("确认后的 FLUSHDB 应已执行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRedisGenericDoAndScan(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mr.Close()
|
||||
base := redisTestReq(t, mr)
|
||||
ctx := context.Background()
|
||||
_ = mr.Set("user:1", "a")
|
||||
_ = mr.Set("user:2", "b")
|
||||
_ = mr.Set("other", "c")
|
||||
|
||||
run := func(sql string) ExecResult {
|
||||
c := base
|
||||
c.SQL = sql
|
||||
return execRedis(ctx, c)
|
||||
}
|
||||
|
||||
if r := run("EXISTS user:1"); r.Error != "" || r.RowCount != 1 {
|
||||
t.Fatalf("EXISTS via Do: %#v", r)
|
||||
}
|
||||
if r := run("SCAN 0 MATCH user:* COUNT 50"); r.Error != "" {
|
||||
t.Fatalf("SCAN MATCH: %#v", r)
|
||||
} else {
|
||||
if r.RowCount != 2 {
|
||||
t.Fatalf("SCAN MATCH want 2 got %d %#v", r.RowCount, r.Rows)
|
||||
}
|
||||
}
|
||||
if r := run("ECHO hello"); r.Error != "" {
|
||||
t.Fatalf("ECHO via Do: %#v", r)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
func TestListGetDeleteRedisKeys(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mr.Close()
|
||||
|
||||
_ = mr.Set("str:a", "hello")
|
||||
mr.HSet("hash:b", "f1", "v1")
|
||||
mr.Push("list:c", "x", "y")
|
||||
mr.SAdd("set:d", "m1", "m2")
|
||||
_, _ = mr.ZAdd("zset:e", 1.5, "zm")
|
||||
|
||||
host, portStr, err := net.SplitHostPort(mr.Addr())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
base := ConnReq{Driver: "redis", Host: host, Port: port, Database: "0"}
|
||||
|
||||
list := ListRedisKeys(context.Background(), base)
|
||||
if list.Error != "" {
|
||||
t.Fatal(list.Error)
|
||||
}
|
||||
if len(list.Keys) < 5 {
|
||||
t.Fatalf("want >=5 keys, got %d %#v", len(list.Keys), list.Keys)
|
||||
}
|
||||
byName := map[string]RedisKeyInfo{}
|
||||
for _, k := range list.Keys {
|
||||
byName[k.Name] = k
|
||||
}
|
||||
if byName["str:a"].Type != "string" {
|
||||
t.Fatalf("str type %s", byName["str:a"].Type)
|
||||
}
|
||||
if byName["hash:b"].Type != "hash" {
|
||||
t.Fatalf("hash type %s", byName["hash:b"].Type)
|
||||
}
|
||||
// Real Redis returns -1 (no expire); miniredis may report 0.
|
||||
if byName["str:a"].TTL < -1 {
|
||||
t.Fatalf("unexpected ttl %d", byName["str:a"].TTL)
|
||||
}
|
||||
|
||||
gv := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
|
||||
if gv.Error != "" || gv.RowCount != 1 || gv.Rows[0]["value"] != "hello" {
|
||||
t.Fatalf("get string %#v", gv)
|
||||
}
|
||||
gh := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "hash:b"})
|
||||
if gh.Error != "" || gh.RowCount < 1 || gh.Columns[0] != "field" {
|
||||
t.Fatalf("get hash %#v", gh)
|
||||
}
|
||||
if gh.Rows[0]["field"] != "f1" {
|
||||
t.Fatalf("hash fields should be sorted, got %#v", gh.Rows)
|
||||
}
|
||||
gl := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "list:c"})
|
||||
if gl.Error != "" || gl.RowCount < 2 {
|
||||
t.Fatalf("get list %#v", gl)
|
||||
}
|
||||
gs := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "set:d"})
|
||||
if gs.Error != "" || gs.RowCount < 2 {
|
||||
t.Fatalf("get set %#v", gs)
|
||||
}
|
||||
gz := GetRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "zset:e"})
|
||||
if gz.Error != "" || gz.RowCount != 1 {
|
||||
t.Fatalf("get zset %#v", gz)
|
||||
}
|
||||
|
||||
pat := ListRedisKeys(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Pattern: "str:*"})
|
||||
if pat.Error != "" || len(pat.Keys) != 1 || pat.Keys[0].Name != "str:a" {
|
||||
t.Fatalf("pattern %#v", pat)
|
||||
}
|
||||
|
||||
del := DeleteRedisKey(context.Background(), ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
|
||||
if del.Error != "" {
|
||||
t.Fatal(del.Error)
|
||||
}
|
||||
if mr.Exists("str:a") {
|
||||
t.Fatal("key should be deleted")
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRedisAddrs(t *testing.T) {
|
||||
got := parseRedisAddrs("127.0.0.1:26379\n127.0.0.1:26380, 10.0.0.1:26379")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 addrs, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRedisModeValidation(t *testing.T) {
|
||||
_, err := openRedis(ConnReq{Driver: "redis", RedisMode: "sentinel"})
|
||||
if err == nil || !strings.Contains(err.Error(), "Sentinel") {
|
||||
t.Fatalf("sentinel without nodes: %v", err)
|
||||
}
|
||||
_, err = openRedis(ConnReq{Driver: "redis", RedisMode: "sentinel", RedisNodes: "127.0.0.1:26379"})
|
||||
if err == nil || !strings.Contains(err.Error(), "masterName") {
|
||||
t.Fatalf("sentinel without master: %v", err)
|
||||
}
|
||||
_, err = openRedis(ConnReq{Driver: "redis", RedisMode: "cluster"})
|
||||
if err == nil || !strings.Contains(err.Error(), "Cluster") {
|
||||
t.Fatalf("cluster without nodes: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTTLSecondsSentinels(t *testing.T) {
|
||||
cases := []struct {
|
||||
in time.Duration
|
||||
want int64
|
||||
}{
|
||||
{time.Duration(-1), -1},
|
||||
{-1 * time.Nanosecond, -1},
|
||||
{-1 * time.Second, -1},
|
||||
{time.Duration(-2), -2},
|
||||
{-2 * time.Nanosecond, -2},
|
||||
{-2 * time.Second, -2},
|
||||
{0, 0},
|
||||
{5 * time.Second, 5},
|
||||
{1500 * time.Millisecond, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ttlSeconds(c.in)
|
||||
if got != c.want {
|
||||
t.Fatalf("ttlSeconds(%v)=%d want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ObjectHit 对象搜索结果条目。
|
||||
type ObjectHit struct {
|
||||
Schema string `json:"schema"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // table | view
|
||||
}
|
||||
|
||||
// SearchObjects 按关键词模糊搜索表/视图,最多返回 50 条。redis 返回空。
|
||||
func SearchObjects(ctx context.Context, c ConnReq) ([]ObjectHit, error) {
|
||||
ctx = reqCtx(ctx)
|
||||
if isRedis(c.Driver) {
|
||||
return []ObjectHit{}, nil
|
||||
}
|
||||
var out []ObjectHit
|
||||
err := withDB(c, func(db *sql.DB) error {
|
||||
var err error
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
out, err = searchObjectsMySQL(ctx, db, c.Database, c.Keyword)
|
||||
case isPostgresFamily(c.Driver):
|
||||
out, err = searchObjectsPostgres(ctx, db, c.Keyword)
|
||||
case isMSSQL(c.Driver):
|
||||
out, err = searchObjectsMSSQL(ctx, db, c.Keyword)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
out, err = searchObjectsOracle(ctx, db, c.Database, c.User, c.Keyword)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
out, err = searchObjectsSQLite(ctx, db, c.Keyword)
|
||||
default:
|
||||
err = unsupportedDriverErr(c)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []ObjectHit{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func likePattern(keyword string) string {
|
||||
return "%" + keyword + "%"
|
||||
}
|
||||
|
||||
// hitType information_schema 的 table_type 含 VIEW 即为视图。
|
||||
func hitType(tableType string) string {
|
||||
if strings.Contains(strings.ToUpper(tableType), "VIEW") {
|
||||
return "view"
|
||||
}
|
||||
return "table"
|
||||
}
|
||||
|
||||
func searchObjectsMySQL(ctx context.Context, dbh *sql.DB, database, keyword string) ([]ObjectHit, error) {
|
||||
// database 为空时用当前库 DATABASE()
|
||||
q := `SELECT table_schema, table_name, table_type FROM information_schema.TABLES
|
||||
WHERE table_schema = DATABASE() AND table_name LIKE ? LIMIT 50`
|
||||
args := []interface{}{likePattern(keyword)}
|
||||
if strings.TrimSpace(database) != "" {
|
||||
q = `SELECT table_schema, table_name, table_type FROM information_schema.TABLES
|
||||
WHERE table_schema = ? AND table_name LIKE ? LIMIT 50`
|
||||
args = []interface{}{database, likePattern(keyword)}
|
||||
}
|
||||
return scanObjectHits(ctx, dbh, q, args, "mysql")
|
||||
}
|
||||
|
||||
func searchObjectsPostgres(ctx context.Context, dbh *sql.DB, keyword string) ([]ObjectHit, error) {
|
||||
return scanObjectHits(ctx, dbh,
|
||||
`SELECT table_schema, table_name, table_type FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog','information_schema') AND table_name LIKE $1 LIMIT 50`,
|
||||
[]interface{}{likePattern(keyword)}, "pg")
|
||||
}
|
||||
|
||||
func searchObjectsMSSQL(ctx context.Context, dbh *sql.DB, keyword string) ([]ObjectHit, error) {
|
||||
// MSSQL 无 LIMIT,用 TOP 50
|
||||
return scanObjectHits(ctx, dbh,
|
||||
`SELECT TOP 50 TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES
|
||||
WHERE TABLE_NAME LIKE @p1`,
|
||||
[]interface{}{likePattern(keyword)}, "mssql")
|
||||
}
|
||||
|
||||
func searchObjectsOracle(ctx context.Context, dbh *sql.DB, database, user, keyword string) ([]ObjectHit, error) {
|
||||
owner := strings.ToUpper(strings.TrimSpace(database))
|
||||
if owner == "" {
|
||||
owner = strings.ToUpper(strings.TrimSpace(user))
|
||||
}
|
||||
return scanObjectHits(ctx, dbh,
|
||||
`SELECT owner, table_name, 'table' FROM all_tables WHERE owner = :1 AND table_name LIKE :2
|
||||
UNION ALL
|
||||
SELECT owner, view_name, 'view' FROM all_views WHERE owner = :3 AND view_name LIKE :4
|
||||
FETCH FIRST 50 ROWS ONLY`,
|
||||
[]interface{}{owner, likePattern(keyword), owner, likePattern(keyword)}, "oracle")
|
||||
}
|
||||
|
||||
func searchObjectsSQLite(ctx context.Context, dbh *sql.DB, keyword string) ([]ObjectHit, error) {
|
||||
return scanObjectHits(ctx, dbh,
|
||||
`SELECT '', name, type FROM sqlite_master WHERE name LIKE ? AND type IN ('table','view') LIMIT 50`,
|
||||
[]interface{}{likePattern(keyword)}, "sqlite")
|
||||
}
|
||||
|
||||
func scanObjectHits(ctx context.Context, dbh *sql.DB, q string, args []interface{}, dialect string) ([]ObjectHit, error) {
|
||||
rows, err := dbh.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ObjectHit{}
|
||||
for rows.Next() {
|
||||
var schema, name, typ string
|
||||
if err := rows.Scan(&schema, &name, &typ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := "table"
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
t = typ // sqlite_master.type 直接是 table/view
|
||||
default:
|
||||
t = hitType(typ)
|
||||
}
|
||||
out = append(out, ObjectHit{Schema: schema, Name: name, Type: t})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R2-B6:sqlite 搜索命中表与视图,类型区分正确。
|
||||
func TestSearchObjectsSQLite(t *testing.T) {
|
||||
c := newSQLiteFile(t,
|
||||
`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
`CREATE TABLE orders (id INTEGER PRIMARY KEY)`,
|
||||
`CREATE VIEW v_users AS SELECT id, name FROM users`,
|
||||
)
|
||||
c.Keyword = "user"
|
||||
hits, err := SearchObjects(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("应命中 2 个(user 前缀的表和视图): %+v", hits)
|
||||
}
|
||||
byName := map[string]ObjectHit{}
|
||||
for _, h := range hits {
|
||||
byName[h.Name] = h
|
||||
}
|
||||
if byName["users"].Type != "table" {
|
||||
t.Fatalf("users 应为 table: %+v", byName["users"])
|
||||
}
|
||||
if byName["v_users"].Type != "view" {
|
||||
t.Fatalf("v_users 应为 view: %+v", byName["v_users"])
|
||||
}
|
||||
|
||||
// 不命中
|
||||
c.Keyword = "nothing_match"
|
||||
hits, err = SearchObjects(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("不应命中: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// redis 返回空结果而非报错。
|
||||
func TestSearchObjectsRedis(t *testing.T) {
|
||||
c := ConnReq{Driver: "redis", Keyword: "k"}
|
||||
hits, err := SearchObjects(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("redis 应返回空: %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const sessionTTL = 45 * time.Minute
|
||||
const maxSessions = 64
|
||||
|
||||
// maxPoolHandles 每会话缓存的 *sql.DB 句柄上限(按 database 分键),超出时关闭最久未用。
|
||||
const maxPoolHandles = 8
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
Conn ConnReq
|
||||
mu sync.Mutex
|
||||
sqlPool map[string]*sql.DB
|
||||
poolUsed map[string]time.Time // sqlPool 各键最近使用时间,供 LRU 轮换
|
||||
redisClis map[int]redis.UniversalClient // 按 DB 序号缓存的 Redis 客户端(DB 是连接级参数)
|
||||
// agentHandles: agent 驱动(如达梦)按 database 缓存的 JDBC 句柄;
|
||||
// JVM 进程走全局池(agentPool),会话关闭只关句柄、不杀进程。
|
||||
agentHandles map[string]string
|
||||
tunnel *SSHTunnel
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
sessionsMu sync.Mutex
|
||||
sessions = map[string]*Session{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
t := time.NewTicker(2 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
expireSessions()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// newSessionID 生成 128bit 随机会话 ID;crypto/rand 失败时返回错误,
|
||||
// 不再静默降级为低熵时间戳。
|
||||
func newSessionID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("生成会话 ID 失败: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func OpenSession(c ConnReq) (string, error) {
|
||||
c.SQL = ""
|
||||
c.Table = ""
|
||||
c.Key = ""
|
||||
c.Pattern = ""
|
||||
c.SessionID = ""
|
||||
|
||||
// agent 驱动(JDBC 桥)不走本地 SSH 端口转发(转发对 JVM 侧 TCP 无效)
|
||||
if isAgentDriver(c.Driver) && c.SSHEnabled {
|
||||
return "", fmt.Errorf("%s 走 JDBC agent,暂不支持 SSH 隧道", c.Driver)
|
||||
}
|
||||
dial, tun, err := ApplySSHTunnel(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := testDial(dial); err != nil {
|
||||
if tun != nil {
|
||||
tun.Close()
|
||||
}
|
||||
return "", fmt.Errorf("数据库: %w", err)
|
||||
}
|
||||
|
||||
sessionsMu.Lock()
|
||||
if len(sessions) >= maxSessions {
|
||||
sessionsMu.Unlock()
|
||||
if tun != nil {
|
||||
tun.Close()
|
||||
}
|
||||
return "", fmt.Errorf("连接会话已满,请关闭不用的连接后重试")
|
||||
}
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
sessionsMu.Unlock()
|
||||
if tun != nil {
|
||||
tun.Close()
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// Persist dialed endpoints (127.0.0.1:local) so later ops reuse the tunnel.
|
||||
stored := dial
|
||||
stored.SSHEnabled = false
|
||||
s := &Session{
|
||||
ID: id,
|
||||
Conn: stored,
|
||||
sqlPool: map[string]*sql.DB{},
|
||||
poolUsed: map[string]time.Time{},
|
||||
agentHandles: map[string]string{},
|
||||
tunnel: tun,
|
||||
LastUsed: time.Now(),
|
||||
}
|
||||
sessions[id] = s
|
||||
sessionsMu.Unlock()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func GetSession(id string) (*Session, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("缺少 sessionId")
|
||||
}
|
||||
sessionsMu.Lock()
|
||||
s, ok := sessions[id]
|
||||
sessionsMu.Unlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("会话已失效,请重新连接")
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.LastUsed = time.Now()
|
||||
s.mu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func CloseSession(id string) {
|
||||
sessionsMu.Lock()
|
||||
s, ok := sessions[id]
|
||||
if ok {
|
||||
delete(sessions, id)
|
||||
}
|
||||
sessionsMu.Unlock()
|
||||
if ok {
|
||||
s.closeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func expireSessions() {
|
||||
cutoff := time.Now().Add(-sessionTTL)
|
||||
sessionsMu.Lock()
|
||||
var dead []*Session
|
||||
for id, s := range sessions {
|
||||
s.mu.Lock()
|
||||
stale := s.LastUsed.Before(cutoff)
|
||||
s.mu.Unlock()
|
||||
if stale {
|
||||
dead = append(dead, s)
|
||||
delete(sessions, id)
|
||||
}
|
||||
}
|
||||
sessionsMu.Unlock()
|
||||
for _, s := range dead {
|
||||
s.closeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) closeAll() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for k, dbh := range s.sqlPool {
|
||||
_ = dbh.Close()
|
||||
delete(s.sqlPool, k)
|
||||
}
|
||||
s.poolUsed = map[string]time.Time{}
|
||||
for k, cli := range s.redisClis {
|
||||
_ = cli.Close()
|
||||
delete(s.redisClis, k)
|
||||
}
|
||||
// agent 句柄随会话释放;JVM 进程归全局池,进程生命周期不挂钩会话
|
||||
if len(s.agentHandles) > 0 {
|
||||
if cli, err := getAgentClient(s.Conn.Driver); err == nil {
|
||||
for _, h := range s.agentHandles {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_ = cli.call(ctx, "close", map[string]any{"handle": h}, nil)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
s.agentHandles = map[string]string{}
|
||||
}
|
||||
if s.tunnel != nil {
|
||||
s.tunnel.Close()
|
||||
s.tunnel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// agentConn 按 database 懒建/复用 agent JDBC 句柄(模式同 sqlDB)。
|
||||
// 调用方不得持有 s.mu(本函数内部自行加锁;getAgentClient 用独立的全局池锁)。
|
||||
func (s *Session) agentConn(c ConnReq) (*AgentClient, string, error) {
|
||||
cli, err := getAgentClient(c.Driver)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
key := c.Database
|
||||
s.mu.Lock()
|
||||
if h, ok := s.agentHandles[key]; ok {
|
||||
s.mu.Unlock()
|
||||
return cli, h, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
h, err := agentOpen(ctx, cli, c)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old, ok := s.agentHandles[key]; ok {
|
||||
// 双检:并发下已有别的 goroutine 建好同键句柄,回收多余句柄
|
||||
go func() {
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel2()
|
||||
_ = cli.call(ctx2, "close", map[string]any{"handle": h}, nil)
|
||||
}()
|
||||
return cli, old, nil
|
||||
}
|
||||
s.agentHandles[key] = h
|
||||
return cli, h, nil
|
||||
}
|
||||
|
||||
// touch 滚动续期(GetSession 已调用,池化路径同样经 GetSession 进入)。
|
||||
func (s *Session) touch() {
|
||||
s.mu.Lock()
|
||||
s.LastUsed = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// sqlDB 按 database 懒加载缓存 *sql.DB,会话内复用连接池;
|
||||
// 超过 maxPoolHandles 个句柄时关闭最久未用的池。
|
||||
func (s *Session) sqlDB(database string) (*sql.DB, error) {
|
||||
// SQLite 的 database 是文件路径,对象树的 "main" 不能覆盖它
|
||||
if driverKey(s.Conn.Driver) == "sqlite" {
|
||||
database = ""
|
||||
}
|
||||
s.mu.Lock()
|
||||
if dbh, ok := s.sqlPool[database]; ok {
|
||||
s.poolUsed[database] = time.Now()
|
||||
s.mu.Unlock()
|
||||
return dbh, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
conn := s.Conn
|
||||
if database != "" {
|
||||
conn.Database = database
|
||||
}
|
||||
dbh, err := Open(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 双检:并发下可能已有别的 goroutine 建好同键池
|
||||
if old, ok := s.sqlPool[database]; ok {
|
||||
s.poolUsed[database] = time.Now()
|
||||
_ = dbh.Close()
|
||||
return old, nil
|
||||
}
|
||||
// 超上限先淘汰最久未用
|
||||
for len(s.sqlPool) >= maxPoolHandles {
|
||||
var lruKey string
|
||||
var lruTime time.Time
|
||||
for k := range s.sqlPool {
|
||||
t := s.poolUsed[k]
|
||||
if lruKey == "" || t.Before(lruTime) {
|
||||
lruKey, lruTime = k, t
|
||||
}
|
||||
}
|
||||
_ = s.sqlPool[lruKey].Close()
|
||||
delete(s.sqlPool, lruKey)
|
||||
delete(s.poolUsed, lruKey)
|
||||
}
|
||||
s.sqlPool[database] = dbh
|
||||
s.poolUsed[database] = time.Now()
|
||||
return dbh, nil
|
||||
}
|
||||
|
||||
// redisClient 懒建会话级 Redis 单例,避免每次命令新建连接。
|
||||
func (s *Session) redisClient() (redis.UniversalClient, error) {
|
||||
// 按连接配置缓存:DB 序号是 go-redis 的连接级参数(建连时钉死),
|
||||
// 对象树里切换 db0/db1 必须各建一个客户端,否则全部打到首个 db。
|
||||
return s.redisClientFor(s.Conn)
|
||||
}
|
||||
|
||||
func (s *Session) redisClientFor(c ConnReq) (redis.UniversalClient, error) {
|
||||
db := redisDBIndex(c)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.redisClis == nil {
|
||||
s.redisClis = map[int]redis.UniversalClient{}
|
||||
}
|
||||
if cli, ok := s.redisClis[db]; ok {
|
||||
return cli, nil
|
||||
}
|
||||
// 以会话凭据 + 目标 DB 建连(Sentinel/Cluster 模式 DB 恒为 0,天然只有一份)
|
||||
cli, err := openRedis(ConnReq{
|
||||
Driver: s.Conn.Driver,
|
||||
Host: s.Conn.Host,
|
||||
Port: s.Conn.Port,
|
||||
User: s.Conn.User,
|
||||
Password: s.Conn.Password,
|
||||
Database: strconv.Itoa(db),
|
||||
RedisMode: s.Conn.RedisMode,
|
||||
RedisNodes: s.Conn.RedisNodes,
|
||||
MasterName: s.Conn.MasterName,
|
||||
SentinelPassword: s.Conn.SentinelPassword,
|
||||
SSL: s.Conn.SSL,
|
||||
SSLMode: s.Conn.SSLMode,
|
||||
SSLCA: s.Conn.SSLCA,
|
||||
SSLCert: s.Conn.SSLCert,
|
||||
SSLKey: s.Conn.SSLKey,
|
||||
SSHEnabled: s.Conn.SSHEnabled,
|
||||
SSHHost: s.Conn.SSHHost,
|
||||
SSHPort: s.Conn.SSHPort,
|
||||
SSHUser: s.Conn.SSHUser,
|
||||
SSHAuth: s.Conn.SSHAuth,
|
||||
SSHPassword: s.Conn.SSHPassword,
|
||||
SSHPrivateKey: s.Conn.SSHPrivateKey,
|
||||
SSHPassphrase: s.Conn.SSHPassphrase,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.redisClis[db] = cli
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
func (s *Session) ConnFor(req ConnReq) ConnReq {
|
||||
c := s.Conn
|
||||
// SQLite 的 database 是文件路径,对象树里的 "main" 不能覆盖它
|
||||
if req.Database != "" && driverKey(c.Driver) != "sqlite" {
|
||||
c.Database = req.Database
|
||||
}
|
||||
c.SQL = req.SQL
|
||||
c.Table = req.Table
|
||||
c.Key = req.Key
|
||||
c.Pattern = req.Pattern
|
||||
c.Keyword = req.Keyword
|
||||
c.Format = req.Format
|
||||
c.Limit = req.Limit
|
||||
c.Offset = req.Offset
|
||||
c.ConfirmDanger = req.ConfirmDanger
|
||||
// 逐请求字段:新增 ConnReq 字段时若属于"每次请求携带"语义,必须在此补拷贝,
|
||||
// 否则会话路径会静默丢弃(前端全部走会话,等于功能失效)
|
||||
c.TimeoutMs = req.TimeoutMs
|
||||
c.Cursor = req.Cursor
|
||||
c.RequestID = req.RequestID
|
||||
c.SessionID = s.ID
|
||||
return c
|
||||
}
|
||||
|
||||
func ResolveConn(req ConnReq) (ConnReq, error) {
|
||||
if req.SessionID == "" {
|
||||
return req, nil
|
||||
}
|
||||
s, err := GetSession(req.SessionID)
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
return s.ConnFor(req), nil
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// touchFile 先建空文件(零字节即合法 SQLite 库),绕开存在性检查。
|
||||
func touchFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestSession(driver, database string) *Session {
|
||||
return &Session{
|
||||
ID: "test-pool",
|
||||
Conn: ConnReq{Driver: driver, Database: database},
|
||||
sqlPool: map[string]*sql.DB{},
|
||||
poolUsed: map[string]time.Time{},
|
||||
LastUsed: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// 池化:同 database 复用同一 *sql.DB 句柄。
|
||||
func TestSessionSqlDBReuse(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "pool.db")
|
||||
touchFile(t, dbFile)
|
||||
s := newTestSession("sqlite", dbFile)
|
||||
defer s.closeAll()
|
||||
|
||||
a, err := s.sqlDB("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.sqlDB("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatal("同 database 应复用同一 *sql.DB 句柄")
|
||||
}
|
||||
// sqlite 下 database 参数不覆盖文件路径
|
||||
c, err := s.sqlDB("main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c != a {
|
||||
t.Fatal("sqlite 会话应忽略 database 复用同一句柄")
|
||||
}
|
||||
}
|
||||
|
||||
// 池化上限:超过 8 个句柄时关闭最久未用(LRU 轮换)。
|
||||
func TestSessionSqlDBLRU(t *testing.T) {
|
||||
// mysql 驱动 sql.Open 不立即建连,可离线验证池管理逻辑
|
||||
s := newTestSession("mysql", "")
|
||||
defer s.closeAll()
|
||||
|
||||
first, err := s.sqlDB("db0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= maxPoolHandles; i++ {
|
||||
time.Sleep(2 * time.Millisecond) // 保证 LRU 时间序确定
|
||||
if _, err := s.sqlDB(string(rune('a' + i))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
n := len(s.sqlPool)
|
||||
_, firstAlive := s.sqlPool["db0"]
|
||||
s.mu.Unlock()
|
||||
if n > maxPoolHandles {
|
||||
t.Fatalf("池句柄数 %d 超上限 %d", n, maxPoolHandles)
|
||||
}
|
||||
if firstAlive {
|
||||
t.Fatal("最久未用句柄应被淘汰")
|
||||
}
|
||||
_ = first
|
||||
}
|
||||
|
||||
// 会话路径端到端:ExecSQL 走池化,两次执行共享句柄且结果正确。
|
||||
func TestExecSQLSessionPooling(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "exec.db")
|
||||
touchFile(t, dbFile)
|
||||
id, err := OpenSession(ConnReq{Driver: "sqlite", Database: dbFile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer CloseSession(id)
|
||||
|
||||
mk := func(sqlText string) ConnReq {
|
||||
c, err := ResolveConn(ConnReq{SessionID: id, SQL: sqlText})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
if r := ExecSQL(context.Background(), mk("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")); r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
if r := ExecSQL(context.Background(), mk("INSERT INTO t (v) VALUES ('a'),('b'),('c')")); r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
r := ExecSQL(context.Background(), mk("SELECT * FROM t ORDER BY id"))
|
||||
if r.Error != "" || r.RowCount != 3 {
|
||||
t.Fatalf("查询结果错误: %#v", r)
|
||||
}
|
||||
|
||||
s, err := GetSession(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
poolSize := len(s.sqlPool)
|
||||
s.mu.Unlock()
|
||||
if poolSize != 1 {
|
||||
t.Fatalf("会话池应仅 1 个句柄,实际 %d", poolSize)
|
||||
}
|
||||
|
||||
// offset 分页:跳过前 1 行取 1 行
|
||||
p := mk("SELECT * FROM t ORDER BY id")
|
||||
p.Offset = 1
|
||||
p.Limit = 1
|
||||
r2 := ExecSQL(context.Background(), p)
|
||||
if r2.Error != "" || r2.RowCount != 1 {
|
||||
t.Fatalf("offset 分页错误: %#v", r2)
|
||||
}
|
||||
if r2.Rows[0]["v"] != "b" {
|
||||
t.Fatalf("offset=1 应返回第 2 行,实际 %#v", r2.Rows[0])
|
||||
}
|
||||
if r2.Offset != 1 || !r2.Truncated {
|
||||
t.Fatalf("分页元数据错误: offset=%d truncated=%v", r2.Offset, r2.Truncated)
|
||||
}
|
||||
|
||||
// 会话路径危险确认:ConfirmDanger 必须经 ConnFor 透传
|
||||
d := mk("DROP TABLE t")
|
||||
if r := ExecSQL(context.Background(), d); !r.NeedConfirm {
|
||||
t.Fatalf("会话路径未确认应拦截: %#v", r)
|
||||
}
|
||||
d.ConfirmDanger = true
|
||||
if r := ExecSQL(context.Background(), d); r.Error != "" || r.NeedConfirm {
|
||||
t.Fatalf("会话路径确认后应放行: %#v", r)
|
||||
}
|
||||
|
||||
// 逐请求字段透传:RequestID/TimeoutMs 丢失会让取消/超时在前端(全走会话路径)静默失效
|
||||
s2, err := GetSession(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2 := s2.ConnFor(ConnReq{Driver: "sqlite", SessionID: id, SQL: "SELECT 1", RequestID: "req-1", TimeoutMs: 5000})
|
||||
if c2.RequestID != "req-1" || c2.TimeoutMs != 5000 {
|
||||
t.Fatalf("ConnFor 应透传 RequestID/TimeoutMs: %#v", c2)
|
||||
}
|
||||
}
|
||||
|
||||
// 危险语句二段流:未确认拦截,确认后放行。
|
||||
func TestExecSQLDangerConfirm(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "danger.db")
|
||||
touchFile(t, dbFile)
|
||||
c := ConnReq{Driver: "sqlite", Database: dbFile}
|
||||
|
||||
r := ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "CREATE TABLE t (id int)"})
|
||||
if r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
// 未确认:DROP 不执行
|
||||
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "DROP TABLE t"})
|
||||
if !r.NeedConfirm {
|
||||
t.Fatalf("应 needConfirm: %#v", r)
|
||||
}
|
||||
if r.Error == "" {
|
||||
t.Fatal("应返回命中原因")
|
||||
}
|
||||
// 表仍在
|
||||
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT count(*) AS n FROM t"})
|
||||
if r.Error != "" {
|
||||
t.Fatalf("DROP 不应已执行: %s", r.Error)
|
||||
}
|
||||
// 确认后放行
|
||||
c.SQL = "DROP TABLE t"
|
||||
c.ConfirmDanger = true
|
||||
r = ExecSQL(context.Background(), c)
|
||||
if r.Error != "" || r.NeedConfirm {
|
||||
t.Fatalf("确认后应放行: %#v", r)
|
||||
}
|
||||
// 表已删
|
||||
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT count(*) FROM t"})
|
||||
if r.Error == "" {
|
||||
t.Fatal("确认后 DROP 应已生效")
|
||||
}
|
||||
}
|
||||
|
||||
// bigint 精度:超 2^53 的 int64 以字符串无损返回。
|
||||
func TestExecSQLBigint(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "bigint.db")
|
||||
touchFile(t, dbFile)
|
||||
mk := func(sqlText string) ConnReq {
|
||||
return ConnReq{Driver: "sqlite", Database: dbFile, SQL: sqlText}
|
||||
}
|
||||
if r := ExecSQL(context.Background(), mk("CREATE TABLE b (id INTEGER PRIMARY KEY)")); r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
if r := ExecSQL(context.Background(), mk("INSERT INTO b (id) VALUES (9223372036854775807)")); r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
r := ExecSQL(context.Background(), mk("SELECT id FROM b"))
|
||||
if r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
if r.Rows[0]["id"] != "9223372036854775807" {
|
||||
t.Fatalf("bigint 应字符串保真: %#v", r.Rows[0]["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// EXPLAIN:sqlite 行列计划非空。
|
||||
func TestExplainSQLite(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "explain.db")
|
||||
touchFile(t, dbFile)
|
||||
c := ConnReq{Driver: "sqlite", Database: dbFile, SQL: "CREATE TABLE e (id int)"}
|
||||
if r := ExecSQL(context.Background(), c); r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
r := ExplainSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT * FROM e"})
|
||||
if r.Error != "" {
|
||||
t.Fatal(r.Error)
|
||||
}
|
||||
if len(r.Rows) == 0 {
|
||||
t.Fatal("sqlite EXPLAIN QUERY PLAN 应返回行列计划")
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite 打开不存在文件应报错而非静默建空库。
|
||||
func TestSQLiteMissingFile(t *testing.T) {
|
||||
_, err := Open(ConnReq{Driver: "sqlite", Database: filepath.Join(t.TempDir(), "not-exist", "x.db")})
|
||||
if err == nil {
|
||||
t.Fatal("不存在的 sqlite 路径应报错")
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// openSQLite 打开 SQLite 库文件;文件不存在时报错而非静默建空库。
|
||||
// DSN 追加 busy_timeout / foreign_keys pragma。
|
||||
func openSQLite(c ConnReq) (*sql.DB, error) {
|
||||
path := c.Database
|
||||
if path == "" {
|
||||
path = c.Host
|
||||
}
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("SQLite 需要 database 填文件路径")
|
||||
}
|
||||
dsn := path
|
||||
switch {
|
||||
case path == ":memory:":
|
||||
// 统一走 file: URI 以便携带 _pragma 参数
|
||||
dsn = "file::memory:"
|
||||
case strings.HasPrefix(path, "file:"):
|
||||
// 调用方自带 URI 参数,不再做存在性检查
|
||||
default:
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("SQLite 文件不存在: %s", path)
|
||||
}
|
||||
dsn = "file:" + path
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
dsn += sep + "_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
|
||||
return sql.Open("sqlite", dsn)
|
||||
}
|
||||
|
||||
func quoteSQLiteIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func listDatabasesSQLite(_ *sql.DB) ([]string, error) {
|
||||
return []string{"main"}, nil
|
||||
}
|
||||
|
||||
func listTablesSQLite(ctx context.Context, db *sql.DB, _ string) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listColumnsSQLite(ctx context.Context, dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||
rows, err := dbh.QueryContext(ctx, `PRAGMA table_info(`+quoteSQLiteIdent(table)+`)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull, pk int
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &def, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col := ColumnInfo{Name: name, Type: typ, Nullable: notnull == 0}
|
||||
if pk > 0 {
|
||||
col.Key = "PRI"
|
||||
}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package db
|
||||
|
||||
import "strings"
|
||||
|
||||
// SplitStatements 按分号切分 SQL 为多条语句,忽略单/双引号字符串与
|
||||
// 行注释(--、#)、块注释(/* */)内的分号。语义对齐前端 sql.ts splitStatements。
|
||||
// 返回的语句已去除首尾空白,空语句被丢弃。
|
||||
func SplitStatements(sql string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
i := 0
|
||||
n := len(sql)
|
||||
inS, inD, inLine, inBlock := false, false, false, false
|
||||
flush := func() {
|
||||
if s := strings.TrimSpace(cur.String()); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
cur.Reset()
|
||||
}
|
||||
for i < n {
|
||||
ch := sql[i]
|
||||
var nxt byte
|
||||
if i+1 < n {
|
||||
nxt = sql[i+1]
|
||||
}
|
||||
switch {
|
||||
case inLine:
|
||||
cur.WriteByte(ch)
|
||||
if ch == '\n' {
|
||||
inLine = false
|
||||
}
|
||||
i++
|
||||
case inBlock:
|
||||
cur.WriteByte(ch)
|
||||
if ch == '*' && nxt == '/' {
|
||||
cur.WriteByte(nxt)
|
||||
inBlock = false
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
case !inS && !inD && ch == '-' && nxt == '-':
|
||||
inLine = true
|
||||
cur.WriteString("--")
|
||||
i += 2
|
||||
case !inS && !inD && ch == '#':
|
||||
inLine = true
|
||||
cur.WriteByte(ch)
|
||||
i++
|
||||
case !inS && !inD && ch == '/' && nxt == '*':
|
||||
inBlock = true
|
||||
cur.WriteString("/*")
|
||||
i += 2
|
||||
case ch == '\'' && !inD:
|
||||
inS = !inS
|
||||
cur.WriteByte(ch)
|
||||
i++
|
||||
case ch == '"' && !inS:
|
||||
inD = !inD
|
||||
cur.WriteByte(ch)
|
||||
i++
|
||||
case ch == ';' && !inS && !inD:
|
||||
flush()
|
||||
i++
|
||||
default:
|
||||
cur.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// StripComments 剥除 --、#、/* */ 注释,并把字符串字面量替换为 ” 占位,
|
||||
// 便于在纯代码文本上做危险关键字判定。
|
||||
// MySQL 可执行注释 /*! ... */ 的内容会被保留参与检查(去掉包裹的 /*! 与 */)。
|
||||
func StripComments(sql string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(sql))
|
||||
i := 0
|
||||
n := len(sql)
|
||||
for i < n {
|
||||
ch := sql[i]
|
||||
var nxt byte
|
||||
if i+1 < n {
|
||||
nxt = sql[i+1]
|
||||
}
|
||||
switch {
|
||||
// 行注释 -- 与 #
|
||||
case ch == '-' && nxt == '-':
|
||||
for i < n && sql[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
case ch == '#':
|
||||
for i < n && sql[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
// 块注释;/*! ... */ 为 MySQL 可执行注释,保留内容
|
||||
case ch == '/' && nxt == '*':
|
||||
exec := i+2 < n && sql[i+2] == '!'
|
||||
start := i + 2
|
||||
if exec {
|
||||
start = i + 3
|
||||
}
|
||||
j := strings.Index(sql[start:], "*/")
|
||||
if j < 0 {
|
||||
if exec {
|
||||
b.WriteString(sql[start:])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
if exec {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(sql[start : start+j])
|
||||
b.WriteByte(' ')
|
||||
} else {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
i = start + j + 2
|
||||
// 单引号字面量(处理 '' 与 \' 转义)
|
||||
case ch == '\'':
|
||||
b.WriteString("''")
|
||||
i++
|
||||
for i < n {
|
||||
if sql[i] == '\'' {
|
||||
if i+1 < n && sql[i+1] == '\'' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
break
|
||||
}
|
||||
if sql[i] == '\\' && i+1 < n {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
// 双引号标识符/字面量同样置空内容(防 "DROP" 作为标识符误报)
|
||||
case ch == '"':
|
||||
b.WriteString(`""`)
|
||||
i++
|
||||
for i < n {
|
||||
if sql[i] == '"' {
|
||||
if i+1 < n && sql[i+1] == '"' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
// 反引号标识符内容置空
|
||||
case ch == '`':
|
||||
b.WriteString("``")
|
||||
i++
|
||||
for i < n && sql[i] != '`' {
|
||||
i++
|
||||
}
|
||||
if i < n {
|
||||
i++
|
||||
}
|
||||
default:
|
||||
b.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitStatements(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"SELECT 1", []string{"SELECT 1"}},
|
||||
{"SELECT 1; SELECT 2;", []string{"SELECT 1", "SELECT 2"}},
|
||||
{"SELECT ';'; SELECT 2", []string{"SELECT ';'", "SELECT 2"}},
|
||||
{"SELECT 1; -- c;mt\nSELECT 2", []string{"SELECT 1", "-- c;mt\nSELECT 2"}},
|
||||
{"/* a;b */ SELECT 1; SELECT 2", []string{"/* a;b */ SELECT 1", "SELECT 2"}},
|
||||
{`SELECT "a;b"; SELECT 2`, []string{`SELECT "a;b"`, "SELECT 2"}},
|
||||
{";;", nil},
|
||||
{"", nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := SplitStatements(tc.in)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("SplitStatements(%q) = %#v, want %#v", tc.in, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("SplitStatements(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripComments(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
has string // 结果应包含(大小写敏感原文)
|
||||
missing string // 结果不应包含
|
||||
}{
|
||||
{"SELECT 1 -- WHERE x", "SELECT 1", "WHERE x"},
|
||||
{"UPDATE t SET x=1 # note\nWHERE id=1", "WHERE id=1", "note"},
|
||||
{"SELECT /* hint */ 1", "SELECT", "hint"},
|
||||
{"SELECT /*!50000 DROP TABLE t*/", "DROP TABLE t", "/*!"},
|
||||
{"SELECT 'DROP TABLE x'", "SELECT", "DROP TABLE x"},
|
||||
{`SELECT "col;drop" FROM t`, "FROM t", "col;drop"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := StripComments(tc.in)
|
||||
if tc.has != "" && !strings.Contains(got, tc.has) {
|
||||
t.Fatalf("StripComments(%q) = %q, 应包含 %q", tc.in, got, tc.has)
|
||||
}
|
||||
if tc.missing != "" && strings.Contains(got, tc.missing) {
|
||||
t.Fatalf("StripComments(%q) = %q, 不应包含 %q", tc.in, got, tc.missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// SSHTunnel holds a local TCP listener that forwards through an SSH session
|
||||
// to the remote database host:port (local port forward semantics).
|
||||
type SSHTunnel struct {
|
||||
LocalPort int
|
||||
listener net.Listener
|
||||
client *ssh.Client
|
||||
cancel chan struct{}
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (t *SSHTunnel) Close() {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.once.Do(func() {
|
||||
close(t.cancel)
|
||||
if t.listener != nil {
|
||||
_ = t.listener.Close()
|
||||
}
|
||||
if t.client != nil {
|
||||
_ = t.client.Close()
|
||||
}
|
||||
t.wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// StartLocalForward dials SSH and listens on 127.0.0.1:0, forwarding to c.Host:c.Port.
|
||||
func StartLocalForward(c ConnReq) (*SSHTunnel, error) {
|
||||
sshHost := strings.TrimSpace(c.SSHHost)
|
||||
if sshHost == "" {
|
||||
return nil, fmt.Errorf("请填写 SSH 主机")
|
||||
}
|
||||
sshPort := c.SSHPort
|
||||
if sshPort <= 0 {
|
||||
sshPort = 22
|
||||
}
|
||||
sshUser := strings.TrimSpace(c.SSHUser)
|
||||
if sshUser == "" {
|
||||
return nil, fmt.Errorf("请填写 SSH 用户")
|
||||
}
|
||||
|
||||
auth, err := sshAuthMethods(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: sshUser,
|
||||
Auth: auth,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // product tunnel; host-key pinning later
|
||||
Timeout: 12 * time.Second,
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(sshHost, fmt.Sprintf("%d", sshPort))
|
||||
client, err := ssh.Dial("tcp", addr, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法连接跳板机 %s: %w", addr, err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("本地监听失败: %w", err)
|
||||
}
|
||||
localPort := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
remoteHost := c.Host
|
||||
if remoteHost == "" {
|
||||
remoteHost = "127.0.0.1"
|
||||
}
|
||||
remotePort := c.Port
|
||||
if remotePort <= 0 {
|
||||
remotePort = 3306
|
||||
}
|
||||
remoteAddr := net.JoinHostPort(remoteHost, fmt.Sprintf("%d", remotePort))
|
||||
|
||||
tun := &SSHTunnel{
|
||||
LocalPort: localPort,
|
||||
listener: ln,
|
||||
client: client,
|
||||
cancel: make(chan struct{}),
|
||||
}
|
||||
|
||||
tun.wg.Add(1)
|
||||
go func() {
|
||||
defer tun.wg.Done()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-tun.cancel:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
tun.wg.Add(1)
|
||||
go func(local net.Conn) {
|
||||
defer tun.wg.Done()
|
||||
defer local.Close()
|
||||
remote, err := client.Dial("tcp", remoteAddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer remote.Close()
|
||||
pipe(local, remote)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return tun, nil
|
||||
}
|
||||
|
||||
func sshAuthMethods(c ConnReq) ([]ssh.AuthMethod, error) {
|
||||
authMode := strings.ToLower(strings.TrimSpace(c.SSHAuth))
|
||||
if authMode == "" {
|
||||
authMode = "password"
|
||||
}
|
||||
switch authMode {
|
||||
case "password":
|
||||
if c.SSHPassword == "" {
|
||||
return nil, fmt.Errorf("请填写 SSH 密码")
|
||||
}
|
||||
return []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, nil
|
||||
case "privatekey", "key", "private_key":
|
||||
keyMaterial, err := loadSSHPrivateKey(c.SSHPrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var signer ssh.Signer
|
||||
if strings.TrimSpace(c.SSHPassphrase) != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyMaterial, []byte(c.SSHPassphrase))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKey(keyMaterial)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||
}
|
||||
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 SSH 认证方式: %s", c.SSHAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func loadSSHPrivateKey(raw string) ([]byte, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("请填写私钥 PEM 或路径")
|
||||
}
|
||||
if strings.Contains(s, "BEGIN") && strings.Contains(s, "PRIVATE KEY") {
|
||||
return []byte(s), nil
|
||||
}
|
||||
path := s
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("展开 ~ 失败: %w", err)
|
||||
}
|
||||
path = filepath.Join(home, path[2:])
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func pipe(a, b net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(a, b)
|
||||
_ = a.Close()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(b, a)
|
||||
_ = b.Close()
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ApplySSHTunnel starts a local forward when sshEnabled and rewrites Host/Port to 127.0.0.1:<local>.
|
||||
// Caller must Close the returned tunnel (may be nil).
|
||||
func ApplySSHTunnel(c ConnReq) (ConnReq, *SSHTunnel, error) {
|
||||
if !c.SSHEnabled {
|
||||
return c, nil, nil
|
||||
}
|
||||
tun, err := StartLocalForward(c)
|
||||
if err != nil {
|
||||
return c, nil, fmt.Errorf("SSH: %w", err)
|
||||
}
|
||||
return rewriteHostForTunnel(c, tun.LocalPort), tun, nil
|
||||
}
|
||||
|
||||
// rewriteHostForTunnel 把连接改写到隧道本地端口(127.0.0.1:<localPort>)。
|
||||
// 改写会丢失真实 Host,导致隧道内 TLS ServerName 校验失败(127.0.0.1 不在证书 SAN 里),
|
||||
// 故改写前把原 Host 记入 TLSServerName(用户显式指定时保留其值)。
|
||||
func rewriteHostForTunnel(c ConnReq, localPort int) ConnReq {
|
||||
out := c
|
||||
if strings.TrimSpace(out.TLSServerName) == "" {
|
||||
out.TLSServerName = out.Host
|
||||
}
|
||||
out.Host = "127.0.0.1"
|
||||
out.Port = localPort
|
||||
// Avoid nested tunnel if helpers re-enter
|
||||
out.SSHEnabled = false
|
||||
return out
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// R2-B1:SSH 隧道改写 Host 后,TLS ServerName 必须保留原 Host(隧道内 TLS 校验依赖)。
|
||||
func TestRewriteHostForTunnelPreservesServerName(t *testing.T) {
|
||||
c := ConnReq{Host: "db.internal", Port: 3306, SSHEnabled: true}
|
||||
out := rewriteHostForTunnel(c, 45678)
|
||||
if out.Host != "127.0.0.1" || out.Port != 45678 {
|
||||
t.Fatalf("隧道地址改写错误: %s:%d", out.Host, out.Port)
|
||||
}
|
||||
if out.SSHEnabled {
|
||||
t.Fatal("SSHEnabled 应置 false,防嵌套隧道")
|
||||
}
|
||||
if out.TLSServerName != "db.internal" {
|
||||
t.Fatalf("TLSServerName 应保留原 Host, got %q", out.TLSServerName)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户显式指定 tlsServerName 时,隧道改写不得覆盖。
|
||||
func TestRewriteHostForTunnelKeepsExplicitServerName(t *testing.T) {
|
||||
c := ConnReq{Host: "db.internal", TLSServerName: "db.example.com", SSHEnabled: true}
|
||||
out := rewriteHostForTunnel(c, 40001)
|
||||
if out.TLSServerName != "db.example.com" {
|
||||
t.Fatalf("显式 TLSServerName 应保留, got %q", out.TLSServerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSServerNameFor(t *testing.T) {
|
||||
if got := tlsServerNameFor(ConnReq{Host: "db.example"}); got != "db.example" {
|
||||
t.Fatalf("空 tlsserverName 应用 Host, got %q", got)
|
||||
}
|
||||
if got := tlsServerNameFor(ConnReq{Host: "127.0.0.1", TLSServerName: "db.example"}); got != "db.example" {
|
||||
t.Fatalf("显式 tlsserverName 优先, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EffectiveSSLMode resolves ssl / sslMode for drivers.
|
||||
// Checkbox off or mode disable → disable. Legacy ssl:true with empty mode → require.
|
||||
func EffectiveSSLMode(c ConnReq) string {
|
||||
mode := strings.ToLower(strings.TrimSpace(c.SSLMode))
|
||||
switch mode {
|
||||
case "disable", "require", "skip-verify", "verify-ca":
|
||||
if mode == "disable" {
|
||||
return "disable"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
if c.SSL {
|
||||
return "require"
|
||||
}
|
||||
return "disable"
|
||||
}
|
||||
|
||||
// SSLEnabled is true when TLS should be negotiated.
|
||||
func SSLEnabled(c ConnReq) bool {
|
||||
return EffectiveSSLMode(c) != "disable"
|
||||
}
|
||||
|
||||
// tlsServerNameFor 返回 TLS 校验用的 ServerName:显式 tlsServerName 优先,否则用 Host。
|
||||
// SSH 隧道把 Host 改写成 127.0.0.1 之前,ApplySSHTunnel 会把原 Host 存入 TLSServerName。
|
||||
func tlsServerNameFor(c ConnReq) string {
|
||||
if v := strings.TrimSpace(c.TLSServerName); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(c.Host)
|
||||
}
|
||||
|
||||
// tlsConfigFrom builds a *tls.Config from ConnReq paths/mode.
|
||||
// Returns (nil, nil) when SSL is disabled.
|
||||
func tlsConfigFrom(c ConnReq) (*tls.Config, error) {
|
||||
mode := EffectiveSSLMode(c)
|
||||
if mode == "disable" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if host := tlsServerNameFor(c); host != "" {
|
||||
cfg.ServerName = host
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "skip-verify":
|
||||
cfg.InsecureSkipVerify = true
|
||||
case "require":
|
||||
// Encrypt; verify with system / provided CAs when present.
|
||||
case "verify-ca":
|
||||
// Prefer explicit CA; still verify chain (hostname via ServerName when set).
|
||||
default:
|
||||
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||
}
|
||||
|
||||
caContent := strings.TrimSpace(c.SSLCAContent)
|
||||
caPath := strings.TrimSpace(c.SSLCA)
|
||||
if caContent != "" || caPath != "" {
|
||||
// 内容优先于路径(免在服务端落盘/共享文件)
|
||||
var pem []byte
|
||||
if caContent != "" {
|
||||
pem = []byte(caContent)
|
||||
} else {
|
||||
var err error
|
||||
pem, err = os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CA 证书失败 (%s): %w", caPath, err)
|
||||
}
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
if caContent != "" {
|
||||
return nil, fmt.Errorf("CA 证书内容不是有效 PEM")
|
||||
}
|
||||
return nil, fmt.Errorf("CA 证书不是有效 PEM: %s", caPath)
|
||||
}
|
||||
cfg.RootCAs = pool
|
||||
if mode == "verify-ca" {
|
||||
// verify-ca: trust given CA but allow hostname mismatch (common for IP hosts)
|
||||
cfg.InsecureSkipVerify = true
|
||||
roots := pool
|
||||
cfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(rawCerts) == 0 {
|
||||
return fmt.Errorf("服务端未提供证书")
|
||||
}
|
||||
certs := make([]*x509.Certificate, 0, len(rawCerts))
|
||||
for i, raw := range rawCerts {
|
||||
cert, err := x509.ParseCertificate(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析服务端证书失败: %w", err)
|
||||
}
|
||||
if i == 0 {
|
||||
// keep leaf for Intermediates below
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
leaf := certs[0]
|
||||
inter := x509.NewCertPool()
|
||||
for _, cert := range certs[1:] {
|
||||
inter.AddCert(cert)
|
||||
}
|
||||
_, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter})
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if mode == "verify-ca" {
|
||||
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
|
||||
}
|
||||
|
||||
certContent := strings.TrimSpace(c.SSLCertContent)
|
||||
keyContent := strings.TrimSpace(c.SSLKeyContent)
|
||||
certPath := strings.TrimSpace(c.SSLCert)
|
||||
keyPath := strings.TrimSpace(c.SSLKey)
|
||||
if certContent != "" || keyContent != "" {
|
||||
// 内容与路径二选一,各自成对:证书与密钥必须同时给
|
||||
if certContent == "" || keyContent == "" {
|
||||
return nil, fmt.Errorf("客户端证书与密钥需同时填写")
|
||||
}
|
||||
cert, err := tls.X509KeyPair([]byte(certContent), []byte(keyContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载客户端证书内容失败: %w", err)
|
||||
}
|
||||
cfg.Certificates = []tls.Certificate{cert}
|
||||
} else if certPath != "" || keyPath != "" {
|
||||
if certPath == "" || keyPath == "" {
|
||||
return nil, fmt.Errorf("客户端证书与密钥需同时填写")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载客户端证书失败: %w", err)
|
||||
}
|
||||
cfg.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEffectiveSSLMode(t *testing.T) {
|
||||
if got := EffectiveSSLMode(ConnReq{}); got != "disable" {
|
||||
t.Fatalf("empty -> disable, got %s", got)
|
||||
}
|
||||
if got := EffectiveSSLMode(ConnReq{SSL: true}); got != "require" {
|
||||
t.Fatalf("ssl true -> require, got %s", got)
|
||||
}
|
||||
if got := EffectiveSSLMode(ConnReq{SSL: true, SSLMode: "skip-verify"}); got != "skip-verify" {
|
||||
t.Fatalf("mode wins, got %s", got)
|
||||
}
|
||||
if got := EffectiveSSLMode(ConnReq{SSL: true, SSLMode: "disable"}); got != "disable" {
|
||||
t.Fatalf("explicit disable, got %s", got)
|
||||
}
|
||||
if !SSLEnabled(ConnReq{SSLMode: "require"}) {
|
||||
t.Fatal("require should enable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigFromDisable(t *testing.T) {
|
||||
cfg, err := tlsConfigFrom(ConnReq{})
|
||||
if err != nil || cfg != nil {
|
||||
t.Fatalf("disable => nil,nil got %#v %v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigFromSkipVerify(t *testing.T) {
|
||||
cfg, err := tlsConfigFrom(ConnReq{SSL: true, SSLMode: "skip-verify", Host: "db.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg == nil || !cfg.InsecureSkipVerify {
|
||||
t.Fatalf("expected InsecureSkipVerify, got %#v", cfg)
|
||||
}
|
||||
if cfg.ServerName != "db.example" {
|
||||
t.Fatalf("ServerName %q", cfg.ServerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigFromVerifyCARequiresPath(t *testing.T) {
|
||||
_, err := tlsConfigFrom(ConnReq{SSLMode: "verify-ca"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error without CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigFromVerifyCAWithPEM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caPath := filepath.Join(dir, "ca.pem")
|
||||
if err := writeTestCA(caPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := tlsConfigFrom(ConnReq{SSLMode: "verify-ca", SSLCA: caPath, Host: "127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg == nil || cfg.RootCAs == nil {
|
||||
t.Fatal("expected RootCAs")
|
||||
}
|
||||
if cfg.VerifyPeerCertificate == nil {
|
||||
t.Fatal("expected custom verify for verify-ca")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigFromBadCAPath(t *testing.T) {
|
||||
_, err := tlsConfigFrom(ConnReq{SSLMode: "require", SSLCA: "/no/such/ca.pem"})
|
||||
if err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
}
|
||||
|
||||
// R2-B1:显式 tlsServerName 优先于 Host(tlsConfigFrom 层)。
|
||||
func TestTLSConfigFromTLSServerName(t *testing.T) {
|
||||
cfg, err := tlsConfigFrom(ConnReq{SSLMode: "require", Host: "127.0.0.1", TLSServerName: "db.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg == nil || cfg.ServerName != "db.example.com" {
|
||||
t.Fatalf("ServerName 应取 tlsServerName, got %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// R2-B2:CA/客户端证书内容优先于路径;成对校验仍然生效。
|
||||
func TestTLSConfigFromContentCerts(t *testing.T) {
|
||||
caPEM, certPEM, keyPEM := genTestCertMaterials(t)
|
||||
|
||||
cfg, err := tlsConfigFrom(ConnReq{
|
||||
SSLMode: "require", Host: "db.example.com",
|
||||
SSLCAContent: caPEM, SSLCertContent: certPEM, SSLKeyContent: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg == nil || cfg.RootCAs == nil {
|
||||
t.Fatal("内容 CA 应构建 RootCAs")
|
||||
}
|
||||
if len(cfg.Certificates) != 1 {
|
||||
t.Fatal("内容客户端证书应加载")
|
||||
}
|
||||
if cfg.ServerName != "db.example.com" {
|
||||
t.Fatalf("ServerName 错误: %q", cfg.ServerName)
|
||||
}
|
||||
|
||||
// 内容 CA 非法 PEM 应报错(不回退路径)
|
||||
if _, err := tlsConfigFrom(ConnReq{SSLMode: "require", SSLCAContent: "not-a-pem"}); err == nil {
|
||||
t.Fatal("非法 CA 内容应报错")
|
||||
}
|
||||
// 证书与密钥仍需成对:只给内容证书不给内容密钥
|
||||
if _, err := tlsConfigFrom(ConnReq{SSLMode: "require", SSLCertContent: certPEM}); err == nil {
|
||||
t.Fatal("半对内容证书应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// genTestCertMaterials 生成自签名 CA 与客户端证书 PEM 串。
|
||||
func genTestCertMaterials(t *testing.T) (caPEM, certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "opsxdb-test-cert"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
return certPEM, certPEM, keyPEM // 自签名,CA 与客户端证书同一张
|
||||
}
|
||||
|
||||
func writeTestCA(path string) error {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "opsxdb-test-ca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const DefaultRowLimit = 1000
|
||||
const MaxRowLimit = 10000
|
||||
|
||||
// ConnReq 连接 / 执行请求。sessionId 有值时服务端用会话里的账密,请求里的 password 可空。
|
||||
type ConnReq struct {
|
||||
Driver string `json:"driver"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
Service string `json:"service"`
|
||||
SSL bool `json:"ssl"`
|
||||
SSLMode string `json:"sslMode"` // disable|require|skip-verify|verify-ca
|
||||
SSLCA string `json:"sslCA"`
|
||||
SSLCert string `json:"sslCert"`
|
||||
SSLKey string `json:"sslKey"`
|
||||
// 证书 PEM 内容,优先于上面的文件路径(客户端与服务端分离部署时免共享文件)
|
||||
SSLCAContent string `json:"sslCAContent"`
|
||||
SSLCertContent string `json:"sslCertContent"`
|
||||
SSLKeyContent string `json:"sslKeyContent"`
|
||||
// TLSServerName 显式指定 TLS 校验用的 ServerName;SSH 隧道改写 Host 前
|
||||
// 会把原 Host 自动填入,避免隧道内 TLS 校验失败。
|
||||
TLSServerName string `json:"tlsServerName"`
|
||||
|
||||
SessionID string `json:"sessionId"`
|
||||
Table string `json:"table"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"` // 结果分页:跳过前 offset 行再按 limit 收集
|
||||
Key string `json:"key"` // Redis key (get/delete)
|
||||
Pattern string `json:"pattern"` // Redis SCAN match
|
||||
Cursor int64 `json:"cursor"` // Redis 值分页游标(hash/set=SCAN 游标;list/zset=偏移)
|
||||
Keyword string `json:"keyword"` // 对象搜索关键词
|
||||
Format string `json:"format"` // 导出格式: csv | json | insert
|
||||
|
||||
// TimeoutMs > 0 时 /db/exec、/db/explain 以此为语句超时
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
// RequestID 非空时注册 in-flight 注册表,可通过 /api/db/cancel 取消
|
||||
RequestID string `json:"requestId"`
|
||||
|
||||
// ConfirmDanger 为 true 表示前端已完成危险语句确认,服务端放行本次执行。
|
||||
ConfirmDanger bool `json:"confirmDanger"`
|
||||
|
||||
SSHEnabled bool `json:"sshEnabled"`
|
||||
SSHHost string `json:"sshHost"`
|
||||
SSHPort int `json:"sshPort"`
|
||||
SSHUser string `json:"sshUser"`
|
||||
SSHAuth string `json:"sshAuth"` // password | privateKey
|
||||
SSHPassword string `json:"sshPassword"`
|
||||
SSHPrivateKey string `json:"sshPrivateKey"`
|
||||
SSHPassphrase string `json:"sshPassphrase"`
|
||||
|
||||
// Redis 拓扑:standalone(默认)| sentinel | cluster
|
||||
RedisMode string `json:"redisMode"`
|
||||
// Sentinel/Cluster 节点列表,host:port 多行或逗号分隔
|
||||
RedisNodes string `json:"redisNodes"`
|
||||
// Sentinel 主节点名(必填)
|
||||
MasterName string `json:"masterName"`
|
||||
// Sentinel 自身密码(可空);Redis 密码仍用 Password
|
||||
SentinelPassword string `json:"sentinelPassword"`
|
||||
}
|
||||
|
||||
type ColumnInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Nullable bool `json:"nullable"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ExecResult 与前端 QueryResult 字段对齐
|
||||
type ExecResult struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
RowCount int `json:"rowCount"`
|
||||
TimeMs int64 `json:"timeMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
// NeedConfirm 为 true 表示命中危险语句,需前端确认后带 confirmDanger 重发。
|
||||
NeedConfirm bool `json:"needConfirm,omitempty"`
|
||||
// Redis 值游标分页:>0 还有更多(HGETALL/SMEMBERS/ZRANGE/LRANGE 单键读透传)
|
||||
NextCursor int64 `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// ExplainResult 执行计划结果:行列(计划表格)与 Text(XML/文本计划)二选一或兼有。
|
||||
type ExplainResult struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Rows []map[string]interface{} `json:"rows,omitempty"`
|
||||
Text string `json:"text,omitempty"` // MSSQL SHOWPLAN_XML / Oracle 文本计划
|
||||
TimeMs int64 `json:"timeMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func normalize(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case int64:
|
||||
// bigint 超 JS 安全整数时转字符串保真,避免前端精度丢失错行
|
||||
if t > 9007199254740991 || t < -9007199254740991 {
|
||||
return strconv.FormatInt(t, 10)
|
||||
}
|
||||
return t
|
||||
case []byte:
|
||||
if utf8.Valid(t) {
|
||||
return string(t)
|
||||
}
|
||||
// BLOB 等非 UTF-8 数据以十六进制标注返回,替代乱码
|
||||
return `\x` + hex.EncodeToString(t)
|
||||
case time.Time:
|
||||
return t.Format(time.RFC3339Nano)
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// Redis key browse (dedicated endpoints; do not reshape SQL tables []).
|
||||
type RedisKeyInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"` // seconds; -1 no expire; -2 missing
|
||||
}
|
||||
|
||||
type RedisKeysResult struct {
|
||||
Keys []RedisKeyInfo `json:"keys"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TimeMs int64 `json:"timeMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RedisKeyValue feeds ResultPane (columns/rows) plus type/ttl meta.
|
||||
type RedisKeyValue struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
RowCount int `json:"rowCount"`
|
||||
TimeMs int64 `json:"timeMs"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
NextCursor int64 `json:"nextCursor"` // >0 表示还有更多(续载时回传 cursor)
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeBigint(t *testing.T) {
|
||||
// 超 JS 安全整数 → 字符串保真
|
||||
if got := normalize(int64(9223372036854775807)); got != "9223372036854775807" {
|
||||
t.Fatalf("bigint max: %#v", got)
|
||||
}
|
||||
if got := normalize(int64(-9223372036854775807)); got != "-9223372036854775807" {
|
||||
t.Fatalf("bigint min: %#v", got)
|
||||
}
|
||||
// 安全范围内保持 number
|
||||
if got := normalize(int64(9007199254740991)); got != int64(9007199254740991) {
|
||||
t.Fatalf("safe int: %#v", got)
|
||||
}
|
||||
if got := normalize(int64(9007199254740992)); got != "9007199254740992" {
|
||||
t.Fatalf("边界+1 应转字符串: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBytes(t *testing.T) {
|
||||
if got := normalize([]byte("hello")); got != "hello" {
|
||||
t.Fatalf("utf8 bytes: %#v", got)
|
||||
}
|
||||
if got := normalize([]byte("中文")); got != "中文" {
|
||||
t.Fatalf("utf8 中文: %#v", got)
|
||||
}
|
||||
// 非 UTF-8 → \x 十六进制
|
||||
if got := normalize([]byte{0xff, 0x00, 0x01}); got != `\xff0001` {
|
||||
t.Fatalf("blob hex: %#v", got)
|
||||
}
|
||||
if got := normalize(nil); got != nil {
|
||||
t.Fatalf("nil: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMssqlDSNEncoding(t *testing.T) {
|
||||
// 含空格 / '+' 的密码应经 userinfo 编码,不被 QueryEscape 破坏
|
||||
dsn, err := mssqlDSN(ConnReq{Driver: "sqlserver", Host: "h1", Port: 1433, User: "sa", Password: "p@ ss+word", Database: "db1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "p%40%20ss+word"
|
||||
if !strings.Contains(dsn, want) {
|
||||
t.Fatalf("DSN 密码编码不符合预期: %s(应含 %s)", dsn, want)
|
||||
}
|
||||
if !strings.Contains(dsn, "sqlserver://sa:") || !strings.Contains(dsn, "@h1:1433") {
|
||||
t.Fatalf("DSN 结构错误: %s", dsn)
|
||||
}
|
||||
if !strings.Contains(dsn, "database=db1") {
|
||||
t.Fatalf("DSN 缺 database: %s", dsn)
|
||||
}
|
||||
// verify-ca 无 CA 应报错
|
||||
if _, err := mssqlDSN(ConnReq{Driver: "sqlserver", Host: "h1", SSLMode: "verify-ca"}); err == nil {
|
||||
t.Fatal("verify-ca 缺 CA 应报错")
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/* OpsxDB 文档站 —— 清爽专业风格(浅色) */
|
||||
:root {
|
||||
--blue: #059669;
|
||||
--blue-dark: #047857;
|
||||
--blue-soft: rgba(5, 150, 105, 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(5, 150, 105, 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(5, 150, 105, 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: #f7fbf9; 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; }
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/* OpsxDB 文档站交互:搜索 / 代码复制 / 目录高亮 / 反馈 / 移动端导航 */
|
||||
(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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
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 = "opsxdb-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");
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,40 +0,0 @@
|
||||
OpsxDB 是一款**本地优先**的桌面数据库工作台,面向运维与开发:连库、写 SQL、看结果,全在本机完成。
|
||||
|
||||
主交付形态是可安装的桌面应用(Tauri 2),本地 Go 后端随应用自动启动;同一套界面也可用单个二进制在 Web / 内网部署。数据全部保存在本机,不经过任何第三方服务器。
|
||||
|
||||
## 功能总览
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 多驱动连库 | MySQL 族 / PostgreSQL 族 / SQL Server / Oracle / SQLite / Redis,native Go 驱动编译内置 |
|
||||
| 连接选择器 | 搜索 / 分类 / 最近 / 图标网格;粘贴连接 URL 一键填入表单 |
|
||||
| SQL 工作台 | CodeMirror 编辑器、⌘/Ctrl+Enter 执行语句或选区、多 Tab、可拖分栏 |
|
||||
| 对象树 | 懒加载库 / 表 / 列;点表名直接看前 100 行 |
|
||||
| 结果处理 | 一键复制 / CSV / 生成 INSERT;双击单元格复制;截断有提示 |
|
||||
| Redis 工作台 | 编辑器执行 PING / GET / SET / DEL / KEYS,键空间浏览 |
|
||||
| 安全通道 | 每连接独立配置 SSL/TLS 与 SSH 隧道;密码本机混淆存储 |
|
||||
| AI 面板 | 生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL |
|
||||
| 危险操作防护 | DROP / TRUNCATE / 无 WHERE 的 UPDATE·DELETE 执行前确认 |
|
||||
| 桌面唯一形态 | 桌面 GUI(Tauri 2),本地后端随应用自动拉起 |
|
||||
|
||||
## 支持的数据库
|
||||
|
||||
| 家族 | 覆盖 | 默认端口 |
|
||||
| --- | --- | --- |
|
||||
| MySQL 族 | MySQL · MariaDB · TiDB · OceanBase · PolarDB · TDSQL | 3306 |
|
||||
| PostgreSQL 族 | PostgreSQL · GaussDB / openGauss · 人大金仓 | 5432 |
|
||||
| SQL Server | 原生协议直连 | 1433 |
|
||||
| Oracle | 纯 Go 驱动 go-ora(不引入 JVM) | 1521 |
|
||||
| SQLite | 本地文件库 | — |
|
||||
| Redis | 命令执行 + 键管理 | 6379 |
|
||||
|
||||
> **提示**:达梦 DM8、MongoDB、ZooKeeper、Elasticsearch、InfluxDB 已列入路线,规划 agent 按需接入。
|
||||
|
||||
## 为什么选 OpsxDB
|
||||
|
||||
- **数据主权**:连接配置与查询历史只存在本机;后端默认仅监听 127.0.0.1。
|
||||
- **开箱即用**:桌面版双击即用,本地后端随应用自动拉起;官网与文档也由它内置提供。
|
||||
- **为快设计**:点表即看数据、⌘Enter 即执行、结果三键取走(复制 / CSV / INSERT)。
|
||||
- **轻量克制**:一个 Go 二进制 + 一份前端,内网可部署,无遥测无账号。
|
||||
|
||||
> **提示**:想马上动手?直接看下一章《快速开始》,30 秒连上第一个库。
|
||||
@@ -1,62 +0,0 @@
|
||||
本章从零到出结果:起服务 → 建连接 → 执行第一条 SQL。
|
||||
|
||||
## 1. 启动
|
||||
|
||||
桌面版(主交付形态)双击 OpsxDB 应用即可,本地后端随应用自动拉起。
|
||||
|
||||
从源码运行(开发):
|
||||
|
||||
```bash
|
||||
git clone <repo> opsxdb && cd opsxdb
|
||||
npm install
|
||||
|
||||
./run.sh gui # 桌面形态(开发):Tauri 窗口 + 本地后端
|
||||
./run.sh web # 仅启动官网与文档 → http://127.0.0.1:8788/
|
||||
./run.sh package # 打桌面安装包(DMG,内嵌后端)
|
||||
```
|
||||
|
||||
> **注意**:客户端只在桌面 App 里运行。`./run.sh gui` 起的 `:5174` 是桌面 devUrl 的资产服务,不是浏览器客户端;浏览器只用于官网 `/` 与文档 `/docs`(后端 `:8788` 直接提供)。
|
||||
|
||||
## 2. 新建连接
|
||||
|
||||
1. 打开应用,进入**连接选择器**,选择数据库类型(如 MySQL);
|
||||
2. 或者直接粘贴连接 URL,点「填入」自动拆解字段:
|
||||
|
||||
```
|
||||
mysql://root:pass@127.0.0.1:3306/shop
|
||||
```
|
||||
|
||||
3. 点 **测试** 验证连通性,再点 **保存并连接**。
|
||||
|
||||
连接配置保存在本机,密码混淆存储;后续查询只携带会话 ID,不再上送账密。
|
||||
|
||||
## 3. 第一次查询
|
||||
|
||||
1. 左侧对象树展开库,**点表名**直接查看前 100 行(展开表可看列结构);
|
||||
2. 打开 SQL 编辑器,输入:
|
||||
|
||||
```sql
|
||||
SELECT * FROM orders ORDER BY id DESC LIMIT 100;
|
||||
```
|
||||
|
||||
3. 按 **⌘/Ctrl + Enter** 执行当前语句(或选中的片段);
|
||||
4. 结果面板支持**复制 / 导出 CSV / 生成 INSERT**,双击单元格即复制。
|
||||
|
||||
## 4. 验证服务
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8788/health
|
||||
# {"ok":true,"service":"opsxdb"}
|
||||
```
|
||||
|
||||
## 5. 手边没有数据库?
|
||||
|
||||
本地还没有可连的库?用仓库自带的辅助脚本一键起一个(需要 Docker,一次只起一个):
|
||||
|
||||
```bash
|
||||
./tests/dbctl.sh up a # MySQL
|
||||
./tests/dbctl.sh up c # PostgreSQL(用户 opsx)
|
||||
./tests/dbctl.sh up f # Redis
|
||||
```
|
||||
|
||||
各驱动的验收状态见《驱动支持》。
|
||||
@@ -1,41 +0,0 @@
|
||||
OpsxDB 的界面为「高频操作」设计:从打开应用到看到数据,路径越短越好。
|
||||
|
||||
## 连接选择器
|
||||
|
||||
启动后的第一个界面:
|
||||
|
||||
- **搜索**:按名称快速过滤已保存的连接;
|
||||
- **分类**:关系型 / NoSQL / 国产库 / 时序·搜索 分组浏览;
|
||||
- **最近**:最近使用的连接排在最前;
|
||||
- **图标网格**:按数据库类型直选,未就绪的类型会标注「未就绪」(仅可保存配置);
|
||||
- **URL 填入**:粘贴 `mysql://user:pass@host:3306/db` 点「填入」,字段自动拆解。
|
||||
|
||||
## 工作台布局
|
||||
|
||||
连接成功后进入工作台,三栏结构:
|
||||
|
||||
| 区域 | 内容 |
|
||||
| --- | --- |
|
||||
| 左侧 | 连接列表 + 对象树(库 → 表 → 列,懒加载) |
|
||||
| 中部 | SQL 编辑器(多 Tab)+ 结果面板,可拖动分栏 |
|
||||
| 右侧 | AI 面板(生成 / 解释 / 优化 / 修复 / 方言 / 样例) |
|
||||
|
||||
## 对象树
|
||||
|
||||
- 点**表名**:直接打开前 100 行数据;
|
||||
- 展开**表**:查看列结构;
|
||||
- Redis:按键分隔符编文件夹,点键打开类型化详情(TTL / JSON / 增删字段);
|
||||
- 库列表懒加载,大实例也不卡。
|
||||
|
||||
## 编辑器与结果
|
||||
|
||||
- CodeMirror 语法高亮,⌘/Ctrl+Enter 执行**当前语句或选区**(不是整页脚本);
|
||||
- 多 Tab 并行查询,互不阻塞;
|
||||
- 结果面板:复制 / CSV / INSERT 三种取数方式,双击单元格复制;
|
||||
- 超过截断阈值时结果有明确提示。
|
||||
|
||||
## 个性化
|
||||
|
||||
- 深色 / 浅色主题切换;
|
||||
- Tab 布局与主题选择持久化,下次打开保持原样;
|
||||
- 查询历史本地留存,可回跳重跑。
|
||||
@@ -1,50 +0,0 @@
|
||||
本章说明各数据库类型的连接表单、URL 格式与高级选项(SSL / SSH 隧道)。
|
||||
|
||||
## 通用流程
|
||||
|
||||
1. 连接选择器点「新建」或选某个数据库类型;
|
||||
2. 填写表单(不同驱动表单不同,见下表);
|
||||
3. **测试** → 通过后 **保存并连接**。
|
||||
|
||||
支持直接粘贴 URL 自动填表:
|
||||
|
||||
```
|
||||
mysql://root:pass@127.0.0.1:3306/shop
|
||||
postgres://opsx:pass@127.0.0.1:5432/app
|
||||
```
|
||||
|
||||
## 各驱动表单
|
||||
|
||||
| 驱动 | 表单 | 特有字段 |
|
||||
| --- | --- | --- |
|
||||
| MySQL / PostgreSQL / SQL Server | standard | host · port · user · password · database · ssl · ssh |
|
||||
| Oracle | oracle | host · port · user · password · **service**(如 ORCL) |
|
||||
| SQLite | file | database 填本地文件路径(如 `~/data/demo.sqlite`) |
|
||||
| Redis | redis | host · port · password · database 序号 · **模式**(单机 / 哨兵 / 集群)· 键分隔符 |
|
||||
|
||||
表单只收集服务端实际消费的字段;历史遗留的死参数不会出现在表单里。
|
||||
|
||||
## MySQL 族与 PostgreSQL 族
|
||||
|
||||
协议兼容的国产库无需单独驱动,直接复用家族驱动:
|
||||
|
||||
- **MySQL 族**:MariaDB(3307)、TiDB(4000)、OceanBase(2881)、PolarDB / TDSQL(3306)—— 表单里选对应类型即可;
|
||||
- **PostgreSQL 族**:GaussDB / openGauss、人大金仓 Kingbase(54321)—— 走 postgres 表单,注意默认库与用户名差异。
|
||||
|
||||
## SSL / TLS
|
||||
|
||||
连接表单中开启 **ssl** 即可;按连接独立配置,互不影响。内网自签证书场景按需放宽校验策略。
|
||||
|
||||
## SSH 隧道
|
||||
|
||||
数据库在跳板机后面时,开启 **ssh** 字段组,填写跳板机地址与凭据:OpsxDB 会先建 SSH 隧道,再把数据库连接走隧道转发,无需把库端口暴露到公网。
|
||||
|
||||
## Redis 哨兵与集群
|
||||
|
||||
- **哨兵**:模式选 Sentinel,填写 Sentinel 节点列表(`host:port`,多行)和主节点名 `masterName`;
|
||||
- **集群**:模式选 Cluster,填写至少一个种子节点。对象树只显示 DB 0(Cluster 无 `SELECT`);
|
||||
- 单机模式只填 host/port,与原先一致。
|
||||
|
||||
字段细节与键浏览见 [Redis 工作台](redis)。
|
||||
|
||||
> **提示**:连接保存后,后续查询仅携带 `sessionId`,账密不会随每次请求上送;密码在本机混淆存储。
|
||||
@@ -1,41 +0,0 @@
|
||||
OpsxDB 的常用数据库驱动以 native Go 驱动编译内置,开箱即用;更多数据库在路线中。
|
||||
|
||||
## 内置驱动
|
||||
|
||||
| 驱动 | 连接库 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| mysql | MySQL · MariaDB · TiDB · OceanBase · PolarDB · TDSQL | 协议族复用,默认端口 3306 |
|
||||
| postgres | PostgreSQL · GaussDB / openGauss · Kingbase | 协议族复用,默认端口 5432 |
|
||||
| sqlserver | SQL Server | 原生协议,默认端口 1433 |
|
||||
| oracle | Oracle | go-ora 纯 Go 驱动,不引入 JVM,端口 1521 |
|
||||
| sqlite | SQLite | 本地文件库,无端口 |
|
||||
| redis | Redis | 默认端口 6379 |
|
||||
|
||||
## 支持状态
|
||||
|
||||
以下为逐项实测的结果 —— 你可以按这个表判断「现在就能连什么」:
|
||||
|
||||
| 数据库 | 连接 | 库表浏览 | 数据操作 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| MySQL / MariaDB | ✅ | ✅ | ✅ | MariaDB 默认端口 3307 |
|
||||
| PostgreSQL | ✅ | ✅ | ✅ | GaussDB / 金仓同样适用 |
|
||||
| SQL Server | ✅ | ✅ | ✅ | Apple Silicon 上连接稍慢属正常 |
|
||||
| SQLite | ✅ | ✅ | ✅ | 填本地文件路径即连 |
|
||||
| Redis | ✅ | ✅ | ✅ | 支持命令执行与键管理 |
|
||||
| Oracle | 代码就绪 | ✅ | 待实测 | 见下方说明 |
|
||||
|
||||
> **说明(Oracle)**:驱动已内置(纯 Go 实现,无需安装 Java),库表浏览可用;完整数据操作正在验收中 —— 在 Apple Silicon 的容器环境会遇到 Oracle 限制(ORA-00442),x86 环境或真实数据库不受影响。
|
||||
|
||||
## 暂不支持(规划中)
|
||||
|
||||
| 数据库 | 状态 |
|
||||
| --- | --- |
|
||||
| 达梦 DM8 | 规划中,暂不可用 |
|
||||
| MongoDB | 规划中,暂不可用 |
|
||||
| ZooKeeper / Elasticsearch / InfluxDB | 规划中,暂不可用 |
|
||||
|
||||
> **注意**:选择器中标注「未就绪」的类型仍可保存连接配置,但不能测试 / 连接;开放支持后会随版本更新自动可用。
|
||||
|
||||
## 后续驱动的接入方式
|
||||
|
||||
常用库继续以原生驱动内置;小众数据库(达梦、MongoDB 等)将通过**按需下载**方式接入 —— 首次使用时下载对应驱动组件,版本固定并校验完整性(sha256),不随包体膨胀。
|
||||
@@ -1,47 +0,0 @@
|
||||
SQL 工作台是日常使用率最高的界面,所有交互围绕「快」设计。
|
||||
|
||||
## 执行模型
|
||||
|
||||
- **⌘/Ctrl + Enter**:执行光标所在的**当前语句**;有选区时执行**选区**;
|
||||
- 不是「整页脚本」模式 —— 多条语句互不干扰,想跑哪句把光标放哪;
|
||||
- Redis 连接共用同一编辑器,按**行**执行命令(见 [Redis 工作台](redis)),不另开 CLI;
|
||||
- 执行在会话上运行,连接断开后自动提示。
|
||||
|
||||
## 多 Tab 与分栏
|
||||
|
||||
- 每个查询一个 Tab,并行执行互不阻塞;
|
||||
- 编辑器 / 结果区可拖动分栏,长查询时编辑器不被结果挤占;
|
||||
- Tab 与布局持久化,重开应用保持原样。
|
||||
|
||||
## 对象树联动
|
||||
|
||||
- 左侧对象树懒加载库 / 表 / 列;
|
||||
- **点表名 = 查询前 100 行**,自动生成对应 SQL;
|
||||
- 展开表看列名与类型,写 SQL 时可对照。
|
||||
|
||||
## 查询历史
|
||||
|
||||
- 本地留存最近 200 条(不上传、不同步);
|
||||
- **相同语句自动去重置顶** —— 历史即「最近用过的不同语句」,高频查询永远在第一条;
|
||||
- 点击条目回填编辑器;悬停出现 **▶** 按钮,回填并直接重跑;
|
||||
- 顶部过滤框支持按语句内容或连接名搜索;
|
||||
- 条目显示时间、连接名、行数与耗时;失败的语句红色标出。
|
||||
|
||||
## 危险 SQL 防护
|
||||
|
||||
执行以下语句前会弹确认框:
|
||||
|
||||
| 类型 | 规则 |
|
||||
| --- | --- |
|
||||
| 删除对象 | DROP / TRUNCATE |
|
||||
| 全表更新 | 无 WHERE 的 UPDATE / DELETE |
|
||||
|
||||
> **注意**:确认框只拦「明确危险」的模式,不替代事务与备份习惯;生产库操作请配合审批流程。
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 快捷键 | 动作 |
|
||||
| --- | --- |
|
||||
| ⌘/Ctrl + Enter | 执行当前语句 / 选区 |
|
||||
| 双击结果单元格 | 复制单元格内容 |
|
||||
| Esc | 关闭弹层 / 取消确认 |
|
||||
@@ -1,27 +0,0 @@
|
||||
查询跑完只是开始,把结果带走才算完成 —— OpsxDB 给了三条最快路径。
|
||||
|
||||
## 三种取数方式
|
||||
|
||||
| 方式 | 用途 |
|
||||
| --- | --- |
|
||||
| **复制** | 直接进剪贴板,贴到群聊 / 文档 / 表格 |
|
||||
| **CSV** | 导出标准 CSV 文件,Excel / Numbers 直接打开 |
|
||||
| **INSERT** | 把结果行生成为 INSERT 语句,直接可在别的库回放 |
|
||||
|
||||
## 单元格操作
|
||||
|
||||
- **双击单元格**:复制该单元格内容;
|
||||
- 列头即字段名,长文本自动截断显示,完整内容在复制时保留。
|
||||
|
||||
## 截断提示
|
||||
|
||||
为保护界面与内存,超大结果集会按阈值截断展示:
|
||||
|
||||
- 结果面板顶部显示「已截断」提示与实际行数;
|
||||
- 需要完整数据时,缩小查询范围(加 LIMIT / WHERE)或使用导出。
|
||||
|
||||
> **提示**:默认「点表名看前 100 行」就是截断策略的一部分 —— 快速浏览用 100 行,精确取数写 WHERE。
|
||||
|
||||
## 与 AI 面板联动
|
||||
|
||||
写不出来的查询可以直接在 AI 面板用自然语言描述(「查最近 7 天下单但未支付的用户」),生成 SQL 后放回编辑器执行,见《AI 助手》。
|
||||
@@ -1,93 +0,0 @@
|
||||
Redis 连接使用专用表单(redis)。连上后,对象树是键空间浏览器,编辑器是 Redis 命令行 —— **和 SQL 共用同一套输入框**,不另开 CLI 页。
|
||||
|
||||
## 为什么命令行不单独做一套
|
||||
|
||||
SQL 工作台已经有:多 Tab、⌘/Ctrl+Enter、查询历史、书签、危险确认、取消。Redis 命令同样是「输入 → 执行 → 结果表」。
|
||||
|
||||
拆成独立 CLI(终端 REPL)会让快捷键、历史、危险确认分叉,也和 Navicat 式多引擎工作台不一致。
|
||||
|
||||
因此:
|
||||
|
||||
| | SQL | Redis |
|
||||
| --- | --- | --- |
|
||||
| 输入框 | 同一 CodeMirror | 同一 CodeMirror(占位/补全换成命令) |
|
||||
| 切分 | 分号语句 | **按行**(`SET k "a;b"` 不会被分号拆开) |
|
||||
| 执行 | ⌘/Ctrl+Enter 当前语句或选区 | ⌘/Ctrl+Enter 当前行或选区 |
|
||||
| 结果 | 结果表 | 命令结果表;从对象树打开键则进入键详情 |
|
||||
|
||||
Another Redis Desktop Manager 的独立 CLI Tab、opsxsql 的单行命令 + 输出日志,列为后续 P1,不是现在的主路径。
|
||||
|
||||
## 键空间浏览
|
||||
|
||||
对象树对 Redis 显示 DB0–15(Cluster 仅 0)。展开库后:
|
||||
|
||||
- **SCAN 过滤**:输入 `user:*` 点「应用」;生产大库请带前缀,避免扫全库。最多展示 500 个键,超出提示截断。
|
||||
- **按分隔符编文件夹**:连接表单的「键分隔符」默认 `:`,`CEX:FOLLOW:FOLLOWERS:1` 显示为 `CEX / FOLLOW / FOLLOWERS / 1`。文件夹显示数量;右键可「只看此前缀」「在此新建键」。
|
||||
- **类型色点**:string 绿 / hash 蓝 / list 橙 / set 紫 / zset 青。
|
||||
- **点键**:打开键详情(不是假 SELECT)。
|
||||
|
||||
## 键详情
|
||||
|
||||
对标 Another Redis Desktop Manager 的 Hash/List/Set 视图:
|
||||
|
||||
- 类型徽章、键名(回车重命名)、TTL(回车或「保存 TTL」,`-1` 永久)、刷新、导出还原命令、删除;
|
||||
- **结构**表:Hash 为 ID / Field / Value,JSON 值自动美化;可新增字段、编辑、删除;
|
||||
- **JSON** 视图:整体格式化;
|
||||
- 表内关键字过滤。
|
||||
|
||||
String 可直接改值保存。
|
||||
|
||||
## 命令执行
|
||||
|
||||
编辑器直接写 Redis 命令:
|
||||
|
||||
```bash
|
||||
PING
|
||||
SET demo:key "hello" EX 60
|
||||
GET demo:key
|
||||
HGETALL demo:hash
|
||||
SCAN 0 MATCH demo:* COUNT 100
|
||||
INFO memory
|
||||
```
|
||||
|
||||
- 未点名的命令走通用 `Do`(`EXISTS` / `ECHO` / `INFO` / `CLIENT LIST` 等不必再加白名单);
|
||||
- `FLUSHALL` / `FLUSHDB` / `KEYS` / `CONFIG` 仍要二段确认;
|
||||
- `KEYS *` 在大库上会阻塞 —— 请用 `SCAN MATCH`。
|
||||
|
||||
### 切换逻辑库(SELECT)
|
||||
|
||||
`SELECT 1` 是 Redis 官方的切库命令,编辑器直接支持 —— 语义与 redis-cli 一致:**切换后本 Tab 的后续命令都在目标库执行**:
|
||||
|
||||
```bash
|
||||
SELECT 1
|
||||
GET db1:only:key # 在 db1 执行
|
||||
```
|
||||
|
||||
- 没有也没有 `USE`(那是 SQL 语法)—— 输入 `USE db1` 会得到 Redis 原生报错
|
||||
`ERR unknown command 'USE'`,与 redis-cli 行为一致;
|
||||
- 切库只影响当前查询 Tab(多 Tab 可各连各的库),左侧对象树点开对应库可浏览键;
|
||||
- Cluster 模式只有 DB 0,`SELECT` 无效。
|
||||
|
||||
## 连接参数
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| host / port | 单机默认 127.0.0.1:6379 |
|
||||
| password / user | ACL / requirepass |
|
||||
| database | 逻辑库序号(0–15 常见;Cluster 忽略) |
|
||||
| 键分隔符 | 对象树文件夹分隔,默认 `:` |
|
||||
| ssl | TLS |
|
||||
| redisMode | `standalone`(默认)/ `sentinel` / `cluster` |
|
||||
| redisNodes | Sentinel / Cluster 节点列表,`host:port` 多行或逗号分隔 |
|
||||
| masterName | Sentinel 主节点名(必填) |
|
||||
| sentinelPassword | Sentinel 密码(可空) |
|
||||
|
||||
### Sentinel
|
||||
|
||||
模式选「哨兵」,填写至少一个 Sentinel 地址和 `masterName`(如 `mymaster`)。逻辑库序号仍可用。
|
||||
|
||||
### Cluster
|
||||
|
||||
模式选「集群」,填写至少一个种子节点。Cluster 不支持 `SELECT` 多库,对象树只暴露 DB 0。槽位路由由客户端处理,键浏览走 `SCAN`。
|
||||
|
||||
> **环境**:Sentinel / Cluster 表单与后端已接好;真实拓扑联调见 `docs/BACKLOG.md` 环境缺口。
|
||||
@@ -1,35 +0,0 @@
|
||||
连生产库的工具,安全必须是默认项。OpsxDB 的安全设计围绕一句话:**数据不出你的机器。**
|
||||
|
||||
## 本地优先
|
||||
|
||||
- 连接配置、查询历史、主题偏好全部保存在本机;
|
||||
- 无账号体系、无遥测、无云端同步;
|
||||
- 后端默认只监听 `127.0.0.1`,对外服务需要显式配置(`OPSXDB_BIND_PUBLIC=1` 或 `OPSXDB_ADDR`)。
|
||||
|
||||
## 凭据保护
|
||||
|
||||
| 层面 | 机制 |
|
||||
| --- | --- |
|
||||
| 存储 | 连接密码在本机混淆存储,不落明文 |
|
||||
| 传输 | 连接建立后,后续查询只携带 `sessionId`,账密不再每次上送 |
|
||||
| 会话 | 会话在服务端维护,可主动关闭(连接断开即失效) |
|
||||
|
||||
## 加密通道
|
||||
|
||||
**SSL / TLS**:每个连接可独立开启;数据库支持 TLS 即可启用(MySQL、PostgreSQL、SQL Server、Redis、MongoDB 均支持)。
|
||||
|
||||
**SSH 隧道**:数据库在跳板机后时,配置 SSH 字段组,OpsxDB 先建隧道再转发数据库连接 —— 库端口不必暴露公网。
|
||||
|
||||
## 危险操作防护
|
||||
|
||||
- DROP / TRUNCATE 执行前确认;
|
||||
- 无 WHERE 的 UPDATE / DELETE 执行前确认;
|
||||
- 结果集按阈值截断,避免误操作拖垮界面。
|
||||
|
||||
## 内网部署形态
|
||||
|
||||
单二进制 + 内置静态页(官网 / 文档 / 工作台),无外部依赖:
|
||||
|
||||
- 任意内网主机可部署;
|
||||
- 无外联请求,可在纯内网环境运行;
|
||||
- 升级即替换一个二进制。
|
||||
@@ -1,32 +0,0 @@
|
||||
AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻塞你。
|
||||
|
||||
## 六种动作
|
||||
|
||||
| 动作 | 用途 |
|
||||
| --- | --- |
|
||||
| 生成 | 自然语言 → SQL(「查最近 7 天下单但未支付的用户」) |
|
||||
| 解释 | 把一段复杂 SQL 翻译成中文说明 |
|
||||
| 优化 | 分析慢查询,给出索引 / 改写建议 |
|
||||
| 修复 | 报错信息 + SQL → 修正版本 |
|
||||
| 方言 | MySQL ↔ PostgreSQL 等跨方言转换 |
|
||||
| 样例 | 按表结构生成示例查询 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 连接库后打开右侧 AI 面板;
|
||||
2. 选择动作,输入需求或粘贴 SQL(可附上对象树上下文);
|
||||
3. 生成结果一键放回编辑器,⌘/Ctrl + Enter 执行。
|
||||
|
||||
> **提示**:生成的 SQL 依旧走危险语句确认流程 —— AI 产出与手写 SQL 同等对待,不放宽防护。
|
||||
|
||||
## 当前状态(v0.1)
|
||||
|
||||
AI 面板当前为**本地 stub**:六种动作的交互框架与 API 已就位(`POST /api/ai/chat`),模型服务接入版本会明确标注数据流向与开关位置。
|
||||
|
||||
## 数据流向承诺
|
||||
|
||||
接入模型服务时遵循三条原则:
|
||||
|
||||
- 默认只发送你明确提交的内容(不含连接凭据);
|
||||
- 可完全关闭 AI 功能;
|
||||
- 使用云端模型时在界面上明确标识。
|
||||
@@ -1,40 +0,0 @@
|
||||
## 产品形态
|
||||
|
||||
**Q:能在浏览器里操作数据库吗?**
|
||||
不能。OpsxDB 客户端唯一形态是桌面 App(Tauri 2);浏览器只承载官网与文档(由本地后端 `:8788` 内置提供)。开发时的 `:5174` 只是桌面 devUrl 的资产服务,不是网页客户端。
|
||||
|
||||
**Q:官网和文档部署在哪?**
|
||||
就内置在你运行的那个二进制里:`http://127.0.0.1:8788/` 是官网,`/docs` 是文档;无需额外部署。
|
||||
|
||||
**Q:怎么打安装包?**
|
||||
`./run.sh package` 产出 Tauri 安装包(DMG)并内嵌 sidecar 后端二进制。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
**Q:我的连接密码存在哪?**
|
||||
本机,混淆存储;查询只带会话 ID,密码不会随请求重复上送,也不会同步到云端。
|
||||
|
||||
**Q:会上传我的 SQL 或查询结果吗?**
|
||||
不会。没有遥测、没有账号、没有云依赖;AI 功能当前为本地 stub,接入模型服务时会明确标注数据流向。
|
||||
|
||||
**Q:能在公司内网用吗?**
|
||||
能。单二进制 + 内置静态页,无外部依赖;默认只监听回环地址,对外需显式配置。
|
||||
|
||||
## 驱动相关
|
||||
|
||||
**Q:Oracle 能用吗?**
|
||||
驱动代码就绪(go-ora 纯 Go 实现,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过(ORA-00442),需 x86 环境或真库完成验收,详见《驱动支持》。
|
||||
|
||||
**Q:达梦 / MongoDB 什么时候支持?**
|
||||
已列入路线:长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。
|
||||
|
||||
**Q:MySQL 族 / PG 族的国产库怎么连?**
|
||||
协议兼容直接复用家族驱动:TiDB / OceanBase / PolarDB / TDSQL 选对应类型(MySQL 协议);GaussDB / Kingbase 走 PostgreSQL 表单。注意各自默认端口与用户名差异。
|
||||
|
||||
## 其他
|
||||
|
||||
**Q:和 OpsxAPI 是什么关系?**
|
||||
同一团队的姊妹项目:OpsxAPI 是接口工作台,OpsxDB 是数据库工作台,共用一套 monorepo 脚手架(web + Gin + 可选 Tauri),产品面各自独立。
|
||||
|
||||
**Q:快捷键有哪些?**
|
||||
核心就一个:⌘/Ctrl + Enter 执行当前语句或选区;双击结果单元格复制。完整列表见《SQL 工作台》。
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"productVersion": "0.1",
|
||||
"groups": [
|
||||
{
|
||||
"name": "开始使用",
|
||||
"pages": [
|
||||
{ "file": "01-intro.md", "slug": "intro", "title": "产品介绍", "updated": "2026-09-20" },
|
||||
{ "file": "02-quickstart.md", "slug": "quickstart", "title": "快速开始", "updated": "2026-09-20" },
|
||||
{ "file": "03-interface.md", "slug": "interface", "title": "界面导览", "updated": "2026-09-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "连接与驱动",
|
||||
"pages": [
|
||||
{ "file": "04-connect.md", "slug": "connect", "title": "新建连接", "updated": "2026-09-20" },
|
||||
{ "file": "05-drivers.md", "slug": "drivers", "title": "驱动支持", "updated": "2026-09-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "SQL 工作台",
|
||||
"pages": [
|
||||
{ "file": "06-workbench.md", "slug": "workbench", "title": "SQL 工作台", "updated": "2026-09-20" },
|
||||
{ "file": "07-results.md", "slug": "results", "title": "结果与导出", "updated": "2026-09-20" },
|
||||
{ "file": "08-redis.md", "slug": "redis", "title": "Redis 工作台", "updated": "2026-09-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "安全与 AI",
|
||||
"pages": [
|
||||
{ "file": "09-security.md", "slug": "security", "title": "安全设计", "updated": "2026-09-20" },
|
||||
{ "file": "10-ai.md", "slug": "ai", "title": "AI 助手", "updated": "2026-09-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "参考",
|
||||
"pages": [
|
||||
{ "file": "11-faq.md", "slug": "faq", "title": "常见问题", "updated": "2026-09-20" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
// 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 · OpsxDB 文档</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)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Page.Title}} · OpsxDB 文档</title>
|
||||
<meta name="description" content="OpsxDB 桌面数据库工作台用户文档 — 连库、写 SQL、看结果;MySQL / PostgreSQL / SQL Server / Oracle / SQLite / Redis 内置驱动,SSH 隧道与 SSL 加密。">
|
||||
<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%23059669%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%2206B6D4%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(%23g)%22%2F%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%224.5%22%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cellipse%20cx%3D%2232%22%20cy%3D%2220%22%20rx%3D%2215%22%20ry%3D%226.5%22%2F%3E%3Cpath%20d%3D%22M17%2020v24c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5V20%22%2F%3E%3Cpath%20d%3D%22M17%2032c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5%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>
|
||||
</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="OpsxDB"><defs><linearGradient id="lg-docs" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#059669"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-docs)"/><g stroke="#fff" stroke-width="4.5" fill="none" stroke-linecap="round"><ellipse cx="32" cy="20" rx="15" ry="6.5"/><path d="M17 20v24c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5V20"/><path d="M17 32c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5"/></g></svg></span>
|
||||
<span class="brand-name">OpsxDB <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="/docs/quickstart">快速开始</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 OpsxDB · 本地优先的数据库工作台</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>
|
||||
@@ -1,35 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware 本地鉴权:
|
||||
// - token == "" → 直通(本机 dev 无感);
|
||||
// - /health、/api/health 豁免;
|
||||
// - 其余 /api/** 要求 Header "Authorization: Bearer <token>",失败 401。
|
||||
func AuthMiddleware(token string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if token == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
p := c.Request.URL.Path
|
||||
if p == "/health" || p == "/api/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(p, "/api/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if c.GetHeader("Authorization") == "Bearer "+token {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
}
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxdb/server/internal/ai"
|
||||
"opsxdb/server/internal/db"
|
||||
"opsxdb/server/internal/docs"
|
||||
"opsxdb/server/internal/site"
|
||||
)
|
||||
|
||||
// Version 由 cmd/opsxdb 注入,/health 响应携带供桌面 sidecar 版本握手。
|
||||
var Version = "dev"
|
||||
|
||||
func NewRouter(token string) *gin.Engine {
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
r.Use(gin.Logger(), gin.Recovery(), localhostCORS(), AuthMiddleware(token))
|
||||
|
||||
r.GET("/health", health)
|
||||
r.GET("/api/health", health)
|
||||
|
||||
docs.New().Register(r)
|
||||
site.New().Register(r)
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/db/test-connection", func(c *gin.Context) {
|
||||
req, err := bindConn(c, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := db.TestConnection(req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "connected"})
|
||||
})
|
||||
|
||||
api.POST("/db/open", func(c *gin.Context) {
|
||||
req, err := bindConn(c, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, err := db.OpenSession(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "sessionId": id, "message": "connected"})
|
||||
})
|
||||
|
||||
api.POST("/db/close", func(c *gin.Context) {
|
||||
var req db.ConnReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.SessionID != "" {
|
||||
db.CloseSession(req.SessionID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
api.POST("/db/databases", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
list, err := db.ListDatabases(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"databases": list})
|
||||
})
|
||||
|
||||
api.POST("/db/tables", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
list, err := db.ListTables(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tables": list})
|
||||
})
|
||||
|
||||
api.POST("/db/columns", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
list, err := db.ListColumns(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"columns": list})
|
||||
})
|
||||
|
||||
api.POST("/db/exec", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx, done := withExecControl(c, req)
|
||||
defer done()
|
||||
res := db.ExecSQL(ctx, req)
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
api.POST("/db/explain", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx, done := withExecControl(c, req)
|
||||
defer done()
|
||||
res := db.ExplainSQL(ctx, req)
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
// /db/cancel 取消在执行的请求(按 requestId),body: {"requestId":"..."}
|
||||
api.POST("/db/cancel", func(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RequestID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requestId 不能为空"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "cancelled": db.CancelInflight(req.RequestID)})
|
||||
})
|
||||
|
||||
// /db/ddl 查看建表语句,body=ConnBody + table → {"ddl":"...","error":""}
|
||||
api.POST("/db/ddl", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ddl": "", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Table) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ddl": "", "error": "缺少表名"})
|
||||
return
|
||||
}
|
||||
ddl, err := db.GetDDL(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ddl": "", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ddl": ddl, "error": ""})
|
||||
})
|
||||
|
||||
// /db/search 对象搜索,body=ConnBody + keyword → {"results":[...],"error":""}
|
||||
api.POST("/db/search", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"results": []db.ObjectHit{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Keyword) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"results": []db.ObjectHit{}, "error": "缺少关键词"})
|
||||
return
|
||||
}
|
||||
results, err := db.SearchObjects(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"results": []db.ObjectHit{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"results": results, "error": ""})
|
||||
})
|
||||
|
||||
// /db/export 导出中心,body=ConnBody + format(csv|json|insert) + sql?/table? + limit?
|
||||
api.POST("/db/export", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
contentType, ext := exportContentType(format)
|
||||
if ext == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "format 仅支持 csv/json/insert"})
|
||||
return
|
||||
}
|
||||
sqlText := strings.TrimSpace(req.SQL)
|
||||
if sqlText == "" && strings.TrimSpace(req.Table) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 sql 或 table"})
|
||||
return
|
||||
}
|
||||
if sqlText != "" {
|
||||
// sql 模式:多条时只取第一条
|
||||
stmts := db.SplitStatements(sqlText)
|
||||
if len(stmts) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "sql 为空"})
|
||||
return
|
||||
}
|
||||
sqlText = stmts[0]
|
||||
// 导出仅允许只读查询,已确认也不放行写操作
|
||||
if !db.ExportAllowedQuery(sqlText) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "导出仅允许只读查询(SELECT/WITH/SHOW/DESCRIBE/EXPLAIN)"})
|
||||
return
|
||||
}
|
||||
if hits := db.CheckDangerousSQL(req.Driver, sqlText); len(hits) > 0 {
|
||||
if !req.ConfirmDanger {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "检测到危险语句: " + strings.Join(hits, "; "),
|
||||
"needConfirm": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
req.SQL = sqlText
|
||||
} else {
|
||||
// 表模式:SELECT * FROM <按 family 引号>
|
||||
req.SQL = db.SelectAllSQL(req.Driver, req.Table)
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="export-%s-%d.%s"`, format, time.Now().Unix(), ext))
|
||||
c.Header("Content-Type", contentType)
|
||||
if err := db.ExportQuery(ctx, req, c.Writer); err != nil {
|
||||
// 尚未流出数据时可回 JSON 错误;已流式写出则只能截断
|
||||
if !c.Writer.Written() {
|
||||
c.Writer.Header().Del("Content-Disposition")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
api.POST("/db/redis/keys", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !db.IsRedisDriver(req.Driver) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
|
||||
return
|
||||
}
|
||||
res := db.ListRedisKeys(c.Request.Context(), req)
|
||||
if res.Error != "" {
|
||||
c.JSON(http.StatusBadRequest, res)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
api.POST("/db/redis/key", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !db.IsRedisDriver(req.Driver) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
|
||||
return
|
||||
}
|
||||
res := db.GetRedisKey(c.Request.Context(), req)
|
||||
if res.Error != "" && res.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, res)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
api.POST("/db/redis/key/delete", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !db.IsRedisDriver(req.Driver) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
|
||||
return
|
||||
}
|
||||
res := db.DeleteRedisKey(c.Request.Context(), req)
|
||||
if res.Error != "" {
|
||||
c.JSON(http.StatusBadRequest, res)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
// /db/redis/del-pattern 目录(前缀)批量删除:body=ConnBody + pattern(如 "agent:*")
|
||||
api.POST("/db/redis/del-pattern", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "deleted": 0})
|
||||
return
|
||||
}
|
||||
if !db.IsRedisDriver(req.Driver) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用", "deleted": 0})
|
||||
return
|
||||
}
|
||||
res := db.DeleteRedisPattern(c.Request.Context(), req)
|
||||
if res.Error != "" {
|
||||
c.JSON(http.StatusBadRequest, res)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
// 驱动管理:状态 / 预装 / 离线包导入(agent 驱动)
|
||||
api.GET("/drivers/status", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"drivers": db.DriversStatus()})
|
||||
})
|
||||
api.POST("/drivers/install", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Driver string `json:"driver"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Driver == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 driver"})
|
||||
return
|
||||
}
|
||||
if err := db.InstallDriver(req.Driver); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "drivers": db.DriversStatus()})
|
||||
})
|
||||
api.POST("/drivers/import", func(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 file 字段(zip)"})
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "只接受 .zip 离线包"})
|
||||
return
|
||||
}
|
||||
tmp := filepath.Join(os.TempDir(), "opsxdb-driver-import-*.zip")
|
||||
f, err := os.CreateTemp(os.TempDir(), "opsxdb-driver-import-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tmp = f.Name()
|
||||
defer os.Remove(tmp)
|
||||
if err := c.SaveUploadedFile(fh, tmp); err != nil {
|
||||
f.Close()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
n, err := db.ImportOfflinePack(tmp)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "imported": n, "drivers": db.DriversStatus()})
|
||||
})
|
||||
|
||||
api.POST("/ai/chat", func(c *gin.Context) {
|
||||
var req ai.ChatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := ai.Default().Complete(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
// GET /api/ai/config 返回脱敏配置:apiKey 只回掩码与是否已配置
|
||||
api.GET("/ai/config", func(c *gin.Context) {
|
||||
cfg := ai.LoadConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"provider": cfg.Provider,
|
||||
"baseURL": cfg.BaseURL,
|
||||
"model": cfg.Model,
|
||||
"hasKey": cfg.APIKey != "",
|
||||
"apiKey": cfg.MaskedKey(),
|
||||
})
|
||||
})
|
||||
|
||||
api.POST("/ai/config", func(c *gin.Context) {
|
||||
var req ai.Config
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ai.SaveConfig(req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
api.POST("/ai/generate", func(c *gin.Context) {
|
||||
var req ai.ChatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Action = "generate"
|
||||
res, err := ai.Default().Complete(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func bindConn(c *gin.Context, allowSession bool) (db.ConnReq, error) {
|
||||
var req db.ConnReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return req, err
|
||||
}
|
||||
if allowSession && req.SessionID != "" {
|
||||
return db.ResolveConn(req)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// withExecControl 按 timeoutMs/requestId 包装请求 ctx,返回 cleanup(处理完调用:注销 in-flight + 释放计时器)。
|
||||
// timeoutMs > 0 时以此作为语句超时;requestId 非空时注册进 in-flight 表,/api/db/cancel 可取消。
|
||||
func withExecControl(c *gin.Context, req db.ConnReq) (context.Context, func()) {
|
||||
ctx := c.Request.Context()
|
||||
var cancel context.CancelFunc
|
||||
if req.TimeoutMs > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutMs)*time.Millisecond)
|
||||
}
|
||||
if req.RequestID != "" {
|
||||
if cancel == nil {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
}
|
||||
db.RegisterInflight(req.RequestID, cancel)
|
||||
}
|
||||
cleanup := func() {
|
||||
if req.RequestID != "" {
|
||||
db.UnregisterInflight(req.RequestID)
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
return ctx, cleanup
|
||||
}
|
||||
|
||||
// exportContentType 返回导出格式对应的 Content-Type 与扩展名;不支持的格式 ext 为空。
|
||||
func exportContentType(format string) (contentType, ext string) {
|
||||
switch format {
|
||||
case "csv":
|
||||
return "text/csv", "csv"
|
||||
case "json":
|
||||
return "application/json", "json"
|
||||
case "insert":
|
||||
return "text/plain", "sql"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb", "version": Version})
|
||||
}
|
||||
|
||||
func localhostCORS() gin.HandlerFunc {
|
||||
// 5174 = 桌面 devUrl(Tauri dev 的 Vite 资产服务);tauri:// = 打包桌面壳。
|
||||
// 浏览器不作为客户端,不再放行其它来源。
|
||||
allowed := map[string]struct{}{
|
||||
"http://localhost:5174": {},
|
||||
"http://127.0.0.1:5174": {},
|
||||
"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")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func doPost(t *testing.T, r http.Handler, path, body, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// 鉴权矩阵:token 为空直通;token 非空时无头 401、正确头 200;health 豁免。
|
||||
func TestAuthMatrix(t *testing.T) {
|
||||
open := NewRouter("")
|
||||
if w := doPost(t, open, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, ""); w.Code == http.StatusUnauthorized {
|
||||
t.Fatal("token 为空应直通")
|
||||
}
|
||||
|
||||
authed := NewRouter("tok123")
|
||||
if w := doPost(t, authed, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("无 token 应 401,实际 %d", w.Code)
|
||||
}
|
||||
if w := doPost(t, authed, "/api/db/exec", `{"driver":"sqlite","sql":"SELECT 1"}`, "wrong"); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("错误 token 应 401,实际 %d", w.Code)
|
||||
}
|
||||
// health 豁免
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
authed.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("health 应豁免,实际 %d", w.Code)
|
||||
}
|
||||
var healthBody map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &healthBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if healthBody["version"] == nil {
|
||||
t.Fatal("health 应携带 version")
|
||||
}
|
||||
}
|
||||
|
||||
// 危险 SQL 二段流 + 确认放行(HTTP 层)。
|
||||
func TestExecDangerHTTP(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "http.db")
|
||||
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRouter("")
|
||||
|
||||
mk := func(sql string, confirm bool) string {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"driver": "sqlite", "database": dbFile, "sql": sql, "confirmDanger": confirm,
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
w := doPost(t, r, "/api/db/exec", mk("CREATE TABLE t (id int)", false), "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("建表失败: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 未确认:needConfirm 且不执行
|
||||
w = doPost(t, r, "/api/db/exec", mk("DROP TABLE t", false), "")
|
||||
var res struct {
|
||||
Error string `json:"error"`
|
||||
NeedConfirm bool `json:"needConfirm"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.NeedConfirm || res.Error == "" {
|
||||
t.Fatalf("应 needConfirm: %s", w.Body.String())
|
||||
}
|
||||
// 表仍在
|
||||
w = doPost(t, r, "/api/db/exec", mk("SELECT count(*) FROM t", false), "")
|
||||
if strings.Contains(w.Body.String(), `"error"`) {
|
||||
t.Fatalf("DROP 不应已执行: %s", w.Body.String())
|
||||
}
|
||||
// 确认后放行
|
||||
w = doPost(t, r, "/api/db/exec", mk("DROP TABLE t", true), "")
|
||||
if strings.Contains(w.Body.String(), `"error"`) {
|
||||
t.Fatalf("确认后应放行: %s", w.Body.String())
|
||||
}
|
||||
// 多语句绕过同样被拦
|
||||
w = doPost(t, r, "/api/db/exec", mk("SELECT 1; DROP TABLE t", false), "")
|
||||
if !strings.Contains(w.Body.String(), `"needConfirm":true`) {
|
||||
t.Fatalf("多语句绕过应被拦截: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// EXPLAIN 端点:sqlite 行列计划。
|
||||
func TestExplainHTTP(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "ex.db")
|
||||
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRouter("")
|
||||
w := doPost(t, r, "/api/db/explain", `{"driver":"sqlite","database":"`+dbFile+`","sql":"SELECT 1"}`, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
Error string `json:"error"`
|
||||
TimeMs int64 `json:"timeMs"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Error != "" || len(res.Rows) == 0 {
|
||||
t.Fatalf("sqlite explain 应有行列结果: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// AI 配置保存/读取(脱敏),chat 未配置回落 stub。
|
||||
func TestAIConfigHTTP(t *testing.T) {
|
||||
// 隔离 HOME,避免读写真实 ~/.opsxdb
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
|
||||
r := NewRouter("")
|
||||
w := doPost(t, r, "/api/ai/config", `{"provider":"openai","baseURL":"http://x.local/v1","apiKey":"sk-secret-123","model":"m1"}`, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("保存配置: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 文件权限 0600
|
||||
fi, err := os.Stat(filepath.Join(tmp, ".opsxdb", "ai.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0600 {
|
||||
t.Fatalf("ai.json 权限应 0600,实际 %v", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ai/config", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["hasKey"] != true || cfg["model"] != "m1" || cfg["provider"] != "openai" {
|
||||
t.Fatalf("配置回读错误: %v", cfg)
|
||||
}
|
||||
if key, _ := cfg["apiKey"].(string); strings.Contains(key, "secret") {
|
||||
t.Fatalf("apiKey 应脱敏: %v", key)
|
||||
}
|
||||
|
||||
// 未配置 API Key 的 stub 回落(全新 HOME)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
w = doPost(t, r, "/api/ai/chat", `{"action":"generate","prompt":"查用户"}`, "")
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "content") {
|
||||
t.Fatalf("stub 回落: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// R2-B4:/api/db/cancel 空 requestId 400;未知 requestId cancelled=false。
|
||||
func TestCancelEndpoint(t *testing.T) {
|
||||
r := NewRouter("")
|
||||
w := doPost(t, r, "/api/db/cancel", `{"requestId":""}`, "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("空 requestId 应 400,实际 %d", w.Code)
|
||||
}
|
||||
w = doPost(t, r, "/api/db/cancel", `{"requestId":"never-existed"}`, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.OK || res.Cancelled {
|
||||
t.Fatalf("未知 requestId 应 cancelled=false: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// R2-B7:/api/db/export 三种格式的响应头与内容形状。
|
||||
func TestExportHTTPEndpoint(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "exp.db")
|
||||
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRouter("")
|
||||
mk := func(fields map[string]interface{}) string {
|
||||
fields["driver"] = "sqlite"
|
||||
fields["database"] = dbFile
|
||||
b, _ := json.Marshal(fields)
|
||||
return string(b)
|
||||
}
|
||||
if w := doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
|
||||
"sql": `CREATE TABLE t_exp (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
}), ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("建表失败: %s", w.Body.String())
|
||||
}
|
||||
if w := doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
|
||||
"sql": `INSERT INTO t_exp (id, name) VALUES (1, 'alice'), (2, 'bob')`,
|
||||
}), ""); strings.Contains(w.Body.String(), `"error"`) {
|
||||
t.Fatalf("插数失败: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// csv:Content-Disposition + 内容
|
||||
w := doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "csv", "table": "t_exp",
|
||||
}), "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("csv 导出: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
cd := w.Header().Get("Content-Disposition")
|
||||
if !strings.Contains(cd, `attachment; filename="export-csv-`) || !strings.HasSuffix(cd, `.csv"`) {
|
||||
t.Fatalf("Content-Disposition 错误: %q", cd)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "text/csv" {
|
||||
t.Fatalf("Content-Type 错误: %q", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "id,name\n1,alice\n2,bob") {
|
||||
t.Fatalf("csv 内容错误: %q", w.Body.String())
|
||||
}
|
||||
|
||||
// json
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "json", "sql": "SELECT id, name FROM t_exp ORDER BY id",
|
||||
}), "")
|
||||
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("json 导出: %d %q", w.Code, w.Header().Get("Content-Type"))
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil || len(rows) != 2 {
|
||||
t.Fatalf("json 内容应可解析且 2 行: %v %s", err, w.Body.String())
|
||||
}
|
||||
|
||||
// insert
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "insert", "table": "t_exp", "sql": "SELECT id, name FROM t_exp ORDER BY id",
|
||||
}), "")
|
||||
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "text/plain" {
|
||||
t.Fatalf("insert 导出: %d %q", w.Code, w.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `INSERT INTO "t_exp" ("id", "name") VALUES (1, 'alice');`) {
|
||||
t.Fatalf("insert 内容错误: %q", w.Body.String())
|
||||
}
|
||||
|
||||
// limit 生效
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "csv", "table": "t_exp", "limit": 1,
|
||||
}), "")
|
||||
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("limit=1 应表头+1 行: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// R2-B7:导出安全闸门——非只读拒绝(即使已确认),危险只读语句走二段流。
|
||||
func TestExportHTTPRejections(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "expr.db")
|
||||
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRouter("")
|
||||
mk := func(fields map[string]interface{}) string {
|
||||
fields["driver"] = "sqlite"
|
||||
fields["database"] = dbFile
|
||||
b, _ := json.Marshal(fields)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 写操作开头:未确认也拒绝,已确认也拒绝
|
||||
w := doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "csv", "sql": "DROP TABLE t",
|
||||
}), "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("写操作应 400: %d", w.Code)
|
||||
}
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "csv", "sql": "DELETE FROM t", "confirmDanger": true,
|
||||
}), "")
|
||||
if w.Code != http.StatusBadRequest || strings.Contains(w.Body.String(), "needConfirm") {
|
||||
t.Fatalf("已确认写操作仍应拒绝且不带 needConfirm: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 只读但命中危险(INTO OUTFILE):未确认 → needConfirm 二段流
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "csv", "sql": "SELECT * INTO OUTFILE '/tmp/x' FROM t",
|
||||
}), "")
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"needConfirm":true`) {
|
||||
t.Fatalf("危险只读应 needConfirm: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// 已确认:放行到执行层(sqlite 语法错误,但不再 needConfirm)
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "csv", "sql": "SELECT * INTO OUTFILE '/tmp/x' FROM t", "confirmDanger": true,
|
||||
}), "")
|
||||
if strings.Contains(w.Body.String(), "needConfirm") {
|
||||
t.Fatalf("确认后不应再 needConfirm: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 缺 sql 与 table / 非法 format
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{"format": "csv"}), "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("缺 sql/table 应 400: %d", w.Code)
|
||||
}
|
||||
w = doPost(t, r, "/api/db/export", mk(map[string]interface{}{
|
||||
"format": "xml", "sql": "SELECT 1",
|
||||
}), "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法 format 应 400: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// R2-B5/R2-B6:/api/db/ddl 与 /api/db/search 的 HTTP 层冒烟。
|
||||
func TestDDLAndSearchHTTP(t *testing.T) {
|
||||
dbFile := filepath.Join(t.TempDir(), "meta.db")
|
||||
if err := os.WriteFile(dbFile, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRouter("")
|
||||
mk := func(fields map[string]interface{}) string {
|
||||
fields["driver"] = "sqlite"
|
||||
fields["database"] = dbFile
|
||||
b, _ := json.Marshal(fields)
|
||||
return string(b)
|
||||
}
|
||||
doPost(t, r, "/api/db/exec", mk(map[string]interface{}{
|
||||
"sql": `CREATE TABLE t_ddl (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
}), "")
|
||||
|
||||
w := doPost(t, r, "/api/db/ddl", mk(map[string]interface{}{"table": "t_ddl"}), "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ddl: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ddlRes struct {
|
||||
DDL string `json:"ddl"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &ddlRes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ddlRes.Error != "" || !strings.Contains(ddlRes.DDL, "CREATE TABLE t_ddl") {
|
||||
t.Fatalf("ddl 响应错误: %+v", ddlRes)
|
||||
}
|
||||
w = doPost(t, r, "/api/db/ddl", mk(map[string]interface{}{}), "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("缺 table 应 400: %d", w.Code)
|
||||
}
|
||||
|
||||
w = doPost(t, r, "/api/db/search", mk(map[string]interface{}{"keyword": "ddl"}), "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var searchRes struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &searchRes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if searchRes.Error != "" || len(searchRes.Results) != 1 || searchRes.Results[0]["name"] != "t_ddl" {
|
||||
t.Fatalf("search 响应错误: %+v", searchRes)
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
/* OpsxDB 官网 —— 营销单页样式(祖母绿 → 青家族色) */
|
||||
:root {
|
||||
--green: #059669;
|
||||
--green-dark: #047857;
|
||||
--green-soft: rgba(5, 150, 105, 0.08);
|
||||
--cyan: #06b6d4;
|
||||
--bg: #ffffff;
|
||||
--bg-alt: #f5faf8;
|
||||
--border: #e2ebe7;
|
||||
--text: #101828;
|
||||
--text-2: #52606d;
|
||||
--text-3: #98a2b3;
|
||||
--red: #dc2626;
|
||||
--amber: #d97706;
|
||||
--radius: 14px;
|
||||
--shadow: 0 1px 3px rgba(6, 23, 18, .06), 0 12px 32px rgba(6, 23, 18, .08);
|
||||
--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(--green-dark); 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 图标 ---------- */
|
||||
.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(--green-soft); color: var(--green-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(--green); color: #fff !important; box-shadow: 0 6px 18px rgba(5,150,105,.30); }
|
||||
.btn-primary:hover { background: var(--green-dark); transform: translateY(-1px); }
|
||||
.btn-ghost { border-color: var(--border); color: var(--text) !important; background: #fff; }
|
||||
.btn-ghost:hover { border-color: var(--green); color: var(--green-dark) !important; }
|
||||
.btn-inverse { background: #fff; color: var(--green-dark) !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(--green-dark) !important; }
|
||||
.nav-cta { margin-left: auto; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative; overflow: hidden;
|
||||
background:
|
||||
radial-gradient(1200px 500px at 85% -10%, rgba(5,150,105,.13), transparent 60%),
|
||||
radial-gradient(800px 400px at -10% 10%, rgba(6,182,212,.09), transparent 55%),
|
||||
linear-gradient(180deg, #f5fbf8 0%, #fff 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hero-inner {
|
||||
max-width: 1120px; margin: 0 auto; padding: 72px 24px 56px;
|
||||
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(--green-dark);
|
||||
background: #fff; border: 1px solid rgba(5,150,105,.25); border-radius: 999px; padding: 4px 14px;
|
||||
box-shadow: 0 2px 8px rgba(5,150,105,.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(--green); }
|
||||
.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(纯 CSS) ---------- */
|
||||
.mock {
|
||||
border-radius: 14px; overflow: hidden; box-shadow: var(--shadow); border: 1px solid #1b2b26;
|
||||
background: #0c1512; transform: perspective(1400px) rotateY(-4deg) rotateX(1.5deg);
|
||||
}
|
||||
.mock-titlebar {
|
||||
display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 12px;
|
||||
background: #0f1a16; border-bottom: 1px solid #1b2b26;
|
||||
}
|
||||
.mock-titlebar i { width: 11px; height: 11px; border-radius: 50%; background: #37504a; }
|
||||
.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: #8aa39b; font-size: 11px; }
|
||||
.mock-body { display: grid; grid-template-columns: 158px minmax(0,1fr); min-height: 330px; }
|
||||
.mock-side { border-right: 1px solid #1b2b26; padding: 10px 8px; background: #0b1310; }
|
||||
.mock-search { height: 22px; border-radius: 6px; background: #142019; margin-bottom: 10px; }
|
||||
.mock-group { color: #8aa39b; font-size: 11px; padding: 8px 6px 3px; }
|
||||
.mock-item { display: flex; align-items: center; gap: 6px; color: #a9c0b8; font-size: 11.5px; padding: 4px 6px; border-radius: 6px; }
|
||||
.mock-item.active { background: rgba(5,150,105,.18); color: #6ee7b7; }
|
||||
.mock-item.conn .dot { width: 7px; height: 7px; border-radius: 50%; background: #47564f; flex-shrink: 0; }
|
||||
.mock-item.conn .dot.on { background: #34d399; }
|
||||
.mi { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; opacity: .8; }
|
||||
.mock-main { padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.mock-tabs { display: flex; gap: 4px; }
|
||||
.mock-tabs .tab { color: #5f7a70; font-size: 10.5px; padding: 4px 10px; border-radius: 6px 6px 0 0; }
|
||||
.mock-tabs .tab.on { color: #6ee7b7; background: rgba(5,150,105,.12); border-bottom: 2px solid var(--green); }
|
||||
.mock-sql { border: 1px solid #1b2b26; border-radius: 9px; padding: 10px 12px; background: #0b1310; }
|
||||
.mock-sql pre {
|
||||
margin: 0; font-family: var(--mono); font-size: 11px; line-height: 1.7; color: #c3d4cd; white-space: pre-wrap;
|
||||
}
|
||||
.mock-sql .kw { color: #6ee7b7; }
|
||||
.mock-sql .str { color: #fcd34d; }
|
||||
.mock-sql .num { color: #7dd3fc; }
|
||||
.mock-grid {
|
||||
display: grid; grid-template-columns: 52px 62px 1fr 68px; gap: 0 10px;
|
||||
border: 1px solid #1b2b26; border-radius: 9px; padding: 8px 10px; background: #0b1310;
|
||||
font-family: var(--mono); font-size: 10.5px; line-height: 1.9; color: #b7c8c1;
|
||||
}
|
||||
.mock-grid .gh { color: #7fe3c3; border-bottom: 1px solid #1b2b26; margin-bottom: 3px; font-weight: 600; }
|
||||
.mock-grid .ok { color: #34d399; }
|
||||
.mock-grid .warn { color: #fbbf24; }
|
||||
.mock-status { display: flex; justify-content: space-between; color: #5f7a70; font-size: 10px; font-family: var(--mono); padding: 0 2px; }
|
||||
.mock-status .ok { color: #34d399; }
|
||||
|
||||
/* ---------- 数据统计条 ---------- */
|
||||
.stats { border-top: 1px solid var(--border); background: rgba(255,255,255,.7); }
|
||||
.stats-inner {
|
||||
max-width: 1120px; margin: 0 auto; padding: 18px 24px;
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; text-align: center;
|
||||
}
|
||||
.stat b { display: block; font-size: 26px; font-weight: 800; color: var(--green-dark); line-height: 1.2; }
|
||||
.stat span { font-size: 12.5px; color: var(--text-2); }
|
||||
|
||||
/* ---------- 通用 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(6,23,18,.04); transition: all .18s;
|
||||
}
|
||||
.card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: rgba(5,150,105,.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(--green); 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; }
|
||||
|
||||
/* ---------- 驱动矩阵 ---------- */
|
||||
.proto {
|
||||
background: #fff; border: 1px solid var(--border); border-left: 3px solid var(--green);
|
||||
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(--green); width: 17px; height: 17px; }
|
||||
.proto p { margin: 0; color: var(--text-2); font-size: 13.5px; }
|
||||
.proto .port { margin-left: 10px; font-family: var(--mono); font-size: 11.5px; color: var(--green-dark); background: var(--green-soft); border-radius: 5px; padding: 1px 8px; white-space: nowrap; }
|
||||
|
||||
/* ---------- 徽章 / 脚注 ---------- */
|
||||
.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; }
|
||||
|
||||
/* ---------- 形态卡(下载) ---------- */
|
||||
.pricing.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 22px; 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(6,23,18,.04); display: flex; flex-direction: column;
|
||||
}
|
||||
.price-card.featured { border: 2px solid var(--green); box-shadow: 0 12px 36px rgba(5,150,105,.16); }
|
||||
.price-card.enterprise {
|
||||
background: linear-gradient(160deg, #0f1f1a 0%, #0c1512 70%);
|
||||
border: 1px solid #22453a; box-shadow: 0 14px 40px rgba(12,21,18,.4);
|
||||
}
|
||||
.price-card.enterprise .plan { color: #8aa39b; }
|
||||
.price-card.enterprise .price b { color: #fff; font-size: 34px; }
|
||||
.price-card.enterprise .price span { color: #8aa39b; }
|
||||
.price-card.enterprise .plan-sub { color: #8aa39b; }
|
||||
.price-card.enterprise li { color: #a9c0b8; border-bottom-color: #1b2b26; }
|
||||
.btn-dark { background: #fff; color: #0c1512 !important; }
|
||||
.btn-dark:hover { transform: translateY(-1px); background: #e6f7f0; }
|
||||
.plan-tag {
|
||||
position: absolute; top: -13px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--green); color: #fff; font-size: 12px; font-weight: 700;
|
||||
padding: 3px 14px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.plan-tag.dark { background: #0c1512; border: 1px solid #22453a; }
|
||||
.plan { font-size: 16px; font-weight: 700; color: var(--text-2); }
|
||||
.price { margin: 8px 0 2px; }
|
||||
.price b { font-size: 40px; 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 li code { font-size: 12px; }
|
||||
.price-card .btn { width: 100%; }
|
||||
|
||||
/* ---------- 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, #047857 0%, #059669 55%, #0d9488 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: #0c1512; color: #8aa39b; 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 .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: #8aa39b !important; padding: 4px 0; font-size: 13px; }
|
||||
.footer a:hover { color: #fff !important; }
|
||||
.footer-bar { border-top: 1px solid #1b2b26; text-align: center; padding: 18px; font-size: 12px; color: #5f7a70; }
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
@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.three { grid-template-columns: minmax(0, 420px); }
|
||||
.footer-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.stats-inner { 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; }
|
||||
.stats-inner { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Package site:产品官网落地页(嵌入二进制,深色密度跟工作台)。
|
||||
package site
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed template.html assets
|
||||
var files embed.FS
|
||||
|
||||
type Handler struct {
|
||||
html string
|
||||
}
|
||||
|
||||
func New() *Handler {
|
||||
raw, err := files.ReadFile("template.html")
|
||||
if err != nil {
|
||||
panic("site: template.html 缺失: " + err.Error())
|
||||
}
|
||||
return &Handler{html: string(raw)}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
ct := "application/octet-stream"
|
||||
if strings.HasSuffix(name, ".css") {
|
||||
ct = "text/css; charset=utf-8"
|
||||
}
|
||||
c.Header("Content-Type", ct)
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Data(http.StatusOK, ct, data)
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpsxDB — 本地优先的桌面数据库工作台</title>
|
||||
<meta name="description" content="OpsxDB:连库、写 SQL、看结果,全在本机完成。MySQL / PostgreSQL / SQL Server / Oracle / SQLite / Redis 一个窗口管完,SSH 隧道与 SSL 加密,AI 辅助写 SQL。">
|
||||
<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%23059669%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%2206B6D4%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(%23g)%22%2F%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%224.5%22%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cellipse%20cx%3D%2232%22%20cy%3D%2220%22%20rx%3D%2215%22%20ry%3D%226.5%22%2F%3E%3Cpath%20d%3D%22M17%2020v24c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5V20%22%2F%3E%3Cpath%20d%3D%22M17%2032c0%203.6%206.7%206.5%2015%206.5s15-2.9%2015-6.5%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-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></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-terminal" viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></symbol>
|
||||
<symbol id="i-table" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></symbol>
|
||||
<symbol id="i-tree" viewBox="0 0 24 24"><path d="M13 10h7a1 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="M13 21h7a1 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-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-sparkles" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></symbol>
|
||||
<symbol id="i-key" viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></symbol>
|
||||
<symbol id="i-monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></symbol>
|
||||
<symbol id="i-clipboard" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></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="OpsxDB"><defs><linearGradient id="lg-site" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#059669"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-site)"/><g stroke="#fff" stroke-width="4.5" fill="none" stroke-linecap="round"><ellipse cx="32" cy="20" rx="15" ry="6.5"/><path d="M17 20v24c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5V20"/><path d="M17 32c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5"/></g></svg></span>
|
||||
<span class="brand-name">OpsxDB</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">功能</a>
|
||||
<a href="#drivers">驱动</a>
|
||||
<a href="#security">安全</a>
|
||||
<a href="#download">下载</a>
|
||||
<a href="/docs" target="_blank" rel="noopener">文档</a>
|
||||
</nav>
|
||||
<div class="nav-cta">
|
||||
<a class="btn btn-primary" href="/docs/quickstart" 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-sparkles"/></svg> v0.1 · 桌面 GUI · 数据 100% 本机</div>
|
||||
<h1>一个窗口,<br>管好<em>全公司的数据库</em></h1>
|
||||
<p class="hero-sub">
|
||||
连库、写 SQL、看结果,全在本机完成。<br>
|
||||
MySQL / PostgreSQL / SQL Server / Oracle / SQLite / Redis 内置驱动,
|
||||
SSH 隧道与 SSL 加密直达内网库,AI 帮你把 SQL 写得又快又稳。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary btn-lg" href="#download">下载桌面版</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/docs/quickstart" target="_blank" rel="noopener">30 秒连上第一个库 →</a>
|
||||
</div>
|
||||
<div class="hero-pills">
|
||||
<span>本地优先</span><span>SSH 隧道</span><span>SSL 加密</span><span>国产库兼容</span><span>AI 写 SQL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作台示意(纯 CSS 绘制) -->
|
||||
<div class="mock" aria-hidden="true">
|
||||
<div class="mock-titlebar"><i></i><i></i><i></i><span>OpsxDB · prod-mysql</span></div>
|
||||
<div class="mock-body">
|
||||
<div class="mock-side">
|
||||
<div class="mock-search"></div>
|
||||
<div class="mock-item conn"><i class="dot on"></i>prod-mysql</div>
|
||||
<div class="mock-item conn"><i class="dot on"></i>pg-analytics</div>
|
||||
<div class="mock-item conn"><i class="dot"></i>sqlite-demo</div>
|
||||
<div class="mock-group">shop(库)</div>
|
||||
<div class="mock-item active"><svg class="mi"><use href="#i-table"/></svg> orders</div>
|
||||
<div class="mock-item"><svg class="mi"><use href="#i-table"/></svg> users</div>
|
||||
<div class="mock-item"><svg class="mi"><use href="#i-table"/></svg> products</div>
|
||||
</div>
|
||||
<div class="mock-main">
|
||||
<div class="mock-tabs"><span class="tab on">orders · 前 100 行</span><span class="tab">query-2</span></div>
|
||||
<div class="mock-sql">
|
||||
<pre><span class="kw">SELECT</span> id, user_id, amount, status
|
||||
<span class="kw">FROM</span> orders
|
||||
<span class="kw">WHERE</span> created_at > <span class="str">'2026-09-01'</span>
|
||||
<span class="kw">ORDER BY</span> id <span class="kw">DESC</span> <span class="kw">LIMIT</span> <span class="num">100</span>;</pre>
|
||||
</div>
|
||||
<div class="mock-grid">
|
||||
<span class="gh">id</span><span class="gh">user_id</span><span class="gh">amount</span><span class="gh">status</span>
|
||||
<span>9f2a</span><span>u_1024</span><span>¥ 1,299.00</span><span class="ok">PAID</span>
|
||||
<span>9f29</span><span>u_0987</span><span>¥ 89.00</span><span class="ok">PAID</span>
|
||||
<span>9f28</span><span>u_2333</span><span>¥ 459.00</span><span class="warn">PENDING</span>
|
||||
<span>9f27</span><span>u_1024</span><span>¥ 3,280.00</span><span class="ok">PAID</span>
|
||||
</div>
|
||||
<div class="mock-status"><span class="ok">✓ 100 行 · 38ms</span><span>MySQL 8 · 127.0.0.1:3306</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据统计条 -->
|
||||
<div class="stats">
|
||||
<div class="stats-inner">
|
||||
<div class="stat"><b>7</b><span>内置驱动</span></div>
|
||||
<div class="stat"><b>18</b><span>可连数据库</span></div>
|
||||
<div class="stat"><b>0</b><span>数据外发</span></div>
|
||||
<div class="stat"><b>1</b><span>个二进制内置官网与文档</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 为什么选 ===================== -->
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">为什么运维和开发选 OpsxDB</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>连接配置、查询历史全部保存在本机,服务只监听 127.0.0.1,不经过任何第三方服务器。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-database"/></svg></div>
|
||||
<h3>六类库一个窗口</h3>
|
||||
<p>MySQL 族 / PostgreSQL 族 / SQL Server / Oracle / SQLite / Redis 统一工作台,切换零负担。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-key"/></svg></div>
|
||||
<h3>安全通道直达内网</h3>
|
||||
<p>每个连接可独立配置 SSL/TLS 与 SSH 隧道,跳板机后面的库也能安全直连。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div>
|
||||
<h3>AI 把 SQL 写快</h3>
|
||||
<p>自然语言生成、解释、优化、修复、方言转换,写不出的那句 SQL 交给 AI 起草。</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-terminal"/></svg></div><div><h3>连接选择器</h3><p>搜索 / 分类 / 最近 / 图标网格;粘贴 <code>mysql://user:pass@host:3306/db</code> 一键填入表单。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-zap"/></svg></div><div><h3>SQL 工作台</h3><p>CodeMirror 编辑器,⌘/Ctrl+Enter 执行当前语句或选区,多 Tab 并行,可拖分栏。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-table"/></svg></div><div><h3>结果随手取</h3><p>结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-tree"/></svg></div><div><h3>对象树与表数据</h3><p>懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-database"/></svg></div><div><h3>Redis 工作台</h3><p>编辑器直接执行 <code>PING</code> / <code>GET</code> / <code>SET</code> / <code>KEYS</code>,键空间浏览、TTL 管理。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI 面板</h3><p>生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL,六种动作贴着查询上下文。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 驱动矩阵 ===================== -->
|
||||
<section class="section" id="drivers">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">数据库支持</h2>
|
||||
<p class="sec-sub">常用库 native Go 驱动编译内置;长尾库规划 agent 按需接入。</p>
|
||||
<div class="grid g3">
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> MySQL 族</h3><p>MySQL · MariaDB · TiDB · OceanBase · PolarDB · TDSQL<span class="port">:3306</span></p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> PostgreSQL 族</h3><p>PostgreSQL · GaussDB / openGauss · 人大金仓<span class="port">:5432</span></p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> SQL Server</h3><p>原生协议直连,SSL 可选<span class="port">:1433</span></p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> Oracle</h3><p>纯 Go 驱动 go-ora,不引入 JVM<span class="port">:1521</span></p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-database"/></svg> SQLite</h3><p>本地文件库,填路径即连<span class="port">file</span></p></div>
|
||||
<div class="proto"><h3><svg class="i"><use href="#i-zap"/></svg> Redis</h3><p>连接即可执行命令与键管理<span class="port">:6379</span></p></div>
|
||||
</div>
|
||||
<div class="badges">
|
||||
<span class="more">规划中 · agent 按需接入:达梦 DM8 · MongoDB · ZooKeeper · Elasticsearch · InfluxDB</span>
|
||||
</div>
|
||||
<p class="footnote">驱动验收状态(本机 docker 实测)见 <a href="/docs/drivers" target="_blank" rel="noopener">文档 · 驱动支持</a>。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 安全 ===================== -->
|
||||
<section class="section alt" id="security">
|
||||
<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-key"/></svg></div><div><h3>密码不出本机</h3><p>连接密码本机混淆存储;查询只带 <code>sessionId</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>SSL / TLS 每库可配</h3><p>按连接独立开启 TLS,证书校验策略随库配置。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-server"/></svg></div><div><h3>SSH 隧道</h3><p>跳板机场景一条隧道直达内网库,无需暴露公网端口。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-monitor"/></svg></div><div><h3>仅监听回环</h3><p>后端默认绑定 127.0.0.1,对外服务需显式开启。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-clipboard"/></svg></div><div><h3>危险 SQL 确认</h3><p>DROP / TRUNCATE / 无 WHERE 的 UPDATE·DELETE 执行前二次确认。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-clock"/></svg></div><div><h3>查询历史本地留存</h3><p>历史只存在本机,随时回跳重跑,不上传不同步。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 下载 / 形态 ===================== -->
|
||||
<section class="section" id="download">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">三种形态,同一套体验</h2>
|
||||
<p class="sec-sub">客户端唯一形态是桌面 GUI;官网与文档内置在同一个 Go 二进制里。</p>
|
||||
<div class="pricing three">
|
||||
<div class="price-card featured">
|
||||
<div class="plan-tag">推荐 · 主交付</div>
|
||||
<div class="plan">桌面版</div>
|
||||
<div class="price"><b>Tauri 2</b></div>
|
||||
<div class="plan-sub">macOS(Apple Silicon / Intel)· Windows 规划中</div>
|
||||
<ul>
|
||||
<li>✓ 双击启动,本地后端随应用自动拉起</li>
|
||||
<li>✓ DMG 安装包内置 sidecar,无需手动起服务</li>
|
||||
<li>✓ 连接 / 查询 / 结果全功能</li>
|
||||
<li>✓ 数据 100% 留在本机</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="/docs/quickstart" target="_blank" rel="noopener">从源码构建</a>
|
||||
</div>
|
||||
<div class="price-card">
|
||||
<div class="plan">官网 + 文档</div>
|
||||
<div class="price"><b>内置</b></div>
|
||||
<div class="plan-sub">随二进制提供,无需部署</div>
|
||||
<ul>
|
||||
<li>✓ 官网 <code>/</code> 与文档 <code>/docs</code> 内置于后端</li>
|
||||
<li>✓ 文档支持全文搜索 / 打印 / 导出 PDF</li>
|
||||
<li>✓ 浏览器只看站点与文档,不承载客户端</li>
|
||||
<li>✓ 随版本发布自动更新</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="/docs" target="_blank" rel="noopener">浏览文档</a>
|
||||
</div>
|
||||
<div class="price-card enterprise">
|
||||
<div class="plan-tag dark">内网部署</div>
|
||||
<div class="plan">企业内网版</div>
|
||||
<div class="price"><b>无外联</b></div>
|
||||
<div class="plan-sub">数据不出企业网</div>
|
||||
<ul>
|
||||
<li>✓ 单二进制 + 静态页,内网任意主机部署</li>
|
||||
<li>✓ 默认仅回环监听,对外需显式配置</li>
|
||||
<li>✓ SSH 隧道 / TLS 直达内网数据库</li>
|
||||
<li>✓ 无遥测、无账号、无云依赖</li>
|
||||
</ul>
|
||||
<a class="btn btn-dark" href="/docs/security" target="_blank" rel="noopener">安全设计</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FAQ ===================== -->
|
||||
<section class="section alt" id="faq">
|
||||
<div class="wrap narrow">
|
||||
<h2 class="sec-title">常见问题</h2>
|
||||
<div class="faq">
|
||||
<details open><summary>我的连接密码保存在哪?安全吗?</summary><p>全部保存在本机(混淆存储),查询时只携带会话 ID,密码不会随每次请求上送,也不会同步到任何云端。</p></details>
|
||||
<details><summary>能在浏览器里操作数据库吗?</summary><p>不能。客户端唯一形态是桌面应用(Tauri 2);浏览器只承载官网与文档,由后端 :8788 内置提供。开发时的 :5174 只是桌面 devUrl 的资产服务。</p></details>
|
||||
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
|
||||
<details><summary>Oracle 支持好了吗?</summary><p>驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。</p></details>
|
||||
<details><summary>AI 功能需要联网吗?</summary><p>当前版本 AI 面板为本地 stub(生成 / 解释 / 优化等动作的框架已就位),接入模型服务的版本会明确标注数据流向。</p></details>
|
||||
<details><summary>支持达梦 / MongoDB 吗?</summary><p>已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。</p></details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== CTA ===================== -->
|
||||
<section class="cta">
|
||||
<div class="wrap">
|
||||
<h2>把数据库客户端收回自己手里</h2>
|
||||
<p>30 秒连上第一个库;官网与文档都由你手里的这个二进制提供。</p>
|
||||
<div class="hero-actions center">
|
||||
<a class="btn btn-inverse btn-lg" href="/docs/quickstart" target="_blank" rel="noopener">快速开始 ↗</a>
|
||||
<a class="btn btn-outline btn-lg" href="/docs" target="_blank" rel="noopener">阅读文档</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="OpsxDB"><defs><linearGradient id="lg-foot" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#059669"/><stop offset="1" stop-color="#06B6D4"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#lg-foot)"/><g stroke="#fff" stroke-width="4.5" fill="none" stroke-linecap="round"><ellipse cx="32" cy="20" rx="15" ry="6.5"/><path d="M17 20v24c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5V20"/><path d="M17 32c0 3.6 6.7 6.5 15 6.5s15-2.9 15-6.5"/></g></svg></span><span class="brand-name">OpsxDB</span></div>
|
||||
<p class="footer-desc">本地优先的桌面数据库工作台。<br>数据在自己手里,才是真的效率工具。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>产品</h4>
|
||||
<a href="#features">功能特性</a>
|
||||
<a href="#drivers">数据库支持</a>
|
||||
<a href="#download">下载与形态</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>资源</h4>
|
||||
<a href="/docs" target="_blank" rel="noopener">用户文档</a>
|
||||
<a href="/docs/drivers" target="_blank" rel="noopener">驱动验收状态</a>
|
||||
<a href="/docs/faq" target="_blank" rel="noopener">常见问题</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>安全</h4>
|
||||
<a href="#security">安全设计</a>
|
||||
<a href="/docs/security" target="_blank" rel="noopener">SSL 与 SSH 隧道</a>
|
||||
<a href="/docs/quickstart" target="_blank" rel="noopener">快速开始</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bar">© 2026 OpsxDB · 本页由单二进制内置提供(/ · /home)</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user