feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、 Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
commit
f8740d27f7
240 files changed
+21625
No files matched your search
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"opsxdb/server/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := resolveListenAddr()
|
||||
log.Printf("OpsxDB server listening on %s", addr)
|
||||
if err := server.NewRouter().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
|
||||
}
|
||||
pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXDB_BIND_PUBLIC")))
|
||||
if pub == "1" || pub == "true" || pub == "yes" {
|
||||
return ":8788"
|
||||
}
|
||||
return "127.0.0.1:8788"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
server:
|
||||
addr: ":8788"
|
||||
proxy:
|
||||
timeout_ms: 30000
|
||||
# Environment overrides:
|
||||
# OPSXDB_ADDR=:8788
|
||||
# OPSXDB_PROXY_TIMEOUT_MS=30000
|
||||
@@ -0,0 +1,63 @@
|
||||
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
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
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/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=
|
||||
@@ -0,0 +1,97 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type ChatRes struct {
|
||||
Content string `json:"content"`
|
||||
SQL string `json:"sql,omitempty"`
|
||||
}
|
||||
|
||||
func Chat(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"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package db
|
||||
|
||||
import "strings"
|
||||
|
||||
// IsDangerousSQL 识别可能破坏数据的语句,供前端二次确认。
|
||||
func IsDangerousSQL(sql string) bool {
|
||||
s := stripSQLLead(sql)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
up := strings.ToUpper(s)
|
||||
for _, p := range []string{"DROP ", "DROP\t", "TRUNCATE ", "ALTER ", "GRANT ", "REVOKE "} {
|
||||
if strings.HasPrefix(up, strings.TrimSpace(p)) || strings.HasPrefix(up, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(up, "DELETE") {
|
||||
return !containsWhere(up)
|
||||
}
|
||||
if strings.HasPrefix(up, "UPDATE") {
|
||||
return !containsWhere(up)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsWhere(up string) bool {
|
||||
return strings.Contains(up, " WHERE ") || strings.Contains(up, "\nWHERE ") || strings.Contains(up, "\tWHERE ")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package db
|
||||
|
||||
import "strings"
|
||||
|
||||
func quoteMySQLIdent(s string) string {
|
||||
return "`" + strings.ReplaceAll(s, "`", "") + "`"
|
||||
}
|
||||
|
||||
func quotePGIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
)
|
||||
|
||||
func openMSSQL(c ConnReq) (*sql.DB, 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 nil, 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 nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||
}
|
||||
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?%s",
|
||||
url.QueryEscape(user), url.QueryEscape(c.Password), host, port, q.Encode())
|
||||
return sql.Open("sqlserver", dsn)
|
||||
}
|
||||
|
||||
func listDatabasesMSSQL(db *sql.DB) ([]string, error) {
|
||||
rows, err := db.Query(`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(db *sql.DB, database string) ([]string, error) {
|
||||
_ = database
|
||||
rows, err := db.Query(`
|
||||
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(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||
schema, t := splitSchemaTable(table)
|
||||
if schema == "" {
|
||||
schema = "dbo"
|
||||
}
|
||||
rows, err := dbh.Query(`
|
||||
SELECT COLUMN_NAME, DATA_TYPE,
|
||||
ISNULL(CAST(CHARACTER_MAXIMUM_LENGTH AS varchar), ''),
|
||||
IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @p1 AND TABLE_NAME = @p2
|
||||
ORDER BY ORDINAL_POSITION`, schema, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var name, typ, extra, nullable string
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if extra != "" && extra != "-1" {
|
||||
typ = typ + "(" + extra + ")"
|
||||
}
|
||||
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"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 c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" {
|
||||
cfg.TLSConfig = "skip-verify"
|
||||
} else {
|
||||
name, err := registerMySQLTLS(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.TLSConfig = name
|
||||
}
|
||||
case "require", "verify-ca":
|
||||
if c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" && 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())
|
||||
}
|
||||
|
||||
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.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(db *sql.DB) ([]string, error) {
|
||||
rows, err := db.Query("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(db *sql.DB, database string) ([]string, error) {
|
||||
q := "SHOW TABLES"
|
||||
if database != "" {
|
||||
q = "SHOW TABLES FROM " + quoteMySQLIdent(database)
|
||||
}
|
||||
rows, err := db.Query(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(db *sql.DB, database, table string) ([]ColumnInfo, error) {
|
||||
ident := quoteMySQLIdent(table)
|
||||
if database != "" {
|
||||
ident = quoteMySQLIdent(database) + "." + ident
|
||||
}
|
||||
rows, err := db.Query("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()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(2)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func TestConnection(c ConnReq) error {
|
||||
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)
|
||||
}
|
||||
db, err := Open(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return db.Ping()
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ListDatabases(c ConnReq) ([]string, error) {
|
||||
if isRedis(c.Driver) {
|
||||
return listDatabasesRedis(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"
|
||||
}
|
||||
}
|
||||
db, err := Open(cc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
return listDatabasesMySQL(db)
|
||||
case isPostgresFamily(c.Driver):
|
||||
return listDatabasesPostgres(db)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
return listDatabasesMSSQL(db)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
return listDatabasesOracle(db)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
return listDatabasesSQLite(db)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported driver")
|
||||
}
|
||||
}
|
||||
|
||||
func ListTables(c ConnReq) ([]string, error) {
|
||||
if isRedis(c.Driver) {
|
||||
return listTablesRedis(c)
|
||||
}
|
||||
db, err := Open(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
return listTablesMySQL(db, c.Database)
|
||||
case isPostgresFamily(c.Driver):
|
||||
return listTablesPostgres(db, c.Database)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
return listTablesMSSQL(db, c.Database)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
return listTablesOracle(db, c.Database)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
return listTablesSQLite(db, c.Database)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported driver")
|
||||
}
|
||||
}
|
||||
|
||||
func ListColumns(c ConnReq) ([]ColumnInfo, error) {
|
||||
if c.Table == "" {
|
||||
return nil, fmt.Errorf("缺少表名")
|
||||
}
|
||||
if isRedis(c.Driver) {
|
||||
return listColumnsRedis(c, c.Table)
|
||||
}
|
||||
db, err := Open(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
switch {
|
||||
case isMySQLFamily(c.Driver):
|
||||
return listColumnsMySQL(db, c.Database, c.Table)
|
||||
case isPostgresFamily(c.Driver):
|
||||
return listColumnsPostgres(db, c.Database, c.Table)
|
||||
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||
return listColumnsMSSQL(db, c.Database, c.Table)
|
||||
case driverKey(c.Driver) == "oracle":
|
||||
return listColumnsOracle(db, c.Database, c.Table)
|
||||
case driverKey(c.Driver) == "sqlite":
|
||||
return listColumnsSQLite(db, c.Database, c.Table)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported driver")
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// ExecSQL 执行查询 / 非查询语句。ctx 来自 HTTP 请求,客户端断开/Abort 时可取消 DB 调用。
|
||||
func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
|
||||
start := time.Now()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
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)
|
||||
}
|
||||
db, err := Open(c)
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if !isQuerySQL(c.SQL) {
|
||||
res, err := db.ExecContext(ctx, c.SQL)
|
||||
if err != nil {
|
||||
return execFail(start, err)
|
||||
}
|
||||
affected, _ := res.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)
|
||||
var resultRows []map[string]interface{}
|
||||
truncated := false
|
||||
for rows.Next() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return execFail(start, 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 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,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"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(db *sql.DB) ([]string, error) {
|
||||
// 返回当前用户可见 schema
|
||||
rows, err := db.Query(`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(db *sql.DB, schema string) ([]string, error) {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if schema == "" {
|
||||
rows, err = db.Query(`SELECT table_name FROM user_tables ORDER BY table_name`)
|
||||
} else {
|
||||
rows, err = db.Query(`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(dbh *sql.DB, schema, table string) ([]ColumnInfo, error) {
|
||||
owner, t := splitSchemaTable(table)
|
||||
if owner == "" {
|
||||
owner = schema
|
||||
}
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if owner == "" {
|
||||
rows, err = dbh.Query(`
|
||||
SELECT column_name, data_type, nullable, data_default
|
||||
FROM user_tab_columns WHERE table_name = UPPER(:1)
|
||||
ORDER BY column_id`, t)
|
||||
} else {
|
||||
rows, err = dbh.Query(`
|
||||
SELECT column_name, data_type, nullable, data_default
|
||||
FROM all_tab_columns WHERE owner = UPPER(:1) AND table_name = UPPER(:2)
|
||||
ORDER BY column_id`, owner, t)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var name, typ, nullable string
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&name, &typ, &nullable, &def); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "Y"}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"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) == "" {
|
||||
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(db *sql.DB) ([]string, error) {
|
||||
rows, err := db.Query(`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(db *sql.DB, database string) ([]string, error) {
|
||||
// 当前连接库下的 public 表;切库需重新 Open
|
||||
_ = database
|
||||
rows, err := db.Query(`
|
||||
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(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||
schema, t := splitSchemaTable(table)
|
||||
if schema == "" {
|
||||
schema = "public"
|
||||
}
|
||||
rows, err := dbh.Query(`
|
||||
SELECT column_name, data_type,
|
||||
COALESCE(character_maximum_length::text, numeric_precision::text, ''),
|
||||
is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2
|
||||
ORDER BY ordinal_position`, schema, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ColumnInfo
|
||||
for rows.Next() {
|
||||
var name, typ, extra, nullable string
|
||||
var def sql.NullString
|
||||
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if extra != "" {
|
||||
typ = typ + "(" + extra + ")"
|
||||
}
|
||||
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
|
||||
if def.Valid {
|
||||
col.Default = def.String
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ColumnInfo{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func redisOpts(c ConnReq) (*redis.Options, error) {
|
||||
host := c.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 6379
|
||||
}
|
||||
db := 0
|
||||
if c.Database != "" {
|
||||
if n, err := strconv.Atoi(c.Database); err == nil {
|
||||
db = n
|
||||
}
|
||||
}
|
||||
opts := &redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", host, port),
|
||||
Password: c.Password,
|
||||
Username: c.User,
|
||||
DB: db,
|
||||
DialTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
if SSLEnabled(c) {
|
||||
tlsCfg, err := tlsConfigFrom(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.TLSConfig = tlsCfg
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func openRedis(c ConnReq) (*redis.Client, error) {
|
||||
opts, err := redisOpts(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return redis.NewClient(opts), nil
|
||||
}
|
||||
|
||||
func testRedis(c ConnReq) error {
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return cli.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func listDatabasesRedis(c ConnReq) ([]string, error) {
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
// INFO keyspace 可拿已用 DB;统一暴露 0-15 便于切换
|
||||
_ = ctx
|
||||
out := make([]string, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
out[i] = strconv.Itoa(i)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func listTablesRedis(c ConnReq) ([]string, error) {
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
var keys []string
|
||||
var cursor uint64
|
||||
for {
|
||||
batch, next, err := cli.Scan(ctx, cursor, "*", 200).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, batch...)
|
||||
cursor = next
|
||||
if cursor == 0 || len(keys) >= 500 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []string{}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func execRedis(parent context.Context, c ConnReq) ExecResult {
|
||||
start := time.Now()
|
||||
sql := strings.TrimSpace(c.SQL)
|
||||
if sql == "" {
|
||||
return ExecResult{Error: "empty sql", TimeMs: 0}
|
||||
}
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
defer cli.Close()
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fields := splitRedisCmd(sql)
|
||||
if len(fields) == 0 {
|
||||
return ExecResult{Error: "empty command", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
cmd := strings.ToUpper(fields[0])
|
||||
args := fields[1:]
|
||||
|
||||
switch cmd {
|
||||
case "PING":
|
||||
val, err := cli.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||
case "GET":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "GET key", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
val, err := cli.Get(ctx, args[0]).Result()
|
||||
if err == redis.Nil {
|
||||
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{{"key": args[0], "value": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||
case "SET":
|
||||
if len(args) < 2 {
|
||||
return ExecResult{Error: "SET key value", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
if err := cli.Set(ctx, args[0], strings.Join(args[1:], " "), 0).Err(); err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": 1}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||
case "DEL":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "DEL key [key...]", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
n, err := cli.Del(ctx, args...).Result()
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||
case "KEYS":
|
||||
pat := "*"
|
||||
if len(args) > 0 {
|
||||
pat = args[0]
|
||||
}
|
||||
keys, err := cli.Keys(ctx, pat).Result()
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
rows := make([]map[string]interface{}, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
rows = append(rows, map[string]interface{}{"key": k})
|
||||
}
|
||||
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
|
||||
case "SCAN":
|
||||
keys, err := listTablesRedis(c)
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
rows := make([]map[string]interface{}, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
rows = append(rows, map[string]interface{}{"key": k})
|
||||
}
|
||||
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
|
||||
case "HGETALL":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "HGETALL key", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
c2 := c
|
||||
c2.Key = args[0]
|
||||
c2.Limit = redisValueRowCap
|
||||
v := GetRedisKey(c2)
|
||||
if v.Error != "" && v.Type == "" {
|
||||
return ExecResult{Error: v.Error, TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||
case "LRANGE":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "LRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
c2 := c
|
||||
c2.Key = args[0]
|
||||
c2.Limit = redisValueRowCap
|
||||
v := GetRedisKey(c2)
|
||||
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||
case "SMEMBERS":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "SMEMBERS key", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
c2 := c
|
||||
c2.Key = args[0]
|
||||
v := GetRedisKey(c2)
|
||||
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||
case "ZRANGE":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "ZRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
c2 := c
|
||||
c2.Key = args[0]
|
||||
v := GetRedisKey(c2)
|
||||
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||
case "TYPE":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "TYPE key", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
t, err := cli.Type(ctx, args[0]).Result()
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{Columns: []string{"key", "type"}, Rows: []map[string]interface{}{{"key": args[0], "type": t}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||
case "TTL":
|
||||
if len(args) < 1 {
|
||||
return ExecResult{Error: "TTL key", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
n, err := cli.TTL(ctx, args[0]).Result()
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{Columns: []string{"key", "ttl_sec"}, Rows: []map[string]interface{}{{"key": args[0], "ttl_sec": int64(n.Seconds())}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||
default:
|
||||
return ExecResult{Error: fmt.Sprintf("暂不支持命令 %s(可用: PING/GET/SET/DEL/KEYS/SCAN/TYPE/TTL/HGETALL/LRANGE/SMEMBERS/ZRANGE)", cmd), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
}
|
||||
|
||||
func splitRedisCmd(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inQ := false
|
||||
esc := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case esc:
|
||||
cur.WriteRune(r)
|
||||
esc = false
|
||||
case r == '\\':
|
||||
esc = true
|
||||
case r == '"':
|
||||
inQ = !inQ
|
||||
case !inQ && (r == ' ' || r == '\t' || r == '\n'):
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isRedis(d string) bool {
|
||||
return driverKey(d) == "redis"
|
||||
}
|
||||
|
||||
// IsRedisDriver exported for router guards.
|
||||
func IsRedisDriver(d string) bool {
|
||||
return isRedis(d)
|
||||
}
|
||||
|
||||
func listColumnsRedis(c ConnReq, key string) ([]ColumnInfo, error) {
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
typ, err := cli.Type(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ttl, _ := cli.TTL(ctx, key).Result()
|
||||
return []ColumnInfo{
|
||||
{Name: key, Type: typ, Comment: fmt.Sprintf("ttl=%ds", int64(ttl.Seconds()))},
|
||||
}, nil
|
||||
}
|
||||
|
||||
const redisKeyListCap = 500
|
||||
const redisValueRowCap = 500
|
||||
|
||||
func redisKeyName(c ConnReq) string {
|
||||
if c.Key != "" {
|
||||
return c.Key
|
||||
}
|
||||
return c.Table
|
||||
}
|
||||
|
||||
func redisScanPattern(c ConnReq) string {
|
||||
p := strings.TrimSpace(c.Pattern)
|
||||
if p == "" {
|
||||
return "*"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func redisValueLimit(c ConnReq) int {
|
||||
n := c.Limit
|
||||
if n <= 0 {
|
||||
n = redisValueRowCap
|
||||
}
|
||||
if n > MaxRowLimit {
|
||||
n = MaxRowLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func ttlSeconds(d time.Duration) int64 {
|
||||
// go-redis maps Redis TTL -1 (no expire) / -2 (missing) to -1ns/-2ns
|
||||
// (some versions use -1s/-2s). Truncating with d/time.Second turns those into 0.
|
||||
switch d {
|
||||
case time.Duration(-1), -1 * time.Second:
|
||||
return -1
|
||||
case time.Duration(-2), -2 * time.Second:
|
||||
return -2
|
||||
}
|
||||
return int64(d / time.Second)
|
||||
}
|
||||
|
||||
// ListRedisKeys SCAN + pipeline TYPE/TTL. Cap ~500.
|
||||
func ListRedisKeys(c ConnReq) RedisKeysResult {
|
||||
start := time.Now()
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pat := redisScanPattern(c)
|
||||
var names []string
|
||||
var cursor uint64
|
||||
truncated := false
|
||||
for {
|
||||
batch, next, err := cli.Scan(ctx, cursor, pat, 200).Result()
|
||||
if err != nil {
|
||||
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
names = append(names, batch...)
|
||||
cursor = next
|
||||
if len(names) >= redisKeyListCap {
|
||||
truncated = true
|
||||
names = names[:redisKeyListCap]
|
||||
break
|
||||
}
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
|
||||
out := make([]RedisKeyInfo, len(names))
|
||||
if len(names) == 0 {
|
||||
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
// Pipeline TYPE + TTL (avoid N+1)
|
||||
pipe := cli.Pipeline()
|
||||
typeCmds := make([]*redis.StatusCmd, len(names))
|
||||
ttlCmds := make([]*redis.DurationCmd, len(names))
|
||||
for i, k := range names {
|
||||
typeCmds[i] = pipe.Type(ctx, k)
|
||||
ttlCmds[i] = pipe.TTL(ctx, k)
|
||||
}
|
||||
_, _ = pipe.Exec(ctx)
|
||||
for i, k := range names {
|
||||
typ := "none"
|
||||
if t, err := typeCmds[i].Result(); err == nil {
|
||||
typ = t
|
||||
}
|
||||
ttl := int64(-2)
|
||||
if d, err := ttlCmds[i].Result(); err == nil {
|
||||
ttl = ttlSeconds(d)
|
||||
}
|
||||
out[i] = RedisKeyInfo{Name: k, Type: typ, TTL: ttl}
|
||||
}
|
||||
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
// GetRedisKey typed value → ResultPane columns/rows.
|
||||
func GetRedisKey(c ConnReq) RedisKeyValue {
|
||||
start := time.Now()
|
||||
key := redisKeyName(c)
|
||||
if key == "" {
|
||||
return RedisKeyValue{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
typ, err := cli.Type(ctx, key).Result()
|
||||
if err != nil {
|
||||
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
ttl := int64(-2)
|
||||
if d, err := cli.TTL(ctx, key).Result(); err == nil {
|
||||
ttl = ttlSeconds(d)
|
||||
}
|
||||
limit := redisValueLimit(c)
|
||||
base := RedisKeyValue{Name: key, Type: typ, TTL: ttl, Limit: limit}
|
||||
|
||||
switch typ {
|
||||
case "none":
|
||||
base.Columns = []string{"value"}
|
||||
base.Rows = []map[string]interface{}{}
|
||||
base.RowCount = 0
|
||||
base.Error = "key 不存在"
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
case "string":
|
||||
val, err := cli.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
base.Columns = []string{"value"}
|
||||
base.Rows = []map[string]interface{}{}
|
||||
base.RowCount = 0
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
}
|
||||
if err != nil {
|
||||
base.Error = errMsg(err)
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
}
|
||||
base.Columns = []string{"value"}
|
||||
base.Rows = []map[string]interface{}{{"value": val}}
|
||||
base.RowCount = 1
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
case "hash":
|
||||
fields, err := cli.HGetAll(ctx, key).Result()
|
||||
if err != nil {
|
||||
base.Error = errMsg(err)
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
}
|
||||
base.Columns = []string{"field", "value"}
|
||||
rows := make([]map[string]interface{}, 0, len(fields))
|
||||
for f, v := range fields {
|
||||
rows = append(rows, map[string]interface{}{"field": f, "value": v})
|
||||
if len(rows) >= limit {
|
||||
base.Truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
base.Rows = rows
|
||||
base.RowCount = len(rows)
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
case "list":
|
||||
vals, err := cli.LRange(ctx, key, 0, int64(limit)-1).Result()
|
||||
if err != nil {
|
||||
base.Error = errMsg(err)
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
}
|
||||
truncated := false
|
||||
n, _ := cli.LLen(ctx, key).Result()
|
||||
if n > int64(len(vals)) {
|
||||
truncated = true
|
||||
}
|
||||
base.Columns = []string{"index", "value"}
|
||||
rows := make([]map[string]interface{}, 0, len(vals))
|
||||
for i, v := range vals {
|
||||
rows = append(rows, map[string]interface{}{"index": i, "value": v})
|
||||
}
|
||||
base.Rows = rows
|
||||
base.RowCount = len(rows)
|
||||
base.Truncated = truncated
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
case "set":
|
||||
base.Columns = []string{"member"}
|
||||
var rows []map[string]interface{}
|
||||
var scursor uint64
|
||||
truncated := false
|
||||
for {
|
||||
batch, next, err := cli.SScan(ctx, key, scursor, "*", 200).Result()
|
||||
if err != nil {
|
||||
base.Error = errMsg(err)
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
}
|
||||
for _, m := range batch {
|
||||
rows = append(rows, map[string]interface{}{"member": m})
|
||||
if len(rows) >= limit {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
scursor = next
|
||||
if truncated || scursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []map[string]interface{}{}
|
||||
}
|
||||
base.Rows = rows
|
||||
base.RowCount = len(rows)
|
||||
base.Truncated = truncated
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
case "zset":
|
||||
vals, err := cli.ZRangeWithScores(ctx, key, 0, int64(limit-1)).Result()
|
||||
if err != nil {
|
||||
base.Error = errMsg(err)
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
}
|
||||
truncated := false
|
||||
n, _ := cli.ZCard(ctx, key).Result()
|
||||
if n > int64(len(vals)) {
|
||||
truncated = true
|
||||
}
|
||||
base.Columns = []string{"score", "member"}
|
||||
rows := make([]map[string]interface{}, 0, len(vals))
|
||||
for _, z := range vals {
|
||||
rows = append(rows, map[string]interface{}{"score": z.Score, "member": z.Member})
|
||||
}
|
||||
base.Rows = rows
|
||||
base.RowCount = len(rows)
|
||||
base.Truncated = truncated
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
default:
|
||||
// stream / recon / module types: meta only
|
||||
base.Columns = []string{"info"}
|
||||
base.Rows = []map[string]interface{}{{"info": fmt.Sprintf("类型 %s 暂不展开,可用命令查看", typ)}}
|
||||
base.RowCount = 1
|
||||
base.TimeMs = time.Since(start).Milliseconds()
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteRedisKey DEL one key.
|
||||
func DeleteRedisKey(c ConnReq) ExecResult {
|
||||
start := time.Now()
|
||||
key := redisKeyName(c)
|
||||
if key == "" {
|
||||
return ExecResult{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
cli, err := openRedis(c)
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
n, err := cli.Del(ctx, key).Result()
|
||||
if err != nil {
|
||||
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||
}
|
||||
return ExecResult{
|
||||
Columns: []string{"affected_rows"},
|
||||
Rows: []map[string]interface{}{{"affected_rows": n}},
|
||||
RowCount: 1,
|
||||
TimeMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"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(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(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(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)
|
||||
}
|
||||
gl := GetRedisKey(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(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(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(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(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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const sessionTTL = 45 * time.Minute
|
||||
const maxSessions = 64
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
Conn ConnReq
|
||||
mu sync.Mutex
|
||||
sqlPool map[string]*sql.DB
|
||||
redisCli *redis.Client
|
||||
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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func newSessionID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func OpenSession(c ConnReq) (string, error) {
|
||||
c.SQL = ""
|
||||
c.Table = ""
|
||||
c.Key = ""
|
||||
c.Pattern = ""
|
||||
c.SessionID = ""
|
||||
|
||||
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 := newSessionID()
|
||||
// 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{},
|
||||
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)
|
||||
}
|
||||
if s.redisCli != nil {
|
||||
_ = s.redisCli.Close()
|
||||
s.redisCli = nil
|
||||
}
|
||||
if s.tunnel != nil {
|
||||
s.tunnel.Close()
|
||||
s.tunnel = 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.Limit = req.Limit
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func openSQLite(c ConnReq) (*sql.DB, error) {
|
||||
path := c.Database
|
||||
if path == "" {
|
||||
path = c.Host
|
||||
}
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("SQLite 需要 database 填文件路径")
|
||||
}
|
||||
return sql.Open("sqlite", path)
|
||||
}
|
||||
|
||||
func listDatabasesSQLite(_ *sql.DB) ([]string, error) {
|
||||
return []string{"main"}, nil
|
||||
}
|
||||
|
||||
func listTablesSQLite(db *sql.DB, _ string) ([]string, error) {
|
||||
rows, err := db.Query(`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(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||
rows, err := dbh.Query(`PRAGMA table_info(` + quotePGIdent(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()
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
||||
out := c
|
||||
out.Host = "127.0.0.1"
|
||||
out.Port = tun.LocalPort
|
||||
// Avoid nested tunnel if helpers re-enter
|
||||
out.SSHEnabled = false
|
||||
return out, tun, nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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"
|
||||
}
|
||||
|
||||
// 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 := strings.TrimSpace(c.Host); 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)
|
||||
}
|
||||
|
||||
if path := strings.TrimSpace(c.SSLCA); path != "" {
|
||||
pem, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CA 证书失败 (%s): %w", path, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("CA 证书不是有效 PEM: %s", path)
|
||||
}
|
||||
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 证书路径")
|
||||
}
|
||||
|
||||
certPath := strings.TrimSpace(c.SSLCert)
|
||||
keyPath := strings.TrimSpace(c.SSLKey)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
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"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Table string `json:"table"`
|
||||
Limit int `json:"limit"`
|
||||
Key string `json:"key"` // Redis key (get/delete)
|
||||
Pattern string `json:"pattern"` // Redis SCAN match
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func normalize(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []byte:
|
||||
return string(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"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
:root {
|
||||
--bg: #0e1116; --panel: #151a21; --border: #243044;
|
||||
--text: #e6edf7; --muted: #8b9bb4; --accent: #3b82f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 13px/1.55 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
||||
.top {
|
||||
height: 44px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
display: flex; align-items: center; justify-content: space-between; padding: 0 16px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; font-weight: 600; }
|
||||
.brand em { color: var(--muted); font-style: normal; font-weight: 500; }
|
||||
.logo {
|
||||
width: 24px; height: 24px; border-radius: 6px; background: var(--accent);
|
||||
display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.top a { color: var(--muted); text-decoration: none; font-size: 12.5px; }
|
||||
.layout { display: flex; min-height: calc(100vh - 44px); }
|
||||
.side {
|
||||
width: 200px; border-right: 1px solid var(--border); padding: 12px 10px;
|
||||
background: var(--panel); display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.nav-link {
|
||||
display: block; padding: 6px 8px; border-radius: 6px; color: var(--muted);
|
||||
text-decoration: none; font-size: 12.5px;
|
||||
}
|
||||
.nav-link:hover { color: var(--text); background: #1a2230; }
|
||||
.nav-link.active { color: var(--text); background: color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||
.content { flex: 1; padding: 24px 28px; max-width: 720px; }
|
||||
.content h1 { font-size: 20px; margin: 0 0 14px; }
|
||||
.content h2 { font-size: 15px; margin: 22px 0 8px; }
|
||||
.content p, .content li { color: #c5d0e0; }
|
||||
.content code, .content pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
||||
}
|
||||
.content pre {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
|
||||
}
|
||||
.content code { background: #1a2230; padding: 1px 5px; border-radius: 4px; }
|
||||
@@ -0,0 +1,7 @@
|
||||
<h1>连库入门</h1>
|
||||
<p>在连库页选择驱动,填写主机 / 端口 / 账号,测试通过后保存并打开。</p>
|
||||
<ul>
|
||||
<li>密码仅存本机(本地加密存储)。</li>
|
||||
<li>会话由本地后端维护;后端未启动时顶部会提示,不会白屏。</li>
|
||||
<li>断线后可在底栏重连,恢复当前库上下文。</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,7 @@
|
||||
<h1>常见问题</h1>
|
||||
<h2>打开后提示本地后端未就绪?</h2>
|
||||
<p>确认应用已拉起 sidecar;或手动 <code>./run.sh server</code> 后重试。默认只监听 <code>127.0.0.1:8788</code>。</p>
|
||||
<h2>浏览器也能用吗?</h2>
|
||||
<p>可以调试,但<strong>产品说明与对外沟通应以桌面版为准</strong>,不要暗示 Web 即产品。</p>
|
||||
<h2>官网 / 文档在哪?</h2>
|
||||
<p>本地后端提供:<code>http://127.0.0.1:8788/</code>(官网)、<code>/docs</code>(文档)。</p>
|
||||
@@ -0,0 +1,13 @@
|
||||
<h1>安装与启动</h1>
|
||||
<p>OpsxDB <strong>正式形态是桌面 GUI</strong>(Tauri)。开发态浏览器 <code>:5174</code> 只用于调试,不是产品交付。</p>
|
||||
<h2>桌面版</h2>
|
||||
<ol>
|
||||
<li>安装本机构建产物(macOS <code>.dmg</code> / <code>.app</code>,或 <code>npm run build:desktop</code> 产出)。</li>
|
||||
<li>双击启动:应用会拉起本地后端(默认 <code>127.0.0.1:8788</code>),进入工作台。</li>
|
||||
<li>无需手动执行 <code>./run.sh web</code>。</li>
|
||||
</ol>
|
||||
<h2>开发调试</h2>
|
||||
<pre><code>./run.sh gui # 或 ./run.sh web
|
||||
</code></pre>
|
||||
<p>开发壳地址:<code>http://127.0.0.1:5174</code>。</p>
|
||||
<p>改后端(<code>apps/server</code>)后请执行 <code>./run.sh restart</code>(或等价重启 Gin),避免 <code>:8788</code> 仍跑旧二进制。</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
<h1>工作台简介</h1>
|
||||
<p>深色密度布局:左侧连接与对象树,中间编辑器与多结果 Tab,可选 AI 侧栏。</p>
|
||||
<ul>
|
||||
<li>多结果集:成功结果保留;失败不盖掉已有成功集。</li>
|
||||
<li>执行计划:有则展示,无则禁用。</li>
|
||||
<li>书签:紧凑列表,回填 SQL,本地持久化。</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package docs:用户文档骨架(嵌入 HTML,侧栏+正文密度对齐工作台)。
|
||||
package docs
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed content assets template.html
|
||||
var files embed.FS
|
||||
|
||||
type page struct {
|
||||
Slug string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
var order = []page{
|
||||
{Slug: "install", Title: "安装与启动"},
|
||||
{Slug: "connect", Title: "连库入门"},
|
||||
{Slug: "workbench", Title: "工作台简介"},
|
||||
{Slug: "faq", Title: "常见问题"},
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
tpl string
|
||||
}
|
||||
|
||||
func New() *Handler {
|
||||
raw, err := files.ReadFile("template.html")
|
||||
if err != nil {
|
||||
panic("docs: template.html 缺失: " + err.Error())
|
||||
}
|
||||
return &Handler{tpl: string(raw)}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(r *gin.Engine) {
|
||||
r.GET("/docs", func(c *gin.Context) { c.Redirect(http.StatusFound, "/docs/install") })
|
||||
r.GET("/docs/:slug", h.Page)
|
||||
r.GET("/docs/assets/*file", h.Asset)
|
||||
}
|
||||
|
||||
func (h *Handler) Page(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
bodyBytes, err := files.ReadFile("content/" + slug + ".html")
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
title := slug
|
||||
nav := strings.Builder{}
|
||||
for _, p := range order {
|
||||
cls := "nav-link"
|
||||
if p.Slug == slug {
|
||||
cls += " active"
|
||||
title = p.Title
|
||||
}
|
||||
nav.WriteString(`<a class="` + cls + `" href="/docs/` + p.Slug + `">` + p.Title + `</a>`)
|
||||
}
|
||||
html := h.tpl
|
||||
html = strings.ReplaceAll(html, "{{TITLE}}", title)
|
||||
html = strings.ReplaceAll(html, "{{NAV}}", nav.String())
|
||||
html = strings.ReplaceAll(html, "{{BODY}}", string(bodyBytes))
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
_, _ = c.Writer.WriteString(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.Data(http.StatusOK, ct, data)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}} · OpsxDB 文档</title>
|
||||
<link rel="stylesheet" href="/docs/assets/docs.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<a class="brand" href="/docs"><span class="logo">DB</span> OpsxDB <em>文档</em></a>
|
||||
<nav><a href="/home">官网</a></nav>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="side">{{NAV}}</aside>
|
||||
<main class="content">{{BODY}}</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var blockedHosts = map[string]struct{}{
|
||||
"169.254.169.254": {},
|
||||
"metadata.google.internal": {},
|
||||
}
|
||||
|
||||
// ValidateURL applies MVP SSRF basics. Week 2 should add configurable CIDR
|
||||
// deny-lists and DNS-rebinding protection before exposing this beyond localhost.
|
||||
func ValidateURL(raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid url: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("only http/https schemes are allowed")
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return nil, fmt.Errorf("url host is required")
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||
if _, blocked := blockedHosts[host]; blocked {
|
||||
return nil, fmt.Errorf("blocked metadata host")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.Equal(net.ParseIP("169.254.169.254")) {
|
||||
return nil, fmt.Errorf("blocked metadata address")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package proxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
ok bool
|
||||
}{
|
||||
{"https://example.com/a", true},
|
||||
{"file:///etc/passwd", false},
|
||||
{"http://169.254.169.254/latest/meta-data", false},
|
||||
{"http://metadata.google.internal/computeMetadata/v1", false},
|
||||
} {
|
||||
_, err := ValidateURL(tc.raw)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("%s: %v", tc.raw, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("expected rejection: %s", tc.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxResponseBytes = 10 << 20 // 10 MiB MVP safety cap
|
||||
|
||||
type Handler struct {
|
||||
BaseClient *http.Client
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{BaseClient: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (h *Handler) ProxyHTTP(c *gin.Context) {
|
||||
var spec HttpSpec
|
||||
if err := c.ShouldBindJSON(&spec); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
method := strings.ToUpper(spec.Method)
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported method"})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := ValidateURL(spec.URL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
query := u.Query()
|
||||
for key, value := range spec.Query {
|
||||
query.Set(key, value)
|
||||
}
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
var body io.Reader
|
||||
if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet {
|
||||
body = bytes.NewBufferString(spec.Body.Raw)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
for key, value := range spec.Headers {
|
||||
// Hop-by-hop headers are managed by net/http.
|
||||
if !isHopByHop(key) {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := h.BaseClient
|
||||
if spec.TimeoutMS > 0 {
|
||||
client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond}
|
||||
}
|
||||
started := time.Now()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes+1))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "cannot read upstream response", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(payload) > maxResponseBytes {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream response exceeds 10 MiB limit"})
|
||||
return
|
||||
}
|
||||
|
||||
headers := make(map[string]string, len(res.Header))
|
||||
for key, values := range res.Header {
|
||||
headers[strings.ToLower(key)] = strings.Join(values, ", ")
|
||||
}
|
||||
c.JSON(http.StatusOK, HttpResult{
|
||||
Status: res.StatusCode,
|
||||
Headers: headers,
|
||||
Body: string(payload),
|
||||
TimeMS: time.Since(started).Milliseconds(),
|
||||
Size: len(payload),
|
||||
})
|
||||
}
|
||||
|
||||
func isHopByHop(name string) bool {
|
||||
switch strings.ToLower(name) {
|
||||
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package proxy
|
||||
|
||||
type BodySpec struct {
|
||||
Mode string `json:"mode"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type HttpSpec struct {
|
||||
Method string `json:"method" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
Body *BodySpec `json:"body,omitempty"`
|
||||
TimeoutMS int `json:"timeoutMs,omitempty"`
|
||||
}
|
||||
|
||||
type HttpResult struct {
|
||||
Status int `json:"status"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
TimeMS int64 `json:"timeMs"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxdb/server/internal/ai"
|
||||
"opsxdb/server/internal/db"
|
||||
"opsxdb/server/internal/docs"
|
||||
"opsxdb/server/internal/site"
|
||||
)
|
||||
|
||||
func NewRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
r.Use(gin.Logger(), gin.Recovery(), localhostCORS())
|
||||
|
||||
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
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
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(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(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(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
|
||||
}
|
||||
res := db.ExecSQL(c.Request.Context(), req)
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
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(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(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(req)
|
||||
if res.Error != "" {
|
||||
c.JSON(http.StatusBadRequest, res)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
c.JSON(http.StatusOK, ai.Chat(req))
|
||||
})
|
||||
|
||||
api.POST("/ai/generate", func(c *gin.Context) {
|
||||
var req ai.ChatReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
req.Action = "generate"
|
||||
c.JSON(http.StatusOK, ai.Chat(req))
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb"})
|
||||
}
|
||||
|
||||
func localhostCORS() gin.HandlerFunc {
|
||||
allowed := map[string]struct{}{
|
||||
"http://localhost:5174": {},
|
||||
"http://127.0.0.1:5174": {},
|
||||
"http://localhost:5173": {},
|
||||
"http://127.0.0.1:5173": {},
|
||||
"http://localhost:1420": {},
|
||||
"http://127.0.0.1:1420": {},
|
||||
"tauri://localhost": {},
|
||||
"http://tauri.localhost": {},
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
:root {
|
||||
--bg: #0e1116;
|
||||
--panel: #151a21;
|
||||
--border: #243044;
|
||||
--text: #e6edf7;
|
||||
--muted: #8b9bb4;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav { border-bottom: 1px solid var(--border); background: var(--panel); }
|
||||
.nav .inner {
|
||||
max-width: 880px; margin: 0 auto; padding: 0 20px; height: 48px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font-weight: 600; }
|
||||
.logo {
|
||||
width: 28px; height: 28px; border-radius: 7px; background: var(--accent);
|
||||
display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.nav a { color: var(--muted); text-decoration: none; margin-left: 16px; font-size: 13px; }
|
||||
.nav a:hover { color: var(--text); }
|
||||
.hero { max-width: 880px; margin: 0 auto; padding: 56px 20px 72px; }
|
||||
.eyebrow { color: var(--accent); font-size: 12px; letter-spacing: .04em; margin: 0 0 12px; }
|
||||
h1 { font-size: 28px; line-height: 1.25; margin: 0 0 14px; font-weight: 650; }
|
||||
.lead { color: var(--muted); max-width: 52ch; margin: 0 0 24px; }
|
||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 36px; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; height: 36px; padding: 0 14px;
|
||||
border-radius: 8px; text-decoration: none; font-size: 13px; font-weight: 550;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn.ghost { color: var(--text); background: transparent; }
|
||||
.caps { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
||||
.caps li {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 14px; display: grid; gap: 4px;
|
||||
}
|
||||
.caps strong { font-size: 13px; }
|
||||
.caps span { color: var(--muted); font-size: 12.5px; }
|
||||
.foot {
|
||||
border-top: 1px solid var(--border); color: var(--muted);
|
||||
text-align: center; padding: 18px; font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<!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 主交付是桌面 GUI:连库、写 SQL、看结果。开发态 Vite 仅调试用。">
|
||||
<link rel="stylesheet" href="/site/assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<div class="inner">
|
||||
<a class="brand" href="/home"><span class="logo">DB</span> OpsxDB</a>
|
||||
<nav>
|
||||
<a href="/docs">文档</a>
|
||||
<a href="#download">下载</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="hero">
|
||||
<p class="eyebrow">桌面 GUI · 本地优先</p>
|
||||
<h1>连库、写 SQL、看结果——都在本机完成</h1>
|
||||
<p class="lead">正式形态是可安装的桌面应用,本地后端随应用启动。浏览器里的 :5174 只是开发壳,不是产品。</p>
|
||||
<div class="actions">
|
||||
<a class="btn primary" id="download" href="#download">下载桌面版(占位)</a>
|
||||
<a class="btn ghost" href="/docs">阅读文档</a>
|
||||
</div>
|
||||
<ul class="caps">
|
||||
<li><strong>多驱动连库</strong><span>MySQL / Postgres / SQLite 等,会话保活</span></li>
|
||||
<li><strong>工作台密度</strong><span>结果集、对象树、书签,深色紧凑布局</span></li>
|
||||
<li><strong>本机后端</strong><span>双击启动即带本地服务,不依赖手动起 Vite</span></li>
|
||||
</ul>
|
||||
</main>
|
||||
<footer class="foot">© 2026 OpsxDB · 产品形态为桌面 GUI</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user