feat(data-sync/oceanbase): 拆分比对入口并修复 OceanBase Oracle 连接

- 数据同步:新增表结构比对、数据比对两个独立工具入口
- 比对模式:为 DataSyncModal 增加只读入口展示与模式化文案
- OceanBase:Oracle 租户改用 OB Oracle 专用 MySQL-wire 连接路径
- 连接表单:允许 OceanBase Oracle Service Name 留空,仅 TNS 场景需要填写
- 驱动提示:revision 不匹配提示收敛到驱动管理,不再在普通数据源入口弹出
- 测试覆盖:补充数据比对入口、OceanBase Oracle、driver-agent 提示边界测试
This commit is contained in:
Syngnat
2026-06-16 12:15:16 +08:00
parent 938bc53966
commit f41a15c7b8
15 changed files with 707 additions and 471 deletions

View File

@@ -9,6 +9,7 @@ import ConnectionModal from './components/ConnectionModal';
import SnippetSettingsModal from './components/SnippetSettingsModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
import DataSyncModal from './components/DataSyncModal';
import { type DataSyncEntryMode } from './components/dataSyncEntryMode';
import DriverManagerModal from './components/DriverManagerModal';
import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';
import LogPanel from './components/LogPanel';
@@ -208,6 +209,7 @@ function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
const [syncModalEntryMode, setSyncModalEntryMode] = useState<DataSyncEntryMode>('sync');
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
const connectionModalWarmupDoneRef = useRef(false);
@@ -3759,13 +3761,36 @@ function App() {
void handleExportConnections();
},
},
{
key: 'schema-compare',
icon: <AppstoreOutlined />,
title: '表结构比对',
description: '对比源表与目标表结构差异,只预览不执行。',
onClick: () => {
setIsToolsModalOpen(false);
setSyncModalEntryMode('schemaCompare');
setIsSyncModalOpen(true);
},
},
{
key: 'data-compare',
icon: <SwitcherOutlined />,
title: '数据比对',
description: '按主键分析新增、更新、删除和相同行。',
onClick: () => {
setIsToolsModalOpen(false);
setSyncModalEntryMode('dataCompare');
setIsSyncModalOpen(true);
},
},
{
key: 'sync',
icon: <UploadOutlined rotate={90} />,
title: '数据同步',
description: '进入跨源同步工作流。',
description: '进入可执行写入的跨源同步工作流。',
onClick: () => {
setIsToolsModalOpen(false);
setSyncModalEntryMode('sync');
setIsSyncModalOpen(true);
},
},
@@ -3976,6 +4001,7 @@ function App() {
<DataSyncModal
open={isSyncModalOpen}
onClose={() => setIsSyncModalOpen(false)}
entryMode={syncModalEntryMode}
/>
)}
{isDriverModalOpen && (

View File

@@ -163,6 +163,16 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"');
expect(source).toContain('? "goldendb"');
});
it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => {
expect(source).toContain('OceanBase Oracle 服务名 (Service Name可选)');
expect(source).toContain('isOceanBaseOracle\n ? []');
expect(source).toContain('连接 OBClient/OBServer MySQL-wire 入口时可留空');
expect(source).toContain('只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME');
expect(source).toContain('createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1")');
expect(source).not.toContain('请输入 OceanBase Oracle 服务名');
expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME');
});
});
describe('ConnectionModal Redis Sentinel configuration', () => {

View File

@@ -2075,7 +2075,7 @@ const ConnectionModal: React.FC<{
const scheme =
dbType === "diros" ? "doris" : dbType === "starrocks" ? "starrocks" : dbType === "oceanbase" ? "oceanbase" : dbType === "goldendb" ? "goldendb" : "mysql";
if (dbType === "oceanbase") {
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`;
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}?protocol=oracle`;
}
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
}
@@ -4227,14 +4227,6 @@ const ConnectionModal: React.FC<{
? currentDriverSnapshot.message ||
`${currentDriverSnapshot.name || dbType} 驱动未安装启用`
: "";
const currentDriverUpdateReason =
hasCurrentDriverType &&
currentDriverSnapshot?.connectable &&
currentDriverSnapshot.needsUpdate
? currentDriverSnapshot.message ||
currentDriverSnapshot.updateReason ||
`${currentDriverSnapshot.name || dbType} 驱动代理需要重装后才能应用当前版本的驱动侧更新`
: "";
const driverStatusChecking =
hasCurrentDriverType && !driverStatusLoaded && step === 2;
@@ -5246,9 +5238,9 @@ const ConnectionModal: React.FC<{
label="OceanBase 协议"
help={
<span>
MySQL MySQLOracle OracleGoNavi OB MySQL wire OBClient capability Navicat OBProxy Oracle listener TNS
MySQL MySQLOracle OracleOracle 使 OBClient/OBServer MySQL-wire OBProxy Oracle listener/TNS Service Name
<br />
Oracle Error 1235 OBClient <code>connectionAttributes=key1:value1,key2:value2</code> GoNavi OBClient capability
MySQL Oracle Error 1235 Oracle connectionAttributes
</span>
}
style={{ marginBottom: 0 }}
@@ -5356,24 +5348,22 @@ const ConnectionModal: React.FC<{
children: (
<Form.Item
name="database"
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name)" : "服务名 (Service Name)"}
rules={[
createUriAwareRequiredRule(
isOceanBaseOracle
? "请输入 OceanBase Oracle 服务名"
: "请输入 Oracle 服务名(例如 ORCLPDB1",
),
]}
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name,可选)" : "服务名 (Service Name)"}
rules={
isOceanBaseOracle
? []
: [createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1")]
}
help={
isOceanBaseOracle
? "Oracle 租户必须填写监听器注册的 SERVICE_NAME用户名仍按 OceanBase 租户格式填写。"
? "连接 OBClient/OBServer MySQL-wire 入口时可留空;只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME。"
: "请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
}
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如ORCLPDB1"
placeholder={isOceanBaseOracle ? "TNS 入口例如 ORCLPDB1OBClient/MySQL-wire 可留空" : "例如ORCLPDB1"}
/>
</Form.Item>
),
@@ -6778,26 +6768,6 @@ const ConnectionModal: React.FC<{
}
/>
)}
{currentDriverUpdateReason && (
<Alert
showIcon
type="warning"
style={{ marginBottom: 12 }}
message="当前数据源驱动代理建议重装"
description={
<Space size={8}>
<span>{currentDriverUpdateReason}</span>
<Button
type="link"
size="small"
onClick={() => onOpenDriverManager?.()}
>
</Button>
</Space>
}
/>
)}
{(() => {
const sectionItems: Array<{
key: "basic" | "network" | "appearance";

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { resolveDataSyncEntryModePresentation } from './dataSyncEntryMode';
describe('resolveDataSyncEntryModePresentation', () => {
it('marks schema compare as a read-only independent entry', () => {
const presentation = resolveDataSyncEntryModePresentation('schemaCompare');
expect(presentation.title).toBe('表结构比对');
expect(presentation.analyzeButtonText).toBe('开始比对');
expect(presentation.badgeText).toBe('结构比对');
expect(presentation.readOnly).toBe(true);
});
it('marks data compare as a read-only independent entry', () => {
const presentation = resolveDataSyncEntryModePresentation('dataCompare');
expect(presentation.title).toBe('数据比对');
expect(presentation.tableSelectLabel).toContain('比对数据');
expect(presentation.badgeText).toBe('数据比对');
expect(presentation.readOnly).toBe(true);
});
it('keeps the original sync entry writable', () => {
const presentation = resolveDataSyncEntryModePresentation('sync');
expect(presentation.title).toBe('数据同步工作台');
expect(presentation.analyzeButtonText).toBe('对比差异');
expect(presentation.badgeText).toBe('同步模式');
expect(presentation.readOnly).toBe(false);
});
});

View File

@@ -10,6 +10,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
import { resolveDataSyncEntryModePresentation, type DataSyncEntryMode } from './dataSyncEntryMode';
const { Title, Text } = Typography;
const { Step } = Steps;
const { Option } = Select;
@@ -188,7 +189,11 @@ const buildSqlPreview = (
};
};
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: DataSyncEntryMode }> = ({ open, onClose, entryMode = 'sync' }) => {
const entryPresentation = resolveDataSyncEntryModePresentation(entryMode);
const isSchemaCompareEntry = entryMode === 'schemaCompare';
const isDataCompareEntry = entryMode === 'dataCompare';
const isCompareEntry = entryPresentation.readOnly;
const connections = useStore((state) => state.connections);
const themeMode = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
@@ -217,7 +222,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
// Options
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data');
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>(isSchemaCompareEntry ? 'schema' : 'data');
const [syncMode, setSyncMode] = useState<string>('insert_update');
const [autoAddColumns, setAutoAddColumns] = useState<boolean>(true);
const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only');
@@ -276,8 +281,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
});
return () => {
offLog();
offProgress();
if (typeof offLog === 'function') offLog();
if (typeof offProgress === 'function') offProgress();
};
}, [open]);
@@ -299,7 +304,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setSourceDatasetMode('table');
setSourceQuery('');
setWorkflowType('sync');
setSyncContent('data');
setSyncContent(isSchemaCompareEntry ? 'schema' : 'data');
setSyncMode('insert_update');
setAutoAddColumns(true);
setTargetTableStrategy('existing_only');
@@ -319,9 +324,48 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
jobIdRef.current = '';
autoScrollRef.current = true;
}
}, [open]);
}, [open, isSchemaCompareEntry]);
useEffect(() => {
if (isSchemaCompareEntry) {
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (sourceDatasetMode !== 'table') {
setSourceDatasetMode('table');
}
if (syncContent !== 'schema') {
setSyncContent('schema');
}
if (syncMode !== 'insert_update') {
setSyncMode('insert_update');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
return;
}
if (isDataCompareEntry) {
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (syncContent !== 'data') {
setSyncContent('data');
}
if (syncMode !== 'insert_update') {
setSyncMode('insert_update');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
return;
}
if (workflowType === 'migration') {
if (syncMode === 'insert_update') {
setSyncMode('insert_only');
@@ -343,7 +387,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setCreateIndexes(false);
}
}
}, [workflowType]);
}, [isSchemaCompareEntry, isDataCompareEntry, workflowType, sourceDatasetMode, syncContent, syncMode, targetTableStrategy, createIndexes]);
useEffect(() => {
if (sourceDatasetMode !== 'query') return;
@@ -371,38 +415,38 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setSourceConnId(connId);
setSourceDb('');
const conn = connections.find(c => c.id === connId);
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setSourceDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e) { message.error("Failed to fetch source databases"); }
setLoading(false);
}
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setSourceDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e) { message.error("Failed to fetch source databases"); }
setLoading(false);
}
};
const handleTargetConnChange = async (connId: string) => {
setTargetConnId(connId);
setTargetDb('');
const conn = connections.find(c => c.id === connId);
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setTargetDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e) { message.error("Failed to fetch target databases"); }
setLoading(false);
}
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setTargetDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e) { message.error("Failed to fetch target databases"); }
setLoading(false);
}
};
const nextToTables = async () => {
@@ -416,15 +460,15 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const dbName = isSourceQueryMode ? targetDb : sourceDb;
const conn = connections.find(c => c.id === connId);
if (conn) {
const config = normalizeConnConfig(conn, dbName);
const res = await DBGetTables(config as any, dbName);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tableRows = Array.isArray(res.data) ? res.data : [];
const tables = tableRows
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setAllTables(tables as string[]);
const config = normalizeConnConfig(conn, dbName);
const res = await DBGetTables(config as any, dbName);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tableRows = Array.isArray(res.data) ? res.data : [];
const tables = tableRows
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setAllTables(tables as string[]);
setSelectedTables(prev => {
const existing = prev.filter((name) => tables.includes(name));
if (isSourceQueryMode) {
@@ -432,8 +476,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
}
return existing;
});
setCurrentStep(1);
} else {
setCurrentStep(1);
} else {
message.error(res.message);
}
}
@@ -681,7 +725,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
}, [diffTables]);
const isSourceQueryMode = sourceDatasetMode === 'query';
const isMigrationWorkflow = workflowType === 'migration';
const isMigrationWorkflow = !isCompareEntry && workflowType === 'migration';
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
const sourceType = String(sourceConn?.config?.type || '').toLowerCase();
@@ -797,7 +841,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
return (
<>
<Modal
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : '数据同步工作台', isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : '按已有目标表完成差异对比、同步执行与结果确认。')}
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : entryPresentation.title, isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : entryPresentation.description)}
open={open}
onCancel={() => {
if (syncing) {
@@ -830,15 +874,15 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<div style={heroPanelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : '数据同步'}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : entryPresentation.heroTitle}</div>
<div style={{ marginTop: 6, fontSize: 13, lineHeight: 1.7, color: darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.62)' }}>
{isMigrationWorkflow
? '适合把源表迁移到另一套数据库,可按策略自动建表、导入数据并补建可兼容索引。'
: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。'}
: entryPresentation.heroDescription}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : '同步模式'}</span>
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : entryPresentation.badgeText}</span>
<span style={badgeStyle}><DatabaseOutlined /> {sourceConnId ? '已选源连接' : '待选源连接'}</span>
<span style={badgeStyle}><TableOutlined /> {selectedTables.length || 0} </span>
</div>
@@ -847,7 +891,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Steps current={currentStep} style={{ marginBottom: 24 }}>
<Step title="配置源与目标" />
<Step title="选择表" />
<Step title="执行结果" />
<Step title={entryPresentation.resultTitle} />
</Steps>
</div>
@@ -900,35 +944,45 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</div>
<Card
title={isMigrationWorkflow ? '迁移选项' : '同步选项'}
title={isMigrationWorkflow ? '迁移选项' : entryPresentation.optionTitle}
style={{ ...shellCardStyle, marginTop: 18 }}
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
>
<div style={{ ...quietPanelStyle, marginBottom: 14 }}>
<Text style={{ color: darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)', lineHeight: 1.7 }}>
{isCompareEntry
? '当前入口只做差异分析和预览,不会执行同步、建表、补字段或删除数据。'
: '先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。'}
</Text>
</div>
<Form layout="vertical">
<Form.Item label="功能类型">
<Select value={workflowType} onChange={setWorkflowType}>
<Option value="sync"></Option>
<Option value="migration" disabled={isSourceQueryMode}></Option>
</Select>
</Form.Item>
<Form.Item label="源数据方式">
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
<Option value="table"></Option>
<Option value="query"> SQL </Option>
</Select>
</Form.Item>
{!isCompareEntry && (
<Form.Item label="功能类型">
<Select value={workflowType} onChange={setWorkflowType}>
<Option value="sync"></Option>
<Option value="migration" disabled={isSourceQueryMode}></Option>
</Select>
</Form.Item>
)}
{!isSchemaCompareEntry && (
<Form.Item label="源数据方式">
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
<Option value="table">{isCompareEntry ? '按表比对' : '按表同步'}</Option>
<Option value="query">{isCompareEntry ? '按 SQL 结果集比对' : '按 SQL 结果集同步'}</Option>
</Select>
</Form.Item>
)}
<Alert
type={isMigrationWorkflow ? 'info' : 'success'}
type={isMigrationWorkflow || isCompareEntry ? 'info' : 'success'}
showIcon
style={{ marginBottom: 12 }}
message={isMigrationWorkflow
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
: isSchemaCompareEntry
? '当前为“表结构比对”入口:固定只分析结构差异和生成可审阅 SQL不执行变更。'
: isDataCompareEntry
? '当前为“数据比对”入口:固定按主键分析行级差异,不执行写入。'
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
/>
{isSourceQueryMode && (
<Alert
@@ -938,27 +992,33 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
/>
)}
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
<Select value={syncContent} onChange={setSyncContent}>
<Option value="data"></Option>
<Option value="schema" disabled={isSourceQueryMode}></Option>
<Option value="both" disabled={isSourceQueryMode}> + </Option>
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
<Option value="insert_update">//</Option>
<Option value="insert_only"></Option>
<Option value="full_overwrite"></Option>
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
<Option value="existing_only">使</Option>
<Option value="auto_create_if_missing"></Option>
<Option value="smart"></Option>
</Select>
</Form.Item>
{!isCompareEntry && (
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
<Select value={syncContent} onChange={setSyncContent}>
<Option value="data"></Option>
<Option value="schema" disabled={isSourceQueryMode}></Option>
<Option value="both" disabled={isSourceQueryMode}> + </Option>
</Select>
</Form.Item>
)}
{!isCompareEntry && (
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
<Option value="insert_update">//</Option>
<Option value="insert_only"></Option>
<Option value="full_overwrite"></Option>
</Select>
</Form.Item>
)}
{!isCompareEntry && (
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
<Option value="existing_only">使</Option>
<Option value="auto_create_if_missing"></Option>
<Option value="smart"></Option>
</Select>
</Form.Item>
)}
{isRedisMongoKeyspaceMigration && (
<Form.Item
label="Mongo 集合名(可选)"
@@ -975,16 +1035,22 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
/>
</Form.Item>
)}
<Form.Item>
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
/SQL
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
/
</Checkbox>
</Form.Item>
{(!isCompareEntry || isSchemaCompareEntry) && (
<Form.Item>
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
{isSchemaCompareEntry
? '生成目标表缺失字段的兼容变更 SQL仅预览不执行'
: '自动补齐目标表缺失字段(按源/目标数据源选择可兼容规划器SQL 结果集模式暂不支持)'}
</Checkbox>
</Form.Item>
)}
{!isCompareEntry && (
<Form.Item>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
/
</Checkbox>
</Form.Item>
)}
{isMigrationWorkflow && targetTableStrategy !== 'existing_only' && (
<Alert
type="info"
@@ -993,7 +1059,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
style={{ marginBottom: 12 }}
/>
)}
{!isMigrationWorkflow && (
{!isCompareEntry && !isMigrationWorkflow && (
<Alert
type="info"
showIcon
@@ -1020,7 +1086,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{!isSourceQueryMode && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Text type="secondary">{entryPresentation.tableSelectLabel}</Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
@@ -1204,48 +1270,50 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
<Alert
message={syncing ? "正在同步" : (syncResult?.success ? "同步完成" : "同步失败")}
description={
syncing
? `当前阶段:${syncProgress.stage || '执行中'}${syncProgress.table ? `,表:${syncProgress.table}` : ''}`
: (syncResult?.message || `成功同步 ${syncResult?.tablesSynced || 0} 张表. 插入: ${syncResult?.rowsInserted || 0}, 更新: ${syncResult?.rowsUpdated || 0}`)
}
type={syncing ? "info" : (syncResult?.success ? "success" : "error")}
showIcon
/>
<div style={{ marginTop: 14 }}>
<Progress
percent={syncProgress.percent}
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
format={() => `${syncProgress.current}/${syncProgress.total}`}
<Alert
message={syncing ? (isCompareEntry ? '正在比对' : '正在同步') : (syncResult?.success ? (isCompareEntry ? '比对完成' : '同步完成') : (isCompareEntry ? '比对失败' : '同步失败'))}
description={
syncing
? `当前阶段:${syncProgress.stage || '执行中'}${syncProgress.table ? `,表:${syncProgress.table}` : ''}`
: (syncResult?.message || (isCompareEntry
? `成功比对 ${diffTables.length || syncResult?.tablesSynced || 0} 张表。`
: `成功同步 ${syncResult?.tablesSynced || 0} 张表. 插入: ${syncResult?.rowsInserted || 0}, 更新: ${syncResult?.rowsUpdated || 0}`))
}
type={syncing ? "info" : (syncResult?.success ? "success" : "error")}
showIcon
/>
</div>
<div style={{ marginTop: 14 }}>
<Progress
percent={syncProgress.percent}
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
format={() => `${syncProgress.current}/${syncProgress.total}`}
/>
</div>
</div>
<div style={quietPanelStyle}>
<Divider orientation="left" style={{ marginTop: 0 }}></Divider>
<div
ref={logBoxRef}
onScroll={() => {
const el = logBoxRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = nearBottom;
}}
style={{
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
borderRadius: 14,
padding: 12,
height: 300,
overflowY: 'auto',
fontFamily: 'var(--gn-font-mono)'
}}
>
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
</div>
<Divider orientation="left" style={{ marginTop: 0 }}>{isCompareEntry ? '分析日志' : '执行日志'}</Divider>
<div
ref={logBoxRef}
onScroll={() => {
const el = logBoxRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = nearBottom;
}}
style={{
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
borderRadius: 14,
padding: 12,
height: 300,
overflowY: 'auto',
fontFamily: 'var(--gn-font-mono)'
}}
>
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
</div>
</div>
</div>
)}
@@ -1256,25 +1324,32 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 0 && (
<Button type="primary" onClick={nextToTables} loading={loading}></Button>
)}
{currentStep === 1 && (
<>
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}></Button>
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
</Button>
<Button
type="primary"
onClick={runSync}
loading={loading}
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
>
</Button>
{currentStep === 1 && (
<>
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}></Button>
<Button onClick={analyzeDiff} loading={loading} disabled={selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
{entryPresentation.analyzeButtonText}
</Button>
{isCompareEntry && (
<Button onClick={onClose}>
{entryPresentation.closeButtonText}
</Button>
)}
{!isCompareEntry && (
<Button
type="primary"
onClick={runSync}
loading={loading}
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
>
</Button>
)}
</>
)}
{currentStep === 2 && (
<>
<Button disabled={syncing} onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}></Button>
<Button disabled={syncing} onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}>{isCompareEntry ? '返回比对' : '继续同步'}</Button>
<Button type="primary" disabled={syncing} onClick={onClose}></Button>
</>
)}
@@ -1351,7 +1426,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
label: `插入(${previewData.totalInserts || 0})`,
children: (
<div>
<Text type="secondary"></Text>
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部插入差异”;如不想执行插入请在对比结果中取消勾选“插入”。'}</Text>
<Table
size="small"
style={{ marginTop: 8 }}
@@ -1376,7 +1451,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
label: `更新(${previewData.totalUpdates || 0})`,
children: (
<div>
<Text type="secondary"></Text>
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部更新差异”;如不想执行更新请在对比结果中取消勾选“更新”。'}</Text>
<Table
size="small"
style={{ marginTop: 8 }}
@@ -1427,7 +1502,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
children: (
<div>
<Alert type="warning" showIcon message="删除默认不勾选。请确认业务允许后再开启删除操作。" />
<Text type="secondary"></Text>
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部删除差异”;如不想执行删除请在对比结果中取消勾选“删除”。'}</Text>
<Table
size="small"
style={{ marginTop: 8 }}
@@ -1457,8 +1532,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
showIcon
message={
previewHasDataDiff
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
? (isCompareEntry ? 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,仅用于审核差异。' : 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。')
: (isCompareEntry ? 'SQL 预览展示结构差异建议语句,仅用于审核差异。' : 'SQL 预览展示将执行的结构变更语句,用于审核确认。')
}
/>
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>

View File

@@ -1,9 +1,14 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DriverManagerModal from './DriverManagerModal';
const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
const driverManagerModalSource = readFileSync(new URL('./DriverManagerModal.tsx', import.meta.url), 'utf8');
const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const storeState = vi.hoisted(() => ({
theme: 'light',
appearance: {
@@ -57,6 +62,20 @@ vi.mock('@ant-design/icons', () => {
};
});
describe('driver-agent update prompt placement', () => {
it('keeps revision mismatch prompts inside driver manager only', () => {
expect(driverManagerModalSource).toContain('需要重装');
expect(driverManagerModalSource).toContain('row.needsUpdate');
expect(connectionModalSource).not.toContain('当前数据源驱动代理建议重装');
expect(connectionModalSource).not.toContain('去驱动管理重装');
expect(sidebarSource).not.toContain('warnIfConnectionDriverAgentNeedsUpdate');
expect(sidebarSource).not.toContain('driver-agent-update-');
expect(sidebarSource).not.toContain('驱动代理需要重装:');
});
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>

View File

@@ -55,7 +55,7 @@ import {
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -91,7 +91,6 @@ import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
import type { DriverStatusSnapshot } from '../utils/connectionDriverType';
import JVMModeBadge from './jvm/JVMModeBadge';
import MessagePublishModal from './MessagePublishModal';
import {
@@ -104,7 +103,6 @@ import {
isExternalSQLDirectoryModalMode,
isPostgresSchemaDialect,
isV2SidebarObjectNode,
normalizeDriverType,
normalizeMySQLViewDDLForEditing,
resolveSavedConnectionDriverType,
resolveSidebarContextMenuPosition,
@@ -247,8 +245,6 @@ interface BatchObjectItem {
dataRef: any;
}
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
smart: <ThunderboltOutlined />,
object: <TableOutlined />,
@@ -525,8 +521,6 @@ const Sidebar: React.FC<{
selectedNodes: [],
activeContext: null,
});
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState | null>(null);
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
@@ -1825,67 +1819,8 @@ const Sidebar: React.FC<{
return { schemas, supported: hasSuccessfulQuery };
};
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const cached = driverStatusCacheRef.current;
if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) {
return cached.items;
}
const result: Record<string, DriverStatusSnapshot> = {};
const res = await GetDriverStatusList('', '');
if (!res?.success) {
return result;
}
const data = (res.data || {}) as any;
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
drivers.forEach((item: any) => {
const type = normalizeDriverType(String(item.type || '').trim());
if (!type) return;
result[type] = {
type,
name: String(item.name || item.type || type).trim(),
connectable: !!item.connectable,
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || '').trim() || undefined,
message: String(item.message || '').trim() || undefined,
};
});
driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result };
return result;
};
const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => {
try {
const driverType = resolveSavedConnectionDriverType(conn);
if (!driverType || driverType === 'custom') {
return;
}
const statusMap = await fetchDriverStatusMap();
const status = statusMap[driverType];
if (!status?.connectable || !status.needsUpdate) {
return;
}
const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown';
const warningKey = `${conn.id}:${driverType}:${revisionKey}`;
if (driverUpdateWarningKeysRef.current.has(warningKey)) {
return;
}
driverUpdateWarningKeysRef.current.add(warningKey);
const driverName = status.name || driverType;
const reason = status.message || status.updateReason || `${driverName} driver-agent 与当前 GoNavi 版本要求不一致`;
message.warning({
content: `${driverName} 驱动代理需要重装:${reason}`,
key: `driver-agent-update-${conn.id}`,
duration: 10,
});
} catch (error) {
console.warn('检查驱动代理更新状态失败', error);
}
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
void warnIfConnectionDriverAgentNeedsUpdate(conn);
const loadKey = `dbs-${conn.id}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
@@ -3161,7 +3096,6 @@ const Sidebar: React.FC<{
};
const loadDatabasesForBatch = async (conn: SavedConnection) => {
void warnIfConnectionDriverAgentNeedsUpdate(conn);
const config = {
...conn.config,
port: Number(conn.config.port),
@@ -3483,7 +3417,6 @@ const Sidebar: React.FC<{
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
setBatchConnContext(conn);
void warnIfConnectionDriverAgentNeedsUpdate(conn);
const config = {
...conn.config,

View File

@@ -0,0 +1,62 @@
export type DataSyncEntryMode = 'sync' | 'schemaCompare' | 'dataCompare';
export type DataSyncEntryModePresentation = {
title: string;
description: string;
heroTitle: string;
heroDescription: string;
optionTitle: string;
tableSelectLabel: string;
analyzeButtonText: string;
closeButtonText: string;
badgeText: string;
resultTitle: string;
readOnly: boolean;
};
export const resolveDataSyncEntryModePresentation = (entryMode: DataSyncEntryMode): DataSyncEntryModePresentation => {
switch (entryMode) {
case 'schemaCompare':
return {
title: '表结构比对',
description: '按源表与目标表生成结构差异、兼容风险和可审阅 SQL。',
heroTitle: '表结构比对',
heroDescription: '适合发布前核对两端表结构差异,只做分析与预览,不执行结构变更。',
optionTitle: '比对选项',
tableSelectLabel: '请选择需要比对结构的表:',
analyzeButtonText: '开始比对',
closeButtonText: '关闭',
badgeText: '结构比对',
resultTitle: '比对结果',
readOnly: true,
};
case 'dataCompare':
return {
title: '数据比对',
description: '按主键对比源表与目标表的数据差异,查看新增、更新和删除明细。',
heroTitle: '数据比对',
heroDescription: '适合核对两端数据一致性,只做差异分析与行级预览,不执行写入。',
optionTitle: '比对选项',
tableSelectLabel: '请选择需要比对数据的表:',
analyzeButtonText: '开始比对',
closeButtonText: '关闭',
badgeText: '数据比对',
resultTitle: '比对结果',
readOnly: true,
};
default:
return {
title: '数据同步工作台',
description: '按已有目标表完成差异对比、同步执行与结果确认。',
heroTitle: '数据同步',
heroDescription: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。',
optionTitle: '同步选项',
tableSelectLabel: '请选择需要同步的表:',
analyzeButtonText: '对比差异',
closeButtonText: '关闭',
badgeText: '同步模式',
resultTitle: '执行结果',
readOnly: false,
};
}
};

1
go.mod
View File

@@ -15,6 +15,7 @@ require (
github.com/elastic/go-elasticsearch/v8 v8.19.6
github.com/go-sql-driver/mysql v1.9.3
github.com/google/uuid v1.6.0
github.com/helingjun/obconnector-go v0.4.2
github.com/highgo/pq-sm3 v0.0.0
github.com/lib/pq v1.11.1
github.com/microsoft/go-mssqldb v1.9.6

2
go.sum
View File

@@ -176,6 +176,8 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/helingjun/obconnector-go v0.4.2 h1:TmN1jjFYJu1ZZ+X+uWr7lGmKW7MsJjdfsEb8A/Ox3+8=
github.com/helingjun/obconnector-go v0.4.2/go.mod h1:zJL1M1xZvTm7OGKEDl04TPRDqi5MRXj7/hMb4CqTCDI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@@ -114,7 +114,7 @@ func TestVerifyInstalledOptionalDriverAgentRevisionRejectsProbeFailure(t *testin
}
}
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseAgent(t *testing.T) {
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseMySQLAgent(t *testing.T) {
originalProbe := optionalDriverAgentMetadataProbe
t.Cleanup(func() {
optionalDriverAgentMetadataProbe = originalProbe
@@ -132,6 +132,45 @@ func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseAgent(t *te
}
}
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseOracleAgent(t *testing.T) {
originalProbe := optionalDriverAgentMetadataProbe
t.Cleanup(func() {
optionalDriverAgentMetadataProbe = originalProbe
})
optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) {
return db.OptionalDriverAgentMetadata{
DriverType: driverType,
AgentRevision: "src-stale-agent",
}, nil
}
err := verifyRuntimeOptionalDriverAgentRevision(connection.ConnectionConfig{
Type: "oceanbase",
ConnectionParams: "protocol=oracle",
})
if err != nil {
t.Fatalf("runtime revision mismatch should stay in driver manager only, got %v", err)
}
}
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsUnknownOceanBaseOracleAgent(t *testing.T) {
originalProbe := optionalDriverAgentMetadataProbe
t.Cleanup(func() {
optionalDriverAgentMetadataProbe = originalProbe
})
optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) {
return db.OptionalDriverAgentMetadata{}, errOptionalDriverAgentMetadataUnavailable
}
err := verifyRuntimeOptionalDriverAgentRevision(connection.ConnectionConfig{
Type: "oceanbase",
OceanBaseProtocol: "oracle",
})
if err != nil {
t.Fatalf("runtime metadata probe failure should stay in driver manager only, got %v", err)
}
}
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsMetadataProbeFailure(t *testing.T) {
originalProbe := optionalDriverAgentMetadataProbe
t.Cleanup(func() {

View File

@@ -5,7 +5,7 @@ package db
func init() {
optionalDriverAgentRevisions = map[string]string{
"mariadb": "src-0a4176f4b5743323",
"oceanbase": "src-e996325fd6d52648",
"oceanbase": "src-7cb0f2c4dc0510a5",
"diros": "src-cc11b882e28fa5d4",
"starrocks": "src-83a6d81c91c7f5c8",
"sphinx": "src-a70c2cd4d223dac2",

View File

@@ -7,36 +7,25 @@
// 2. OBProxy Oracle listener —— 标准 Oracle TNS 网络协议
//
// Navicat 的"OceanBase"数据源经实测能在 OB MySQL wire 端口上直接连接 Oracle 租户,
// 证明 OB 服务端识别 OBClient 客户端的关键是 CLIENT_CONNECT_ATTRS 中的特定 attribute
// 组合,而不是 capability bit 0-31 的扩展(这些 bit 是 MySQL 协议标准定义的)
// go-sql-driver/mysql v1.9+ 通过 DSN 参数 connectionAttributes 透传 CLIENT_CONNECT_ATTRS
// 因此 **不需要 fork mysql driver** 即可复刻 Navicat 的连接路径。
// 但本机企业版验证表明:仅通过 go-sql-driver/mysql 注入 CLIENT_CONNECT_ATTRS 不足以
// 让 Oracle 租户放行,还需要 CLIENT_SUPPORT_ORACLE_MODE 等 OceanBase 私有 capability
// 因此 GoNavi 将 Oracle 租户的 MySQL-wire 路径隔离到 OB Oracle 专用 driver。
//
// GoNavi 当前路由(按 OceanBase 协议字段选择决定):
// - 协议=MySQL走 go-sql-driver/mysql连 MySQL 租户。OB 服务端在 Oracle 租户上返回
// "Error 1235 (0A000): Oracle tenant for current client driver is not supported"
// 时,错误信息提示用户切换到 Oracle 协议。
// - 协议=Oracle先做 mysql wire 端口预探测probeOceanBaseMySQLWireHandshake
// 识别为 OB MySQL wire 时,走 mysql wire + OBClient capability 注入路径
// ensureOceanBaseOBClientAttributes + ensureOceanBaseOracleANSIQuotes
// 识别为 OB MySQL wire 时,走 obconnector-go 的 OB Oracle 专用握手路径
// 元数据查询通过 OracleDB wrapper 复用 Oracle 方言 SQLApplyChanges 用
// applyOracleChangesMySQLWire"?" 占位符 + 双引号引用)。
// 端口非 OB MySQL wire 时,走 sijms/go-ora 连接 OBProxy 的 Oracle listener。
//
// OBClient capability attribute 候选清单(基于 OceanBase 公开 connector-j 资料 +
// 社区经验,**未在本仓库联调验证 Navicat 用的具体组合**
// - _client_name=OceanBase Connector/J ← OB connector-j 标准
// - _client_version=2.4.5
// - __ob_client_attribute_capability_flag=1
// - ob_capability_flag=1
//
// 默认注入完整候选清单mysql server 忽略未知 attribute 是安全行为)。用户/DBA 通过
// ConnectionParams 设置 connectionAttributes 时,会与默认注入合并(用户值优先)。
//
// 历史教训d2dad751 / 17331ddb / 5/14 两次反转都没在真实 OB Oracle 租户集群上联调,
// 多次方向摇摆。本次反转有 Navicat 真实工作证据用户报告Navicat 用 OceanBase 数据源
// 类型连同一端口 60014 成功)。后续若收到"OBClient 默认注入仍失败"反馈,需要 Wireshark
// 抓 Navicat 握手包对照 attribute 组合,不要再盲改方向。
// 类型连同一端口 60014 成功)以及本机企业版 Oracle 租户验证。go-sql-driver/mysql 即使
// 注入 connectionAttributes 也无法发出 OceanBase Oracle 租户需要的私有 capability
// 因此 Oracle/MySQL-wire 路径必须和普通 OceanBase MySQL 路径隔离。
package db
import (
@@ -56,10 +45,12 @@ import (
"GoNavi-Wails/internal/utils"
mysqlDriver "github.com/go-sql-driver/mysql"
_ "github.com/helingjun/obconnector-go"
)
const (
oceanbaseDriverName = "oceanbase"
oceanbaseDriverName = "gonavi_oceanbase_mysql"
oceanbaseOracleOBClientDriver = "oboracle"
defaultOceanBasePort = 2881
oceanBaseProtocolMySQL = "mysql"
oceanBaseProtocolOracle = "oracle"
@@ -290,39 +281,6 @@ func withoutOceanBaseProtocolParams(config connection.ConnectionConfig) connecti
return next
}
// ensureOceanBaseOracleANSIQuotes 在 ConnectionParams 中注入 sql_mode='ANSI_QUOTES'
// 让 OceanBase Oracle 租户通过 MySQL wire 连接时把双引号识别为标识符引用Oracle 语义),
// 否则元数据查询的列别名 `AS "OWNER"` 和 ApplyChanges 的 `"schema"."table"` 会被当字符串字面量。
// 用户已显式设置 sql_mode 时追加 ANSI_QUOTES保留其它 mode。
func ensureOceanBaseOracleANSIQuotes(raw string) string {
values := connectionParamsFromText(raw)
if values == nil {
values = url.Values{}
}
existing := strings.TrimSpace(values.Get("sql_mode"))
if existing == "" {
values.Set("sql_mode", "'ANSI_QUOTES'")
return values.Encode()
}
if strings.Contains(strings.ToUpper(existing), "ANSI_QUOTES") {
return values.Encode()
}
trimmed := strings.Trim(existing, "'")
values.Set("sql_mode", "'"+trimmed+",ANSI_QUOTES'")
return values.Encode()
}
// defaultOceanBaseOBClientAttributes 是 GoNavi 在 OceanBase Oracle 租户连接路径上默认注入的
// CLIENT_CONNECT_ATTRS 列表,用于声明 OBClient 客户端身份让 OB 服务端放行 Oracle 租户。
// 这些 key/value 基于公开 OceanBase Connector/J 资料整理,未经本仓库真实环境验证。
// 用户通过 ConnectionParams 中的 connectionAttributes 设置的 attribute 优先级更高。
var defaultOceanBaseOBClientAttributes = []struct{ Key, Value string }{
{Key: "_client_name", Value: "OceanBase Connector/J"},
{Key: "_client_version", Value: "2.4.5"},
{Key: "__ob_client_attribute_capability_flag", Value: "1"},
{Key: "ob_capability_flag", Value: "1"},
}
// parseMySQLConnectionAttributes 解析 "key1:value1,key2:value2" 格式的 attribute 串。
// 兼容 mysql DSN 中 connectionAttributes 参数的格式。
func parseMySQLConnectionAttributes(raw string) map[string]string {
@@ -350,51 +308,6 @@ func parseMySQLConnectionAttributes(raw string) map[string]string {
return result
}
// serializeMySQLConnectionAttributes 把 map 序列化回 mysql DSN 期望的 "key1:value1,key2:value2"。
// 输出按 key 字典序排序以保证可重现。
func serializeMySQLConnectionAttributes(attrs map[string]string) string {
if len(attrs) == 0 {
return ""
}
keys := make([]string, 0, len(attrs))
for k := range attrs {
keys = append(keys, k)
}
// 字典序排序:测试可重现 + 用户视角一致
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
keys[j-1], keys[j] = keys[j], keys[j-1]
}
}
var b strings.Builder
for i, k := range keys {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(k)
b.WriteByte(':')
b.WriteString(attrs[k])
}
return b.String()
}
// ensureOceanBaseOBClientAttributes 把 GoNavi 的默认 OBClient capability attribute 合并到
// ConnectionParams 的 connectionAttributes 中。用户已设置的 attribute 优先(不覆盖)。
func ensureOceanBaseOBClientAttributes(rawConnectionParams string) string {
values := connectionParamsFromText(rawConnectionParams)
if values == nil {
values = url.Values{}
}
existing := parseMySQLConnectionAttributes(values.Get("connectionAttributes"))
for _, attr := range defaultOceanBaseOBClientAttributes {
if _, ok := existing[attr.Key]; !ok {
existing[attr.Key] = attr.Value
}
}
values.Set("connectionAttributes", serializeMySQLConnectionAttributes(existing))
return values.Encode()
}
// promoteOceanBaseOracleURIParams 把 oceanbase:// URI 中的 Oracle 业务参数提升到 ConnectionParams
// 让 OracleDB.Connect 在不解析 oceanbase URI 的情况下仍能拿到 PREFETCH_ROWS 等参数。
func promoteOceanBaseOracleURIParams(config connection.ConnectionConfig) connection.ConnectionConfig {
@@ -415,6 +328,77 @@ func promoteOceanBaseOracleURIParams(config connection.ConnectionConfig) connect
return config
}
func mergeOceanBaseOracleOBClientParams(params url.Values, values url.Values) {
if len(values) == 0 {
return
}
for key, vals := range values {
name := strings.TrimSpace(key)
if name == "" {
continue
}
lowerName := strings.ToLower(name)
if lowerName == "connectionattributes" {
for _, value := range vals {
for attrKey, attrValue := range parseMySQLConnectionAttributes(value) {
params.Set("attr."+attrKey, attrValue)
}
}
continue
}
if strings.HasPrefix(lowerName, "attr.") {
for _, value := range vals {
params.Set(name, value)
}
continue
}
switch lowerName {
case "timeout", "connecttimeout", "connect timeout":
for _, value := range vals {
params.Set("timeout", normalizeMySQLDurationParam(value, time.Millisecond))
}
case "trace", "preset", "cap.add", "cap.drop", "collation", "ob20", "protocol.v2",
"ob20.magic", "ob20.disablechecksum", "compress", "usecompression", "use_compression",
"tls", "tls.ca", "tls_ca", "tls.cert", "tls_cert", "tls.key", "tls_key":
for _, value := range vals {
params.Set(name, value)
}
case "init":
for _, value := range vals {
params.Add("init", value)
}
}
}
}
func buildOceanBaseOracleOBClientDSN(config connection.ConnectionConfig) (string, error) {
if strings.TrimSpace(config.User) == "" {
return "", fmt.Errorf("OceanBase Oracle (OBClient 路径) 缺少用户名")
}
address := normalizeMySQLAddress(config.Host, config.Port)
dsnURL := url.URL{
Scheme: "oboracle",
Host: address,
User: url.UserPassword(config.User, config.Password),
}
if strings.TrimSpace(config.Database) != "" {
dsnURL.Path = "/" + strings.TrimSpace(config.Database)
}
params := url.Values{}
params.Set("preset", "oboracle")
if timeout := getConnectTimeout(config); timeout > 0 {
params.Set("timeout", timeout.String())
}
mergeOceanBaseOracleOBClientParams(params, connectionParamsFromURI(config.URI, "oceanbase", "mysql", "oboracle"))
mergeOceanBaseOracleOBClientParams(params, connectionParamsFromText(config.ConnectionParams))
if strings.TrimSpace(params.Get("preset")) == "" {
params.Set("preset", "oboracle")
}
dsnURL.RawQuery = params.Encode()
return dsnURL.String(), nil
}
func prepareOceanBaseOracleConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
runConfig := withoutOceanBaseProtocolParams(applyOceanBaseURI(config))
runConfig = promoteOceanBaseOracleURIParams(runConfig)
@@ -436,7 +420,7 @@ func isOceanBaseOracleTenantMySQLDriverError(err error) bool {
func formatOceanBaseMySQLAttemptError(address string, err error) string {
if isOceanBaseOracleTenantMySQLDriverError(err) {
return fmt.Sprintf("%s 验证失败:当前选择的是 OceanBase MySQL 协议,但服务端返回 Oracle 租户不支持 MySQL 客户端驱动OB Error 1235请在连接配置中将 OceanBase 协议切换为 Oracle,并填写 OBProxy 暴露的 Oracle 协议端口与服务名Service Name", address)
return fmt.Sprintf("%s 验证失败:当前选择的是 OceanBase MySQL 协议,但服务端返回 Oracle 租户不支持 MySQL 客户端驱动OB Error 1235请在连接配置中将 OceanBase 协议切换为 Oracle。若使用 OBClient/OBServer MySQL-wire 入口,主机和端口可保持不变且服务名可留空;只有连接 OBProxy Oracle listener/TNS 入口时才需要填写服务名Service Name", address)
}
return fmt.Sprintf("%s 验证失败:%v", address, err)
}
@@ -459,7 +443,7 @@ func annotateOceanBaseOracleConnectError(err error) error {
strings.Contains(lower, "unexpected packet"),
strings.Contains(lower, "got packets out of order"),
strings.Contains(lower, "use of closed network connection"):
return fmt.Errorf("%wOceanBase Oracle TNS 路径握手失败:当前端口可能是 OBServer 的 MySQL wire 协议端口而非 OBProxy 的 Oracle listener。GoNavi 已实现 OBClient capability 注入路径,路由层会优先尝试该路径;如这里仍报此错说明 OBClient 路径也未成功,详见随后的 OBClient 错误诊断)", err)
return fmt.Errorf("%wOceanBase Oracle TNS 路径握手失败:当前端口可能是 OBServer 的 MySQL wire 协议端口而非 OBProxy 的 Oracle listener。GoNavi 会优先尝试 OB Oracle 专用 MySQL-wire 路径;如这里仍报此错说明路径也未成功,详见随后的 OBClient 错误诊断)", err)
case strings.Contains(lower, "ora-"):
return fmt.Errorf("%wOceanBase Oracle 租户认证或服务名失败请确认服务名Service Name、用户名如 SYS@oracle_tenant#cluster_name与权限配置", err)
}
@@ -591,8 +575,8 @@ func (o *OceanBaseDB) connectOracleViaTNS(config connection.ConnectionConfig) er
return nil
}
// connectOracleViaOBClient 走 mysql wire + OBClient capability attribute 注入,连 OceanBase
// MySQL wire 端口上的 Oracle 租户(复刻 Navicat OceanBase 数据源的连接路径)
// connectOracleViaOBClient 走 OB Oracle 专用 MySQL-wire 握手,连 OceanBase
// MySQL wire 端口上的 Oracle 租户。
// 用于端口预探测识别为 OB MySQL wire 的情况。
func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfig) error {
addresses := collectOceanBaseAddresses(config)
@@ -610,17 +594,29 @@ func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfi
candidateConfig.Host = host
candidateConfig.Port = port
candidateConfig.User, candidateConfig.Password = resolveMySQLCredential(config, index)
// 注入 OBClient capability attribute让 OB 服务端识别为 OBClient 客户端而放行 Oracle 租户。
// 同时确保 sql_mode='ANSI_QUOTES',让后续 Oracle 元数据查询里的双引号被识别为标识符引用。
candidateConfig.ConnectionParams = ensureOceanBaseOBClientAttributes(candidateConfig.ConnectionParams)
candidateConfig.ConnectionParams = ensureOceanBaseOracleANSIQuotes(candidateConfig.ConnectionParams)
dsn, err := o.getDSN(candidateConfig)
if candidateConfig.UseSSH {
forwarder, err := ssh.GetOrCreateLocalForwarder(candidateConfig.SSH, host, port)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 创建 SSH 本地转发失败:%v", address, err))
continue
}
localHost, localPort, ok := parseHostPortWithDefault(forwarder.LocalAddr, defaultOceanBasePort)
if !ok {
errorDetails = append(errorDetails, fmt.Sprintf("%s 解析 SSH 本地转发地址失败:%s", address, forwarder.LocalAddr))
continue
}
candidateConfig.Host = localHost
candidateConfig.Port = localPort
candidateConfig.UseSSH = false
}
dsn, err := buildOceanBaseOracleOBClientDSN(candidateConfig)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 生成连接串失败:%v", address, err))
continue
}
db, err := sql.Open(oceanbaseDriverName, dsn)
db, err := sql.Open(oceanbaseOracleOBClientDriver, dsn)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败:%v", address, err))
continue
@@ -650,10 +646,8 @@ func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfi
func formatOceanBaseOBClientAttemptError(address string, err error) string {
if isOceanBaseOracleTenantMySQLDriverError(err) {
return fmt.Sprintf("%s 验证失败OceanBase 服务端仍返回 Error 1235 拒绝当前 client driver。"+
"GoNavi 已默认注入 OBClient capability attribute_client_name=OceanBase Connector/J 等)"+
"但该组合未能让服务端放行 Oracle 租户。请用 Wireshark 抓 Navicat 连接此 OB 集群的 mysql 握手包,"+
"对照 Client Login Request → Connection Attributes 部分确认服务端期望的 key/value"+
"然后在 GoNavi 连接配置的 ConnectionParams 里通过 connectionAttributes=key1:value1,key2:value2 覆盖。"+
"GoNavi 已使用 OB Oracle 专用握手路径;如仍失败,请确认该端口是 OceanBase Oracle 租户的 MySQL-wire 入口"+
"并在 ConnectionParams 中通过 preset/cap.add/cap.drop 或 connectionAttributes=key1:value1 覆盖驱动握手参数。"+
"详细错误:%v", address, err)
}
return fmt.Sprintf("%s 验证失败:%v", address, err)
@@ -700,13 +694,15 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
probeResult := probeOceanBaseMySQLWireHandshakeDetailWithTimeouts(runConfig, probeDialTimeout, probeReadTimeout)
switch {
case probeResult.probeSucceeded && probeResult.isOBMySQLWire:
// 明确识别为 OB MySQL wire 端口:直接走 OBClient capability 路径
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 是 OB MySQL wire 端口,走 OBClient capability 注入路径连接 Oracle 租户", runConfig.Host, runConfig.Port)
// 明确识别为 OB MySQL wire 端口:直接走 OB Oracle 专用 MySQL-wire 路径
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 是 OB MySQL wire 端口,走 OB Oracle 专用 MySQL-wire 路径连接 Oracle 租户", runConfig.Host, runConfig.Port)
return o.connectOracleViaOBClient(runConfig)
case probeResult.probeSucceeded:
// 探测成功但 server_version 不含 OceanBase 标识:可能是真正的 Oracle TNS 端口
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 不是 OB MySQL wire走标准 Oracle TNS 协议OBProxy Oracle listener", runConfig.Host, runConfig.Port)
return o.connectOracleViaTNS(runConfig)
// 已收到 MySQL handshake但 server_version 不一定包含 OceanBase 标识
// 部分 OceanBase Oracle 租户会返回通用 MySQL 版本串;此时仍应优先按
// OBClient/MySQL-wire 路径连接,失败后再尝试 TNS。
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 返回 MySQL handshake 但未识别 OceanBase 标识,优先尝试 OB Oracle 专用 MySQL-wire 路径", runConfig.Host, runConfig.Port)
return o.connectOracleViaOBClientThenTNS(runConfig)
case !probeResult.tcpReachable && probeResult.err != nil:
logger.Warnf("OceanBase 协议=Oracle 预探测建连失败:%s:%d跳过 OBClient/TNS 重复尝试:%v", runConfig.Host, runConfig.Port, probeResult.err)
return formatOceanBaseOracleNetworkProbeError(runConfig, probeResult.err)
@@ -714,17 +710,8 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
// 探测失败但 TCP 已建连:可能是异常截断的握手包,或某些 OB 版本不主动发完整 handshake。
// 不能盲选 TNS——用户填 60014/2881 这类端口大概率仍是 OB MySQL wire。
// 串行尝试两条真实路径:先 OBClient命中概率更高失败再 TNS合并错误信息。
logger.Warnf("OceanBase 协议=Oracle 预探测失败:%s:%d串行尝试 OBClient capability 与 TNS 两条路径", runConfig.Host, runConfig.Port)
obclientErr := o.connectOracleViaOBClient(runConfig)
if obclientErr == nil {
return nil
}
logger.Warnf("OceanBase Oracle OBClient 路径失败,继续尝试 TNS 路径:%v", obclientErr)
tnsErr := o.connectOracleViaTNS(runConfig)
if tnsErr == nil {
return nil
}
return fmt.Errorf("OceanBase Oracle 两条连接路径均失败OBClient 路径错误:%vTNS 路径错误:%w", obclientErr, tnsErr)
logger.Warnf("OceanBase 协议=Oracle 预探测失败:%s:%d串行尝试 OB Oracle 专用 MySQL-wire 与 TNS 两条路径", runConfig.Host, runConfig.Port)
return o.connectOracleViaOBClientThenTNS(runConfig)
}
}
@@ -777,6 +764,22 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ""))
}
func (o *OceanBaseDB) connectOracleViaOBClientThenTNS(config connection.ConnectionConfig) error {
obclientErr := o.connectOracleViaOBClient(config)
if obclientErr == nil {
return nil
}
if strings.TrimSpace(config.Database) == "" {
return fmt.Errorf("OceanBase Oracle OBClient/MySQL-wire 路径连接失败:%v当前未填写 Service Name已跳过 TNS 路径。若连接的是 OBClient/OBServer MySQL-wire 入口Service Name 可继续留空,请检查主机、端口、用户名、密码和 driver-agent 是否为当前版本Service Name 只用于 OBProxy Oracle listener/TNS 入口", obclientErr)
}
logger.Warnf("OceanBase Oracle OBClient 路径失败,继续尝试 TNS 路径:%v", obclientErr)
tnsErr := o.connectOracleViaTNS(config)
if tnsErr == nil {
return nil
}
return fmt.Errorf("OceanBase Oracle 两条连接路径均失败OBClient 路径错误:%vTNS 路径错误:%w", obclientErr, tnsErr)
}
func (o *OceanBaseDB) activeDatabase() Database {
if o.oracle != nil {
return o.oracle
@@ -906,7 +909,6 @@ func (o *OceanBaseDB) ApplyChanges(tableName string, changes connection.ChangeSe
// applyOracleChangesMySQLWire 在 OceanBase Oracle 租户的 mysql wire 连接上执行
// DELETE/UPDATE/INSERT使用 Oracle 风格双引号引用标识符 + mysql wire 风格 "?" 占位符。
// 需要事先确保 sql_mode='ANSI_QUOTES'(由 ensureOceanBaseOracleANSIQuotes 在 DSN 中注入)。
func (o *OceanBaseDB) applyOracleChangesMySQLWire(tableName string, changes connection.ChangeSet) error {
if o.oracle == nil || o.oracle.conn == nil {
return fmt.Errorf("连接未打开")

View File

@@ -300,20 +300,39 @@ func TestOceanBaseOracleDSNParsesTenantCredentials(t *testing.T) {
}
}
// buildMySQLHandshakePacket 构造一个最小化的 MySQL initial handshake packetprotocol v10
// buildMySQLHandshakePacket 构造一个完整的 MySQL initial handshake packetprotocol v10
// 用于 mock OceanBase / 通用 MySQL / OBProxy 各种 server_version 场景。
// 实际字段顺序按 MySQL 协议规范:
//
// 4 字节 header (3 字节 payload length + 1 字节 sequence id)
// payload[0] protocol_version (10)
// payload[1..N] server_version (null-terminated)
// ... (后续字段对协议探测无关,可省略)
// ... auth seed / capability / auth plugin
func buildMySQLHandshakePacket(serverVersion string) []byte {
payload := []byte{10}
payload = append(payload, []byte(serverVersion)...)
payload = append(payload, 0)
// 追加几个占位字节,让 packet 看起来更像真实 handshake探测代码并不解析这些字段
// connection id
payload = append(payload, []byte{0x01, 0x00, 0x00, 0x00}...)
// auth-plugin-data-part-1 + filler
payload = append(payload, []byte("12345678")...)
payload = append(payload, 0)
// capability lower 2 bytes: CLIENT_LONG_PASSWORD | CLIENT_LONG_FLAG |
// CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION
payload = append(payload, 0x05, 0x82)
// character set + status flags
payload = append(payload, 45, 0x00, 0x00)
// capability upper 2 bytes: CLIENT_MULTI_RESULTS | CLIENT_PLUGIN_AUTH |
// CLIENT_CONNECT_ATTRS | CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | CLIENT_SESSION_TRACK
payload = append(payload, 0x1a, 0x00)
// auth-plugin-data length + reserved
payload = append(payload, 21)
payload = append(payload, make([]byte, 10)...)
// auth-plugin-data-part-2 minimum 13 bytes, last byte NUL
payload = append(payload, []byte("abcdefghijkl")...)
payload = append(payload, 0)
payload = append(payload, []byte("mysql_native_password")...)
payload = append(payload, 0)
payloadLen := len(payload)
header := []byte{byte(payloadLen), byte(payloadLen >> 8), byte(payloadLen >> 16), 0}
return append(header, payload...)
@@ -407,6 +426,37 @@ func TestProbeOceanBaseMySQLWireHandshakeReturnsFalseOnUnreachable(t *testing.T)
}
}
func TestOceanBaseOracleConnectTriesOBClientWhenMySQLHandshakeIsGeneric(t *testing.T) {
t.Parallel()
host, port, cleanup := startMockHandshakeServer(t, buildMySQLHandshakePacket("8.0.36"))
defer cleanup()
ob := &OceanBaseDB{}
err := ob.Connect(connection.ConnectionConfig{
Type: "oceanbase",
Host: host,
Port: port,
User: testOceanBaseOracleUser,
Password: testOceanBaseOraclePassword,
OceanBaseProtocol: oceanBaseProtocolOracle,
Timeout: 1,
})
if err == nil {
t.Fatal("expected connect error from mock generic MySQL handshake server")
}
got := err.Error()
if !strings.Contains(got, "OBClient/MySQL-wire 路径连接失败") {
t.Fatalf("expected generic MySQL handshake to try OBClient first, got %q", got)
}
if !strings.Contains(got, "已跳过 TNS 路径") {
t.Fatalf("expected empty Service Name to skip TNS fallback, got %q", got)
}
if strings.Contains(got, "需要填写服务名") {
t.Fatalf("expected empty Service Name not to surface TNS required error for MySQL-wire fallback, got %q", got)
}
}
func TestOceanBaseOracleConnectStopsOnProbeDialFailure(t *testing.T) {
t.Parallel()
@@ -648,85 +698,54 @@ func TestProbeOceanBaseMySQLWireHandshakeAcceptsLargerPayload(t *testing.T) {
}
}
// decodeConnectionAttributesFromConnectionParams 把 connectionAttributes 从 url-encoded
// ConnectionParams 中取出来并解析成 map便于测试用解码后的值断言
func decodeConnectionAttributesFromConnectionParams(t *testing.T, raw string) map[string]string {
t.Helper()
values, err := url.ParseQuery(raw)
if err != nil {
t.Fatalf("ParseQuery(%q) failed: %v", raw, err)
}
return parseMySQLConnectionAttributes(values.Get("connectionAttributes"))
}
// ensureOceanBaseOBClientAttributes 必须默认注入 OBClient capability 候选 attribute
// 并且用户在 ConnectionParams 里已设置的 attribute 优先级更高(不被默认值覆盖)。
func TestEnsureOceanBaseOBClientAttributesInjectsDefaults(t *testing.T) {
t.Parallel()
attrs := decodeConnectionAttributesFromConnectionParams(t, ensureOceanBaseOBClientAttributes(""))
want := map[string]string{
"_client_name": "OceanBase Connector/J",
"_client_version": "2.4.5",
"__ob_client_attribute_capability_flag": "1",
"ob_capability_flag": "1",
}
for k, v := range want {
if attrs[k] != v {
t.Fatalf("expected default attribute %s=%q, got %q (all=%v)", k, v, attrs[k], attrs)
}
}
}
func TestEnsureOceanBaseOBClientAttributesPreservesUserOverrides(t *testing.T) {
t.Parallel()
attrs := decodeConnectionAttributesFromConnectionParams(t,
ensureOceanBaseOBClientAttributes("connectionAttributes=_client_name:libobclient,_pid:9527"))
if attrs["_client_name"] != "libobclient" {
t.Fatalf("expected user-supplied _client_name preserved, got %q", attrs["_client_name"])
}
if attrs["_pid"] != "9527" {
t.Fatalf("expected user extra attribute _pid preserved, got %q", attrs["_pid"])
}
// 仍应补齐默认值中用户未提供的部分
if attrs["ob_capability_flag"] != "1" {
t.Fatalf("expected default ob_capability_flag still injected when user did not set it, got %q (all=%v)", attrs["ob_capability_flag"], attrs)
}
}
// 锁定 Oracle 协议路径下OBClient capability attribute 会被注入到生成的 mysql DSN 中。
func TestOceanBaseOracleOBClientDSNCarriesCapabilityAttributes(t *testing.T) {
// 锁定 Oracle 协议 MySQL-wire 路径使用 oboracle 专用 DSN并把用户提供
// connectionAttributes 映射为 obconnector-go 支持的 attr.* 参数
func TestOceanBaseOracleOBClientDSNUsesDedicatedDriverParams(t *testing.T) {
t.Parallel()
cfg := connection.ConnectionConfig{
Type: "oceanbase",
Host: "127.0.0.1",
Port: 2881,
User: "SYS@oracle_tenant#cluster",
Password: "x",
Database: "ORCL",
Type: "oceanbase",
Host: "127.0.0.1",
Port: 2881,
User: "SYS@oracle_tenant#cluster",
Password: "p@ss",
Database: "ORCL",
Timeout: 12,
ConnectionParams: "connectionAttributes=_client_name:Custom OB,_pid:9527&cap.add=0x80&init=alter+session+set+nls_date_format%3D%27YYYY-MM-DD%27",
}
cfg.ConnectionParams = ensureOceanBaseOBClientAttributes(cfg.ConnectionParams)
cfg.ConnectionParams = ensureOceanBaseOracleANSIQuotes(cfg.ConnectionParams)
ob := &OceanBaseDB{}
dsn, err := ob.getDSN(cfg)
dsn, err := buildOceanBaseOracleOBClientDSN(cfg)
if err != nil {
t.Fatalf("getDSN error: %v", err)
t.Fatalf("buildOceanBaseOracleOBClientDSN error: %v", err)
}
parsed, err := mysqlDriver.ParseDSN(dsn)
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatalf("ParseDSN error: %v", err)
t.Fatalf("Parse DSN error: %v", err)
}
if !strings.Contains(parsed.ConnectionAttributes, "_client_name:OceanBase Connector/J") {
t.Fatalf("expected default _client_name in DSN, got %q", parsed.ConnectionAttributes)
if parsed.Scheme != "oboracle" {
t.Fatalf("expected oboracle scheme, got %q (dsn=%s)", parsed.Scheme, dsn)
}
if !strings.Contains(parsed.ConnectionAttributes, "ob_capability_flag:1") {
t.Fatalf("expected default ob_capability_flag in DSN, got %q", parsed.ConnectionAttributes)
if parsed.User.Username() != "SYS@oracle_tenant#cluster" {
t.Fatalf("unexpected user %q", parsed.User.Username())
}
if !strings.Contains(dsn, "sql_mode=%27ANSI_QUOTES%27") {
t.Fatalf("expected ANSI_QUOTES sys var in DSN, got %q", dsn)
password, _ := parsed.User.Password()
if password != "p@ss" {
t.Fatalf("unexpected password %q", password)
}
query := parsed.Query()
if query.Get("preset") != "oboracle" {
t.Fatalf("expected preset=oboracle, got query=%v", query)
}
if query.Get("timeout") != "12s" {
t.Fatalf("expected timeout=12s, got query=%v", query)
}
if query.Get("attr._client_name") != "Custom OB" || query.Get("attr._pid") != "9527" {
t.Fatalf("expected connectionAttributes mapped to attr.*, got query=%v", query)
}
if query.Get("cap.add") != "0x80" {
t.Fatalf("expected cap.add passthrough, got query=%v", query)
}
if got := query["init"]; len(got) != 1 || got[0] != "alter session set nls_date_format='YYYY-MM-DD'" {
t.Fatalf("expected init SQL passthrough, got query=%v", query)
}
}
@@ -807,7 +826,10 @@ func TestFormatOceanBaseMySQLAttemptErrorHintsOracleProtocol(t *testing.T) {
if !strings.Contains(got, "切换为 Oracle") {
t.Fatalf("expected Oracle protocol hint, got %q", got)
}
if !strings.Contains(got, "OBProxy") {
t.Fatalf("expected hint to mention OBProxy Oracle protocol port, got %q", got)
if !strings.Contains(got, "主机和端口可保持不变") {
t.Fatalf("expected hint to mention OBClient/MySQL-wire host and port can be kept, got %q", got)
}
if !strings.Contains(got, "只有连接 OBProxy Oracle listener/TNS 入口时才需要填写服务名") {
t.Fatalf("expected hint to limit Service Name requirement to TNS path, got %q", got)
}
}

View File

@@ -0,0 +1,43 @@
//go:build oceanbase_live
package db
import (
"os"
"strconv"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestOceanBaseOracleLive(t *testing.T) {
port, err := strconv.Atoi(os.Getenv("GONAVI_OB_PORT"))
if err != nil {
t.Fatalf("invalid GONAVI_OB_PORT: %v", err)
}
ob := &OceanBaseDB{}
cfg := connection.ConnectionConfig{
Type: "oceanbase",
Host: os.Getenv("GONAVI_OB_HOST"),
Port: port,
User: os.Getenv("GONAVI_OB_USER"),
Password: os.Getenv("GONAVI_OB_PASSWORD"),
Database: os.Getenv("GONAVI_OB_DATABASE"),
ConnectionParams: os.Getenv("GONAVI_OB_PARAMS"),
OceanBaseProtocol: "oracle",
Timeout: 10,
}
if err := ob.Connect(cfg); err != nil {
t.Fatalf("connect failed: %v", err)
}
defer ob.Close()
rows, fields, err := ob.Query("select 1 from dual")
if err != nil {
t.Fatalf("query failed: %v", err)
}
if len(fields) != 1 || len(rows) != 1 {
t.Fatalf("unexpected result fields=%v rows=%v", fields, rows)
}
}