feat(oracle): 支持 SID 连接模式

- 在连接弹窗中切换 Service Name 与 SID,并完善 URI 和参数回显
- 使用 SID 查询参数生成 Oracle DSN,兼容 Navicat 导入及历史配置
- 补充多语言、弹窗滚动布局和前后端回归测试
This commit is contained in:
liuqiufeng
2026-07-31 22:02:11 +08:00
parent e117ec2c8c
commit c259e2ebe2
23 changed files with 996 additions and 39 deletions

View File

@@ -45,7 +45,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
runConfig = runConfig.WithRuntimeDatabaseOverride(name)
}
default:
// oracle: dbName 表示 schema/owner不能覆盖 config.Database服务名
// oracle: dbName 表示 schema/owner不能覆盖 config.Database服务名或 SIDSID 模式)
// sqlite: 无需设置 Database
// 其他 custom: 语义不明确,避免污染缓存 key
}

View File

@@ -146,7 +146,10 @@ func parseNavicatNCXConnectionWithText(item navicatNCXConnection, text navicatTe
config.Database = strings.TrimSpace(item.TNS)
}
if configType == "oracle" && strings.EqualFold(strings.TrimSpace(item.OraServiceNameType), "SID") && strings.TrimSpace(config.Database) != "" {
// SID 模式SID 值仅存于 ConnectionParamsgo-ora 的 SID 查询参数),
// Database服务名置空避免 DSN path 冗余,语义与内置连接表单一致。
config.ConnectionParams = "SID=" + strings.TrimSpace(config.Database)
config.Database = ""
}
}

View File

@@ -239,6 +239,9 @@ func TestImportConnectionsPayloadNavicatNCXMapsOracleSIDAndRedisDB(t *testing.T)
if oracleConn.Config.Type != "oracle" || oracleConn.Config.ConnectionParams != "SID=ORCL" {
t.Fatalf("expected oracle SID connection params, got %#v", oracleConn.Config)
}
if oracleConn.Config.Database != "" {
t.Fatalf("expected oracle SID import to leave Database empty (SID only in ConnectionParams), got %q", oracleConn.Config.Database)
}
resolvedOracle, err := app.resolveConnectionSecrets(oracleConn.Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets for oracle returned error: %v", err)

View File

@@ -263,6 +263,79 @@ func TestOracleDSN_EscapesUserAndPassword(t *testing.T) {
}
}
func TestOracleDSN_SIDModeOmitsPathAndCarriesSIDParam(t *testing.T) {
o := &OracleDB{}
cfg := connection.ConnectionConfig{
Type: "oracle",
Host: "127.0.0.1",
Port: 1521,
User: "system",
Password: "secret",
ConnectionParams: "SID=ORCL",
}
if !isOracleSIDMode(cfg) {
t.Fatal("expected isOracleSIDMode to be true when SID param present")
}
if got := oracleConnectionSID(cfg); got != "ORCL" {
t.Fatalf("oracleConnectionSID = %q, want ORCL", got)
}
dsn := o.getDSN(cfg)
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatalf("parse dsn failed: %v", err)
}
if strings.Trim(parsed.EscapedPath(), "/") != "" {
t.Fatalf("SID 模式不应在 URL path 携带服务名path=%q dsn=%s", parsed.EscapedPath(), dsn)
}
if got := parsed.Query().Get("SID"); got != "ORCL" {
t.Fatalf("SID 参数 = %q, want ORCLdsn=%s", got, dsn)
}
if parsed.Query().Get("PREFETCH_ROWS") == "" {
t.Fatalf("SID 模式应保留默认驱动参数dsn=%s", dsn)
}
}
func TestOracleDSN_SIDModeOverridesLegacyDatabaseField(t *testing.T) {
o := &OracleDB{}
// 兼容历史数据Navicat 导入的旧 SID 连接同时存在 Database 与 SID 参数,
// SID 优先且 path 不携带服务名,避免冗余与日志误读。
cfg := connection.ConnectionConfig{
Type: "oracle",
Host: "db.example.com",
Port: 1521,
User: "system",
Password: "secret",
Database: "ORCL",
ConnectionParams: "SID=ORCL",
}
dsn := o.getDSN(cfg)
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatalf("parse dsn failed: %v", err)
}
if strings.Trim(parsed.EscapedPath(), "/") != "" {
t.Fatalf("SID 模式不应在 URL path 携带 Databasepath=%q dsn=%s", parsed.EscapedPath(), dsn)
}
if got := parsed.Query().Get("SID"); got != "ORCL" {
t.Fatalf("SID 参数 = %q, want ORCLdsn=%s", got, dsn)
}
}
func TestOracleSIDParamParsingIsCaseInsensitive(t *testing.T) {
for _, raw := range []string{"sid=ORCL", "Sid=ORCL", "SID=ORCL", "SID =ORCL"} {
cfg := connection.ConnectionConfig{
Type: "oracle",
ConnectionParams: raw,
}
if got := oracleConnectionSID(cfg); got != "ORCL" {
t.Fatalf("oracleConnectionSID(%q) = %q, want ORCL", raw, got)
}
}
}
func TestDamengDSN_KeepsRawPasswordForDriverParser(t *testing.T) {
d := &DamengDB{}
cfg := connection.ConnectionConfig{

View File

@@ -111,13 +111,27 @@ func TestOracleDSNLogSummaryDoesNotExposePassword(t *testing.T) {
if strings.Contains(got, "top-secret") || strings.Contains(got, "sys@tenant") {
t.Fatalf("summary should not expose credentials, got %q", got)
}
for _, want := range []string{"服务名=ORCLPDB1", "DBA_PRIVILEGE=SYSDBA", "AUTH_TYPE=NORMAL"} {
for _, want := range []string{"连接模式=服务名", "服务名=ORCLPDB1", "DBA_PRIVILEGE=SYSDBA", "AUTH_TYPE=NORMAL"} {
if !strings.Contains(got, want) {
t.Fatalf("expected summary to contain %q, got %q", want, got)
}
}
}
func TestOracleDSNLogSummaryUsesEffectiveSIDOverLegacyDatabase(t *testing.T) {
dsn := "oracle://sys:top-secret@127.0.0.1:1521?SID=ORCL&DBA+PRIVILEGE=SYSDBA"
got := oracleDSNLogSummary(connection.ConnectionConfig{Database: "OLD_SERVICE"}, dsn)
for _, want := range []string{"连接模式=SID", "SID=ORCL", "DBA_PRIVILEGE=SYSDBA"} {
if !strings.Contains(got, want) {
t.Fatalf("expected summary to contain %q, got %q", want, got)
}
}
if strings.Contains(got, "OLD_SERVICE") || strings.Contains(got, "top-secret") {
t.Fatalf("summary should use the effective SID without exposing stale or secret values, got %q", got)
}
}
func TestAnnotateOracleValidationErrorAddsClosedConnectionHint(t *testing.T) {
t.Parallel()

View File

@@ -41,17 +41,36 @@ func oracleRuntimeError(key string, params map[string]any) error {
return fmt.Errorf("%s", localizedDriverRuntimeText(key, params))
}
// oracleConnectionSID 解析连接配置ConnectionParams / URI中的 SID 参数。
// SID 与 Service Name 是 Oracle 两种互斥的连接定位方式go-ora 驱动在
// CONNECT_DATA 中优先使用 SIDconfigurations/connect_config.go因此
// SID 模式只需把 SID 值放入 DSN 查询参数Database服务名可留空。
func oracleConnectionSID(config connection.ConnectionConfig) string {
values := url.Values{}
mergeConnectionParamsFromConfigWithAllowlist(values, config, oracleConnectionParamNames, "oracle")
return oracleQueryValue(values, "SID")
}
// isOracleSIDMode 报告连接是否以 SID 模式连接(存在 SID 参数时优先于服务名)。
func isOracleSIDMode(config connection.ConnectionConfig) bool {
return oracleConnectionSID(config) != ""
}
func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
// oracle://user:pass@host:port/service_name
// 服务名模式:oracle://user:pass@host:port/service_name
// SID 模式oracle://user:pass@host:port/?SID=sidgo-ora 驱动据此组装 (SID=...)
database := strings.TrimSpace(config.Database)
sid := oracleConnectionSID(config)
u := &url.URL{
Scheme: "oracle",
Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
Path: "/" + database,
}
if sid == "" {
u.Path = "/" + database
u.RawPath = "/" + url.PathEscape(database)
}
u.User = url.UserPassword(config.User, config.Password)
u.RawPath = "/" + url.PathEscape(database)
q := url.Values{}
switch normalizedSSLMode(config) {
case sslModeRequired:
@@ -97,18 +116,28 @@ func oracleDSNLogSummary(config connection.ConnectionConfig, dsn string) string
}
params = parsed.Query()
}
if serviceName == "" {
serviceName = "(未配置)"
sid := oracleQueryValue(params, "SID")
mode := "服务名"
targetLabel := "服务名"
targetValue := serviceName
if sid != "" {
mode = "SID"
targetLabel = "SID"
targetValue = sid
}
return fmt.Sprintf("服务名=%s CONNECT_TIMEOUT=%s READ_TIMEOUT=%s SSL=%s SSL_VERIFY=%s AUTH_TYPE=%s DBA_PRIVILEGE=%s SID=%s",
serviceName,
if targetValue == "" {
targetValue = "(未配置)"
}
return fmt.Sprintf("连接模式=%s %s=%s CONNECT_TIMEOUT=%s READ_TIMEOUT=%s SSL=%s SSL_VERIFY=%s AUTH_TYPE=%s DBA_PRIVILEGE=%s",
mode,
targetLabel,
targetValue,
oracleQueryValueOrDefault(params, "CONNECT TIMEOUT"),
oracleQueryValueOrDefault(params, "READ TIMEOUT"),
oracleQueryValueOrDefault(params, "SSL"),
oracleQueryValueOrDefault(params, "SSL VERIFY"),
oracleQueryValueOrDefault(params, "AUTH TYPE"),
oracleQueryValueOrDefault(params, "DBA PRIVILEGE"),
oracleQueryValueOrDefault(params, "SID"),
)
}
@@ -120,7 +149,7 @@ func annotateOracleValidationError(err error) error {
if !strings.Contains(message, "use of closed network connection") {
return err
}
return fmt.Errorf("%wOracle 连接在验证阶段被服务端关闭或被驱动超时中断;请检查监听端口是否为 Oracle 协议端口、Service Name 是否正确、认证参数如 DBA_PRIVILEGE/AUTH_TYPE 是否匹配)", err)
return fmt.Errorf("%wOracle 连接在验证阶段被服务端关闭或被驱动超时中断;请检查监听端口是否为 Oracle 协议端口、服务名(Service Name)或 SID 是否正确、认证参数如 DBA_PRIVILEGE/AUTH_TYPE 是否匹配)", err)
}
func (o *OracleDB) Connect(config connection.ConnectionConfig) (err error) {
@@ -133,8 +162,9 @@ func (o *OracleDB) Connect(config connection.ConnectionConfig) (err error) {
runConfig := config
serviceName := strings.TrimSpace(config.Database)
if serviceName == "" {
return fmt.Errorf("Oracle 连接缺少服务名Service Name请在连接配置中填写例如 ORCLPDB1")
sid := oracleConnectionSID(config)
if serviceName == "" && sid == "" {
return fmt.Errorf("Oracle 连接缺少服务名Service Name或 SID请在连接配置中填写例如 ORCLPDB1服务名或 ORCLSID")
}
if config.UseSSH {